视频封面

视频元信息

  • 课程:MIT 6.001 Structure and Interpretation of Computer Programs(SICP)1986 传奇原版
  • 讲次:第 4 讲(P04)——高阶过程(Higher-Order Procedures)
  • 主讲:Hal Abelson
  • 字幕:中英双语
  • 时长:约 50 分钟(含课间休息)

本讲是 SICP 从「语言规则」迈向「编程惯用法」的关键转折点。核心思想是:过程(procedure)可以作为参数传入、也可以作为返回值产出,即「高阶过程」。这是全书后续所有抽象(如 map、filter、accumulate、数据导向编程)的基石。


一、开场与课程引入 [00:25]

视频开头约 6 分钟为课堂开场白,转录质量较差,内容大致是引入「抽象」与「优雅」的编程风格。

  • 讲师提到:我们要让程序「更优雅」,慢慢逼近某种理想状态。
  • 强调:编程的目标是让计算机为人服务,而不是人为计算机服务(computers to make people happy, not people to make computers happy)。
  • 本讲要介绍的新概念是——高阶过程。

理解:这段开场虽然转录混乱,但透露了 SICP 的核心价值观——抽象的目的是让程序「更容易写、更容易读」,而非炫技。


二、从求和模式看「共同模式」 [06:30]

2.1 回顾已知程序:求和、平方和、π 近似

讲师先回顾了之前学过的几个程序:

  1. 整数求和:从 a 加到 b。
  2. 平方和:从 a 到 b 的平方累加。
  3. π/8 的级数近似:1/(1×3) + 1/(5×7) + 1/(9×11) + ...

例子是 formula for finding pi over eight. It's a funny ugly mess. Something like one over one time three plus one over five times seven plus one over nine times eleven.

2.2 发现共同结构

讲师指出:这三个程序几乎一模一样,都是「一个求和(sum)」。

  • 它们的差别只有两点:

    1. 步长不同:整数求和步长为 1,π 近似步长为 4。
    2. 被累加的项(term)不同:
      • 整数求和:项就是 A 本身。
      • 平方和:项是 A 的平方。
      • π 近似:项是 1/(A×(A+2))。
  • 其余部分(递归结构、终止条件、累加方式)完全相同。

Anytime we have a bunch of things like this that are identical, we're going to have to come up with some sort of abstraction to cover them.

2.3 语言规则 vs 编程惯用法

考点: 讲师在此区分了两个层次:

  • 语言规则(rules of language):原语(primitives)、组合方法(means of combination)、抽象方法(means of abstraction)——这些你已经学过。
  • 编程惯用法(common patterns of usage / idioms):这是学一门语言时真正要掌握的「套路」。

理解:这类似于自然语言学习——语法只是基础,真正让你流利的是「成语」和「固定搭配」。编程中的 idiom 就是那些一眼就能认出的常见模式。

易错点:很多人学编程只学语法,不学 idiom。结果遇到问题要从头推导,效率极低。SICP 的独特之处在于——你不仅知道这些 idiom,还能给它们命名。


三、抽象出通用求和过程 sum [09:57]

3.1 通用模式的形式化

讲师把上述三个程序的共同结构抽象成一个通用模式:

  • 定义一个过程,接受两个参数:下界 a 和 上界 b。
  • 若 a > b,返回 0。
  • 否则,计算 (term a),加上「对 (next a) 到 b 递归调用本过程」的结果。

3.2 引入过程参数

考点: 关键洞察是——数字只是数据的一种,过程也可以是数据。

Numbers are just one kind of data. It seems to me perfectly reasonable to give all sorts of names to all kinds of data. For example procedure.

因此,sum 可以接受四个参数:

参数 含义 类型
term 计算当前项的过程 过程
a 当前索引(下界) 数字
next 计算下一个索引的过程 过程
b 上界 数字

3.3 sum 的定义

(define (sum term a next b)
  (if (> a b)
      0
      (+ (term a)
         (sum term (next a) next b))))

要点说明:

  • term 是一个过程:给定索引,返回该项的值。
  • next 是一个过程:给定索引,返回下一个索引。
  • 递归调用时,term 和 next 这两个过程本身也要作为参数传递下去,因为它们后面还要用。

易错点:递归调用中传的是 (next a)(next 作用于 a 的结果),而不是 next 本身。同时 next 过程本身也要作为参数继续传递。这两个是不同的东西:

  • next = 计算下一个索引的过程
  • (next a) = 用 next 作用于 a 得到的下一个索引值

