CPU、汇编与控制流#
计算机发展早期,编程者通过汇编编写程序(更早的硬件控制、打孔就不提了)。一个带有循环能力的汇编:
START:
MOV AX, 1 ; 初始化变量
LOOP_BODY:
ADD AX, 1 ; AX 自增
CMP AX, 10 ; 比较 AX 与 10
JL LOOP_BODY ; 若 AX < 10,则跳转回 LOOP_BODY 执行
整段代码就是一系列按顺序排列的指令,在底层,CPU 通过 PC 记录当前执行位置,循环从内存读取指令,更新 PC,顺序执行指令,并在遇到 JUMP 语句时进行跳转。我们可以将 CPU 执行指令的路径称为控制流。
一些编程语言开始对汇编进行封装。比如 FORTRAN。
控制权#
在任意时刻,PC 指向哪条指令,那条指令所在的代码就 "持有控制权"。通俗地说,控制权就是 "现在轮到谁来决定程序往下做什么"。
考虑一个函数调用:
void caller() {
a(); // 1
b(); // 2
c(); // 3
}
执行到 a() 时,PC 跳入 a 的代码,控制权从 caller 转移到 a。在 a 执行期间,是 a 内部的指令在驱动程序前进,caller 什么都干不了,只能等。a 返回之后,PC 跳回 caller 中 b() 对应的位置,控制权归还给 caller。
"控制权转移" 这件事在底层就是 PC 的跳转,在语义层面上就是 "当前是哪段代码在决定程序的行为"。
- 顺序执行,控制权逐行向下流动。
- 函数调用,控制权从调用者转移给被调用者,返回后再归还。
- 条件分支,根据条件将控制权导向不同的代码路径。
控制权的主体#
在单线程程序中,即为当前执行上下文 —— 也就是那一条执行链路。PC 在哪,控制权就在哪。控制权沿着 PC 的轨迹,在不同代码段之间流动。这个时候不需要纠结 "主体" 的问题,因为只有一个执行者。
在多线程场景下,每个线程有自己的 PC,有自己的调用栈。它们各自独立地持有控制权,各自做着自己的控制流决策。这个时候 "控制权" 就不再是全局唯一的了,每个线程拥有属于自己的那一份。线程间的同步机制所做的事情,是协调不同线程的控制权何时可以进入某段共享代码。协程也是类似的。每个协程有自己的执行状态,yield 和 resume 就是在多个协程之间显式地传递控制权。和线程不同的是,协程的控制权切换是主动让出的,而线程是被 OS 调度器强制切走的。
GOTO#
明确了以上概念之后,就可以来聊结构化编程了。在结构化编程出现之前,高级语言中的控制流组织靠的是 GOTO。
GO TO 是 JMP 指令的高级语言版本。它的语义极其简单:把 PC 设置为目标标签的地址。
C FORTRAN 求 1~10 的和
I = 1
SUM = 0
10 IF (I .GT. 10) GO TO 30
SUM = SUM + I
I = I + 1
GO TO 10
30 WRITE(*,*) SUM
这段代码要实现的就是一个求和循环。但在代码中没有明确的 "循环" 结构 —— 有的只是 "跳到第 10 行" 和 "跳到第 30 行"。控制流的形状完全依赖于这些跳转指令的目标地址,阅读者需要人肉模拟的才能知道这是个循环结构。
当一个程序有几十上百个 GO TO,跳转目标交叉嵌套,控制流就变成网状结构,几乎不可能从任何一个局部去推断程序的行为。
在这种随意 JUMP 的环境下,程序可能任何时候跳转至任何指令位置,在高复杂度下,一段代码可能出现多个入口、多个出口。这时,要人肉看出代码执行路径非常困难。
GO TO 本身很简洁好用,但问题在于这种能力是无约束的。一个简单好用的工具如果不做约束,就容易被过度使用,乃至到滥用的地步。
于是就有了 Dijkstra 著名的 Goto Statement Considered Harmful。
他主张应该从高级语言中移除 GO TO。因为 GO TO 使得程序的静态文本结构和运行时行为之间失去了对应关系。
结构化编程#
但是,一份倡议很难改变什么,编程者所面临的问题不会凭空消失。
于是在 Goto Statement Considered Harmful 发表一年多后,Dijkstra 又发表了 Notes on Structured Programming,表达了其理想的编程范式,提出结构化编程的概念。
他提出需要有意识进行抽象复用,将复杂任务进行分解,分解为程序块。
通过有限的、可嵌套的控制结构(顺序、分支、循环、函数调用)来组合不同的块,并确保每个结构有且只有一个入口和一个出口。从而保证控制流具有单一入口和单一出口。实现代码逻辑的抽象与封装。
同时保证了代码文本结构与实际运行控制流的对齐。
func process(items []Item) {
for _, item := range items { // 控制流进入循环
if item.IsValid() { // 进入条件分支
result := transform(item) // 进入函数调用,返回后回到这里
save(result) // 进入函数调用,返回后回到这里
} // 退出条件分支
} // 退出循环
}
比如如上的代码,每一层缩进对应着控制流的一次嵌套。代码从上到下阅读的顺序,就是控制流的流动方向。
受控的逃逸#
但是,严格的 "单入口 - 单出口" 在实践中有时候太死板了。
一个常见的场景:在深层嵌套中找到了想要的结果,需要立刻终止整个过程。如果严格遵守单出口,就得引入一堆标志变量,逐层传递退出信号:
found := false
var result int
for i := 0; i < n && !found; i++ {
for j := 0; j < m && !found; j++ {
if matrix[i][j] == target {
result = matrix[i][j]
found = true
}
}
}
对比 return:
func find(matrix [][]int, target int) int {
for _, row := range matrix {
for _, v := range row {
if v == target {
return v
}
}
}
return -1
}
后者清晰得多。return 把控制权直接交回调用者,跳过了所有剩余的迭代。
类似的 break、continue、return、throw,这些都是对严格结构化的松动。但相比于 GO TO:这类语句 跳转的目标受到结构性的约束。break 只能跳到所在循环的出口,return 只能跳到调用者,throw 只能沿着调用栈向上找 catch。
隐式的控制流#
结构化编程处理的主要是显式的控制流 ——if、for、return—— 程序员在代码中直接书写控制权转移的规则。
这在命令式编程中很有效。但在现代编程中,命令式编程已经显得不再那么有吸引力了。
有大量控制流是被埋在底层的。
比如事件驱动,在一个 GUI 或 Web 服务中,编码者可能注册一组 callback,然后把控制权交给事件循环。事件循环根据运行时收到的事件来决定调用哪个回调。控制流的顺序取决于事件的到达顺序,而不取决于代码的书写顺序。
响应式编程也是如此。A 变了,依赖 A 的 B 自动重算,依赖 B 的 C 跟着更新。控制权沿着数据依赖图自动蔓延,程序员没有显式地编写这个流转过程。
而声明式编程则更进一步,比如 SQL,SELECT * FROM users WHERE age > 18,这里面完全不涉及到控制流,与实际的查询执行是完全背离的。索引选择、扫描策略、过滤顺序 —— 这些控制流决策全部由数据库引擎内部做出。