1. 把"看得见"和"还活着"拆开:作用域与生存期的两套账本
写过解释器的人大概都经历过这样一个阶段:变量能存能取、函数能调能返回,看起来一切正常,可一旦在块里定义变量、或者让函数返回一个内部函数,程序立刻开始报一些莫名其妙的错——要么是"变量不存在",要么是"读到了一个不该有的值"。这类问题追到根上,八成是把作用域和生存期这两件事混成了一件。我在做自研小语言的第八个迭代时,主要工作就是把这两套账本彻底拆开,让块作用域和函数真正落地,而不是靠一层字典硬撑着跑通 Demo。
先说清楚这两个词在我这里的确切含义。作用域(scope)解决的是"名字在哪里能被看见":它是一张静态的、在代码文本上就能判定的映射表,编译器或解析器扫一遍源码就应该能回答"这个x指的是哪一次声明"。生存期(lifetime)解决的是"这份存储从什么时候开始有效、到什么时候彻底作废":它是动态的、只有程序跑起来才知道的,跟调用栈的压入弹出、堆上的分配释放直接挂钩。
为什么一定要拆开?因为只要你不拆,就一定会在两个方向上撞墙。往小了说,块作用域做不对,内层变量会污染外层,循环体里的临时变量会互相踩;往大了说,函数做不对,递归调不通、返回值带不出来、闭包一捕获就崩。更麻烦的是,这两类问题的表象非常像——都是"取不到值"或者"取到错的值",如果脑子里没有两套账本,排查时就会在错误的方向上耗费大量时间。下面我用一段极短的代码,把四种组合一次性摆出来,后面所有实现细节都围绕这张表展开。
1.1 一个三段代码把两个概念彻底分开
看这段伪代码:
{ let a = 1; // a 在块内可见,存储在块帧里 let f = fn() { return a; }; } f(); // 名字 f 已经不可见 -> 作用域问题再看另一段:
fn make() { let b = 2; return fn() { return b; }; } let g = make(); g(); // b 的名字在 make 里,但存储必须活到 g 被调用第一段是典型的作用域错误:f这个绑定随着块结束就"看不见"了,运行到f()时名字解析阶段就应该报错,而不是等到运行时才发现字典里没这个键。第二段是典型的生存期问题:b所在的那个调用帧在make返回时已经弹掉了,可返回出去的函数还攥着一个引用,于是你必须让这份存储在帧销毁之后继续有效。这两段代码的报错信息如果都写成"变量未定义",那这个语言就没法调试了。
我把这四种组合整理成了下面这张表,实现时每一格都对应一条独立的规则,不许互相串:
| 组合 | 名字可见 | 存储有效 | 典型场景 | 实现要点 |
|---|---|---|---|---|
| 正常局部变量 | 是 | 是 | 块内定义块内使用 | 帧内槽位,进出块配对 |
| 已出作用域 | 否 | 是(可能) | 块结束后外部引用 | 解析期就拒绝,不等运行期 |
| 闭包捕获 | 是 | 是(被延长) | 函数返回内部函数 | 被捕获变量提升到堆 |
| 悬垂引用 | 是 | 否 | 帧已弹出仍持有指针 | 逃逸分析或引用计数兜底 |
这张表里最容易被忽略的是第二行。很多人做块作用域时,只在运行时"退出块就把变量从字典里删掉",表面上没问题,实际上这属于把静态错误拖到动态才暴露,调试体验极差。我现在坚持的做法是:块作用域首先是一个编译期事实,运行时只是照着这份事实去执行。解析阶段就该知道每个名字属于哪一层,运行时不需要再做任何"猜"的动作。
1.2 为什么这两个概念必须分开建模
分开建模带来的第一个好处是错误信息能分得清。解析期发现名字不可见,报"标识符未声明"或者"此处不可见";运行时发现存储已被释放,报"访问已失效的绑定"。这两种错误在排查时指向完全不同的代码区域,前者改解析器,后者改内存管理,混在一起就是灾难。
第二个好处是性能路径清晰。作用域解析的产物可以是"从当前帧往上跳几层、取第几个槽位"这样的两个整数,运行时取变量就是两次数组下标,O(1),跟变量名长短、作用域嵌套深度都无关。而如果只用一个字典链,每次取变量都要沿着父指针一路往上爬,还要做字符串哈希,深度一大就明显变慢。我在早期版本上做过一个粗糙的对比:一个嵌套五层的循环里访问最外层变量,字典链方案比槽位方案慢了三倍多,这还是在我没做任何字符串驻留优化的情况下。
第三个好处是可扩展。后面如果要加const、加类型标注、加编译期常量折叠,全都挂在解析阶段做,运行时的帧结构一点不用动。反过来,如果一开始就把"查名字"和"取存储"揉成一个动作,加任何静态检查都得改运行时,改一次崩一次。
提示:判断你的实现有没有把这两件事分开,有个很简单的自测方法——故意写一个块外引用块内变量的程序,看它是在解析阶段就报错,还是要跑到那一行才报错。如果后者,说明你的块作用域只做了一半。
2. 环境帧还是符号表栈:作用域在运行时的承载形式
搞清楚了概念,接下来的问题很具体:运行时拿什么数据结构来承载作用域。主流做法有两种,一种叫环境帧链,一种叫平坦符号表加作用域深度栈,两者在编译期其实是一回事(都是树),差别全在运行时的访问方式上。我最初用的是环境帧链,因为它直观、好写、能立刻跑起来;后来为了性能和错误定位,逐步过渡到了"编译期解析出槽位 + 运行时按槽位访问"的混合方案。这一节把两条路都讲清楚,包括我为什么最终选后者。
2.1 链式环境帧:能跑通,但查名字要一路往上爬
环境帧的最小形态就是一个字典加一个指向外层的指针:
class Env: def __init__(self, parent=None): self.vars = {} self.parent = parent def define(self, name, value): self.vars[name] = value def get(self, name): env = self while env is not None: if name in env.vars: return env.vars[name] env = env.parent raise RuntimeError("未声明的标识符: " + name)这段代码三十行不到,块作用域和函数调用都能撑起来:进入块就Env(parent=cur),退出块就把cur指回parent;调用函数就新建一个帧,父指针指向函数定义时捕获的那个环境。跑递归也没问题,每层调用一个独立帧。
但它有四个绕不开的毛病。第一,查名字的复杂度是 O(嵌套深度),深度越大越慢,而且这个深度在解析阶段明明可以静态算出来。第二,拼写错误要到运行时才暴露,因为字典是动态的,count写成cuont也能通过解析。第三,遮蔽容易出错。内层定义了同名变量,get会从内层开始找,这是对的;可一旦你在某个分支里忘了定义、或者定义写在了使用之后,它就会悄悄去找外层那个同名变量,程序不报错,结果全错。第四,调试困难,你不知道某个引用最终落在哪一帧上,只能靠打印整条链。
第四点我踩得最狠。当时有个循环跑出来结果差一,我加了五行打印才发现:内层的i因为某条分支没走到而没被定义,get一路爬到了外层那个完全无关的i上。这种 bug 靠读代码基本看不出来,必须把"引用落在哪一帧"这件事变成显式信息。
2.2 从名字查找改成槽位访问:编译期切分作用域带来的收益
改造思路很朴素:既然作用域在代码文本上是静态的,那就让解析器在遍历语法树的时候维护一个作用域栈,遇到变量引用就当场把它改写成"从当前作用域往外跳hops层,取第slot个变量"。运行时的帧变成定长数组(或者数组加溢出字典),取变量就是先按hops找到帧,再按slot取下标。
# 解析阶段的产物:每个引用被改写为一个 (hops, slot) 对 # 运行时帧: class Frame: __slots__ = ("slots", "parent") def __init__(self, size, parent): self.slots = [None] * size self.parent = parent def lookup(frame, hops, slot): f = frame for _ in range(hops): f = f.parent return f.slots[slot]解析器一侧要维护的东西也不复杂:一个scopes列表,每进入一个块append一个空字典,退出就pop,遇到let x就在当前层登记名字并分配一个槽位号。查找时从栈顶往下扫,扫到就记录层差和槽位。全部函数体解析完,如果还有没解析到的名字,那要么是全局变量,要么是拼写错误,这时给一条明确的错误就行。
两条路的对比我列成了表,你可以按自己的项目阶段选:
| 维度 | 链式环境帧 | 解析后槽位 |
|---|---|---|
| 变量访问复杂度 | O(嵌套深度),含哈希 | O(1),两次下标 |
| 未声明变量报错时机 | 运行时 | 解析时 |
| 遮蔽行为 | 依赖查找顺序,易出错 | 编译期确定,无歧义 |
| 实现成本 | 极低,几十行 | 中,需要作用域栈与槽位分配 |
| 对闭包的支持 | 直接可用 | 需要额外的上值解析 |
| 调试友好度 | 差,只能打印整链 | 好,可直接打印 hops/slot |
这里要强调一个容易被略过的点:槽位分配和块作用域是同一件事。你不能一边说实现了块作用域,一边又让所有局部变量共用一套平坦槽位而不做生命周期切分,那样内层同名变量会覆盖外层,循环里也没法每次迭代拿到新的绑定。正确的做法是每进入一个块就分配一批新槽位(可以复用已退出的槽位号,但那属于优化),退出块时把槽位标记为可回收。作用域栈的深度,就是运行时帧数组大小的依据。
我实际采用的是一个折中:函数级别的局部变量走槽位数组,全局变量留在一个字典里挂在最外层。理由很实在——全局变量需要动态增删(比如交互式环境里随时定义新函数),用字典最省事;局部变量数量在编译期就固定了,用数组最划算。这个混合结构跑了很久,没出过问题。
3. 块作用域的进出配对:别让一条异常路径把栈带歪
块作用域的原理一句话就能说完:进入块压一层,退出块弹一层。真正写起来,坑全在"退出"这两个字上——因为退出的路径不止一条。正常执行到底是一条,中途return是一条,break和continue是一条,抛出异常还是一条。我第一版只在正常路径上写了弹栈,结果一遇到块内return,帧就多留了一层,后面的变量全落在错误的层级上,症状是"函数返回后外面多了一堆看不见的变量"。这个 bug 花了我整整一个晚上。
3.1 入块登记、出块回退:基线式弹栈
修法不是到处补弹栈代码,那样代码会碎成一地,而且每加一种控制流就要再补一次。我改成了基线式回退:进入块之前先记下当前的作用域深度base = len(scope_stack),退出时不管从哪条路径走,统一执行while len(scope_stack) > base: pop()。
编译器一侧对应的是:每条可能"提前离开"的语句(return、break、continue)都带上一个需要回退到的深度值,解释器在执行这条语句时先按这个值把帧链和槽位状态退回,再做跳转。这样无论嵌套多少层块,退出动作只写在一处,逻辑不会散。
def exec_block(self, stmts): base = len(self.frames) self.frames.append(Frame(self.block_size(stmts), self.frames[-1])) try: for s in stmts: self.exec(s) if self.signal is not None: # return / break / continue break finally: del self.frames[base:] # 一条路径统一收口用try/finally是最省心的写法,因为宿主语言帮你兜住了异常路径。如果你的宿主语言没有这个特性(比如纯 C),那就得在每个可能出错的调用点做手工展开,或者维护一个显式的"异常跳板"结构,把深度记在里面,统一恢复。这两种我都写过,前者省事,后者可控,看你团队更看重哪一头。
这里有个细节值得单独说:槽位回收的时机要跟帧弹出同步。我在实现里给每个帧记了一个slot_count,帧弹出时把这段槽位标记为可复用。如果只弹帧不回收槽位,长时间运行的程序槽位号会一直涨,最后变成一个巨大的稀疏数组,内存直接爆掉。这个坑在小测试里完全看不出来,跑一个十几万次循环的脚本才会现形。
3.2 循环体每次迭代要不要新建作用域
这是块作用域里我最愿意跟人讨论的一个点,因为它直接决定闭包语义对不对。看这段代码:
let fs = []; for (let i = 0; i < 3; i = i + 1) { fs.push(fn() { return i; }); }如果循环体不每次迭代新建作用域,那三个闭包捕获的是同一个i,最后全部输出 3。如果每次迭代新建,三个闭包各自捕获一份,输出 0、1、2。两种行为在不同语言里都有先例,但我的建议很明确:既然你声明了let是块作用域,就应该每次迭代新建,否则块作用域在循环上就出现了语义裂缝,用户会以为let和var没区别。
实现上就是在进入循环体之前记录基线,每轮迭代结束回到基线,下一轮重新压帧、重新分配槽位。代价是每轮多一次分配,如果帧里有被捕获的变量,还要多一次堆分配(下一节讲)。这个代价我认为完全值得,因为语义一致性比省那点分配重要得多。
反过来,如果你的语言区分var和let,那var就该是函数级作用域、整个循环共用一份,这样两种声明才有区分意义。这个设计决策必须在实现块作用域的时候就定下来,不能等到用户抱怨了再改,改了就是破坏性变更。
3.3 遮蔽(shadowing)带来的隐蔽读错
遮蔽是块作用域的天然产物:内层可以声明一个跟外层同名的变量。语义上没毛病,实现上有个经典坑——声明语句本身是在声明之前求值的。也就是说:
let x = 1; { let x = x + 1; // 右边的 x 指的是哪个? }如果解析器在遇到let x时立刻把x登记进当前作用域并标记为"初始化中",那右边的x就会解析到内层这个尚未初始化的槽,读到空值。正确做法是两阶段:先登记名字但标记为"未就绪",等初始值表达式解析完、求值完,再把状态改成"就绪"。如果在这个窗口期内被引用,直接报"变量在初始化前被使用"。我把这条检查加进去之后,至少拦下了三个自己写的愚蠢 bug。
还有一个隐蔽变体:内层块里声明了同名变量,但某条分支没走到,正常情况下应该报未定义,可如果你的解析器是"整个块一次性扫描所有声明",它会把那个变量当成在整个块内都可见,于是另一条分支去读它,读到的就是空槽。这个行为到底是错是对,取决于你的语言要不要块级提升。我的选择是不做提升,声明只在其后的代码里可见,宁可让用户多写一个提前声明,也不让"读到未初始化的槽"这种事发生。
4. 函数调用帧:参数绑定、返回与递归自引用
函数本身不难写,难的是把函数接进作用域体系。我的经验是:函数是这个语言里唯一一个"作用域跨越了两次执行"的构造。它的作用域在定义时确定(词法作用域),但执行时又要新建一个帧,这个帧的父指针该指向哪里,决定了递归能不能跑、闭包能不能捕获,是整章最容易写错的地方。
4.1 调用帧里到底放什么
一个调用帧至少要装四样东西:参数槽、局部变量槽、指回定义处环境的父指针、以及一个"这是一次调用"的标记(用来在返回时确认该恢复到哪一层)。
参数绑定顺序有讲究。位置参数按顺序填进前几个槽位,多余的实参怎么处理、少给的怎么处理,都得明确定下来。我的做法是解析期就记下函数的参数个数arity,调用时严格校验,多了少了都抛错误,并给出具体数字。看起来不近人情,但调试体验比"静默忽略多余参数"好太多——静默忽略会掩盖掉调用方的一整类错误。
默认参数值在哪个作用域求值,这是个容易被忽略的设计选择。有两种做法:在调用方作用域求值(像宏展开),或者在函数自己的作用域求值(像函数体内的一行赋值)。前者能拿到调用者的变量,后者只能看到参数和全局。我选后一种,理由是词法作用域的自洽性:一个函数体里出现的名字,语义应该只跟它定义在哪有关,跟谁调用它无关。选前一种的话,同一个函数在不同地方调用会有不同行为,这就滑向动态作用域了。
4.2 递归必须先让函数名在自己作用域里可见
递归写不通是新手最常见的卡点。症状是:函数体里调用自己的名字,报"未声明的标识符"。原因也好理解——你是在函数定义完成之后才把这个名字登记进外层作用域的,而函数体在解析时就已经走完了,那时名字还不存在。
解法是分两步:解析到函数声明时,先把名字登记到当前作用域并占好槽位,再解析函数体,最后才绑定函数对象。这样函数体在解析期间就能在上一层作用域里找到自己的名字。运行时同理,先把槽位占住(值先留空),等函数对象创建好再回填。
let fact; fact = fn(n) { if (n <= 1) { return 1; } return n * fact(n - 1); };注意这里我写成了先声明后赋值,而不是let fact = fn(...)的糖。因为糖形式里,右边的函数体在求值时fact还没绑定,虽然多数实现通过"先占位后回填"也能处理,但显式拆开能避免一大类边界情况,尤其是在你的解析器还没做占位处理的时候。这条我踩过:匿名函数直接赋值的写法在递归时崩了,查了半天才发现是初始化顺序问题。
还有一点,函数帧的父指针必须指向函数定义时所在的那个环境,不是全局环境。这是词法作用域的核心。如果图省事直接指全局,那函数就捕获不到定义处的局部变量,闭包立刻失效,而且嵌套函数访问外层参数也会全部报错。这个错误特别隐蔽,因为它只在嵌套函数访问外层局部变量时才暴露,平铺直叙的测试用例全都通过。
4.3 返回值的统一出口
返回值这块我建议一开始就设计成单出口。做法是:return不在原地直接弹栈并跳出,而是设置一个"函数返回"信号,把返回值放进信号里,然后由执行循环自然向上退栈,直到遇到函数调用的边界才真正取出返回值、恢复调用者帧。
这么设计有两个好处。第一,深度回退只写一次,前面说的块作用域基线回退天然复用同一套逻辑,不会出现"return在块里少弹了一层"的问题。第二,将来加尾调用优化、加协程、加生成器,都是在这条信号通道上扩展,不用改动已有的控制流。
代价是每层执行循环都要检查一次信号标志,有一点开销。实测下来可以忽略,因为函数调用本身的开销远大于一次标志位判断。真要抠这点性能,可以把信号编码进指令指针的特殊值里,但那是后期优化,不值得在功能没闭环的时候做。
顺带说一句递归深度。树遍历解释器的每一次语言层递归都对应宿主语言的若干层递归,所以语言层跑个几千层深就可能把宿主栈撑爆,报出来的是宿主语言的栈溢出,用户完全看不懂。我加了一个可配置的最大调用深度,超了直接报一个语义清晰的错误,并打印调用链。这个改动很小,但对使用者友好度的提升非常明显。
5. 逃逸变量与悬垂引用:生存期被闭包拉长之后
到这里,作用域的部分基本闭环了,剩下的全是生存期的问题。核心矛盾就一句话:块作用域要求变量在退出块时失效,闭包却要求它继续有效。这两条规则直接冲突,必须有一条让路,而让路的只能是存储位置——把被捕获的变量从栈上搬到堆上,用引用计数或者垃圾回收管着。
5.1 栈帧退场后,谁还在指向那块内存
如果你的实现是用宿主语言的数组/列表当帧,那这块基本不用操心,因为宿主语言自己管内存,帧对象只要还有人引用就不会被回收,闭包天然能捕获。这也解释了为什么很多用高级语言写的解释器,第一版闭包是"免费"的。
但如果宿主语言是 C 这类手工管理内存的,问题立刻就来了:帧是一块malloc出来的结构体,函数返回时free掉,闭包里存的那个指针就成了悬垂指针,读它可能拿到垃圾值,写它可能破坏堆,症状是随机崩溃,且崩溃点离出错点十万八千里。
typedef struct { Value* slots; int count; } Frame; typedef struct { Frame* captured; int slot; } Upvalue; /* 错误示范:帧被释放,闭包里还攥着指针 */ Frame* f = frame_new(4); Closure* c = closure_new(f, 2); frame_free(f); /* c->captured 变成悬垂指针 */这类 bug 我用调试器追过整整一天,因为它只在特定内存布局下才崩,加了打印就"好了",典型的堆行为。从那以后我给自己定了一条硬规矩:只要语言支持闭包,被捕获的变量就绝不放在可被释放的帧内存里,哪怕多一次分配。
5.2 上值捕获:把被捕获的变量搬到更长寿的地方
最省事的做法是"捕获即装箱":解析阶段就能静态判断出某个局部变量是否被内层函数引用,如果是,就给这个变量打个标记,运行时进入作用域时直接在堆上分配一个单元素盒子(Box / Cell),栈槽里只放盒子指针。闭包捕获的是盒子,不是栈槽,所以帧释放了也不影响。
class Cell: __slots__ = ("value",) def __init__(self, value): self.value = value代价是每次进入含被捕获变量的块都要分配一次,读写得先解一层指针。所以关键是只对被捕获的变量做,普通局部变量老老实实待在槽里。这个判断完全可以在解析阶段完成:解析函数体时,遇到引用外层局部变量,就给那个外层变量标一个captured = True。
要不要做"捕获即复制"?也就是闭包创建时把值拷一份,之后互不影响。这个语义简单、实现更简单,而且天然没有悬垂问题。但它跟大多数人的直觉不符:两个闭包共享一个计数器、一个改一个读,这是很常见的用法。我最终选的是共享盒子(引用语义),并且在文档里写清楚"闭包捕获的是变量本身,不是当时的值",省掉后面无数的解释成本。
5.3 引用计数与循环引用:别让延长寿命变成泄漏
搬到堆上之后,谁来释放?如果是引用计数,最简单的规则是:每个帧持有它直接创建的盒子的一个引用,每个闭包持有它捕获的盒子的一个引用,帧销毁时减一,闭包销毁时减一,减到零就释放。
麻烦在于循环引用。一个闭包捕获了一个盒子,盒子里装的又是这个闭包自己(比如递归的局部函数),引用计数就永远减不到零。我用过的最务实的处理是:接受这部分泄漏,但在调试模式下提供一份"存活盒子"的统计,能看出数量是不是在异常增长。真想彻底解决就得引入标记清除,那是另一个量级的工作,不建议在功能还没稳定的时候上。
还有一种更隐蔽的情况:闭包捕获了循环变量。如果每轮迭代新建作用域,那每轮的盒子都是独立的,三个闭包持有三个盒子,没问题;如果共用作用域,三个闭包持有同一个盒子,那计数是对的,但语义是你不想看到的。所以第 3.2 节那个决定,实际上同时决定了生存期的行为,两个模块是绑在一起的,这也是我坚持把作用域和生存期放在一起讲的原因。
注意:一旦你引入"被捕获变量提升到堆"的规则,就必须保证所有访问路径都走盒子,包括赋值、复合赋值、自增自减。我漏过一个自增路径,结果
i++改的是栈副本,闭包读到的还是旧值,输出差了一位,查了很久。
6. 排查链路:作用域相关 Bug 的定位顺序
功能实现完之后,真正花时间的是排查。作用域和生存期的 bug 有个共同特点:报错信息和真实原因对不上。所以我给自己总结了一套固定顺序,照着走能省掉大部分试错。第一步永远是分类:这到底是"名字找错了",还是"存储没了"?分类错了,后面所有动作都是白费。
6.1 先确认是"找错了"还是"没了"
我的判断方法很土但有效:在出错的那一行把变量的名字、解析出的hops和slot、以及该槽位的当前值全部打出来。如果hops/slot指向的位置根本不该有这个变量,那是解析问题;如果位置对但值是空或者明显不合理,那是生存期问题。
常见的现象和它对得上的原因,我整理成了表,排查时直接对照:
| 现象 | 更可能的原因 | 优先检查 |
|---|---|---|
| 块外能读到块内变量 | 退出块没弹帧 | 基线回退是否覆盖所有路径 |
| 内层变量读到外层同名值 | 解析时扫错了作用域层 | 作用域栈的压入弹出顺序 |
| 闭包读到最后一个循环值 | 循环体复用了作用域 | 每轮迭代是否重压帧 |
| 递归报未声明 | 名字登记晚于函数体解析 | 声明与函数体的解析次序 |
| 函数返回后崩溃或读到脏值 | 帧先于闭包释放 | 被捕获变量是否在堆上 |
| 嵌套函数读不到外层参数 | 帧父指针指向全局 | 闭包记录的环境指针 |
| 浅层测试全过深层失败 | 栈弹出数量算错 | 每层块的槽位计数 |
拿这张表对照着看,比自己瞎猜快得多。我第一次遇到"闭包读到最后一个循环值"时,第一反应是闭包捕获写错了,改了半天捕获逻辑,最后发现根子在循环体不新建作用域——方向错了,越改越乱。
6.2 环境链快照打印器
有一个工具我强烈建议你一开始就写:一个能把当前帧链完整打印出来的调试函数。输出格式大概是每行一个帧,标明层号、槽位数、每个槽的名字和值的摘要(截断长字符串),以及该槽是否被标记为已捕获。
def dump_frames(frames, names): for depth, frame in enumerate(reversed(frames)): rows = [] for slot, val in enumerate(frame.slots): tag = "*" if frame.captured[slot] else " " rows.append("%s%d:%s=%r" % (tag, slot, names[slot], val)) print("frame -%d | %s" % (depth, " ".join(rows)))这个函数的投入产出比高得离谱。有它之后,前述那个"帧多留一层"的 bug 我五分钟就定位了,因为在快照里能直接看到多出来那一层的槽全是空的。没有它的时候,只能靠加print一行行试,试到最后自己都记不清改了多少处。
顺带说一个细节:快照打印要放在出错时自动触发,而不是靠手动调用。我把它挂在抛出未声明错误和访问失效绑定这两种异常上,程序一崩就自动把帧链倒出来,现场信息完整保留。这个改动让我排查效率又上了一个台阶。
6.3 最小用例矩阵
功能写完不等于做对,得有能长期跑的回归用例。下面这组是我自己维护的,每一行都对应过至少一个真实的 bug,建议你也照着搭一份:
| 用例 | 期望行为 | 覆盖点 |
|---|---|---|
| 块内定义、块外读取 | 解析期报未声明 | 静态作用域判定 |
| 内层同名遮蔽外层 | 内层读内层,外层不受影响 | 遮蔽与两阶段声明 |
块内return | 正确弹帧并带出返回值 | 提前退出的回退路径 |
| 块内抛错穿过三层 | 三层帧全部回退 | 异常路径的深度恢复 |
| 闭包捕获参数 | 函数返回后仍可读到参数值 | 函数参数的装箱 |
| 闭包捕获循环变量 | 每轮各自独立 | 迭代作用域重建 |
| 直接递归 | 正常返回阶乘结果 | 自引用与帧父指针 |
| 嵌套函数访问外层局部变量 | 读到外层值 | 词法作用域链 |
| 少传一个参数 | 明确的参数个数错误 | 参数绑定校验 |
| 深递归一万层 | 语义清晰的深度超限提示 | 调用深度保护 |
跑这套用例的时候有个小技巧:每加一个新特性,先把矩阵里相关的用例跑一遍,再写新用例。因为作用域改动的影响面非常大,一个看似无关的修改(比如给槽位加回收)足以让三层之外的闭包行为变掉。我吃过的教训是:改了槽位回收之后没跑循环闭包那条用例,上线后才发现三个闭包开始共用一个槽,全串了。
我个人在实际操作中的体会是,作用域和生存期这块,最难的不是写出能跑的代码,而是在加新特性的时候守住已有的语义边界。我的做法是每个特性都对应一个"语义承诺"记到文档里,比如"闭包捕获的是变量本身而非值""循环体每轮新建作用域""被捕获变量提升到堆",然后所有实现都必须围绕这几条承诺走,任何一条动了都要全量回归。这套笨办法救过我很多次,尤其是在我为了性能想走捷径的时候——捷径往往就是语义裂缝的起点。