视频封面

视频元信息

  • 课程:MIT 6.001 Structure and Interpretation of Computer Programs(SICP)1986 年原版
  • 讲次:第 9 讲(p09)——通用操作符(Generic Operators)
  • 主讲:Hal Abelson
  • 主题:抽象屏障、类型化数据、类型分派、数据导向编程、通用算术系统、强制类型转换(Coercion)

本节核心是解决"同一抽象接口存在多种不兼容表示"的系统组织问题。理解顺序:水平抽象屏障 → 垂直抽象屏障 → 类型化数据 → 类型分派 → 数据导向编程(表格)→ 通用算术系统 → 递归类型塔 → 强制类型转换。


一、从水平屏障到垂直屏障 [00:20]

前面课程讲的是"把使用与表示分离"(separating use from representation),即在抽象数据之上建立水平抽象屏障:上层用 real-part、magnitude 等抽象选择器,下层由某个人(如 George)负责具体实现。

理解:水平屏障解决的是"一个接口、一个实现"的问题,上层不需要知道数据长什么样。

但现实系统里往往同一种抽象存在多个互不兼容的实现。这时只靠水平屏障不够,还需要垂直屏障——让 George 的实现和 Martha 的实现各自独立存在、互不干扰。

类比:一家大公司有多个部门,每个部门对"人事记录"(personnel record)的存储格式约定完全不同。公司总部希望在任何地方都能问"这条人事记录的姓名是什么""职务描述是什么",而不必关心是哪个部门、用什么格式存的。

易错点: 常见的错误做法是"下发一份统一格式命令,要求所有部门改成兼容格式"——这在实际中几乎从不成功。正确做法是保留各部门的独立格式,在中间加一层通用操作符(generic operator)。

考点:generic operator 的定义——它的具体行为取决于它所面对的数据类型。并且系统应设计成:新部门加入时,不需要修改已有的任何代码。


二、用复数算术作为具体问题 [05:14]

为了把问题讲清楚,Abelson 选了一个比人事记录更自包含(self-contained)的问题:复数算术系统。

复数的两种表示

复数可以看成平面上的一个点 z,有两种等价表示:

  • 直角坐标形式(rectangular form):z = x + yi,x 为实部,y 为虚部。
  • 极坐标形式(polar form):z = r·(cos A + i·sin A),r 为模(magnitude),A 为辐角(angle)。

两者的换算关系:

  • x = r·cos(A),y = r·sin(A)
  • r = √(x² + y²),A = arctan(y/x)

【拓展】复数的极坐标形式之所以重要,是因为乘除法在极坐标下极其简洁:模相乘、角相加。这正是后面"哪种表示更好"讨论的数学基础。

复数运算的数学公式

  • 加法:实部相加、虚部相加。Re(z1+z2) = Re(z1)+Re(z2),Im(z1+z2) = Im(z1)+Im(z2)。
  • 乘法(极坐标下最自然):模相乘、角相加。|z1·z2| = |z1|·|z2|,∠(z1·z2) = ∠z1 + ∠z2。
  • 减法:与加法同构,对应部分相减。
  • 除法:模相除、角相减。

考点: 加法在直角坐标下简单,乘法在极坐标下简单——这是后面"表示之争"的根本原因。


三、复数的抽象数据实现 [08:40]

与上一讲的有理数完全同构,先假定一组构造器和选择器:

构造器(constructors):

  • make-rectangular(real, imag):由实部、虚部构造复数
  • make-polar(magnitude, angle):由模、角构造复数

选择器(selectors):

  • real-part(z)、imag-part(z)、magnitude(z)、angle(z)

在这层抽象之上,直接用公式实现运算:

  • add-complex:用 real-part 与 imag-part 取出两部分,分别相加,再用 make-rectangular 装回去。
  • sub-complex:同上,但改为相减。
  • mul-complex:用 magnitude 与 angle 取出,模相乘、角相加,再用 make-polar 装回。
  • div-complex:模相除、角相减。

推导:注意 add-complex 用的是直角坐标的选择器,而 mul-complex 用的是极坐标的选择器——运算层并不关心底层是哪种表示,这正体现了抽象屏障的价值。


