7编程语言实现模式(第2版)

子程序

可复用的代码块

函数过程参数传递闭包

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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)高阶函数,自动缓存函数的计算结果以避免重复计算。

7/9子程序
0%