视频封面

视频元信息

  • 课程:MIT 6.001 Structure and Interpretation of Computer Programs(SICP),1986 年原版
  • 本讲主题:第 9 讲 —— 赋值(Assignment)、状态(State)与副作用(Side Effects)
  • 主讲:Hal Abelson
  • 形式:课堂录像 + 板书 + 幻灯片

本讲是整门课程的重大转折点:引入赋值操作 set! 后,之前建立的"替换模型(Substitution Model)"被彻底摧毁,课程被迫重建一套全新的计算模型——环境模型(Environment Model)。这是理解后续所有内容的关键。


一、回顾:函数式编程与替换模型 [01:44]

本节回顾此前所学的"函数式程序"(functional programs)的本质,为引出赋值做铺垫。

  • 函数式程序是数学真理的编码。以阶乘为例,它由两条子句构成:若 n 为 1 则结果为 1,否则为 n 乘以 (n-1) 的阶乘。
  • 理解:这种写法与数学逻辑、微积分课上见过的递归定义本质相同,都是在陈述"真命题"。
  • 这类真命题的执行方式可以通过替换(substitution)来理解:把实参替换进过程体中的形参位置,得到一连串等式(如 factorial(4) = 4 × factorial(3) = 4 × 3 × factorial(2) …),每一步都保持真值不变。
  • 关键点:同一个函数可以有多种真命题的组织方式。例如求和函数既可以写成递归过程,也可以写成迭代过程,二者计算相同答案,只是真命题的排列方式不同。
  • 思考:这说明"要计算的函数"与"计算所用的方法"是可以分离的两件事——这是本课程反复强调的主题。

二、引入赋值:set! 与"时间"的诞生 [04:55]

本节正式引入本讲的核心新概念——赋值操作,并解释它为何如此特殊。

2.1 set! 的语法与含义

  • 编程语言中新增一种语句:set!(赋值)。它把一个变量设置为某个值。
  • 感叹号 ! 与问号 ? 一样,是附加在符号名上的约定标记,对系统本身没有意义,只是提醒人类读者"这是一个赋值操作"。
  • 赋值引入了"时间"的概念:set! 是第一个能区分"之前"和"之后"的构造。
    • 在此之前的所有程序,求值顺序无关紧要,因为它们是纯数学真理。
    • 而赋值会产生一个时间点:在此刻之前变量有旧值,在此刻之后变量有新值,且新值与旧值无关。

易错点:赋值打破了"同一表达式永远得到同一结果"这一性质。此前 factorial(4) 无论在什么上下文、写多少次,答案永远是 24(从参数到答案的唯一映射)。而一旦有了赋值,同样的表达式在不同时刻可能给出不同答案。

2.2 反例演示:demo 过程

  • 定义 count 为 1,再定义一个过程 demo:
    • 先把 count 设置为 1 + count(自增);
    • 再返回 x + count。
  • 执行 (demo 3):count 从 1 变 2,返回 3 + 2 = 5。
  • 再次执行 (demo 3):count 已是 2,自增为 3,返回 3 + 3 = 6。
  • 结论:同一个表达式因时间不同得到不同答案,demo 不是函数,它不计算任何数学函数。

2.3 替换模型被彻底摧毁

  • 这是替换模型第一次失效的地方,而且是"致命一击"。
  • 理解:替换模型是静态现象,描述的是"永远为真"的东西;而赋值带来的是"会变化的真理"。
  • 若试图用替换模型处理 count,会发现替换前后的 count 已不是同一个 count,结果错误。
  • 赋值要求我们改变对变量的理解:变量(如 count、x)不再直接指代某个值,而是指代存储值的某个"位置"(place)。

考点:替换模型是静态的、描述不变真理的模型;赋值引入变化,因此必须用能表达"位置"与"状态"的新模型来取代它。


三、为什么需要赋值?两种阶乘写法对比 [11:54]

本讲反复强调:引入这么"丑"的东西,必须有充分的理由。本节先用阶乘的两种写法做对比。

