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 字符
逐条读这份输出:
ValidateAndPrice只有 22 行,远低于 40 行阈值,却同时踩了两个红线:5 个参数、5 层嵌套。这说明行数是最弱的那个度量——把代码压成一行只会让它更糟,而嵌套和参数不会因为行数少就变好。- 圈复杂度 8 没超 10,嵌套 5 却超了。两者测的不是一件事:CC 数的是“有几条路径要测”,嵌套数的是“读者要在脑子里叠几层”。10 条扁平的分支比 4 层嵌套好读得多——优先拆嵌套,再拆分支。
- 一致性得分 33/100:
UserID(字段)、userId(参数)、uid(函数名)同一个概念三种写法。这不是“小事”:每次跨文件读代码,大脑都要做一次映射;接手的第一个人会再发明第四种。 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 天然偏高,硬压只会换来更晦涩的写法 |
关联
- 前置:40.1 可测试性(嵌套与依赖方向同时决定“能不能测”)、40.2 耦合与内聚度量(函数级度量之上的模块级版本:内聚、LCOM4)
- 并列:40.3 文档与知识传递(命名一致靠术语表,与文档的术语表同一份)
- 下游:40.5 变更成本(可读性差的代码,改一次的成本是它的 2–3 倍)
自检
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 数量与目录分布,数量上涨就说明阈值不合理或有人在刷。门禁的目的不是零告警,是让每一个例外都被看见。