读书导读 · CC BY-SA 4.0
《计算机程序的构造和解释》全书摘要与逐章导读
《计算机程序的构造和解释》是一本讲“怎样让大型程序始终可以被人理解”的教科书。作者在序言里说,它的主题不是某门语言的语法,也不是巧妙的算法,而是控制软件智力复杂度的方法:建立抽象、约定通用接口,在问题需要时发明一门小语言。本页依据第二版(1996),MIT 出版社以 CC BY-SA 4.0 在网上免费公开全文。
引文按 CC BY-SA 4.0 使用;本页文字也按同一协议授权,引文译文属于改编作品。免费阅读原书。
《计算机程序的构造和解释》讲了什么AI 辅助
形式化证明无法扩展:只有组织技术才能造出大型系统
我们如何控制程序里的复杂性?本书的回答是:靠抽象,最终靠构造语言。它从这样的主张出发:编程语言是向人类读者表达方法论的媒介,而使大型程序成为可能的是组织技术,不是形式化证明。三种机制——基本表达式、组合的手段、抽象的手段——描述任何强大的语言,而它的求值规则是递归的。抽象随后被用到过程上,用到复合数据上(理由相同),也用到由构造函数和选择函数的条件所定义的表示上,所以仅凭过程就能在抽象屏障和约定的接口之后实现数据。通过镜像来给世界建模,会引来赋值和局部状态,它们换来模块化,代价是引用透明性和代换模型。本书于是上升一层:决定含义的求值器本身是个普通程序,所以程序员成了语言设计者,而别的求值规则,只是对它的修改。因为元循环求值器借用了宿主的控制结构,本书又下降到寄存器机器,在那里控制、栈和递归都变得显式。
《计算机程序的构造和解释》的核心论点
全书捍卫的 10 个论点,按论证需要的顺序排列。
- 因为形式化的正确性论证无法随程序规模扩展,使大型程序成为可能的,实际上是对组织技术和抽象技术的掌握,而不是证明。
“比什么都重要的是,发现并掌握强大的组织技术,能加快我们创造大型而有意义的程序的能力。”
“More than anything else, the uncovering and mastery of powerful organizational techniques accelerates our ability to create large, significant programs.”
Foreword查看原文 → - 编程语言是向人类读者表达方法论的媒介,所以评判程序应当像评判写作,而不是评判给机器的指令。
- 每一种强大的语言,只靠三种机制就能恰当地描述——基本表达式、组合的手段、抽象的手段——因而它的求值规则是递归的。
- 数据结构的统一性胜过可声明结构的泛滥,因为统一的数据使过程不特异,可以自由组合。
- 复合数据的理由,与复合过程恰好相同;一种表示由其构造函数和选择函数所满足的条件来定义,而不是由任何存储布局来定义,所以仅凭过程就能实现数据。
- 把对表示的依赖限制在少数几个接口过程里,使程序可维护,也让设计可以在表示的选择尚未确定之前就往前推进。
- 程序的结构应当镜像被建模世界的结构,这样扩展模型就只意味着加上新的符号类比物。
- 赋值通过给对象局部状态来换取模块化,但代价是引用透明性、代换模型,以及对同一性、时间和并发的可处理的推理。
- 随着问题增长,没有哪种固定的语言是足够的,而且既然决定表达式含义的求值器本身只是另一个程序——这使几乎任何像样的程序,都是某种语言的求值器——程序员就必须成为语言设计者,靠修改求值器来获得新的求值规则。
- 因为元循环求值器悄悄借用了宿主的控制结构,要理解计算就得下降到专门造的寄存器机器,那里迭代只需要寄存器,递归却要一个栈,而复杂的操作仍然可以合理地当作可展开的基本操作。
逐章导读
每一章都在本页:论证怎样展开、关键论点、常见误读和核心概念。从目录里选一章。
第 1 章:构造过程抽象
代换模型怎样解释复合过程的应用?
第 1 章是怎样展开的AI 辅助
开场:一个看不见的问题。 这一章开头讲的不是语法,而是一个困境。程序指挥一个没有人看得见的计算过程,这意味着程序员的核心技能,是预见一个小错误在下游会造成什么——而作者的例子坚持说,惩罚与错误的大小并不成比例:设计程序里的一个 bug,可能以坍塌的飞机或水坝告终,或是一个机器人毁了自己。接着,Lisp(Scheme 方言)被论证为合适的载体,并从这个框架里提炼出本章的组织性主张:任何真正有力的语言都恰好提供三样东西——基本表达式、组合的手段、抽象的手段。开场的其余部分,只是把这个三元组一步步落到实处。表达式与递归的求值规则先出场,被画成一棵树,值沿着树向上渗透,所以对 (* (+ 2 (* 4 6)) (+ 3 5 7)) 求值,就是把一条规则用到四个组合式上。然后是 define,最简单的抽象,让 circumference 代表 62.8318——不过作者小心地称命名只是一种“有限的”抽象,而正是这个压力,让复合过程成为下一步。
第一个展开:一个模型,及其坦率的免责声明。 有了复合过程,本章需要说明应用一个复合过程“意味着”什么,于是给出代换模型。这里要紧的是紧随其后的限定。代换是给读者推理用的脚手架,不是对解释器的报告:真实的实现是在一个局部环境里约束形式参数(推迟到第3章和第4章),而一旦过程操作可变数据,这个模型就明说会垮掉。作者把这种“超越”说成普通的科学实践,而不是缺陷——这一点值得记住,因为自然的误读是把文本替换当作机器的图景。从这个模型出发,本章分叉到应用序对正则序,然后是 cond/if 和逻辑谓词,这给了它足够的机制去尝试做点真东西。
转向方法:牛顿法求平方根。 那件真东西是平方根,它承载了本章最尖锐的概念区分。平方根的数学定义,即使音译成伪 Lisp,也是完全合法的,却几乎没有告诉你怎样去找一个。所以计算过程与数学函数不是同一类对象,因为它必须是“有效的”——说明式知识对命令式知识——而牛顿法补上了缺失的“怎么做”。造好之后,本章马上把这个结果转成一课关于打包的功课:过程是黑盒,有约束变量和自由变量,而块结构让 good-enough? 和 improve 住在 sqrt 里面,既够不到用户,也够不到别的、正在写他们自己的近似例程的程序员。词法作用域随后完成这一步:因为 x 由 sqrt 自己约束,嵌套的辅助过程就坐在它的作用域里,把它当作自由变量引用,所以 x 不必作为额外的参数传来传去。
第二个展开:作为测量对象的计算过程。 本章现在把口气从“过程意味着什么”换成“过程代价几何”。斐波那契和换零钱给出演示:树形递归容易写,却指数级地浪费,因为朴素的 fib 在许多分支上重复计算同样的小情形,而最终落到 (fib 1) 或 (fib 0) 的叶子数,恰好是 Fib(n+1)——本身就是指数的。这种不对称是最难忘的部分:内存只需要保存沿着树往下的当前路径,所以空间只与 n 成正比地增长,而时间爆炸。迭代随后显得廉价,增长阶的记号(theta)提供了词汇,而逐次平方和欧几里得算法给出真正对数级的替代。这个序列以费马检验收尾,它把测量推进一个新的范畴:随机选一个 a < n,检查 aⁿ 模 n 是否等于 a,然后重复。只有失败是决定性的——卡迈克尔数对每一个 a 都满足这个同余式,所以通过什么也证明不了——但它们稀少得足以让检验保持实用,而有了不可欺骗的变体,每次独立抽取都会使被误导的概率相乘,所以一次通过胜过一半一半,两次胜过四分之三,足够多次试验可以把出错概率降到想要的低。这就界定了概率性算法,一类保证是统计性而非演绎性的方法。
最后的展开:抽象用到自己身上。 高阶过程是通过一个从反面出发的论证到来的。一门没有办法给“立方”命名的语言,仍然可以计算立方,却会迫使每个人停留在一次次乘法的层次上;反过来,一门不能把过程当作参数的语言,只能陈述一个模式的实例,永远陈述不了模式本身。sum 就是演示——给定一个项函数、一个取下一个下标的函数,以及上下界 a 和 b,它把 sum-cubes、sum-integers 和 pi-sum 收成三次调用,就像求和记号 sigma 表达一般的求和。本章随后把这个手段叠加起来:sum 变成 integral;lambda 和 let 处理掉有名字的辅助过程;half-interval 和 fixed-point 表达的是一般方法,而不是特定的计算;而 average-damp 和 deriv 靠“返回”过程越过了剩下的那条线——把 average-damp 用到 square 上,得到一个对 10 返回 55 的过程,而把 fixed-point 同平均阻尼复用,立刻就产生了 cube-root。
收束:为挣得的东西命名。 最后一个运动是把线索系在一起,而不是增加材料。牛顿法由 deriv 重建为一个不动点过程,然后两个平方根程序——本章前三分之一手工做出来的那个,和牛顿版本——都从同一个 fixed-point-of-transform 里落了出来,这是本章自己的证明:它一直是在抽象方法,而不只是计算。在此基础上,本章命名了一等公民地位及其四项权利:过程可以被命名、被当作参数传递、被当作结果返回、被存进数据结构(最后一项要到后面才演示)。Lisp 四项都给了;作者承认这使高效实现变复杂,并论证表达力偿还了这笔代价。这一章于是恰好结束在它开头的那个三元组所指向的地方:抽象的手段被转用在它们自己身上,使抽象不再只是被构造,而是被操作。
关键论点,以及支撑它们的原文
因为程序指挥的是一个没有人看得见的过程,程序员的核心技能,就是预见一个小小的错误会造成什么。
作者指出计算机之外的后果:计算机辅助设计程序里一个小小的 bug,可能以一架飞机或一座水坝的坍塌,或一个机器人毁了自己告终。这些案例是说明性的,不是测量得来的,但它们说明了一点:小错误的惩罚,与它的大小并不成比例。
“即使程序里很小的错误(通常称为 bug 或故障)也可能带来复杂而意想不到的后果。”
“Even small errors (usually called bugs or glitches) in programs can have complex and unanticipated consequences.”
Chapter 1 Building Abstractions with Procedures查看原文 →
任何真正有力的语言都提供同样的三样东西:基本表达式,组合它们的办法,以及对结果进行抽象的办法。
作者逐个演示:define,最简单的抽象手段,让一整个复合操作可以用一个名字来调用,如 circumference 代表 62.8318;而对一个嵌套表达式如 ( (+ 2 ( 4 6)) (+ 3 5 7)) 求值,意味着把一条求值规则用到四个组合式上,可以画成一棵值向上渗透的树。与定义过程相比,用 define 命名仍然只是有限的抽象,这就是为什么接下来是过程定义。
“每一种强大的语言,都有三种机制来完成这件事:”
“Every powerful language has three mechanisms for accomplishing this:”
§1.1 The Elements of Programming查看原文 →
代换模型是给读者思考用的脚手架,不是对解释器所做之事的报告——而且后来被取代是正常的,不是缺陷。
用代换解释过程应用之后,作者立刻加了限定:实际上形式参数是由局部环境来处理的,在第3章和第4章讨论,而一旦过程作用在可变数据上,这个模型就明说会垮掉。作者把这说成科学和工程中的标准做法:从有意简化的模型出发,期待自己会超越它们。
“代换的目的,是帮助我们思考过程的应用,而不是描述解释器真正怎样工作。”
“The purpose of the substitution is to help us think about procedure application, not to provide a description of how the interpreter really works.”
§1.1 The Elements of Programming查看原文 →
另外 3 个论点
计算过程与数学函数不是同一类东西,因为它必须真的是有效的。
作者拿教科书里平方根的定义同一个程序作对比:即使改写成伪 Lisp,数学定义完全合法,却几乎没有告诉你怎样去找一个根。这就是命令式对说明式的区分,它催生了牛顿法,后者补上了缺失的“怎么做”。
“但数学函数与计算机过程之间有一个重要的区别。过程必须是有效的。”
“But there is an important difference between mathematical functions and computer procedures. Procedures must be effective.”
§1.1 The Elements of Programming查看原文 →
让过程能接受过程作参数,使一门语言能陈述模式本身,而不只是它的实例。
作者先从反面论证:一门没有办法给“立方”命名的语言,仍然可以计算立方,却会迫使每个人停留在一次次乘法的层次上。反过来,一旦 sum 接受项函数和下一个下标的函数,连同上下界 a 和 b,sum-cubes、sum-integers 和 pi-sum 就都收成对这一个过程的调用,就像求和记号 sigma 表达一般的求和。
“我们的程序能够计算立方,但我们的语言将缺乏表达“立方”这个概念的能力。”
“Our programs would be able to compute cubes, but our language would lack the ability to express the concept of cubing.”
§1.3 Formulating Abstractions with Higher-Order Procedures查看原文 →
授予过程完整的一等公民地位,使抽象本身可以被操作,而实现上的代价是值得的。
作者一步步建到这里:复合过程已经使方法显式化,而高阶过程让这些方法可以被进一步处理——把 average-damp 用到 square 上,得到一个对 10 返回 55 的过程,而把 fixed-point 同平均阻尼复用,立刻产生 cube-root;两个平方根程序随后都从同一个 fixed-point-of-transform 里落出来。结尾那四项权利的清单(可被命名、可当作参数传递、可当作结果返回、可存进数据结构,最后一项到后面才演示),界定了 Lisp 授予的东西,据说它使高效实现复杂化,同时在表达力上得到回报。
“与 average-damp 一样,deriv 是一个接受过程为参数、返回过程为值的过程。”
“Like average-damp, deriv is a procedure that takes a procedure as argument and returns a procedure as value.”
§1.3 Formulating Abstractions with Higher-Order Procedures查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么内层辅助过程可以丢掉参数 x
把 good-enough? 和 improve 定义在 sqrt 内部,它们的名字就使用者够不到,也别的写类似近似例程的程序员够不到。因为 x 由 sqrt 自己约束,嵌套的定义就坐在 x 的作用域里,能把它当作自由变量引用,所以 x 不必作为额外的参数传来传去。
机制 2
为什么树形递归在时间上爆炸、在空间上却不
朴素的斐波那契过程沿着许多分支重复计算同样的小情形:最终落到 (fib 1) 或 (fib 0) 的叶子数,恰好是 Fib(n+1),它本身指数级增长,所以步数也是。相比之下,内存只需保存沿着树往下的当前路径,所以空间只与 n 成正比地增长。
机制 3
反复的随机试验怎样买来近乎确定
一个合数通过的单次试验,留下真实的疑问;但对于大多数 a 的选择都没有合数能欺骗的检验变体,每次独立的随机抽取都使被误导的概率相乘。一次已经给出好于一半一半的把握,两次好于四分之三,所以跑足够多次试验,就能把出错概率压到想要的低,而永远不会成为证明。
常见误读
代换就是解释器真正运行一次过程调用的方式,所以想象实参被文本地粘贴进过程体,是机器的准确图景。
代换是为了帮助读者推理过程应用;真实的实现是在局部环境里约束形式参数,而作者警告说,一旦过程作用在可变数据上,这个模型就不灵了——它是一个注定要被取代的第一模型。
“代换的目的,是帮助我们思考过程的应用,而不是描述解释器真正怎样工作。”
“The purpose of the substitution is to help us think about procedure application, not to provide a description of how the interpreter really works.”
§1.1 The Elements of Programming查看原文 →
一个数如果通过了费马检验,就证明了它是素数。
只有失败是决定性的。存在这样的合数,对每个小于 n 的 a 都满足这个同余式,所以通过并不能绝对地确定什么;这样的数极其稀少,这使检验保持实用,而没有合数能欺骗的变体,才使反复试验可以把出错概率压到想要的低。
“更准确地说,如果 n 在费马检验中曾经失败,我们就能确定 n 不是素数。”
“More precisely, if n ever fails the Fermat test, we can be certain that n is not prime.”
§1.2 Procedures and the Processes They Generate查看原文 →
第 1 章的核心概念
- 计算过程
- 计算机内部一个看不见、无形、却真实存在的实体,在程序的指引下,随着演化操作数据。
- 适用处: 它不是物质,也看不见,却做着实实在在的智力工作,并能影响物理世界。
- 抽象的手段
- 让复合元素可以被命名、并当作单一单元来处理的语言特性。
- 适用处: 仅仅 define 只给出有限的抽象手段;过程定义才是更有力的那一种。
- 组合式
- 一个带括号的表,表示过程应用,运算符在最左,其余是运算对象。
- 适用处: 像 (define x 3) 这样的定义不是组合式;它们是特殊形式。
- 环境
- 解释器里“名字-对象”对的存储,使符号有了含义。
- 适用处: 这里叫作全局环境,因为以后的计算会涉及好几个环境。
- 特殊形式
- 自带求值规则的一种表达式类型,在一般的组合式规则之外。
- 适用处: define 是迄今引入的唯一特殊形式;这类规则的集合加上一般规则,就构成这门语言的语法。
- 复合过程
- 一个有名字的复合操作,它的形式参数代表应用时提供的实参。
- 适用处: 使用起来,与解释器内置的基本过程没有区别。
另外 22 个概念
- 代换模型
- 应用一个复合过程,就是把它过程体里的每个形式参数换成对应的实参,然后求值。
- 适用处: 仅仅是第1章里的过程的思维辅助;到第3章处理可变数据时就失效。
- 应用序求值
- 先对运算符和运算对象求值,再应用——这是 Lisp 实际使用的顺序。
- 适用处: 对非法的值,可能与正则序不同,如习题1.5。
- 正则序求值
- 先把运算对象表达式完全展开到过程体里,到最后才归约。
- 适用处: 可能重复求值;只有对可用代换模型描述的、值合法的应用,才与应用序得到同样的值。
- 条件分析
- 通过检验谓词,在不同的动作之间选择,在 Lisp 里用 cond 或 if 写出。
- 适用处: if 限于恰好两种情形;如果没有谓词为真,cond 的值是未定义的。
- 说明式知识与命令式知识
- 描述一样东西“是什么”,对描述“怎么计算”它。
- 适用处: 数学偏说明式,计算机科学偏命令式;一个合法的定义仍可能不是一个过程。
- 过程抽象
- 把过程当作黑盒:你依赖它计算出什么,而不是怎么计算。
- 适用处: 只对返回的值成立;含义仍取决于自由变量的名字。
- 约束变量与自由变量
- 形式参数是约束的,在过程体内可以改名;其他名字是自由的。
- 适用处: 改名必须一致,且必须避免捕获一个自由的名字。
- 块结构
- 把辅助定义放在使用它们的过程里面,让它们的名字保持私有。
- 适用处: 只解决最简单的名字打包问题;内部定义必须放在过程体的最前面。
- 词法作用域
- 内层过程里的自由变量,从外围定义的约束中得到它的值。
- 适用处: 取决于过程是在哪里定义的,而不是在哪里调用的。
- 树形递归
- 一个过程每次调用时多次调用自己,所以计算过程像树一样分叉。
- 适用处: 对数而言是浪费的,但对层次结构的数据很自然。
- 增长的阶
- 对所需资源怎样随问题规模增长的粗略度量。
- 适用处: 只是粗略的描述;忽略常数因子。
- 逐次平方
- 通过对中间结果求平方,把指数折半,把线性的工作变成对数的工作。
- 适用处: 需要分奇偶两种情形来处理一般的指数。
- 费马检验
- 随机选一个小于 n 的 a,检查 a 的 n 次方模 n 的余数是否为 a。
- 适用处: 失败证明是合数;通过只是强有力的证据,而卡迈克尔数会漏过去。
- 概率性算法
- 答案只是大概率正确的算法,靠反复的随机试验把错误压低。
- 适用处: 需要证明大多数随机选择都能暴露出非素数;否则重复并不能给错误设界。
- 高阶过程
- 以别的过程为参数、或把过程作为值返回的过程。 接受别的过程为参数、或返回过程为值的过程。
- 适用处: 只有在同一个模式在不同的项之间反复出现时才需要;单靠数值参数抓不住它。 当一个模式在许多函数中反复出现时有用;一次性的计算不需要这种抽象。
- 求和抽象
- 把几个求和过程的共同部分变成参数,就像数学里的求和记号 sigma 那样。
- 适用处: 适用于各过程只在项函数和下一个值函数上不同的情形。
- lambda
- 一种特殊形式,创建一个过程而不在环境里给它起名字。
- 适用处: 除了命名之外,等价于 define;let 只是 lambda 应用的语法糖。
- 不动点
- 一个数 x,使 f(x) 等于 x,通过反复应用 f 直到值不再有多大变化而找到。
- 适用处: 反复应用不一定收敛;y 变为 x/y 只会来回振荡。
- 平均阻尼
- 把一个猜测同 f(猜测) 取平均,来减缓变化,帮助搜索收敛。
- 适用处: 有助于收敛,但不保证对每个函数都成立。
- 控制复杂性
- 选择能暴露可复用部分的表述,让思想本身变得清楚。 选择恰当的抽象层次,而不是尽可能抽象的层次。
- 适用处: 许多表述计算的是同一个过程;不同的是清晰度,而不是正确性。 抽象是一种判断,不是一个要最大化的目标。
- 一等公民地位
- 受到限制最少的元素:可以被命名、传递、返回,并存进数据里。
- 适用处: 这是语言设计的属性,不是哪一个过程的属性;Lisp 把它授予过程,付出了实现上的代价。
- fixed-point-of-transform
- 一个过程,接受一个函数、对它的一个变换和一个猜测,返回变换后函数的不动点。
- 适用处: 只能抓住形如“先变换,再找不动点”的方法。
第 1 章划线与批注AI 陪读
“实际上,我们是用咒语召唤计算机的精灵。”
“In effect, we conjure the spirits of the computer with our spells.”
查看原文 →一本以巫术而不是硬件开篇的教科书——这个比喻就是一个主张:编程是心灵的行为,不是机器的行为。
“像设计良好的汽车或核反应堆那样,是以模块化的方式设计的”
“like well-designed automobiles or nuclear reactors, are designed in a modular manner”
查看原文 →咒语的说法被悄悄换成了安全工程。三页里两种口气——而整本书就活在它们之间的缝里。
“Lisp 不是一次协调一致的设计努力的产物。”
“Lisp was not the product of a concerted design effort.”
查看原文 →引人注目的是,他们居然以此自夸。他们将要教的这份优雅是长出来的,不是规划出来的——这是对当时那些委员会设计的语言的一个狡黠的论证。
另外 17 条批注
“用 Lisp 编程非常好玩。”
“programming in Lisp is great fun.”
查看原文 →在一页关于效率和主流地位的防御性论证之后,真正的理由最后、也最平白地到来了。诚实。
“让我们人类感到困惑的,是像下面这样仍然相对简单的表达式”
“It is we humans who get confused by still relatively simple expressions such as”
查看原文 →极限不在机器。缩进在这里被点名为人类工作记忆的义肢,而不是风格规则。
“它所起的作用,就像代词在自然语言里所起的作用”
“which plays the same role that a pronoun plays in natural language”
查看原文 →我所知道的对形式参数最好的一句解释——绑定,用每个读者已经能流畅做到的事来说明。
“单看 sum-of-squares 的定义,是看不出来的”
“one could not tell by looking at the definition of sum-of-squares”
查看原文 →无从区分就是回报:抽象恰恰在你看不穿它的时候起作用。基本操作从来只是一个起点的约定。
“并不是要描述解释器真正怎样工作”
“not to provide a description of how the interpreter really works”
查看原文 →教科书很少递给你一个模型,同时就标上它是假的。靠预定的淘汰来教学。
“正则序求值可以是一个极有价值的工具”
“normal-order evaluation can be an extremely valuable tool”
查看原文 →他们以效率为由否决它,然后悄悄承认以后是不可或缺的。全书方法的缩影:没有东西被抛弃,只是被推迟。
“如果没有一个 <p> 被发现为真,这个 cond 的值就是未定义的。”
“If none of the <p>'s is found to be true, the value of the cond is undefined.”
查看原文 →注意第一个 abs 没有 else——三个子句碰巧覆盖了一切。一个有意脆弱的例子,放在他们展示更安全的版本之前。
“注意 and 和 or 是特殊形式,不是过程”
“Notice that and and or are special forms, not procedures”
查看原文 →短路不是优化,而是语义。求值参数的代价,已经成了含义的一部分——这正是习题1.5的主题。
“过程必须是有效的。”
“Procedures must be effective.”
查看原文 →三个词,承载了整章。数学可以指,代码必须走。
“这只是在回避问题。”
“This only begs the question.”
查看原文 →可爱的干巴巴的讽刺——给一个定义裹上括号,并不会让它变得可计算。Lisp 的语法不是 Lisp 的实质。
“说明了迭代可以不用任何特殊结构来实现”
“demonstrates how iteration can be accomplished using no special construct”
查看原文 →安静的夸耀:没有循环关键字,却有 C 的表达范围。重复从来不是一种控制结构,只是自指。
“平方是怎么算出来的这些细节,可以被抑制”
“The details of how the square is computed can be suppressed”
查看原文 →抽象被定义为获准的遗忘。一个程序规模的极限,就是你一次必须记在脑子里的东西的极限。
“如果我们把 guess 改名为 abs,就会因为捕获变量 abs 而引入一个错误”
“If we renamed guess to abs we would have introduced a bug by capturing the variable abs”
查看原文 →这个括号,正是不言自明的原则开始咬人的地方——只有在你按名字知道自己依赖的一切时,改名才是安全的。自由变量是一种未登记的依赖。
“其他过程(sqrt-iter、good-enough? 和 improve)只会塞满他们的脑子。”
“The other procedures (sqrt-iter, good-enough?, and improve) only clutter up their minds.”
查看原文 →命名是一种稀缺的公共资源——你的辅助函数在悄悄地向其他每个程序员征税。支持作用域的论证,先是社会的,然后才是技术的。
“不必把 x 显式地传给其中每一个过程”
“it is not necessary to pass x explicitly to each of these procedures”
查看原文 →优雅,但留意换掉了什么:那些辅助过程原本是可移植的黑盒,现在被封进了 sqrt 的上下文。用复用换了简洁。
“的确,在逻辑学和程序设计语义学的文献里,有一段很长的关于代换的错误定义的历史。”
“Indeed, there is a long history of erroneous definitions of substitution in the literature of logic and programming semantics.”
查看原文 →一条脚注悄悄承认,这本书核心的那个“简单”模型,是专家们栽过跟头的雷区。教学上的诚实,兼作一个警告。
“著名的逻辑学家在这里犯过令人难堪的错误。”
“Famous logicians have made embarrassing errors here.”
查看原文 →现在说了两遍,且带着兴味。作者们要学生感到:严格并不居高临下——最聪明的人恰恰在这里失过足。
读后感AI 陪读
悖论:抽象恰恰在你看不穿它的时候成功
第1章沿着一条长弧,从咒语走向工程,而它开头的两种口气——召唤计算机精灵的咒语,然后是“像设计良好的汽车或核反应堆”那样模块化建造的程序——不是文风上的失误,而是这一章把论题说了两遍。编程是心灵的行为;心灵很小;所以此后的一切,都是节省注意力的机器。这样读,这一章看似杂乱的内容(命名、递归、增长率、素性、lambda),就收束成一门课程:构造出内部你被允许忘掉的结构。
开头的框架——基本表达式、组合的手段、抽象的手段——看似一份整齐的分类,但它真正的作用,是让这三种机制成为递归的、没有尽头的。一旦 sum-of-squares 与基本操作无从区分,“基本”这个范畴就被揭穿为一个“你碰巧从哪里开始”的约定。这一章第一个真正的思想动作就是这次坍缩:抽象恰恰在你看不穿它的时候成功,而作者们坦率地说,回报是认知上的,而不是数学上的。缩进的引入,不是作为风格,而是作为一种义肢,因为“是我们人类会感到困惑”。形式参数被解释成一个代词——这是我知道的对绑定最好的一句话说明,用每个读者已有的能力,解释一个形式化装置。程序的极限被直截了当地点明,就是你一次必须记在脑子里的东西的极限。
继续阅读
然后是代换模型,以及这一章最不同寻常的教学姿态:一个模型,递过来的同时就被标上“假”——“并不是要描述解释器真正怎样工作”。靠预定的淘汰来教学。脚注加倍地强调,坦白关于代换有一段很长的错误定义的历史,著名的逻辑学家在这里犯过令人难堪的错误,说了两遍,且分明带着兴味。这不是谦虚的表演。这是在主张:严格并不居高临下,你觉得自己蠢的地方,正是专家们栽跟头的地方,而一条看上去正确的简单规则,可能是雷区。应用序对正则序,也是同样处理:先以效率为由否决,然后悄悄承认以后是不可或缺的。这本书里没有东西被抛弃;东西只是被推迟。这就是方法,微缩在这里清晰可见。
条件那一节藏着比它所宣称的更尖锐的一点。第一个 abs 是用三个 cond 子句写的,它们只是碰巧覆盖了所有情形,紧接在课文指出没被覆盖的 cond 是未定义之后——一个有意脆弱的例子,放在更安全的那个之前。而 and 和 or 被点明是特殊形式,不是过程,这是这一章不再只谈方便的地方:短路意味着求值一个参数的代价,已经进入了表达式的含义。一旦如此,你就不能仅凭一个程序的形状来推理它,而教训是:求值顺序是语义,不是优化。
牛顿法值得它的篇幅,因为它把知识劈成两半——说明式的(平方根是什么)对命令式的(怎么得到一个)——而那个三个词的裁决,“过程必须是有效的”,承载了整章。数学可以指,代码必须走。在一个裹着括号的定义旁,那句干巴巴的“这只是在回避问题”是同一把刀:Lisp 的语法不是 Lisp 的实质,而一个只是重述问题的递归规范,什么也计算不出来。同样,“迭代可以不用任何特殊结构来实现”是一句值得一听的夸耀——重复从来不是一种控制结构,只是从远处看到的自指。
块结构和词法作用域,是我最想让读者抵制这一章自身热情的地方。支持作用域的论证,是先在社会层面、后在技术层面提出的:你的辅助过程“只会塞满他们的脑子”,命名被当作一种稀缺的公共资源,每个私人过程都在悄悄地向它征税。有理。但第二步,让 x 保持自由,这样辅助过程就不必传它,换掉了一些真实的东西。那些辅助过程原本是可移植的黑盒;现在它们被封进了 sqrt 的上下文里。而关于把 guess 改名为 abs 就会捕获变量的那个括号,正是这条不言自明的原则咬人的地方:自由变量是一种未登记的依赖,所以只有在你已经按名字知道自己依赖的一切时,改名才是安全的。这一章把词法作用域说成纯粹的收益。它是一种权衡——用复用换简洁,又用一些易读性同时换这两样。
效率方面的材料(树形递归的斐波那契、换零钱、逐次平方、欧几里得)常被读作算法上的插曲。并不是。它是这一章对自己的反驳:抽象让你能像写一个线性过程那样顺手地写出一个指数过程,记号里没有任何东西警告你。增长的阶被引入,当作缺失的感觉器官——是看穿你被告知要忘掉的那层抽象的唯一办法。费马检验把这推到边缘,用确定性换速度,到达概率性算法,答案正确的概率是你选的,而卡迈克尔数则提醒我们:检验的保证是关于检验的,不是关于那个数的。
收尾的运动——sum 推广成积分,lambda 和 let 溶解了有名字的辅助过程,半区间搜索和不动点作为一般方法,average-damp 和 deriv 返回过程,牛顿法被重建为不动点,两种平方根被 fixed-point-of-transform 统一起来——是这一章在偿还它自己开头的前提。论证了抽象是获准的遗忘之后,它表明值得忘掉的,通常是方法而不是数据。最后给一等公民地位的四项权利命名,则揭示:前面的六十页,是对一个大多数读者从未注意到其存在的限制,确实是一种限制的演示。
我想对读者说的是:这一章很容易被误当作 Scheme 入门,而这样读它,回报很差。它的习题才是论证真正落地的地方,它的脚注承载了它一些最好的思考。把对自身会出错的承认读成指示,而不是道歉——递给你的模型在设计上就是暂时的,你是要超越它的。留意文本推销一项技术而略去代价的地方;这样的地方是有的,词法作用域首当其冲,而注意到它们,才是这一章真正在教的技能。还要认真对待这一点:这一切诚实的理由是最后、也最平白地到来的,在关于效率和主流地位的整整一页防御性论证之后:用 Lisp 编程非常好玩。一本这样严谨的书承认这个,不是口气上的失误。这是对“当初为什么有人要用咒语召唤精灵”的最终回答。
在 FeedLitra 里读第 1 章,每段旁边都有这些卡片。
开始读书第 2 章:构造数据抽象
如何只用过程、不用任何数据基本体,造出复合数据?
第 2 章是怎样展开的AI 辅助
这一章一开头就论证:复合数据之所以有价值,原因恰与复合过程相同——它提高了你思考和写作的层次。演示是有理数程序包:cons 把分子和分母粘在一起,一旦一个有理数可以作为一个对象传来传去,一个线性组合的例程就不必再亲手追踪哪个整数和哪个整数配对。由此,本章提炼出它的组织性想法,即“数据抽象”:构造函数和选择函数构成一道屏障,因为只有它们碰表示,格式的改变就只波及那几个过程,而建在它们之上的一切继续工作。开场还确定了本章的有效性标准——使一样东西成为数据表示的,是它的构造函数和选择函数所满足的“条件”(取回分子和分母必须重现这个比值),而不是它是用什么做的。
第一个展开:条件就够了,所以过程就足够
这个标准随后被一路压到序对本身。如果一个序对必须做的只是满足 cons/car/cdr 的恒等式,那么 cons 就可以直接返回一个记住它两个参数、并回应下标 0 和 1 的过程;代换验证了这些恒等式,而这个抽象的使用者分不出它同存储结构的区别。这是本章第一个真正的概念收获——表示被消解为行为——而区间算术给出了一个屏障要紧的演算例子。与它并行的是第二个收获,“闭包”性质:因为序对的各部分本身可以是序对,一种基本体就成了层次结构的通用建筑块。
展开继续:序列作为约定的接口
闭包产生表和树,本章现在为它们寻找合适的词汇。map 抽象出逐元素的处理,递归加上 map 处理树,然后 enumerate/filter/map/accumulate 把程序变成显式的信号流管道。这里的论证被小心地限定了:不管哪种办法,机器运行的是同一个计算过程;变的是程序员的框架。在普通的递归版奇数平方和里,枚举阶段被涂抹在基本情形检验和递归下降里,对应不上任何可辨认的部分。一旦这四个操作成了词汇,各阶段变得可见,各块成了标准部件——那个过程的片段,和一个偶数斐波那契过程的片段,可以自由重组,而嵌套循环通过 flatmap,成了“生成-过滤-映射”的管道,比如素数和对、排列、八皇后。
一次反转,还不是转折:数据对象作为过程
图形语言随后把先前的洞见倒着走一遍。画家“就是”一个接受框架的过程,所以 flip-vert 只需要把框架反转,beside 只需要把两个画家缩小到半个框架并调用每一个——两者都不知道任何一个画家是怎么画的。画家在 beside 和 below 下封闭,而因为这些操作是普通的 Scheme 过程,不需要特殊的组合机制:square-of-four 接受四个画家操作,返回另一个。框架变换把这推广为 transform-painter,这一节以“分层设计”收尾:每一层提供自己的基本元素、组合手段和抽象的手段,所以规格上的小改动只触及一个层。
符号插曲,以及作为效率所在地的表示
符号微分以相反的顺序重演开头的方法:deriv 先针对抽象的和、积与变量写好,然后才选定前缀表的表示,化简被限制在构造函数里。集合随后表明互补的一点——在一个固定的接口之后,效率是在表示里得到或失去的。无序的表给出线性的成员检验和平方级的交集;有序使平均检验减半,并且因为交集的每一步都从两个集合之一中丢掉一个元素,把交集化为两个大小之和;二叉树则达到对数级的搜索。收益被明确说是有条件的:按顺序把 1 到 7 一个个并入,得到的是每个左子树都为空的树,不比表好。霍夫曼编码树作为一个持续的例子,结束这段插曲——变长的前缀码,基于树的编码和解码,合并算法,以及按权重排序的集合。
转折:多重表示、表格,与层次的极限
真正的转折,出现在同一个对象有几种好的表示的时候。复数的直角形式便于加法,极坐标形式便于乘法,而在由许多人长期写成的系统里,没有哪种表示能事先商定。类型标志使得按表示分别设选择函数成为可能——没有标志,序对 (3,4) 的模长,在 5 和 3 之间含混不清——但靠显式条件的分派,迫使每个通用过程都知道每一种表示,每个名字都保持全局唯一,这无法扩展。补救办法是数据导向的程序设计:一张带 put 和 get 的二维“操作-类型”表,apply-generic 读标志、查一个格子、应用找到的东西,所以添一种表示,就意味着添加表项,而不是编辑代码。程序包按行分解这张表,消息传递按列分解它;带 scheme-number、有理数和复数程序包的通用算术,展示了两层标志的运作。
收束:强制、塔,和一个被承认的未决问题
跨类型的操作随后分三步升级——显式的成对过程,强制表,最后是带 raise 和 lower 的类型塔——而本章坦率地说,这座塔能成立,只因为每种类型恰好只有一个上级类型。在一种类型有几个上级的地方(一个等腰直角三角形,既在三角形之下,又在直角三角形之下),没有唯一的向上之路,而要关联这些类型就需要代价高昂的搜索。多项式给出了这套机制的最后一个案例,也反驳了自满:项表是通过抽象的构造函数和选择函数访问的,add-terms 和 mul-terms 调用的是“通用的” add 和 mul,所以这个程序包继承了任何系数类型,并以数据导向的方式递归,选用稀疏的有序表作为表示。本章结束时给出的不是一个解法,而是一个承认——多项式类型不能叠成一座塔,而强制一般来说仍然所知甚少——脚注则指向点值表示和拉格朗日插值、数的零次强制、欧几里得算法的欧几里得环论证、有理系数 GCD 的陷阱,以及齐佩尔的概率性方法。
关键论点,以及支撑它们的原文
复合数据之所以有价值,原因与复合过程相同:它提高了你能思考和写作的层次。
作者的有理数例子表明了另一种做法:让分子和分母作为松散的整数,迫使程序员亲手追踪哪个数同哪个数配对。随后用通用的 add 和 mul 重写一个线性组合例程,表明一旦参数可以作为单个对象传递,这个过程就不需要知道它们是怎么造出来的。
“为什么我们要在编程语言里有复合数据?理由与要有复合过程相同:提高我们设计程序的概念层次,增强设计的模块性,增强语言的表达能力。”
“Why do we want compound data in a programming language? For the same reasons that we want compound procedures: to elevate the conceptual level at which we can design our programs, to increase the modularity of our designs, and to enhance the expressive power of our language.”
Chapter 2 Building Abstractions with Data查看原文 →
接着,使一样东西成为合法的数据表示的,是它的构造函数和选择函数所满足的条件,而不是它是用什么做的。
作者先陈述有理数的要求——取回一个被构造出来的数的分子和分母,必须重现原来的比值——然后把同样的标准一路压到序对本身。一个返回过程、并回应 0 和 1 的 cons,经代换检验满足序对的条件,作者因而得出结论:这个抽象的使用者分不出它同建在真正存储上的序对的区别。
“”显然,并不是任意三个过程的集合,都能作为有理数实现的合适基础。”
“Clearly, not every arbitrary set of three procedures can serve as an appropriate basis for the rational-number implementation.”
§2.1 Introduction to Data Abstraction查看原文 →
标准的序列操作使程序的信号流计划变得可见,使它的各部分可复用。
作者论证:在普通的递归定义里,各阶段消融了:在奇数平方和过程里,枚举被分在空表和序对检验与递归下降之间,没有哪个部分对应任何一个阶段。一旦 enumerate、filter、map 和 accumulate 成了词汇,那个过程的各块,和一个偶数斐波那契过程的各块,就可以重新组合成全新的程序,而嵌套循环成了“生成-过滤-映射”的管道,如素数和对,以及由 flatmap 做出的排列。
“一般地说,这两个过程里都没有哪个不同的部分,与信号流描述中的元素相对应。”
“In general, there are no distinct parts of either procedure that correspond to the elements in the signal-flow description.”
§2.2 Hierarchical Data and the Closure Property查看原文 →
另外 3 个论点
反转通常的方向,让数据对象本身成为过程,白白买来了抽象屏障和闭包。
在图形语言里,画家是一个接受框架的过程,所以 flip-vert 只需要知道怎样反转一个框架,beside 只需要把两个画家缩小到半个框架,依次调用每一个——两者都不知道任何一个画家是怎么画的。而因为画家的操作是普通的 Scheme 过程,不需要特殊的抽象机制:square-of-four 只是接受四个画家操作,返回另一个。作者把这一点同一般的闭包联系起来,指出各部分本身是序对的序对,成了层次结构的通用建筑块。
“把画家表示为过程,在图形语言里竖起了一道强有力的抽象屏障。”
“Representing painters as procedures erects a powerful abstraction barrier in the picture language.”
§2.2 Hierarchical Data and the Closure Property查看原文 →
在一个固定的接口之后,选择什么表示,是效率得失之所在。
对集合,作者在同样的操作下比较三种表示:无序的表使成员检验要扫描全部,产生线性代价和平方级的交集;使表有序,使平均成员检验减半,并且因为交集的每一步都从两个集合之一中丢掉一个元素,把交集降到两个大小之和;二叉树则达到对数级的搜索。收益是有条件的——对数级的说法假定树是平衡的,而作者表明按顺序并入 1 到 7,得到的是每个左子树都为空的树,不比表好。
“这仍然是 (n) 的增长,但平均而言,确实使步数比前一种实现省了一半。”
“This is still (n) growth, but it does save us, on the average, a factor of 2 in number of steps over the previous implementation.”
§2.3 Symbolic Data查看原文 →
一旦一个对象有几种好的表示,单纯的数据抽象就不够了,这正是推动走向标志和数据导向表的原因。
复数有直角和极坐标两种形式,各自对不同的操作方便,而在由许多人长期写成的系统里,没有人能事先商定一种表示。给每个数据打上类型标志,使选择函数能按表示分别起作用——没有标志,序对 (3,4) 的模长在 5 和 3 之间含混不清。但靠显式条件的分派,迫使每个通用过程都知道每一种表示,所有名字都保持全局唯一,作者论证这在大型数据库的规模上是站不住的;一张带 put 和 get 的二维“操作-类型”表去掉了条件,这样添加一种表示,就只意味着添加表项。
“但这种数据抽象还不够强,因为对一个数据对象,并不总是适合谈论“底层的表示”。”
“But this kind of data abstraction is not yet powerful enough, because it may not always make sense to speak of ``the underlying representation'' for a data object.”
§2.4 Multiple Representations for Abstract Data查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么接口过程把变化局部化
当只有构造函数和选择函数碰表示时,其他每个过程都通过它们说话,对格式一无所知。所以表示的改变只能波及这条边界上的那几个过程,而建在它们之上的一切原封不动地继续工作。
机制 2
仅靠过程怎样就能充当序对
cons 返回一个记住 x 和 y、并回应选择函数下标的过程;car 把它应用到 0,cdr 应用到 1。代换表明所要求的 car/cdr-of-cons 恒等式成立,所以定义序对的条件得到满足,而始终不需要存储结构。
机制 3
为什么分派表使系统具有可加性
每种表示的过程,被安装在一张以操作名和参数类型作索引的表里;apply-generic 从数据上读出标志,查出那个格子,并应用查到的东西。因为通用过程只是查表,加一种新表示,就是写新的表项,而不是编辑现有的代码。
常见误读
用 map 重写一个表过程,使计算本身不同了——大概是更好或更快。
作者明确说,不管哪种办法,计算机执行的是同一个过程;变的是程序员的框架,从一步一步的 cons 和 cdr 递归,变成对整个序列的单一操作。回报是概念上和组织上的——各块成了可以重新组合的标准部件——而不是运行内容的改变。
“这两个定义的差别,不在于计算机执行了不同的计算过程(它没有),而在于我们对这个过程的想法不同了。”
“The difference between the two definitions is not that the computer is performing a different process (it isn't) but that we think about the process differently.”
§2.2 Hierarchical Data and the Closure Property查看原文 →
一旦你用 raise 和 lower 把类型叠成一座塔,对任何一组类型,跨类型操作就是已解决的问题。
塔之所以行得通,恰恰因为每种类型恰好只有一个上级类型,这使提升是唯一的,继承是一致的。在一种类型有几个上级的地方——作者的例子是一个等腰直角三角形,在多边形里既处在三角形之下,又处在直角三角形之下——没有单一的向上之路,而关联这些类型需要代价高昂的搜索;作者最后承认,多项式类型抗拒一座塔,而强制一般来说仍然所知甚少。
“如果所要求的操作没有直接为所给对象的类型定义,我们就把这个对象提升到它的上级类型,再试一次。”
“If the required operation is not directly defined for the type of the object given, we raise the object to its supertype and try again.”
§2.5 Systems with Generic Operations查看原文 →
第 2 章的核心概念
- 数据抽象
- 把程序构造成:使用一个复合数据对象的代码,对它是怎么构造的不做任何假设。 选择表示和抽象的选择函数,使系统的其余部分不受表示变化的影响。
- 适用处: 一旦程序的不同部分用不同方式表示同样的数据,单靠简单的数据抽象就不够了;通用操作需要更强的屏障。 只有当访问真的通过抽象的选择函数和构造函数时才行。
- 抽象屏障
- 一道层次的边界,一小组接口过程把数据抽象的使用者同它的实现者隔开。
- 适用处: 只有所有的操作都通过选定的操作进行时才成立;直接访问表示就会破坏它。
- 序对
- 由 cons 做成的唯一基本复合结构,各部分由 car 和 cdr 读出。
- 适用处: 序对可以任意嵌套,所以一种基本体就足以充当更大结构的粘合剂。
- 一厢情愿的想法
- 假定构造函数和选择函数已经存在,这样高层操作就可以在选定任何表示之前先写出来。 假定所需的选择函数和构造函数已经存在,这样你现在就可以写算法。
- 适用处: 只有当所假定的过程以后能满足所规定的条件时才有效。 只有在你以后真的实现了每一个假定的谓词、选择函数和构造函数时才安全。
- 数据即选择函数加条件
- 数据不只是随便三个过程返回的东西;构造函数和选择函数必须满足所述条件。
- 适用处: 任意的三个过程不构成一种表示。
- 数据的过程性表示
- 把序对表示为一个被要求时就返回其各部分的过程,而不是存储结构。 把数据对象表示为一个接受操作名并自己执行它的过程。
- 适用处: 就正确性而言是够用的,但真正的 Lisp 为了效率直接实现序对。 按这里的给法,它只允许单个参数的通用过程。
另外 31 个概念
- 闭包性质
- 一种组合操作,其结果可以再用同一个操作来组合。 组合两个对象得到同一种对象,所以组合可以无限制地嵌套。
- 适用处: 一般地适用于组合的手段,对过程和对数据都一样。 这里它适用于 beside/below 下的画家,就像它适用于 cons 和表结构一样。
- 区间算术
- 对可能取值的范围做运算,使结果带有已知的精度。
- 适用处: 代数上等价的表达式可能得到不同的区间,尤其当一个不确定的变量重复出现时。
- 作为抽象屏障的 map
- 用 map 隐藏了逐元素的递归,所以你想的是整张表变换成整张表。
- 适用处: 它改变的是我们怎么想,而不是机器计算什么。
- 约定的接口
- 约定每一级传递的都是表,这样标准部件就能自由混搭。
- 适用处: 取决于把结构统一地表示为序列。
- 信号流结构
- 把计算看成数据流过 enumerate、filter、map 和 accumulate 这些阶段。
- 适用处: 普通的递归定义可以算出同样的结果,同时把这种结构藏起来。
- 作为嵌套序列的树
- 一张其元素可以是表的表,靠对 car 和 cdr 递归处理,直到叶子。
- 适用处: 需要 pair? 谓词来区分叶子和子树。
- flatmap
- 把一个过程映射到一个序列上,然后把所有得出的序列并成一个扁平的序列。
- 适用处: 当每个元素产生一个结果序列时有用,如生成序对或排列。
- 画家
- 一个接受框架、画出一幅平移缩放以适合该框架的图像的过程。
- 适用处: 任何接受框架并在其中作画的过程都算;实现细节被隐藏。
- 分层设计
- 把系统构造成一叠层次,每层有自己的小语言,其各部分成为下一层的基本元素。
- 适用处: 当各层有稳定的词汇时有用;它描述的是结构,不是性能。
- 框架变换
- 把一个框架映射到子框架,并让旧画家在那里作画,从而做出一个新画家。
- 适用处: 单位正方形内的参数保持在原框架内;rotate90 只在框架是正方形时才是纯旋转。
- 稳健设计
- 想要的东西有小变化,程序就只需要相应小的改动。
- 适用处: 取决于选择与可能的变化相符的层次边界。
- 控制复杂性
- 固定一个接口,使变化保持局部,从而让程序保持可管理。 把表示藏在屏障之后,把一个大程序分成分别设计的几个部分。 在增加功能时保持模块边界不破,这里的功能是跨类型算术和符号代数。 即使关于类型和强制的未决问题,仍留下可用的原则,用来构造大型系统。
- 适用处: 当选择函数/构造函数完全覆盖算法所需时有效;不带括号的中缀记号会使它吃紧。 当不存在单一的底层表示时,单靠它不够。 对大量相互关联的类型会失效,这是当前许多研究的领域。 它提供的是构造的纪律,而不是一套完整的类型理论。
- 作为一束操作的集合
- 一个集合是由 union-set、intersection-set、element-of-set? 和 adjoin-set 定义的,而不是由它的存储定义的。
- 适用处: 任何表示都被允许,只要它遵守所述的解释。
- 集合操作的增长的阶
- 在每种表示下,步数怎样随集合大小而变。
- 适用处: 对数级的树搜索假定树是平衡的,而 adjoin-set 并不保证这一点。
- 前缀码
- 一种编码,其中没有哪个符号的完整编码是另一个符号编码的开头,所以边界不需要分隔符。
- 适用处: 只消除符号结束处的歧义;它本身并不使编码最短。
- 霍夫曼编码方法
- 反复合并两个权重最低的结点,建起一棵二叉编码树,把罕见的符号放在离根最远的地方。
- 适用处: 只有当信息的频率与建树所用的频率一致时才是最优的;这棵树不是唯一的。
- 类型标志
- 附在数据上、说明它该怎样处理的一个符号。
- 适用处: 要求每个生产者都打标志,每个消费者都剥标志。
- 通用过程
- 能处理以不止一种方式表示的数据的过程。
- 适用处: 靠显式分派,它们必须知道所有的表示。
- 数据导向的程序设计
- 在表里查操作和类型,而不是写死分派。 通过一张按操作名和参数类型作索引的显式表来组织通用操作。
- 适用处: 假定 put 和 get 存在;表本身要到第3章才构造。 要求数据上有标志,并有共享的表;程序包必须自己登记。
- 最小承诺原则
- 尽可能晚地选择具体的表示。
- 适用处: 永远推迟下去,就逼出一种打标志的纪律。
- 可加性
- 添加一个新部分,而不编辑现有的代码。 新程序包可以分别写成,并在不编辑现有代码的情况下组合。
- 适用处: 显式的类型分派通不过这个检验。 对添加表示是成立的;添加操作仍需要触动每个程序包。
- 两层标志系统
- 外层标志把一个数据送到某个程序包,内层标志在里面进一步引导它。
- 适用处: 当对象向下传递时,每一层标志由 contents 剥掉。
- 强制
- 把一种类型的对象变成另一种类型的等价对象,使单一类型的操作可以应用。
- 适用处: 不是每一对都能强制;不存在一般的 complex->scheme-number,有时两个参数都得转成第三种类型。
- 类型塔
- 一种层次结构,其中每种类型至多有一个上级类型和一个下级类型,所以提升是没有歧义的。
- 适用处: 几何图形有多个下级和上级类型,所以不存在唯一的提升方式。
- 作为语法形式的多项式
- 在第2章的代数系统里,多项式是一种选定的书写形式,而不是它的数学含义。
- 适用处: 限于同一个不定元的单变量多项式,并且只有加法和乘法。
- 数据导向的递归
- 因为系数也通过同样的通用操作分派,对一个复合对象的操作,会递归地对它的各部分调用自己,数据嵌套多深,就递归多深。
- 适用处: 只有当每个嵌套的层次都是通用程序包认识并为之安装了所需操作的类型时才行得通。
- 项表抽象
- 项和项表纯粹由构造函数和选择函数定义,所以算术代码从不依赖它们是怎么存储的。
- 适用处: 从高次到低次的顺序访问模式,限制了哪些具体表示是高效的。
- 稠密多项式与稀疏多项式
- 稠密多项式在大多数次数上有非零系数;稀疏的则大多是零,所以各自倾向一种不同的表表示。
- 适用处: 选定的稀疏表示是一个有意的赌注:大多数操作涉及的是稀疏多项式。
- 欧几里得环
- 一个带有加法、减法、可交换的乘法,以及一个在取余下会缩小的正整数大小度量的域,这恰恰是欧几里得算法能终止的原因。
- 适用处: 需要一个有效的度量:整数用绝对值,多项式用次数;没有这样的度量,算法就没有保证。
- 多项式的点值表示
- 用多项式在选定点上的值来存储它,所以多项式相加就是把对应的值相加。
- 适用处: 恢复系数需要 n 次多项式的 n + 1 个点,靠拉格朗日插值。
- 概率性算法
- 用随机性来用确定性换速度的方法,如齐佩尔的多项式 GCD 方法。
- 适用处: 结果以高概率成立,而不是有证明级别的确定性。
第 2 章划线与批注AI 陪读
“我们忘掉了这些符号代表什么”
“we forget about what the symbols stand for”
查看原文 →把外尔作为题词是一个大胆的主张:遗忘不是损失,而是力量。整章是对这一个动词的注解。
“这样的簿记细节会大大塞满程序,更不用说它们会对我们的头脑做什么”
“such bookkeeping details would clutter the programs substantially, to say nothing of what they would do to our minds”
查看原文 →真正的论证不是机器效率——而是认知负担。抽象被提出,当作心智的卫生。
“(add (mul a x) (mul b y)))”
查看原文 →隔了两行,+ 变成了 add,过程不再关心数是什么。这个小小的改名,就是整章弧线的缩影。
另外 17 条批注
“一种强有力的综合策略:一厢情愿的想法”
“a powerful strategy of synthesis: wishful thinking”
查看原文 →好名字——他们把“假设解已存在”合法化了。凭欠条设计,合同写在货物之前。
“单一的复合数据基本体——序对,由过程 cons、car 和 cdr 实现——是我们唯一需要的粘合剂。”
“The single compound-data primitive pair, implemented by the procedures cons, car, and cdr, is the only glue we need.”
查看原文 →一个非同寻常的朴素宣称。一个两槽的盒子,一切结构随之而来。值得怀疑——而他们会挣得这份信任。
“仅仅说“不管给定的选择函数和构造函数实现出什么都行”是不够的。”
“It is not enough to say ``whatever is implemented by the given selectors and constructors.'”
查看原文 →这里他们拒绝了廉价的回答,把数据做成一个公理系统:不是实体,而是被满足的义务。
“这种对过程的用法,与我们对数据应当是什么的直觉概念毫无相似之处。”
“This use of procedures corresponds to nothing like our intuitive notion of what data should be.”
查看原文 →他们承认这种怪异,而没有把它抹平——正是这份坦率,使分派的把戏成了启示,而不是花招。
“不是说我们的语言就是这样工作的”
“not that our language works this way”
查看原文 →从“做”到“可能做”的情态转移才是要点:可能性是教学的内容,效率只是现实不同的理由。
“他想用 3.5± 0.15 这样的区间,而不是 [3.35, 3.65]”
“he wants to work with intervals such as 3.5± 0.15 rather than [3.35, 3.65]”
查看原文 →同一个对象,两副面孔。这一节的全部要点,伪装成顾客的抱怨到来——数据原封不动,只有构造函数和选择函数变了。
“莱姆抱怨说,阿莉莎的程序对两种计算方式给出不同的答案。这是一个严重的抱怨。”
“Lem complains that Alyssa's program gives different answers for the two ways of computing. This is a serious complaint.”
查看原文 →一本正经。教科书悄悄把读者引着造出一个代数是坏的系统,直到此刻才承认。靠让你摔跤来教学。
“没有哪个代表不确定数的变量重复出现”
“no variable that represents an uncertain number is repeated”
查看原文 →关键:区间算术忘了 A 是“同一个” A。每次出现都被独立地重新取样,所以重复使误差膨胀。不是代码里的 bug——是表示里的 bug。
“闭包是任何组合手段中力量的关键”
“Closure is the key to power in any means of combination”
查看原文 →借自数学,又用到记号本身。第1章里关于组合式嵌套的一切,现在在低一层、在数据上重演。
“这两个定义的差别,不在于计算机执行了不同的计算过程(它没有),而在于我们对这个过程的想法不同了。”
“The difference between the two definitions is not that the computer is performing a different process (it isn't) but that we think about the process differently.”
查看原文 →这一节最诚实的一句话。抽象在这里没有从机器那里买到任何东西——它完全是给人类读者的礼物。
“以这种方式定义选择函数和构造函数是高效的”
“Defining selectors and constructors in this way is efficient”
查看原文 →然后他们拒绝这么做。一条脚注悄悄承认,这本书用速度换可检视性——一个审美选择,说得和任何技术选择一样平白。
“令人惊讶的是,这个想法很难严格地表述。”
“Surprisingly, this idea is very difficult to formulate rigorously.”
查看原文 →在几页关于“过程加条件”的自信的挥手之后,脚注坦白没有人真正知道怎么把它钉牢。严格性被推给了霍尔和代数学家。
“(define (scale-tree tree factor) (map (lambda (sub-tree)”
查看原文 →第二个版本悄悄揭示:树“就是”一个序列,其元素本身可以是树——递归藏在 map 的 lambda 里,而不是在 car/cdr 的拆分中。
“一般地说,这两个过程里都没有哪个不同的部分,与信号流描述中的元素相对应。”
“In general, there are no distinct parts of either procedure that correspond to the elements in the signal-flow description.”
查看原文 →这才是真正的控诉:代码能运行,却不能被“读”成自己的计划。SICP 的伦理——程序应当暴露自己的结构。
“信号处理工程师会觉得很自然地把这些过程设想为”
“A signal-processing engineer would find it natural to conceptualize these processes”
查看原文 →借来工程师的直觉作论证。留意被偷运进来的主张:正确的分解,就是别的学科已经觉得自然的那一种。
“(accumulate cons nil (list 1 2 3 4 5))”
查看原文 →可爱的随手一笔:用 cons 对 nil 做累积,就是恒等。构造不过是把吃下去的东西重建出来的那个折叠。
“当我们把结构统一地表示为序列时,就把对数据结构的依赖局部化了”
“when we uniformly represent structures as sequences, we have localized the data-structure dependencies”
查看原文 →这里的隐藏成本从未被标价——enumerate-tree 建了一张整个儿的中间表,而递归版本什么也没建。用分配买来的清晰;账单在第3章的流里到期。
读后感AI 陪读
代码的可见性是这里最尖锐的伦理
第2章是为一个动词作的一场持续的论证,而外尔的题词给出了它:忘掉。忘掉符号代表什么,不是损失而是力量,而从 cons 到多项式 GCD 的一切,都是对这个主张的注解。本章分四个运动推进。第一,复合数据:序对作为唯一的粘合剂,有理数作为演算的例子,抽象屏障作为纪律,而“一厢情愿的想法”被合法化为一种设计方法——先写合同,再假设货物到手。然后这道屏障被测试到崩溃:序对原来可以只用过程来定义,作者们承认这“与我们对数据应当是什么的直觉概念毫无相似之处”,而一条脚注承认没有人能把这个想法严格地形式化。数据成了不是实体、而是被满足的义务;从“做”到“可能做”的情态转移,就是全部的教学内容。第二,闭包和约定的接口:cons 是封闭的,所以表和树随之而来,而 map 加 accumulate 把不透明的递归变成可读的信号流管道。这里浮现的伦理,是本章最尖锐的——程序应当暴露自己的计划,而能运行、却不能被读成自己结构的代码,是被控诉的。图形语言随后把这一切用“过程即数据”重演一遍,以分层设计收尾,每一层都带着自己的基本元素、组合手段和抽象。第三,是被比较而不是被断言的各种表示:在选定任何表之前,就针对抽象的和写出的符号微分,价格为 Θ(n²)、Θ(n)、Θ(log n) 的集合,由合并建起的霍夫曼树。第四,是多样性:类型标志,经过 put/get 表的数据导向分派,消息传递,通用算术,最后是诚实的崩溃——多项式拒绝加入的强制塔,以及结尾的承认:这一点仍然所知甚少。我最欣赏的,是这一章愿意让你摔跤。阿莉莎的区间程序被造出来、用起来,然后才被莱姆的抱怨打破,而 bug 不在代码里,而在表示里:区间算术记不住 A 就是同一个 A,所以每次重复出现都被独立地重新取样,误差膨胀。从这个陷阱里学到的,比从任何定理里都多。有两种诚实应当同样看重。作者们直说,重构后的过程并不让机器做什么不同的事——“我们对计算过程的想法不同了”——所以这里的抽象是认知上的卫生,是给读者的礼物,不是加速。而一条脚注承认,直接的选择函数会更快,却仍拒绝使用:用速度换来可检视性,一个审美选择,说得和技术选择一样平白。没被标价的,是反过来的账单。统一的序列把对数据结构的依赖局部化了,但 enumerate-tree 分配了整整一张中间表,而原来的递归什么也没分配。清晰的代价是分配,而第3章的流,就是这张发票到期的地方。我想对读者说的是:不要略过作者们让系统崩溃的那些习题,也不要把类型塔读成一个已解决的问题——它是这一章写到一半就签字离开,而这正是其中最有启发的东西。
在 FeedLitra 里读第 2 章,每段旁边都有这些卡片。
开始读书第 3 章:模块化、对象和状态
流的观点是怎样避开同一性问题的?
第 3 章是怎样展开的AI 辅助
第3章一开头就承认前面所讲的有一个局限:光靠抽象,并不能告诉你怎样安排一个程序的布局。强大的过程和复合数据让你造出大东西,却没说接缝该设在哪里,于是本章动身去寻找模块性的“组织原则”。它提出的策略是模仿——把程序构造得像它所建模的系统,这样扩展这个世界,就意味着在代码里加一个对应物,而不是整个重写。试金石是银行账户:能不能取出100美元,取决于全部交易的历史,被压缩成一个当前余额,所以程序的自然单位,就是带着这个余额的对象。从这一个例子出发,本章宣布了它将用余下篇幅展开的两种对立的世界观——一个有状态对象的世界,和一个流的世界——并立刻警告说,第一种是有代价的:代换模型必须放弃,换成环境模型。
它是怎样展开的
第一个运动把这笔代价详细地兑现。赋值买来局部状态,但变量不再命名一个值,而成了对一个可变位置的引用,前面几章里简单的推理就垮了:把 balance 代换进 make-simplified-withdraw,得出错误的答案,因为两处出现再也无法区分。本章用一个成对的比较使这种损失更尖锐——由相同文本写出的两个函数式过程是可互换的,由相同文本写出的两个取款器却不是——所以相同的文本不再意味着相同的含义。随之而来的后果是:在命令式代码里,赋值的“顺序”成了含义的一部分。命令式的阶乘碰巧把两个 set! 放对了顺序;把它们对调,答案就错了,而函数式的版本压根没有可以对调的顺序。诊断了问题之后,本章给出替代的机制:两条规则,一次调用据此建立一个被过程对象所指向的环境所包围的框架,而一个 lambda 把代码同它的出生地配成一对。这恰恰解释了为什么每次 make-withdraw 调用都留下自己私有的 balance,以及为什么两个取款器从不互相干扰。
第二个运动把赋值带进数据本身。有了 set-car! 和 set-cdr!,共享不再是一个看不见的实现细节,而成了可观察的:z1 和 z2 打印出来一样,同一个改变过程却从一个产生 ((wow b) wow b),从另一个产生 ((wow b) a b),因为在 z1 里 car 和 cdr 是同一个序对;eq? 直接暴露出这个区别。本章小心地坚持:没有任何新东西被偷运进来——用带内部 set! 的过程做成的序对,自己提供 set-car! 和 set-cdr!,所以承认赋值,就已经承认了可变数据的一切困难。可变序对随后作为工程回报了自己:带头尾指针的常数时间队列,以及作为带头表的表格,一维和二维的,包装成局部对象。
接下来是两个较大的模拟,它们标志着对象观点内部的一次安静的转移。数字电路模拟器彻头彻尾是有方向的:导线是带动作过程的可变的、传递消息的对象,门安排延时的信号变化,而一个由按时间排序的段构成的议程,驱动传播。随后的约束网络用同样的材料造成——连接器和基本约束,作为带局部状态的过程性对象——却抛弃了有特权的方向。本章论证,要点不在算术,而在没有输入和输出:普通的面积过程无法倒着运行,而一个摄氏-华氏网络,当告诉它 C 是 25 时回答 77,而在这个值被撤回之后,掉转过来,当告诉它 F 是 212 时给出 C = 100。要保持诚实,需要关于理由的簿记:一个连接器只在提供它的那一方要求时才撤回它的值,所以撤回 C 使 F 因为没有理由而放弃 77,而在 C 仍在时断言 F,只会引起矛盾。
转折在哪里
真正的转折随时间而来。一旦并发进程共享可变状态,赋值的隐藏结构就要紧了:它读、算、写,所以第二次读可能在第一次写落地之前插进来,从100美元里取10美元和25美元,可能留下75美元。本章没有禁止同时的改变——正确,却浪费,因为给一个私人账户存钱,没有理由要等一个共享的账户——而是采纳较弱的标准:结果必须与“某种”顺序执行相符,这意味着几种不同的结果可能都是正确的。建立在互斥元上的串行化器强制执行这一点,而这个判据是有牙齿的:把两个并行过程串行化,把 x 的可能值从五个减少到 101 或 121。但本章拒绝那个令人安心的结论:每个对象各设串行化器就能使并发安全。它们一次只能处理一个资源;一个跨两个账户的交换,仍可能在一方计算差额、另一方改变账户时被破坏,而通常的补救——输出串行化器——故意破坏了对象的模块性。在互斥元之下是原子的 test-and-set!;在它们之上潜伏着死锁,两个人朝相反方向交换 a1 和 a2,永远等下去,靠按固定的编号顺序获取资源来缓解,有时还需要彻底的检测和恢复。又因为共享状态在流水线式或分布式系统里甚至没有良好定义,并发里的时间就归结为通信——这恰恰是流的观点所需要的开口。
它是怎样收尾的
最后一个运动,不用赋值重建变化。流是一个序对,其余部分是一个未求值的承诺,所以每个阶段只在下一个阶段要求时才产生一个元素:在 10,000 和 1,000,000 之间找一个素数,只检验到 10,009 为止的数,而不是造出一个百万元素的表。delay 和 force 原来不过是 lambda 加记忆化——而记忆化不是优化,而是必需,因为同一个承诺被反复强制,递归的流程序否则会无休止地重算。由此,本章收获了无穷的 integers、fibs,埃拉托色尼筛法,隐式的自指定义,级数的欧拉加速和表格加速,交错的无穷序对流,以及信号处理里的反馈回路,在那里被积函数的延时,使 solve 能把结系在自己身上。然后它拒绝宣布胜利。显式的 delay 很别扭,把世界分成普通过程和期待承诺的过程,所以像 integral 这样的东西的每个调用者,都得知道这个约定——可是通过正则序求值使延时普遍化更糟,因为可变性与延时求值在程序设计语言里,不知道能否良好结合。流甚至可以取代局部状态,如蒙特卡罗估计 π;但把真正独立的主体的请求流合并起来,实时顺序的问题就原封不动地回来了。本章有意不作了结地收尾:两种世界观,各自用另一方付不起的代价买来模块性,谁也吸收不了谁。
关键论点,以及支撑它们的原文
程序的布局应当镜像它所建模的系统的结构,这样扩展这个系统就只意味着在代码里加上新的对应物。
作者的试金石是银行账户:能不能取出100美元,取决于全部交易的历史,被总结成一个当前余额,所以自然的程序部件,是带着这个余额的对象——镜像真实的账户,而不是横切它。
“一种强大的设计策略——特别适合构造物理系统建模的程序——是让我们程序的结构,以被建模的系统的结构为基础。”
“One powerful design strategy, which is particularly appropriate to the construction of programs for modeling physical systems, is to base the structure of our programs on the structure of the system being modeled.”
Chapter 3 Modularity, Objects, and State查看原文 →
用赋值来买这种模块性,要付出根本性的代价:变量不再是一个值的名字,而成了对一个可以变化的位置的引用,这就破坏了前面几章里简单的推理。
把 balance 的值代换进 make-simplified-withdraw,会得出错误的答案,因为 balance 的两处出现再也无法区分。作者用一个成对的比较使之更尖锐:在函数式的情形,由相同表达式定义的两个过程是可互换的,但以同样方式造出的两个取款器却不是,因为对其中一个的调用只影响它自己的 balance。相同的文本不再意味着相同的含义。
“现在,变量以某种方式指向一个可以存放值的地方,存放在这个地方的值是可以改变的。”
“Now a variable somehow refers to a place where a value can be stored, and the value stored at this place can change.”
§3.1 Assignment and Local State查看原文 →
由这一损失而来,命令式代码使赋值的顺序成了程序含义的一部分,产生了一类在函数式代码里根本不会出现的 bug。
在命令式的阶乘版本里,两条 set! 碰巧顺序放对了;对调它们,结果就错了。函数式的版本里没有任何可以对调的顺序。作者指出,一旦几个进程同时运行,这个负担只会变得更重。
“一般地说,带赋值的程序设计迫使我们仔细考虑各个赋值的相对顺序,以确保每条语句使用的都是已被改变的变量的正确版本。”
“In general, programming with assignment forces us to carefully consider the relative orders of the assignments to make sure that each statement is using the correct version of the variables that have been changed.”
§3.1 Assignment and Local State查看原文 →
另外 3 个论点
一旦数据可以被改变,共享就不再是一个隐藏的实现细节,而成了程序实际可以观察到的东西。
两个结构 z1 和 z2 打印出来一样,同一个改变过程却把其中一个变成 ((wow b) wow b),把另一个变成 ((wow b) a b),因为在 z1 里 car 和 cdr 是同一个序对。eq? 通过检验两个表达式是否命名同一个对象,直接暴露了这一点。作者补充说,没有偷运任何新东西进来:用带内部 set! 的过程做成的序对,自己就提供 set-car! 和 set-cdr!,所以承认赋值,就已经承认了可变数据的一切问题。
“虽然 z1 和 z2 是“同样的”结构,对它们应用 set-to-wow!,却产生不同的结果。”
“Even though z1 and z2 are ``the same'' structure, applying set-to-wow! to them yields different results.”
§3.3 Modeling with Mutable Data查看原文 →
从单向的计算转开,作者论证:使约束系统与众不同的,不是它们的算术,而是它们没有固定的输入输出方向。
一个普通的面积过程,无法倒着运行去找出它是由什么量算出来的。相反,一个表达摄氏-华氏关系的约束网络,当被告知 C 是 25 时回答 77,后来摄氏值被撤回之后,掉转过来,当告诉它 F 是 212 时给出 C = 100——同一个网络,没有享特权的方向。为使这一点保持诚实,一个连接器只在提供它的那一方要求时才丢掉一个值;当用户撤回 C,这个丢失就会传播,F 因为理由不在了而放弃 77,而在 C 仍在时断言 F,只会引起矛盾。
“因此,计算面积 A 的过程,不能用来计算挠度 d,尽管 A 和 d 的计算出自同一个方程。”
“Thus, a procedure for computing the area A could not be used to compute the deflection d, even though the computations of A and d arise from the same equation.”
§3.3 Modeling with Mutable Data查看原文 →
对并发来说,正确性的恰当标准,不是禁止一切同时的变化,而是要求结果与某种顺序执行相符——这意味着几种不同的结果可以都是正确的。
禁止对共享状态的一切并发改变,会是正确的,却是浪费的:没有理由要一笔对私人账户的存款不能和一笔对共享账户的取款同时进行。这个较弱的判据是有牙齿的——把两个并行过程串行化,把 x 的可能最终值从五个减少到 101 或 121——而作者指出,对顺序无所谓的算法,可以用得更少。
“这既低效,又过分保守。”
“This would be both inefficient and overly conservative.”
§3.4 Concurrency: Time Is of the Essence查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么每个对象保有自己的状态
由 lambda 做成的过程带着一个指针,指向它被创建时的环境,而每次调用都建立一个新框架,其外围是那个环境,而不是全局环境。所以对像 make-withdraw 这样的构造过程的每次调用,都留下它自己的框架,带着它自己的 balance 约束,而返回的过程里的 set! 只触及那个私有的槽——这就是为什么由相同代码造出的两个取款器从不互相干扰。
机制 2
交错如何使钱丢失
一次赋值不是一个不可分的动作:它读变量、计算,然后写回。当两个进程触及同一个账户,第二次读可能在第一次写落地之前插进来,于是一次更新覆盖了另一次——从100美元里取10美元和25美元,可能留下75美元。串行化器强制各部分好像按某种顺序运行,从而排除这种交错。
机制 3
只计算被要求的东西
流是一个序对,其余部分是一个未求值的承诺,所以每个阶段只在下一个阶段要求时才产生一个元素。因此在 10,000 和 1,000,000 之间找一个素数,只检验到 10,009 为止的数,而不是造出一个百万元素的表。因为同一个承诺常常被反复强制,它必须记住它的第一个结果,否则递归的流程序会无休止地重算。
常见误读
给每个共享对象都包一个它自己的串行化器,并发的程序就安全了。
每个对象各设的串行化器,一次只处理一个资源;一个跨两个账户的操作,仍可能在一方计算差额、另一方在中间改变账户时被破坏,而通常的补救——输出串行化器——故意破坏了对象的模块性。就算串行化器正确,也允许死锁:一个人拿 a1 同 a2 交换,另一个人拿 a2 同 a1 交换,会让两人永远等下去。按固定的编号顺序获取资源有所帮助,不过有些情形仍然需要检测和恢复。
“42 为了行为正确,我们必须安排好 exchange 过程,使它在交换的整个期间内,排斥对这些账户的任何其他并发访问。”
“For correct behavior, we must arrange for the exchange procedure to lock out any other concurrent accesses to the accounts during the entire time of the exchange.”
§3.4 Concurrency: Time Is of the Essence查看原文 →
既然延时求值使流变得强大,干净的办法就是默认延迟每一个过程的参数。
显式的 delay 本来就别扭——它把世界分成普通过程和期待延时参数的过程,所以调用 integral 之类的每个东西,都得知道这个约定——而使延时普遍化更糟,因为可变性和延时求值在程序设计语言里,不知道能否良好结合。本章收尾时,对象的观点和流的观点仍然没有调和,而不是一方吸收了另一方。
“实际上,我们造出了两类过程:普通过程,和接受延时参数的过程。”
“In effect, we have created two classes of procedures: ordinary procedures and procedures that take delayed arguments.”
§3.5 Streams查看原文 →
第 3 章的核心概念
- 模块性
- 把系统构造得能自然地分成一致的几个部分,各部分可以分别建造和维护。
- 适用处: 这是组织的目标,单靠抽象并不能保证。
- 局部状态
- 一个对象的行为取决于它的历史时,它就有状态;状态变量概括了这段历史。 每个连接器和约束,都是一个封闭了私有变量、按消息分派的过程。
- 适用处: 只有当状态变量聚成松耦合的子系统时才有用。 第3章表明,这种建模能力的代价,是引用透明性,并且迫使时间进入模型。
- 代换模型
- 先前把过程应用理解为用值替换参数的看法;赋值会破坏它。 把符号当作值的名字,于是应用一个过程,就意味着在文本上用实参替换形参。
- 适用处: 一旦有了 set! 就不够用了;由环境模型取代。 一旦 set! 让一个变量的值在求值过程中改变,它就失效。
- 引用透明性
- 在表达式里,相等的东西可以互相代换而不改变其值。
- 适用处: 一旦 set! 进入语言,就被违反。
- 函数式程序设计
- 不用赋值的程序设计,所以同一个调用、同样的参数,总得到同样的结果。
- 适用处: 与依赖赋值的命令式程序设计相对。
- 命令式程序设计
- 大量使用赋值去更新变量的程序设计。
- 适用处: 引来函数式代码里没有的赋值顺序方面的 bug。
另外 29 个概念
- 变化下的同一性
- 一个可变的复合对象不只是它的各部分;即使它的状态变了,它也保有一个同一性。
- 适用处: 只有对象可被修改时才要紧;不可变数据,就只是它各部分的总和。
- 环境
- 在新的求值模型里,存放变量可变的值的那个“位置”。 一串框架;每个框架约束一些名字,并指向一个外围框架,查找在那里继续。
- 适用处: 这里只是作为一个承诺引入;在第3章的3.2节才充分展开。 描述的是名字的查找和修改,但没有描述返回值是怎样传递的,也没有描述尾递归的空间行为。
- 块结构
- 放在一个过程里面的定义,所以辅助过程的名字活在该过程运行时创建的框架里。
- 适用处: 只是保护名字不受外界影响,并共享参数;并不使辅助过程在别处可见。
- 可变数据对象
- 同时提供改变函数的数据抽象,改变函数是改变一个现有对象、而不是新造一个的操作。
- 适用处: 需要来自赋值的变化模型;脱离的结构和共享的部分都可能成为隐患。
- 序对的共享
- 不同的结构指向同一个序对,而不是指向相等的副本。
- 适用处: 仅用 cons/car/cdr 时看不见;只有在可以使用变更或 eq? 时才要紧。
- 变更即赋值
- 可变数据除了 set! 之外不需要新的基本操作:带局部状态的过程性序对,给出 set-car!/set-cdr!。
- 适用处: 这是关于表达能力的理论归约,不是关于实现效率的主张。
- 队列
- 一个先进先出的序列,作为头尾指针构成的 cons 保存,这样插入不需要扫描。
- 适用处: 尾指针的把戏依赖于改变函数;普通的表给出 Θ(n) 的插入。
- 带头表格
- 一张键-值记录的表,建在一条主干表上,由一个哑的 table 序对领头,给出一个固定的位置供修改。
- 适用处: 查找用 equal? 扫描记录,所以是无序表的表示,对大表很慢。
- 电路语言
- 把模拟器当作一门语言:门是基本元素,连线是组合,有名字的连线模式是抽象。
- 适用处: 这是对模拟器的一个设计视角,不是另一个独立的解释器。
- 可变对象
- 像导线这样的对象,其局部状态变量靠赋值变化,而同一性保持不变。
- 适用处: 需要赋值和局部状态;共享的导线使变化对每个相连的设备都可见。
- 议程
- 一张按时间排序的、要运行的动作的日程,保存着模拟的时间,驱动整个模拟。
- 适用处: 段按时间递增排序;每一段里的过程先进先出地运行。
- 约束网络
- 持有值的连接器,由基本约束连在一起,所以任何一个未知量都可以从其他量算出。
- 适用处: 只有当一个约束有足够多已知的连接器来决定一个值时才行得通。
- 非定向的约束传播
- 一个网络可以解出任何一个未知的连接器,而不是沿一个固定的方向从输入跑到输出。
- 适用处: 只有当一个约束可以从其他端点用代数方法恢复出每个端点时才行得通;比如乘法器,遇到零因子就短路。
- 提供者
- 连接器记得是谁设置了它的值,所以只有同一个设置者才被允许撤回它。
- 适用处: 提供者根本不必是一个约束;它可以是用户。
- 并发
- 把系统的各部分建模为同时演化的进程,符合世界看起来在行动的样子。
- 适用处: 得到模块性和可能的加速,但赋值加并发,使关于时间的推理更困难。
- 串行化
- 把过程分成组,使组里一次只有一个能运行,阻塞别的,直到它结束。
- 适用处: 对单个共享资源容易;对多个则危险,如交换的例子所示。
- 互斥元
- 一个基本的、有两个操作的对象,可获取、可释放,用来构造串行化器。
- 适用处: 一个保存真/假的忙等待单元;获取它会阻塞别人,直到释放。
- 共享状态变量
- 几个并发进程都读写的一个变量,所以每个进程的假设都可能被推翻。
- 适用处: 当顺序无关紧要时无害,如扩散模拟。
- 原子的 test-and-set!
- 对一个标志的检验加上它的设置,没有别的进程能插进来。
- 适用处: 它的实现取决于机器:在单处理器上禁用时间片,或硬件的原子指令。
- 死锁
- 两个进程各占着一个资源,并永远等待对方的资源。
- 适用处: 给资源编号,只有当一个进程事先知道所有资源时,才能修复交换的情形。
- 流
- 惰性构造的序列,所以构造与消费交错进行。
- 适用处: 作为数据抽象,流与表相等;不同的只是求值的时间,而流的办法有它自己的困难。
- 延时求值
- delay 返回一个承诺;force 稍后兑现它。 流管道的每一个阶段,只运行到下一个阶段要求它运行的程度。 delay 把一个表达式包成一个无参数的过程;force 调用它来得到值。 把包在 delay 里的参数传下去,使一个过程能在参数尚未定义之前,就产生它的第一个输出。
- 适用处: 表示很大或无穷的序列所必需;cons-stream 不过是带延时 cdr 的 cons。 只是把事件的顺序同程序结构分开;并不去掉最终被要求的工作的代价。 没有记忆化,反复强制同一个对象就会重复计算,并可能使递归的流程序爆炸。 迫使这类过程的每个调用者,都得知道延时,把过程分成两类。
- 无穷流
- 没有尽头的流,之所以可用,是因为只检视有限的前缀。
- 适用处: 自指的定义只在已经生成了足够多的流、能产生下一个元素时才行得通。
- 欧拉变换
- 对交错级数的部分和流作的一种变换,产生一个收敛更快的序列。
- 适用处: 专门对交错级数的部分和效果好。
- 表格(tableau)
- 一个由流构成的流,其中每一行是前一行的变换;取每一行的第一项,得到超加速的结果。
- 适用处: 要求变换可以反复应用到它自己的输出上。
- 交错
- 交替地从两个流中取元素,这样即使两个都是无穷的,每个流的每个元素也最终都能到达。
- 适用处: 保证最终可达,而不是按大小或权重给出任何有用的顺序。
- 正则序求值
- 延迟每一个参数,只在真正需要时才强制它。
- 适用处: 对纯流处理很优雅,但会破坏其正确性取决于事件顺序的程序。
- 时间的函数式观点
- 把一个变化的量表示成它相继状态的流,这样时间是显式的数据,而不是执行顺序。
- 适用处: 当必须把来自独立主体的流合并时,无法确定顺序。
- 合并
- 把两个请求流交错成一个;它不是其输入的确定性函数。
- 适用处: 任何可接受的交错,都必须尊重参与者所感知的实际时间。
第 3 章划线与批注AI 陪读
“变得越多,越还是那回事。”
“Plus ça change, plus c'est la même chose.”
查看原文 →两条题词对变化说的话相反——而这一章将把自己耗在这道缺口上:当一样东西变化时,什么保持不变。
“我们组织一个大型程序的方式,是由我们对所要建模的系统的认识决定的”
“the way we organize a large program is dictated by our perception of the system to be modeled”
查看原文 →留意是“认识”,不是“结构”。作者们悄悄承认,这个模型不是现实给定的——它是对世界观的一种选择。这就是为什么是两种策略,而不是一个正确的。
“如果一个对象的行为受它的历史影响,就说这个对象“有状态”。”
“An object is said to ``have state'' if its behavior is influenced by its history.”
查看原文 →一个干净的操作性定义——状态就是压缩了的历史。银行余额是对每一笔交易的有损摘要,只因为它足以回答下一个问题才被保留。
另外 17 条批注
“注意,表达式 (withdraw 25) 求值两次,产生的值不同。”
“Observe that the expression (withdraw 25), evaluated twice, yields different values.”
查看原文 →第1到2章的全部天真,死在这一行里。到此为止,过程还是数学函数;现在它是一件有过去的东西。
“我们在此刻技术上无法理解,为什么 new-withdraw 过程的行为如上面所宣称的那样”
“we technically have at this point no way to understand why the new-withdraw procedure behaves as claimed above”
查看原文 →令人惊讶的诚实:他们交付了能用的代码,同时承认自己还不能解释它。大多数教科书把债藏起来;这里这张欠条就是教学。
“没有哪个具有“好的”数学性质的简单模型,能成为一个恰当的框架”
“no simple model with ``nice'' mathematical properties can be an adequate framework”
查看原文 →这才是赋值真正的账单——不是 bug,而是推理能力的丧失。我们用数学买来了模块性。
“随机数生成器的内部在往程序的其他部分漏出来这一事实”
“The fact that the random-number generator's insides are leaking out into other parts of the program”
查看原文 →这个论证的力量在于:显式状态的版本没有错,只是被污染了:一次实验的形状感染了一般方法。隐藏状态是一道封印,不是作弊。
“现在,变量以某种方式指向一个可以存放值的地方”
“Now a variable somehow refers to a place where a value can be stored”
查看原文 →名字成了地址。“以某种方式”这个词在干很多活——他们在指向内存,同时又暂时拒绝说出这个词。
“没有某个先验的“相同”概念,我们就无法确定“变化”;而不观察变化的效果,我们又无法确定相同”
“we cannot determine ``change'' without some a priori notion of ``sameness,'' and we cannot determine sameness without observing the effects of change”
查看原文 →一个没有出口的圆圈——作者们并不试图打破它。一本编程教科书悄悄承认自己撞上了形而上学。
“这种复杂性不是我们的程序设计语言造成的,而是我们把银行账户看作一个对象的认识造成的。”
“This complication is a consequence, not of our programming language, but of our perception of a bank account as an object.”
查看原文 →这一段里最尖锐的一步:乱不是 set! 的错,而是我们把世界看成对象的错。赋值是一个建模选择的症状。
“我们可以有两个不同的银行账户,具有相同的状态信息”
“we could have two different bank accounts with the same state information”
查看原文 →同一性超出了内容。有理数在它的各部分变化时死去;账户却活下来。这种不对称就是整章。
“本会产生一个不同的、不正确的结果”
“would have produced a different, incorrect result”
查看原文 →留意他们从不说它“为什么”错——读者得在脑子里运行一遍。一个有意设的小陷阱,呼应着这个主题。
“讽刺的是,程序设计入门最常以高度命令式的风格来教”
“it is ironic that introductory programming is most often taught in a highly imperative style”
查看原文 →埋在脚注里,却是一篇论战:初学者被先递了最难的模型,还被告知那是容易的。
“程序设计语言里的表达式,本身并没有任何含义”
“expressions in a programming language do not, in themselves, have any meaning”
查看原文 →说得平淡,仿佛毫无争议。含义总是随上下文的——连 + 也是。环境不是簿记,而是语义。
“变量必须以某种方式指明一个可以存放值的“位置”
“a variable must somehow designate a ``place'' in which values can be stored”
查看原文 →“位置”这个词承担了重活——名字不再指向值,而开始指向位置。此后的一切,都出自这一个让步。
“这个框架的外围环境不是全局环境,而是环境 E1”
“this frame has as its enclosing environment not the global environment, but rather the environment E1”
查看原文 →回报在这里:局部状态不是魔法,只是一个没人扔掉的框架。闭包不用这个词就被解释清楚了。
“我们看到,E1 充当了“位置”,保存着过程对象 W1 的局部状态变量”
“We see that E1 serves as the ``place'' that holds the local state variable for the procedure object W1”
查看原文 →状态被去神秘化为地理——一个对象,不过是一个没人扔掉的框架。“位置”上的引号,承认了这个比喻已经变得多么物理。
“W1 和 W2 有同样的代码:make-withdraw 体中的 lambda 表达式所指定的代码”
“W1 and W2 have the same code: the code specified by the lambda expression in the body of make-withdraw”
查看原文 →共享的代码,分开的环境——整个对象理论,一句话说完,早在有人为面向对象教科书付钱的几十年前。
“这也意味着 define 可以用来改变值,由此引出了不显式使用 set! 的赋值问题”
“it also means that define can be used to change values, and this brings up the issues of assignment without explicitly using set!”
查看原文 →一条脚注悄悄承认本章的防火墙漏了:他们花了好几页让赋值显得危险,而 define 一直在偷运它。
“一般地说,如果我们只用 cons、car 和 cdr 来操作表,共享是完全察觉不到的”
“In general, sharing is completely undetectable if we operate on lists using only cons, car, and cdr”
查看原文 →可变性不创造共享,只是揭示它。这些结构一直是纠缠在一起的;set-car! 只是把灯打开。
读后感AI 陪读
“相同”与“变化”坍缩成一个没有出口的圆圈
第3章是 SICP 失去天真的一章,而它是有意安排这次失去的。两条题词——变化是最深的真理,变化是让一切保持不变的东西——框住了整条弧线:一旦我们承认了有状态的对象,问题就不再是怎么计算,而是当某样东西变化时,什么保持同一。开头的那个让步是要抓住的:我们组织一个大型程序的方式,是由我们对所要建模的系统的“认识”决定的。不是它的结构。模块性是对世界观的一种选择,这就是为什么我们得到的是两种策略,而不是一个正确的,也是为什么本章在没有调和中收场,而不是得胜。
展开是一份冗长而诚实的核算。赋值是作为礼物到来的:(withdraw 25) 两次返回不同的答案,一行之间,一个过程不再是函数,而成了一件有过去的东西。状态被干净利落地定义为压缩了的历史——余额是对每一笔交易的有损摘要,保留它,是因为它能回答下一个问题。但代价马上被逐项列出:变量成了位置,引用透明性失效,“相同”和“变化”坍缩成一个没有出口的圆圈,代换模型死去。作者们承认他们还无法解释刚刚交付的代码,这不是书里的缺口;这就是教学——这张欠条就是课程。而真正的账单,正如他们直接说的,不是 bug,而是推理能力的丧失。我们用数学买来了模块性。
继续阅读
环境模型用地理来还这笔债。表达式本身没有含义;一个其外围环境是 lambda 创建处那个环境的框架,就是局部状态从来的全部。W1 和 W2 共享代码,只在环境上不同——整套对象理论,不用“闭包”这个词,早在有人卖面向对象教科书的几十年前就陈述出来了。然后是可变序对:两个打印出来一样、行为却不同的结构,共享一直就在那里,set-car! 只是把它照亮。从这里,机制以惊人的经济性向外扩展——队列,表格,一个其议程“就是”模拟时间的数字电路模拟器,以及传播可朝任何方向进行、连接器可以忘记的约束网络。同一个基本体,赋值,被表明足以应付每一种,这恰恰是批评可信的原因:本章先让这东西能用,才挣得了抱怨的权利。
然后是并发,那里账单第二次到期,而且更大。正确性必须被重新定义为与“某种”顺序执行相符——一个惊人地弱的标准,也是诚实的那一种。串行化器和 test-and-set! 在局部恢复了顺序,却重新引入死锁,而在流水线式或分布式的系统里,我们原本在推理的那个共享状态,干脆不再存在了。并发里的时间归结为通信。对象的世界观,在这里明显超出了它的保修期。
流的这一半是反提案,真是美:delay 和 force 不过是一个 lambda 加记忆化,却换来了无穷的整数、筛法、自指的定义、加速的级数,以及必须延迟被积函数才能把结系上的反馈回路。没有赋值的状态——蒙特卡罗的重写证明这是办得到的。但本章拒绝推销它。正则序求值会让延时统一,却与可变性冲突;而一旦两个独立主体的请求流必须合并,实时的顺序就重新出现,而函数式的观点恰恰需要它本来想避开的那个东西。
我最尖锐的判断是:本章的论题不是“赋值是危险的”。而是这团乱麻属于我们,不属于 set!——正如他们谈银行账户时所说,这种复杂性不是我们的程序设计语言造成的,而是我们的认识造成的。同一性超出内容:两个余额相同的账户不是同一个账户,而当一个有理数的各部分变了,它就死了。这种不对称就是整章。有两个细节印证作者们知道自己的防火墙是漏的——那条承认 define 一直在偷运赋值的脚注,以及那段埋着的论战:用高度命令式的风格教初学者是讽刺的事,他们最难的模型被先交给初学者,还说那是容易的。
我想对读者说的是:不要把它读成技术之旅,也不要带着找胜者的心去读。流不是答案,对象也不是恶棍;每种世界观都用不同的货币付钱来买模块性——对象付出的是丧失的推理,流付出的是丧失的实时——而本章收尾时,两边的账单都没有付清。把这些机制读得仔细到足以实现它们,然后去读脚注和那些犹豫,诚实就住在那里。本章长久的礼物是一个你该带进你所造的每个系统里的问题:我把什么叫作同一个东西?为了继续这样叫它,我同意忘掉了什么?
在 FeedLitra 里读第 3 章,每段旁边都有这些卡片。
开始读书第 4 章:元语言抽象
如果 eval 的 cond 分派挡住了新表达式类型,数据导向的 eval(习题4.3)能换来什么?
第 4 章是怎样展开的AI 辅助
开场。 这一章是靠放宽框架开始的,而不是引入一项技术。它的主张是:没有哪门固定的程序设计语言——包括 Lisp——会在问题变难时一直够用,而它所想到的不足,是适配上的,而不是能力上的。给出的类比是电气工程师,并排用着两套分层的记号,电路作为电阻和晶体管构成的网络,同样的系统又作为信号处理模块,因为单靠哪一种记号,都无法同时兼顾物理的视角和功能的视角。提出的补救是“元语言抽象”:当语言不再适配问题,就发明一门新的,并用一个求值器实现它。而在这里,本章种下了它余下篇幅都要去兑现的想法——决定表达式含义的求值器,本身不过是另一个程序,一个普通的 Lisp 过程。这单单一个观察,就把程序员从语言使用者变成语言设计者,因为赋予含义的机制,就在人已经在写的那些程序的空间之内。随之而来一个更强的推论:几乎任何像样的程序,都可以读成某种语言的求值器,而书重新审视了它自己前面的系统——多项式操作器、数字逻辑模拟器、约束传播器——表明每一个都有真正的基本元素、组合手段和抽象手段。这个检验并不平凡;仅仅接受输入,并不使一个系统成为一门语言。开场随后预告了要来的四个求值器:元循环的、惰性的、非确定性的,和逻辑的。
第一个展开:元循环求值器。 本章用 Lisp 建一个 Lisp 求值器,而构造的组织围绕着保持核心小巧。像 cond 和 let 这样的派生表达式,没有它们自己的求值规则;它们在求值之前被改写成 if 和 lambda 形式,所以一个多子句的 cond 成了嵌套的 if,在任何持有多个动作的分支外包一层 begin。因为翻译先发生,求值器只需要给小小的核心集合以显式规则,而添加便利的语法对它毫无代价。内部数据结构随后确定什么是真、什么是过程、什么是环境——变量-值的表构成的框架;基本过程被约束在一个全局环境里;一个读-求值-打印循环驱动全局。这个求值器被有意剥得很简,把错误处理和调试都略去了。回报是概念上的:既然程序是对一台抽象机器的描述,喂给求值器这样一份描述,它就“成了”那台机器。阶乘被读成一台由递减、乘法和相等检验部件、一个开关,以及它自己的一份副本构成的机器。作者设想了一个能把自己重新配置成任何别的电路的通用电路,来使这一点更尖锐——复杂得几乎无法想象——这就是为什么一个能放进一个短程序的通用求值器,是了不起的。它也是语言与其自身数据之间的桥梁,因为它所消费的程序,是用户的。
两项改进,然后是第一次转折。 离开元循环求值器之前,本章先修复,再调优它。依次的 define 悄悄破坏了块结构所许诺的同时作用域;把内部定义扫描出来,改成一个带 set! 的 let,恢复了它。然后是一次性能上的分离,带来了持久的结构后果:普通的求值器每过一遍,都要重新判断自己看的是哪种表达式,所以一个循环或递归,会一遍遍回答同样的语法问题。把分析同执行分开,使分析产生一个执行过程,只付一次语法的代价——lambda 体在过程创建时被分析,此后每次应用只运行结果。脚注结束这一节(宏、环境、通用性、停机定理),本章迎来第一次真正的转折:转向惰性求值。正则序把参数的求值推迟到需要时,用被强制并记忆化的 thunk 实现。收益是具体的:在普通的 Scheme 下,一个在不被使用的参数位置上带 (/ 1 0) 的过程调用会出错,而用延时的参数,它直接返回 1,因为那个参数从不被计算。像 unless 这样的过程成了可写的,非严格的 cons 成为可能——这正是表和流合并的地方,因为你可以不计算元素就取一个表的长度,而普通的表操作开始作用在无穷表上,延时被统一起来。
第二次转折:非确定性计算。 本章随后改变语言,比惰性深得多。amb 让一个表达式有几个可能的值,而求值器靠按时间顺序的回溯在它们之间搜索。区别在于种类:程序员现在陈述要求,而机制隐藏了选择是怎么做出的:prime-sum-pair 看上去像是对它自己问题的重述——从每个表里取一个元素,坚持要求和是素数——却能运行,先打印出 (3 20),要求时再给出 (3 110) 和 (8 35),然后报告什么也不剩了。正如惰性隐藏了延时和强制,这隐藏了选择。本章把 amb 用于自然语言解析,语法规则作为作用于全局 *unparsed* 的过程,并指出一句话可能有几种合法的解析。实现建立在分析求值器之上:每个表达式被交付两条后续路径,一条是带着一个值走的成功延续,一条是尝试死掉时走的失败延续。死去的分支调用失败路径,去试下一个备选;amb 自己的“试下一个”循环,和驱动程序的 try-again,都复用同一条第二路径;而赋值把自己包在一条失败路径里,先恢复旧值,所以撤退不留残余。
第三次转折:逻辑程序设计与查询系统。 本章最后也是最长的一个运动,从“怎么做”转向关系性的“是什么”。它先从用户一侧介绍查询语言——带 ?变量 的简单模式,复合的组合手段 and、or、not 和 lisp-value,以及作为这门语言抽象手段的规则,用 lives-near、wheel、outranked-by 和 append-to-form 加以说明。然后解释机制:查询作用在框架的流上,各组合手段过滤并扩展这些流;合一把模式匹配推广,使规则的结论能被匹配;而规则的应用原来与 eval/apply 相映。关键的是,本章坦率地说了逻辑的表面在哪里漏了。子句的顺序没有逻辑含义,却决定会发生什么:一条对称的 married 规则会永远循环,而因为否定只过滤那些变量已被约束的框架,把 not 子句放在最前面,会丢掉那唯一的空框架,查询什么也不返回——哪种顺序更便宜,则取决于数据的相对大小。实现一节随后提供了机制:以数据导向风格分派特殊形式的 qeval,把断言匹配同规则应用合并起来的 simple-query,靠唯一的规则应用编号实现的变量重命名,守护合一的 depends-on?,交错的延时流,被索引的数据库,把 ?x 改写成 (? x) 的语法过程,以及作为约束表的框架。
收束。 本章结束在它自己的局限上,而不是一场胜利。习题推动着往效率、not 的正确性,以及通过非确定性求值或环境重新设计系统去。结尾的脚注与此一脉相承:失败即否定被标出需要恰当的引用;把一个变量同含有它自己的表达式合一,被表明要求一个可能不存在的不动点,被比作对发散级数的非法操作;而 ?x 记号被承认原本可以交给读入宏处理,只是为清晰起见写了出来。本章于是结束在它开始的地方,封闭世界假设和自指上——它已经证明,四种相当不同的语言,都是作为对同一个可访问的 Lisp 程序的修改而得到的,其中最远的一种,仍复用了它的基本结构。
关键论点,以及支撑它们的原文
没有哪门固定的程序设计语言,包括 Lisp,会在问题变难时一直够用,所以程序员总得迁移到新语言。
作者指出电气工程师,他们并排用着两套分层的记号——电路作为电阻和晶体管构成的网络,以及同样的系统作为信号处理模块——因为没有哪一种记号能同时兼顾物理的和功能的视角。要点是同问题领域的适配,而不是计算能力。
“然而,随着我们面对越来越复杂的问题,我们会发现,Lisp,或者说任何固定的编程语言,都不足以满足我们的需要。”
“However, as we confront increasingly complex problems, we will find that Lisp, or indeed any fixed programming language, is not sufficient for our needs.”
Chapter 4 Metalinguistic Abstraction查看原文 →
这里最深的想法是:决定表达式含义的求值器,本身不过是另一个程序,这把程序员从语言使用者变成了语言设计者。
推理是:编程的特别之处,在于既让你发明一门新语言,又让你建造赋予它含义的机制;所以赋予含义的部分,就在你已经在写的那些程序的空间之内。所展示的求值器被有意剥得很简,略去了错误处理和调试。
“求值器,也就是决定编程语言中表达式含义的东西,不过是另一个程序。”
“The evaluator, which determines the meaning of expressions in a programming language, is just another program.”
Chapter 4 Metalinguistic Abstraction查看原文 →
从这一重新框定,得出一个更强的主张:几乎任何像样的程序,都可以读成某种语言的求值器,所以构造大系统和构造语言是同一门手艺。
书自己前面的系统被重新审视——多项式操作器、数字逻辑模拟器、约束传播器——每一个原来都有真正的基本元素、组合手段和抽象手段。这个检验要紧:仅仅接受输入,并不使一个系统成为一门语言。
“事实上,我们几乎可以把任何程序都看作某种语言的求值器。”
“In fact, we can regard almost any program as the evaluator for some language.”
Chapter 4 Metalinguistic Abstraction查看原文 →
另外 3 个论点
既然程序是对一台抽象机器的描述,求值器就是一台通用机器,也是语言与其自身数据之间的桥梁。
阶乘程序被读成一台由递减、乘法和相等检验部件、一个开关,以及它自己的一份副本构成的机器;求值器被喂给这样一份描述,就模仿它。作者设想了一个能把自己重新配置成任何别的电路的通用电路,来使这一点更尖锐——复杂得几乎无法想象——这就是为什么一个通用求值器只是一个短程序,是了不起的。
“求值器另一个引人注目的方面是,它充当了我们的程序设计语言所操作的数据对象,和程序设计语言本身之间的桥梁。”
“Another striking aspect of the evaluator is that it acts as a bridge between the data objects that are manipulated by our programming language and the programming language itself.”
§4.1 The Metacircular Evaluator查看原文 →
把参数推迟到需要时,使过程即使在某个参数会出错或永不终止时,也能完成有用的工作。
在普通的 Scheme 下,用 (/ 1 0) 作为一个不被使用的参数去调用过程,会出错;用延时的参数,它返回 1,因为那个参数从不被计算。让 cons 成为非严格的,同样让你不计算元素就能取一个表的长度,而这恰恰是流被吸收进普通表的办法。
“惰性求值的一个好处是,有些过程,如 unless,即使对它们某些参数的求值会产生错误或不终止,也能做有用的计算。”
“An advantage of lazy evaluation is that some procedures, such as unless, can do useful computation even if evaluation of some of their arguments would produce errors or would not terminate.”
§4.2 Variations on a Scheme -- Lazy Evaluation查看原文 →
把自动搜索建进求值器,对语言的改变,比惰性深得多,因为程序员陈述要求,而机制隐藏了选择是怎么做出的。
prime-sum-pair 看上去只是重述了问题——从每个表里选一个元素,坚持要求和是素数——却是一个能运行的程序。驱动循环打印出第一个答案 (3 20),再次询问时产生 (3 110) 和 (8 35),然后报告什么也不剩了;正如惰性隐藏了延时和强制,这隐藏了选择。
“这是对语言的一个比 4.2 节引入惰性求值深刻得多的改变。”
“This is a much more profound change to the language than the introduction of lazy evaluation in section 4.2.”
§4.3 Variations on a Scheme -- Nondeterministic Computing查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
改写形式让求值器变小
有些结构没有它们自己的求值规则;它们先被翻译成更基本的结构——一个多子句的 cond 成了嵌套的 if,在多动作的分支外包一层 begin。因为翻译发生在求值之前,求值器只需要给小小的核心集合以显式规则,所以添加便利的语法对它毫无代价。
机制 2
分析一次,运行多次
普通的求值器每过一遍都重新判断自己看的是哪种表达式,所以一个循环或递归,每次调用都重新回答同样的语法问题。把工作拆开,让分析产生一个执行过程,这笔代价每个表达式只付一次;lambda 体在过程造出时被分析,此后每次应用只运行结果。
机制 3
靠会撤销的延续来搜索
每个表达式被交付两条后续路径:一条带着值走,一条在尝试死掉时走。失败的分支只是调用失败路径,去试下一个备选,而驱动循环复用同一条第二路径来索取更多答案。赋值把自己包在一条失败路径里,先恢复旧值,所以撤退不留残余。
常见误读
Lisp 对难题一定太弱,所以你才需要抛下它,改用别的语言。
不足是对一个领域的表达适配,而不是计算能力;恰恰因为求值器是一个普通的、可访问的 Lisp 程序,正则序的、非确定性的和逻辑的语言,才能作为对那一个程序的修改而得到——其中最远的一个,仍复用了它的基本结构。
“然而,随着我们面对越来越复杂的问题,我们会发现,Lisp,或者说任何固定的编程语言,都不足以满足我们的需要。”
“However, as we confront increasingly complex problems, we will find that Lisp, or indeed any fixed programming language, is not sufficient for our needs.”
Chapter 4 Metalinguistic Abstraction查看原文 →
既然逻辑规则只是蕴涵,你写一个查询里各子句的顺序,就不会影响答案。
顺序没有逻辑含义,却决定实际会发生什么:一条对称的 married 规则让系统陷入无限循环,而因为否定只过滤那些变量已被约束的框架,把 not 子句放在最前面,会丢掉那唯一的空框架,整个查询什么也不返回。哪种顺序更便宜,还取决于所涉数据的相对大小。
“如果一家公司的主管比程序员多得多(通常的情形),用第一种形式就比第二种好,因为对 and 的第一个子句产生的每个中间结果(框架),数据库都必须扫描一遍。”
“If a company has many more supervisors than programmers (the usual case), it is better to use the first form rather than the second because the data base must be scanned for each intermediate result (frame) produced by the first clause of the and.”
§4.4 Logic Programming查看原文 →
第 4 章的核心概念
- 元语言抽象
- 从编写程序上升一层,去设计、构造程序所用的语言。
- 适用处: 在所有工程中都要紧,但只有在编程中,新语言才也能被执行,因为你可以为它构造一个求值器。
- 求值器(解释器)
- 一个普通的过程,接受一门语言的表达式,并执行赋予该表达式含义的任何动作。
- 适用处: 求值器确定表达式的含义,所以改变求值规则,就意味着编辑一个程序,而不是等待一门新语言;它不是程序世界之外的特权制品。
- 元循环求值器
- 用某门语言本身写成的、该语言的求值器——这里是用 Lisp 写成的 Lisp 求值器。
- 适用处: 这种循环只是表面的:求值是一个过程,而 Lisp 是本书描述过程的工具,所以这个定义是有根基的,而不是空洞的。
- eval/apply 循环
- 这个两步的循环:环境中的表达式归约为应用于实参的过程,后者又归约为新环境里的新表达式。
- 适用处: 这种回归只在环境中查到的符号,和直接应用的基本过程处终止。
- 通过数据抽象得到的抽象语法
- 通过谓词和选择函数来检验和拆解表达式,而不是把它们的表表示硬接线到求值器里。
- 适用处: 它使求值器与表面记号解耦,但除非把分派做成数据导向的,eval 的 cond 分派仍然固定了存在哪些表达式类型。
- 派生表达式
- 通过翻译成别的特殊形式来实现、而不是直接求值的特殊形式。
- 适用处: 只对其含义能完全由现有形式表达的形式成立,如 cond 通过嵌套的 if,let 通过 lambda 应用。
另外 31 个概念
- 环境
- 一张框架的表,每个框架把一张约束变量表同一张它们的值的表配对,向外搜索直到找到变量。
- 适用处: 这种表示在概念上简单,但慢;生产用的 Lisp 不会采用它,因为变量查找主宰了性能。
- 通用机器
- 一台以另一台机器的描述为输入、然后行为恰如所描述的那台机器的机器。 任何求值器都能模仿任何别的求值器,所以可计算性与语言无关。
- 适用处: 引人注目是因为,与之对应的通用电路会复杂得难以想象,而程序求值器却保持简单。 只有在忽略时间和内存代价时才成立。
- 数据即程序
- 同一个对象,对用户是程序,对求值器是普通的表,所以程序可以像别的数据一样被计算。
- 适用处: 把这个区别塌缩,是有意的、有用的,许多方言暴露 eval,使程序可以对构造出的数据求值。
- 块结构
- 内部的 define,其名字在整个外围过程体内可见,而不只是在 define 之后。
- 适用处: 依次求值只在定义放在最前、且它们的值表达式不用任何被定义变量时,才模仿得出来。
- 扫描出定义
- 一种语法变换:先在一个 let 里把所有内部变量创建为 unassigned,再用 set! 给它们赋值。
- 适用处: 多加了一层环境框架,并需要在变量查找里检查 unassigned。
- 执行过程
- 由 analyze 产生的、只接受一个环境并完成求值的过程。
- 适用处: 变量查找仍必须等到执行时,因为它取决于环境。
- letrec
- let 的一个变体,其初值表达式是在一个已含有它所有约束的环境里求值的,所以约束可以递归。
- 适用处: 可以作为派生表达式通过 let 加 set! 来实现;普通 let 不支持互递归。
- 正则序求值
- 参数表达式在其值真正被需要之前不被求值。
- 适用处: 这里只有复合过程是非严格的;基本过程保持严格。
- thunk
- 一个延时的参数,打包为它的表达式加上它所来自的环境。
- 适用处: thunk 必须带上环境,否则那个表达式以后无法求值。
- 严格性
- 一个过程体是在参数求值之前还是之后被进入。
- 适用处: 纯应用序的语言对每个参数都是严格的。
- 惰性表
- car 和 cdr 都被延时的表,所以无穷表可以配合普通的表过程使用。
- 适用处: 需要 cons 是非严格的;在驱动循环里读入的带引号的表,不是惰性序对。
- 数据的过程性表示
- 把序对表示为一个把选择函数应用到它两个部分上的过程,所以不需要基本的 cons。
- 适用处: 只在求值器不需要从结构上识别序对(如为了打印)的地方行得通。
- 非确定性计算
- 一种范型:程序陈述选择和要求,求值器搜索满足这些要求的选择。
- 适用处: 搜索是系统的深度优先加回溯,不是并行的,也不是随机的。
- 按时间顺序的回溯
- 失败时,回到最近的选择点,试它的下一个备选。
- 适用处: 可能卡在探索一个无穷的第一分支里,如无界的整数范围。
- 解析自然语言
- 把输入的词与语法规则相匹配,每条规则一个过程。
- 适用处: 只对这里给的玩具词表和语法行得通;依赖从左到右地对操作数求值。
- 延续过程
- 传给每个执行过程的两个回调:一个收值,一个收死胡同。
- 适用处: 未约束变量的错误是 bug,不是失败,所以不触发回溯。
- 说明式知识与命令式知识
- “是什么”的事实,对一步步的“怎么做”的方法;逻辑程序设计让解释器提供后者。
- 适用处: 当前逻辑语言自动提供的“怎么做”方法,可能陷入虚假的无限循环。
- 合一
- 符号的模式匹配,通过约束变量使模式与数据一致,驱动关系查询。 匹配两个都可能含有变量的模式,返回使它们相等的约束。 一种对称的匹配,变量可以出现在任何一边,所以靠建立约束使两个模式相等。
- 适用处: 这里只是作为查询语言的匹配引擎引入,还没有实现。 一次成功的合一,不必把每个变量都约束到常量;有些可以保持不约束,或约束到含有变量的模式。 它拒绝把一个变量约束到含有它自己的表达式,因为这样的方程没有一般的解。
- 查询语言
- 这里所实现的逻辑程序设计语言,用来通过陈述查询,从数据库中检索信息。
- 适用处: 它的解释器仍然反映了前面各节的 eval/apply,加上框架和流。
- amb 的“试下一个”循环
- amb 的执行过程循环遍历各个备选,每个备选都被给予一个试下一个的失败延续。
- 适用处: 回溯是按时间顺序、从左到右的,除非加上像 ramb 这样的变体。
- 模式变量
- 一个以问号为前缀的符号,代表“任何东西”,重复同一个名字,则迫使在每个位置上是同一个值。
- 适用处: 名字只在一个查询内部、跨位置共享值时才要紧。
- 规则
- 一个有名字的查询抽象:一个结论模式加一个体,代表其体被满足的、结论的每一个实例。
- 适用处: 可以嵌套和递归,但演绎在复杂情形下可能垮掉。
- 框架流
- 每个查询接收变量约束的框架,并发出它们所有一致的扩展,所以组合手段靠把流传下去而串联。 一条贯穿求值器的、由变量约束框架构成的流;每个查询过滤或扩展它。
- 适用处: 描述的是实现策略,而不是数学逻辑本身。 像 not 和 lisp-value 这样的过滤器只移除框架;它们不添加约束,并要求变量已被约束。
- 封闭世界假设
- not P 只意味着 P 无法从数据库推出,而不意味着 P 为假。
- 适用处: 只要数据库不完整就失效,产生可以推导出的荒谬的否定。
- 逻辑程序设计
- 通过使用一个带有可控的过程性解读的逻辑子集,把要计算什么同怎么计算分开。
- 适用处: 子句的顺序仍然决定效率,所以“是什么/怎么做”的划分从来不是彻底的。
- 数据导向的程序设计
- 靠在表中查找处理函数、而不是固定的分情形分析,来按查询的类型分派。
- 适用处: 只有用 put 登记过的形式才是特殊的;其余一概落到 simple-query。
- 模式变量的内部格式
- 处理之前,?x 被改写成表 (? x),这样系统看一个表的 car 就能认出变量,而不必检查字符。
- 适用处: 只是表示上的加速;它不改变任何查询的语义,打印时必须用 contract-question-mark 转换回来。
- flatmap
- 把一个过程映射到一个流上并把得到的各个流连接起来;这里是靠交错连接,使无穷的分支不会饿死其他分支。
- 适用处: 只在某些子流是无穷或很长时,交错才要紧;习题4.74指出,单元素的结果不需要它。
- 框架与约束
- 框架就是一张变量-值序对的表,靠把一个新约束 cons 到最前面来扩展。
- 适用处: 查找是靠 assoc 线性进行的,而框架里未约束的变量,恰恰是 not 和 lisp-value 行为异常的原因。
- 不动点
- 一个解出未知量出现在两边的方程的值;这里,把 ?y 同含有 ?y 的表达式合一,就需要这样一个解。
- 适用处: 并非每个这样的方程都有有意义的不动点,所以语法上的解可能是假的。
- 读入宏字符
- 让读入器在求值之前改写表面语法的钩子,如 'expression 变成 (quote expression)。
- 适用处: 第4章避开了它,对问号语法,选择把变换显式写出来,以求清晰。
第 4 章划线与批注AI 陪读
“就好像——就好像宝藏的钥匙就是宝藏!”
“It's as if -- as if the key to the treasure is the treasure!”
查看原文 →巴思这一句,在一行代码出现之前就干了整章的活:你建来运行 Lisp 的求值器,本身就是 Lisp。题词当论题陈述。
“求值器,也就是决定编程语言中表达式含义的东西,不过是另一个程序。”
“The evaluator, which determines the meaning of expressions in a programming language, is just another program.”
查看原文 →像经文一样单独成行。排版就是论证——他们要你在这里停一下。
“我们开始把自己看作语言的设计者,而不只是别人设计的语言的使用者。”
“We come to see ourselves as designers of languages, rather than only users of languages designed by others.”
查看原文 →真正的主张是关于身份,不是关于技术。注意它被框成自我形象的改变——作者们是在招募,不只是在教学。
另外 17 条批注
“计算机科学本身不过是(也不多于)构造恰当的描述性语言的学科”
“computer science itself becomes no more (and no less) than the discipline of constructing appropriate descriptive languages”
查看原文 →括号承担了所有平衡。一次大胆的、对整个领域的重新定义,被偷运成一句谦逊的旁白。
“然而,求值是一个过程,所以用 Lisp 来描述求值过程是恰当的”
“However, evaluation is a process, so it is appropriate to describe the evaluation process using Lisp”
查看原文 →这样回答循环论证的指控,有点太快了。真正的出路是:内层求值器倚靠外层求值器,去做它从不定义的事——这一点书里后来悄悄承认了。
“元循环对真的表示,可能同底层 Scheme 的不一样。”
“The metacircular representation of truth might not be the same as that of the underlying Scheme.”
查看原文 →接缝露出来了。元循环求值器“必须”翻译的每个地方,都是它其实并不自足的地方——true? 就是坦白。
“我们无法判断元循环求值器是从左到右还是从右到左求值操作数”
“we cannot tell whether the metacircular evaluator evaluates operands from left to right or from right to left”
查看原文 →到目前为止最诚实的一句:这个求值器悄悄继承了它从没写下来的语义。元循环性部分是一种不做决定的办法。
“被求值的语言的语法,完全由对表达式进行分类和提取各部分的那些过程决定”
“the syntax of the language being evaluated is determined solely by the procedures that classify and extract pieces of expressions”
查看原文 →数据抽象兑现为对语法的主权——换掉十几个小小的选择函数,你就得到一门不同的语言,却有同样的大脑。
“我们接受凡不是那个显式假对象的任何东西为真”
“we accept anything to be true that is not the explicit false object”
查看原文 →一条一句话的政策决定,却有巨大的下游后果——这就是为什么在 Scheme 里 0 和 '() 是真。真是用否定来定义的,是排除一个对象之后的剩余。
“虽然概念上简单,但不高效,通常不会用在生产系统里”
“although conceptually simple, is not efficient and would not ordinarily be used in a production system”
查看原文 →作者们一再坦白这一点。每次查找都对平行表作线性扫描——他们要你看到,清晰和速度是可以分开的,而清晰是有意先选的。
“这样一个通用电路几乎复杂得难以想象。值得注意的是,程序求值器是一个相当简单的程序。”
“Such a universal electrical circuit is almost unimaginably complex. It is remarkable that the program evaluator is a rather simple program.”
查看原文 →这一节里最好的一句。电路类比使通用性在物理上显得荒谬,然后表明它只花了大约一页 Lisp。为什么这么便宜?因为程序本来就是表——表示做了硬件必须付钱买的活。
“我们的解释器会正确求值对 f 的调用,但那是出于一个“偶然”的原因”
“our interpreter will evaluate calls to f correctly, but for an ``accidental'' reason”
查看原文 →诚实,也略微令人不安。正确的行为是顺序的巧合,而不是保证——恰是那种在你所有测试里都活下来、直到有一次不再活下来的 bug。
“路易斯的 map 失败了,尽管伊娃的行得通”
“Louis's map fails even though Eva's works”
查看原文 →陷阱:宿主的 map 在一个元循环的过程对象上调用它自己的 apply——那是底层 Scheme 无法调用的带标志的表。这两个世界共享语法,别的什么也不共享。
“这些观点里,你支持哪一个(如果有的话)?”
“Which (if any) of these viewpoints do you support?”
查看原文 →三个聪明人,三个站得住的答案——这道习题承认,“同时作用域”在有人拍板之前是规定不全的。语言的语义是协商出来的,而不是发现出来的。
“令人惊讶的是,路易斯在习题4.20中的直觉是正确的。”
“Amazingly, Louis's intuition in exercise 4.20 is correct.”
查看原文 →漂亮的教学一招:稻草人角色被平反——但只是在作者们把“正确”的代价抬高到自我应用之后。
“(fact fact n))”
查看原文 →诀窍是把一个函数自身作为第一个参数传给它——用纯粹的参数传递重建的递归,伪装起来的 Y 组合子。
“表达式的语法分析与它们的执行是交织在一起的”
“the syntactic analysis of expressions is interleaved with their execution”
查看原文 →整章的铰链:一旦你看出分析和执行是可以分开的,你就发明了编译器,却没说出这个词。
“(define (eval exp env) ((analyze exp) env))”
查看原文 →两行,就重构了整个求值器。把环境柯里化掉,工作就重新排布了。
“虽然序列里的各个表达式已经被分析过,序列本身却还没有”
“although the individual expressions in the sequence have been analyzed, the sequence itself has not been”
查看原文 →尖锐的区别——控制结构也是语法,也可以预先计算。执行器里剩下的循环就是漏洞。
“任何求值器都能模拟任何别的求值器”
“any evaluator can emulate any other”
查看原文 →掉在脚注里:整个丘奇-图灵论题,作为一道 Lisp 习题的旁白。这份随意就是炫耀。
读后感AI 陪读
求值器坦白了它假装不依赖的东西
第4章是这本书从“写程序”转向“写赋予程序以含义的东西”的转折,而它挣得这次转折,靠的是一个重复四次的动作:如果语言配不上问题,就去写一个求值器。巴思的题词——宝藏的钥匙“就是”宝藏——在任何代码出现之前,就是整个论证,而作者们把论题单独放一行,像经文一样:求值器不过是另一个程序。留意这里实际宣称了什么。它不是一门技术,而是一个身份:我们开始把自己看作语言的设计者,而不是别人设计的语言的使用者,计算机科学被重新定义——括号里承担了所有平衡——为不多也不少,就是构造描述性语言的学科。这是招募,不是教学。
随后的元循环求值器很美,细读之下,在它自身的自足性上又悄悄不诚实——这正是它的优点,因为它一直在坦白。核心是 eval/apply 互相调用,cond 和 let 被降为派生表达式,改写成 if 和 lambda,环境由持有平行表的框架构成,语法被整个儿推进了小小的选择函数——所以替换十几个一行的过程,就得到一门不同的语言,却有同样的大脑。但内层求值器必须翻译的每一处,都是一道接缝。true? 就是坦白:元循环的真,可能不是宿主的真,而真本身是用否定来定义的,凡是排除一个假对象后剩下的都算(这就是为什么 0 和 '() 此后永远是真的)。更好的是,作者们承认,你无法从代码里看出操作数是不是从左到右求值——这个求值器悄悄继承了它从没写下来的语义。元循环性部分是一种不做决定的办法。那是本章最尖锐的东西,却几乎是耸耸肩提出来的。通用机器的框架随后收获了惊人的回报:一个通用电路会复杂得难以想象,而程序求值器大约只有一页 Lisp。为什么这么便宜?因为程序本来就是表。表示做了硬件必须花钱买的活。
继续阅读
本章还不同寻常地坦率:清晰和速度是可以分开的,而它是有意先选清晰——每次变量查找都线性扫描,“不高效,通常不会用在生产系统里”。习题是这种坦率长出牙齿的地方。内部定义依次求值,破坏了块结构所许诺的同时作用域;把定义扫描出来,改成 let 加 set!,打了补丁;而那道问你支持三个角色中哪一个观点的习题,承认“同时作用域”在有人拍板之前是规定不全的——语言的语义是协商出来的,而不是发现出来的。路易斯的 map 在伊娃的行得通的地方失败,因为宿主的 apply 无法调用一个带标志的表:两个世界共享语法,别的什么也不共享。有一个解释器求 f 求对了,是“出于偶然的原因”——正确性成了求值顺序的巧合,恰是那种在每次测试里都活下来、直到有一次不再活下来的 bug。而那个稻草人角色终于被平反,但代价是自我应用,(fact fact n),用纯粹的参数传递重建的递归——戴着假鼻子的 Y 组合子。
然后是本章结构上的铰链,说得平淡:语法分析与执行是交织在一起的。把它们分开——(define (eval exp env) ((analyze exp) env))——你就发明了编译器,却一次也没用这个词。两行,整个求值器就重新排布;把环境柯里化掉,工作就从运行时转移到分析时。诚实在这里也继续着:序列本身没有被分析,执行器里剩下的循环就是漏洞,而控制结构原来也是语法,因此也可以预先计算。
从这个重新架构的基础出发,本章把语言又变异三次,每次靠改变 eval,而不是改变程序。惰性求值把参数延迟成记忆化的 thunk,这使 unless 成了过程而不是特殊形式,并使非严格的 cons 成为可能——由此,流不再是一门单独的学科,而干脆成了表,无穷的表,由普通的表过程去操作。第3章里显式的 delay,消融进了求值器。非确定性计算加入 amb,并把分析求值器变成一个穿着成功与失败延续的求值器,于是回溯是按时间顺序、自动进行的;用到自然语言解析上,它暴露出真实的结构歧义——一句话有几个合法的解析,靠 try-again 逐个取回。而逻辑程序设计,则通过彻底抛弃“怎么做”的口气、改用“是什么”,完成了这条弧线:对一个被索引的断言数据库做模式匹配,规则作为查询抽象,合一作为匹配的推广,而 qeval 和规则应用与 eval/apply 严丝合缝地对应,全部管道通过延时的框架流接好。
本章在谈论最后这个系统的局限时最值得信任。逻辑程序设计被标榜为说明式的,随后又被表明根本不是那回事:规则的顺序控制终止,循环很容易写出,而 not 不是逻辑否定,而是建立在封闭世界假设上的“失败即否定”。把一个变量同含有它自己的表达式合一,要求一个可能不存在的不动点——被尖锐地比作对发散级数的非法操作。而一条脚注把整个丘奇-图灵论题——任何求值器都能模拟任何别的求值器——当作一道 Lisp 习题的随意旁白丢了出来。这份随意就是炫耀。
我想对读者说的是:不要把这一章读成四个解释器。要把它读成一个主张的四次演示——语义是你能编辑的程序,而语言内部通常隐藏起来的那些选择(求值顺序,什么算真,何时求值参数,“否”意味着假还是仅仅未证明),在你自己写出求值器的那一刻就变得可见、可协商。亲手建元循环求值器,然后做分析型的重写,因为那是这一章不再只是一种好奇、而成为工程的地方。还要盯着接缝,而不是对称:内层语言必须倚靠外层语言的地方,不是这一课的缺陷,它们“就是”这一课——每门语言都建立在一个基底上,基底的种种决定,它是继承来的,没有说出。钥匙真的就是宝藏,但锁是有人装上的,而读过这一章,这个人可以是你。
在 FeedLitra 里读第 4 章,每段旁边都有这些卡片。
开始读书第 5 章:寄存器机器里的计算
continue 寄存器怎样预示了递归所需的栈?
第 5 章是怎样展开的AI 辅助
这一章开头不是一台新机器,而是对旧求值器的不满。上一章的元循环求值器,是用它声称要解释的那门语言写成的,能描述 Lisp 表达式的含义,却说不出控制是怎样运作的:它从未解释一个子表达式的值怎样找回向它索取的那一方,也没解释为什么有些递归过程能在常数空间里运行。它只是借用了宿主 Lisp 的控制机制。所以这一章完全下降到 Lisp 之下,到寄存器机器——一组固定的寄存器,一组固定的基本操作,和一个给它们排序的控制器。开场的例子是 GCD 机器,用三种方式呈现:数据通路图,控制器图,以及一种稍后要喂给模拟器的文本描述语言。开场的要点是:在这样一台机器的任何一个控制器点上,一个迭代计算的整个状态,就是各寄存器的内容。什么也没有藏起来。
展开一:标号,然后是栈
展开从把这个主张推到崩溃开始。把标号存进寄存器,使 gcd 成了一个真正的子程序,可以从不止一个地方调用——一个温和的扩展。使这个模型垮掉的是阶乘。你不能为递归调用重复使用同一台机器,靠递减 n 并重新开始,因为旧的 n 仍然被最后一次乘法需要;重新开始会抹掉待办工作所依赖的值。化解的办法是带 save 和 restore 的栈:因为同一时刻只有一个嵌套实例是活跃的,保存活着的寄存器加上返回标号(continue 总是被保存),就让一台带 n、val 和 continue 的有限机器,能挂起外层实例、解决内层实例、再恢复并完成,深度只受栈的大小限制。因此留下来的主张是个更锋利的主张——迭代只需要寄存器,递归需要一个额外的机制,而一台有限的机器仍然够用。
展开二:让模型可执行,再给它内存
在图上论证了模型之后,这一章让它跑起来。模拟器用 pc、flag、一个 execute 循环和消息传递分派,完成了机器模型;然后是汇编器,它把控制器文本扫描一遍,把标号同指令分开,并给每条指令挂上一个执行过程,在汇编时而不是运行时解析寄存器查找和标号引用。(这种一次性分析的想法,悄悄是两节之后编译器的种子。)从模拟器的调试练习,本章转向存储:表结构被表示成指向 the-cars 和 the-cdrs 向量的带类型指针,基本的表操作成了向量操作,而停止-复制垃圾收集——写成寄存器机器代码,从 begin-garbage-collection 经 gc-loop、update-car/update-cdr、relocate-old-result-in-new,一直到 gc-flip——维持着内存无限的幻觉。收集器的逻辑值得精确说明,因为很容易误读:判据不是程序还“需要”什么,而是从机器寄存器出发还“可达”什么。不可达的对象再也不会被碰到,所以回收它们是安全的;可达的对象即使程序永远不会再看它们,也会被保留。当扫描指针遇上空闲指针时,收集结束,这意味着没有发现新东西,每个活对象都已被搬走。
显式控制求值器:让控制可见
有了机器、模拟器和内存,本章建立显式控制求值器:七个寄存器、一个栈,以及对表达式类型做分情形分析的 eval-dispatch,其中应用靠保存寄存器并运行一个运算对象循环来处理。然后是对基本过程和复合过程的 apply,序列的求值,条件,赋值,定义,以及一个带栈监视的驱动循环。把控制用寄存器写出来,解决了先前模型留下未决的问题。运算对象的求值顺序,原来是从宿主语言继承来的;在这里,运算对象循环用 car 取第一个运算对象,用 cdr 取其余的,所以一个组合式必然是从左到右走过的。尾递归也同样成了你看得见的性质,而不是假定的:它成立,是因为求值器不在过程体的最后一个表达式周围保存寄存器。作者表明,加上这样的保存是致命的——栈的深度就会随迭代次数增长,一个只由过程调用构成的循环最终会耗尽栈。尾递归是这个求值器的成就,不是把调用写在最后一个位置上就白得的结果。
转折:从解释到编译
本章真正的枢纽,出现在它不再解释、开始翻译的时候。编译器发出原生的寄存器机器指令,用解释器的灵活性换效率。compile 按语法类型分派,一路带着一个 target 和一个 linkage,而指令序列由 append-instruction-sequences 和 preserving 粘在一起,本章最漂亮的机制就住在后者里:每个序列记录它需要的寄存器和它修改的寄存器,只有当第一个序列修改了第二个所需要的东西时,才在它周围包一对 save/restore——所以大多数调用根本不产生栈上的操作。本章随后编译赋值、定义、if、序列、lambda 和组合式;参数表在 argl 里从后往前建,应用按基本过程还是编译过的代码分派,而一个返回链接复用 continue,所以目标代码生来就是尾递归的。组合手段 preserving、tack-on 和 parallel 管理这些寄存器声明,一个阶乘编译的实例显示出由此得到的骨架,然后词法地址,把运行时的变量搜索,换成针对编译时环境算出的“框架加位移”的查找。加速的两个原因被分开:分析只做一次,以及编译器可以特化。解释器每过一遍都重新给 (f 84 96) 分类,重新检验运算对象表是否结束;更糟的是,它必须为任何延续做好准备,所以总是保存。编译器知道变量查找什么也不会扰乱,就发出一条对 proc 的赋值,省掉保存、恢复,和从 val 的移动。
收束:测量、混用,与可移植性
最后一节通过习题完成词法地址,然后借 apply-dispatch、external-entry 和 compile-and-go,把编译出的代码接回求值器机器,并以让自己的论点可以检验来收尾。被监视的栈报告:解释执行 (factorial 5) 是 144 次压栈、最大深度 28,对编译后的 31 次压栈、深度 14——这是这次特定模拟的数字,但毕竟是数字。随后的裁决故意是双面的:解释在交互式开发上保有真实的优势,因为程序运行时,源程序和全套基本操作仍然可用,这就是为什么真正的 Lisp 系统让编译的和解释的过程互相调用,让程序员编译调试好的部分,而把不稳定的部分留着解释。这种互操作不是运气——编译器被造成遵守求值器的寄存器约定(env、argl、proc、val、continue)。最后的脚注留意杂务(在启动 eceval 之前初始化 flag,让 user-print 不进入编译过程的内部,在检错的代价与速度之间权衡),然后以最宽的主张结束:把 Lisp 移植到新机器,缩成一个手工编码的小内核,加上编译器为它自己提供的操作,由编译器编译它自己,作为对其自身正确性的检验。
关键论点,以及支撑它们的原文
元循环求值器解释不了控制是怎样运作的,因为它是用它所解释的那门语言本身写成的。
作者指出,它从未说明一个子表达式的值怎样找回向它索取的那一方,也没说明为什么有些递归过程能在常数空间里运行;求值器只是借用了宿主 Lisp 的控制,这就是为什么这一章下降到 Lisp 之下,到寄存器和基本操作。
“这些问题仍未得到回答,因为元循环求值器本身是一个 Lisp 程序,因而继承了底层 Lisp 系统的控制结构。”
“These questions remain unanswered because the metacircular evaluator is itself a Lisp program and hence inherits the control structure of the underlying Lisp system.”
Chapter 5 Computing with Register Machines查看原文 →
迭代的计算只需要寄存器,而递归需要一个额外的机制——但一台有限的机器仍然够用。
在一台迭代的机器里,任何控制器点上的整个状态,不过是寄存器的内容,如 GCD 机器所示。阶乘打破了这一点:递减 n 并重新运行,会丢掉最后一次乘法所需要的旧 n。因为同一时刻只有一个嵌套实例是活跃的,一个栈加上 save/restore,让一台带 n、val 和 continue 的机器能处理任何深度——只受栈的大小限制。
“然而,实现递归的计算过程,需要一个额外的机制。”
“Implementing recursive processes, however, requires an additional mechanism.”
§5.1 Designing Register Machines查看原文 →
把控制用寄存器写出来,迫使人做出先前的求值模型留下未决的决定。
接着对元循环求值器的抱怨,作者指出:运算对象的求值顺序,以前是从宿主语言继承来的,而在这里,运算对象循环用 car 取第一个运算对象,用 cdr 取其余的,所以这个组合式必然是从左到右走过的。
“这个顺序不是元循环求值器所决定的,它从实现它所用的底层 Scheme 继承了控制结构。”
“This order is not determined by the metacircular evaluator, which inherits its control structure from the underlying Scheme in which it is implemented.”
§5.4 The Explicit-Control Evaluator查看原文 →
另外 3 个论点
编译的代码胜过解释,既因为分析只做一次,也因为编译器可以针对手头的表达式特化。
解释器每次看到 (f 84 96),都要重新给表达式分类,重新检验运算对象表是否结束,而编译器在生成指令时,只做一次这项工作。更进一步,解释器必须为任何延续做好准备,所以总是保存寄存器;编译器知道变量查找什么也不会扰乱,就发出一条对 proc 的赋值,省掉保存、恢复,以及从 val 的移动。
“有了编译器,表达式只在编译时、生成指令序列的时候被分析一次。”
“With a compiler, the expression is analyzed only once, when the instruction sequence is generated at compile time.”
§5.5 Compilation查看原文 →
从另一面看,解释对于交互式开发保有真实的优势,这就是真实的系统为什么把两者混用。
作者论证,解释器在程序运行时,保留着源程序和全套基本操作,所以 Lisp 系统被安排成让编译的和解释的过程互相调用——程序员编译调试好的部分,而把不稳定的部分留着解释。互操作之所以行得通,是因为编译器被造成遵守求值器的寄存器约定:env、argl、proc、val 和 continue。
“另一方面,解释器为交互式的程序开发和调试,提供了更强大的环境,因为被执行的源程序在运行时可用,可以被检查和修改。”
“On the other hand, an interpreter provides a more powerful environment for interactive program development and debugging, because the source program being executed is available at run time to be examined and modified.”
§5.5 Compilation查看原文 →
编译所宣称的节省,是可以测量的,不是修辞。
被监视的栈报告:解释执行 (factorial 5) 是 144 次压栈、最大深度 28,而同一个过程编译后的版本是 31 次压栈、深度 14——这些数字来自这次特定的模拟。
“这说明了我们的编译策略所带来的优化。”
“This illustrates the optimization that results from our compilation strategy.”
§5.5 Compilation查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
栈把一台机器变成递归
一次递归调用留下待办的工作,所以递减寄存器再重新开始,会抹掉以后仍需要的值。把这些寄存器连同返回标号保存在栈上,让这台单一的机器能挂起外层实例,解决内层实例,再恢复并完成。
机制 2
可达性加上“扫描=空闲”结束收集
凡是从机器寄存器再也不可达的东西,永远不会再被碰到,所以回收是安全的。收集器把可达对象往前复制,当扫描指针遇上空闲指针时停止,这意味着没有发现新东西,每个活对象都已被搬走。
机制 3
只在两个检验都通过时才保存
每个指令序列带着它需要的寄存器和它修改的寄存器。只有当第一个序列修改了第二个所需要的寄存器时,组合手段才在它周围包一对 save/restore,所以大多数调用根本不产生栈上的操作。
常见误读
垃圾收集释放程序不再需要的一切。
判据是从机器寄存器出发的可达性,而不是需要:仍然可达的东西都被保留,即使程序永远不会再用它们,所以可达性是有用性的一个保守替身。
“垃圾收集基于这样一个观察:在 Lisp 解释的任何时刻,能够影响计算未来的对象,只有那些从目前在机器寄存器里的指针出发、经过某种 car 和 cdr 操作序列能够到达的对象。”
“Garbage collection is based on the observation that, at any moment in a Lisp interpretation, the only objects that can affect the future of the computation are those that can be reached by some succession of car and cdr operations starting from the pointers that are currently in the machine registers.”
§5.3 Storage Allocation and Garbage Collection查看原文 →
只要把循环写成最后位置上的过程调用,尾递归就白得了。
这取决于求值器不在过程体的最后一个表达式周围保存寄存器。作者表明,加上这样的保存是致命的:栈的使用随迭代次数增长,一个只由过程调用构成的循环,最终会耗尽栈空间。
“但这个改变对尾递归的实现是致命的,因为我们现在必须在对序列中最后一个表达式求值之后返回,以撤销那些(无用的)寄存器保存。”
“But this change is fatal to the tail-recursive implementation, because we must now return after evaluating the final expression in a sequence in order to undo the (useless) register saves.”
§5.4 The Explicit-Control Evaluator查看原文 →
第 5 章的核心概念
- 寄存器机器
- 一台一步一步运行指令的机器,每条指令移动或组合保存在一组固定的、有名字的存储单元里的值。
- 适用处: 只有常量和寄存器的内容可以直接被操作;每一步给一个寄存器赋值。
- 数据通路与控制器
- 数据通路是寄存器、操作和按钮;控制器是决定下一个该按哪个按钮的序列。
- 适用处: 第5章看重对控制器的理解,多于对数据通路的细节,尽管真正的机器设计两者都需要。
- 动作
- 像 print 这样的操作:有外部效果,却不往任何寄存器里返回值;由 perform 调用。
- 适用处: 与像 read 这样产生值的操作相区别。
- 子程序
- 一段共享的指令序列,靠分支进入,由 continue 寄存器记录返回的地方。
- 适用处: 只有被复用的寄存器的值在别处不再被需要时,共享才是合法的。
- 延续寄存器
- 一个保存控制器标号的寄存器,子程序应在那里继续它的调用者。
- 适用处: 只有嵌套调用保存并恢复它,才能在子程序之间共享。
- 递归用的栈
- 一个后进先出的、存放已保存寄存器值的存储,使一份机器副本可以服务每一个嵌套调用。
- 适用处: 任何物理的栈都是有限的,所以递归深度受到限制。
另外 32 个概念
- 树形递归
- 一个分成两个或更多子问题的过程,如斐波那契的两个递归调用。
- 适用处: 需要两个不同的返回入口,而不是一个往回的循环。
- 指令执行过程
- 与每条指令存放在一起的无参数过程;调用它,就模拟运行了那条指令。
- 适用处: 只在模拟器里有意义;指令文本本身只为调试而保留。
- pc 寄存器
- 一个保存剩余指令表的寄存器,决定接下来运行什么。
- 适用处: 除分支和 goto 之外,每条指令都由 advance-pc 推进。
- 把分析同执行分开
- 把所有的解析和查找在模拟之前做一次,这样重复运行仍然便宜。
- 适用处: 只可能用于与寄存器实际内容无关的工作。
- 消息传递
- 机器只通过一个回应有名字的请求的分派过程,暴露它的内部。
- 适用处: 未知的消息会报错;方便的过程性包装叠在上面。
- 作为向量的内存
- 把计算机内存建模为向量 the-cars 和 the-cdrs;一个序对指针就是进入两者的下标。
- 适用处: 只有当向量访问时间与下标无关、地址可以被当作数据时才行得通。
- 带类型的指针
- 指针带着数据类型标志,所以系统能区分序对与数或符号,而 eq? 就是指针相等。
- 适用处: 太大而装不进一个指针的数需要单独的大数类型;符号必须被驻留,eq? 才能工作。
- 垃圾收集
- 定期回收再也无法从机器寄存器经 car 和 cdr 步骤到达的序对。
- 适用处: 只有当回收跟得上分配时,才能维持内存无限的幻觉。
- 停止-复制
- 把内存分成两半;把所有可达的序对复制到空闲的一半里,把垃圾留在后面,然后交换两半的角色。 留在一个序对旧位置上的标记,带着转发地址,标明这个序对已被复制。
- 适用处: 需要一个能到达所有活数据的根寄存器,以及空闲指针和扫描指针;当扫描指针追上空闲指针时结束。 只适用于低层的 pointer-to-pair? 谓词算作序对的对象;非序对原样返回。
- 显式控制求值器
- 一台寄存器机器,其控制器指令直接执行 eval 和 apply,而不是倚靠底层的 Scheme。
- 适用处: 它把语法、环境和运行时数据的过程当作基本操作,而不是把它们展开成内存操作。
- 条件分析
- 按表达式的语法类型分派,每种类型一次检验加分支。
- 适用处: 只有重叠的语法形式,检验的顺序才要紧;匹配不上的表达式落到一个错误标号。
- 靠栈保存的递归
- 靠 goto 加在栈上恰好保存调用返回后需要的那些寄存器,来完成的递归求值。
- 适用处: 最后一个运算对象是特例:env 和 unev 不必保存,因为不再需要了。
- 尾递归求值器
- 能在常数栈空间里运行自调用过程的求值器。
- 适用处: 只在调用是序列的最后一个表达式时才适用。
- 环境
- 一个过程在其中运行的约束结构;只在应用复合过程时才被扩展。
- 适用处: 在这个求值器里,只有 compound-apply 给 env 赋新值。
- 编译
- 提前把一个源程序翻译成机器自己语言中的等价指令。
- 适用处: 得到速度,却放弃了在运行时拥有源程序、以便检查和修改的可能。
- 解释策略
- 一个用机器原生语言写的程序,遍历源数据结构,通过调用基本子程序来模拟其行为。
- 适用处: 每次求值时,都要付出重新分析每个表达式的代价。
- 机器语言
- 通用机器的控制器原生解释的寄存器机器语言。
- 适用处: 相对于某台机器的数据通路而定义,不可跨体系结构移植。
- 目标与链接
- 两个额外的参数,告诉生成的代码:哪个寄存器存放结果,以及随后怎样继续:next、return,或一个标号。
- 适用处: 这个编译器只支持三种链接形式。
- 指令序列
- 一个三元组:所需的寄存器、被修改的寄存器,以及实际的语句,这样就不必重新分析寄存器的使用。
- 适用处: 构造一个基本序列时,必须显式地提供寄存器使用的信息。
- Preserving
- 一个组合手段:只有当第一个序列修改了第二个序列真正需要的寄存器时,才在它周围包 save 和 restore。 把生成的代码连接起来、同时计算连接后的代码需要和改变哪些寄存器的粘合操作。 一个组合手段:只对 seq1 修改、而 seq2 仍需要的寄存器,才在 seq1 周围包 save/restore。
- 适用处: 代码生成器本身从不发出 save 或 restore 指令。 标号算作退化的序列,既不需要也不修改任何东西。 只在两个序列真的一个接一个运行时才适用。
- 编译代码里的尾递归
- 最后一次调用,用调用者自己的 continue 径直跳到被调用者,所以栈不增长。
- 适用处: 只在 target 是 val、链接是 return 时才适用。
- 参数表的构造
- 运算对象从后往前求值,并 cons 到 argl 上,这样参数最后是按顺序的。
- 适用处: 没有运算对象时,只是把空表赋给 argl。
- parallel-instruction-sequences
- 用于一个检验的两个分支的组合手段:需要的和修改的取并集,因为只有一个分支会运行。
- 适用处: 被 compile-if 和 compile-procedure-call 使用,不用于顺序的代码。
- 词法作用域
- 运行时的环境结构反映了程序嵌套的 lambda 结构,所以位置是可以预测的。
- 适用处: 假定变量只靠 lambda 来约束,没有 define。
- 词法地址
- 一对数——框架号和位移——直接定位一个变量,而不是去搜索。
- 适用处: 要求编译器知道框架的布局;遇到内部 define 就失效。
- 编译时环境
- 编译期间一路携带、用来计算词法地址的、由变量名构成的框架表(没有值)。
- 适用处: 只保存名字;值不在编译时计算。
- compile-and-go
- 一个过程:编译一个表达式,汇编它,把它装入求值器机器,运行它,打印结果并进入驱动循环。
- 适用处: 别扭,因为编译器只能在求值器机器启动时被调用一次。
- 解释与编译
- 两种执行策略:一种把机器向上提到语言的抽象,另一种把程序向下推到机器语言。
- 适用处: 解释器利于交互式开发和调试;编译的代码,靠跨抽象的优化,利于速度。
- 编译过程的分派
- 求值器在 apply-dispatch 处检验是否是编译过程,并径直跳到编译后的入口,先恢复 continue。
- 适用处: 需要恢复 continue,因为求值器把延续留在栈上,而编译后的代码期待它在一个寄存器里。
- 错误检查的开销
- 在执行数组引用等操作之前先验证它们的运行时代价,与执行速度相权衡。
- 适用处: 这是编译器层面和策略上的选择,与代码是被解释还是被编译无关;它并不使未检查的代码变正确。
- 小内核引导
- 移植 Lisp 的办法:大部分基本操作用 Lisp 写并编译,只给新机器留下一个很小的手工编码的核心。
- 适用处: 垃圾收集和应用真正的机器基本操作,仍必须手工编码;这个策略把与机器相关的工作减到最少,却不能消除。
- 编译器自我编译的检验
- 通过看一个被移植的编译器,是否把一个程序编译成原来的 Lisp 系统所产生的同样代码,来检验它。
- 适用处: 结果对极小的细节极其敏感,所以差异很难诊断,这个检验也不是对正确性的完整证明。
第 5 章划线与批注AI 陪读
“相信时钟有灵魂的人,是把制造者的荣耀归给了作品”
“he who believes that a clock has soul attributes the maker's glory to the work”
查看原文 →开普勒是在揭开天体的神秘;SICP 借他来揭开解释器的神秘。同一个动作,相隔四百年——而两者都把神秘还给了设计者。
“元循环求值器本身是一个 Lisp 程序,因而继承了底层 Lisp 系统的控制结构”
“the metacircular evaluator is itself a Lisp program and hence inherits the control structure of the underlying Lisp system”
查看原文 →这是对第4章部分循环的诚实坦白。求值器靠借用它所声称要解释的那套机制,来解释含义。
“从硬件架构师的角度,而不是机器语言程序员的角度”
“from the perspective of a hardware architect rather than that of a machine-language computer programmer”
查看原文 →漂亮的倒转:不是让程序去适应机器,而是让机器去适应程序。每个过程一台机器——奢侈,而这恰恰是它有教益的原因。
另外 17 条批注
“我们假定每一步只能有一个寄存器被赋予新值”
“we will assume that only one register can be assigned a new value at each step”
查看原文 →临时寄存器 t 的存在,纯粹是因为这条自己给自己定的规则。整套交换的舞步,是一个建模决定的人为产物,而不是算术的产物。
“把这张图想成一个迷宫,里面有一颗弹珠在滚动。”
“Think of the diagram as a maze in which a marble is rolling.”
查看原文 →这颗弹珠在这里悄悄干了很多活——它就是程序计数器,被拟人化了。后面他们会需要栈,因为一颗弹珠记不住自己是从哪儿来的。
“读这样的描述很困难”
“it is difficult to read such a description”
查看原文 →教科书很少建起一套记号,然后立刻控诉它。教学就在于这个抛弃——你信任第二种形式,是因为你感受过第一种的摩擦。
“很容易忘记,它们并不是任意的 Lisp 表达式”
“it is easy to forget that they are not arbitrary Lisp expressions”
查看原文 →括号是它自己造的陷阱。看上去像 Lisp,既使这门语言可读,也使它有误导性。
“虽然它有效果,这个效果却不在我们正在设计的机器的某个部分上。”
“Though it has an effect, this effect is not on a part of the machine we are designing.”
查看原文 →对副作用的一个干脆的定义:凡是其后果活在你的模型边界之外的东西。使它成为一个动作的,是边界,而不是操作。
“我们总是可以把复杂的“基本操作”换成更简单的基本操作。”
“We can always replace the complex ``primitives'' by simpler primitive operations.”
查看原文 →这张期票使整章不致成为作弊——而他们立刻兑现了其中一张,用把取余化为减法来证明这张期票是好的。
“(assign continue (label after-gcd-1))”
查看原文 →这是本章真正的铰链:标号成了数据。控制流一旦成为你能存放的值,延续和递归就都已隐含其中。
“除非 sub1 在为调用 sub2 设置 continue 之前,先把 continue 的内容保存在某个别的寄存器里”
“Unless sub1 saves the contents of continue in some other register before setting up continue for the call to sub2”
查看原文 →bug 在修复之前就被宣布了——单个 continue 寄存器,恰好在深度上失败。递归不是一个新问题,而是这个问题,嵌套没有上界。
“由于每台阶乘机器里都含有另一台阶乘机器,整台机器就含有一个由相似机器构成的无穷嵌套”
“Since each factorial machine contains another factorial machine within it, the total machine contains an infinite nest of similar machines”
查看原文 →这种荒谬是有意的——他们先造出这台不可能的机器,这样栈作为一个救援到来,而不是一条你必须硬记的约定。
“这些副本中,在任何给定的时刻只有一个需要是活跃的”
“only one of these copies needs to be active at any given time”
查看原文 →这一节全部承重的洞见,一个从句就丢出来了。空间里的无穷,坍缩为时间里的先后。
“这些值必须以与保存时相反的次序恢复”
“These values must be restored in the reverse of the order in which they were saved”
查看原文 →漂亮:后进先出不是被选作一种数据结构,而是从递归的形状本身推断出来的。栈是被发现的,不是被搬进来的。
“一个寄存器的旧值,并不总能从新值计算出来”
“the old value of a register cannot always be computed from the new one”
查看原文 →一条脚注做着真正的哲学工作——这就是不可逆性。保存之所以存在,是因为计算销毁信息。
“val 的旧内容在子程序返回之后没有用”
“the old contents of val is not useful after the subroutine returns”
查看原文 →n 和 val 之间的不对称,提前泄露了习题5.4的全部功课:保存“调用者”仍然需要的,而不是一切变化的东西。
“(assign n (reg val)) ; n 现在含有 Fib(n - 2)”
“(assign n (reg val)) ; n now contains Fib(n - 2)”
查看原文 →把 n 当作 Fib 结果的草稿区,是对命名的一次小小暴力——这也恰恰是为什么本·比特迪德尔两段之后能发现一对多余的 save/restore。
“这不会产生无限循环,因为运行指令的执行过程会改变 pc 的内容”
“this does not produce an infinite loop because running the instruction execution procedure changes the contents of pc”
查看原文 →Scheme 的尾调用悄悄地“就是”机器的指令周期。模拟器与其说是在对顺序建模,不如说是借用了它。
“对寄存器机器语言表达式的许多有用分析,不必知道机器寄存器的实际内容就能进行”
“much useful analysis of register-machine-language expressions can be performed without knowing the actual contents of machine registers”
查看原文 →第4章的分析/执行分离,在低一层重现——书在表明,编译是一个可重复的动作,而不是特殊的花招。
“extract-labels 的第二个参数,是一个用来处理这些结果的过程”
“The second argument to extract-labels is a procedure to be called to process these results”
查看原文 →实质上是个延续,只是没起这个名字,这里只用来把两个累加器穿过一次递归。优雅,也是本章里最让人迷惑的那十几行。
读后感AI 陪读
你信任第二种形式,是因为你感受过第一种的摩擦
第5章是 SICP 不再用 Lisp 来解释 Lisp、终于还债的一章。第4章的元循环求值器是一件美丽的循环之作——它靠递归来解释递归,靠对参数求值来解释参数的求值,并继承了宿主系统的整个控制结构,从未将其点名。开普勒的题词,他拒绝承认发条般的天体有灵魂,定下了基调:看上去从头到尾都是机械的,就是从头到尾都是机械的,荣耀属于设计者,而不属于器件。于是这一章下降了——寄存器,基本操作,一个控制器,以及一颗在数据通路的迷宫里滚动的弹珠。下降的次序就是论证。先是一台 GCD 机器,画两遍:一遍是你看得见的图,一遍是文本语言,而在两者之间,书建起一套记号,然后立刻控诉它不可读。这就是本章的教学标记——你信任第二种形式,是因为你感受过第一种的摩擦。自己给自己定下的规则,在这里也起了实际作用:交换里的 temp 寄存器之所以存在,只是因为他们决定每一步只能给一个寄存器赋值,而整个第一幕都老实地承认,它的基本操作是期票,并立刻兑现其中一张,用减法重建了取余。
然后是铰链,只有一行:给 continue 赋一个标号的值。控制流成了数据。返回地址一旦能放在一个寄存器里,子程序、延续和递归就都已隐含其中;本章其余的部分,只是弄清楚这要付什么代价。书不同寻常地诚实:在修复之前先宣布了那个 bug——一个 continue 寄存器对一个子程序是行得通的,一旦一个调用嵌套在另一个里面就垮了。一台包含着阶乘机器、而后者又包含着阶乘机器的阶乘机器,是有意荒谬的,造成一件不可能的对象,使栈作为一个救援到来,而不是作为一条要硬记的约定。而栈确实是推导出来的,不是搬进来的:嵌套中同一时刻只有一份副本是活跃的,所以空间里的无穷,坍缩为时间里的先后;值必须以与保存时相反的次序取回来,所以后进先出是从递归的形状里推断出来的。一条脚注承载了整节最深的一点——一个寄存器的旧内容,一般不能从新内容重新算出来。保存之所以存在,是因为计算销毁信息。这是不可逆性,顺口说出,也是为什么纪律是保存调用者仍然需要的,而不是被调用者搅乱的一切。
继续阅读
从这里起,本章建造它自己的地基。模拟器用 pc、flag 和消息传递寄存器完成了模型,然后汇编器把标号同指令分开,并给每条指令挂上一个执行过程,所以寄存器查找和标号解析只做一次,而不是每过一遍都做。这恰恰是第4章的分析/执行分离在低一层重现,而这种重复就是教训:把静态结构同动态的值分开,是你在任何一层都可以做的一个动作,不是解释器所特有的花招。模拟器里有两件事值得多想。指令循环并不循环——它做的是尾调用,而它之所以终止,唯一的原因是执行一条指令改变了 pc;Scheme 的尾调用悄悄地“就是”机器的指令周期,这意味着模拟器是借用了顺序,而不是对它建模。而 extract-labels 靠接受一个接收结果的过程,把两个累加器穿过一次递归——实质上是个延续,只是没起这个名字,也是本章里最让人迷惑的那十几行,恰好放在你刚被教过返回地址是值的地方。
后半部分让内存、继而让求值变得具体。表结构成了指向 the-cars 和 the-cdrs 的带类型指针;cons、car 和 cdr 成了向量操作;而停止-复制垃圾收集,写成寄存器机器代码,一直写到 gc-flip,维持着前面每一章都在大手大脚地花用的、内存无限的幻觉。然后是显式控制求值器:七个寄存器和一个栈,按表达式类型分支的 eval-dispatch,一个运算对象循环,分成基本过程和复合过程的 apply,序列、条件、赋值、定义,以及一个带栈监视的驱动循环。本章安静的胜利在这里落地——尾递归不再是关于 Scheme 的一个许诺,而成了寄存器机器上看得见的性质,是在过程体的最后一个表达式之前不做压栈的结果。一个语言特性,被揭示为一种实现上的纪律。
编译器是最后的转折,也是恰当的结尾,因为它逆转了本章自己的动作。求值器在运行时判定每个表达式是什么;编译器在编译时一次判定,并发出求值器本会执行的指令——用解释上的灵活性换速度。机制是细致的,而不是聪明的:compile 按语法类型分派,带着一个目标和一个链接;指令序列的组合,靠追踪每个序列需要什么寄存器、修改什么寄存器,所以 preserving 只在寄存器的使用真的要求时才插入 save/restore;参数表从后往前建进 argl;一个返回链接复用 continue,所以编译出的代码生来就是尾递归的。词法地址随后完成这件事,办法是把运行时的变量搜索,换成针对编译时环境算出的“框架加位移”的查找,而 compile-and-go 通过 apply-dispatch 和 external-entry 把编译出的代码缝进求值器机器,使两者可以互相调用。裁决是一个数字:同一个阶乘,31 次压栈,对 144 次。脚注也是同一路——初始化 flag,让 user-print 不进入编译过程的内部,在检错与速度之间权衡——这是造出一个真东西的、不光鲜的残渣。
我想对读者说的是:把第5章读成把神秘变成机制、然后给你看每一项价格的一章。你先前被允许使用的每一样魔法,都得到一张账单:递归要一个栈,因为寄存器会遗忘;无限内存要一个收集器;变量查找要么搜索,要么编译时分析;而一次过程调用要付多少保存,恰好取决于调用者未完成的工作所需要的。不要略过子程序一节去看求值器——那个标号成为数据的单独一行,就是整章的缩影,而显式控制求值器和编译器,都不过是那一行在压力下的展开。还要留意结尾所蕴含的:一旦你有了一个用寄存器机器术语写成的求值器,和一个能编译自己的编译器,把 Lisp 移植到新硬件就缩成一个手工编码的小内核,加上语言自举其余部分,而自我编译同时兼作正确性检验。书开头是揭开天体的神秘,结尾是把时钟递给你,打开着,每个齿轮都有交代——而诚实的最后发现是:解释器从来不是一种不同于它所运行的程序的东西。
在 FeedLitra 里读第 5 章,每段旁边都有这些卡片。
开始读书论证在哪里吃力
不是反驳,而是细心的读者会想追问的地方。
- 本书把形式化正确性当作无法扩展而放弃,却又坚持数据表示要按所述条件来验证,让人不清楚到底还留下多少形式化的纪律。
“比什么都重要的是,发现并掌握强大的组织技术,能加快我们创造大型而有意义的程序的能力。”
“More than anything else, the uncovering and mastery of powerful organizational techniques accelerates our ability to create large, significant programs.”
Foreword查看原文 → - 给人类读者写程序,可能与忠实地给世界建模相冲突,因为镜像的策略接纳了有状态的对象,而读者更难对它们作推理。
“首先,我们想确立这样一个观念:计算机语言不只是让计算机执行操作的一种方式,而是一种新颖的、表达方法论思想的形式化媒介。”
“First, we want to establish the idea that a computer language is not just a way of getting a computer to perform operations but rather that it is a novel formal medium for expressing ideas about methodology.”
Preface to the First Edition查看原文 → - 本书既把元循环求值器颂扬为编程中最根本的想法,又宣布它在控制上的解释是破产的,让人不清楚一个自我描述的求值器到底解释了多少。
“求值器,也就是决定编程语言中表达式含义的东西,不过是另一个程序。”
“The evaluator, which determines the meaning of expressions in a programming language, is just another program.”
Chapter 4 Metalinguistic Abstraction查看原文 → - 允许复杂的操作作为基本操作自由存在,与“下降到 Lisp 之下”别扭地并存——后者恰恰因为借来的机制把要紧的机制藏了起来。
“我们总是可以把复杂的“基本操作”换成更简单的基本操作。”
“We can always replace the complex ``primitives'' by simpler primitive operations.”
§5.1 Designing Register Machines查看原文 →
另外 2 处张力,以及 8 条证据上的局限
- 如果每个像样的程序已经是某种语言的求值器,那么“不断迁移到新语言”的劝告,就有变得无法证伪、而不是真正的设计选择的风险。
“然而,随着我们面对越来越复杂的问题,我们会发现,Lisp,或者说任何固定的编程语言,都不足以满足我们的需要。”
“However, as we confront increasingly complex problems, we will find that Lisp, or indeed any fixed programming language, is not sufficient for our needs.”
Chapter 4 Metalinguistic Abstraction查看原文 → - 抽象同时向上追到语言设计、向下追到数据通路,而本书并没有讲定,对一个具体问题,从业者该走哪个方向。
“然而,随着我们面对越来越复杂的问题,我们会发现,Lisp,或者说任何固定的编程语言,都不足以满足我们的需要。”
“However, as we confront increasingly complex problems, we will find that Lisp, or indeed any fixed programming language, is not sufficient for our needs.”
Chapter 4 Metalinguistic Abstraction查看原文 →
证据上的局限
- 反对数据结构泛滥的理由,是当作语言社群之间的文化对比来论证的,而不是通过任何实测比较。
“让 100 个函数操作一种数据结构,比让 10 个函数操作 10 种数据结构要好。”
“It is better to have 100 functions operate on one data structure than to have 10 functions operate on 10 data structures.”
Foreword查看原文 → - 教学上的证据,来自一所学校的必修入门课,所以关于初学者能学会什么的主张,建立在单一的教学环境上。
“第二,我们认为,在这个层次上,一门课程所要讲授的基本内容,不是某种编程语言结构的语法,也不是为高效计算特定函数而设计的巧妙算法,甚至不是对算法的数学分析和计算的基础,而是用来控制大型软件系统智力复杂性的那些技术。”
“Second, we believe that the essential material to be addressed by a subject at this level is not the syntax of particular programming-language constructs, nor clever algorithms for computing particular functions efficiently, nor even the mathematical analysis of algorithms and the foundations of computing, but rather the techniques used to control the intellectual complexity of large software systems.”
Preface to the First Edition查看原文 → - 关于抽象带来维护好处的主张,靠的是小型的演算例子,比如对有理数构造函数的一处局部修改,而不是大型系统的数据。
“把对表示的依赖限制在少数几个接口过程里,既帮助我们设计程序,也帮助我们修改程序,因为这让我们保有考虑别的实现方案的灵活性。”
“Constraining the dependence on the representation to a few interface procedures helps us design programs as well as modify them, because it allows us to maintain the flexibility to consider alternate implementations.”
§2.1 Introduction to Data Abstraction查看原文 → - 仅仅 define 是一种有限的抽象手段,所以三种机制的说法,要等到引入过程定义之后才算完全站得住。
“每一种强大的语言,都有三种机制来完成这件事:”
“Every powerful language has three mechanisms for accomplishing this:”
§1.1 The Elements of Programming查看原文 → - 赋值引进的那些难题——同一性、时间和并发——在所概括的材料里是被点名为后果,而没有被解决。
“处理对象、变化和同一性的困难,是我们在计算模型里必须同时间打交道的一个根本后果。”
“The difficulties of dealing with objects, change, and identity are a fundamental consequence of the need to grapple with time in our computational models.”
Chapter 3 Modularity, Objects, and State查看原文 → - 放弃固定语言的动机,依据的是一个跨领域的电路设计类比,而“像样的程序就是求值器”的证据,来自本书自己的三个案例,它们是因为本来就带有类似语言的结构才被选中的。
“然而,随着我们面对越来越复杂的问题,我们会发现,Lisp,或者说任何固定的编程语言,都不足以满足我们的需要。”
“However, as we confront increasingly complex problems, we will find that Lisp, or indeed any fixed programming language, is not sufficient for our needs.”
Chapter 4 Metalinguistic Abstraction查看原文 → - 正则序、非确定性和逻辑求值,是后面各节的预告,而不是这里所概括的材料中展示的成果。
“把求值器作为 Lisp 程序提供出来的一个重要好处是:我们可以把别的求值规则描述成对求值器程序的修改,从而实现它们。”
“An important advantage of making the evaluator accessible as a Lisp program is that we can implement alternative evaluation rules by describing these as modifications to the evaluator program.”
Chapter 4 Metalinguistic Abstraction查看原文 → - 寄存器机器和递归的论证,靠的是像 GCD 这样的小型示意机器,以及对控制器各点上寄存器状态的推理,而不是现实的设计或测量。
“因此,我们将从硬件架构师的角度,而不是机器语言程序员的角度,来着手我们的任务。”
“Thus, we will approach our task from the perspective of a hardware architect rather than that of a machine-language computer programmer.”
Chapter 5 Computing with Register Machines查看原文 →
全书概念导图
5 组概念:通俗的叫法、一句话解释,以及原书自己的用语。
1 · 驯服复杂性,而不是证明
- 真正的技能:把想法保持得小到能放在脑子里
- 核心的工程技能:抽象、约定的接口和新的描述性语言,使大型系统在智力上可以管理。原书用语: Controlling intellectual complexity
- 知道一样东西,就是知道一步一步怎么做
- 从“怎么做”的角度来研究知识,对照数学“是什么”的角度。原书用语: Procedural epistemology
- 程序在机器内部引发的、看不见的活动
- 机器内部那个看不见但真实存在的实体,在程序的指引下,随着演化操作数据。原书用语: Computational process
2 · 语言只有三个部分
- 把一整块东西起个名字,这样就能把它当一个词来用
- 让复合元素可以被命名、并当作单一单元来处理的语言特性;控制复杂性的核心手段,先用于过程,再用于数据。原书用语: Means of abstraction
- 括号:一个运算符加上它的各部分,永远嵌套下去
- 一个带括号的应用,运算符在最左,按一条递归的一般规则求值,只有自带规则的表达式类型例外;有些可以通过翻译成别的类型派生出来。原书用语: Combination and special forms
- 把参数换进名字的位置,来算出一次调用
- 把过程应用理解为用实参对形式参数做文本替换;没有赋值时成立,有了赋值就失效。原书用语: Substitution model
- 解释器用来查找东西的、相互链接的名字表
- 解释器的“名字-对象”存储:一串把约束变量与值配对的框架,由内向外搜索,给每个变量一个可变的位置。原书用语: Environment of frames
3 · 由承诺来定义的数据
- 一对“造出来”与“取回来”的过程,必须信守承诺
- 一种表示之所以合法,是因为它的构造函数和选择函数满足所述条件,而不是因为背后有存储。原书用语: Data as conditions on constructors and selectors
- 少数几个过程筑成的一堵墙,隐藏数据是怎么存的
- 一道层次的边界,一小组接口过程把数据抽象的使用者同它的实现者隔开。原书用语: Abstraction barrier
- 一块有两个槽的粘合件,一切结构都由它做成
- 由 cons 构造、由 car/cdr 读取的唯一基本复合结构,可以实现为一个按要求返回其各部分的过程;它的闭包性质产生层次结构。原书用语: Pair and procedural representation of data
- 每一级传递的形状都相同,所以各部件可以卡在一起
- 约定每一级传递同一种结构,这样标准的、可输出的部件就能自由地混搭;统一性优于结构的泛滥。原书用语: Conventional interfaces
4 · 状态使对象能给变化建模
- 按照被建模的东西的分法,来划分程序
- 把程序构造得像被建模的系统那样分解,包括让数据去迎合世界,而不是让控制复杂化。原书用语: Modularity by mirroring the world
- 一个记得自己过去、因而回答会有所不同的对象
- 一个行为取决于其历史的对象,它的历史被概括在状态变量里,并且在各部分变化的同时保持同一性。原书用语: Local state and identity under change
- 一旦值可以变化,相等的东西就不再能互相交换
- 相等的东西可以互相代换而不改变值,同一个调用总返回同一个结果——这是不带赋值的程序设计所具有的性质,命令式的更新把它们放弃了。原书用语: Referential transparency and functional versus imperative style
5 · 运行程序的程序
- 别只写程序了;去设计你所需要的语言
- 从编写程序上升一层,去设计、构造程序所用的语言。原书用语: Metalinguistic abstraction
- 一个决定表达式含义的朴素程序
- 一个普通的、可以修改的过程,它给表达式以含义,办法是把“环境中的表达式”归约为“应用于实参的过程”,再归约回去;用它自己的语言写成时就是元循环的,喂给它一台机器的描述时就是一台通用机器。原书用语: Evaluator and the eval/apply cycle
- 下降到导线和单元:对几个有名字的槽位一步一步操作
- 一台一步一步地对有名字的单元执行指令的机器,寄存器和操作是数据通路,一个显式的控制器序列决定下一步运行什么;通过逐条指令的执行过程和一个 pc 寄存器来模拟。原书用语: Register machine with data paths and controller
- 让递归运转起来的,是一个存着返回位置的栈
- 一个 continue 寄存器记录一段共用的指令序列该从哪里继续,而一个后进先出的、存放已保存寄存器的存储,使一份机器副本可以服务每一个嵌套的或树形递归的调用。原书用语: Stack, subroutines and recursion
值得停下来的段落AI 陪读
几处值得停下来的原文,每处附一句它为什么对全书要紧。
“程序必须写给人读,只是顺带才给机器执行”
“programs must be written for people to read, and only incidentally for machines to execute”
查看原文 →全书弧线的公理:如果程序是写给人看的散文,那么之后的每一种手段——抽象屏障、数据导向的分派、显式的寄存器机器——都是给读者的辅助,而不是对机器的优化。
“完整的形式化正确性论证很少伴随大型程序”
“complete formal arguments of correctness seldom accompany large programs”
查看原文 →“证明无法扩展”这一承认,是五章全部的隐含前提:习惯用法、品味和组织技术,在正确性证明走到头的地方顶了上来。
“我们忘掉了这些符号代表什么”
“we forget about what the symbols stand for”
查看原文 →“忘掉”是统一全书的那个动词——代换模型、抽象屏障、类型塔和编译后的词法地址,都是获得许可的遗忘。
另外 9 条批注
“注意,表达式 (withdraw 25) 求值两次,产生的值不同。”
“Observe that the expression (withdraw 25), evaluated twice, yields different values.”
查看原文 →全书的支点:一个表达式求值两次,就结束了第1到2章的函数式天真,并迫使时间、同一性、流,最终是显式控制,进入故事。
“求值器,也就是决定编程语言中表达式含义的东西,不过是另一个程序。”
“The evaluator, which determines the meaning of expressions in a programming language, is just another program.”
查看原文 →从语言使用者到语言建造者的铰链,也是让第5章的寄存器机器成为必然、而不是附录的那句话。
“元循环求值器本身是一个 Lisp 程序,因而继承了底层 Lisp 系统的控制结构”
“the metacircular evaluator is itself a Lisp program and hence inherits the control structure of the underlying Lisp system”
查看原文 →本书在审计自己:它点出了第4章欠下的债——用求值来解释求值——而这个坦白,是进入第5章唯一诚实的入口。
“他们应当知道不该读什么,以及在任何时刻不需要理解什么。”
“They should know what not to read, and what they need not understand at any moment.”
查看原文 →获得许可的无知,是书里每一道抽象屏障在人这一侧的对应物——正是这种纪律,让分层的系统对一个小小的头脑真正可处理。
“我们组织一个大型程序的方式,是由我们对所要建模的系统的认识决定的”
“the way we organize a large program is dictated by our perception of the system to be modeled”
查看原文 →“认识”承认了本书的架构建模的是我们的世界观,而不是世界本身——这就是为什么第3章提供两种相互竞争的策略,第4章为每个问题提供一种新语言。
“单一的复合数据基本体——序对,由过程 cons、car 和 cdr 实现——是我们唯一需要的粘合剂。”
“The single compound-data primitive pair, implemented by the procedures cons, car, and cdr, is the only glue we need.”
查看原文 →全书所押的一个朴素宣称:一个两个槽的盒子,必须承载序列、树、通用程序包、流,最后是解释器自己的数据,而它做到了。
“让我们人类感到困惑的,是像下面这样仍然相对简单的表达式”
“It is we humans who get confused by still relatively simple expressions such as”
查看原文 →受限的资源很早就被点名,且从未改变:不是时钟周期,也不是内存,而是人的工作记忆,书里的每一项技术都是针对这个约束而设计的。
“计算机科学本身不过是(也不多于)构造恰当的描述性语言的学科”
“computer science itself becomes no more (and no less) than the discipline of constructing appropriate descriptive languages”
查看原文 →本书花了五章去挣得的、对整个领域的重新定义——在这个框架下,连第5章下降到硬件,也读成了语言设计。
“应当避免控制上的复杂性,集中精力去组织数据”
“one should avoid complexities of control and concentrate on organizing the data”
查看原文 →一句话的论题,预测了全书的重心:数据的组织高于控制上的花巧,从序对一路到数据导向的分派都信守这一点,只有第5章不得不让控制本身成为数据时才打破。
读后感AI 陪读
否认它自己的名字:不是科学,几乎与计算机无关
SICP 是一本书,只有一个主题:对智力复杂性的管理,一路追到管理的工具本身变成了主题。前面的部分直截了当地说明了这套方案——佩利的前言是一份宣言,其真正的论证是一次认识论上的撤退,只是装扮成了胜利(形式化证明无法扩展,所以编程必须成为一种由可信的习惯用法和组织技术构成的手艺传统),而序言把这个领域从工程挪到了方法的人文学科,否认了它自己的名字:不是科学,与计算机也几乎无关。致谢随后说明了这为什么站得住——这不是一本教科书,而是一种教学文化的自画像:祖先、趣味上的合作者构成的同心圆,以及十五年里点名的习题课老师。从这里起,脊梁攀一道梯子,每一级都建在上一级的天花板上。第1章把过程当作抽象的单位,并且在“给模式命名,而不只是实例化模式”这一愿望的驱动下,使过程成为一等公民——抽象本身成了可操作的对象。第2章对数据做同样的事,而外尔的题词给了它动词:忘掉。数据不是由存储定义的,而是由构造函数和选择函数所满足的条件定义的,所以表示可以被替换、被标记、被可加地分派、沿着一座塔被强制——直到多项式的最大公因子标出极限:类型抗拒唯一的排序。第3章是书有意失去天真的地方:用 set! 买来的状态,换来模块化并镜像世界,却失去代换模型、引用透明性和稳定的“相同”概念,把时间强行带进计算;流的回答是把整个历史变成数据,却需要显式的延时,拒绝与可变性混用,并在独立主体合并的那一刻把同步带了回来。哪种观点都不令人满意,而书这样说了。第4章通过改变问题来化解这个僵局:求值器是一个普通程序,所以当语言配不上问题时,就去写一个求值器——而一个小小的通用求值器,靠语法变换和数据抽象保持小巧,一次改几行,就改成了正则序、非确定性和逻辑程序设计。第5章随后偿还这种循环一直在欠下的债:元循环求值器在没有点名的情况下继承了宿主的控制结构,所以书下降到寄存器、栈、显式延续、带类型的指针、垃圾收集,以及一个编译自己的编译器。整条弧线上最尖锐的判断是:每一项表达力上的收益都是要付出代价的,而书总是点出代价——代换模型从第一章起就被说成是一种简化,串行化器仍会死锁,合一必须拒绝自指,封闭世界的“非”是一个有理由的缺陷,而编译带来的加速是靠测量证实、不是靠断言。第二尖锐的是结构上的:梯子的每一级都是同一个动作,即反身的转向,你一直在用的机制,变成了你现在去构造的对象——过程,数据,时间,语言,机器。对读者我想说:不要为 Scheme 读它,也不要被动地读。习题不是练习,而是论证缺失的中间部分,而各章对自身不足的坦白,正是思想真正活着的地方。这本书真正的礼物是一种思维习惯——对任何系统,问它让你忘掉什么,逼你记住什么,两者的差别要付出什么——而这种习惯,比它所教授的任何一门语言都活得更久。
《计算机程序的构造和解释》是怎样被读的
6 位读者,以及他们各自拿这本书做了什么。
- 2000 · 翻译
和田英一
把第二版改写成日文,供课程使用
SICP 日文版(2000) - 2001 · 重写
萨斯曼与怀兹德姆
把本书的方法改写成经典力学
SICM(MIT 出版社,2001) - 2004 · 翻译
裘宗燕
把第二版译成中文
SICP 中文版(2004) - 2004 · 评论
费莱森等
主张用分阶段的设计秘诀取代这门课
费莱森等,JFP 2004 - 2011 · 评论
布赖恩·哈维
在 MIT 停开 6.001 之后,为本书的持久价值辩护
《为什么 SICP 很重要》(2011)
另外 1 位读者
- 2022 · 改编
亨茨与里格斯塔德
把全书正文和习题改写成 JavaScript
SICP:JavaScript 版
网络书评怎么说
在这些读者书评里,SICP 被当作一部教的是编程、而不是 Scheme 的经典:评论者称赞它从第一性原理出发的整合式路径,称赞它那些大型的真实示例程序——解释器、编译器、电路模拟器——以及他们称为理解所必需的习题。有几位说,它改变了他们写代码的方式,同时承认一种常见的批评:对初学者来说太难。
综合已发表书评的看法;不展示评分,完整书评请点来源链接。
来源与授权
CC BY-SA 4.0原书
- 《计算机程序的构造和解释》(Structure and Interpretation of Computer Programs)第二版,作者 Harold Abelson、Gerald Jay Sussman,Julie Sussman 参与(© 1996 Massachusetts Institute of Technology),由 MIT 出版社以知识共享 署名-相同方式共享 4.0 国际许可协议(CC BY-SA 4.0)发布。免费阅读原书 · 许可证
- 本页由 FeedLitra 撰写的文字(摘要、批注、读后感)按 CC BY-SA 4.0 授权。
- 本页的引文中文译文属于原作的改编作品,按同一协议(CC BY-SA 4.0)授权。
- 摘要、论点、批注和读后感由 FeedLitra 根据全书原文 AI 辅助生成,引文已对照原文核对。最近更新:2026 年 10 月 21 日。
常见问题
关于《计算机程序的构造和解释》,读者常问
应该读 SICP 的哪个版本?
一共三版。第一版(1984)出自 MIT 的入门课。第二版(1996)就是大家通常说的“SICP”:作者重新设计了解释器、寄存器机器模拟器和编译器,把示例改写成任何符合 IEEE 标准的 Scheme 都能运行,并补充了并发与非确定性计算。另有 Martin Henz、Tobias Wrigstad 与 Julie Sussman 改编的 JavaScript 版(MIT 出版社,2022),保留同样的五章结构。熟悉的语法对你有帮助就选 JavaScript 版,想读原文、配原版讲座就选 Scheme 版;两者都不是用来学这门语言本身的教程。
SICP 难读吗?
难在概念,不在数学。开头的例子只用到普通算术(开平方、最大公约数、素数检测);真正难的是递归、程序如何一步步求值,以及程序一旦能“记住”东西之后会发生什么变化。1984 年的序言说,当年 MIT 的学生大多没有受过正规的计算训练;1996 年的序言也承认,内容已经多到一个学期讲不完。不需要编程基础,但要准备好开着电脑边读边做习题。
应该按什么节奏读?
以下是我们的建议,不是作者的教学大纲:把大部分时间放在第 1–3 章,大约每周一到两节,并做完习题;第 4、5 章当作第二阶段再读。作者在序言里自己也说,他们有时会跳过逻辑程序设计(4.4 节),让学生使用寄存器机器模拟器但不讲它的实现(5.2 节),对编译器(5.5 节)只做简要介绍。你也可以这样安排。
读的时候有哪些配套资源?
MIT OpenCourseWare 上有 Abelson 和 Sussman 1986 年录制的 20 讲视频,以知识共享协议发布;它们对应第一版,但用来配第二版也合适。读 Scheme 版可以用自由软件 MIT/GNU Scheme,或 Racket 的 “#lang sicp” 包;读 JavaScript 版可以用 Source Academy 网站的交互环境。
读者通常卡在哪里?
常见的有五处。括号一开始看着别扭,过一两天就习惯了,例子最好每个都亲手敲一遍。1.2 节里,一个调用自身的过程仍然可以以循环方式执行,用小输入在纸上跟踪,直到分清“递归过程”和“递归计算过程”。3.2 节的环境图自己动手画比光看容易懂。第 4 章刚开始会觉得绕,4.1 节要慢慢读,用很小的表达式去跑求值器。最后,只有动手做点东西,这些想法才会留下来,所以每一节至少做几道习题。
在 FeedLitra 里一边读《计算机程序的构造和解释》,一边看这些卡片。
开始读书