
视频元信息
- 课程:MIT 6.001 计算机程序的构造与解释(SICP)1986 原版
- 本讲主题:亨德森-埃舍尔图形语言(Henderson-Escher Picture Language)
内容摘要(TL;DR)
本讲从向量的表示(用序对构造)出发,引入表(list)这一表示序列的约定,讲解 map 与 for-each 两个高阶过程;随后以 Peter Henderson 的图形语言为例,展示如何用"图片即过程"的表示方式在 Lisp 中嵌入一门小语言,实现 beside、above、rotate、flip 等组合手段,并借助闭包性质与高阶过程快速构建复杂递归图形(如埃舍尔风格的 square limit)。
一、向量的表示与操作 [00:21]
基础内容
向量(vector)可以用序对(pair)来表示。核心操作有两个:
- 向量加法:两个向量相加,对应坐标分别相加,结果是一个新向量,其 x 坐标为两向量 x 坐标之和,y 坐标为两向量 y 坐标之和。
- 向量缩放:
scale操作把向量 v 乘以标量 s,得到同方向、长度不同的向量。实现方式是分别把 x 坐标和 y 坐标乘以缩放因子。
向量的构造函数与选择函数(由 George 实现):
make-vector:构造向量x-coordinate:取 x 坐标y-coordinate:取 y 坐标
这些可以用 cons、car、cdr 实现。
语法细节:过程即对象
重点: 这里出现了一个写法上的差异。以前写:
(define (make-vector x y) (cons x y))
现在直接写:
(define make-vector cons)
理解:后者的含义是"把 make-vector 定义为 cons 这个事物本身",而不是"定义一个接受两个参数并调用 cons 的过程"。两者行为几乎相同,但后者体现了"过程可以是对象、可以被命名"这一重要思想。
易错点:
(define make-vector cons)与(define (make-vector x y) (cons x y))在功能上几乎等价,但概念上不同——前者把make-vector这个名字直接绑定到cons这个过程对象上。这是理解"过程与数据无本质区别"的第一步。
二、用序对构造线段与闭包性质 [03:36]
基础内容
cons 不仅能粘合数字形成序对,还能粘合任意事物。例如表示一条线段:
- 从向量 (2, 3) 到向量 (5, ...) 的线段
- 可以用"序对的序对"(pair of pairs)表示
- 构造函数
make-segment,选择函数segment-start、segment-end
剥开抽象层看真实结构:线段是一个序对,其 car 是一个序对(起点向量),cdr 也是一个序对(终点向量);而起点向量本身又是两个数字的序对。
关于箭头方向的提醒
重点: 很多人误以为盒子和指针图中箭头画成垂直或水平有不同含义。这是无关紧要的——重要的是连接关系,而不是箭头画成什么方向。
闭包性质(Closure Property)
考点: 闭包是指:用 cons 组合事物得到序对后,这些序对本身又可以继续用 cons 组合成更复杂的东西。用数学家的说法:Lisp 中的数据对象集合在"形成序对"这一操作下是封闭的。
理解:正是闭包性质让我们能够不断构建复杂度,而不会被"困在"序对这一层。
对比案例(易错点): 很多编程语言中的组合手段不是封闭的。例如 BASIC 和 FORTRAN 中构造数组的操作就不封闭——你可以构造数字数组或字符串数组,但不能构造"数组的数组"。因此,每当你看到一种组合手段,都应该问自己:这个东西在该组合手段下是否封闭?
三、表(List)的约定 [07:13]
基础内容
因为可以用序对以各种方式粘合事物(比如把 1、2、3、4 粘在一起有多种方式),所以需要建立约定,避免每次都临时做选择。
Lisp 的约定:用序对的链表示序列,称为表(list)。
表示序列 1, 2, 3, 4:
- 第一个序对的
car是 1,cdr指向下一个序对 - 下一个序对的
car是 2,cdr再指向下一个 - 以此类推
- 最后用特殊标记
nil表示"表到此结束"
重点: 表中每个元素对应一个序对;各序对的 car 依次是序列元素,cdr 指向下一个序对。
构造方式
手工构造:
(cons 1 (cons 2 (cons 3 (cons 4 nil))))
其中 nil 是"表结束标记"的名字。
语法糖: list 操作是上述嵌套 cons 的缩写:
(list 1 2 3 4)
例如 (define 1-to-4 (list 1 2 3 4))。
访问元素的后果
(car 1-to-4)→ 1(第一个元素)(car (cdr 1-to-4))→ 2(car (cdr (cdr 1-to-4)))→ 3- 依次取
cdr称为"沿表向下走"(cdring down the list)
屏幕演示要点 [12:57]
1-to-4打印为(1 2 3 4)——用括号括起元素是 Lisp 打印序对链的约定方式(cdr 1-to-4)→(2 3 4),即表的剩余部分(cdr (cdr 1-to-4))→(3 4)(cdr (cdr (cdr 1-to-4)))→(),即表结束标记本身,打印为空括号,可理解为"没有任何元素的表"
易错点: 手工敲一长串
cdr非常繁琐,实际中应写过程来处理,而不是手写嵌套的cdr。
四、递归处理表:scale-list 与"向下 cdr、向上 cons"模式 [16:06]
基础内容
一个非常常见的任务:取一个表,对每个元素做某事,返回结果组成的新表。
例子:scale-list,把表 (1 2 3 4) 中每个元素乘以 10,得到 (10 20 30 40)。
递归策略
推导:
- 假设已经能对表的剩余部分(
cdr)完成缩放,即(scale-list (cdr l)) - 那么只需把
(car l)乘以 10 - 再把结果
cons到第 1 步得到的表前面 - 递归直到遇到表结束标记
nil,直接返回nil
过程定义
(define (scale-list l s)
(if (null? l)
nil
(cons (* (car l) s)
(scale-list (cdr l) s))))
考点: 这里用到的谓词是 null?,含义是"这是不是表结束标记?"也可以理解为"这个表里还有元素吗?"
重点: 这是一个通用模式——"向下 cdr,向上 cons"(cdring down the list and consing up the result)。递归处理表的剩余部分,然后把对第一个元素的处理结果 cons 上去;到达末尾时返回表结束标记。
五、高阶过程 map [19:34]
基础内容
既然存在通用模式,就不应该为每个具体任务重复写这个过程,而应该把模式本身写成一个过程。这就是高阶过程 map。
map 接受一个表 L 和一个过程 P,返回"把 P 应用到表中每个元素"所得结果组成的新表:
P(e1), P(e2), ..., P(en)
定义:
(define (map p l)
(if (null? l)
l
(cons (p (car l))
(map p (cdr l)))))
用 map 定义 scale-list
(define (scale-list l s)
(map (lambda (item) (* item s)) l))
应用示例
(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)
重要思想:从控制结构转向聚合操作
思考: 一旦开始用 map 思考——"scale 就是 map"——你就不再关心它是迭代还是递归过程,而只关心"有一个聚合(表),我要变换其中每一项"。这是非常重要的观念转变。
背景补充【拓展】: 这种思想很大程度上来自 APL 语言——停止思考具体控制结构,转而思考对聚合的操作。课程后半段讲到流处理(stream processing)时,这种世界观会大放异彩。
易错点:
map写成的版本恰好演化出递归过程,但完全可以写成迭代过程。重点在于:一旦用map思考,就不必再纠结控制结构。
六、for-each 与 map 的区别 [25:22]
基础内容
for-each 与 map 非常相似,但有一个关键区别:
map:对每个元素应用过程,返回值的表for-each:对每个元素按顺序应用过程,不构造值的表,只执行动作
定义思路:
(define (for-each p l)
(if (null? l)
'done
(begin (p (car l))
(for-each p (cdr l)))))
即:表非空时,先对第一个元素应用过程,再对剩余部分递归应用;最后返回消息 done。
应用示例
- 打印表中所有内容:
(for-each print this-list) - 在屏幕上绘制一系列图形:
(for-each display this-figure)
课堂问答要点
问: map 是创建一份新副本并对它做处理,除非显式要求,否则不改动原表,对吗?
答: 对。for-each 不创建表,只是执行动作。如果你有一堆事要做、又不关心返回值(如打印、绘图、响铃),就用 for-each;而 map 会构造一个新的值集合供你后续使用。
问: 能否用 for-each 写 map(通过 cons 把表重建回来)?
答: 大致可以,但需要额外安排。
问: map 和 for-each 一个是递归、一个不是?
答: 对,这是个好观点。我写的 map 演化出递归过程,因为对剩余部分做完之后,你需要等待那个值以便把它接到表头;而 for-each 没有值需要等待,所以是迭代过程。当然 map 也可以写成迭代过程,只是我恰好没那样写。
问: 对含嵌套表的表用 for-each 会怎样?
答: 它会工作,也不会工作——取决于你的期望。for-each 只是沿 cdr 依次处理每个元素;对它而言"表的第一项"就是挂在那里的任何东西(可能是数字,也可能是另一个表),它不会深入进去。你真正想要的是一种叫树递归(tree recursion)的通用模式,能一直走到树的叶子。但那既不是 for-each 也不是 map——它们都很"头脑简单"。
易错点:
map与for-each处理嵌套表时都不会递归进入子表,只处理顶层元素。若需要遍历嵌套结构,需自行编写树递归过程。
七、亨德森图形语言:背景与动机 [32:00]
基础内容
本讲后半部分用一个例子总结此前所学:表结构、抽象与表示的议题、用高阶过程捕获共性;同时引入本课程的第三大主题——元语言抽象(meta-linguistic abstraction)。
元语言抽象的核心思想: 应对复杂性和工程设计的一种方法是构建一门合适的、强大的语言。
回顾课程核心观点: 思考一门语言时,要看它的:
- 基本元素(primitives) 是什么
- 组合手段(means of combination) 是什么
- 抽象手段(means of abstraction) 是什么——如何把构建出的东西装进黑盒,用作更复杂事物的元素
示例语言来源
这门语言由 Peter Henderson(苏格兰斯特灵大学)设计,用于描述和绘制类似埃舍尔木刻《square limit》那样具有自相似递归结构的图形。
背景补充【拓展】: 《square limit》是 M.C. 埃舍尔(M.C. Escher)的木刻作品,画面中的鱼形图案以自相似方式不断缩小、向外"渗出",形成复杂的递归对称结构。Henderson 的语言正是为描述这类图形而设计。
过程与数据无区别
重点: 这个例子还展示了"过程与数据在某种意义上没有真正区别"。讲师希望到课程结束时,听众会对"过程与数据的区别"彻底感到困惑——因为确实没有区别。
八、图形语言的基本元素:picture [37:24]
基础内容
这门语言非常简单,只有一个基本元素:picture(图片)。
但"图片"不是你可能以为的那种东西。它的定义是:
图片是一个过程,接受一个矩形作为参数,在矩形内绘制一个按比例缩放以适配该矩形的图形。
例子:同一张 George 的图片,给不同矩形,就会画出不同大小/形状的 George("胖小孩版" George 也是同一个 picture,只是给了不同的矩形)。
考点: picture 是"以矩形为参数的过程",这是整个语言表示方式的关键。
九、组合手段:rotate、flip、beside、above [39:40]
基础内容
一元操作:
rotate:把图片旋转 90 度。给定矩形时,绘制同样的图像,但按该矩形缩放。flip:水平或垂直翻转图片。
二元组合:
beside:接受两张图片 A、B 和一个数 S(0 到 1 之间)。把给定矩形按比例 S 分成左右两部分,左边画 A,右边画 B。- 例如 S = 0.5 时左右各占一半;S = 0.25 时 A 更窄。
above:与beside相同,只是垂直方向排列。
空图片: 有一个 empty picture,即"什么都不画"的图片。
组合示例
- "George 和他的弟弟":George 旁边放(空图片 above George 的副本)——即空图片在上、George 在下,再整体与 George 并排。
- P:George beside(水平翻转的 George),再整体旋转 180 度,以 0.5 的比例并排。
- Q:P above(垂直翻转的 P)。
重点: 只用 15 秒就从 George 构建出了复杂的 Q。为什么能这么快?答案是闭包性质。
理解: 把一张图片 beside 另一张图片,结果仍然是一张图片,可以继续被 rotate、flip 或 above。图片的世界在这些组合手段下是封闭的。因此每次做出东西后,都能立刻把它当作元素用于别处。
十、语言的实现:矩形与坐标映射 [46:00]
基础内容
底层基本元素:矩形(rectangle)
矩形由三个向量指定:
- origin(原点):矩形从哪里开始
- horizontal(水平向量):如何到达下一个顶点
- vertical(垂直向量):如何到达另一个顶点
构造函数 make-rectangle,选择函数 origin、horiz、vert。
坐标映射(coordinate map)
考点: 需要一个过程,把单位正方形内的点 (x, y)(x、y 在 0 到 1 之间)映射到矩形内部。
推导(线性代数):
新点 = 原点 + x × 水平向量 + y × 垂直向量
即:把 x 坐标乘以矩形的水平向量,y 坐标乘以垂直向量,再加上原点。
过程定义:
(define (coordinate-map rect)
(lambda (point)
(add-vector (origin rect)
(add-vector (scale-vector (horiz rect) (x point))
(scale-vector (vert rect) (y point))))))
重点: coordinate-map 接受一个矩形,返回一个作用于点的过程。每个矩形都定义了一个坐标映射。
primitive picture 的构造
从线段表出发构造原始图片:
(define (make-picture segments)
(lambda (rect)
(for-each
(lambda (seg)
(draw-line
((coordinate-map rect) (segment-start seg))
((coordinate-map rect) (segment-end seg))))
segments)))
即:图片是一个以矩形为参数的过程;对线段表中每条线段,把两个端点分别用该矩形的坐标映射变换,然后画线连接。
使用方式:
(define r (make-rectangle ...))
(define g (make-picture (list (make-segment ...) ...)))
(g r) ; 在矩形 r 内绘制图片 g
易错点: 图片是过程,所以"显示图片"不是调用某个显示函数,而是把图片过程应用到矩形上。忘记这一点会导致对语言的使用方式产生根本误解。
十一、用过程实现组合手段 [53:00]
beside 的实现
核心思路: beside 必须返回一张图片,即一个以矩形为参数的过程。给定矩形后,它:
- 把矩形按比例 A 和 (1-A) 分成左右两个
- 让 p1 在左矩形中绘制自己
- 让 p2 在右矩形中绘制自己
矩形计算:
左矩形:
- 原点 = 原矩形的原点
- 垂直向量 = 原矩形的垂直向量
- 水平向量 = 原矩形水平向量 × A
右矩形:
- 原点 = 原原点 + 水平向量 × A
- 水平向量 = 原水平向量 × (1 - A)
- 垂直向量 = 原垂直向量
代码结构:
(define (beside p1 p2 a)
(lambda (rect)
(p1 (make-rectangle (origin rect)
(scale-vector (horiz rect) a)
(vert rect)))
(p2 (make-rectangle (add-vector (origin rect)
(scale-vector (horiz rect) a))
(scale-vector (horiz rect) (- 1 a))
(vert rect)))))
重点: beside 只做两件事——构造两个矩形,然后说"p1,在那个矩形里画你自己;p2,在另一个矩形里画你自己"。它不关心 p1 是原始图片、线段表还是别的 beside/rotate 的结果。
rotate 的实现
核心思路: 旋转 90 度意味着:拿到矩形后,把它"想象成"横过来的矩形(原点、水平向量、垂直向量都相应变换),然后让图片相对于那个矩形绘制自己。
关键洞察
考点: 用过程表示图片,自动获得闭包性质。因为 beside 只需要知道"如果给 p1 一个矩形,它就会画出东西",至于 p1 如何完成绘制,不是 beside 的事。这就是用过程表示带来的闭包。
十二、真正的亮点:抽象手段与语言的嵌入 [59:00]
基础内容
真正的精彩之处在于抽象手段。因为组合手段本身也是用过程实现的,所以在 Lisp 中操作过程的一切手段,都自动可用于图形语言。
术语: 这门语言不只是"用 Lisp 实现",而是很好地嵌入(embedded)在 Lisp 中。嵌入意味着 Lisp 的全部能力自动成为你所用语言的扩展。
示例一:四图组合
想做一个接受四张图片 A、B、C、D、组成特定布局的过程:
(define (four-pictures a b c d)
(beside (above a c) (above b d) 0.5))
理解:这自动用上了 Lisp 的过程组合能力,无需在图形语言中专门实现。
示例二:带参数的旋转
想让每张图片都能独立指定是否旋转 90 度——只需在过程中加参数,自动可行。
示例三:递归组合手段 right-push
(define (right-push p n a)
(if (= n 0)
p
(beside p (right-push p (- n 1) a) a)))
效果:George 以 0.75 的比例因子向右推挤自己两次。
重点: 这种花哨的递归是自动获得的——因为图片就是过程,嵌入意味着可以直接定义递归过程,无需专门安排。
示例四:更复杂的递归——corner-push
类似地,可以定义同时向右和向上递归推挤的过程,最终得到类似《square limit》的图形。
与埃舍尔原作的对比
- George 是相当随意的设计,递归推挤后边缘衔接不够自然
- 埃舍尔的原作是精心设计的:鱼形图案旋转、缩小后能自然地融入下一个图案
- 项目建议: 写一个过程,接受基本图形,微调线段端点位置,使它在 square limit 过程中衔接得更好——这是个很好的练习
十三、高阶过程 push:捕获"递归重复组合手段"的通用模式 [1:04:00]
基础内容
right-push、up-push、corner-push 都是同一类通用思想的特例。可以把它抽象为高阶过程 push:
思路:
beside接受两张图片和一个比例因子- 用它可以构造出
right-push(接受图片、层数、比例因子) - 通用模式是:反复应用某个组合手段
push 的定义
(define (push comb)
(lambda (p n a)
((repeated
(lambda (pic) (comb pic p a))
n)
p)))
其中 repeated 是另一个高阶过程:接受一个过程和一个数字 n,返回一个"把该过程应用 n 次"的新过程。
用 push 定义各种推挤
(define right-push (push beside))
(define up-push (push above))
(define corner-push (push 某个同时做 beside 和 above 的组合))
重点: 这就是从 beside 的思想到 right-push 的思想的抽象过程。
易错点: 如果对 lambda 感到困惑,
push是极好的练习——仔细推敲它到底做了什么。
十四、两大方法论对比:语言分层 vs 严格树状分解 [1:09:00]
基础内容
传统"软件工程"神话(mythology):
- 弄清任务,明确要做什么
- 发现任务分解为三个子任务
- 对每个子任务再明确、再分解为三个子任务
- 递归下去,直到每个节点都被精确、完整地定义
- 最终得到一棵漂亮的、每个节点都精确对应特定任务的树
思考:只有计算机科学家才会相信复杂系统是这样建成的。
Henderson 示例的实际做法——语言的分层:
- 原始图片层:谈论向量、线段、点及其在单位正方形中的位置
- 几何组合子层:谈论
above、beside、right-push、rotate等 - 组合方案层:如
push,谈论"反复做某事"或"带比例因子地做某事"
关键区别:
- 树状分解中,每个节点都是为特定任务设计的
- 语言分层中,每一层都提供完整的语言能力,不是为某个特定任务而设,而是用来谈论一整类事物
为什么语言分层更健壮(robust)
重点: 在语言分层方法中,如果你修改描述,更可能被上一层语言实现中的相应改动所"吸收"。
理解:因为你让每一层都是"满的"——不是只谈论 beside 这一件事,而是给了自己一整套词汇来表达这类事物。所以规格稍作改动时,方法论更可能适应这种变化。
而树状分解不健壮:改动某处可能影响整棵树下游的所有分解。
不同层次提供不同词汇
回到 George 的例子,想修改图片时,有不同层次的描述方式:
- 最低层:移动某个基本图形中某条线段的端点
- 中间层(组合子层):改变某个
beside的比例因子 - 最高层:改变递归分解的基本方式(比如不再向角落扩散)
重点: 因为系统这样分层,你拥有所有这些谈论"变化"的词汇,并有很大的灵活性来决定用哪一层。
十五、总结 [1:13:00]
核心要点
- 语言的嵌入(embedding): 把一门语言嵌入另一门语言,使宿主语言的全部能力仍可为你所用,并作为你所建语言的自然扩展出现。
- 过程与数据无区别: 图片是过程,过程接受图片作参数……到这一步,什么是过程、什么是数据已经彻底模糊。它们其实既都是、又都不是。
- 设计过程即实现语言: 工程设计不是"实现程序",而是"实现语言"。这是 Lisp 真正的力量所在。
- Lisp 的定位: Lisp 不是做任何特定问题的好语言;它擅长的是帮你弄清楚你真正想要的语言是什么,并把它嵌入 Lisp 中。
关键标签 / 关键词
SICP #MIT6001 #Lisp #Scheme #序对 #cons #表 #list #nil #map #for-each #高阶过程 #闭包性质 #元语言抽象 #亨德森图形语言 #埃舍尔 #square-limit #过程即数据 #语言嵌入 #坐标映射 #递归 #树递归 #软件工程方法论 #抽象屏障
生成时间:2026-10-11 17:06:39 生成平台:VideoNote www.xygy.top