3.4 缩进与美观打印

讲师特别提到:当参数写不下时,他会按参数对齐缩进,这样能一眼看出哪些参数是一组的。

思考:这是 Lisp 社区「pretty printing」惯例的体现。良好的缩进让程序可读性大幅提升,尤其在高阶过程中,过程参数和普通参数混在一起,缩进是唯一的视觉线索。


四、用 sum 重写三个程序 [12:30]

4.1 整数求和

需要一个恒等过程(identity procedure),因为 term 对整数不做任何变换:

(define (identity x) x)

(define (sum-integers a b)
  (sum identity a (lambda (x) (+ x 1)) b))

要点:

  • identity 作为 term 传入,对索引不做变换。
  • (lambda (x) (+ x 1)) 作为 next,实现步长 1。

4.2 平方和

(define (sum-squares a b)
  (sum square a (lambda (x) (+ x 1)) b))

其中 square 是之前定义过的平方过程。这里 term 就是 square。

4.3 π 近似

(define (pi-sum a b)
  (sum (lambda (i) (/ 1.0 (* i (+ i 2))))
       a
       (lambda (i) (+ i 4))
       b))

要点:

  • term 用匿名过程直接写:1/(i×(i+2))。
  • next 步长为 4。

4.4 关于匿名过程(lambda)

讲师说:I'm going to write the Greek lambda. The letter here. That is L.A.M.B.D.A.

  • lambda 用于定义匿名过程(不需要命名的过程)。
  • 当某个过程只用一次时,没必要给它起名字,直接用 lambda 写。
  • 讲师开玩笑说:希望计算机产业能在键盘上加一个 λ 键。

理解:lambda 的引入让高阶过程写起来非常自然。没有它,每次传过程参数都要先 define 一个名字,代码会变得冗长。


五、为什么要抽象?——分解与封装 [16:00]

5.1 独立修改的能力

讲师问:为什么 sum 这么重要?

考点: 因为它把问题分解成两个独立的部分:

  1. 被累加的是什么(term)
  2. 如何累加(sum 的递归/迭代结构)

5.2 迭代 vs 递归实现

假设有人想换一种累加方式,比如迭代实现(可能比递归更高效):

  • 如果原来三个程序各写各的(左黑板),那么改累加方式要改三处。
  • 如果用 sum 抽象(右黑板),只需改 sum 一处。

That decomposition allows me to independently change one part of the program and improve it perhaps without changing the other part.

思考:这就是「封装」思想的雏形。sum 把「如何做加法」封装起来,调用者只关心「加什么」。这与面向对象中的「策略模式」有异曲同工之妙。


六、学生提问:过程作为参数如何理解 [18:00]

6.1 问题:过程为什么能当参数传?

学生问:这里过程是怎么当作参数传递的?

讲师回答要点:

  • 过程并不特殊。square 只是一个变量,它的值是一个过程。
  • 过程可以被命名,因此可以被传递。
  • 这里把 term 过程作为参数传给 sum,让它在递归中继续可用。
  • next 也是作为参数传递。

6.2 替换模型(substitution model)

讲师建议用替换模型来理解:

  • 调用 sum 时,把实参替换到形参位置。
  • 例如 (sum-integers 1 10) 展开后,identity 替换 term,(lambda (x) (+ x 1)) 替换 next。
  • 然后逐步展开,就能看到 identity 被应用、1+ 被应用。

易错点:next 作为参数传入时是过程本身;而 (next a) 是调用 next 得到的结果。看代码时要注意括号:

  • next → 过程
  • (next a) → 值
  • 两者在递归调用中都要传,但用途不同。

七、课间休息与引入「不动点」 [21:00]

7.1 休息前的话

讲师说:计算机是让人快乐的,不是让人伺候计算机的。引入抽象的目的是让程序更好写、更好读。

7.2 回顾最复杂的程序:牛顿法求平方根

讲师展示之前学过的牛顿法求平方根程序,指出:

  • 这个程序看起来复杂,一眼看不出它在算什么。
  • 里面有 try 循环、improve 过程(做平均和除法)。
  • 但真正的数学思想是什么?能不能用抽象把它讲清楚?

7.3 数学洞察:不动点

考点: 牛顿法求平方根的本质是寻找函数的不动点(fixed point)。

  • 定义改进函数:f(y) = (y + x/y) / 2
  • 若 y = √x,则 f(y) = (√x + x/√x)/2 = (√x + √x)/2 = √x
  • 所以 √x 是 f 的不动点——把 √x 代入 f,得到还是 √x。

