
视频元信息
- 课程:MIT 6.001 Structure and Interpretation of Computer Programs(SICP)
- 讲次:第 5 讲(p06)——亨德森-埃舍尔图形语言(Henderson-Escher Picture Language)
- 主讲:Hal Abelson(1986 年原版录像)
- 性质:MIT 本科一年级必修课,Scheme/Lisp 语言入门
内容摘要(TL;DR)
本讲从向量的数据抽象出发,引出闭包(closure)概念与表(list)的约定表示,进而用递归与高阶过程实现 map/for-each。核心部分是 Peter Henderson 设计的图形语言:用过程表示"图片",把 beside/above/rotate/flip 等组合手段实现为过程,使语言天然具备闭包性;再借助 push 与 repeated 实现递归图形,最终生成埃舍尔式的 Square Limit 图案。最后引出"程序即语言"的软件工程观。
一、回顾:数据抽象与向量运算 [00:21]
本讲开篇回顾此前建立的两条主线:
- 数据抽象:用
cons/car/cdr构造和访问序对(pair)。关键点:cons/car/cdr究竟如何实现是无关紧要的——它可以是原语,也可以用某种奇怪的过程实现,这是抽象的"实现细节可替换"原则。 - 具体实例:有理数算术、向量运算。
向量加法与数乘
- 向量加法:
v1 + v2的结果向量,其 x 坐标是两者 x 坐标之和,y 坐标是两者 y 坐标之和。(define (make-vector x y) (cons x y)) (define (add-vectors v1 v2) (make-vector (+ (x-coordinate v1) (x-coordinate v2)) (+ (y-coordinate v1) (y-coordinate v2)))) - 向量数乘(scale):把向量 v 乘以标量 s,得到同方向但更长的向量,各坐标分别乘以 s。
过程的另一种定义写法(易错点)
视频中特别提醒了两种定义方式的区别:
- 旧写法:
(define make-vector (lambda (x y) (cons x y))),即"定义一个接受 x、y 两个参数并执行 cons 的过程"。- 新写法:
(define make-vector cons),即"把 make-vector 定义为 cons 这个东西本身"。两者几乎等价,但含义微妙不同。第二种写法强调:过程也是一种对象(object),可以被命名、被传递。这是后面高阶过程与"过程即数据"思想的伏笔。
理解:make-vector 与 cons 在行为上等价,但语义上 make-vector 只是一个指向 cons 的别名。这提醒我们"命名"本身就是一种抽象手段。
二、用序对表示线段与"序对的序对" [04:10]
cons 不仅能粘合数字,还能粘合任意对象。
线段的表示
一条从向量 (2,3) 到向量 (5,1) 的线段,可以表示为一个"序对的序对":
(define (make-segment start end) (cons start end))
(define (segment-start s) (car s))
(define (segment-end s) (cdr s))
剥开抽象层看实际结构:
- 整个线段是一个 pair;
- 它的
car是一个 pair(起点向量),cdr也是一个 pair(终点向量); - 起点向量的
car/cdr分别是 2 和 3。
易错点:很多人以为盒箭头图中箭头"朝下"或"朝上"有区别——完全无关。箭头方向只表示连接关系,不代表任何语义差别。
三、闭包(Closure):构建复杂性的关键 [05:43]
这是本讲最重要的概念之一。
定义:用 cons 组合出的 pair,本身又可以继续用 cons 组合成更复杂的对象。
数学家的说法:Lisp 中的数据对象集合,在"构造序对"这个运算下是封闭的(closed)。
为什么这很重要:
- 闭包性让我们能不断叠加复杂度,而不会被"卡"在 pair 这一层。
- 反例(重点):很多语言的组合手段不封闭。例如 BASIC 和 FORTRAN 中构造数组的运算就不封闭——你可以造"数字的数组"或"字符串的数组",但不能造"数组的数组"。
考点:考察任何组合手段时,都要问一句——这个东西在它的组合手段下是否封闭? 这是判断一种语言能否优雅构建复杂结构的关键标准。
四、表(List):序对的链式约定 [07:15]
把 1、2、3、4 粘在一起有很多种方式,为了避免每次都临时做决定,Lisp 采用一种约定来表示序列,这就是表(list)。
表的表示
1 → 2 → 3 → 4 → nil
- 序列中每个元素对应一个 pair;
- 每个 pair 的
car是当前元素,cdr指向下一个 pair; - 末尾用特殊标记
nil(end-of-list marker)表示"没有更多元素"。
构造与语法糖
手工构造是层层嵌套的 cons:
(cons 1 (cons 2 (cons 3 (cons 4 nil))))
太啰嗦,于是有语法糖 list:
(define 1-to-4 (list 1 2 3 4))
(list 1 2 3 4) 完全等价于上面那串嵌套 cons。
访问元素
| 表达式 | 结果 | 含义 |
|---|---|---|
(car 1-to-4) |
1 | 第一个元素 |
(car (cdr 1-to-4)) |
2 | 第二个元素 |
(car (cdr (cdr 1-to-4))) |
3 | 第三个元素 |
(cdr 1-to-4) |
(2 3 4) |
去掉首元素后的"剩余表" |
(cdr (cdr 1-to-4)) |
(3 4) |
剩余表 |
(cdr (cdr (cdr 1-to-4))) |
() |
nil 本身,打印为空括号 |
打印约定:Lisp 把这种序对链打印成括号包裹的元素序列,如 (1 2 3 4)。
理解:(cdr ...) 反复作用就是"沿着表往下走"(cdr down the list),这是处理表的通用动作。
五、递归处理表:从 scale-list 到 map [15:44]
例子:scale-list
目标:把表 (1 2 3 4) 每个元素乘以 10,得到 (10 20 30 40)。
递归策略:
- 假设已经对表的剩余部分(
(cdr l))做好了缩放; - 把首元素
(car l)乘以 s; - 用
cons把结果"粘"到剩余部分前面; - 递归到表尾(nil)时,直接返回 nil。
(define (scale-list l s)
(if (null? l)
nil
(cons (* (car l) s)
(scale-list (cdr l) s))))
null?:判断是否为表尾标记(即"表里还有没有元素")。- 通用模式:沿表 cdr 下去(cdr down),把结果 cons 上来(cons up)。
提炼通用模式:map
既然存在通用模式,就不该为每个具体任务重写一遍——应该把模式本身写成过程,这就是高阶过程 map。
map 的语义:接受一个过程 P 和一个表 L,返回"把 P 应用到 L 每个元素"后得到的新表:
(map P (e1 e2 ... en)) = ((P e1) (P e2) ... (P en))
实现(与 scale-list 结构完全一致,只是把"乘 10"换成"应用 P"):
(define (map p l)
(if (null? l)
nil
(cons (p (car l))
(map p (cdr l)))))
用 map 重写 scale-list:
(define (scale-list l s)
(map (lambda (x) (* x s)) l))
map 的示例
| 表达式 | 结果 |
|---|---|
(map square (list 1 2 3 4)) |
(1 4 9 16) |
(map (lambda (x) (+ x 10)) (list 1 2 3 4)) |
(11 12 13 14) |
重要思想:从控制结构转向聚合操作 [22:20]
考点:一旦用
map思考,你就不再关心它是迭代还是递归过程——你只说"这是一个聚合(aggregate),我要变换其中每一项"。控制结构的细节被抽象掉了。这一思想源自 APL 语言:停止思考控制结构,开始思考对聚合的操作。课程中段讲"流处理(stream processing)"时,这一世界观将大放异彩。
思考:map 既可以写成递归过程,也可以写成迭代过程(视频提到可以这么做)。但一旦接受 map 这个抽象,这种区分对使用者就变得不重要了——这正是抽象的力量。
六、for-each:不构造新表的遍历 [24:10]
与 map 极其相似但目的不同的操作:for-each。
map:对每项应用过程,返回结果的表(构造新集合)。for-each:对每项应用过程,按顺序执行副作用,不构造新表。
(define (for-each p l)
(if (null? l)
'done
(begin (p (car l))
(for-each p (cdr l)))))
典型用途:打印表中每一项、把一组图形画到屏幕上、让终端响铃等——这些只关心"做动作",不关心返回值。
map 与 for-each 的关键区别(问答环节要点)
易错点 / 考点:
- 过程类型不同:视频中的
map演化出递归过程(因为它要等剩余部分的结果回来,才能 cons 到前面);for-each演化出迭代过程(没有需要等待的值)。- 但这不是本质区别:
map也可以写成迭代风格,只是视频里没那么写。for-each不会创建新副本,除非你显式让它这么做。它只是"做事情"。- 能否用
for-each写map?理论上可以(边遍历边 cons 构造结果),但需要额外安排。
关于嵌套表
若表里套着表(如 ((1 2) (3 4))),map/for-each 只处理最外层——对它们而言,"第一项"就是内层那个表本身,不会自动深入。
拓展【拓展】:要真正深入到"树的叶子",需要写树递归(tree recursion)——即对元素判断:若是表则递归处理,否则直接处理。这与 map/for-each 是不同的模式。
七、引入元语言抽象与 Henderson 图形语言 [28:40]
第三大主题:元语言抽象(Metalinguistic Abstraction)
处理复杂性的又一手段:构建一门合适的、强大的语言。
核心方法论(本课程最重要的观点之一):思考一门语言时,问三个问题:
- 什么是原语(primitives)?
- 什么是组合手段(means of combination)?
- 什么是抽象手段(means of abstraction)——如何把构造出的东西装进黑盒,再作为元素去构造更复杂的东西?
Peter Henderson 的图形语言
- 作者:Peter Henderson,苏格兰 Stirling 大学。
- 目标:描述并绘制自相似、递归式的图形,例如埃舍尔(Escher)的木刻《Square Limit》——中间一个图案,向四周不断缩小、自相似地"渗出"。
- 另一主题:过程与数据没有本质区别。学完本讲,你应当对"什么是过程、什么是数据"彻底困惑。
八、图形语言的原语:picture [32:30]
只有唯一一个原语:picture。
关键定义(易错点):picture 不是你想象的那样(不是一张静态图片),而是一个过程——给它一个矩形,它就把图形缩放绘制到该矩形内。
- 例:同一张 George 的图,给不同矩形,就画出不同大小/形状的 George(瘦的、胖的)。
- 屏幕上淡淡的矩形轮廓只是示意,不是 picture 的一部分。
理解:picture 的"数据"本质是"一个能根据矩形参数绘图的动作"。这是"过程即数据"的第一次具体体现。
九、组合手段:rotate、flip、beside、above [33:50]
一元操作
- rotate:把 picture 旋转 90 度。给一个矩形,就画出旋转后的图像,并缩放填入该矩形。
- flip:水平或垂直翻转。
二元操作
- beside:
(beside A B s),s 是 0 到 1 之间的数。- 把给定矩形按 s : (1−s) 横向切成左右两块;
- 左块画 A,右块画 B。
- 若 s = 0.25,则 A 被压得很窄,B 占大部分。
- above:同上,但纵向切分、上下摆放。
示例:George 和"他的小兄弟"就是用 beside/above 组合出来的;还有 empty-picture(空图)等构造。
十、核心技术:用过程表示图片与坐标映射 [37:20]
单位正方形到矩形的线性变换
问题:如何把一个在单位正方形(0≤x≤1,0≤y≤1)中定义的图形,画到任意矩形里?
解答(线性代数):设矩形的原点为 O,水平向量为 H,垂直向量为 V。点 (x, y) 映射到:
O + x·H + y·V
推导:单位正方形的左下角 (0,0) 映到 O;右下角 (1,0) 映到 O+H;左上角 (0,1) 映到 O+V。任一点由 x、y 线性插值即得。
coordinate-map 过程
(define (coordinate-map rect)
(lambda (point)
(let ((x (x-coordinate point))
(y (y-coordinate point)))
(add-vectors
(rect-origin rect)
(add-vectors
(scale-vector (rect-horizontal rect) x)
(scale-vector (rect-vertical rect) y))))))
考点:
coordinate-map接受一个矩形,返回一个"作用于点的过程"。这是"过程作为返回值"的典型例子,也是后面组合手段能自动闭包的关键。
make-picture:从线段表构造图片
(define (make-picture segments)
(lambda (rect)
(for-each
(lambda (s)
(draw-line
((coordinate-map rect) (segment-start s))
((coordinate-map rect) (segment-end s))))
segments)))
- picture 是"接受矩形、绘制图形"的过程;
- 对线段表中每条线段,把两端点用该矩形的
coordinate-map变换后画线; draw-line是系统内建原语。
使用方式:
(define r (make-rectangle ...))
(define g (make-picture (list (make-segment ...) ...)))
(g r) ; 把 g 画进矩形 r
十一、组合手段的优雅实现与闭包性 [46:00]
beside 的实现
(define (beside p1 p2 a)
(lambda (rect)
(p1 (make-rectangle (rect-origin rect)
(scale-vector (rect-horizontal rect) a)
(rect-vertical rect)))
(p2 (make-rectangle (add-vectors (rect-origin rect)
(scale-vector (rect-horizontal rect) a))
(scale-vector (rect-horizontal rect) (- 1 a))
(rect-vertical rect)))))
理解:beside 返回一个新 picture(一个接受矩形并绘图的过程)。它把矩形按 a 切成两半,分别让 p1、p2 在各自矩形里绘制自己。唯一要算的就是这两个子矩形。
rotate 的实现
(define (rotate90 p)
(lambda (rect)
(p (make-rectangle (rect-origin rect)
(rect-vertical rect)
(scale-vector (rect-horizontal rect) -1)))))
理解:让 picture 在一个"侧过来"的矩形里绘制自己,就实现了旋转。
闭包性自动获得(重点)
考点:
beside只管把矩形递给 p1、p2,完全不关心 p1 是原始图片、还是某个 beside/rotate 组合出来的结果。只要给 p1 一个矩形,它就会画点什么。这就是用过程表示图片带来的自动闭包性:组合出来的结果仍然是图片,可以继续参与组合。组合手段的实现因此既简单又优雅。
十二、抽象手段与语言的"嵌入" [51:00]
组合手段本身就是过程 → 抽象能力自动继承
因为 beside/above/rotate 都是过程,所以 Lisp 中一切操作过程的能力都自动可用:
- 过程组合:定义
four-pictures(把 A、B、C、D 四张图摆成特定布局),只需写一个过程,把beside/above组合起来即可——无需在图形语言里专门实现。 - 参数化:想让四张图各自旋转 90 度?加个参数就行,自动可用。
- 递归:可以定义递归组合手段。
递归组合手段:right-push
(define (right-push p n a)
(if (= n 0)
p
(beside p (right-push p (- n 1) a) a)))
理解:把 p 与"递归 right-push 的结果"并排放置。n=0 时返回 p 本身。
效果:(right-push george 2 0.75) 得到 George 与自身并排、缩小 0.75 倍的图案。
更复杂的递归:向四周扩散
把 beside 和 above 结合,递归地向旁边和上方推,就能得到类似埃舍尔 Square Limit 的效果。
对比要点:埃舍尔的图是精心设计的——鱼身旋转、缩小后能无缝"渗入"下一张;而 George 是任意设计的,拼起来会有明显接缝。
拓展【拓展】:一个很好的项目是写一个过程,自动调整 George 图形中线段端点的位置,使得在 Square Limit 变换下能完美拼接。
十三、用高阶过程推广递归组合:push 与 repeated [1:02:00]
抽象出通用模式
right-push、up-push、corner-push 都是同一思想的特例:把某种组合手段递归地重复应用。于是抽象出 push:
(define (push comb)
(lambda (p n a)
((repeated
(lambda (p) (comb p p a)) ; 把 comb 作用到 p 与自身
n)
p)))
其中 repeated 是另一个高阶过程:接受一个过程和一个数字 n,返回一个"把该过程应用 n 次"的新过程。
(define (repeated f n)
(if (= n 0)
(lambda (x) x)
(lambda (x) (f ((repeated f (- n 1)) x)))))
特化
(define right-push (push beside))
(define up-push (push above))
(define corner-push (push 某种同时 beside+above 的组合))
易错点:
push里嵌套了lambda、repeated、comb,是相当绕的高阶过程用法。视频明确建议:如果你对 lambda 还不熟,这是绝佳的练习——把push展开、跟踪求值过程,能极大加深对高阶过程的理解。
十四、软件工程观:分层语言 vs. 严格树状分解 [1:05:00]
传统"软件工程神话"
教科书式的自顶向下分解:把任务拆成三个子任务,每个子任务再拆成三个……直到每个叶子节点都被精确、完整地定义,最终拼成一棵完美的树。
批判:视频直言"只有计算机科学家才会相信能这样构建复杂系统"。这种严格树状结构不健壮——改动一个叶子节点,可能颠覆整棵树的分解方式。
Henderson 式的"分层语言"
实际做法是一层层的语言:
- 底层:谈论向量、线段、点在单位正方形中的位置——原始图片的语言。
- 中层:几何组合子的语言——
above、beside、right-push、rotate等,谈论的是底层构造的对象。 - 上层:组合方案的语言——如
push,谈论"以某个缩放因子反复做某事",谈论的是中层的东西。
每一层谈论的对象,都是上一层建立起来的东西。
为什么分层语言更健壮(重点)
考点:
- 树状分解中,每个节点为特定任务而设计;
- 分层语言中,每一层提供完整的语言能力,不是为某个特定任务而设,而是为"谈论一整类事物"而设。
后果:分层设计更健壮(robust)——修改描述时,改动更容易被下一层语言的相应改动所吸收。因为你给的是整套词汇,而非某个具体的东西(如某个特定的 beside),所以规格变化时方法论更容易适应。
反之,严格树状设计不健壮:改一处可能影响下面整棵树的分解方式。
不同层次提供不同的"谈论变更的词汇"
回到 George 的例子,想修改图形时:
- 最底层:移动某个向量的端点——在原始设计层讨论;
- 中层:改某个复制元素的 beside 缩放因子——在组合子层讨论;
- 最高层:改变递归分解方式(比如不再向角落扩散)——在方案层讨论。
因为系统被组织成分层结构,你拥有多种词汇来描述变更,并能灵活决定用哪一种。
十五、本讲总结 [1:12:00]
- 语言嵌入(embedding):把一门语言"漂亮地嵌入"另一门语言,使宿主语言(Lisp)的全部能力自动成为被嵌入语言的自然扩展,而不丧失原有能力。
- 对比:仅仅"实现"一门语言 vs. "嵌入"一门语言。
- 过程与数据无本质区别:picture 是过程,rectangle 是抽象,
push是过程又接受 picture 作参数……到这一层,"什么是过程、什么是数据"已彻底模糊——它两者皆是,或两者皆非。 - 设计即造语言:工程设计的本质不是实现程序,而是实现语言(或一系列语言层次)。这正是 Lisp 的真正威力所在。
- Lisp 的定位:Lisp 作为解决某个特定问题的语言很糟糕;它擅长的是帮你找到你想要的语言,并把它嵌入 Lisp——这才是这种设计方法的真正力量。
关键标签 / 关键词
SICP 数据抽象 闭包性 cons/car/cdr 表 list nil 递归 cdr down / cons up 高阶过程 map for-each lambda repeated Henderson 图形语言 picture beside above rotate flip coordinate-map right-push push Square Limit 埃舍尔 元语言抽象 语言嵌入 过程即数据 分层语言 软件工程方法论 健壮性
生成时间:2026-10-11 14:02:46 生成平台:VideoNote www.xygy.top