四、George 与 Martha 的两种表示 [12:30]

George 的方案:直角坐标表示

  • 复数 = 一对 (real-part . imag-part)
  • 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 成对

Martha 的方案:极坐标表示

  • 复数 = 一对 (magnitude . angle)
  • make-polar:直接 cons 成对
  • magnitude:取 car;angle:取 cdr
  • real-part:r·cos(A),即 car·cos(cdr)
  • imag-part:r·sin(A),即 car·sin(cdr)
  • make-rectangular:先算 √(x²+y²) 和 arctan(y/x),再 cons 成对

哪种更好?

  • 如果系统大量做加法,George 的直角坐标表示更优(实部虚部直接相加)。
  • 如果大量做乘除法,Martha 的极坐标表示更优(模乘角加)。
  • 关键结论:你无法决定。 也许两种都得留着。

这正是需要垂直屏障的场景:George 的矩形复数与 Martha 的极坐标复数共存于同一系统,互不干扰,而上层的 add、sub、mul、div 照常工作。


五、类型化数据(Typed Data)[18:50]

核心思想:让数据对象自带标签,标明它是谁造的。

  • 不再只有普通的复数,而是"带签名的"复数:一个标签写着 "Martha",另一个写着 "George"。
  • 每次操作前先读标签,就知道该用谁的规则处理。

类型化数据的构造:

  • attach-type(type, contents):用 cons 把类型符号和内容配对
  • type(datum):取 car
  • contents(datum):取 cdr

类型谓词(type predicates):

  • 判断是否为矩形复数:检查 type 是不是符号 rectangular
  • 判断是否为极坐标复数:检查 type 是不是符号 polar

易错点: 类型符号是符号(symbol),不是字符串,比较时要用符号相等判断。


六、让 George 和 Martha 接入系统 [23:00]

两人各自独立设计自己的复数表示包,接入系统只需很小的改动:

George 需要做的:

  1. 构造复数时,除了 cons 成对,还要贴上类型标签 rectangular。
  2. 重命名过程,避免与 Martha 冲突:
    • real-part → real-part-rectangular
    • imag-part → imag-part-rectangular
    • magnitude → magnitude-rectangular
    • angle → angle-rectangular

Martha 需要做的:

  1. 构造复数时贴上类型标签 polar。
  2. 重命名过程:real-part-polar、imag-part-polar、magnitude-polar、angle-polar。

思考: 重命名这一步很烦人,但本节先跳过这个问题——后面讲"名字与环境的结构"时会给出把命名空间分开打包的办法。


七、类型分派(Dispatch on Type)[26:00]

现在需要一个"经理"(manager)来查看数据类型并分派。

通用选择器 real-part 的工作方式:

  1. 看这个复数的类型。
  2. 如果是 rectangular,剥掉类型(用 contents),把内容交给 real-part-rectangular。
  3. 如果是 polar,剥掉类型,交给 real-part-polar。

imag-part、magnitude、angle 同理。

系统三部分结构:

  • George 的矩形表示包
  • Martha 的极坐标表示包
  • 经理(manager):负责看类型、分派

具体例子

复数 1 + 2i:

  • 在系统里表示为:(rectangular . (1 . 2)),即内容 (1 . 2) 加上标签 rectangular。
  • 取 real-part 时,经理看到是 George 的,剥掉类型,把 (1 . 2) 交给 George 的过程。

关键点: 同样的 (1 . 2) 在 Martha 的世界里含义完全不同(模为 1、角为 2)。但系统不会混淆,因为任何一对数据从底层返回上层时,都会被贴上对应的类型标签——rectangular 或 polar。

考点:这种策略叫 "dispatch on type"(按类型分派)。


八、类型分派的缺陷:官僚瓶颈 [32:30]

批评一:命名冲突问题。 George 和 Martha 必须重命名各自的过程,很烦人。(本节暂不解决。)

批评二(重点):新人加入时的改动问题。

  • George 和 Martha 都不受影响,各自待在自己的世界里。
  • 但经理必须改:如果 Harry 带来一种新的复数类型,经理的所有过程里都要加上对 Harry 类型的判断。

