40.4 代码可读性与一致性

一句话

可读性不是“写得漂亮”,而是同一概念全仓库只有一种写法、一个函数只装一件事、嵌套不超过人脑能同时装下的层数;这三件事都能测、能设阈值、能进 CI 门禁。

四个误区,先拆掉

误区 现实
注释多 = 可读 用注释解释 “这个函数在干什么”,等于承认命名失败。注释该解释为什么,不是是什么
代码越短越好 一行链式调用塞 5 个 lambda,字符数少了、认知步骤多了。删的是重复,不是步骤
风格靠 review 口头约定 口头约定在第三个人加入时失效。一致性只能靠机器强制,人只负责判断“该不该这样拆”
复杂度高 = 水平高 圈复杂度 25 的函数不是炫技,是没拆开。读它的人要在脑子里同时维护 25 条路径

四个维度,四组阈值

可读性拆成四件事,每件都有可测的量与经验阈值:

维度 度量 阈值 超了说明什么 怎么改
命名 同一概念的写法数、命名一致率 一个概念 1 种写法;一致率 100 同一概念 3 种写法 = 每次读都要重新映射 建术语表,lint 拦新增变体
长度 函数行数、参数个数 ≤ 40 行、≤ 4 个参数 参数 5 个以上,调用方要查 5 次签名 引入参数对象,或拆函数
复杂度 圈复杂度(判定点 +1) ≤ 10 10 条路径,测试要覆盖 10 个分支 提前 return、策略表、拆子函数
嵌套 最大嵌套层数 ≤ 3 人一次只能稳拿 4±1 个 chunk(Cowan 2001 的工作记忆容量),第 4 层开始靠猜 卫语句、合并条件、抽函数

两个数字的出处要分清:40 行 / 10 复杂度是工程经验阈值(McCabe 1976 提出圈复杂度,10 是长期沿用的经验线),不是定律;4±1 来自认知心理学实验。前者可以被团队调,后者不该被无视。

可运行的度量:AST 扫描 + 命名一致率

一段故意写坏的样本(同一概念三种命名、5 个参数、4 层嵌套):

package order

import "errors"

type Item struct {
	Price int64
	Qty   int
}

type Order struct {
	UserID string
	Amount int64
}

func ValidateAndPrice(userId string, items []Item, coupon string, vip bool, region string) (int64, error) {
	total := int64(0)
	for _, it := range items {
		if it.Price > 0 {
			if it.Qty > 0 {
				if coupon != "" {
					if vip {
						total += it.Price * int64(it.Qty) * 9 / 10
					} else {
						total += it.Price * int64(it.Qty)
					}
				} else {
					total += it.Price * int64(it.Qty)
				}
			}
		}
	}
	if region == "" || userId == "" {
		return 0, errors.New("bad params")
	}
	return total, nil
}

func uid(o Order) string {
	return o.UserID
}

func Price(items []Item) int64 {
	var sum int64
	for _, it := range items {
		sum += it.Price * int64(it.Qty)
	}
	return sum
}

扫描器用 go/ast 数四件事——圈复杂度按“判定点 +1”算(if/for/range/case/&&/|| 各算一个),嵌套按控制块层级算:

package main

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"sort"
	"strings"
)

// 被检查的样本:故意写了四种不一致——同一概念三种命名、一个长函数、深嵌套、多参数。
const sample = `package order

import "errors"

type Item struct {
	Price int64
	Qty   int
}

type Order struct {
	UserID string
	Amount int64
}

func ValidateAndPrice(userId string, items []Item, coupon string, vip bool, region string) (int64, error) {
	total := int64(0)
	for _, it := range items {
		if it.Price > 0 {
			if it.Qty > 0 {
				if coupon != "" {
					if vip {
						total += it.Price * int64(it.Qty) * 9 / 10
					} else {
						total += it.Price * int64(it.Qty)
					}
				} else {
					total += it.Price * int64(it.Qty)
				}
			}
		}
	}
	if region == "" || userId == "" {
		return 0, errors.New("bad params")
	}
	return total, nil
}

func uid(o Order) string {
	return o.UserID
}

func Price(items []Item) int64 {
	var sum int64
	for _, it := range items {
		sum += it.Price * int64(it.Qty)
	}
	return sum
}
`