3.1 函数式(迭代过程)版本

  • 写法:factorial(n) = iter(1, 1, n),其中 iter 在 i > n 时返回 m,否则递归调用 iter(i × m, i + 1, n)。
  • 这里 m 是累积的乘积,i 是计数器,二者都从 1 开始。
  • 理解:虽然口头上用"计数器递增""乘积累积"来描述,但这纯粹是数学描述,替换模型在这里完全适用,因为每一步都只是真命题的替换。

3.2 命令式(imperative)版本

  • 用 set! 显式修改变量:初始化 i = 1、m = 1,进入循环,若 i > n 则返回 m,否则依次执行:
    • (set! m (* i m))
    • (set! i (+ i 1))
    • 回到循环。
  • 对熟悉传统编程语言(如 Fortran、Pascal)的人而言,这种写法非常眼熟。

3.3 新出现的错误类型:时序错误(Timing Bug)

易错点(重点):如果把上面两条 set! 的顺序写反(先改 i 再改 m),程序就不再计算同一个函数。因为 m 的更新依赖于 i 的旧值,先改 i 会导致乘法用错 i 的值。这是在引入赋值之前根本不存在的 bug 类型——它源于"时间"被引入了程序。

  • 思考:这解释了为什么命令式编程中"语句顺序"如此重要,而函数式编程中求值顺序通常无关紧要。

四、define、let、set! 三者的区别 [17:58]

学生提问引发的一段重要澄清,是理解 Scheme 语义的关键。

4.1 define:一次性定义

  • define 的意图是只设定一次,用于"创建"某物。
  • 从未见过老师连续写两个 define 来"改变"变量旧值——这不是约定,而是语义意图。
  • 规则:过程内部连续两次定义同一变量是非法的。
  • 系统是否捕获这个错误是另一回事,但从语言规范上,define 对任何东西只发生一次。
  • 理解:可以想象所有 define 都发生在程序最开始。在 Scheme 中,过程内部的 define 唯一合法位置是 lambda 表达式体的开头。
  • 交互式调试(interactive debugging)是特例:允许用户重新定义,因为调试本身就需要。

4.2 let:创建局部作用域

  • let 同样只发生一次,它建立一个上下文,让 i 和 m 拥有初值 1,该上下文在其作用域内一直存在。
  • let 不会改变变量,它只是创建绑定。
  • 等价变换(重要):
    (let ((var1 E1) (var2 E2)) E3)
      ≡
      ((lambda (var1 var2) E3) E1 E2)
    
  • 推导:这正是替换模型下可以完美理解的东西——let 只是同一表达式的另一种写法。实际系统在运行前会把 let 翻译成等价的 lambda 应用。

4.3 set!:真正的修改

  • 只有 set! 才会改变变量的值,产生"前/后"的时间差异。

易错点总结:define 是"一次性创建",let 是"创建局部绑定(语法糖,等价于 lambda 应用)",set! 是"修改已有绑定"。三者不可混用。define 本质上可看作"用 let 创建一批变量然后 set 一次"的语法糖。


五、新计算模型:环境模型(Environment Model)[23:05]

替换模型已死,必须重建计算模型。本讲后半段引入环境模型,它比替换模型复杂得多。

5.1 术语准备:约束变量与自由变量

约束变量(Bound Variable)的定义:

  • 变量 V 在表达式 E 中是约束的,当且仅当:把 E 中所有 V 的出现统一替换为另一个不出现在 E 中的变量 W 后,E 的含义不变。
  • 例子(数学中):
    • 逻辑量词:∀X ∃Y P(X,Y) 中的 X、Y 是约束的,换成 ∀W ∃Y P(W,Y) 含义相同。
    • 积分:∫₀¹ (X / (1 + X²)) dX 中的 X 是约束的,换成 T 含义相同。
  • 例子(编程中):lambda 是绑定变量的量词。
    • (lambda (y) ((lambda (x) (* x y)) 3)) 中,外层 lambda 绑定 y,内层 lambda 绑定 x,二者都是约束变量。
    • 理解:约束变量体现了模块性——两个人各自写程序时,内部用什么名字不应影响协作,只要名字不冲突即可。

