测试与代码质量

[代码异味 #1] 意大利面代码为什么像意大利面

意大利面代码指的不是长度或凌乱,而是控制流。本文从 1968 年 Dijkstra 反对 goto 的信件讲起,整理 goto 消失后意大利面仍然存在的原因,以及如何用圈复杂度衡量它。

6 分钟阅读
[代码异味 #1] 意大利面代码为什么像意大利面 封面图

代码一团糟时,开发者会说它像意大利面。但究竟什么才是意大利面?

在接下来的代码异味 #2中,我们继续了解下一个包。

因为很长?因为很乱?

都不是。意大利面指的是控制流

它原本指执行顺序像面条一样纠缠,想用眼睛跟踪时,根本不知道下一步会跳到哪里。

将控制流和职责纠缠在同一个文件中的代码拆分过程,请继续阅读将关注点分离应用于 800 行组件的案例

追溯这个说法的根源,会找到一封发表于 1968 年的信。

Dijkstra 写的一封信

1968 年 3 月,Edsger Dijkstra 在 ACM(Association for Computing Machinery,美国计算机学会)期刊上发表了一篇短文(原文)。

标题是《Go To Statement Considered Harmful》。正文是一封两页的信。

Dijkstra 原本给出的标题是《A Case Against the Go To Statement》。现在这个挑衅性的标题,是当时的编辑 Niklaus Wirth 改的。

这种标题格式后来固定成“○○ Considered Harmful”这一惯用语,可见编辑的影响延伸得相当远。

核心观点是:人们阅读静态代码,并在脑中构建动态执行过程。

但在goto很多的代码中,仅知道“现在正在执行这一行”并不能说明程序处于什么状态,因为不知道它是从哪里来的。

只使用顺序执行、条件分支和循环的代码则不同。

可以像描述坐标一样描述执行位置,例如:“外层循环第三次迭代,内层 if 的 true 分支”。

goto会破坏这个坐标系。

这个观点也有理论依据。两年前的 1966 年,Corrado Böhm 和 Giuseppe Jacopini 证明,任何程序都可以只用顺序、选择和循环来表达(论文)。

数学由此保证了即使没有goto也能实现。

goto 画出的图

看看当时的代码是什么样,就能体会这一点。

10 IF X > 100 THEN GOTO 70
20 IF X < 0 THEN GOTO 90
30 Y = X * 2
40 IF Y > 50 THEN GOTO 70
50 PRINT Y
60 GOTO 100
70 PRINT "TOO BIG"
80 GOTO 100
90 PRINT "NEGATIVE"
100 END

即使只有十行,也需要把流程画在纸上才能理解。每个GOTO是一条线,而这些线会上下交叉。

变成 500 行后会怎样,不难想象。

20 世纪 70~80 年代的 BASIC 和早期 Fortran 代码确实如此,而看到那种团块后出现的说法就是意大利面。

GOTO 跳转纠缠的流程图与只使用顺序和分支的结构化流程图对比
左侧可以用坐标描述执行位置,右侧则不能

goto 消失了,但意大利面还在

奇怪的事情就在这里发生了。现代代码中几乎没有goto

Swift 根本没有它。在拥有它的语言中,用途也基本缩小为返回资源清理位置的惯用写法。

然而,“意大利面代码”这个说法反而使用得更频繁。

因为goto只是原因之一,而不是症状本身。

真正的问题是读者无法预测流程,而现代代码还有很多其他方式会造成这个问题。

**嵌套地狱。**if 里面套 if,里面再套闭包,里面又是 if。

由于缩进像箭头一样不断加深,所以称为末日金字塔。它不像goto那样跳来跳去,但同样难以追踪在不同条件组合下会到达哪一行。

**回调地狱。**用回调串联异步任务,会让执行顺序与代码顺序错位。

当从上到下阅读不再等于执行顺序时,坐标系又一次崩溃了。

// 无法按照代码顺序读出执行顺序
loadUser(id) { user in
    loadProfile(user) { profile in
        loadPosts(profile) { posts in
            DispatchQueue.main.async {
                self.render(posts)   // 错误处理应该位于哪一层?
            }
        }
    }
}

**全局状态。**如果存在任何函数都能随时修改的变量,就必须翻遍整个项目,追踪它的值为什么会变成这样。

这也是单例经常受到批评的原因。

**事件汤。**A 发出通知,B 接收后修改状态,而观察该状态的 C 又发出通知。

每个片段都短小整洁,但整体流程没有记录在任何地方。

这是响应式代码中常见的形式,在某些方面甚至比goto更难追踪,因为跳转目的地没有写在代码里。

展示圈复杂度独立路径的控制流图与阈值仪表盘图片
复杂度数字不是答案,而是提示你该看哪里的信号

可以用数字衡量吗

“这段代码像意大利面”是主观说法。因此,1976 年 Thomas McCabe 提出了圈复杂度(Cyclomatic Complexity)指标(论文)。

计算很简单:把代码的控制流画成图,然后数有多少条独立路径。

实践中通常用分支点数量加 1 来近似。可以大致认为每个 if、for、while、case、&&??增加 1。

通常认为数值超过 10 就该拆分函数了。McCabe 本人也将这个数字定义为合理上限,而不是绝对标准。

实际上,用一个switch列出 20 种情况的函数,复杂度会超过 20,但仍然容易阅读。

SwiftLint 的cyclomatic_complexity规则会测量类似的数值。

不过 SwiftLint 只计算ifguardforwhilerepeatcasecatch,不计算&&??。默认警告阈值是 10。

在项目中启用后,那个悄悄变大的函数总有一天会被发现。

Knuth 的反驳

如果把这个故事总结成“Dijkstra 赢了”,就只了解了一半。

1974 年,Donald Knuth 发表了一篇名为《Structured Programming with go to Statements》的长篇论文进行反驳(论文)。

核心是goto本身并不是邪恶的。在一次跳出嵌套循环等场景中,使用goto反而能让流程更清晰。

如果为了强行删除它而创建布尔标志变量,再叠加条件语句,结果会变成更糟的代码。

现代语言得出的结论接近折中方案:去除无限制跳转,同时为常见模式提供专用语法。

breakcontinue、带标签的break label,以及 Swift 的deferguard都是例子。

// guard: 从上方移除异常情况,让正文保持扁平
func process(_ data: Data?) throws -> Packet {
    guard let data else { throw ParseError.empty }
    guard data.count > headerSize else { throw ParseError.tooShort }
    // 从这里开始,所有条件都已整理完毕
    return try decode(data)
}

guard类似于 C 时代的goto cleanup模式演变为语言功能。也就是固定跳转目的地,并为它命名。

总结

  • 意大利面代码不是凌乱的代码,而是无法预测控制流的代码
  • 起点是 1968 年 Dijkstra 的信,核心论据是“必须能够用坐标描述执行位置”。
  • goto消失了,但深层嵌套、回调嵌套、全局状态和事件链会重新造成同样的问题。
  • 可以用圈复杂度进行粗略衡量,10 左右是常见的警告线。
  • 正如 Knuth 所反驳的,目标不是消灭goto,而是让流程可预测。guardasync/await是由语言代为守护这一目标的机制。

下一篇将介绍反方向损坏的代码。流程非常规整,但只要新增一个值,就必须修改七个文件。

这就是千层面代码。

延伸阅读

来源与验证