Archipelago

Lec 8语义(堆与记录)——带堆的操作语义

讲义完成更新于 2026-09-16

Lec 8 语义(堆与记录)——带堆的操作语义

接 L7。把值从"帧里的整数"搬到堆 (heap) 上:表达式求值返回地址,并把堆线程化穿过每一步求值 记号:⟨e,f,h⟩⇓⟨a,h′⟩\langle e, f, h\rangle \Downarrow \langle a, h'\rangle——在帧 f、堆 h 下,表达式 e 求值得到地址 a 与更新后的堆 h'

TL;DR

  • 引入堆后,帧从「变量 → 值」改为「变量 → 地址」,堆再将地址映射到整数、布尔值或记录。
  • 表达式现在可能分配并更新堆,因此求值规则必须把堆从一个子表达式线程化到下一个,从而显式固化求值顺序。
  • 记录采用引用语义时,多个变量可指向同一堆对象;别名和持续分配也直接引出垃圾回收问题。

1. 为什么引入堆

L7 中帧直接把变量映到整数值(纯函数式、无副作用)。但真实语言有可变的、可共享的数据(记录、对象)。引入堆:

定义(帧与堆)

  • 帧 (frame) ff:变量 → 地址(不再直接是值)。

  • 堆 (heap) hh:地址 → 值(值可以是整数、None,乃至记录)。

求值表达式不再只返回一个整数,而是返回一个地址(指向堆中算出的值)以及更新后的堆。

例

例题(两种堆表示选择:x=5; y=7; z=x; y=z;)

  • 版本一:每次赋值都为变量新建一个对象(语义规则直接产生的形态)——堆里对象较多。

  • 版本二:赋值时让变量共享指向已有值的地址(如 z=x 让 z 与 x 指同一个 5)——堆更小。

两种只是表示选择 (representation choice):只要把堆变化"很小心地"贯穿整套语义,程序输出相同。版本二通常得到更小的堆。


2. 带堆的求值关系

定义(带堆的求值关系)

  • 表达式:⟨e,f,h⟩⇓⟨a,h′⟩\langle e, f, h\rangle \Downarrow \langle a, h'\rangle(返回地址 a 与新堆 h')。

  • 布尔表达式:⟨b,f,h⟩⇓⟨t,h′⟩\langle b, f, h\rangle \Downarrow \langle t, h'\rangle。

  • 语句:⟨s,f,h⟩⇓⟨f′,h′⟩\langle s, f, h\rangle \Downarrow \langle f', h'\rangle。

关键变化:表达式现在对堆有副作用(之前是纯的),所以必须把堆线程化 (thread through) 每一步。


3. 表达式的推理规则(带堆)

定义(表达式带堆语义)

常量——为值分配一个新地址(aa 为堆中新鲜地址): a∉dom(h)⟨n,f,h⟩⇓⟨a, h[a↦n]⟩\frac{a \notin \text{dom}(h)}{\langle n, f, h\rangle \Downarrow \langle a,\ h[a \mapsto n]\rangle} 变量——直接返回帧中记录的地址,不分配: ⟨x,f,h⟩⇓⟨f(x), h⟩\frac{}{\langle x, f, h\rangle \Downarrow \langle f(x),\ h\rangle} 二元运算——依次求左右(线程化堆),再为结果分配新地址: ⟨e1,f,h⟩⇓⟨a1,h1⟩⟨e2,f,h1⟩⇓⟨a2,h2⟩a∉dom(h2)⟨e1+e2,f,h⟩⇓⟨a, h2[a↦h2(a1)+h2(a2)]⟩\frac{\langle e_1, f, h\rangle \Downarrow \langle a_1, h_1\rangle \quad \langle e_2, f, h_1\rangle \Downarrow \langle a_2, h_2\rangle \quad a \notin \text{dom}(h_2)}{\langle e_1 + e_2, f, h\rangle \Downarrow \langle a,\ h_2[a \mapsto h_2(a_1) + h_2(a_2)]\rangle}

注意两点:① 堆被线程化——左子用 h 得 h₁,右子用 h₁ 得 h₂,结果基于 h₂;这把"求值顺序"显式写进了语义。② 每个运算都分配一个地址,于是会产生大量分配,其中大多数(如中间结果 a₁、a₂)最终成为垃圾(只保留最终结果)。

定理(堆语义直接引出垃圾回收)

"每运算一次分配一次、绝大多数立刻成垃圾"正是 Phase 3 垃圾回收存在的根本原因——语义层面就注定了需要回收不可达的堆对象。


4. 语句的推理规则(带堆)

定义(赋值带堆语义——无分配)

⟨e,f,h⟩⇓⟨a,h′⟩⟨x:=e,f,h⟩⇓⟨f[x↦a], h′⟩\frac{\langle e, f, h\rangle \Downarrow \langle a, h'\rangle}{\langle x := e, f, h\rangle \Downarrow \langle f[x \mapsto a],\ h'\rangle} 赋值不分配:只是让变量 x 在帧中指向表达式算出的地址 a。

设计要点:分配发生在"计算"处(表达式),而非"赋值"处。这与早期把分配放在赋值/声明处的版本不同——把分配下沉到运算,语义更统一(赋值只是改指向)。if/while/顺序的规则与 L7 同构,只是多线程一个堆。


5. 记录(Records):引用 vs. 值语义

引入记录后,堆要能存值(而非只存整数)——一个记录值是"字段名 → 地址"的映射。

文法扩展:

e → { field1 : e1 ; field2 : e2 ; ... }    (创建记录)
e → e.field                                 (读字段)
s → e.field = e'                            (更新字段)

例题(别名与引用语义)

var a = { x : 1; y : 2 };
var b = a;
b.x = 3;
print(a.x)      // 输出?

若记录是引用语义(变量持有指向同一堆记录的地址):b = a 让 b 与 a 指向同一记录,b.x = 3 改的就是那个共享记录 → print(a.x) 输出 3。 若是值语义(b 得到一份拷贝):则输出 1。

定义(记录的关键设计决策:值 vs . 指向值)

"记录内容如何表示"——存值本身还是存指向值的地址——直接决定了 b = a 是拷贝还是别名,从而决定上面的输出。MITScript(如 Python)采用引用语义,记录在堆上、变量持地址。

自练:用新的(带堆)语义为"在表达式中创建记录"和"给字段赋值"写出推理规则——带堆后这反而更简单(创建记录 = 在堆分配一个字段→地址的映射并返回其地址;字段赋值 = 求出记录地址与新值地址,更新堆中该记录的对应字段)。


6. 本讲小结

  • 引入堆后:帧 = 变量→地址,堆 = 地址→值;表达式求值返回地址 + 新堆。
  • 表达式现在有副作用,必须把堆线程化穿过每步求值(顺序被显式编码进语义)。
  • 常量/运算分配新地址,变量/赋值不分配(赋值只改指向);每运算一分配 → 大量垃圾 → 引出 GC。
  • 记录在堆上,关键设计是"存值 vs. 存地址"——引用语义下别名 b=a; b.x=3 会让 a.x 也变(输出 3)。