我在写一个玩具解释器的时候,最头疼的其实不是语法分析,也不是表达式求值,反而是“局部变量到底该存哪里”这个问题。它表面上是数据结构设计,实际牵扯到作用域规则、闭包语义、变量生命周期,甚至和后续的类型推断、调试器实现都有关。这篇就把我自己的实现思路和踩过的坑摊开来讲。
1. 为什么“局部变量存储”是所有解释器绕不开的核心问题
解释器的本质是把源代码变成一棵抽象语法树,然后对这棵树求值。求值过程中,变量是绕不开的:算术表达式要取变量值,赋值语句要更新变量,函数体内的变量和全局变量还要隔离。局部变量存储说的就是:当一个函数执行时,它的内部变量放在哪里,怎么组织,才能在调用结束后干净地释放,同时又能支持嵌套作用域和闭包。
很多人第一反应是“用一个字典存变量名到值的映射不就行了”。确实,最简单的解释器就是这么干的,我最初也这么写过。但一旦遇到递归调用,同一个函数同时有多个执行实例,每个实例的局部变量必须互不干扰。只用一个字典的话,第一次调用还没结束又进入第二次调用,同名变量会互相覆盖。所以必须为每次函数调用单独分配一块存储区域,这就是“环境”或者“帧”的概念。
局部变量存储的另一个关键点是查找规则。在大多数语言里,访问一个变量会先从当前函数内部找,找不到再去外部作用域找,比如全局。这种规则叫词法作用域。存储结构必须支持从内向外逐层查找,而且查找的路径要完全按照代码书写时的嵌套结构来,而不是按照调用顺序。
还有一个容易被忽略的点:局部变量的生命周期。函数执行完,理论上它的局部变量就该销毁了。但如果有闭包捕获了这些变量,它们就不能被立即回收,否则闭包访问到的就是“悬空引用”或错误的值。这一点如果存储设计得不对,后面实现的闭包会有一堆莫名其妙的问题。
所以局部变量存储这个模块,实际上一并决定了三个能力:支持递归、支持作用域嵌套、支持闭包。这篇文章里我会用一段手写的Python解释器代码,从最简单的方案逐步演进到能处理闭包的方案,把整个思考过程完整走一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从“全局字典”到“调用帧”:第一次设计迭代
2.1 最朴素的做法:一个字典打天下
我先写一个最原始的版本。假设解释器只支持非常简单的赋值、读取和加法,用Python字典作为变量存储:
python复制env = {}
def eval_expr(node, env):
if node["type"] == "Number":
return node["value"]
elif node["type"] == "Name":
return env[node["name"]]
elif node["type"] == "Assign":
value = eval_expr(node["value"], env)
env[node["name"]] = value
return value
elif node["type"] == "BinOp":
left = eval_expr(node["left"], env)
right = eval_expr(node["right"], env)
return left + right
这样的代码非常简单,跑一层表达式完全够用。问题是如果我要支持函数定义和函数调用,就需要引入“调用时创建新环境”的机制。假设我有一个 Call 节点,调用一个用户自定义函数:
python复制def eval_call(node, env):
func = env[node["func"]]
# 新环境:函数定义时的外部环境?
# 还是函数调用时的调用者环境?
new_env = {}
for param, arg in zip(func["params"], node["args"]):
new_env[param] = eval_expr(arg, env)
return eval_body(func["body"], new_env)
这段代码的大方向是对的:每次调用创建一个新环境,参数绑进新环境。但它立刻暴露一个问题:函数体内部如果要访问全局变量,因为新环境是空的,会访问不到。所以新环境的创建不能是空字典,它需要一条指向外部环境的链子。
2.2 引入“外部环境”指针,形成环境链
在设计解释器时,环境本质上是一个链表或者树:每个环境有一个 outer 指针,指向定义它的外层环境。查找变量的时候,先查当前环境,查不到就顺着 outer 链往上找,直到最外层的全局环境。
python复制class Env:
def __init__(self, outer=None):
self.store = {}
self.outer = outer
def get(self, name):
if name in self.store:
return self.store[name]
if self.outer is not None:
return self.outer.get(name)
raise NameError(f"undefined name: {name}")
def set(self, name, value):
self.store[name] = value
这里的核心改进是:通过 outer 把环境串联起来,新创建的函数调用环境指向“定义这个函数时所在的环境”。注意,是定义时的环境,而不是调用时的环境。这一点必须想清楚。如果指向调用者的环境,那么下面这段代码就会出现完全错误的语义:
python复制x = 100
def outer():
x = 1
def inner():
return x + 1
return inner
f = outer()
# 此时 f 调用时,x 应该取哪个值?
词法作用域要求 inner 定义在 outer 内部,所以它看到的 x 是 outer 内部的 x=1,结果应该是 2。但如果创建 inner 的环境时指向调用者环境,那么 f() 在全局被调用,全局的 x=100 就会跑进来,结果变成 101。这绝对是天壤之别。
所以,函数对象在被创建的时候,必须把当时的“当前环境”保存起来。这个保存了定义环境的函数对象,通常就叫“闭包”的前置形态。到这一步,局部变量存储的模型已经比较清晰了:
- 每个函数调用对应一个
Env。 Env持有一个字典、一个外层指针。- 查找变量时,沿外层指针逐级向上。
- 函数对象保存定义时的
Env,调用时以此为基底创建新的Env。
不过,这套设计还有一个隐患:如果一个环境不再被任何人引用,它就是垃圾,Python自身的GC会负责回收。这看起来没问题,但“变量”和“值”的关系如果处理不好,闭包捕获的变量在循环中会被“共享”,这是一个非常经典的坑。我放到后面专门讲。
2.3 参数绑定和默认值的顺序问题
在继续深入之前,我想先提一个实现上的小细节:函数调用时,参数绑定的顺序。比如 f(a, b),理论上应该先求值实参表达式,再绑定到形参。但如果有默认参数,或者有类似 f(x, x+1) 这种同一个变量出现在多个实参里的情况,绑定顺序就很关键。通常采取的做法是:从左到右求值所有实参,存到临时列表里,再按顺序绑定到形参。这样能保证求值和绑定是分离的,不会出现“第一个参数改动了环境,导致第二个参数取值发生变化”这种诡异行为。
这一点在动态类型语言里尤其重要。如果某个实参表达式有副作用,比如 f(assign(), read()),那么求值顺序直接影响结果。虽然玩具解释器可以简化,但养成“先求值全部实参,再绑定”的习惯,能省掉后面很多难查的bug。
3. 架构选择:哈希表环境 vs. 基于索引的栈帧
3.1 哈希表方案:实现简单但性能有天花板
上面我给的 Env 实现,背后的存储结构是哈希表(Python字典)。哈希表的好处很直接——键名查找是 O(1),实现起来也不费脑筋,插入和删除都很自然。对一个教学性质的手搓解释器来说,这是最平滑的起点。
但它的缺点也明显:
- 每次变量引用都要做字符串哈希和比较,开销偏高。
- 内存布局松散,变量在内存里并不连续,不利于缓存局部性。
- 闭包捕获时,哈希表里的值被共享,生命周期的控制完全交给GC,灵活性差。
如果你打算做性能敏感的解释器,比如给嵌入式设备用,或者要让脚本执行速度接近原生程序,那哈希表几乎不会出现在最终方案里。
3.2 基于栈帧的方案:接近真实虚拟机的做法
主流的脚本引擎,比如CPython(旧版本)和Lua的经典实现,都不是用哈希表来存储局部变量。它们把局部变量放进“栈帧”里的一个连续数组,访问变量通过整数索引,而不是字符串名字。这种方式有几个决定性优势:
- 索引访问比字符串查找快一个数量级。
- 局部变量在栈上是连续分布的,函数调用和返回就是压栈弹栈,天然匹配递归。
- 闭包捕获变量时需要的是“把变量移动到堆上”或“捕获某个索引”,语义更精确。
用数组做局部变量存储,就会遇到一个随之而来的问题:变量名到索引的映射需要编译器在编译阶段计算好。也就是说,解释器不能是纯“树遍历”模型,而要先把AST编译成字节码或至少做一轮“变量解析”的预处理。这一步其实很有价值,即使你最终还是会遍历AST,提前解析变量索引也能让求值阶段的代码干净不少。
我的建议是:如果你是为了学习,先做哈希表版,快速验证语义;如果你打算把解释器做深、做快,可以参考下面这个“编译期分配槽位”的思路。
先看看如何给局部变量分配固定的槽位索引。假设一个函数体的AST里有如下变量定义:
python复制def f(a, b):
c = a + b
d = c * 2
return d
编译阶段扫描这个函数体,把所有出现的局部变量收集起来,映射成索引:a->0、b->1、c->2、d->3。运行时的栈帧就是一个长度固定(比如4)的数组。参数 a 和 b 在调用时从实参列表拷贝到槽位0和1,然后 c 的计算结果写入槽位2,等等。整个过程不涉及任何字符串比较,极快。
3.3 当局部变量数量动态变化时怎么办
有些语言允许运行时动态定义局部变量,比如 exec 动态生成代码,或者在同一个作用域里用变量名拼接访问变量。这时候固定槽位就不适用了。所以在设计阶段,你得先明确语言范围:不需要动态添加局部变量,那固定槽位完全够用;需要的话,就得给某些帧增加一个“溢出区”,用哈希表兜底。
我做的这个解释器定位在“教学和玩具”级别,所以我最终采用了混合方案:默认的局部变量走槽位数组,但函数对象上保留一个 extra 字典,处理特殊情况。这样既能讲原理,又不至于被性能细节困住。如果你不需要这种混合,纯哈希表也完全能讲清楚局部变量存储的核心,不需要追求一步到位。
4. 闭包捕获:变量存储的“生命周期陷阱”和隔离方案
4.1 闭包捕获发生在什么时刻
如果你从没亲手实现过闭包,这里要特别强调:闭包捕获的是“变量容器”,不是变量的“值”。当你写:
python复制def outer():
x = 1
def inner():
return x
x = 2
return inner
inner 返回的应该是 2,而不是 1。因为 inner 捕获的是 outer 环境里的 x 这个名字对应的存储位置,每次读取都从那个位置取当前值。
在哈希表版本的 Env 里,这很容易实现,因为 inner 的 outer 保存的就是 outer 调用生成的 Env 对象。x = 2 更新的是同一个字典里的键,所以闭包看到的是更新后的值。这本质上是引用语义。
但这引出一个常见的bug:循环变量捕获。
python复制funcs = []
for i in range(3):
def f():
return i
funcs.append(f)
# 期望输出 0, 1, 2
# 实际可能输出 2, 2, 2
问题在于,for 循环里的 i 是同一个环境变量(在同一个作用域里),你创建的所有 f 捕获的都是同一个 i。循环结束后 i 变为2,所有 f 再访问当然都得到2。这个坑在Python里也存在,只是Python的 for 会在迭代时不断改变同名变量的值。如果你的语言里 for 循环有独立的块级作用域,那每次迭代应该创建新的环境,此时捕获的 i 才是每次迭代的那一个。
所以设计解释器时,必须决定 for、while、if 这些块结构是否创建新的作用域。很多语言(包括早期JavaScript的 var)都是函数级作用域,块不产生新环境。如果你的语言设计成块级作用域,那么每次进入循环体时都要创建一个新的子环境。
4.2 闭包释放:谁持有谁
闭包给存储带来的另一个挑战是生命周期。函数调用结束,正常的局部变量应该可以被销毁。但如果返回了一个闭包,而这个闭包引用了当前环境,那当前环境必须被保留。如果你在实现里没有做好这一点,而是简单地把局部变量存到一个栈结构并在函数返回时全部弹出,那么闭包再访问局部变量就是访问已经失效的栈内存,这是C语言里经典的“返回局部变量指针”问题。解释器如果不想让用户踩这种底层错误,就必须正确处理“环境逃逸”。
最直觉的做法是:不让环境存放在普通调用栈上,而是把所有环境都当作堆上的对象,用GC管理。这也是CPython和Lua普遍采用的方式。函数调用时新建一个环境对象,函数结束时环境对象不被立即删除,只要还有人引用它,它就会继续存在。没人引用时,GC自然会回收。代价是多了一点堆分配的开销,但换来了正确的语义和简单的实现。
4.3 分离“变量绑定”和“存储位置”:Upvalue的设计
如果你以后想让解释器跑得更快,会发现闭包捕获的环境如果没有被修改,其实可以优化成“值捕获”,而不是“容器捕获”。这就是Lua里Upvalue设计的出发点:闭包初次创建时,决定是直接捕获当前值,还是捕获一个指向外部局部变量的引用。如果外部局部变量在闭包创建后不会再被修改,就做值拷贝;如果可能被修改,就共享一个存储位置。这种优化在实现时比较烧脑,但效果显著。
我自己的实现比较朴素,直接使用哈希表环境,让闭包捕获整个外层环境。这样性能不是最优,但正确性很好写。如果你也想做一套教学向的解释器,完全可以先走这条路,把闭包语义跑通,再考虑Upvalue优化。
5. 一个可运行的最小实现:环境链、函数调用和闭包
5.1 定义AST节点
为了让上面的讨论落到实际,我给出一个可运行的最小Python实现。这个实现不追求高性能,而是把局部变量存储的机制完整展示出来。
先定义AST。为了节省篇幅我用字典表示节点,实际代码里可以用dataclass,但原理相同。
python复制# 节点类型
# {"type": "Number", "value": 1}
# {"type": "Name", "name": "x"}
# {"type": "Assign", "target": "x", "value": expr}
# {"type": "BinOp", "op": "+", "left": expr, "right": expr}
# {"type": "FuncDef", "name": "f", "params": ["a", "b"], "body": [expr], "env": None}
# {"type": "Call", "func": expr, "args": [expr]}
# {"type": "Return", "value": expr}
这里的 FuncDef 还预留了一个 env 字段,用来保存函数定义时所在的环境。这个字段在解释执行阶段填入。
5.2 环境类
python复制class Env:
def __init__(self, outer=None):
self.store = {}
self.outer = outer
def get(self, name):
env = self
while env is not None:
if name in env.store:
return env.store[name]
env = env.outer
raise NameError(f"undefined name: {name}")
def assign(self, name, value):
self.store[name] = value
def assign_local(self, name, value):
self.store[name] = value
get 会沿着 outer 链查找,assign 只修改当前环境的 store。这种做法和Python的全局变量声明规则不太一样,它实现的是“赋值默认只作用于当前作用域”。如果你想模拟Python那种“如果局部变量被赋值过,它就是局部变量”的规则,需要在解析阶段做更复杂的名字绑定分析。我的这个简化版已经能表达局部变量和全局变量读取,但对于同名赋值语义偏简单,提前说明一下。
5.3 求值器
python复制def evaluate(node, env):
ntype = node["type"]
if ntype == "Number":
return node["value"]
elif ntype == "Name":
return env.get(node["name"])
elif ntype == "Assign":
value = evaluate(node["value"], env)
env.assign(node["target"], value)
return value
elif ntype == "BinOp":
left = evaluate(node["left"], env)
right = evaluate(node["right"], env)
if node["op"] == "+":
return left + right
elif node["op"] == "-":
return left - right
return left * right
elif ntype == "FuncDef":
func = {
"name": node["name"],
"params": node["params"],
"body": node["body"],
"env": env,
"type": "closure"
}
env.assign(node["name"], func)
return func
elif ntype == "Call":
func = evaluate(node["func"], env)
args = [evaluate(arg, env) for arg in node["args"]]
return call_function(func, args)
elif ntype == "Return":
return ("return", evaluate(node["value"], env))
这里 Call 节点的 func 字段既可以是一个变量名,也可以是一个更复杂的表达式,比如直接调用一个匿名函数。它先求值函数对象,再求值所有实参。注意 Return 用了一个特殊标记:返回元组 ("return", value),这样在函数体执行时能区分“正常结束”和“显式返回”。这也是解释器里处理控制流的一种常见做法。
5.4 函数调用逻辑
python复制def call_function(func, args):
# 创建新环境,外层指向函数定义时的环境
new_env = Env(func["env"])
for param, arg in zip(func["params"], args):
new_env.assign_local(param, arg)
result = None
for stmt in func["body"]:
r = evaluate(stmt, new_env)
if isinstance(r, tuple) and r[0] == "return":
result = r[1]
break
return result
这段代码最核心的一行是 Env(func["env"]),它把函数调用时的新环境的外层设置成函数定义时的环境,形成环境链。这样做之后,闭包语义天然成立。你不需要额外写“捕获”逻辑,闭包就是“函数对象 + 定义时环境”的组合。
5.5 实测几个场景
我用这个实现测过几个场景,最典型的是下面的代码:
python复制x = 1
def outer():
x = 2
def inner():
return x
return inner
f = outer()
assert f() == 2
在全局环境里 x=1,调用 outer 时创建新环境,内部 x=2。inner 定义时保存的环境是 outer 调用时的环境,所以 f() 里的 x 解析到 2,正确。
再测一个经典计数闭包:
python复制def counter():
count = 0
def inc():
nonlocal count
count = count + 1
return count
return inc
c1 = counter()
c2 = counter()
assert c1() == 1
assert c1() == 2
assert c2() == 1
两个计数器互不干扰,因为每次调用 counter 都创建了一个全新的 Env,count 在不同的 store 字典里。这个测试能很好验证“每次函数调用创建独立环境”的设计是否正确。
5.6 一个必须注意的坑:赋值语义
上面的 assign 直接在当前环境写入变量。如果你的语言想做Python式的“先检查当前环境有没有这个变量,没有就沿外层找”,需要在 assign 里加一层查找逻辑。比如:
python复制def assign(self, name, value):
env = self
while env is not None:
if name in env.store:
env.store[name] = value
return
env = env.outer
# 全都没有,就在当前环境新建
self.store[name] = value
这更接近JavaScript的赋值行为:找到哪个环境有这个名字,就在哪里更新。如果所有环境都没有,就在当前作用域新建。这种赋值语义在你的语言里需要明确定义。最怕的就是实现上模棱两可,最后调试时各种混乱。
6. 词法作用域和变量解析的边界情况
6.1 同一变量名在不同层级的可见性
环境链看起来简单,但变量查找一旦遇到同名的内外层变量,就特别容易写错。规则是:从当前环境开始,逐层向外,遇到第一个包含该名字的环境就返回。内层的名字会“遮蔽”外层的同名变量。实现上 get 的 while 循环天然满足这个规则,但我看到过一些初学实现写成递归形式,然后忘了在递归调用的返回值上做处理,导致最终返回了外层变量。
还有一个边界情况:一个变量在函数体内部被读取,但它从未被赋值过。在真正的语言实现里,这应该在编译阶段或语义分析阶段就报错,而不是等运行时。我的简化实现是运行时抛 NameError,这会导致某些错误延迟到很深的位置才暴露。如果你在做生产级解释器,强烈建议在解析阶段做一次名字绑定校验,把“未定义变量”提前抓出来。
6.2 函数声明提升与局部变量存储
有些语言允许在函数体内部先调用一个后面才定义的局部函数。比如JavaScript的 function 声明会整体提升。如果你的语言也支持这种风格,那么函数定义变量的存储位置需要在函数体进入时就预先在环境中创建,而不是等到执行到 function 声明节点才创建。否则,提前调用会报“未定义”。
在环境里预先创建变量,意味着变量名在函数一进来时就存在于 store 中,只是值可能还没有初始化。你可以赋一个特殊值 Uninitialized,等真正执行到赋值语句时再更新。这个设计能避免“函数提升”带来的很多坑。
6.3 块级作用域和临时作用域
如果你的语言支持块级作用域,比如Java的 {} 或Python的 if(Python 3里并不会为if创建作用域),那么每进入一个块都需要创建新的子环境,块结束后退出这个环境。可以用“进入块时压栈,离开块时弹栈”的方式模拟。但要注意,块级环境的外层指针指向的是外围的函数环境,而不是全局。
这里有一个实际问题:局部变量如果存储在块级环境中,那么闭包捕获块级变量时,语义也要随之调整。比如:
cpp复制{
int x = 1;
auto f = [&]{ return x; };
}
// 离开块后,x理论上已经被销毁
// 但f还引用它,这是悬空引用问题
如果解释器把块级环境做成堆对象,不立刻销毁,这个问题就不存在。但如果你为了性能,把块级环境放在原生栈上,就会遇到C++ lambda捕获局部变量时的生命周期问题。我建议教学版本全部用堆环境,简单安全。
7. 调试局部变量存储的方法和经验
7.1 给环境加可视化输出
我在调试解释器的时候,最常用的一招就是给 Env 加一个 repr 方法,把整个环境链以类似作用域嵌套的方式打印出来:
python复制def __repr__(self):
frames = []
env = self
while env is not None:
frames.append(str(env.store))
env = env.outer
return " -> ".join(frames)
这样在求值某个表达式之前打印环境链,可以一眼看出变量在每一层是什么值。尤其是在测试闭包时,这个方法能立刻暴露“这个闭包到底捕获了哪一层环境”的问题。
7.2 用断言验证作用域规则
写解释器最怕“看起来结果对,但底层对不上”。所以我会给核心测试写断言,把每个节点求值前后的环境状态都验证一遍。比如:
python复制# 测试闭包隔离
c1 = counter()
c2 = counter()
assert c1() == 1
assert c2() == 1 # 关键断言:两个闭包的内部状态互不影响
assert c1() == 2
如果某个实现错误地共享了环境,第二个断言的输出会是2,测试立刻失败。这比人肉观察输出可靠得多。
7.3 断点式排查“变量名写错”的典型案例
有一种极其常见的bug是:变量名拼写错误,导致解释器创建了一个意外的新变量。比如 total 写成 totla,在基于当前环境直接 store[name] = value 的实现里,它会为新名字创建变量,而原变量仍然是旧值,最终结果全错。这个错误在基于字典的环境里尤其隐蔽,因为没有任何报错。要缓解这个问题,可以给 Env 开启一个“严格模式”,赋值时如果当前环境以及外层环境都没有这个变量,就抛异常,而不是静默创建。这样能把拼写错误尽早暴露出来。
7.4 性能上的观察
即便只是玩具解释器,我也做过粗略的性能测试。纯哈希表环境的解释器,在处理大量简单表达式时,耗时主要花在 Name 节点的字典查找和字符串哈希上。而如果改用整数索引的槽位方案,性能能提升好几倍。这个差距在深度递归和密集循环里更加明显。所以如果你的目标是做一个可用的脚本语言,最终少不了“变量名解析到索引”这一步。
不过,如果在学习阶段就纠结性能,纠结得为之分心,反而会丢了对核心语义的理解。我始终认为:先写对,再写快。
8. 更进一步:类型推断、调试器和持久化对存储的影响
局部变量存储不只是求值时用,它还会影响后续一系列工具的开发。
比如你要写一个简单的调试器,需要支持“在断点处查看当前环境的变量值”。此时环境必须支持枚举当前帧的所有变量名和值。哈希表自然支持,槽位数组则需要额外保存一份“索引到名字”的映射。否则调试器只能显示一堆 arg0、arg1 之类的无意义名字。
再比如你要做类型推断,就需要知道每个局部变量的类型集合。这同样依赖于对作用域和变量绑定的分析。如果只在运行时用环境存储变量,那么类型信息完全没法在编译期获取。所以做静态分析时,你通常需要“符号表”,它就是“编译期的变量存储结构”,和运行时的 Env 相对应。符号表一般是一个树形结构,记录每个作用域里的变量名、类型、偏移量等信息。局部变量存储的运行时方案,和符号表的设计是强绑定的。
还有一点:如果你的解释器支持“序列化执行状态”,比如把当前解释器的所有局部变量保存到文件,下次恢复继续执行,那么环境就必须提供“快照”和“恢复”能力。哈希表做快照很容易,直接 copy.deepcopy 就行;槽位数组则需要你能标记出哪些槽位是活跃的。
这些都是局部变量存储这个基础决策带来的连锁反应。所以别看它只是一块简单的数据结构,真的牵扯到很多后续设计。
9. 我踩过的坑和最后的小建议
我想把实际开发过程中真正的教训留到最后说。第一个坑是闭包捕获循环变量。当时我实现了一个简单的 for,它的循环变量存放在函数环境中,然后用闭包去捕获。结果所有闭包拿到的都是循环结束后的最终值。这个问题不仔细想非常难发现,因为从代码上看每个函数应该捕获不同的 i,但运行时它们捕获的是同一个变量容器。解决方式就是给每次迭代创建新的环境,或者把循环变量作为参数传进闭包。
第二个坑是函数调用时实参求值顺序。我一开始图省事,在 call_function 里边求值边绑定参数。一旦遇到 f(x, x = x + 1) 这类有副作用的实参表达式,绑定顺序就会导致怪异结果。后来改成统一的“先求值实参列表,再创建环境并绑定”,问题就消失了。
第三个坑是 return 的控制流表示。如果函数体里有多个 return,比如条件分支的 return,用一个特殊标记元组表示没问题,但要注意函数体执行时不能漏掉对 return 的检查。否则一个分支返回后,代码还会继续执行后面的语句,导致错误结果。我在早期实现中踩过这个坑,后来把所有“控制流跳转”都归纳为特殊返回值,实现起来非常统一。
如果你也要手搓一个解释器,我的建议是:局部变量存储从“环境链 + 字典”开始,先把词法作用域、递归、闭包都跑通。这个阶段的目标是语义正确,哪怕性能差一点也没关系。跑通之后,再考虑把环境改为槽位数组、引入Upvalue优化、加类型推断、加调试信息。这个顺序能让你用最少的复杂度,把解释器最核心的机制搞清楚。
实现过程中记得多写测试,尤其要测闭包隔离、递归深度、同名变量遮蔽、循环变量捕获这几个场景。它们是局部变量存储最容易出bug的地方。只要这些测试都过了,你的解释器在局部变量这一块基本就稳了。