自由变量(Free Variable)的定义:

  • 变量 V 在表达式 E 中是自由的,当且仅当:统一替换 V 会改变 E 的含义。
  • 例子:(lambda (x) (* x y)) 中的 y 是自由的——若 y 原值为 3,替换成 z(值为 4)会得到不同过程。
  • * 本身也是自由的:若把 * 统一换成 +,表达式含义改变。

考点:约束 vs 自由的判别方法就是"统一替换后含义是否改变"。lambda 是唯一的绑定来源;define 可以完全被消除(后续课程会展示这个"魔法")。

5.2 作用域(Scope)

  • 若 X 是 E 中的约束变量,则存在一个 lambda 表达式绑定它。
  • lambda 表达式声明变量的地方叫形参列表(formal parameter list)或约束变量列表(bound variable list)。
  • 表达式中该变量被定义的那部分,称为该变量的作用域(scope)。
  • 理解:此前一直很非正式地使用作用域概念,现在正式命名。

5.3 环境(Environment)与框架(Frame)

  • 环境是一种"虚拟地做替换"的方式:它表示尚未完成的替换的存放处,即变量名与值的关联表。
  • 环境由框架(frame)构成,框架之间通过父链接(parent links)串联。
  • 幻灯片示例:环境结构包含 A、B、C 三个环境,D 是被共享的同一环境——共享正是赋值的本质。
    • 若改变某框架中变量的值,从所有能看到该框架的地方都可见。
    • 遮蔽(shadowing):内层框架的 X(值 7)遮蔽外层框架的 X(值 3);内层 Y(值 2)遮蔽外层 Y。
  • 理解:环境 = 一串链接起来的框架,本质上是"指向第一个框架的指针"。

5.4 过程对象(Procedure Object)

  • 过程由两部分组成(类似 cons):
    1. 代码(code):可执行的指令集;
    2. 环境(environment):该过程被定义时的环境。
  • 第二部分用于捕获过程中自由变量的值:
    • 约束变量容易在环境中找到;
    • 自由变量则需要过程携带"去哪里找它的值"的信息。
  • 思考:这就是闭包(closure)概念的雏形——过程携带其定义环境。

5.5 环境模型的两条求值规则

规则 1(过程应用):

  • 把过程对象应用到一组实参时:
    1. 构造一个新框架,其中形参映射到实参;
    2. 在该新框架接在过程所携带的环境之上形成的新环境中,求值过程体。
  • 例子:过程 p 的代码是绑定 x、y 的 lambda,体为 e;调用 (p 3 4) 时构造 x=3、y=4 的新框架,接到 p 的定义环境上,得到环境 b,在 b 中求值 e。

规则 2(lambda 求值):

  • 当 lambda 表达式在某个环境中被求值时,构造一个过程对象,该对象记住(捕获)该定义环境。
  • 例子:在 y 有定义的环境中求值 (lambda (x) (* x y)),得到的过程对象携带"y 所在的那个环境"的指针,这样以后查找 y 时就能找到正确的 y。
  • 推导:这解释了为什么"同一个 lambda 表达式在不同环境中求值会得到不同过程"——它们携带的环境不同。

考点:环境模型的两条规则——应用过程时"新框架 + 过程环境";求值 lambda 时"捕获定义环境"。这是理解闭包、状态、对象的基础。

  • 学生提问澄清:
    • 环境就是那串链接的框架,本质是指向第一个框架的指针。
    • 同一段代码(同一 lambda 表达式)在不同环境中求值,会产生两个不同的过程,各有自己的局部变量。

六、赋值的价值:用对象实现模块化 [46:12]

讲完环境模型后,本讲转向"赋值到底有什么好处"。

