视频封面

视频元信息

  • 课程: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)。

递归策略

推导:

  1. 假设已经能对表的剩余部分(cdr)完成缩放,即 (scale-list (cdr l))
  2. 那么只需把 (car l) 乘以 10
  3. 再把结果 cons 到第 1 步得到的表前面
  4. 递归直到遇到表结束标记 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)。

元语言抽象的核心思想: 应对复杂性和工程设计的一种方法是构建一门合适的、强大的语言。

回顾课程核心观点: 思考一门语言时,要看它的:

  1. 基本元素(primitives) 是什么
  2. 组合手段(means of combination) 是什么
  3. 抽象手段(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)

矩形由三个向量指定:

  1. origin(原点):矩形从哪里开始
  2. horizontal(水平向量):如何到达下一个顶点
  3. 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 必须返回一张图片,即一个以矩形为参数的过程。给定矩形后,它:

  1. 把矩形按比例 A 和 (1-A) 分成左右两个
  2. 让 p1 在左矩形中绘制自己
  3. 让 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):

  1. 弄清任务,明确要做什么
  2. 发现任务分解为三个子任务
  3. 对每个子任务再明确、再分解为三个子任务
  4. 递归下去,直到每个节点都被精确、完整地定义
  5. 最终得到一棵漂亮的、每个节点都精确对应特定任务的树

思考:只有计算机科学家才会相信复杂系统是这样建成的。

Henderson 示例的实际做法——语言的分层:

  1. 原始图片层:谈论向量、线段、点及其在单位正方形中的位置
  2. 几何组合子层:谈论 above、beside、right-push、rotate 等
  3. 组合方案层:如 push,谈论"反复做某事"或"带比例因子地做某事"

关键区别:

  • 树状分解中,每个节点都是为特定任务设计的
  • 语言分层中,每一层都提供完整的语言能力,不是为某个特定任务而设,而是用来谈论一整类事物

为什么语言分层更健壮(robust)

重点: 在语言分层方法中,如果你修改描述,更可能被上一层语言实现中的相应改动所"吸收"。

理解:因为你让每一层都是"满的"——不是只谈论 beside 这一件事,而是给了自己一整套词汇来表达这类事物。所以规格稍作改动时,方法论更可能适应这种变化。

而树状分解不健壮:改动某处可能影响整棵树下游的所有分解。

不同层次提供不同词汇

回到 George 的例子,想修改图片时,有不同层次的描述方式:

  • 最低层:移动某个基本图形中某条线段的端点
  • 中间层(组合子层):改变某个 beside 的比例因子
  • 最高层:改变递归分解的基本方式(比如不再向角落扩散)

重点: 因为系统这样分层,你拥有所有这些谈论"变化"的词汇,并有很大的灵活性来决定用哪一层。


十五、总结 [1:13:00]

核心要点

  1. 语言的嵌入(embedding): 把一门语言嵌入另一门语言,使宿主语言的全部能力仍可为你所用,并作为你所建语言的自然扩展出现。
  2. 过程与数据无区别: 图片是过程,过程接受图片作参数……到这一步,什么是过程、什么是数据已经彻底模糊。它们其实既都是、又都不是。
  3. 设计过程即实现语言: 工程设计不是"实现程序",而是"实现语言"。这是 Lisp 真正的力量所在。
  4. 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