Lec 8语义(堆与记录)——带堆的操作语义
Lec 8 语义(堆与记录)——带堆的操作语义
接 L7。把值从"帧里的整数"搬到堆 (heap) 上:表达式求值返回地址,并把堆线程化穿过每一步求值 记号:——在帧 f、堆 h 下,表达式 e 求值得到地址 a 与更新后的堆 h'
TL;DR
- 引入堆后,帧从「变量 → 值」改为「变量 → 地址」,堆再将地址映射到整数、布尔值或记录。
- 表达式现在可能分配并更新堆,因此求值规则必须把堆从一个子表达式线程化到下一个,从而显式固化求值顺序。
- 记录采用引用语义时,多个变量可指向同一堆对象;别名和持续分配也直接引出垃圾回收问题。
1. 为什么引入堆
L7 中帧直接把变量映到整数值(纯函数式、无副作用)。但真实语言有可变的、可共享的数据(记录、对象)。引入堆:
定义(帧与堆)
-
帧 (frame) :变量 → 地址(不再直接是值)。
-
堆 (heap) :地址 → 值(值可以是整数、None,乃至记录)。
求值表达式不再只返回一个整数,而是返回一个地址(指向堆中算出的值)以及更新后的堆。
例
例题(两种堆表示选择:x=5; y=7; z=x; y=z;)
-
版本一:每次赋值都为变量新建一个对象(语义规则直接产生的形态)——堆里对象较多。
-
版本二:赋值时让变量共享指向已有值的地址(如
z=x让 z 与 x 指同一个 5)——堆更小。
两种只是表示选择 (representation choice):只要把堆变化"很小心地"贯穿整套语义,程序输出相同。版本二通常得到更小的堆。
2. 带堆的求值关系
定义(带堆的求值关系)
-
表达式:(返回地址 a 与新堆 h')。
-
布尔表达式:。
-
语句:。
关键变化:表达式现在对堆有副作用(之前是纯的),所以必须把堆线程化 (thread through) 每一步。
3. 表达式的推理规则(带堆)
定义(表达式带堆语义)
常量——为值分配一个新地址( 为堆中新鲜地址): 变量——直接返回帧中记录的地址,不分配: 二元运算——依次求左右(线程化堆),再为结果分配新地址:
注意两点:① 堆被线程化——左子用 h 得 h₁,右子用 h₁ 得 h₂,结果基于 h₂;这把"求值顺序"显式写进了语义。② 每个运算都分配一个地址,于是会产生大量分配,其中大多数(如中间结果 a₁、a₂)最终成为垃圾(只保留最终结果)。
定理(堆语义直接引出垃圾回收)
"每运算一次分配一次、绝大多数立刻成垃圾"正是 Phase 3 垃圾回收存在的根本原因——语义层面就注定了需要回收不可达的堆对象。
4. 语句的推理规则(带堆)
定义(赋值带堆语义——无分配)
赋值不分配:只是让变量 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)。