Lec 10语义(闭包 I)——链式栈与堆上帧
Lec 10 语义(闭包 I)——链式栈与堆上帧
接 L9。核心难题:闭包可引用已被弹栈的作用域里的变量。解法:把帧分配到堆上,用父指针链接成"链式栈" 记号:状态从"帧栈"升级为"帧指针 (frame pointer) + 堆 "
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) = 当前帧在堆中的地址。
-
帧由父指针链成一条链("链式栈",又称仙人掌栈 cactus stack)——一个帧可被多个子帧/闭包共享,故能在"逻辑栈帧弹出"后继续存活。
求值关系变为 :"给定帧指针与堆"求值。
例
例题(链式栈堆轨迹:var x=1; var y=2; { var x=3; y=4; })
| 行 | FP | 堆 h |
|---|---|---|
| 1 var x=1 | 100 | {100:{x:200}, 200:1} |
| 2 var y=2 | 100 | {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=3 | 108 | 108:{p:100, x:216}, …, 216:3 |
| 5 y=4 | 108 | y 不在帧 108 → 沿 p 到 100 找到 y,更新 100:{x:200, y:224},224:4 |
| 6 出块 | 100 | FP 回到 100(帧 108 仍在堆里,若无人引用则成垃圾) |
要点:块帧 108 的父指针 p:100 把它链到外层;y=4 通过 lookup 沿链找到外层的 y 并就地更新。
3. 查找:沿父指针上溯(Lookup)
定义(链式 lookup)
即先查当前帧,找不到就跟随父指针到外层帧,直至找到。
由此,变量引用规则:
声明 var 在当前帧 加绑定;赋值 update 用 lookup 定位变量所在帧后就地更新 (in-place update) 其绑定。
3.1 块作用域规则(链式版)
进块:在堆中建新帧(父指针指向当前帧 a),以新帧为 FP 求值块体;出块:FP 恢复为 a(新帧留在堆中,可能成垃圾、也可能被闭包捕获而存活)。
4. 闭包的语法、表示与语义
扩展 IMP:增加创建闭包与调用闭包的语法;表示上闭包是值、帧长寿;语义给出创建与调用规则。
定义(闭包值的表示)
闭包值是三元组 :
-
:创建时捕获的帧指针(定义处的环境);
-
:形参;:函数体代码。
如堆中 116 : (208, y, {print(x); x = x + 1}) 表示一个捕获帧 208、形参 y、体为 {print(x); x=x+1} 的闭包。
4.1 闭包创建规则(捕获当前帧指针)
关键:闭包捕获创建时的帧指针 ——这就是词法/静态作用域 (lexical scoping) 的来源(自由变量在"定义处"而非"调用处"的环境中解析)。
4.2 闭包执行规则(新帧链到捕获帧)
定义
定义(闭包调用 x(e))
-
求值为闭包 ;
-
实参 求值为值(地址);
-
创建新帧,其父指针指向闭包捕获的帧 (不是调用者的帧!),并把形参绑定到实参;
-
以新帧为 FP 求值闭包体 ;返回其结果。
"父指针 = 捕获帧"正是词法作用域:闭包体里的自由变量沿着定义时的链解析,而非调用时的链。
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(要点) |
|---|---|---|
| 1 | 200 | {100:0, 200:{f:100}} |
| 3 | 208 | 进块建帧 208{p:200};x→108,108:1 |
| 7 | 208 | 闭包建于 116=(208, y, {print(x);x=x+1});f 改指 116 → 200:{f:116} |
| 8 | 200 | 出块,FP 回 200。但帧 208 仍在堆里——被闭包 116 捕获,是"长寿帧" |
| 9 f(1) | 200 | f→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):补全规则细节、收尾闭包语义。