type fn struct {
	name   string
	lines  int
	params int
	cc     int
	depth  int
}

func main() {
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "sample.go", sample, 0)
	if err != nil {
		panic(err)
	}

	var fns []fn
	for _, d := range f.Decls {
		fd, ok := d.(*ast.FuncDecl)
		if !ok || fd.Body == nil {
			continue
		}
		start := fset.Position(fd.Pos()).Line
		end := fset.Position(fd.End()).Line
		fns = append(fns, fn{
			name:   fd.Name.Name,
			lines:  end - start + 1,
			params: fd.Type.Params.NumFields(),
			cc:     complexity(fd.Body),
			depth:  maxDepth(fd.Body.List, 0),
		})
	}

	fmt.Println("【一】函数级度量(阈值:行数 ≤40、参数 ≤4、圈复杂度 ≤10、嵌套 ≤3)")
	fmt.Printf("%-18s %6s %6s %6s %6s  %s\n", "函数", "行数", "参数", "复杂度", "嵌套", "判定")
	for _, x := range fns {
		var bad []string
		if x.lines > 40 {
			bad = append(bad, "过长")
		}
		if x.params > 4 {
			bad = append(bad, "参数多")
		}
		if x.cc > 10 {
			bad = append(bad, "复杂")
		}
		if x.depth > 3 {
			bad = append(bad, "嵌套深")
		}
		verdict := "OK"
		if len(bad) > 0 {
			verdict = strings.Join(bad, "/")
		}
		fmt.Printf("%-18s %6d %6d %6d %6d  %s\n", x.name, x.lines, x.params, x.cc, x.depth, verdict)
	}

	// 二、命名一致性:把同一概念的写法变体列成词表,统计出现次数
	groups := map[string][]string{
		"用户 ID": {"userid", "uid", "userno", "user_uuid"},
	}
	counts := map[string]int{}
	ids := map[string]bool{}
	ast.Inspect(f, func(n ast.Node) bool {
		if id, ok := n.(*ast.Ident); ok {
			norm := strings.ToLower(strings.ReplaceAll(id.Name, "_", ""))
			ids[id.Name] = true
			for _, variants := range groups {
				for _, v := range variants {
					if norm == v {
						counts[id.Name]++
					}
				}
			}
		}
		return true
	})

	fmt.Println("\n【二】命名一致性:同一概念(用户 ID)的写法")
	var names []string
	for k := range counts {
		names = append(names, k)
	}
	sort.Strings(names)
	total := 0
	for _, n := range names {
		total += counts[n]
		fmt.Printf("%-10s 出现 %d 次\n", n, counts[n])
	}
	fmt.Printf("共 %d 种写法 / %d 次出现 → 一致性得分 %.0f/100(单一写法 = 100)\n",
		len(names), total, 100.0/float64(len(names)))

	// 三、标识符风格:短名占比
	short := 0
	var lengths []int
	for id := range ids {
		lengths = append(lengths, len(id))
		if len(id) <= 2 {
			short++
		}
	}
	sort.Ints(lengths)
	mean := 0.0
	for _, l := range lengths {
		mean += float64(l)
	}
	mean /= float64(len(lengths))
	fmt.Println("\n【三】标识符风格(会影响扫描速度,不影响正确性)")
	fmt.Printf("不同标识符 %d 个,平均长度 %.1f 字符,≤2 字符占 %.1f%%(%d 个)\n",
		len(lengths), mean, float64(short)/float64(len(lengths))*100, short)
	fmt.Printf("中位数 %d 字符,最长 %d 字符\n", lengths[len(lengths)/2], lengths[len(lengths)-1])
}

func complexity(n ast.Node) int {
	cc := 1
	ast.Inspect(n, func(x ast.Node) bool {
		switch s := x.(type) {
		case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.CaseClause, *ast.CommClause:
			cc++
		case *ast.BinaryExpr:
			if s.Op == token.LAND || s.Op == token.LOR {
				cc++
			}
		}
		return true
	})
	return cc
}

