
视频元信息
- 课程:MIT 6.001 SICP(1986 原版)
- 主题:通用操作符(Generic Operators)——类型分派、数据导向编程、通用算术系统
- 说明:本讲为「通用操作符」的备选版本,内容与主线版本主题一致,侧重系统组织方式与可扩展性设计。
视频开头约 0:00–1:50 的转录内容为语音识别噪声,无法还原有效信息,笔记从有效讲解内容(约 [01:52])开始整理。
内容摘要(TL;DR)
本讲以「复数算术系统」为具体案例,讲解如何用类型标签(typed data) 与 类型分派(dispatch on type) 让两套互不兼容的复数表示(George 的直角坐标、Martha 的极坐标)在同一系统中共存。随后指出「经理式」分派的瓶颈,引出数据导向编程(data-directed programming):用一张操作表 + operate 过程替代经理。最后把复数系统嵌入通用算术系统,并扩展到多项式、有理对象、矩阵,形成可递归嵌套的类型塔。
一、问题背景:为什么要通用操作符 [01:52]
1.1 从「分布式人事记录系统」引入
设想一家由多家公司合并而成的大公司,各分部的数据库格式完全不同。你希望在公司任何地方都能问:「这条人事记录的姓名是什么?」「职务描述是什么?」而不必关心各分部内部用什么格式实现。
传统做法是「从上面下命令,所有人统一格式」——现实中从来不会成功。
理解:这正是软件工程中「异构系统集成」的经典难题。视频用人事记录做类比,是为了让抽象问题具象化——不同子系统各自演化,无法强制统一,只能在中间加一层「翻译/适配」层。
1.2 通用操作符(Generic Operator)的定义
通用操作符:其具体行为取决于它所面对的数据类型。
你希望设计系统时满足两点:
- 新分部加入时,不需要对已有系统做大的改动;
- 公司其他部分也不需要做大的改动就能接纳新分部。
核心目标:以最小的改动纳入新事物(include new things by making minimal changes)。
1.3 本讲的具体载体:复数算术
视频选择「复数算术系统」作为比人事系统更自包含、更能清楚暴露问题的案例。
二、复数数学基础回顾 [05:30]
2.1 两种表示法
| 表示法 | 参数 | 记法 |
|---|---|---|
| 直角坐标(rectangular) | 实部 x、虚部 y | z = x + iy |
| 极坐标(polar) | 模 r、幅角 a | z = r∠a |
2.2 两种表示之间的换算
- 已知 x、y 求 r、a:r = √(x² + y²),a = arctan(y, x)
- 已知 r、a 求 x、y:x = r·cos(a),y = r·sin(a)
2.3 复数运算的公式选择
- 加法:直角坐标下最自然——实部加实部,虚部加虚部。
- Re(z1+z2) = Re(z1) + Re(z2)
- Im(z1+z2) = Im(z1) + Im(z2)
- 乘法:极坐标下最自然——模相乘,角相加。
- |z1·z2| = |z1|·|z2|
- ∠(z1·z2) = ∠z1 + ∠z2
- 除法同理:模相除,角相减。
考点:加法和乘法各自「最自然」的表示法不同,这正是本讲冲突的根源——没有一种表示对所有运算都最优。
思考:这解释了为什么后面要保留两套表示,而不是选一套「最好的」。选择取决于使用频率,而使用频率往往无法事先确定。
三、用抽象数据实现复数运算 [09:00]
3.1 构造器与选择器接口
沿用有理数系统的思路,先定义抽象接口:
- 构造器:
make-rectangular(实部, 虚部)、make-polar(模, 角) - 选择器:
real-part、imag-part、magnitude、angle
共 2 个构造器 + 4 个选择器。
3.2 基于抽象接口实现运算
- 复数加法:用
make-rectangular构造新数,实部取两者实部之和,虚部取两者虚部之和。 - 复数减法:同上,改为相减。
- 复数乘法:用
make-polar构造新数,模取两者模之积,角取两者角之和。 - 复数除法:模相除,角相减。
要点:运算层只依赖抽象接口,不关心底层表示——这就是「使用」与「表示」的分离。
四、George 与 Martha:两套不兼容的表示 [11:30]
4.1 George 的直角坐标表示
- 用序对
(实部 . 虚部)表示复数。 make-rectangular:直接 cons。real-part:取 car;imag-part:取 cdr。magnitude:√(car² + cdr²);angle:arctan(cdr, car)。make-polar:由 r·cos(a) 和 r·sin(a) 算出实部虚部再 cons。
4.2 Martha 的极坐标表示
- 用序对
(模 . 角)表示复数。 make-polar:直接 cons。magnitude:取 car;angle:取 cdr。real-part:r·cos(a);imag-part:r·sin(a)。make-rectangular:由 √(x²+y²) 和 arctan(y,x) 算出模和角再 cons。
4.3 谁更好?——无法决定
- 若以加法为主,George 的直角坐标更好(加法只需操作实部虚部)。
- 若以乘法/除法为主,Martha 的极坐标更好(只需操作模和角)。
- 真正的结论:你可能根本无法决定,或者出于各种原因必须让两者共存。
易错点: 注意同一个序对
(1 . 2)在两套表示中含义完全不同——在 George 世界是 1+2i,在 Martha 世界是模 1、角 2 的复数。系统必须能区分它们,否则会产生灾难性混淆。
五、类型数据与垂直屏障 [16:20]
5.1 需要的不只是抽象屏障
- 水平抽象屏障:隐藏实际表示,对外只暴露
real-part、imag-part、magnitude、angle。 - 垂直屏障:让 George 和 Martha 两套表示互不干扰地共存于同一系统。
5.2 关键想法:给数据贴标签
设想每个复数都带一个标签,写明「这是 Martha 造的」或「这是 George 造的」。使用时先读标签,再决定如何操作。
这就是类型数据(typed data):
- 普通数据对象 → 类型数据对象
- 结构:
(类型 . 内容),用 cons 即可实现 attach-type:把类型符号贴到内容上type:取 car 得到类型;contents:取 cdr 得到内容
5.3 类型谓词
rectangular?:检查类型是否为符号rectangularpolar?:检查类型是否为符号polar
六、策略一:类型分派(Dispatch on Type)[20:30]
6.1 George 与 Martha 需要做的改动
George 的改动(红字标注部分):
make-rectangular时,除了 cons 序对,还要贴上类型符号rectangular。- 为避免与 Martha 的过程重名冲突,把过程改名:
real-part→real-part-rectangularimag-part→imag-part-rectangularmagnitude→magnitude-rectangularangle→angle-rectangular
Martha 的改动:
make-polar时贴上类型符号polar。- 改名:
real-part-polar、imag-part-polar、magnitude-polar、angle-polar。
6.2 经理(Manager)的角色
系统分三部分:George、Martha、经理。
经理实现通用选择器,例如 real-part:
- 看数据的类型:
- 若是
rectangular,剥掉类型,把内容交给 George 的real-part-rectangular; - 若是
polar,剥掉类型,把内容交给 Martha 的real-part-polar。
- 若是
imag-part、magnitude、angle 同理。
6.3 运行示例
复数 1 + 2i(George 表示):
- 高层看到的是:
(rectangular . (1 . 2)) - 请求
real-part时,经理识别类型为 rectangular,剥掉类型,把(1 . 2)交给 George,得到 1。 - George 构造新复数时,经理再贴上
rectangular标签返回上层。
要点:(1 . 2) 在 Martha 世界含义完全不同,但因为有类型标签,系统毫无混淆。
七、类型分派的问题:经理成了瓶颈 [26:00]
7.1 两个批评
- 改名很烦:George 和 Martha 必须给过程改不同的名字。(本讲暂不处理,留待讨论 Lisp 名字与环境结构时解决。)
- 加入新人很麻烦:若 Harry 带来第三种复数表示(新类型),经理必须修改所有过程,为每个操作增加对 Harry 类型的判断分支。
7.2 本质:经理只是「文件搬运工」
经理本身不干任何实事,只是根据类型把请求转发给真正干活的人。系统灵活性的瓶颈,偏偏出在这个纯官僚环节。
思考:这是软件架构中的经典反模式——「中心化的条件分派」。每增加一种类型,就要修改中心分派逻辑,违反开闭原则。
7.3 抽象地看:其实是一张表
系统背后实际是一张二维表:
| 操作 \ 类型 | polar | rectangular | (Harry 的) |
|---|---|---|---|
| real-part | Martha 的 real-part-polar | George 的 real-part-rectangular | … |
| imag-part | Martha 的 imag-part-polar | George 的 imag-part-rectangular | … |
| magnitude | Martha 的 magnitude-polar | George 的 magnitude-rectangular | … |
| angle | Martha 的 angle-polar | George 的 angle-rectangular | … |
经理做的事,本质上就是查这张表。
八、策略二:数据导向编程(Data-Directed Programming)[28:30]
8.1 把经理「自动化掉」
既然经理只是查表,就让系统直接使用这张表,取消经理这个中间层。
8.2 表的抽象接口
(put <key1> <key2> <value>):把 value 存入表中,用两个键索引。(get <key1> <key2>):取出对应 value;若没有则返回空表(null)。
表的底层实现本身也是数据抽象,此处不关心。
8.3 各方职责
George 的职责:定义过程时,把过程存入表中:
(put 'rectangular 'real-part real-part-rectangular)(put 'rectangular 'imag-part imag-part-rectangular)(put 'rectangular 'magnitude magnitude-rectangular)(put 'rectangular 'angle angle-rectangular)
Martha 的职责:建立 polar 这一列:
(put 'polar 'real-part real-part-polar)- ……(imag-part、magnitude、angle 同理)
Harry 加入时:只需自己造好过程,在表中新建一列即可,无需修改任何已有代码。
关键:每个表示的设计者,负责在表中建立自己的一列。
8.4 核心过程 operate
(define (operate op obj)
(let ((proc (get (type obj) op)))
(if (not (null? proc))
(proc (contents obj))
(error "undefined operator"))))
- 输入:操作名
op、对象obj。 - 先查表:用
(type obj)和op作为两个键。 - 若找到过程,则把它应用到
(contents obj)(剥掉类型的内容)。 - 若没找到,报错「未定义的操作符」。
8.5 用 operate 定义通用选择器
(define (real-part obj) (operate 'real-part obj))(define (imag-part obj) (operate 'imag-part obj))(define (magnitude obj) (operate 'magnitude obj))(define (angle obj) (operate 'angle obj))
operate + 表,完全替代了原来的经理。
8.6 运行示例逐步拆解
设 z 是 Martha 的复数,模 1、角 2,标记为 polar:(polar . (1 . 2))。
请求 (real-part z):
real-part展开为(operate 'real-part z)。operate查表:(get 'polar 'real-part)。- 取到 Martha 存入的
real-part-polar。 - 应用到
(contents z),即(1 . 2)。 - 得到结果。
理解:operate 把原来经理「到处都在做」的事情,统一收敛为一个过程。系统变得统一、可扩展。
8.7 数据导向编程的核心思想
数据对象本身携带了「该如何操作它」的信息。
复数对象带着自己的类型标签,类型标签指向表中对应的操作列。
九、课堂问答:类型标签里到底存了什么 [40:00]
问题:数据对象里存了什么?操作又在哪里?
- 数据对象里存的是:数据本身(如序对
(1 . 2))+ 类型符号(如符号POLAR)。 - 操作本身存在表里,不在数据对象里。
延伸讨论(重要):
- 过程可以匿名:George 和 Martha 其实根本不必给过程命名,可以直接把 lambda 生成的匿名过程塞进表里。这样连改名问题都省了。
- 名字冲突不影响:因为放进表里的是过程对象本身,不是名字。只要各自定义不同,即使键相同(如都用
real-part),也不会有冲突后果。 - 另一种组织方式——消息传递(message passing):可以不在数据对象里存类型符号,而是直接在对象里存所有操作。这是另一种系统组织风格。
拓展【拓展】:消息传递风格(message passing)是 SICP 后续章节的重要内容,也是面向对象编程的思想源头之一。数据对象本身「知道」如何响应各种消息,而不是靠外部表来分派。
十、把复数系统嵌入通用算术系统 [46:00]
10.1 通用算术系统的层次结构
顶层:通用 add / sub / mul / div
────────────────────────────────
抽象屏障
────────────────────────────────
├── 复数算术包(complex)
├── 有理数算术包(rational)
└── 普通数字包(number,Lisp 原生 + - * /)
10.2 有理数包几乎不用改
原有的 +rat、-rat、*rat、/rat 代码完全不变。
只需一处改动:构造有理数时贴上类型符号 rational。
然后安装进表:
(put 'rational 'add +rat)(put 'rational 'sub -rat)(put 'rational 'mul *rat)(put 'rational 'div /rat)
10.3 通用操作符 operate-2
因为二元运算需要检查两个参数类型是否一致:
(define (operate-2 op arg1 arg2)
(if (eq? (type arg1) (type arg2))
(let ((proc (get (type arg1) op)))
(if (not (null? proc))
(proc (contents arg1) (contents arg2))
(error "undefined operator")))
(error "types differ")))
- 先检查两参数类型是否相同。
- 相同则查表,找到过程后应用到两个内容上。
- 否则报错。
10.4 安装复数包
make-complex:给 George/Martha 的产物贴上类型complex。+complex:内部调用+c,返回值贴上complex类型。- 安装:
(put 'complex 'add +complex),其余同理。
10.5 安装普通数字包
make-number:贴类型符号number。+number:用 Lisp 原生加法,再贴类型。- 安装:
(put 'number 'add +number),其余同理。
10.6 完整示例:复数乘法 (3+4i)·(2+6i)
数据在高层的形态层层嵌套:
(complex . (rectangular . (3 . 4)))
即:最外层类型是 complex,内容是 George 的 rectangular 复数 (3 . 4)。
执行 mul 的过程:
- 通用
mul调用operate-2。 - 看类型是
complex,查表得到*complex,剥掉complex类型,把(rectangular . (3 . 4))交给复数世界。 - 复数世界执行
*c,需要取模,于是调用magnitude。 magnitude发现类型是rectangular,再剥掉一层类型,把(3 . 4)交给 George 处理。- George 计算 √(3²+4²)。
要点:类型链的长度 = 需要向下穿透的层数。每一层垂直屏障处的歧义,都由类型标签指明该往哪走。数据被构造并向上返回时,每一层再把自己的类型贴回去。
十一、扩展到多项式:递归类型塔 [55:00]
11.1 多项式的表示
- 类型数据,类型符号
polynomial。 - 内容:
(变量 . 项表),如变量 x 加项表。 - 项表(term list):一串
(阶 . 系数)序对。- 例:x¹⁵ + 2x⁷ + 5 表示为
((15 . 1) (7 . 2) (0 . 5))。
- 例:x¹⁵ + 2x⁷ + 5 表示为
拓展【拓展】:视频强调「表示多项式有大量方式和权衡」,这是符号代数系统的核心设计问题。此处选用的「按阶降序的稀疏项表」是较标准的一种。
11.2 多项式加法 +poly
- 用
make-poly构造:把变量和项表 cons 起来,贴上polynomial类型。 - 只处理同变量多项式,否则报错。
- 核心工作推给
+terms(项表相加)。
11.3 项表相加 +terms 的分情况分析
递归处理,三种情况:
- L1 为空 → 返回 L2;L2 为空 → 返回 L1。
- T1 的阶 > T2 的阶 → 递归相加剩余项,再把 T1 接上去(T1 不会与低阶项合并)。
- T1 的阶 < T2 的阶 → 对称处理,把 T2 接上去。
- T1 的阶 = T2 的阶 → 这是唯一真正需要「合并」的情况:递归相加剩余项,再构造一个新项,阶不变,系数用通用
add相加。
考点:整个
+terms中只有一个「有趣的符号」——那个通用的add。 正是它让系统获得了递归扩展能力。
11.4 关键洞察:把多项式加法归约为系数加法
因为系数相加用的是通用 add,所以:
- 多项式的系数可以是有理数、复数(直角或极坐标)、普通数。
- 系统自动支持:系数为 2/3、5/7 等有理数的多项式。
- 系统自动支持:系数为 3+2i 等复数的多项式。
这是「免费」获得的——因为我们把多项式加法归约为系数加法,而系数加法由通用 add 完成。
11.5 递归类型塔
因为 +poly 本身也存进了表(多项式也是「可相加的东西」),所以:
- 多项式可以以多项式为系数 → 多项式的多项式。
- 例:一个关于 y 的多项式,其系数是关于 x 的多项式。
- 由此形成无限递归的类型塔:
普通数 / 有理数 / 复数(直角或极坐标)→ 以其为系数的多项式 → 以其为系数的多项式 → ……
思考:这就是「类型作为构造器」的思想。多项式是一个类型构造器:给定「整数」类型,返回「以整数为系数的 x 的多项式」类型。
十二、进一步推广:有理对象与矩阵 [1:05:00]
12.1 有理对象
- 有理数的本质是「整数 / 整数」,但可以推广为任意分子 / 任意分母。
- 例:(3x + 7) / (x² + 1),分子分母都是多项式。
- 加法公式不变:分子₁·分母₂ + 分母₁·分子₂ / (分母₁·分母₂)。
- 安装方法:把原有理数包中的
+和*换成通用的add和mul,仅此一处改动。
于是系统自动支持:
- 分子是「系数为有理数的 x 多项式」的有理对象。
- 分子是「系数为「由复数构造的有理对象」的 x 多项式」的有理对象。
12.2 矩阵
- 2×2 矩阵的元素可以是任意类型。
- 矩阵加法:对应元素相加——「不管它们喜欢怎么加」。
- 同样方式实现,系统自动支持:元素为「分子分母都是多项式的有理对象」的矩阵。
12.3 本质:去中心化控制(Decentralized Control)
我们干掉了那个到处插手别人事务的经理,建立了去中心化的系统。
当你进入
add时,没有人检查「你是否在官方可加名单上」。相反,你说:「按你自己部件喜欢的方式去加吧。」结果是:自动获得一个极其复杂的层次结构,很多事情被自动路由到正确的地方。
十三、课堂讨论:递归结构的代价与边界 [1:12:00]
13.1 质疑一:底层依赖高层,是否破坏了结构清晰性?
回答:这不是「更不结构化」,而是递归结构。递归不等于混乱。
13.2 质疑二:修改通用操作符会波及整个系统?
回答:这是双向的。例如若让 +rat 做最大公约数(GCD)计算,这个改进会立即对所有上层(多项式、有理对象等)生效。好处坏处取决于你如何定义「系统一致性」。
注意:视频明确提到此处忽略了 GCD 问题以简化示例,这是本讲的一个已知简化。
13.3 质疑三:类型结构在实际运行时才演化,而非静态定义?
回答:正确。类型结构是递归的、运行时演化的,无法用有限列表预先枚举。要描述这个系统,必须用递归结构而非有限清单。
13.4 用户如何构造数据?
- 真实用户会有复数构造器,或在读入循环(reader loop)中有输入复数的语法。
- 用户只需说
mul,系统自动识别类型并路由。 - 用户不必知道 rectangular / polar 的存在。
13.5 修改局部表示的影响范围
- Martha 若想改变序对中模和角的顺序,只需局部修改,改动会正确传播到整个系统。
- 若新增一种有理数表示,只需把它填入表中,所有上层(多项式等)自动获得该表示的能力。
13.6 质疑四:这个「完美结局」是否过于人为设计(contrived)?
回答(讲师坦诚):
- 这是一套人们从符号代数实践中逐步摸索出来的方法论,不是一开始就设计好的。
- 这个例子确实相当人为构造。
- 系统会失效:例如
(add 3 7/1),operate-2会发现一个是number类型、一个是rational类型,无法相加。
13.7 未讲的核心难题:强制类型转换(Coercion)
我们希望系统至少能说:「在做之前,把 3 变成 3/1,转成有理数,交给有理数包处理。」
这就是强制类型转换(coercion) 问题:
- 需要知识:如何把普通数看作有理数,如何把普通数看作复数。
- 更棘手的情况:复数加有理数,如 (a+bi) + 5/7,需要有人知道要转换成「实部虚部可能是有理数的复数」。
- 谁来负责? 是复数知道?是有理数知道?还是
+知道? - 这正是系统真正复杂的地方,也是消息传递风格被提出的动机之一。
拓展【拓展】:强制类型转换(coercion)与类型提升(type coercion)是现代编程语言类型系统的核心议题。SICP 后续章节会给出「类型塔 + 提升」的方案,即把类型按「高度」排列,低类型自动提升为高类型。
13.8 质疑五:高层操作被「冻结」,改动影响范围不可知?
回答:
- 若新增一个通用操作(如相等性测试
equality test),所有表示方都必须决定是否提供该操作。 - 可以通过更彻底的去中心化缓解:不只在对象里存类型符号,而是直接在对象里存它知道的所有操作(即消息传递风格)。
- 例:GCD 只对整数有意义,对一般有理数无意义——系统可能非常「碎片化」。
- 存在一个完整的光谱,你可以在不同位置建立灵活性。
- 但确实存在弱点:顶层必须对「通用操作」达成某种一致,或至少各方必须考虑它们。
十四、本讲核心概念总结
14.1 三种实现通用操作符的策略
| 策略 | 机制 | 缺点 |
|---|---|---|
| 类型分派(dispatch on type) | 经理根据类型标签做条件分派 | 加新类型需改经理,瓶颈在中心 |
| 数据导向编程(data-directed) | 操作表 + operate 过程 |
需要顶层对通用操作达成一致 |
| 消息传递(message passing) | 对象内直接携带操作 | 系统碎片化,一致性难保证 |
14.2 关键术语
- 通用操作符(Generic Operator):行为取决于所面对数据类型的操作符。
- 类型数据(Typed Data):
(类型 . 内容)结构的数据对象。 - 垂直屏障(Vertical Barrier):让多套表示互不干扰共存的隔离层。
- 数据导向编程(Data-Directed Programming):用表 +
put/get/operate组织系统,数据对象携带操作信息。 - 去中心化控制(Decentralized Control):取消中心经理,各方自行注册到表中。
- 强制类型转换(Coercion):把一种类型视为另一种类型(本讲未展开)。
14.3 容易踩坑的点
- 同一个序对在不同表示下含义完全不同,必须靠类型标签区分,否则灾难性混淆。
- 表里存的是过程对象,不是过程名字——所以命名冲突不影响,过程甚至可以匿名。
operate中剥掉类型这一步至关重要:底层过程只认「内容」,不认类型标签。- 多项式加法的威力全在那一个通用
add——写错成原生+,递归类型塔立刻崩塌。- 系统不是万能的:不同类型间的运算(如 number + rational)会失败,需要 coercion 机制,本讲未解决。
- 类型结构是运行时演化的递归结构,不能用有限列表静态描述。
14.4 一句话精华
把「该怎么做」的知识,从中心化的经理,下放到数据对象自身携带的类型标签与操作表中——于是系统获得了递归、可扩展、去中心化的能力。
关键标签 / 关键词
SICP 6.001 通用操作符 Generic Operator 类型数据 Typed Data 类型分派 Dispatch on Type 数据导向编程 Data-Directed Programming 消息传递 Message Passing 垂直屏障 抽象屏障 复数算术 直角坐标 极坐标 多项式 递归类型塔 去中心化控制 强制类型转换 Coercion put/get/operate 符号代数 系统可扩展性
生成时间:2026-10-11 17:06:54 生成平台:VideoNote www.xygy.top