核心痛点: 系统中最不灵活、阻碍变化的地方,恰恰是那个什么都不做的官僚(manager)——它只是转发的"纸面工作者"(paper pusher)。


九、表格与数据导向编程 [36:00]

抽象地看,经理实际上就是在查一张表(table):

操作 \ 类型 polar rectangular ...
real-part real-part-polar real-part-rectangular
imag-part imag-part-polar imag-part-rectangular
magnitude magnitude-polar magnitude-rectangular
angle angle-polar angle-rectangular

解决办法:把经理自动化掉(automate him out of existence),让系统直接查表。

表格抽象

  • put(key1, key2, value):把 value 存到以 key1、key2 为键的位置。
  • get(key1, key2):取出对应的 value;若没有则返回空表(null)。

理解:表格本身也是抽象数据,其实现细节(如何用表结构实现)是另一个"George 的问题",本节不展开。

George 和 Martha 的责任

每人负责建立自己那一列:

  • George:put(rectangular, real-part, real-part-rectangular),以及 imag-part、magnitude、angle 各一行。
  • Martha:put(polar, real-part, real-part-polar),等等。

Harry 来了怎么办? 他自己建新的一列即可,不需要任何人修改已有代码。

operate 过程

(define (operate op obj)
  (let ((proc (get (type obj) op)))
    (if (not (null? proc))
        (proc (contents obj))
        (error "undefined operator"))))

工作流程:

  1. 用对象的类型和操作名作为两个键去查表。
  2. 如果查到过程,剥掉类型,把内容传给它。
  3. 如果没查到,报错 "undefined operator"。

通用选择器的定义

