函数调用:昂贵的累赘,还是被误解的利器?
基于盖伊·斯蒂尔 1977 年的远见卓识,我们重新审视这个编程界的古老神话。
论文: LAMBDA: THE ULTIMATE GOTO
神话的源头:为何我们“感觉”它很慢?
斯蒂尔指出,这种“昂贵”的印象并非空穴来风,而是特定历史时期的产物。
历史的包袱
早期的计算机和编译器设计,确实让函数调用代价不菲。
笨拙的开端
机器仅提供基础调用指令,如将返回地址存入特定寄存器或内存地址,不支持递归。
栈的萌芽
以 PDP-6 为代表,开始出现 PUSHJ 等基于栈的调用指令,但实现方式多样且不统一。
低效的约定
编译器为了简化,采用“被调用者保存所有寄存器”等策略,导致大量不必要的存取操作。
代价的量化
在当时的“优化”编译器下,一次简单的函数调用开销惊人。
“程序员的经理们明确禁止他们使用这类语句,因为子程序调用的开销太大了。” — Yourdon, 1975
OS/360 PL/I 编译器优化效果
通过手动优化,递归栈空间占用锐减97%!这证明了问题出在编译器,而非函数调用本身。
核心洞见:函数调用即 终极 GOTO
斯蒂尔揭示,一个位于函数末尾的调用(尾调用),无需返回,可以直接“跳转”。
传统调用:层层返回
每次调用都将返回地址压入栈中,函数执行完毕后再弹出,返回原处。
BAR:
...
PUSHJ F ; 调用 F, 将 BAR3 压栈
BAR3:
POPJ ; 从栈中弹出地址, 返回
每次递归都消耗栈空间,可能导致栈溢出。
尾调用优化 (TCO):直接跳转
当调用是最后一步时,编译器可将其优化为一个 `JUMP`,复用当前栈帧。
BAR:
...
JUMP F ; 直接跳转到 F, 栈不变
BAR3: ; (这行代码实际上被优化掉了)
POPJ
递归变成了迭代,空间复杂度为 O(1)。
铁证如山:实践中的惊人性能
事实胜于雄辩。恰当实现的 LISP 系统在性能上给了传统语言一记重击。
“...经过正确编译,LISP 在传达算法(甚至是数值算法)方面,可以和任何其他高级语言(如 FORTRAN)一样高效。检查机器码后发现... LISP 的子程序调用开销更低。” — R. Fateman, 1973, 回应 SIGSAM
MacLISP vs. FORTRAN 性能对比 (1973年)
解放思想:任何流程图皆可“结构化”
函数调用提供了一种强大的方式,能将任何复杂的控制流(甚至是“鼠窝式”的 GOTO)优雅地表达出来,而无需引入额外的状态变量。
一个清晰的有限状态机流程图
用纯函数调用优雅实现
每个节点对应一个函数。节点的“下一跳”就是简单地调用下一个函数。尾调用优化确保这不会导致栈溢出。
PROCEDURE Start();
BEGIN
CALL StateA();
END;
PROCEDURE StateA();
BEGIN
<... A 的处理逻辑 ...>
IF <cond1> THEN CALL StateB();
ELSE CALL StateC();
END;
PROCEDURE StateB();
BEGIN
<... B 的处理逻辑 ...>
CALL StateC();
END;
PROCEDURE StateC();
BEGIN
<... C 的处理逻辑 ...>
RETURN; ; 结束/到达 End
END;
✓ 无需全局状态变量(如 PROGRAM_COUNTER
)。
✓ 控制流清晰,逻辑内聚。
✓ 可通过参数传递数据,更安全。
✓ 形式上完全是“结构化编程”。
升华:构件 vs. 概念
斯蒂尔的终极论点:我们不应将编程“概念”与语言“构件”进行僵化的一一对应。
一个强大的语言构件,可以优雅地实现多种编程概念。
编程概念 → 语言构件 ↓ |
模块化 | 迭代/循环 | 条件分支 | 状态赋值 |
---|---|---|---|---|
函数调用 | ✓ | ✓ | ✓ | ✓ |
GOTO | ✓ | ✓ | ||
WHILE/FOR | ✓ |
真正的力量,源于理解构件的本质,并灵活地用它来表达思想,而不是被表面的“推荐用法”所束缚。
结论:敬畏其强大,而非其成本
盖伊·斯蒂尔的论文在近半个世纪后依然振聋发聩。它教导我们,不应因糟糕的工具而限制我们的思想和编程风格。相反,我们应当去打造更好的工具。
对于今天的开发者和语言设计者而言,这意味着:持续优化编译器,拥抱如尾调用等核心优化,并鼓励程序员去探索语言构件的全部潜力,创造出更优雅、更高效、更具表现力的代码。