控制流与结构化编程

2K
0
0
最后修改于

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 跳回 callerb() 对应的位置,控制权归还给 caller

"控制权转移" 这件事在底层就是 PC 的跳转,在语义层面上就是 "当前是哪段代码在决定程序的行为"。

  • 顺序执行,控制权逐行向下流动。
  • 函数调用,控制权从调用者转移给被调用者,返回后再归还。
  • 条件分支,根据条件将控制权导向不同的代码路径。

控制权的主体#

在单线程程序中,即为当前执行上下文 —— 也就是那一条执行链路。PC 在哪,控制权就在哪。控制权沿着 PC 的轨迹,在不同代码段之间流动。这个时候不需要纠结 "主体" 的问题,因为只有一个执行者。

在多线程场景下,每个线程有自己的 PC,有自己的调用栈。它们各自独立地持有控制权,各自做着自己的控制流决策。这个时候 "控制权" 就不再是全局唯一的了,每个线程拥有属于自己的那一份。线程间的同步机制所做的事情,是协调不同线程的控制权何时可以进入某段共享代码。协程也是类似的。每个协程有自己的执行状态,yieldresume 就是在多个协程之间显式地传递控制权。和线程不同的是,协程的控制权切换是主动让出的,而线程是被 OS 调度器强制切走的。

GOTO#

明确了以上概念之后,就可以来聊结构化编程了。在结构化编程出现之前,高级语言中的控制流组织靠的是 GOTO

GO TOJMP 指令的高级语言版本。它的语义极其简单:把 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 把控制权直接交回调用者,跳过了所有剩余的迭代。

类似的 breakcontinuereturnthrow,这些都是对严格结构化的松动。但相比于 GO TO:这类语句 跳转的目标受到结构性的约束break 只能跳到所在循环的出口,return 只能跳到调用者,throw 只能沿着调用栈向上找 catch

隐式的控制流#

结构化编程处理的主要是显式的控制流 ——ifforreturn—— 程序员在代码中直接书写控制权转移的规则。

这在命令式编程中很有效。但在现代编程中,命令式编程已经显得不再那么有吸引力了。

有大量控制流是被埋在底层的。

比如事件驱动,在一个 GUI 或 Web 服务中,编码者可能注册一组 callback,然后把控制权交给事件循环。事件循环根据运行时收到的事件来决定调用哪个回调。控制流的顺序取决于事件的到达顺序,而不取决于代码的书写顺序。

响应式编程也是如此。A 变了,依赖 AB 自动重算,依赖 BC 跟着更新。控制权沿着数据依赖图自动蔓延,程序员没有显式地编写这个流转过程。

而声明式编程则更进一步,比如 SQL,SELECT * FROM users WHERE age > 18,这里面完全不涉及到控制流,与实际的查询执行是完全背离的。索引选择、扫描策略、过滤顺序 —— 这些控制流决策全部由数据库引擎内部做出。