
视频元信息
- 课程:MIT 6.001 Structure and Interpretation of Computer Programs(1986 原版)
- 主题:计算对象(Computational Objects)——面向对象编程、事件驱动仿真、赋值与可变数据
- 讲师:Hal Abelson(本讲由 Abelson 主讲)
内容摘要(TL;DR)
本讲从"世界中的对象"出发,引出面向对象编程思想,并以数字电路仿真器为例,讲解如何用 LISP 构建嵌入式语言。核心内容包括:原始对象与组合手段、抽象屏障与层次化设计、事件驱动仿真中的信号传播、议程(agenda)与队列(queue)的数据结构实现,以及引入赋值后 cons 的身份(identity)与共享(sharing)问题,最后用纯 lambda 演算实现可变 cons。
一、从世界对象到计算对象 [00:00]
1.1 对象的核心概念
考点:对象 = 独立局部状态 + 身份
- 视频开篇指出:世界中的每个事物都有独立的局部状态(independent local state),这正是它成为一个"事物"(thing)的根本原因。
- 我们希望在世界中的对象与计算机中的对象之间建立一一对应关系:
- 世界中的每个对象 → 计算机中的对象
- 世界中对象之间的关系 → 计算机中对象之间的关系
理解:这就是面向对象编程(Object-Oriented Programming)的思想源头。对象不仅仅是数据的封装,更重要的是它拥有"自己的状态",且状态可以随时间变化。
- 正是这种对应关系的需求,催生了面向对象编程的发明。
1.2 为什么选择电路作为对象模型
- 讲师认为电路系统是物理学家能找到的最好的对象模型。
- 电路的关键优势:连接是清晰明确的
- 一段电线连接机器的两个部分,原则上没有任何"未描述"的连接。
- 例如:灯泡与电源插座相连,连接关系完全清楚。
- 即使把电线打个结,灯泡依然亮——物理排列方式不影响连接语义(至少在低频情况下)。
思考:这种"连接清晰性"正是模块化设计的基础。对象之间的交互应当像电路中的导线一样明确、可追踪,而不是隐式的全局耦合。
1.3 数字电路——最抽象的电路对象
- 数字电路是电路系统中最抽象的类型:
- 原始对象:反相器(inverter)、与门(AND gate)、或门(OR gate)
- 组合手段:用导线(wire)连接,导线代表抽象信号
- 我们甚至不关心信号是电压、电流还是水压——它们是抽象变量
理解:抽象层次越高,越能忽略底层物理细节。数字电路把"电压/电流"抽象为"信号 0/1",这正是层次化抽象的典范。
二、在 LISP 中构建嵌入式语言 [04:13]
2.1 两种"嵌入"的区别
易错点:区分两种语言嵌入方式
| 方式 | 说明 | 例子 |
|---|---|---|
| 解释器方式 | 用 LISP 程序解释另一种语言 | 模式匹配替换语言(上一讲) |
| 过程封装方式 | 构建越来越多的过程来封装结构 | Henderson 的图片语言、本讲的电路语言 |
- 本讲采用第二种方式:用过程(procedure)封装电路结构,而非写一个解释器。
2.2 电路语言的原始对象与组合
- 原始对象:反相器、与门、或门
- 组合手段:导线(wire)
- 示例声明:
- 定义导线 A、B、C、D、S
- 或门:输入 A、B,输出 D
- 与门:输入 A、B,输出 C
- 通过一系列声明,可以连接出任意电路。
2.3 抽象:半加器(Half Adder)
考点:抽象 = 盒子(box)
- 半加器:输入 A、B,输出和(Sum)与进位(Carry)
- 抽象的关键是边界(boundary),即盒子:
- 盒子外部可见的变量:A、B、S、C(即 lambda 表达式的声明变量)
- 盒子内部隐藏的互连线:D、E(不穿透盒壁)
- 内部连线逻辑:
- E 是或门输出线,D 是与门输出线
- 按与门、或门、反相器的组合方式连接
理解:抽象屏障(abstraction barrier)让使用者只需关心盒子的接口,无需了解内部实现。这正是模块化编程的核心。
2.4 层次化设计的正确性
考点:语言必须"正确层次化"
- 一个好的语言,复合对象应当看起来像原始对象。
- 如果复合对象不像原始对象,说明语言设计有问题。
- 本讲电路语言的层次:
- 起点:电学对象(而非数学函数)
- 构建:更多电学对象
- 胶水:list 结构(cons),"cons 是终极胶水"
2.5 全加器(Full Adder)与更大抽象
- 全加器由两个半加器 + 额外导线(SC-1、C-2)+ 一个或门组成
- 输入:两个数 + 进位输入(carry-in)
- 输出:和(sum)+ 进位输出(carry-out)
- 用全加器可以构建行波进位加法器链(ripple-adder chains)和更大的加法器
拓展:半加器只能处理两个 1 位二进制数相加,无法处理来自低位的进位;全加器通过引入 carry-in 解决了这个问题,是构建多位加法器的基础单元。
三、实现原始对象:反相器与与门 [08:24]
3.1 实现策略
- 只需要实现原始对象,组合与抽象手段从 LISP 继承。
- 以反相器(inverter)为例:
- 有两个导线:输入(in)、输出(out)
- 需要知道信号到来时该做什么
3.2 对象间的通信机制
考点:对象通过"注册动作"实现通信
- 反相器必须告诉它的输入导线:"当你变化时,告诉我。"
- 类比:"我叫 George,我的工作是在你变化时处理结果。"
- 实现方式:在输入导线上添加一个动作(action),称为
invert-ininvert-in是一个无参数过程- 功能:获取输入导线上信号的逻辑非
- 经过反相器延迟(inverter delay)后,将输出导线上的信号设为新值
理解:这是事件驱动(event-driven)模型的核心。对象不主动轮询,而是被动响应——当依赖的信号变化时,被"唤醒"并执行动作。
3.3 输出导线的敏感性
- 输出导线必须"敏感":当它的信号变化时,要通知其他对象"嘿,醒醒,我的值变了!"
- 当反相器与与门连接时,会有大量通信发生
- 必须确保信号正确传播
3.4 与门(AND Gate)实现
- 与门有两个输入 A1、A2,一个输出
- 结构与反相器完全相同:
- 定义
and-action过程:当输入变化时被调用 - 功能:计算输入信号的逻辑与
- 经过与门延迟(AND gate delay)后,设置输出信号为新值
- 定义
- 实现细节:
- 使用赋值操作
set!(派生赋值操作,按惯例用感叹号标记) and-action通过add-action!告知 A1 和 A2 导线:当变化时调用and-action
- 使用赋值操作
四、信号传播的计算模型 [12:07]
4.1 物理世界与计算模型的对应
考点:为世界中的每个对象和关系,在计算机中建立对应对象和关系
- 示例电路:与门(输入 A、A)→ 导线 C → 反相器 → 输出导线 D
- 物理世界:可以在 Radio Shack 花几美分买到,型号如 LS04
- 目标:在计算机中为每个对象和每个关系建立对应
4.2 导线的内部状态
- 每个导线必须知道:
- signal:当前信号值(0 或 1)
- action-procedures:信号变化时需要通知的动作过程列表
- 每个导线关联一个环境(environment),绑定
signal变量
理解:这就是对象的"局部状态"。每个导线拥有自己的私有变量,外部无法直接访问,只能通过消息(message)交互。
4.3 对象间的连接关系
- 与门动作过程需要知道:
- A1:指向第一条输入导线
- A2:指向第二条输入导线
- output:指向输出导线
- 当与门动作过程被唤醒时:
- 询问 A1:"你的信号是什么?"
- 询问 A2:"你的信号是什么?"
- 计算逻辑与
- 通知输出导线:"你的信号变了"
4.4 与门的环境结构
- 调用与门时创建一帧(frame),绑定 A1、A2、output 到传入的导线
- 在该环境中构造
and-action过程(lambda 表达式求值的结果) and-action过程捕获(hang onto)定义它的帧- 因此
and-action可以访问 A1、A2、output——这是它的局部状态
考点:闭包 = 过程 + 环境
理解:这正是闭包(closure)的本质。and-action 过程"记住"了它被创建时的环境,因此能访问 A1、A2、output。这是面向对象中"对象拥有状态"的实现机制。
五、导线的实现细节 [15:00]
5.1 导线的构造
- 调用
make-wire创建导线时:- 创建变量
signal和action-procedures - 在该上下文中定义多个过程
- 创建变量
5.2 导线内部的过程
(1)set-my-signal!(设置信号)
- 接受新值
- 如果新值等于当前信号值,直接返回(不做任何事)
- 否则:
- 将 signal 设为新值
- 调用 action-procedures 列表中的每个过程
易错点:信号值未变化时不应触发动作
思考:这个"值不变则不通知"的优化避免了无限循环和不必要的计算。在事件驱动系统中,这种判断至关重要。
(2)accept-action-procedure!(接受动作过程)
- 将新过程 cons 到 action-procedures 列表
- 立即调用该过程一次
- 讲师提到这与"事件房间和仿真启动"有关,需要仔细思考
理解:立即调用一次是为了初始化。例如,与门刚连接时,需要根据当前输入值立即计算输出,而不是等待输入变化。
(3)dispatcher(调度器)
- 接受消息,提取信息:
get-signal:获取当前信号值set-signal!:获取设置信号的方法add-action!:获取添加动作过程的方法
- 返回 dispatch 过程作为值
5.3 导线的消息传递接口
考点:导线是"消息接受对象"
- 导线是一个接受消息的对象:
- 问:"你的添加动作过程的方法是什么?" → 返回一个过程
- 用该过程作用于一个动作过程 → 在导线中创建新的动作过程
- 获取信号:
(wire 'get-signal)→ 返回信号值 - 设置信号:
(define (set-signal! wire new-value) ((wire 'set-signal!) new-value)) - 添加动作:
(define (add-action! wire action) ((wire 'add-action!) action))
理解:这是消息传递风格(message-passing style)的经典实现。对象通过 dispatch 过程接受消息,根据消息类型返回不同的方法。这是早期面向对象编程的雏形。
六、时间控制:议程(Agenda)与延迟 [22:36]
6.1 延迟机制
- 反相器和与门都有延迟(delay)
- 信号变化后,经过延迟才执行动作
- 需要一种机制:延迟一段时间后执行动作
6.2 议程(Agenda)
考点:议程 = 组织时间和动作的结构
- 议程是一种特殊结构,用于组织时间和动作
- 延迟是一个数字,动作是一个过程
- 设置方式:
- 时间 = 延迟时间 + 当前时间
- 将动作添加到议程中该时间点
6.3 传播循环(Propagate)
- 仿真运行机制:
- 如果议程为空,结束
- 否则,取出议程的第一项(一个无参数过程)
- 调用该过程(执行动作)
- 从议程中移除第一项
- 回到步骤 1(循环)
理解:这就是事件循环(event loop)的原型。议程按照时间顺序组织待执行的动作,propagate 不断取出并执行,直到没有待处理事件。
七、仿真器演示 [24:00]
7.1 仿真器的实际应用
重点:这个仿真器被用于制造一台真实的计算机
- 讲师强调:这个仿真器看起来简单,但已被用于制造一台相当大型的计算机
- 实际使用的仿真器与演示的差异:
- 有更多种类的原始对象(不只是反相器和与门)
- 包括:边沿触发触发器(edge-triggered flip-flops)、锁存器(latches)、透明锁存器(transparent latches)、错误器件等
- 有大量原始对象定义(型号如 LS04),以及更多参数(建立时间、保持时间等)
- 但除这些复杂性外,仿真器的结构与演示的完全相同
拓展:这说明一个好的抽象设计具有强大的可扩展性。核心机制(事件驱动、议程、信号传播)不变,只需扩展原始对象库即可支持更复杂的电路。
7.2 演示过程
- 设置延迟,创建议程
- 创建输入:input-1、input-2、sum、carry
- 在 sum 和 carry 导线上放置探针(probe)
- 探针特性:当所连导线变化时,打印消息
- 初始状态:
- sum 在时间 0 的值为 0
- carry 在时间 0 的值为 0
- 构建半加器结构,连接 input-1、input-2、sum、carry
- 设置 input-1 信号为 1,传播:
- 时间 8:sum 新值变为 1
- 设置 input-2 信号为 1,传播:
- 时间 11:carry 变为 1
- 时间 16:sum 新值变为 0
理解:这个演示展示了事件驱动仿真的完整流程——设置信号、传播、观察结果。时间戳反映了信号经过各级门电路的累积延迟。
八、议程与队列的数据结构 [29:00]
8.1 仿真器的本质
- 仿真是一种"世界的仿真":
- 世界中的对象 → 计算机中的对象
- 世界中随时间变化的状态 → 计算机中的时间
- 世界中事件的先后顺序 → 计算机中事件的相同顺序
- 这种对齐需要赋值(assignment)
8.2 议程的原始操作
考点:议程的原始操作
| 操作 | 功能 |
|---|---|
make-agenda |
创建新议程 |
current-time |
获取议程当前时间 |
empty-agenda? |
判断议程是否为空 |
add-to-agenda! |
添加动作到议程(需指定时间) |
first-agenda-item |
获取议程第一项(返回动作) |
remove-first-agenda-item! |
移除议程第一项 |
8.3 议程的数据结构
- 议程是一个可修改的列表,按时间组织
- 结构:
- header(头部):有一个头部单元,便于在开头插入
- segments(段):列表的列表
- 每个段有一个时间(如 10、30)
- 每个段有一个队列(queue):该时间要做的动作列表
理解:为什么需要 header?因为要在列表开头插入元素时,需要有一个"位置"来修改。这是链表操作中的常见技巧。
8.4 议程的修改操作
(1)添加动作到已有时间段(如时间 10)
- 遍历找到时间 10 的段
- 增加该段的队列
(2)添加动作到不存在的时间段(如时间 20)
- 需要创建新段
- 修改前一段的 cdr,指向新的 cons(新段 + 后续段)
- 涉及数据结构的修改(mutation)
(3)添加动作到开头(如时间 5)
- 需要修改头部单元
- 这正是需要 header 的原因
(4)移除动作
- 从开头移除较简单:
- 找到第一个段
- 如果队列非空,删除队列中一个元素
- 如果队列变空,删除整个段,修改指针
8.5 队列(Queue)的数据结构
考点:队列的原始操作
| 操作 | 功能 |
|---|---|
make-queue |
创建新队列 |
insert-queue! |
插入新项 |
delete-queue! |
删除第一项 |
front-queue |
获取第一项 |
empty-queue? |
判断是否为空 |
队列结构:
- 有队列头(queue header),包含两个指针:
- front pointer:指向队首
- rear pointer:指向队尾
- 两个指针的作用:可以在队尾添加而无需从头遍历
操作:
- 插入(insert):创建新 cons,修改 rear 指针指向新项,更新 rear 指针
- 取队首(front):沿 front 指针找到第一项
- 删除(delete):移动 front 指针到下一项
理解:队列的 front/rear 双指针设计是经典的数据结构优化。如果用单指针,在队尾插入需要 O(n) 遍历;双指针使其变为 O(1)。
8.6 命名约定
易错点:注意操作命名约定
- 修改操作(有副作用):名字后加
!,如set-car!、insert-queue! - 测试操作(无副作用):名字后加
?,如empty-queue? - 这种约定帮助程序员区分操作是否有副作用
九、赋值与可变数据:cons 的身份问题 [37:00]
9.1 引入赋值后的 cons
- 原始 cons 的公理:
- 对所有 x、y:
(car (cons x y))= x - 对所有 x、y:
(cdr (cons x y))= y
- 对所有 x、y:
- 这些公理没有说明 cons 是否有身份(identity)
- 引入赋值(
set-car!、set-cdr!)后,公理不再讲述完整故事:- 如果设置某个 cons 的 car,是设置所有相同部分的 cons,还是只设置这一个?
9.2 数字 vs 物理对象
- 数字:改变分母没有意义。可以创建一个不同分母的新数,但"改变一个数的分母"在数学上很奇怪。
- 物理对象:改变 cons 的 car 就像剪掉指甲的一部分——对象有身份
- cons 用于表示物理世界的事物时,具有身份
9.3 身份与共享示例
(define a (cons 1 2))
(define b (cons a a))
a是一个指向 (1 . 2) 的指针b是一个指向 (a . a) 的指针- 此时对象 a 有三个名字:
a、(car b)、(cdr b)——这些称为别名(aliases)
(set-car! (car b) 3)
- 找到
(car b),即 a - 设置 a 的 car 为 3
- 现在
(car a)也是 3——a 被改变了
考点:共享(sharing)是强大但危险的
- 共享的好处:队列等数据结构正是为了促进共享而设计
- 共享的代价:意外的共享和非预期的对象交互,是复杂程序大多数 bug 的来源
思考:引入身份和共享带来了巨大能力,但也带来了复杂性。这就是为什么函数式编程(无副作用)在某些场景下更受青睐——它避免了共享带来的 bug。
9.4 非局部交互
(car (cdr b))
- 取
(cdr b),即 a - 取 a 的 car
- 结果也是 3
- 即使这个表达式与
(car a)看似无关,也受到共享的影响
易错点:共享导致非局部交互
理解:修改一个对象可能影响到看似无关的另一处代码。这是可变状态编程中最难调试的问题之一。
9.5 一种赋值操作就够了
- 表面上有多种赋值操作:
set!、set-car!、set-cdr! - 实际上,只要有一个
set!,就能实现所有"坏事" - 可以用纯 lambda 实现 cons,并用
set!实现set-car!和set-cdr!
十、用纯 lambda 实现 cons [42:00]
10.1 Church 的 cons 定义
考点:Alonzo Church 的 lambda 演算 cons
- Alonzo Church:20 世纪最伟大的程序员(虽然从未见过计算机),1930 年代在普林斯顿定义
- 定义:
(define (cons x y) (lambda (m) (m x y))) (define (car x) (x (lambda (a d) a))) (define (cdr x) (x (lambda (a d) d))) (cons 35 47)=(lambda (m) (m 35 47))(car ...)=((lambda (m) (m 35 47)) (lambda (a d) a))- 将
(lambda (a d) a)代入 m:((lambda (a d) a) 35 47) -
将 35 代入 a,47 代入 d:结果为 35
-
验证成功!不需要任何数据结构,甚至不需要数字
- cons 返回的过程接受 4 个参数:
- x 的值、y 的值
- 设置 x 的权限(permission)、设置 y 的权限
- 这些权限是闭包,捕获了 cons 参数所在的环境
set-car!使用设置 x 的权限- x 和 y 在父帧中有位置
- 权限过程可以修改这些位置
set-car!通过传入的权限过程,将 x 设置为新值- 逻辑上,只需一个
set!就能实现所有可变数据操作 - 实际实现中不会这样做(出于工程考虑),但原理上等价
- 没有独立局部状态的事物不能称为"对象"
- 状态通过闭包捕获环境实现
set-my-signal!中必须先判断新值是否等于旧值- 否则会导致无限循环和不必要的计算
- 多个名字指向同一对象时,通过任一名字修改都会影响其他名字
- 这是复杂程序 bug 的主要来源
- 调试时需特别注意"这个对象还有谁在引用?"
!结尾:有副作用(修改操作)?结尾:无副作用(测试操作)- 混淆两者会导致难以发现的 bug
- 在链表开头插入/删除时,需要 header 单元提供修改位置
- 否则无法修改指向第一个元素的指针
- 本讲展示的"消息传递对象"是面向对象编程的早期形态
- 与 Smalltalk(Alan Kay)的消息传递思想一脉相承
- Scheme 中的对象系统(如 CLOS 的前身)正是基于这种消息传递风格
- 现代 OOP 语言(Java、C++)的"对象 = 状态 + 方法"模型,本质上是这种思想的工程化
- 本讲的电路仿真器是离散事件仿真(Discrete Event Simulation)的经典案例
- 现代应用:
- 硬件描述语言(Verilog、VHDL)的仿真器
- 网络仿真(ns-3)
- 游戏引擎的事件系统
- GUI 框架的事件循环
- 核心机制相同:事件队列 + 时间推进 + 状态更新
- 本讲后半部分揭示了可变状态的复杂性
- 函数式编程(无副作用)的优势:
- 避免共享导致的 bug
- 易于并行化
- 易于推理和测试
- 但可变状态在某些场景下更自然(如仿真、GUI)
- 现代语言(如 Clojure、Scala)尝试在两者间取得平衡
- Church 编码证明:数据可以用函数表示
- 这启发了:
- 函数式编程语言的设计
- 类型论中的编码(如 Scott 编码)
- 编译器中的 CPS 变换(Continuation-Passing Style)
- 现代语言中的"对象即闭包"(如 JavaScript 的模块模式)也源于此
10.2 验证 Church 定义
计算 (car (cons 35 47)):
拓展:这就是 lambda 演算的威力——可以用纯函数表示数据结构。Church 编码(Church encoding)是函数式编程的理论基础之一。
10.3 可变 cons 的 lambda 实现
考点:用 set! 实现可变 cons
(define (cons x y)
(lambda (m)
(m x
y
(lambda (n) (set! x n))
(lambda (n) (set! y n)))))
(define (car x)
(x (lambda (a d sa sd) a)))
(define (cdr x)
(x (lambda (a d sa sd) d)))
(define (set-car! x y)
(x (lambda (a d sa sd) (sa y))))
(define (set-cdr! x y)
(x (lambda (a d sa sd) (sd y))))
关键点:
10.4 环境分析
推导:当调用 (cons x y) 时,lambda 表达式在 x、y 定义的环境中求值。该环境成为返回过程的父环境。因此:
考点:一旦引入一个赋值操作,就引入了所有赋值操作
理解:这是计算理论中的一个深刻结论——赋值的本质能力是相同的,不同的赋值操作只是语法糖。
十一、常见问题与易错点总结
11.1 对象与状态
易错点:对象必须有独立局部状态
11.2 信号传播
易错点:信号未变化时不应通知
11.3 共享与别名
易错点:共享导致非局部交互
11.4 命名约定
易错点:区分有副作用和无副作用的操作
11.5 议程与队列
易错点:链表修改需要"位置"
十二、拓展与延伸
【拓展】面向对象编程的历史脉络
【拓展】事件驱动仿真与现代应用
【拓展】函数式编程与可变状态的权衡
【拓展】Church 编码的深远影响
关键标签/关键词
SICP MIT 6.001 面向对象编程 计算对象 事件驱动仿真 数字电路 反相器 与门 或门 半加器 全加器 抽象屏障 层次化设计 消息传递 闭包 局部状态 议程 队列 信号传播 赋值 可变数据 set-car! set-cdr! cons 身份 共享 别名 Church 编码 lambda 演算 Alonzo Church Hal Abelson
生成时间:2026-10-11 14:23:19 生成平台:VideoNote www.xygy.top