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, parentNone): 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(parentcur)退出块就把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正确弹帧并带出返回值提前退出的回退路径块内抛错穿过三层三层帧全部回退异常路径的深度恢复闭包捕获参数函数返回后仍可读到参数值函数参数的装箱闭包捕获循环变量每轮各自独立迭代作用域重建直接递归正常返回阶乘结果自引用与帧父指针嵌套函数访问外层局部变量读到外层值词法作用域链少传一个参数明确的参数个数错误参数绑定校验深递归一万层语义清晰的深度超限提示调用深度保护跑这套用例的时候有个小技巧每加一个新特性先把矩阵里相关的用例跑一遍再写新用例。因为作用域改动的影响面非常大一个看似无关的修改比如给槽位加回收足以让三层之外的闭包行为变掉。我吃过的教训是改了槽位回收之后没跑循环闭包那条用例上线后才发现三个闭包开始共用一个槽全串了。我个人在实际操作中的体会是作用域和生存期这块最难的不是写出能跑的代码而是在加新特性的时候守住已有的语义边界。我的做法是每个特性都对应一个语义承诺记到文档里比如闭包捕获的是变量本身而非值循环体每轮新建作用域被捕获变量提升到堆然后所有实现都必须围绕这几条承诺走任何一条动了都要全量回归。这套笨办法救过我很多次尤其是在我为了性能想走捷径的时候——捷径往往就是语义裂缝的起点。