(define (real-part z) (operate 'real-part z))
(define (imag-part z) (operate 'imag-part z))
(define (magnitude z) (operate 'magnitude z))
(define (angle z)     (operate 'angle z))

operate + 表格,完全取代了原来的经理。


十、数据导向编程的执行示例 [43:30]

设 z 是 Martha 的复数:模 1、角 2,标签 polar。

求 (real-part z):

  1. real-part 展开为 (operate 'real-part z)。
  2. operate 查表,用 (type z) = polar 和 real-part 作为键。
  3. 查到 Martha 放进表里的过程 real-part-polar。
  4. 剥掉 z 的类型,把内容 (1 . 2) 交给 real-part-polar。
  5. 得到结果。

核心思想:数据对象自身携带着"如何操作它"的信息。这叫数据导向编程(data-directed programming)。

课堂问答要点

问:数据对象里存了什么?操作存在哪里?

  • 数据对象里存的是:数据本身(如 (1 . 2))+ 类型符号(如符号 polar)。
  • 操作存在表格里。表格的行列标签是符号;存进去的值是过程。

易错点: 讲课时写 magnitude-polar 容易让人以为是符号,实际上它指的是过程 magnitude-polar。更准确的写法是用 lambda。

拓展:命名冲突的另一种解法——George 和 Martha 根本不必给过程起名,直接把 lambda 生成的匿名过程塞进表格即可。

拓展:消息传递(message passing)——另一种系统组织方式:不存类型符号,而是把所有操作本身存进数据对象里。


十一、把复数系统嵌入通用算术系统 [48:00]

前面只是展示了机制,真正的威力要在更复杂的系统中才显现。

设想一个通用算术系统(generic arithmetic system):

  • 顶层:add、sub、mul、div 通用操作符
  • 中间:抽象屏障
  • 下层:复数包、有理数包、普通数包

有理数包的接入

有理数包本身完全不用改(上一讲的代码原样保留),只需:

  1. 构造有理数时贴上类型标签 rational。
  2. 把操作装进表格:
    • put(rational, add, +rat)
    • put(rational, sub, -rat)
    • put(rational, mul, *rat)
    • put(rational, div, /rat)

注意: 表格的键是符号(要 quote),值是过程。

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")))

考点: 与 operate 的关键区别——先检查两个参数类型是否相同,不同则报错。

通用操作符的定义

(define (add x y) (operate-2 'add x y))
(define (sub x y) (operate-2 'sub x y))
(define (mul x y) (operate-2 'mul x y))
(define (div x y) (operate-2 'div x y))

复数包的接入

  1. make-complex:把 George/Martha 给的复数贴上类型 complex。
  2. 内部过程 +C、-C、*C、/C 保持不变。
  3. 对外过程 +complex 等,区别仅在于返回值带类型。
  4. 装表:put(complex, add, +complex) 等。

普通数包的接入

  1. make-number:给普通数贴上类型 number。
  2. +number:用普通加法,结果贴上类型。
  3. 装表:put(number, add, +number) 等。

十二、多层类型链的执行示例 [55:00]

计算 (3+4i) × (2+6i):

数据的嵌套结构(自顶向下):

(complex . (rectangular . (3 . 4)))

执行流程:

  1. mul 调用 operate-2。
  2. operate-2 看到类型是 complex,查表得到 *complex,剥掉 complex 标签。
  3. 进入复数世界,*C 需要取 magnitude。
  4. 取 magnitude 又触发 operate,看到类型是 rectangular,查表得到 magnitude-rectangular,再剥一层标签。
  5. 交给 George 的过程处理。

类型链(chains of types):链的长度等于你在表格中下降的层数。每当有一个垂直屏障、存在"该往哪层走"的歧义时,类型就是在告诉你该往哪走。底层构造数据时,再把自己的类型贴回去。


十三、多项式与递归类型塔 [59:00]

多项式的表示

多项式也是类型化数据对象:

  • 类型标签:polynomial
  • 变量:如 x
  • 项表(term-list):一串 (order . coefficient) 对

例:x¹⁵ + 2x⁷ + 5 表示为项表 ((15 . 1) (7 . 2) (0 . 5))。

思考:多项式表示方式有很多种,各有取舍(tradeoff),这是符号代数系统的经典设计问题。

多项式加法

(define (add-poly p1 p2)
  (if (same-variable? p1 p2)
      (make-poly (variable p1)
                 (add-terms (term-list p1) (term-list p2)))
      (error "polys not in same var")))

装表:put(polynomial, add, +poly)。

add-terms 的递归结构

对两个项表 L1、L2 做递归归并:

  • 若 L1 为空,返回 L2;若 L2 为空,返回 L1。
  • 取各自首项 T1、T2,比较阶数:
    • order(T1) > order(T2):递归合并 L1 的剩余项与 L2,再把 T1 拼到最前。
    • order(T1) < order(T2):递归合并 L1 与 L2 的剩余项,再把 T2 拼到最前。
    • order(T1) = order(T2):递归合并剩余项,再构造一个新项,阶数不变,系数为两系数之和。

十四、递归类型塔的威力 [1:03:00]

关键洞察: add-terms 里那个"系数相加"用的不是普通 +,而是通用 add。

由此自动获得:

  • 系数可以是有理数:(2/3)x² + (5/7)x + 11/4
  • 系数可以是复数:(3+2i)x⁵ + (4+7i)
  • 系数可以是任意系统能加的东西

更进一步: 因为多项式本身也被装进了表格(put(polynomial, add, +poly)),所以多项式自己也是可以被加的东西。于是可以构造:

  • 变量为 y、系数为"x 的多项式"的多项式
  • 系数是"系数是有理数的多项式"的多项式
  • ……无限递归下去

考点:多项式是一个"类型构造器"(constructor for types)——给它一个类型(如整数),它返回"以该类型为系数的 x 的多项式"类型。对多项式的操作归约为对其系数的操作。

推广:有理对象与矩阵

一般有理对象:(3x+7)/(x²+1),分子分母都是多项式。

只需把有理数包里的 +、* 换成通用操作符 add、mul,整个系统立刻能处理这类对象。

2×2 矩阵:加法归约为元素相加,同样方式接入。于是可以处理"元素是分子分母为多项式的有理对象"的矩阵。

本质:去中心化控制(decentralized control)。 进入 add 时,没有人检查"你是否在官方可加名单上",而是说"你自己去按你的部件喜欢的方式相加"。结果是自动涌现出极其复杂的层次结构。


十五、课堂讨论:结构性与复杂性 [1:10:00]

问:这样是不是破坏了上下层的清晰分界?下层定义依赖了上层的东西?

  • 答:这只是递归。说"加这些要用那个",并不是结构更差,只是递归结构。

问:改一下 add 会有巨大后果,你甚至不知道影响范围?

  • 答:这要看你怎么定义"一致性"。比如本节忽略了 GCD(最大公约数)问题;如果突然决定 +rat 要做 GCD 计算,那么它立刻对所有上层可用。好处是大部分情况下这正是你想要的。

问:类型结构是运行时演化出来的,不是定义时就固定的?

  • 答:正确。类型结构是递归的、随系统运行演化的,无法用有限清单预先枚举。要描述这样的系统,必须用递归结构。

问:用户不知道 rectangular / polar,怎么构造数据?

  • 答:用户手里会有构造器(如 make-complex),或读入循环(reader loop)提供某种输入复数的格式。用户只需说 mul,系统自己搞定。

问:改一种表示要改多少代码?

  • 答:只改你要改的那一处。比如 Martha 想把对里 angle 和 magnitude 的顺序调换,她只做局部修改,整个系统会自动正确传播。新增一种有理数表示,只要把操作装进表格,所有上层(包括系数是系数是多项式的对象)自动获得该表示。

问:这例子是不是太刻意了?

  • 答:部分是。这是从符号代数领域提炼出的方法论,很多人摸爬滚打才总结出来。但确实能说明设计复杂系统的真实困境:有很多种部件、甚至不知道将来要对部件做哪些操作,如何组织操作使得组合起来"自动免费地"工作。

十六、系统的局限:强制类型转换(Coercion)[1:16:00]

本节系统的一个明显破绽:

如果执行 (add 3 (make-rational 7 8))——一个 number 类型、一个 rational 类型——operate-2 会说"类型不同,我不知道怎么加",直接报错。

理想行为: 系统应该能自动把 3 转成 3/1,交给有理数包处理。

这就是 coercion(强制类型转换)问题:既然费尽心思把各种类型建成不同对象,很多时候又需要知道"如何把普通数看成有理数""如何把普通数看成复数"。

真正的复杂性从这里开始:这份知识该放在哪里?

  • 是有理数包知道"普通数可能是它的一部分"?
  • 还是复数包负责把有理数转成"实部为有理数的复数"?
  • 谁负责 (add 5/7 (make-complex ...)) 这种混合运算?

思考:Abelson 指出,消息传递(message passing)这套东西很大程度上正是被这类问题激发出来的。而符号代数问题极其复杂,处于前沿的人始终处在"摸爬滚打、蒸馏经验"的状态。


十七、再次讨论复杂性 [1:19:00]

问:下层改动很灵活,但上层操作实际上被"冻结"了?改上层不知道影响会扩散到哪里?

答:这是极好的问题。比如要新增一个通用操作 equality-test,那么所有表示包都必须决定"我是否支持相等测试",方法是去查表。

拓展:可以进一步去中心化——不只在数据里存类型符号,而是把操作本身存进每个对象(即消息传递)。

拓展:碎片化系统——像 greatest-common-divisor 这种只对整数有意义、对一般数无意义的过程,会让系统非常碎片化。你可以根据想要的灵活性,在整个谱系上选择把灵活性建在哪里。

结论: 数据导向编程的弱点在于——顶层必须就"有哪些通用操作"达成某种一致,或者至少人们得考虑过它们。你也可以选择一张非常稀疏的表,只放少数几项。玩法很多。


关键标签 / 关键词

SICP 通用操作符 Generic Operator 抽象屏障 垂直屏障 类型化数据 Typed Data 类型分派 Dispatch on Type 数据导向编程 Data-Directed Programming operate put/get 表格 复数算术 极坐标 直角坐标 有理数 多项式 递归类型塔 去中心化控制 消息传递 Message Passing 强制类型转换 Coercion 符号代数 MIT 6.001 Hal Abelson


生成时间:2026-10-11 14:09:34 生成平台:VideoNote www.xygy.top