func maxDepth(stmts []ast.Stmt, cur int) int {
	best := cur
	for _, s := range stmts {
		switch v := s.(type) {
		case *ast.IfStmt:
			best = max(best, maxDepth(v.Body.List, cur+1))
			if v.Else != nil {
				if e, ok := v.Else.(*ast.BlockStmt); ok {
					best = max(best, maxDepth(e.List, cur+1))
				} else if e2, ok := v.Else.(*ast.IfStmt); ok {
					best = max(best, maxDepth([]ast.Stmt{e2}, cur+1))
				}
			}
		case *ast.ForStmt:
			best = max(best, maxDepth(v.Body.List, cur+1))
		case *ast.RangeStmt:
			best = max(best, maxDepth(v.Body.List, cur+1))
		case *ast.SwitchStmt:
			best = max(best, maxDepth(v.Body.List, cur+1))
		case *ast.BlockStmt:
			best = max(best, maxDepth(v.List, cur+1))
		}
	}
	return best
}

本机运行结果(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):

【一】函数级度量(阈值:行数 ≤40、参数 ≤4、圈复杂度 ≤10、嵌套 ≤3)
函数                     行数     参数    复杂度     嵌套  判定
ValidateAndPrice       22      5      8      5  参数多/嵌套深
uid                     3      1      1      0  OK
Price                   7      1      2      1  OK

【二】命名一致性:同一概念(用户 ID)的写法
UserID     出现 2 次
uid        出现 1 次
userId     出现 2 次
共 3 种写法 / 5 次出现 → 一致性得分 33/100(单一写法 = 100)

【三】标识符风格(会影响扫描速度,不影响正确性)
不同标识符 27 个,平均长度 4.7 字符,≤2 字符占 11.1%(3 个)
中位数 5 字符,最长 16 字符

逐条读这份输出:

  1. ValidateAndPrice 只有 22 行,远低于 40 行阈值,却同时踩了两个红线:5 个参数、5 层嵌套。这说明行数是最弱的那个度量——把代码压成一行只会让它更糟,而嵌套和参数不会因为行数少就变好。
  2. 圈复杂度 8 没超 10,嵌套 5 却超了。两者测的不是一件事:CC 数的是“有几条路径要测”,嵌套数的是“读者要在脑子里叠几层”。10 条扁平的分支比 4 层嵌套好读得多——优先拆嵌套,再拆分支。
  3. 一致性得分 33/100:UserID(字段)、userId(参数)、uid(函数名)同一个概念三种写法。这不是“小事”:每次跨文件读代码,大脑都要做一次映射;接手的第一个人会再发明第四种。
  4. uid 函数只有 3 行、CC=1,却仍然是负资产——它存在的唯一作用是给同一概念加了第三种叫法。可读性缺陷不只藏在“大函数”里,也藏在“小而多余的东西”里。

一致性靠机器锁,不靠记忆

阈值写成代码才有用。两个层次的落地:

① 先用本节扫描器当门禁(零依赖,能立刻跑):把阈值写进上面的 if,命中就 os.Exit(1),挂进 CI。它只拦“新增代码”,存量不动。

② 成熟方案是 golangci-lint(指名):内置 cyclop(圈复杂度)、gocognit(认知复杂度)、funlen(行长/语句数)、nestif(嵌套深度)、dupl(重复代码)、revive(命名与风格)这几个 linter。可抄的配置:

# .golangci.yml —— 只开与可读性直接相关的 linter,避免"全开 = 全忽略"
linters:
  disable-all: true
  enable:
    - cyclop
    - funlen
    - nestif
    - gocognit
    - dupl
    - revive

linters-settings:
  cyclop:
    max-complexity: 10
  funlen:
    lines: 40
    statements: 30
  nestif:
    min-complexity: 4
  gocognit:
    min-complexity: 15
  dupl:
    threshold: 100

⚠️ 未验证:本机 golangci-lint 为 v1.52.2(内嵌 go1.20),加载 Go 1.27 的工具链时报 package unsafe is not in std,上述配置没有在本机跑通。字段名与默认值以所用版本的官方文档为准;先用 --new-from-rev=HEAD~1 或 --issues-exit-code=0 只报不拦,确认噪音量后再切成阻断。

