我在写一个玩具解释器的时候,最头疼的其实不是语法分析,也不是表达式求值,反而是“局部变量到底该存哪里”这个问题。它表面上是数据结构设计,实际牵扯到作用域规则、闭包语义、变量生命周期,甚至和后续的类型推断、调试器实现都有关。这篇就把我自己的实现思路和踩过的坑摊开来讲。
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。这绝对是天壤之别。
所以,