不动点定义: 一个值,把它代入函数后,输出还是它自己。

推导:

f(√x) = (√x + x/√x) / 2
      = (√x + √x) / 2      [因为 x/√x = √x]
      = 2√x / 2
      = √x

7.4 计算器类比

讲师举了一个生动例子:

  • 把计算器调到弧度模式,反复按 cos 键。
  • 最终会收敛到约 0.739(cos 的不动点)。
  • 这说明:某些函数可以通过反复迭代来找到不动点。

理解:这就是「迭代法」的直观演示。每次把上次的输出作为下次的输入,如果函数「收缩」,就会收敛到不动点。


八、定义 fixed-point 过程 [25:30]

8.1 用愿望思维写程序

讲师先「愿望思维」地写出平方根:

(define (sqrt x)
  (fixed-point (lambda (y) (average y (/ x y)))
               1.0))
  • 先假设 fixed-point 已经存在。
  • 初始猜测为 1.0(对平方根来说,起点无所谓,有定理保证收敛)。

讲师说:This is called wishful thinking. I don't know how I'm going to make fixed point happen. But I'm going to write my program anyway.

考点: 「愿望思维」是 SICP 反复强调的编程方法——先假设辅助过程存在,把主程序写出来,再回头实现辅助过程。这是自顶向下设计的具体体现。

8.2 实现 fixed-point

(define (fixed-point f first-guess)
  (define (close-enough? v1 v2)
    (< (abs (- v1 v2)) tolerance))
  (define (try guess)
    (let ((next (f guess)))
      (if (close-enough? guess next)
          next
          (try next))))
  (try first-guess))

要点说明:

  • 内部过程:close-enough? 和 try 都定义在 fixed-point 内部,外部不可见。
  • 终止条件:当旧值和新值「足够接近」时停止。
  • 迭代循环:try 不断把 (f guess) 作为新的 guess。
  • tolerance:一个很小的容差常量(如 0.00001)。

易错点:

  • 不要写成 (if (close-enough? guess next) guess (try next))——应该返回 next,因为 next 是更新后的值。
  • close-enough? 用 abs 取绝对值,不能直接比较差值,否则负数差值会误判。

8.3 内部过程的意义

Internal loops are written by defining internal procedures.

理解:内部过程是 Lisp 中实现「循环」的惯用法。因为 Lisp 没有传统的 for/while,用递归的内部过程来实现迭代。同时,内部过程可以访问外部过程的变量(如 f、tolerance),形成闭包。


九、为什么用平均阻尼?——收敛性分析 [30:00]

9.1 一个更简单但失败的方法

讲师问:为什么不直接用 g(y) = x/y?它的不动点也是 √x,而且更简单。

答案:不收敛,会振荡。

举例:x = 2,初始猜测 1。

  • g(1) = 2/1 = 2
  • g(2) = 2/2 = 1
  • g(1) = 2
  • g(2) = 1
  • ……永远在 1 和 2 之间振荡,永远不会接近 √2 ≈ 1.414。

9.2 平均阻尼(average damping)

考点: 解决办法是阻尼振荡——把新旧值取平均。

  • 这就像信号处理中消除振荡的经典方法。
  • 定义 average-damp:接受一个过程 f,返回一个新过程,该过程对 f(x) 和 x 取平均。
(define (average-damp f)
  (lambda (x) (average x (f x))))

9.3 用平均阻尼重写平方根

(define (sqrt x)
  (fixed-point (average-damp (lambda (y) (/ x y)))
               1.0))

要点:

  • average-damp 接受过程 (lambda (y) (/ x y)),返回一个新过程。
  • 新过程每次计算时,把 x/y 和 y 取平均,从而阻尼振荡。
  • 这样就能收敛到 √x。

推导:

g(y) = x/y          (会振荡)
h(y) = (y + x/y)/2  (平均阻尼后,收敛)

易错点:average-damp 返回的是一个过程,不是数值。它的类型是「过程 → 过程」。这与 sum 的「过程 + 数值 → 数值」不同。理解这一点是理解高阶过程的关键。


十、学生提问:lambda 与多参数的区别 [36:00]

10.1 问题:能不能写成 (lambda (f x) ...)?

学生问:average-damp 能不能写成 (lambda (f x) (average (f x) x))?

