Archipelago

Lec 10语义(闭包 I)——链式栈与堆上帧

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

Lec 10 语义(闭包 I)——链式栈与堆上帧

接 L9。核心难题:闭包可引用已被弹栈的作用域里的变量。解法:把帧分配到堆上,用父指针链接成"链式栈" 记号:状态从"帧栈"升级为"帧指针 (frame pointer) aa + 堆 hh"

TL;DR

  • 闭包可在创建它的作用域结束后继续使用自由变量,因此这些变量所在的帧不能随控制栈弹出而消失。
  • 链式栈把帧分配到堆上,并用父指针表示词法嵌套;闭包保存代码和创建时的环境指针。
  • 函数调用会创建新帧,将实参与局部变量绑定到该帧,再沿父链解析自由变量;帧的回收交给 GC。

1. 难题:作用域逃逸(Managing Scopes)

定义(闭包带来的核心难题)

一个闭包可能引用某个已经被弹出栈的作用域中的变量——作用域的帧可能比"该作用域代码的单次执行"活得更久 (long-lived)。纯栈式实现(L9)无法支持。

例题(捕获的变量在块结束后仍存活)

var f = 0;
{ var x = 1;
  f = fun() { print(x); x = x + 1; };
}
f(); f(); f();        // 输出 1, 2, 3

块结束后帧本应弹出,但闭包 f 仍持有 x 并跨三次调用累加——x 的帧必须比块的执行活得更久。

1.1 闭包 vs. "函数"

把上例与 C 的几种写法对比,凸显闭包语义:

例题(闭包 vs . C 函数)

局部 int x=1;        每次调用重置 → 1, 1, 1
static int x=1;      静态局部     → 1, 2, 3(但全局唯一,非"每个闭包一份")
全局 int x;          全局共享     → 1, 2, 3(共享,非捕获)
MITScript 闭包                    → 1, 2, 3(每个闭包捕获自己的作用域)

区别在**"每个闭包实例一份捕获状态"**:

var g = fun() { var x = 1; var f = fun(){print(x); x=x+1;}; return f; };
g()(); g()(); g()();      // 输出 1, 1, 1

每次调用 g() 都新建一个 x,故每个返回的闭包各有独立的 x → 1,1,1(对比复用同一闭包的 1,2,3)。C 的 static/全局做不到这种"每实例独立"。


2. 解法:把帧分配到堆上(Linked Stacks)

定义(链式栈 / 堆上帧)

不再用连续栈,而是把每个帧作为堆对象分配:

  • 帧 (frame) = 堆中的一个记录,含若干"变量 → 地址"绑定,外加一个父指针 p 指向外层(enclosing)帧的地址。

  • 帧指针 (frame pointer, FP) aa = 当前帧在堆中的地址。

  • 帧由父指针链成一条链("链式栈",又称仙人掌栈 cactus stack)——一个帧可被多个子帧/闭包共享,故能在"逻辑栈帧弹出"后继续存活。

求值关系变为 ⟨e,a,h⟩⇓⟨a′,h′⟩\langle e, a, h\rangle \Downarrow \langle a', h'\rangle:"给定帧指针与堆"求值。

例

例题(链式栈堆轨迹:var x=1; var y=2; { var x=3; y=4; })

行FP堆 h
1 var x=1100{100:{x:200}, 200:1}
2 var y=2100{100:{x:200, y:208}, 200:1, 208:2}
3 进入块108{100:{x:200,y:208}, 108:{p:100}, 200:1,208:2}
4 var x=3108108:{p:100, x:216}, …, 216:3
5 y=4108y 不在帧 108 → 沿 p 到 100 找到 y,更新 100:{x:200, y:224},224:4
6 出块100FP 回到 100(帧 108 仍在堆里,若无人引用则成垃圾)

要点:块帧 108 的父指针 p:100 把它链到外层;y=4 通过 lookup 沿链找到外层的 y 并就地更新。


3. 查找:沿父指针上溯(Lookup)

定义(链式 lookup)

