测试与代码质量

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

意大利面代码指的不是长度或脏乱,而是控制流。本文从迪杰斯特拉 1968 年反对 goto 的信件讲起,总结 goto 消失后意大利面依然存在的原因,以及如何用圈复杂度衡量它。

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

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

在预定为下一篇文章的 代码异味 #2中,我们继续了解下一个包。

因为太长?因为太乱?

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

当你试图用眼睛跟踪执行顺序,却发现流程像面条一样纠缠,无法判断会跳到哪里,这才是它原本的含义。

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

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

迪杰斯特拉写的一封信

1968 年 3 月,埃兹赫尔·迪杰斯特拉在 ACM(Association for Computing Machinery,美国计算机协会)期刊上发表了一篇短文(原文)。

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

迪杰斯特拉最初拟定的标题是《A Case Against the Go To Statement》。如今这个挑衅性的标题,是当时的编辑尼克劳斯·维尔特改的。

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

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

但在包含大量 goto 的代码中,仅知道“当前正在执行这一行”无法解释程序处于什么状态,因为不知道它从哪里跳转而来。

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

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

goto会摧毁这个坐标系。

这一论点也有理论依据。早两年的 1966 年,科拉多·贝姆和朱塞佩·雅各比尼证明,任何程序都可以只用顺序、选择和循环来表达(论文)。

也就是说,即使没有 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 行后会怎样,并不难想象。

1970 至 1980 年代的 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 年托马斯·麦凯布提出了圈复杂度(Cyclomatic Complexity)这一指标(论文)。

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

实际工作中,通常用分支点数量加 1 进行近似。可以大致认为每个 if、for、while、case、&&和??增加 1。

一种常见约定是,数值超过 10 就该拆分函数了。麦凯布本人也把这个数字定义为合理的上限,而不是绝对标准。

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

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

不过 SwiftLint 只统计 if、guard、for、while、repeat、case和catch,不统计 &&或??。默认警告阈值是 10。

在项目中启用它,悄悄增长的函数总有一天会被发现。

克努特的反驳

如果把这个故事总结为“迪杰斯特拉赢了”,就只了解了一半。

1974 年,唐纳德·克努特在一篇题为《Structured Programming with go to Statements》的长篇论文中提出了反驳(论文)。

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

为了强行移除它而创建布尔标志变量,再叠加条件语句,反而会产生更糟糕的代码。

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

break、continue、带标签的 break label,以及 Swift 的 defer和guard都是例子。

// 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 年的信,核心论据是“应该能够用坐标描述执行位置”。
  • goto消失了,但深层嵌套、回调嵌套、全局状态和事件链会重新制造同样的问题。
  • 可以用圈复杂度进行粗略衡量,10 左右是常见的警告线。
  • 正如克努特反驳的那样,目标不是消灭goto,而是让流程具有可预测性。guard和async/await是由语言代替我们守护这一目标的机制。

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

这就是千层面代码。

延伸阅读

来源与验证