6.1 经典案例:make-counter

  • 定义 make-counter:接受参数 n,返回一个无参数的过程,该过程把 n 自增并返回 n。
  • 环境模型逐步推演:
    1. 全局环境中已有 +、*、car、cdr 等初始绑定;
    2. 求值 make-counter 的 lambda,生成过程对象,携带全局环境;
    3. (define c1 (make-counter 0)):应用 make-counter 到 0,构造 n=0 的新框架(父环境为全局),在其中求值 lambda,生成新过程对象,其环境指针指向"n=0 的框架";c1 绑定到该过程;
    4. (define c2 (make-counter 10)):同理,构造 n=10 的另一个框架,生成另一个过程对象。
  • 关键观察:两个 n 是彼此独立的两个实例,每次调用 make-counter 都产生一个新的 n。
  • 执行效果:
    • (c1) → n 从 0 变 1,返回 1;
    • (c2) → n 从 10 变 11,返回 11;
    • (c1) → 返回 2;
    • (c2) → 返回 12。
  • 结论:得到了两个计算对象(computational objects),各自拥有独立的局部状态。

6.2 什么是"对象"?——哲学插曲

  • 对象的概念并不显然。我们乐于把世界看作由独立对象组成,这是一种智力经济学(intellectual economy)。
  • 理解:我有血压、体温、血糖等成千上万(数百万)个状态变量;你也有无数个。其中大部分彼此解耦,所以可以单独计算我的属性而无需考虑你的。若必须一起考虑,状态数将是二者之积而非之和。
  • 但耦合无法完全避免:我说话会改变你的状态,你的反应会改变我的状态(如你突然大叫,我血压升高)。
  • 思考:量子力学中的"bug"正源于我们把世界强行拆成独立部分,而实际上耦合比表面看到的更多。

6.3 如何判断是否有两个对象?——同一性(Identity)问题

  • 光学错觉类比:两支粉笔看起来一样,可能是镜子把戏(其实是同一支)。如何判断是一支还是两支?
    • 方法:抓住其中一支改变它,看另一支是否随之改变。若不变,则是两支。
  • 如何知道某物改变了? 必须在改变前后观察它——而改变就是赋值,是时间中的一个瞬间。这要求我们知道"前后看到的是同一个东西"。
  • 这引出一系列深刻的哲学问题:
    • 我剪掉一片指甲,我还是同一个人吗?我物理上已不同,为何仍是我?我的同一性(identity)是什么?
    • 思考:引入赋值和对象,就打开了困扰哲学家数千年的关于变化与同一性的问题。数学之所以更干净,正因为它没有这些麻烦。

6.4 行动与同一性的形式化定义

  • 行动 A 对对象 X 有影响(等价地,X 被 A 改变):若某性质 P 在 A 之前对 X 为真,在 A 之后对 X 为假。
  • 两个对象 X 和 Y 相同:任何对 X 有影响的行动,对 Y 有同样的影响。
  • 理解:这两个定义都仍需"前后的 X"这一概念,因此同一性问题并未真正解决,只是被形式化了。

6.5 对象为何有用:模块性

  • 我们喜欢把世界看作由独立的、带局部状态的对象组成,尽管这不完全真实。
  • 当要编写处理这种世界的复杂程序时,若希望程序可理解且易修改(改世界时只改程序一小部分),就需要世界中的对象与心智模型中的对象之间存在同构(isomorphism)。
  • 世界的模块性可以转化为编程的模块性——这就是面向对象编程(object-oriented programming)的动机。

提醒:不要像在 Fortran、Basic、Pascal 中那样滥用赋值。大部分场景不该用赋值,它不是思考大多数问题的正确方式。赋值只在必要时才使用。


七、模块化的精彩案例:用蒙特卡洛方法估算 π [1:11:xx]

本节用一个"好玩的游戏"展示赋值如何增强模块性。

7.1 Cesàro 方法(数学背景)

  • 数学家 Cesàro 发现:随机取两个整数,它们最大公约数(gcd)为 1 的概率与 π 有关。
  • 结论:两个随机数的 gcd 为 1 的概率 = 6 / π²。
  • 理解:这与"往方格纸上扔针"估算 π 属于同一类方法。视频中未给出证明,但说明"其实不太难,还挺有趣"。