lookup(a,x,h)={h(a)[x]x∈dom(h(a))lookup(h(a)[p], x, h)否则(沿父指针 p 上溯)\text{lookup}(a, x, h) = \begin{cases} h(a)[x] & x \in \text{dom}(h(a)) \\ \text{lookup}(h(a)[p],\ x,\ h) & \text{否则(沿父指针 } p \text{ 上溯)} \end{cases} 即先查当前帧,找不到就跟随父指针到外层帧,直至找到。

由此,变量引用规则:

lookup(a,x,h)=ax⟨x,a,h⟩⇓⟨ax,h⟩\frac{\text{lookup}(a, x, h) = a_x}{\langle x, a, h\rangle \Downarrow \langle a_x, h\rangle}

声明 var 在当前帧 h(a)h(a) 加绑定;赋值 update 用 lookup 定位变量所在帧后就地更新 (in-place update) 其绑定。

3.1 块作用域规则(链式版)

a′ fresh⟨s, a′, h[a′↦{p:a}]⟩⇓⟨a′′, h′⟩⟨{ s }, a, h⟩⇓⟨a, h′⟩\frac{a' \text{ fresh} \quad \langle s,\ a',\ h[a' \mapsto \{p : a\}]\rangle \Downarrow \langle a'',\ h'\rangle}{\langle \{\,s\,\},\ a,\ h\rangle \Downarrow \langle a,\ h'\rangle}

进块:在堆中建新帧(父指针指向当前帧 a),以新帧为 FP 求值块体;出块:FP 恢复为 a(新帧留在堆中,可能成垃圾、也可能被闭包捕获而存活)。


4. 闭包的语法、表示与语义

扩展 IMP:增加创建闭包与调用闭包的语法;表示上闭包是值、帧长寿;语义给出创建与调用规则。

定义(闭包值的表示)

闭包值是三元组 (acaptured, params, body)(a_{\text{captured}},\ \text{params},\ \text{body}):

  • acaptureda_{\text{captured}}:创建时捕获的帧指针(定义处的环境);

  • params\text{params}:形参;body\text{body}:函数体代码。

如堆中 116 : (208, y, {print(x); x = x + 1}) 表示一个捕获帧 208、形参 y、体为 {print(x); x=x+1} 的闭包。

4.1 闭包创建规则(捕获当前帧指针)

a′ fresh⟨fun(pˉ){s}, a, h⟩⇓⟨a′, h[a′↦(a, pˉ, s)]⟩\frac{a' \text{ fresh}}{\langle \text{fun}(\bar{p})\{s\},\ a,\ h\rangle \Downarrow \langle a',\ h[a' \mapsto (a,\ \bar{p},\ s)]\rangle}

关键:闭包捕获创建时的帧指针 aa——这就是词法/静态作用域 (lexical scoping) 的来源(自由变量在"定义处"而非"调用处"的环境中解析)。

4.2 闭包执行规则(新帧链到捕获帧)

定义

定义(闭包调用 x(e))

  • xx 求值为闭包 (ac,pˉ,s)(a_c, \bar{p}, s);

  • 实参 ee 求值为值(地址);

  • 创建新帧,其父指针指向闭包捕获的帧 aca_c(不是调用者的帧!),并把形参绑定到实参;

  • 以新帧为 FP 求值闭包体 ss;返回其结果。

"父指针 = 捕获帧"正是词法作用域:闭包体里的自由变量沿着定义时的链解析,而非调用时的链。


5. 完整轨迹:闭包跨调用累加

例

例题(f();f() 的堆演化,输出 1, 2)

1: var f = 0;
2: { 3: var x = 1;
   4: f = fun(y) { 5: print(x); 6: x = x + 1; 7: };
8: }
9: f(1);
10: f(2);
行FP堆 h(要点)
1200{100:0, 200:{f:100}}
3208进块建帧 208{p:200};x→108,108:1
7208闭包建于 116=(208, y, {print(x);x=x+1});f 改指 116 → 200:{f:116}
8200出块,FP 回 200。但帧 208 仍在堆里——被闭包 116 捕获,是"长寿帧"
9 f(1)200f→116 闭包;建新帧 212{p:208, y:124}(父=捕获帧 208);print(x):在 212 找不到 x→沿 p 到 208,x=1,打印 1;x=x+1 → 208 的 x 改指新值 132:2
10 f(2)200建新帧 216{p:208, y:140};print(x):沿 p 到 208,x=2,打印 2;x→3

输出 1, 2(再调一次得 3)。两次调用的实参帧不同(212/216),但父指针都指向同一捕获帧 208,所以 x 被持续累加——这正是"每个闭包一份、跨调用持久"的捕获语义。

对照"返回新闭包"的版本(每次 g() 新建 x、新建捕获帧):每个闭包父指针指向不同的帧,故各自的 x 独立 → 输出 1,1,1。


6. 本讲小结

  • 难题:闭包引用的变量所在作用域可能已弹栈,帧需比单次执行活得更久——纯栈不行。
  • 解法:帧分配到堆、用父指针 p 链成链式栈;状态 = 帧指针 + 堆;lookup 沿父指针上溯。
  • 闭包值 = (捕获的帧指针, 形参, 体);创建时捕获当前帧指针(词法作用域之源)。
  • 调用时新建帧、父指针指向捕获帧(非调用者帧),在其中求值体;自由变量沿定义链解析。
  • 捕获帧因被闭包引用而存活于堆中,跨调用持久(1,2,3);不同闭包实例捕获不同帧(1,1,1)。
  • 下一讲(Closures II):补全规则细节、收尾闭包语义。