《SICP》笔记:计算机程序的构造和解释,以及编程世界的“无所住而生其心”
- 约 25 分钟读完 - 全文 9993 字《SICP》笔记:计算机程序的构造和解释,以及编程世界的“无所住而生其心”
目标读者:写过几年代码、用过不止一门语言、想知道“编程这件事的本质到底是什么”的程序员。
参考书籍:Harold Abelson & Gerald Jay Sussman, Structure and Interpretation of Computer Programs(SICP,中译《计算机程序的构造和解释》)
阅读目标:不求逐章复述,而是提炼一条主线——过程与数据的对偶,看清抽象、求值、解释器这三件事如何环环相扣。
0. 写在前面:为什么这本书被称为 CS 的“佛经”
SICP 是 MIT 的传奇教材。1980 年代到 2000 年代,MIT 的计算机导论课 6.001 用它做教材:第一堂课不教语法,直接写求平方根的牛顿法;第一学期结束,学生要用 Scheme 写出一个 Scheme 解释器。这门课出了名地难,也出了名地改变人——无数程序员回忆它是“第一次觉得编程是一门手艺,而不是一门手艺活”的时刻。
几个背景事实,帮你定位这本书:
- 书出版于 1985 年(第一版),语言用的是 Lisp 方言 Scheme。Lisp 诞生于 1958 年(John McCarthy),是现存第二老的高级语言(第一是 Fortran,1957 年)。
- 2008 年 MIT 把 6.001 换成了 Python 课程。Sussman 本人的解释是:现在工程师更多是在“把不理解的黑盒零件拼起来”,而不是从零构造系统——时代变了。但 SICP 的地位反而因此更稳固:它不再是一门课的教材,而是程序员自我修炼的经典。
- Paul Graham(Y Combinator 创始人)1995 年用 Lisp 写了 Viaweb——史上最早的 Web 应用之一,1998 年被雅虎以约 4900 万美元收购。他后来写《黑客与画家》,反复鼓吹 Lisp 的威力,把 SICP 式的抽象能力说成是 Viaweb 能以 3 个人对抗几十人团队的“秘密武器”。这个故事后面还会用到。
这本书为什么这么神?一句话:它讲的不是“怎么写代码”,而是**“计算是什么”**。全书有一条暗线:
计算的本质 = 过程(process)与数据(data)的对偶。程序在二者之间不断转换:数据可以被当作过程执行,过程也可以被当作数据传递。到书的结尾,我们用一个程序(求值器)把语言自己解释了一遍——这就是编程世界的“言语道断,心行处灭”。
这个博客另有佛学笔记,读完全文你会发现,SICP 的气质确实和《金刚经》那句“应无所住而生其心”隐隐相通:不执着于任何固定的表示(无所住),才能自由地在过程与数据之间切换(生其心)。但先把技术内容讲扎实,佛的部分放最后。
1. 一句话理解 SICP:抽象的三层结构
SICP 开篇就给出了“什么是编程语言”的定义。任何一门强大的语言,必须提供三样东西:
- 基本表达(primitive expressions):语言提供的最小积木,比如数字
2、运算符+。 - 组合手段(means of combination):把简单东西拼成复杂东西,比如
(+ 2 3)。 - 抽象手段(means of abstraction):给复杂东西命名,然后像用基本积木一样用它,比如
(define (square x) (* x x))。
一句话理解全书:SICP 就是在“过程”和“数据”两个战场上,反复演示这三层结构怎么玩。
- 第 1 章:过程的抽象(用函数表达计算过程)。
- 第 2 章:数据的抽象(用构造函数/选择函数把表示藏起来)。
- 第 3 章:模块化、对象与状态(引入赋值后,代换模型失效,必须换环境模型)。
- 第 4 章:元语言抽象(写一个求值器,用语言解释语言)。
- 第 5 章:寄存器机器(解释器如何在真实机器上跑起来)。
读 SICP 的正确姿势,不是记知识点,而是看作者怎么用同一把刀(三层抽象)切开五个不同的问题。这把刀,就是这本书真正想传给你的东西。
2. 过程抽象:牛顿法开方,SICP 的第一个“当头棒喝”
6.001 第一堂课就讲平方根。这不是随便选的例子——它完整演示了“一个数学定义(声明性知识)”和“一个计算过程(操作性知识)”之间的鸿沟,而弥合这个鸿沟,正是编程的本质。
2.1 数学定义 ≠ 计算过程
数学上,$\sqrt{x}$ 的定义是:“满足 $y^2 = x$ 且 $y \ge 0$ 的那个 $y$”。这个定义完美,但不能算——它没告诉你怎么找到这个 $y$。
牛顿法(Newton’s method,SICP 中归为 Heron of Alexandria 的巴比伦法特例)给出的是一个过程:从任意猜测 $y$ 出发,反复取 $y$ 和 $x/y$ 的平均值作为新猜测,直到足够接近:
$$ y_{n+1} = \frac{y_n + x / y_n}{2} $$
2.2 用小数字完整走一遍:求 $\sqrt{2}$
从猜测 $y_0 = 1$ 开始:
| 迭代 | 当前猜测 $y_n$ | $x / y_n$ | 新猜测(取平均) |
|---|---|---|---|
| 0 | 1 | 2 | (1 + 2) / 2 = 1.5 |
| 1 | 1.5 | 1.3333… | (1.5 + 1.3333) / 2 = 1.4167 |
| 2 | 1.4167 | 1.4118 | (1.4167 + 1.4118) / 2 = 1.4142 |
三步就到 1.4142,误差已经小于 $10^{-4}$。真实值是 1.41421356…。收敛速度是平方级的——每迭代一次,有效位数大约翻倍。
2.3 Scheme 实现:嵌套定义把复杂性藏起来
SICP 把牛顿法写成一组互相调用的过程,最后用 define 的内部嵌套把辅助过程全部藏起来:
(define (sqrt x)
(define (good-enough? guess)
(< (abs (- (square guess) x)) 0.001))
(define (improve guess)
(average guess (/ x guess)))
(define (sqrt-iter guess)
(if (good-enough? guess)
guess
(sqrt-iter (improve guess))))
(sqrt-iter 1.0))注意这里发生了什么:
- 用户只看到
(sqrt 2),不用知道里面有迭代、有容忍度、有牛顿公式。这就是过程抽象:把一个过程当作黑盒,用名字调用它。 - 辅助过程
good-enough?、improve、sqrt-iter定义在sqrt内部,外面的代码根本看不见它们,而且它们直接共享参数x,不用层层传递。 - 这是 SICP 给新人的第一棒:函数不是用来“复用代码”的,是用来“命名计算过程、压制细节”的。
避坑提示:SICP 练习 1.7 指出,
good-enough?用固定阈值0.001对很小的数(如sqrt 0.0001)和很大的数(如sqrt 1e13)都会出错——前者精度不够,后者因为浮点精度问题可能不收敛。工程上的修法是判断“新旧猜测的相对变化量是否足够小”,而不是绝对误差。这个练习至今仍是理解“数值代码里的坑”的最佳入门。
3. 高阶函数:sum/integral 通用模板,抽象出“模式”本身
3.1 三个长得像的求和
SICP 第 1 章的下一个重点:数学家早就发现一堆求和过程长得几乎一样,只是“每一项怎么算”和“下一项怎么来”不同:
; 从 a 加到 b
(define (sum-integers a b)
(if (> a b) 0 (+ a (sum-integers (+ a 1) b)))))
; 从 a 的立方加到 b 的立方
(define (sum-cubes a b)
(if (> a b) 0 (+ (cube a) (sum-cubes (+ a 1) b)))))
; 莱布尼茨级数求 π:1/1 - 1/3 + 1/5 - ...
(define (pi-sum a b)
(if (> a b) 0 (+ (/ 1.0 (* a (+ a 2))) (pi-sum (+ a 4) b))))三个过程的骨架一模一样。SICP 的做法:把骨架抽出来,让“每一项怎么算(term)”和“下一项怎么来(next)”变成参数:
(define (sum term a next b)
(if (> a b)
0
(+ (term a) (sum term (next a) next b))))
; π 级数就剩两行
(define (pi-sum a b)
(sum (lambda (x) (/ 1.0 (* x (+ x 2))))
a
(lambda (x) (+ x 4))
b))sum 就是一个高阶函数(higher-order procedure):以过程为参数、以过程为返回值的过程。
3.2 一句话理解高阶函数
高阶函数 = 把“算法中的变化点”变成参数。你传进去的不是数据,是行为。
如果你觉得眼熟,那就对了:
- 你在 JavaScript 里写的
array.map(fn)、array.filter(fn)、array.reduce(fn, init),就是sum的亲戚。 - Python 的
sorted(lst, key=fn)、Java 的Comparator,传的都是“行为参数”。 - 数值积分 $\int_a^b f \approx \sum f(x),dx$ 在 SICP 里就是
sum换一套term/next的事——积分器和求和器共享同一个骨架,这在数学上是黎曼和,在代码上就是高阶函数。 - 设计模式里的 Strategy、模板方法,本质都是“手写的高阶函数”——语言不支持把函数当值传递,就只能用类和接口模拟。这也是为什么 Peter Norvig 那篇著名文章指出:GoF 23 个设计模式里有 16 个在有头等函数的语言里“要么消失、要么简化到看不见”。
3.3 lambda 与 let:不命名,也是自由
(lambda (x) (* x x)) 造出一个匿名过程。SICP 特意强调:能给过程命名(define)当然好,但能不命名也同样重要——很多小过程只用一次,为它发明一个名字反而污染命名空间。
工程对照:这就是你写 list.sort(key=lambda x: x.age) 时干的事。let 则只是 lambda 的语法糖:
(let ((a 1) (b 2))
(+ a b))
; 等价于
((lambda (a b) (+ a b)) 1 2)一句话理解:
let不是“定义变量的特殊语法”,而是“立刻调用一个匿名函数”。变量绑定就是函数参数绑定。这个视角在第 4 章写求值器时会回报你——let根本不用实现,翻译成 lambda 就完了。
4. 数据抽象:有理数例子,和“抽象屏障”这道墙
4.1 用之前,先假装它存在
SICP 第 2 章开篇的方法论非常反直觉:先有“愿望思维”(wishful thinking)——假设有理数的构造函数和选择函数已经存在,先把用它们的上层代码写完,再回头实现它们。
; 假设这三个东西存在:
; (make-rat n d) → 构造一个 n/d 的有理数
; (numer x) → 取分子
; (denom x) → 取分母
; 于是加法直接写出来:
(define (add-rat x y)
(make-rat (+ (* (numer x) (denom y))
(* (numer y) (denom x)))
(* (denom x) (denom y))))用小数字验证:$\frac{1}{2} + \frac{1}{3} = \frac{1\times3 + 1\times2}{2\times3} = \frac{5}{6}$。对。
关键点:add-rat 的代码里没有任何一处依赖“有理数内部怎么存”。它只依赖 make-rat/numer/denom 这三个接口承诺的行为。这条接口,SICP 称之为抽象屏障(abstraction barrier)。
4.2 屏障之下,表示随便换
因为有屏障,下面这些是可以在不改变 add-rat 一行的前提下完成的修改:
- 存原始分子分母,约分留给打印时做;
- 在构造时就约分(SICP 练习 2.1:用
gcd化简); - 保证分母恒为正,把符号统一挪到分子。
这就是数据抽象的工程价值:表示的修改被关进笼子,使用方无感知。 你在工程里天天享受这个红利:
java.util.List背后是ArrayList还是LinkedList,调用方不该关心;- HTTP API 的响应字段内部存 JSON 还是 protobuf,客户端不该关心;
- 数据库的存储引擎从 MyISAM 换 InnoDB,SQL 不变。
一句话理解:数据抽象不是“封装”这个 OOP 词汇的专利,它是更根本的原则——“用一组行为约定代替对表示的直接依赖”。Java 的接口、C 的 opaque pointer、Rust 的 trait,全是这道墙的不同砌法。
5. cons/car/cdr 与闭包性质:一对盒子造出整个数据世界
5.1 唯一的胶水:cons
Scheme 的数据组合手段只有一个:cons。(cons 1 2) 造出一个序对(pair),car 取左,cdr 取右。就这一个盒子,SICP 搭出了列表、树、表、集合……全部数据结构。
(cons 1 (cons 2 (cons 3 nil))) ; 这就是列表 (1 2 3)
(cons (cons 1 2) (cons 3 4)) ; 这就是一棵二叉树为什么够了?因为 SICP 给出了闭包性质(closure property):
组合手段满足闭包性质 = 组合出来的东西,本身还能继续被同样的手段组合。
cons 出来的序对,可以再被 cons。这一句话是数据世界的“递归基”:列表是嵌套的 cons,树是嵌套的 cons,任何层级结构都是嵌套的 cons。
工程对照:HTML 的 DOM 树满足闭包性质(元素套元素);Unix 管道满足闭包性质(任何命令的输出都能进下一个命令的输入);JSON 满足闭包性质(对象里套对象)。满足闭包性质的组合手段,组合成本不随复杂度增长——这是 SICP 对“为什么 Lisp 和 Unix 都能小核心、大生态”的统一解释。
5.2 最炸裂的一课:cons 可以不用数据实现
SICP 2.1.3 有一个让无数人第一次头皮发麻的段落:cons、car、cdr 可以只用过程(函数)实现,不用任何数据结构:
(define (cons x y)
(lambda (m) (m x y)))
(define (car z)
(z (lambda (p q) p)))
(define (cdr z)
(z (lambda (p q) q)))走一遍 (car (cons 1 2)):
(cons 1 2)返回一个匿名函数,它等着别人告诉它“你要左还是要右”。(car z)把(lambda (p q) p)塞给它,意思是“给我左边那个”。- 结果:
1。
这意味着什么?“数据”和“过程”的边界是虚构的。 一个序对可以完全用行为(“当被问到时左右各报什么”)来定义,不占用任何存储意义上的“盒子”。这就是第 0 节说的对偶性第一次显形:
数据是被冻住的过程;过程是活过来的数据。 佛家说“色即是空,空即是色”,SICP 说“数据即过程,过程即数据”——结构上是同一个句式。
(历史彩蛋:这套“用函数编码数据”的玩法不是 SICP 发明的,是 Alonzo Church 1930 年代 λ 演算里的 Church 编码——连自然数都可以用纯函数定义。SICP 把它拿来当头棒喝用。)
6. 求值的两个模型:代换模型优雅,环境模型救命
6.1 代换模型:把函数调用当代数化简
求值一个组合式,最朴素的模型是代换模型(substitution model):把形参全部替换成实参,然后化简。
(square (+ 2 3))
→ (square 5)
→ (* 5 5)
→ 25
这个模型简单、优雅,而且可以直接证明程序正确性(这正是函数式编程“可推导”的理论基础)。SICP 用它讲清了**应用序(先算实参再代入)与正则序(先代入再按需算)**的区别——后者就是第 7 节惰性求值的种子。
6.2 赋值一来,代换模型当场死亡
第 3 章引入 set!(赋值)之后,代换模型立刻崩溃。考虑一个取钱过程:
(define balance 100)
(define (withdraw amount)
(set! balance (- balance amount))
balance)(withdraw 25) 得 75,再 (withdraw 25) 得 50。同样的表达式、同样的参数,两次结果不同——代换模型的基石“相同表达式有相同值”(引用透明性)碎了。表达式不再是值的替身,时间进入了程序:值取决于“你什么时候问”。
6.3 环境模型:变量不再“是”值,而是“在”某个环境里的格子
替代方案是环境模型(environment model):
- 环境 = 一串框架(frame),每个框架是一张“变量名 → 值”的表,框架之间用指针连成链,最后终结于全局环境。
- 变量求值 = 从当前框架往外一层层找,找到第一个绑定。
lambda求值 = 造出一个过程对象,它携带两样东西:参数+函数体,和一个指向定义时环境的指针。- 调用过程 = 建一个新框架,把实参绑进去,新框架的外层指针指向过程对象里存的那个环境。
flowchart TD
GE["全局环境<br/>balance = 50<br/>withdraw = 过程对象"]
PO["过程对象<br/>参数: amount<br/>函数体: set! balance ...<br/>环境指针 ──→"]
E1["框架 E1<br/>amount = 25"]
PO --> GE
E1 -->|"外层指针"| GE
GE -.->|"调用 withdraw 25 时创建"| E1
为什么函数式程序员迷恋“纯函数、不可变数据”?SICP 给了最诚实的答案:不是因为美,而是因为一旦引入赋值,你从此失去了代换模型这个思维工具,必须用环境模型这种更贵的心智模型去追每一个变量的历史。 工程对照:
- Redux 的“单一不可变 state 树”,本质是在 JS 里人工维持代换模型可用;
- Rust 的所有权与借用检查,是编译器帮你控制“谁在什么时候能改什么”;
- 并发 bug 的根源几乎都是“共享可变状态”——环境模型里那条指针链,到了多线程就是一场灾难。
避坑提示:闭包捕获的是环境(变量的格子)而不是当时的值。经典面试题——Python 里
fns = [lambda: i for i in range(3)],三个函数都返回 2——就是这句话的具象化。JS 的var循环、Go 的 goroutine 循环变量(Go 1.22 之前的坑)都是同一原理。学完环境模型,这类 bug 你一眼看穿。
7. 流与惰性求值:用“无穷的数据结构”把时间装回瓶子
7.1 问题:状态和赋值污染了一切,能不要吗?
第 3 章结尾 SICP 问了一个漂亮的反问:我们引入赋值是为了建模“随时间变化的状态”(比如银行账户余额)。但换个视角——余额不是“一个会变的格子”,而是“一个无穷序列:每次交易后的全部余额历史”。变化的东西,可以被重新表示成一个不变但无穷的东西。
问题是:无穷的序列怎么在有限的内存里表示?答案:只在你看它的时候才算它——这就是流(stream)和惰性求值(lazy evaluation)。
7.2 流的实现:cons 的另一半推迟到以后
流的全部魔法在一对操作里(SICP 用 cons-stream 宏和 delay/force):
(cons-stream a b):a立刻算,b不算,包成一个“承诺”(promise)存起来;(stream-cdr s):兑现那个承诺(force),真正算出下一截。
于是无穷斐波那契流只需要三行:
(define fibs
(cons-stream 0
(cons-stream 1
(add-streams fibs (stream-cdr fibs)))))读法:fibs = 0 开头,接着 1,接着是“fibs 和它自己的错位相加”。用小数字走一遍错位相加:
fibs: 0, 1, 1, 2, 3, 5, 8, ...
(stream-cdr fibs): 1, 1, 2, 3, 5, 8, 13, ...
对应位相加: 1, 2, 3, 5, 8, 13, ...
从第 3 位开始,每一位都是前两段对应位相加的结果——正是斐波那契定义。但注意:这个流并没有预先存在,是你每取一个元素,它才长出一个元素。定义是递归的,数据却是按需生长的。
7.3 流处理管道:信号处理式的程序结构
有了流,程序可以写成信号处理系统的样子:生成器 → 变换器 → 过滤器 → 累积器,一环一环接起来。
flowchart LR
G["生成器<br/>无穷整数流<br/>2, 3, 4, 5, ..."] --> F1["过滤器<br/>筛掉被 2 整除的"]
F1 --> F2["过滤器<br/>筛掉被 3 整除的"]
F2 --> F3["过滤器<br/>筛掉被 5 整除的"]
F3 --> OUT["输出<br/>素数流<br/>2, 3, 5, 7, 11, ..."]
style OUT fill:#e8f5e9
上图就是 SICP 的埃氏筛素数流:每个素数一旦露面,就派生出一个新过滤器挂在管道上,把它的倍数全部筛掉。整个程序是对“全体素数”这个无穷集合的操作,但任意时刻内存里只有用到的那一小段。
工程对照,这个思想你今天天天在用:
- Unix 管道:
cat access.log | grep 500 | wc -l——三段程序用惰性流串起来,谁也不会把整个日志读进内存。 - Java 8 Streams / RxJava / Rust 的 Iterator:
stream.filter(...).map(...).collect(),中间操作全部惰性,终端操作才触发计算。 - Python 生成器:
yield就是delay,next()就是force。 - 大数据框架(Flink/Spark Streaming):整个流处理行业就是 SICP 第 3.5 节的工业化。
一句话理解:流 = 用“无穷的静态结构”替代“变化的状态”。赋值把“值随时间变”摊给了程序员的心智;流把同样的现象表示成一个数学对象,时间被折叠进“取到第几个元素”这个参数里。这是 FP 社区对抗复杂性的核心武器之一。
8. 元循环求值器:用语言解释语言,全书的高潮
8.1 为什么要写求值器
第 4 章,SICP 做了一件别的教材不敢在入门书里做的事:用 Scheme 写一个 Scheme 解释器。
这个解释器叫元循环求值器(metacircular evaluator):“元”因为它在谈论语言自身,“循环”因为被解释的语言和实现它的语言是同一个。为什么这是高潮?因为前四章一直在说“过程与数据可以对偶转换”,而求值器是这个命题的终极证明:你的程序,对解释器来说只是数据(一个列表结构);而解释器本身,也只是一个程序。
8.2 eval/apply 循环:整个语言只剩两个函数
剥掉所有细节,求值器的心脏是两个互相递归的函数:
- eval(表达式,环境):对表达式分派——
- 自求值的(数字、字符串)→ 直接返回;
- 变量 → 去环境里查;
(quote x)→ 返回 x 本身;(define ...)/(set! ...)→ 改环境;(if ...)→ 先 eval 谓词,再选一支;(lambda ...)→ 打包成“过程对象”(参数 + 函数体 + 当前环境指针,正是第 6 节环境模型里的那个结构);- 函数调用 → 先 eval 运算符得到过程,再把所有实参 eval 出来,交给 apply。
- apply(过程,实参列表):
- 基本过程(
+、car这种内置的)→ 直接调用底层实现; - 复合过程(用户用 lambda 定义的)→ 建一个新框架,形参绑实参,把过程体在这个新环境里交给 eval。
- 基本过程(
flowchart TD
E["eval(表达式, 环境)"] --> Q{"表达式是什么类型?"}
Q -->|"数字/字符串"| R1["直接返回"]
Q -->|"变量"| R2["沿环境链查找绑定"]
Q -->|"quote"| R3["返回被引用的数据本身"]
Q -->|"lambda"| R4["打包过程对象:<br/>参数+函数体+环境指针"]
Q -->|"if / define / set!"| R5["按规则处理<br/>(子表达式递归调 eval)"]
Q -->|"函数调用"| R6["eval 运算符得过程<br/>逐个 eval 实参"]
R6 --> A["apply(过程, 实参)"]
A --> Q2{"过程是哪种?"}
Q2 -->|"基本过程"| R7["调用底层内置实现"]
Q2 -->|"复合过程"| R8["新建框架: 形参→实参<br/>外层指针=过程携带的环境"]
R8 -->|"在新环境中求值过程体"| E
style E fill:#e3f2fd
style A fill:#fff3e0
这个图值得你盯着看五分钟:整个语言——所有语法、所有抽象——最后都坍缩成 eval 和 apply 的互相调用。这就是“同像性(homoiconicity,代码即数据)”的兑现:因为 Scheme 代码本身就是 Scheme 列表((f a b) 既是调用,也是一个三元素列表),所以 eval 可以直接拿程序当数据拆开、分派、重组。
8.3 这一课在工程里的投影
别以为元循环求值是学院派玩具。它直接对应你今天用的东西:
- 每个语言运行时都是 eval/apply:V8 解释 JS、CPython 执行字节码、JVM 跑字节码,本质都是“拿一段数据结构(代码),分派,执行”。JIT 只是给这个循环加缓存。
- SQL 引擎:你写的 SQL 对数据库是数据,查询规划器就是 eval,执行器就是 apply。
- 规则引擎、模板引擎、配置文件即代码(Terraform 的 HCL、Kubernetes 的 YAML 控制器循环):全是“程序读数据,把数据当程序跑”。
- AI 时代的镜像:今天的 LLM Agent 循环——模型输出一段“计划/代码”(数据),宿主程序解释执行它(eval),结果再喂回模型——结构上和元循环求值器惊人地相似。提示工程里的 ReAct 模式,就是给 LLM 套了一个手写的 eval/apply。
- 历史掌故:正因为 Lisp 代码即数据,它的**宏(macro)**系统才能在编译期用 Lisp 本身变换 Lisp 代码。Paul Graham 说 Viaweb 能以 3 人团队每周上线几次、碾压竞争对手,核心就是 Lisp 宏让他们随时给语言“长”出适应业务的新语法——竞争对手在用语言写功能,他们在先造语言再写功能。这是 SICP 第 4 章思想最著名的一次商业兑现。
8.4 无穷后退,以及“无所住”
一个哲学问题浮出来:元循环求值器自己又是被谁求值的?——被底层的 Scheme 实现。那个实现又被谁执行?被机器。机器呢?……这就是 Sussman 说的“求值的无穷后退(infinite regress)”,像一组两面相对的镜子。
SICP 的回答很佛系,也很硬核:后退没有终点,但有可以停下来的层级。 求值器把“语言的意义”钉在了一个你能完整看懂的两页代码上,这就够了——剩下的层级,当作黑盒信任。这和第 2 节 sqrt 的嵌套定义是同一把刀:在正确的层级上停止追问,把下层当黑盒,是驾驭复杂性的唯一办法。
9. 收个尾:从 eval/apply 到“无所住而生其心”
《金刚经》那句“应无所住而生其心”,粗解是:心不执着于任何固定的相(“住”),才能生起真正的觉照(“生其心”)。
SICP 教的恰恰是同一个心法的技术版:
- 不住于表示:有理数怎么存不重要,重要的是
make-rat/numer/denom的行为约定(第 4 节);cons 可以用盒子实现,也可以用纯函数实现(第 5 节)。 - 不住于过程与数据的分别:代码可以被当数据改写(宏、同像性),数据可以被当过程执行(Church 编码、求值器)。
- 不住于语言的边界:当你能为问题造一门小语言(第 4 章元语言抽象、DSL),“用什么语言编程”这个执念就放下了。
- 不住于模型的执念:代换模型够用就别上环境模型;状态非要建模时,先试试流能不能把它变成静态结构(第 6、7 节)。
心无所住,方能在过程与数据、语言与元语言之间自由穿行。这是 SICP 作为“CS 佛经”的真正含义:它教的不是 Scheme,是一种对一切表示层都不执着、只忠于计算本质的编程心法。
10. 带走的清单
五句话记住 SICP:
- 语言三要素:基本表达、组合手段、抽象手段。评价任何语言/框架,先数这三样。
- 过程抽象:给计算过程起名字,把细节关进黑盒(牛顿法 sqrt 是起点)。
- 高阶函数:把算法的变化点变成参数(sum/integral 一个模板;map/filter/reduce 是它的日常形态)。
- 数据抽象:用行为约定代替对表示的依赖,屏障之下表示随便换。
- 闭包性质:组合产物可再组合,小核心才能长成大生态(cons、Unix 管道、JSON 同理)。
四个思维工具,终身受益:
- 代换模型:纯函数代码的推理方式;引入赋值前先想想值不值得失去它。
- 环境模型:变量是“环境里的格子”不是“值”;闭包捕获格子不捕获值——一类经典 bug 的透视镜。
- 流/惰性求值:把“随时间变化的状态”重述为“无穷的静态结构”,按需兑现。
- eval/apply:一切解释器、查询引擎、规则引擎、Agent 循环的两行心脏。
避坑指南:
- 读 SICP 不要当“Lisp 教程”读。Scheme 只是手术刀,病灶是“计算的本质”;纠结括号语法就本末倒置了。
- 习题一定要做,至少做完第 1、2 章。SICP 的习题(如 1.7 的
good-enough?失效分析)是正文的一半。 - 不要被第 3 章的状态讨论劝退——代换模型失效、环境模型救场,正是全书承上启下的枢纽,跳过它后面元循环求值器就看不懂。
- 元循环求值器别只“看懂”,找一门宿主语言(Python/JS 都行)亲手写一遍 eval/apply,哪怕只支持五个特殊形式。写过和看过是两个物种。
- 线上资源:MIT 官网有全文免费的 HTML 版(SICP Web Site),1986 年 Abelson 和 Sussman 给惠普员工讲的 20 集录像(MIT OpenCourseWare / “SICP Lectures”)仍然是最生动的配套。
参考资料
- Harold Abelson, Gerald Jay Sussman, Structure and Interpretation of Computer Programs, 2nd Edition, MIT Press, 1996(MIT 官网有免费全文)。
- MIT OpenCourseWare:6.001 Structure and Interpretation of Computer Programs(1986 年 HP 版课程录像)。
- Paul Graham, Hackers & Painters(Viaweb 与 Lisp 的故事见其中 “Beating the Averages” 一章)。
- Peter Norvig, Design Patterns in Dynamic Programming(设计模式与头等函数的关系)。