可退:门禁上线顺序是 只报不拦 → 拦新增(new-from-rev)→ 拦全量。第二步到第三步之间至少隔一个迭代,因为存量命中量通常在第一步才第一次被看见(常见是单仓几百到几千条)。

什么时候不必追求这几个数值

  • 生成的代码、测试表、main 入口:结构由模板或框架决定,拆不动也不必拆。
  • 性能热点的内联实现:为了省一次函数调用而拉长的函数,可读性让位于实测收益,但要写清“为什么这么写”。
  • 一次性迁移脚本:跑完即弃,阈值不适用。

反模式

反模式 后果
同一概念保留多种写法“向后兼容” 变体只会增加:第三个人会挑一个自己喜欢的,第四个人再发明一个
为了降低 CC 把函数拆成一堆两行函数 调用链变成跳转迷宫,读者要开 8 个文件才能看懂一次请求
lint 全开、每行都告警 团队学会无视告警,真正的问题也被无视
只在 review 里提风格问题 风格变成评审者的个人偏好,且每次结论可能不同
用注释解释代码在做什么 代码改了注释不改,注释变成误导;改名比改注释更该做
阈值一刀切,不区分领域 状态机、解析器的 CC 天然偏高,硬压只会换来更晦涩的写法

关联

自检

Q1 一个 22 行、圈复杂度 8 的函数被判“嵌套深、参数多”,而一个 60 行、CC=6、一层不嵌套的函数通过。这两个判定说明什么?哪个更该先改?

▸ 答案:说明四个度量各自独立,不能用一个阈值代表“可读”。22 行那个问题在“读者要同时记住 5 个入参 + 叠 4 层条件”,60 行那个问题在“一次看不完、要来回翻”。该先改的是 22 行那个:嵌套每多一层,读者的心智负担是指数的(4 层内层条件要同时成立才能读懂),而 60 行的扁平函数只是“长”,可以靠滚动和分段注释缓解。先拆嵌套,再拆长度。

Q2 团队把命名统一成 userID,但线上仍有 12 处 userId。用 lint 能拦住吗?拦不住的部分怎么办?

▸ 答案:lint 能拦新增(revive 的 var-naming 或自定义规则),拦不住三样东西:① 存量 12 处——需要一次性 gofmt -r 或重命名工具批量改,并接受一次“大 diff”;② 相同概念的新名字——今天统一了 userID,明天有人写 buyerId,词表没覆盖就漏;③ 跨仓库/跨语言的命名(数据库列 user_id、JSON 字段 userId)——这需要契约层定义,不是 lint 的职责。所以一致性是“词表 + lint + 契约”三件套,缺一件就有漏口。

Q3 为什么“为了降复杂度把函数拆成 10 个两行函数”是坏主意?判断该不该拆的判据是什么?

▸ 答案:因为可读性的目标不是“每个函数都简单”,而是“读一次请求的处理过程,需要打开的上下文最少”。拆成 10 个两行函数后,CC 都降到 1,但读者要在 10 个函数间跳转、还要记住每个函数的语义边界,认知成本反而上升。判据是:拆完之后,新函数是否有独立可命名的职责(能用一个动词短语说清、且能被独立测试)——能,就拆;不能,说明只是把代码切碎。

Q4 门禁上线后,团队把所有 funlen 命中都加了 //nolint:funlen。这说明门禁失败在哪一环?怎么改?

▸ 答案:失败在阈值与豁免没有 owner:nolint 变成了“消除告警”的快捷方式,而不是“记录例外”。三个改法:① nolint 必须带理由和 issue 号(//nolint:funlen // 状态机迁移表,见 #4123),CI 校验格式;② 按目录设不同阈值(状态机/解析器目录 80 行,业务目录 40 行),一刀切必然被绕过;③ 每月统计 nolint 数量与目录分布,数量上涨就说明阈值不合理或有人在刷。门禁的目的不是零告警,是让每一个例外都被看见。