第7章:子程序(可复用的代码块) Subprograms: Reusable Blocks of Code
一、导读
子程序是程序设计中最基本的抽象和复用单元。无论是简单的工具函数还是复杂的系统模块,子程序都扮演着将大问题分解为小问题、将重复逻辑抽象为可调用单元的关键角色。
子程序的概念可以追溯到 1940 年代,但现代子程序的设计包含了丰富的语义特性:参数传递方式、返回值机制、闭包、高阶函数、协程等。理解这些特性对于编写高质量的模块化代码至关重要。
学习目标:
理解函数与过程的区别和联系
掌握各种参数传递方式的语义和实现
理解闭包的原理和应用
掌握高阶函数和回调的设计模式
了解协程和生成器的概念
核心问题: 调用一个函数时究竟发生了什么?参数是如何传递的?闭包为什么能"记住"环境变量?
二、核心概念详解
2.1 函数与过程(Function and Procedure)
函数(Function) 是带有返回值的子程序,执行计算并返回结果。函数的核心语义是"输入到输出的映射",理想情况下应该是纯的(无副作用)。
过程(Procedure) 是执行特定操作但不返回值的子程序,主要用于产生副作用。在 Pascal 中,函数和过程有明确的语法区分。
现代语言的趋势是统一函数和过程的概念:
- C 中所有函数都有返回值,无返回值时使用
void - Python 中所有函数都返回值,没有显式 return 时返回
None - Rust 中返回
()(单元类型)表示无有意义的返回值
这种统一简化了语言设计,使类型系统更加一致。
2.2 参数传递方式(Parameter Passing)
参数传递方式决定了实参如何传递给形参,是子程序语义的核心问题:
值传递(Pass by Value):
将实参的值复制给形参。函数内部对形参的修改不影响实参。C、Java(基本类型)采用值传递。优点是安全,缺点是大对象复制的开销。
引用传递(Pass by Reference):
将实参的地址传递给形参。函数内部对形参的修改直接影响实参。C++ 的引用参数采用引用传递。优点是避免了复制开销,缺点是可能产生意外的副作用。
值结果传递(Pass by Value-Result / Copy-Restore):
调用时复制实参的值到形参,返回时再将形参的值复制回实参。这是值传递和引用传递的折中方案。
名称传递(Pass by Name):
将实参的文本替换到函数体中。Algol 60 采用了这种方式,但由于语义复杂且难以实现,现代语言很少使用。
共享对象传递(Pass by Sharing):
Python、Java(对象类型)的方式。传递的是对象引用的副本,函数内部可以修改对象的内容,但不能让实参指向新的对象。这实际上是"值传递的引用"。
2.3 返回值机制(Return Mechanism)
函数返回值的方式包括:
- 单返回值:大多数传统语言的方式,通过 return 语句返回一个值
- 多返回值:Python、Go、Rust 支持函数返回多个值,通常通过元组实现
- 命名返回值:Go 支持在函数签名中命名返回值,简化返回语句
- 尾调用优化:当函数的最后一步是调用另一个函数时,可以复用当前栈帧,避免栈溢出
2.4 闭包(Closure)
闭包是能够捕获并记住其定义环境中变量的函数。闭包由两部分组成:
- 函数代码:可执行的逻辑
- 环境引用:对定义时环境中自由变量的引用
// JavaScript
function makeAdder(x) {
return function(y) {
return x + y; // x 是自由变量,被闭包捕获
};
}
const add5 = makeAdder(5);
add5(3); // 返回 8
add5(10); // 返回 15闭包的实现需要堆上分配环境(而非栈上),因为闭包的生命周期可能超过其定义时的函数调用。
2.5 高阶函数(Higher-Order Functions)
高阶函数是以函数为参数或返回函数的函数。高阶函数是函数式编程的核心特性:
// map: 对集合中每个元素应用函数
[1, 2, 3].map(x => x * 2); // [2, 4, 6]
// filter: 筛选满足条件的元素
[1, 2, 3, 4].filter(x => x % 2 === 0); // [2, 4]
// reduce: 将集合归约为单个值
[1, 2, 3, 4].reduce((acc, x) => acc + x, 0); // 10高阶函数使得我们可以用声明式的方式表达数据变换,替代传统的循环和条件判断。
三、深入分析
3.1 调用约定与栈帧管理
函数调用的底层实现涉及调用约定(Calling Convention),它定义了参数如何传递、返回值如何传递、寄存器如何保存等细节。
栈帧(Stack Frame)结构:
每次函数调用都会在调用栈上创建一个新的栈帧,包含:
- 参数区域:存放传递给函数的参数
- 局部变量区域:存放函数内部的局部变量
- 返回地址:函数执行完毕后返回的位置
- 保存的寄存器:需要跨调用保存的寄存器值
常见调用约定:
- cdecl:C 语言默认约定,调用者清理栈,参数从右到左压栈
- stdcall:Windows API 使用,被调用者清理栈
- fastcall:部分参数通过寄存器传递,减少栈操作开销
3.2 尾调用优化(Tail Call Optimization, TCO)
当函数的最后一个操作是调用另一个函数时(尾调用),编译器可以复用当前的栈帧而非创建新的栈帧。这使得递归调用不会导致栈溢出。
// 尾递归的阶乘
function factorialTail(n, acc) {
if (n <= 1) return acc;
return factorialTail(n - 1, n * acc); // 尾调用
}
// 非尾递归的阶乘
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // 非尾调用(还需要乘法操作)
}Scheme 和 Erlang 等语言要求实现尾调用优化。JavaScript ES6 规范也要求支持,但实际浏览器支持有限。Rust 不保证尾调用优化,但提供了 loop 等替代方案。
3.3 柯里化与偏函数应用
柯里化(Currying) 是将接受多个参数的函数转换为一系列接受单个参数的函数的技术:
// 未柯里化
function add(a, b) { return a + b; }
// 柯里化
function curriedAdd(a) {
return function(b) {
return a + b;
};
}
curriedAdd(3)(5); // 8偏函数应用(Partial Application) 是固定函数的部分参数,生成接受剩余参数的新函数:
function add(a, b) { return a + b; }
const add3 = add.bind(null, 3); // 固定第一个参数为 3
add3(5); // 8柯里化和偏函数应用是函数组合和声明式编程的基础工具。
四、实践案例
案例1:闭包实现私有状态
利用闭包可以创建具有私有状态的"对象",无需使用类:
function createBankAccount(initialBalance) {
let balance = initialBalance; // 私有变量
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error("Insufficient funds");
balance -= amount;
return balance;
},
getBalance() {
return balance;
}
};
}
const account = createBankAccount(1000);
account.deposit(500); // 1500
account.withdraw(200); // 1300
// account.balance // 无法访问,真正的私有balance 变量被闭包捕获,对外部代码不可见,只能通过返回的方法来访问。这比使用类和下划线约定更可靠地实现了封装。
案例2:高阶函数实现管道处理
Unix 管道的思想可以用高阶函数在代码中复现:
// 管道函数:将数据依次通过一系列变换
function pipe(value, ...fns) {
return fns.reduce((acc, fn) => fn(acc), value);
}
// 数据处理管道
const result = pipe(
" Hello, World! ",
s => s.trim(),
s => s.toLowerCase(),
s => s.replace(/[!,]/g, ""),
s => s.split(" "),
words => words.filter(w => w.length > 3),
words => words.join("-")
);
// result: "hello-world"管道模式使数据处理逻辑清晰可读,每一步变换都是独立的纯函数,易于测试和复用。
五、常见误区
误区1:Java 的对象参数传递是引用传递
Java 的对象参数传递实际上是"共享对象传递"(Pass by Sharing)。传递的是引用的副本,而非引用本身。函数内部可以修改对象的内容(因为引用指向同一个对象),但不能让实参指向新的对象(因为引用本身是值传递的)。这与 C++ 的引用传递有本质区别。
误区2:闭包会复制环境变量的值
闭包捕获的是变量的引用,而非值的副本。如果闭包捕获的变量在外部被修改,闭包内部也会看到修改后的值:
let x = 10;
const closure = () => x;
x = 20;
closure(); // 返回 20,不是 10这一特性在循环中创建闭包时经常导致意外行为(经典的 for 循环中 var 变量问题)。
误区3:递归总是比循环慢
在没有尾调用优化的语言中,递归确实有额外的栈帧开销。但在有尾调用优化的语言(如 Scheme、Erlang)中,尾递归的性能与循环等价。此外,递归的表达力往往更强,对于树形结构和分治算法,递归是自然的表达方式。
误区4:高阶函数会降低代码可读性
适度使用高阶函数(map、filter、reduce)可以提高代码的声明性和可读性。但过度嵌套的高阶函数调用确实会降低可读性。最佳实践是在声明性和可读性之间找到平衡,必要时使用中间变量和管道操作符来分解复杂表达式。
误区5:所有函数都应该尽量保持纯函数
纯函数(无副作用)确实更易于推理、测试和并行化,但实际程序中副作用是不可避免的(I/O、状态修改、异常抛出)。最佳实践是将纯计算与副作用分离:核心逻辑尽量保持纯粹,副作用集中在边界处理。
六、本章小结
本章系统讲解了子程序的基本概念与实现机制。函数和过程作为代码复用的基本单元,通过参数传递和返回值实现了模块间的交互。参数传递方式包括值传递、引用传递和共享对象传递等,各有其安全性和性能特征。闭包机制扩展了函数的能力边界,使其能够携带状态并在后续调用中访问,是实现封装和高阶函数的基础。高阶函数(map、filter、reduce)提供了声明式的数据处理方式。尾调用优化使得递归调用不会导致栈溢出。柯里化和偏函数应用是函数组合的重要工具。理解这些子程序概念有助于我们设计清晰的接口、编写可复用的代码模块,并为深入学习函数式编程奠定基础。
七、思考题
比较值传递和引用传递的优缺点。在什么场景下应该使用引用传递?
解释闭包的工作原理。为什么闭包捕获的是引用而非值的副本?这种设计有什么利弊?
用高阶函数实现一个 compose 函数,将多个函数组合为一个函数。
什么是尾调用优化?为什么它对递归程序很重要?哪些语言支持尾调用优化?
实现一个"记忆化"(memoize)高阶函数,自动缓存函数的计算结果以避免重复计算。