讲师回答:不行。

  • (lambda (f x) ...) 是一个接受两个参数的过程:一个过程 f 和一个数值 x,返回一个数值。
  • 而 average-damp 需要的是:接受一个过程 f,返回一个过程。
  • 返回的过程再接受 x,计算平均值。

类型对比:

写法 类型
(lambda (f x) ...) 过程 × 数值 → 数值
(lambda (f) (lambda (x) ...)) 过程 → 过程

易错点:average-damp 必须返回过程,因为它的返回值要放进 fixed-point 的过程槽位。如果返回数值,类型就不匹配了。

10.2 不用 lambda 的等价写法

讲师说:可以不用 lambda,但需要给内部过程起个名字:

(define (average-damp f)
  (define (damped x)
    (average x (f x)))
  damped)

这与 lambda 写法完全等价。lambda 只是「匿名过程」的语法糖。

理解:lambda 的价值在于——当过程只用一次时,不必费心起名字。这让高阶过程的代码更简洁、更聚焦于逻辑本身。


十一、牛顿法(Newton's Method) [39:00]

11.1 牛顿法的一般形式

考点: 牛顿法是求函数零点(根)的通用方法。

  • 目标:找到 y 使得 f(y) = 0。
  • 迭代公式:
y_{n+1} = y_n - f(y_n) / f'(y_n)

其中 f' 是 f 的导数。

11.2 用牛顿法求平方根

要解 y² = x,即 y² - x = 0。

所以定义 f(y) = y² - x(注意:这里的 x 是外层传入的自由变量)。

(define (sqrt x)
  (newton (lambda (y) (- (square y) x))
          1.0))

要点:

  • (lambda (y) (- (square y) x)) 中,x 是自由变量,由外层 sqrt 捕获。
  • 初始猜测 1.0。

11.3 实现 newton

(define (newton f guess)
  (define df (derivative f))
  (fixed-point (lambda (x) (- x (/ (f x) (df x))))
               guess))

要点:

  • 先求 f 的导数 df。
  • 然后用 fixed-point 迭代牛顿法公式。
  • 这里再次体现「愿望思维」:derivative 还没实现,先假设它存在。

理解:牛顿法本质上是「不动点 + 特定迭代函数」的组合。fixed-point 是通用引擎,牛顿法只是给它喂了一个特定的迭代函数。


十二、实现 derivative [42:30]

12.1 导数的数值近似

(define (derivative f)
  (lambda (x)
    (/ (- (f (+ x dx)) (f x))
       dx)))

其中 dx 是一个很小的数(如 0.00001)。

要点:

  • derivative 接受过程 f,返回一个过程(导函数)。
  • 返回的过程接受 x,用差分商近似导数。
  • 这是「过程 → 过程」的又一例。

12.2 数值分析的警告

讲师说:The numerical analyst here should jump on me and say, don't do that. Computing derivatives produces noisy answers.

考点: 数值微分会产生噪声,实际工程中应谨慎使用。

  • 差分商只是近似,dx 太小会因浮点误差而失真,太大则近似不准。
  • 这里用它是为了教学清晰,展示抽象的力量。

思考:这体现了 SICP 的一贯风格——先讲清思想,再讨论工程细节。数值稳定性是后续课程(如 18.335)的内容。


十三、程序结构图:抽象机器 [44:30]

讲师展示了一张幻灯片,用方框和箭头描述整个程序的结构:

  • sqrt 接受 x,构造出 (lambda (y) (- (square y) x))。
  • 这个过程的 x 被「卷入」其中(通过闭包捕获)。
  • 该过程作为 F 传入 newton。
  • newton 内部:F 一部分直接使用,另一部分经过 derivative 变成 DF。
  • F 和 DF 组合成迭代函数,送入 fixed-point。
  • fixed-point 配合初始猜测,产出 √x。

So what we've built is a very powerful engine, which allows us to make nice things like this.

理解:这张图是「数据流」视角的抽象机器。每个方框是一个过程,箭头是数据(包括过程本身)的流动。高阶过程的本质就是——过程在数据流中流动。


十四、第一类公民(First-Class Citizens) [47:00]

14.1 Christopher Strachey 的思想

讲师引用计算机科学先驱 Christopher Strachey(指称语义学创始人之一)的观点:

过程(函数)应当是编程语言中的第一类公民(first-class citizens)。

14.2 第一类公民的权利与特权

考点: 一个对象要成为第一类公民,必须满足:

  1. 可以被变量命名——如 (define square ...)。
  2. 可以作为参数传递给过程——如 sum 的 term 参数。
  3. 可以作为过程的返回值——如 average-damp、derivative。
  4. 可以放入数据结构——如列表、向量(后续课程会讲)。

考点:这四条是判断「某对象是否第一类」的标准。在 SICP 中,过程是第一类公民,这是它区别于许多早期语言(如 Fortran、C)的关键特征。在 C 中函数不能返回函数,在 Java 早期版本中函数不是一等公民(需要包装成对象)。

14.3 第一类公民的意义

Having things like procedures as first-class data allows one to make powerful abstractions, which can code general methods like Newton's method in a very clear way.

  • 第一类过程让通用方法(如牛顿法、不动点法)可以被清晰地编码。
  • 这是「元编程」和「高阶抽象」的基础。

十五、学生提问:能否内联展开 [49:00]

15.1 问题:能不能把 derivative 直接写进 fixed-point?

学生问:能不能把 (derivative f) 直接放进 fixed-point 里?

讲师回答:可以,但有代价。

(define (newton f guess)
  (fixed-point
    (lambda (x) (- x (/ (f x) ((derivative f) x))))
    guess))

要点:

  • 语法上完全合法,因为求值规则是「先求值所有子表达式」。
  • 但这样写有个坏处:每次迭代都会重新计算导数,效率低下。
  • 原来的写法把 (derivative f) 算一次,存为 df,然后复用。

易错点:内联展开虽然语法正确,但可能导致重复计算。在性能敏感的代码中,应把不变的部分提取出来,只计算一次。这是「公共子表达式消除」的朴素版本。

15.2 求值规则回顾

讲师强调求值规则:

The evaluation rule is evaluate all of the parts of the combination, the operator, and the operands.

  • 对组合式 (operator operand1 operand2 ...),先求值 operator,再求值各 operand。
  • 所以 (derivative f) 作为 operator 的一部分,会被求值——每次迭代都求一次。

思考:这个例子很好地说明了「语法正确」不等于「工程最优」。SICP 不仅教你怎么写对,还教你怎么写好。


十六、本讲总结

核心概念回顾

概念 说明
高阶过程 接受过程作为参数,或返回过程作为值的过程
过程作为参数 sum 接受 term 和 next 两个过程参数
过程作为返回值 average-damp、derivative 返回过程
lambda 匿名过程,用于一次性使用的过程
不动点 f(y) = y 的解,可用迭代法求解
平均阻尼 消除振荡,让迭代收敛
牛顿法 求函数零点的通用方法,用导数迭代
愿望思维 先假设辅助过程存在,写出主程序,再实现辅助过程
第一类公民 可命名、可传参、可返回、可放入数据结构

关键代码汇总

; 通用求和
(define (sum term a next b)
  (if (> a b)
      0
      (+ (term a) (sum term (next a) next b))))

; 不动点
(define (fixed-point f first-guess)
  (define (close-enough? v1 v2)
    (< (abs (- v1 v2)) tolerance))
  (define (try guess)
    (let ((next (f guess)))
      (if (close-enough? guess next)
          next
          (try next))))
  (try first-guess))

; 平均阻尼
(define (average-damp f)
  (lambda (x) (average x (f x))))

; 平方根(平均阻尼 + 不动点)
(define (sqrt x)
  (fixed-point (average-damp (lambda (y) (/ x y))) 1.0))

; 牛顿法
(define (newton f guess)
  (define df (derivative f))
  (fixed-point (lambda (x) (- x (/ (f x) (df x)))) guess))

; 导数
(define (derivative f)
  (lambda (x) (/ (- (f (+ x dx)) (f x)) dx)))

本讲最核心的一句话:过程可以作为参数传入,也可以作为返回值产出——这就是高阶过程。它让「通用方法」可以被清晰地抽象和复用。


关键标签 / 关键词

SICP 高阶过程 higher-order procedure 过程作为参数 过程作为返回值 lambda 匿名过程 不动点 fixed-point 平均阻尼 average-damp 牛顿法 Newton's method 导数 derivative 愿望思维 wishful thinking 第一类公民 first-class citizen 抽象 abstraction 求和 sum MIT 6.001 Hal Abelson Christopher Strachey 闭包 替换模型 substitution model 数值微分 收敛性 振荡


生成时间:2026-10-11 13:52:39 生成平台:VideoNote www.xygy.top