7.2 蒙特卡洛方法(Monte Carlo)

  • 估算概率的方法:做大量实验,计算"某种结果出现次数"与"总实验次数"之比。
  • 拓展:蒙特卡洛方法在多维积分(变量极多、空间维度极高)等场景中非常有用。

7.3 优雅的程序分解

  • 用 Cesàro 方法估算 π:π ≈ √(6 / (n 次试验的蒙特卡洛估计))。
  • 程序被漂亮地分成两部分:
    1. 具体的蒙特卡洛实验(Cesàro 实验:判断两个随机数的 gcd 是否为 1);
    2. 通用的蒙特卡洛技术(做 n 次试验,统计通过次数,返回比值)。
  • 通用蒙特卡洛过程是一个迭代过程:变量为"剩余试验次数"和"已通过次数",剩余为 0 时返回 已通过/总次数。
  • 理解:注意代码中 (random) 后面带括号,说明它每次调用返回不同值——它不是函数,内部有变化的状态。

7.4 随机数生成器

  • 随机数生成器本质上是一个带内部状态的过程,类似计数器:
    • 它把内部状态变量 x 更新为某个函数 f(x) 的结果(f 是 Knuth 书中记载的那种"古怪函数");
    • 然后返回 x 的值。
  • 该状态变量被初始化后,隐藏在随机数生成器内部,外部不可见。
  • 思考:这正是"对象 + 局部状态"的又一实例。

八、反例:不用赋值会怎样?信息泄漏(Leakage)[1:20:xx]

本讲用一个反面例子说明:拒绝使用赋值会导致信息泄漏,破坏模块化。

8.1 不用赋值的版本:庞大且单一

  • 若不用赋值,整个程序变成一大坨单体(monolithic)代码:
    • 没有独立的"Cesàro 实验";
    • 没有独立的"蒙特卡洛过程";
    • 无法分离。

8.2 原因:每个过程必须是函数

  • 约束条件:每个过程对相同参数必须返回相同值(即每个过程代表一个函数)。
  • 这就导致随机数生成器的种子(seed)状态变量无处安放,只能一路"泄漏":
    • 从随机数生成器泄漏到 Cesàro 实验;
    • 从 Cesàro 实验泄漏到蒙特卡洛过程。
  • 具体表现:
    • Cesàro 实验被调用两次必须得到不同值(否则不是合法的实验测试),因此 Cesàro 不能是函数,除非把随机数生成器的种子一路传进去;
    • 蒙特卡洛过程也不得不"知道"实验需要几个随机数,不再通用;
    • 丧失了把问题分解为部件的能力。

考点(核心结论):随机数生成器是一个带内部状态变量的对象,其状态被限制在自身内部,不受外界影响,只对外"施加作用"。正是这种局部状态的封装,让模块化成为可能。不用赋值,就必须让状态到处游走,模块化随之瓦解。


九、收尾与反思 [1:30:xx]

  • 赋值看起来很棒,但讲者提醒:"事情很少如表象所示,脱脂牛奶常被伪装成奶油。"(引自 Gilbert 与 Sullivan 的轻歌剧)
  • 讲者向在座的"哲学家"发问:有人想争论对象的问题吗?——大家被"震住了",还没做作业,提不出好问题。
  • 随后进入长休息。

本讲核心矛盾:赋值带来了模块化、对象、局部状态等强大能力,但同时摧毁了替换模型、引入了时序 bug、打开了关于变化与同一性的哲学深渊。这是 SICP 中"美丽与危险并存"的经典一课。


关键标签 / 关键词

SICP MIT 6.001 赋值 set! 状态 副作用 替换模型 环境模型 框架 约束变量 自由变量 作用域 闭包 过程对象 局部状态 对象 同一性 模块化 make-counter 蒙特卡洛 Cesàro 随机数生成器 define let lambda 时序错误 信息泄漏 面向对象编程


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