
视频元信息
- 课程:MIT 6.001 Structure and Interpretation of Computer Programs(1986 传奇原版)
- 本讲主题:复合数据(Compound Data)与数据抽象(Data Abstraction)
- 备注:备选版本(Alternative Version),内容与主线第 24 讲基本一致,讲解顺序略有差异
内容摘要(TL;DR)
本讲从程序抽象过渡到数据抽象,以有理数算术系统为例,引入构造器(constructor)与选择器(selector)的愿望思维设计法。随后引出序对(pair)作为数据的粘合剂,介绍 cons/car/cdr 与盒指针表示法。最后用纯过程(lambda)实现 cons/car/cdr,证明数据可完全由过程构建,模糊数据与过程的界限。
一、回顾:语言三要素与分层系统 [00:22]
课程开头回顾了讨论语言时使用的框架,包含三个核心要素:
- 基本元素(primitive things):系统内建的东西,如数字;
- 组合手段(means of combination):把基本元素组合成更复杂的东西;
- 抽象手段(means of abstraction):给复杂的东西命名,使其可作为简单积木复用。
上一讲通过高阶过程展示了组合抽象手段后能获得的惊人表达力,例如用不动点法或牛顿法表达通用的计算方法。
核心思想:我们构建的是分层系统(layered system),层与层之间存在抽象屏障(abstraction barrier)。
理解:所谓"与 George 签约",本质上是把某个子任务的行为契约(contract)与实现细节分离。只要 George 实现的行为符合契约,他内部怎么做、甚至他是否又找 Harry 写了 abs 过程,都与上层无关。
关键点:构建东西的任务与实现部件的任务被解耦(divorce)。大型系统中这种抽象屏障会在极多层出现。
二、从过程抽象到数据抽象 [02:39]
本讲把同样的思路搬到数据上:
- 系统有基本数据(primitive data),如数字;
- 数据也有组合手段(glue),把基本数据粘合成复合数据(compound data);
- 数据也有抽象方法论,用于自底向上构建数据。
思考:过程抽象解决"如何计算",数据抽象解决"如何表示"。两者共享同一套分层哲学——上层只关心行为契约,下层细节被屏障隔离。
本节关键:从"过程的三要素"平行迁移到"数据的三要素",这是本讲所有后续内容的骨架。
三、有理数算术:愿望思维与"云" [03:39]
3.1 问题背景
目标:让计算机能回答"1/2 + 1/4 = 3/4""3/4 × 2/3 = 1/2"这类问题。这些都是五六年级就学过的分数运算:
- 加法公式:N1/D1 + N2/D2 = (N1·D2 + N2·D1) / (D1·D2)
- 乘法公式:N1/D1 × N2/D2 = (N1·N2) / (D1·D2)
3.2 遇到的障碍
易错点:系统只提供单个数字(如 5、3),没有"有理数"这种数据对象——没有一种东西能同时装下一个分子和一个分母。
理解:视频里用"云(cloud)"作比喻——我们希望有一个对象,里面同时包含分子和分母,可以整体命名、整体传递。
3.3 解决方案:愿望思维(Wishful Thinking)
沿用此前写 sqrt 时"假设 fixed-point 已存在"的策略,假设有三个过程:
| 过程 | 作用 | 类型 |
|---|---|---|
make-rat |
输入分子、分母,返回一个"云" | 构造器(constructor) |
numer |
输入"云",返回分子 | 选择器(selector) |
denom |
输入"云",返回分母 | 选择器(selector) |
重点:make-rat 返回的"云"到底是什么,是 George 的事,我们不想知道。这就是与 George 签的契约。
3.4 用愿望思维实现有理数加法
(define (add-rat x y)
(make-rat (+ (* (numer x) (denom y))
(* (numer y) (denom x)))
(* (denom x) (denom y))))
乘法同理:
(define (mul-rat x y)
(make-rat (* (numer x) (numer y))
(* (denom x) (denom y))))
关键点:只要假设有构造器和选择器,上层运算的代码就极其自然,几乎就是数学公式的直译。
四、为什么需要复合数据对象? [10:05]
课堂上有学生提问:既然最终还是要取出分子分母,为什么不直接传四个数字?
4.1 反面例子:临时变量泛滥
若直接操作四个数字,计算 (x + y) * (s + t) 时:
- 先算 x+y,需要两个临时变量存分子分母;
- 再算 s+t,又需要两个临时变量;
- 最后相乘,还要再组合。
易错点:随着表达式变复杂,这些"临时变量"(有理数的内部细节)会散落(hang out)在整个系统中,既搞乱编程,也搞乱思维。
4.2 正面理由:语言应表达头脑中的概念
关键论点:编程语言应当能表达我们头脑中的概念——"有理数"是一个可以相加、可以把结果再相乘的整体对象。
理解:就像过程体本来只是一串指令,我们却要把它打包成"过程"并命名一样。有理数也应该被打包、命名、整体传递。
重点:这是数据抽象的根本动机——让数据对象成为一等公民,而非散落的零件。
五、George 的解决方案:序对(Pairs) [16:30]
5.1 数据的粘合剂:list structure
现在解决 George 的问题——如何把分子和分母真正粘在一起。引入序对(pair):
cons:构造器,接受 x 和 y,返回一个 pair,第一部分是 x,第二部分是 y;car:选择器,取 pair 的第一部分;cdr:选择器,取 pair 的第二部分。
5.2 盒指针表示法(Box and Pointer Notation)
用一个小盒子加两个箭头表示 (cons 2 3):
[ * | * ]
| |
2 3
易错点:很多学生误以为箭头的几何方向有含义(如朝上、朝下)。实际上只有连接关系有意义,箭头画在哪、朝哪个方向完全无所谓。
5.3 序对的行为契约(公理)
对任意 x 和 y:
(car (cons x y)) = x,(cdr (cons x y)) = y。
重点:这就是 cons/car/cdr 的全部行为定义。至于它们内部怎么实现,与上层无关。
5.4 用序对实现有理数
(define (make-rat n d) (cons n d))
(define (numer x) (car x))
(define (denom x) (cdr x))
至此,有理数系统完整实现。
六、测试与 Bug:约分问题 [22:30]
6.1 测试用例
(define a (make-rat 1 2))
(define b (make-rat 1 4))
(define ans (add-rat a b))
(numer ans) → 6
(denom ans) → 8
易错点:期望输出 3/4,系统却输出 6/8。程序完全按公式执行,逻辑没错,但结果未约分到最简形式。
6.2 修复方案一:构造时约分
让 George 在 make-rat 时就除以最大公约数:
(define (make-rat n d)
(let ((g (gcd n d)))
(cons (/ n g) (/ d g))))
这样 add-rat 中调用 make-rat 时会自动约分。
6.3 修复方案二:选择时约分(替代表示)
另一种做法是构造时原样存,选择时再算 GCD:
(define (make-rat n d) (cons n d))
(define (numer x)
(let ((g (gcd (car x) (cdr x))))
(/ (car x) g)))
(define (denom x)
(let ((g (gcd (car x) (cdr x))))
(/ (cdr x) g)))
思考:哪种更好?取决于使用模式——若频繁构造、很少读取,则选择时约分更省;若频繁读取、很少构造,则构造时约分更好。这正是数据抽象的价值:表示选择被局部化,可以随时替换。
七、数据抽象方法论 [26:10]
7.1 抽象屏障的建立
┌─────────────────────────────┐
│ 有理数算术(add-rat 等) │ ← 使用层
├─────────────────────────────┤
│ make-rat / numer / denom │ ← 抽象屏障
├─────────────────────────────┤
│ cons / car / cdr(序对) │ ← 表示层
└─────────────────────────────┘
7.2 数据抽象的定义
数据抽象(Data Abstraction):通过假定构造器和选择器存在来建立数据对象,从而把数据的使用与数据的表示隔离开来。
7.3 反面教材:不抽象会怎样
若直接写 (car (car s)) 这类代码,会带来两大问题:
- 可读性差:代码难以理解;
- 脆弱:一旦表示改变(如 George 把 x 坐标放前面、或要求线段反向绘制),所有依赖细节的代码都要改。
理解:视频里用"打 George 一拳"的玩笑说明——在十层系统里,表示细节泄漏会导致复杂度爆炸。
7.4 命名的力量
编程中最重要的原则之一,和巫术一样:如果你知道精灵的名字,你就能控制它。
重点:make-rat/numer/denom 就是给"表示选择"这个精灵命名。命名后,决策被局部化,上层代码可以假装决策已经做出。
八、抽象与决策延迟 [32:00]
8.1 两种表示的权衡
- 构造时约分:构造慢、读取快;
- 选择时约分:构造快、读取慢。
关键点:在写系统时你可能无法预先决定哪种更好。
8.2 延迟决策 vs. 拖延
作为系统设计者,你希望保留灵活性——不到被迫时绝不做决定。但"延迟决策"与"彻底拖延"之间只有一条很窄的线。
思考:数据抽象让你"假装已经做了决定"继续前进,日后真正需要时再回来面对。这是控制复杂度的核心技巧。
8.3 关于"先设计后编码"的讨论
有学生问:抽象分层是否意味着"先完成所有设计再写代码"?
易错点:讲师明确反对这种"教条"。真正强大的地方在于:你可以假装已经做了决定,之后再回头判断哪个决定是对的——两全其美。
8.4 补充:let 与 define 的区别
define:在全局环境中建立名字,之后一直记得;let:建立局部名字,只在该表达式内有效,外部访问会报"unbound variable"。
(let ((z 10)) (+ z z)) → 20
z → 未绑定变量(错误)
理解:let 提供局部上下文,不会污染全局命名空间。
九、分层构建:线段与向量 [38:00]
9.1 向量(点)的表示
- 构造器:
make-vector(或make-point),接受两个坐标; - 选择器:
xcor(x 坐标)、ycor(y 坐标); - 可用序对实现,但对上层隐藏。
9.2 线段的表示
- 构造器:
make-segment,接受起点和终点; - 选择器:
start-segment、end-segment; - 用序对实现,两个元素都是点。
9.3 线段操作
中点(midpoint):取两端点的 x、y 坐标平均值,构造新点。
长度(length):用勾股定理。
(define (length s)
(let ((dx (- (xcor (start-segment s))
(xcor (end-segment s))))
(dy (- (ycor (start-segment s))
(ycor (end-segment s)))))
(sqrt (+ (* dx dx) (* dy dy)))))
9.4 分层结构
┌──────────────────────┐
│ 线段操作(length 等) │
├──────────────────────┤
│ make-segment/start/end │ ← 抽象屏障 1
├──────────────────────┤
│ 向量操作 │
├──────────────────────┤
│ make-vector/xcor/ycor │ ← 抽象屏障 2
├──────────────────────┤
│ cons / car / cdr │
└──────────────────────┘
理解:若不用抽象屏障,length 会写成 (car (car s)) 这种深层嵌套,一旦表示改变(如坐标顺序调整、线段方向反转),代码会全面崩溃。
十、闭包(Closure):组合手段的关键性质 [52:00]
10.1 什么是闭包
闭包(Closure):组合手段的性质——用它组合出的东西,本身还能再用同样的手段继续组合。
重点:cons 是闭包的——不仅能造"数字的序对",还能造"序对的序对"。
10.2 反例
理解:某些语言的数组不是闭包的——能造"数字的数组",却不能造"数组的数组"。这是组合手段的质量判据。
10.3 用序对表示三元组
若要表示三维点(三个坐标),可用嵌套序对:
(cons 1 (cons 2 3))
易错点:如何嵌套是任意的(可以 (cons (cons 1 2) 3)),有多种约定,后续课程会讨论。
关键点:这依赖于"序对的序对"是合法的——即闭包性质。若只能造"数字的序对",就做不出复杂结构。
十一、数据到底是什么?——用过程实现序对 [58:00]
11.1 回到原点
我们一直假设 make-rat/numer/denom 存在。现在追问:它们的行为契约到底是什么?
有理数的契约:对任意 n 和 d,
(= (/ (numer (make-rat n d)) (denom (make-rat n d))) (/ n d))。
重点:任何满足这个契约的三个过程,都可以作为有理数的表示。这就是"抽象数据"的含义。
11.2 更震撼的问题:序对又是什么?
我们说过 cons/car/cdr 是原语,但其实我们只告诉了它们的行为公理:
(car (cons x y)) = x,(cdr (cons x y)) = y。
11.3 用纯过程实现序对
这是本讲最精彩的部分——不用任何数据结构,只用 lambda 实现 cons/car/cdr:
(define (cons x y)
(lambda (pick)
(if (= pick 1) x y)))
(define (car p) (p 1))
(define (cdr p) (p 2))
易错点:这里没有任何"数据",全部是过程。cons 返回的是一个过程,它"记住"了 x 和 y。
11.4 用替换模型验证
验证 (car (cons 37 49)) 是否等于 37:
(cons 37 49)替换为(lambda (pick) (if (= pick 1) 37 49));(car ...)展开为(... 1);- 把
pick = 1代入,(= 1 1)为真,返回 37。
同理 (cdr (cons 37 49)) 返回 49。
重点:这证明了只用过程就能满足序对的公理,因此数据抽象根本不需要"数据"。
11.5 为什么这么做
讲师给出两个理由:
- 强化抽象观念:抽象到极致,数据可完全由过程构建;
- 模糊数据与过程的界限:这是后续课程反复出现的主题——许多重要编程技术依赖于打破"数据 vs. 过程"的传统界线。
思考:这正是 SICP 的哲学——数据即过程,过程即数据。Church 编码(用 lambda 编码数据)在理论计算机科学中有深厚根基。
十二、课堂问答:过程即对象 [01:02:00]
12.1 若打印这个"序对"会怎样?
答:会打印出 compound procedure(复合过程),因为系统不知道它是"数据",只知道它是个过程。
12.2 多个 cons 会不会混淆?
学生问:(cons 37 49) 和 (cons 1 2) 同时存在,系统怎么知道取哪个?
答:这是本讲最重要的哲学问题。过程不只是"动作",而是有独立存在的对象(conceptual entity)。
(cons 37 49)创建了一个特定的过程对象,内部"记住"了 37 和 49;(cons 1 2)创建了另一个独立的对象;- 二者互不干扰,就像
(sqrt 5)和(sqrt 20)互不混淆一样。
重点:这标志着从"过程是动作"到"过程是对象"的观念转变。
12.3 延伸:两个相同的 cons 是同一个对象吗?
(cons 37 49)做两次,是同一个东西还是不同的东西?如何判断?什么时候重要?
这是一个极深的问题,讲师说这将在课程中段(约一半处)用一整讲来讨论——涉及对象同一性(identity)与引用透明性。
思考:这正是函数式编程与命令式编程的核心分歧之一。SICP 在此埋下伏笔,后续会讨论 eq?、赋值、副作用等主题。
关键标签 / 关键词
数据抽象、构造器、选择器、愿望思维、抽象屏障、分层系统、有理数算术、序对(pair)、cons/car/cdr、盒指针表示法、闭包(closure)、Church 编码、过程即对象、延迟决策、数据与过程的界限、SICP、MIT 6.001
本讲要点速查
| 概念 | 核心要点 | 易错点 |
|---|---|---|
| 数据抽象 | 用构造器+选择器隔离使用与表示 | 直接写 (car (car s)) 会破坏屏障 |
| 愿望思维 | 假设过程已存在,先写上层 | 契约必须明确(如有理数比值不变) |
| 序对公理 | car(cons x y)=x,cdr(cons x y)=y |
箭头几何方向无意义 |
| 闭包 | 组合结果可再组合 | 数组常不满足闭包 |
| 过程实现序对 | cons = λpick. if pick=1 then x else y |
需用替换模型验证 |
| 过程即对象 | 每次 cons 创建独立对象 |
对象同一性是深层问题 |
生成时间:2026-10-11 17:09:11 生成平台:VideoNote www.xygy.top