「手写 AST 解释器与求值语义」

从树遍历求值出发,讲解手写 AST 解释器的完整实现:环境与词法作用域、闭包与递归、引用与值的语义、求值顺序与短路求值、控制流的树形编码,并对比字节码解释器的取舍,附带可运行的 Python 求值器示例。

1. AST 求值模型概览

一句话总结: 树遍历解释器直接对语法树递归求值,把每个节点翻译成一个计算步骤,是最直观也最易维护的执行模型。

AST 解释器(tree-walking interpreter)在语法分析之后不再产生任何中间表示,而是直接遍历语法树,对每个节点执行对应的求值动作。1 + 2 * 3 被语法分析器构造成一棵加法节点挂两颗子树的树,求值器先递归求值右子树得到 6,再与左子树的 1 相加得到 7。整个过程与表达式的数学直觉一一对应,因此常被选作教学语言与脚本语言的起步实现。

from dataclasses import dataclass
from typing import Any

class ASTNode:
    """所有 AST 节点的基类,解释器按 type 分派求值。"""

@dataclass
class Num(ASTNode):
    value: Any

@dataclass
class BinOp(ASTNode):
    op: str          # '+', '-', '*', '/'
    left: ASTNode
    right: ASTNode

@dataclass
class Assign(ASTNode):
    name: str
    value: ASTNode

@dataclass
class If(ASTNode):
    cond: ASTNode
    then_branch: ASTNode
    else_branch: ASTNode | None = None
执行模型中间表示复杂度典型场景
AST 解释语法树低,直译教学语言、DSL
字节码解释字节码数组中Lua、CPython、Java
JIT 编译机器码高V8、HotSpot、PyPy

AST 求值的好处是零编译延迟,语法树构建完成即可执行,调试时能看到节点与源码的直接对应关系;代价是每个节点都是一次 Python 级别的对象遍历与分派,跨语言或性能敏感场景里明显慢于字节码。理解 AST 求值的语义是理解一切解释器的起点,后面的字节码只是把「递归遍历」换成「顺序扫描」而已。

2. 环境与作用域

一句话总结: 环境把变量名映射到值,词法作用域让每个函数记住定义时的环境链,从而支持嵌套与遮蔽。

求值器需要一张从名字到值的映射表,这张表就是环境(environment)。最简单的实现是链式作用域:每进入一个块或函数,就基于父环境新建一层子环境,变量查找沿着链自内向外进行。词法作用域(lexical scoping)意味着名字的绑定在编译期就由源码嵌套结构决定,而不是由运行时调用顺序决定。

class Environment:
    def __init__(self, parent: "Environment | None" = None):
        self.store: dict[str, Any] = {}
        self.parent = parent

    def define(self, name: str, value: Any) -> None:
        self.store[name] = value

    def lookup(self, name: str) -> Any:
        env = self
        while env is not None:
            if name in env.store:
                return env.store[name]
            env = env.parent
        raise NameError(f"undefined variable: {name}")

    def assign(self, name: str, value: Any) -> None:
        env = self
        while env is not None:
            if name in env.store:
                env.store[name] = value
                return
            env = env.parent
        self.store[name] = value  # 宽松模式: 未声明即赋值
作用域类型绑定依据例子解释器行为
词法作用域源码嵌套Python、Rust函数绑定创建处环境
动态作用域调用栈早期 Lisp函数绑定调用处环境
块级作用域大括号C、Java每对花括号新建环境

词法作用域对闭包的正确性至关重要:函数被返回后仍要能访问定义它的环境。Python 的 LEGB 规则(局部、闭包、全局、内建)正是链式环境查找的一种具体化。实现赋值时要注意区分声明与更新:许多语言要求变量先声明再赋值,宽松模式虽然便于教学,却会掩盖拼写错误。

3. 闭包与递归

一句话总结: 闭包把函数定义与其词法环境打包保存,递归则依赖环境链上的自身绑定,两者共同构成函数式语义的地基。

当一个函数值离开它被定义的作用域继续存活时,它必须携带创建它的环境,这样的「函数 + 环境」组合就是闭包(closure)。在链式环境里实现闭包几乎不费事:求值 function 节点时新建子环境,把参数绑定进去,再把「参数列表、函数体、这个环境」三元组存成函数值即可。递归函数也走同一机制——函数体执行时在环境链里能找到自己的名字。

@dataclass
class Function(ASTNode):
    params: list[str]
    body: ASTNode
    name: str | None = None

class ClosureValue:
    """函数值: 参数、函数体与定义时环境的打包。"""
    def __init__(self, params, body, env):
        self.params = params
        self.body = body
        self.env = env

    def call(self, args, evaluator):
        call_env = Environment(self.env)
        for name, val in zip(self.params, args):
            call_env.define(name, val)
        if self.name:                      # 支持递归: 绑定自身
            call_env.define(self.name, self)
        return evaluator.eval(self.body, call_env)
# 求值器中的函数调用分派
def eval_call(self, node, env):
    fn = self.eval(node.callee, env)
    args = [self.eval(a, env) for a in node.args]
    if isinstance(fn, ClosureValue):
        return fn.call(args, self)
    if isinstance(fn, BuiltinFunction):
        return fn.impl(args)
    raise TypeError(f"{node.callee} is not callable")

闭包带来的一个经典坑是循环变量捕获:在循环体内创建函数并立即返回多个闭包时,若所有闭包共享同一个环境,则它们看到的都是循环结束后的同一个变量。现代语言用每轮迭代新建环境来规避此问题,实现时须注意环境创建的粒度,否则会出现「所有闭包都等于最后一个值」的诡异现象。

4. 引用 vs 值

一句话总结: 值语义复制数据、引用语义共享数据,两者的选择决定了赋值与传参是否产生别名,直接影响程序可预测性。

求值器必须明确赋值与参数传递的语义:传递的是值的副本,还是指向同一存储位置的引用?值语义(value semantics)下 b = a 后修改 a 不影响 b;引用语义(reference semantics)下两者共享同一对象。语言设计需要在两者之间选边,或用显式类型标注让程序员自选。

# 值语义: 赋值复制整个数据结构
def deep_copy(obj):
    if isinstance(obj, list):
        return [deep_copy(x) for x in obj]
    if isinstance(obj, dict):
        return {k: deep_copy(v) for k, v in obj.items()}
    return obj

a = {"count": 1}
b = deep_copy(a)          # b 与 a 互不影响
b["count"] = 2
assert a["count"] == 1

# 引用语义: 赋值只是共享引用
c = a
c["count"] = 9
assert a["count"] == 9    # 别名效应
语义别名风险复制开销代表语言
值语义低大对象拷贝高C++(默认)、Rust(move)
引用语义高仅指针拷贝Java、Python、JavaScript
混合中视类型而定C#(struct/class)

在实现求值器时,把「值」表示为不可变对象(如元组、frozenset、或冻结的 dataclass)可以大幅简化引用语义带来的别名排查。若语言需要可变对象,则应区分「对象身份」与「对象内容」:赋值复制引用但对象唯一,修改走方法而非重新赋值。这组决策也决定了垃圾回收时谁持有谁、谁该被保留。

5. 求值顺序与短路求值

一句话总结: 操作数按固定顺序求值会产生可观察的副作用,短路运算则让后操作数在条件已定时被跳过。

大多数语言规定表达式按从左到右求值,但复合表达式的内部顺序仍需要精确界定:f() + g() * h() 中三个函数调用按什么顺序执行?如果函数有副作用(打印、改全局),顺序不同结果就不同。C 语言把大多数子表达式顺序留作未定义行为,而 Python 明确从左到右求值。求值器实现时须把求值顺序写进求值函数,而不是依赖宿主语言巧合。

def eval_binop(self, node, env):
    # 明确顺序: 先左后右, 结果可见于副作用
    left = self.eval(node.left, env)
    right = self.eval(node.right, env)
    return apply_op(node.op, left, right)

def eval_and(self, node, env):
    left = self.eval(node.left, env)
    if not truthy(left):          # 短路: 不再求值右操作数
        return False
    return truthy(self.eval(node.right, env))

def eval_or(self, node, env):
    left = self.eval(node.left, env)
    if truthy(left):
        return True               # 短路
    return truthy(self.eval(node.right, env))
# 短路在条件表达式中阻止副作用
count = 0
def side_effect():
    global count
    count += 1
    return True

if False and side_effect():
    pass
assert count == 0                # and 短路, 函数未执行

短路求值(short-circuit evaluation)既是语义也是性能优化:x != 0 and y / x > 1 依赖短路避免除零。实现上,and/or 不能当作普通二元操作符「先求两操作数再合并」,而必须实现为带条件的控制流。求值顺序的文档化还关系到调试器的单步行为——单步进入的顺序应与语言规范一致,否则开发者在断点前会看到反直觉的执行轨迹。

6. 控制流与异常

一句话总结: return、break 与异常都改变控制流的直线前进方向,树遍历求值器用信号对象沿调用链向上抛出以模拟跳转。

函数体的求值需要处理 return 这类「从内层表达式跳出整个函数」的控制转移。递归求值中无法用普通的返回值表达跳转,常用的做法是定义控制流信号(signal)对象,一旦遇到 return/break/continue 就向上抛,直到对应的处理者捕获。异常机制与 return 信号机制高度同构,许多求值器直接复用宿主语言的异常来传信号。

class ReturnSignal(Exception):
    def __init__(self, value):
        self.value = value

@dataclass
class Return(ASTNode):
    value: ASTNode

def eval_return(self, node, env):
    raise ReturnSignal(self.eval(node.value, env))

def eval_function_body(self, body, env, params, args):
    call_env = Environment(env)
    for name, val in zip(params, args):
        call_env.define(name, val)
    try:
        self.eval(body, call_env)
        return None
    except ReturnSignal as sig:
        return sig.value

@dataclass
class While(ASTNode):
    cond: ASTNode
    body: ASTNode

def eval_while(self, node, env):
    while truthy(self.eval(node.cond, env)):
        try:
            self.eval(node.body, env)
        except BreakSignal:
            break
控制流实现机制宿主语言等价物
return抛 ReturnSignalreturn 语句
break/continue抛 Break/ContinueSignalbreak/continue
异常抛出抛 ExceptionValueraise
异常捕获try/except 匹配try/catch

树遍历求值器里异常还有一个天然好处:宿主语言(Python)的异常带有完整调用栈,报错信息可以自动携带「哪个函数调了哪个函数」。但也正因如此,解释器不能滥用异常做普通控制流,否则每次 if 都抛异常会让性能雪上加霜。控制流信号一旦定义清晰,后续给语言加 switch、yield、defer 都只是在信号集上增加新成员。

7. 与字节码解释器对比

一句话总结: 字节码把树遍历换成顺序指令扫描,配合栈式或寄存器式虚拟机,换来更紧的内存与更可预测的分派性能。

AST 解释器每次求值都是一次 Python 对象方法调用,节点类型的分派往往还要经过多层 if 或属性访问。字节码解释器先做一次编译:把语法树线性化为紧凑的字节码数组,求值变成 while 循环内逐条指令解码与执行,指令本身是整数,指令参数拼进常量表与变量表。这让解释器主体极度紧凑(Lua 虚拟机约两千行 C),且分支预测友好。

# 字节码: 表达式 1 + 2 * 3
BYTECODE = [
    ("CONST", 1),
    ("CONST", 2),
    ("CONST", 3),
    ("MUL", None),
    ("ADD", None),
]

def run(bytecode):
    stack = []
    ip = 0
    while ip < len(bytecode):
        op, arg = bytecode[ip]
        ip += 1
        if op == "CONST":
            stack.append(arg)
        elif op == "MUL":
            b, a = stack.pop(), stack.pop()
            stack.append(a * b)
        elif op == "ADD":
            b, a = stack.pop(), stack.pop()
            stack.append(a + b)
    return stack.pop()

assert run(BYTECODE) == 7
对比维度AST 解释器字节码解释器
编译延迟无,直译一次线性化
内存占用树节点多、碎片化紧凑数组
指令分派多态方法调用循环内 switch/跳表
调试体验与源码对应直接需字节码到源码映射
优化潜力低可做栈顶缓存、超指令

AST 解释器的用武之地在于:语言还在快速演进、正确性优先于速度的教学与原型场景。字节码解释器则适合希望稳定分发、长期演进的生产语言。两者并非互斥——PyPy 先用字节码再 JIT,Tree-sitter 的语法树则被很多语言服务器直接驱动 IDE 功能,可见执行模型的选择要服务于语言当前的生命周期目标。

8. 总结

一句话总结: 手写 AST 解释器以最直白的方式落实求值语义,环境、闭包、控制流信号与短路规则是其中绕不开的四个核心机制。

主题核心结论
求值模型树遍历递归求值,节点与计算步骤一一对应
环境与作用域链式环境实现词法作用域,查找沿链自内向外
闭包与递归函数值携带定义环境,递归靠环境链中的自绑定
值 vs 引用复制与共享决定别名语义,不可变对象简化实现
求值顺序先左后右、短路跳过,副作用使顺序成为语义
控制流用 Return/Break 信号沿调用链上抛实现跳转
字节码对比树遍历直观但慢,字节码紧凑且分派友好

求值语义是所有执行引擎的语义内核:字节码虚拟机、JIT 编译器甚至在 Rust 里用宏展开的求值器,最终都在回答「这段程序该产生什么可观察行为」。把环境、闭包、短路与控制流信号这四件事写对,就等于掌握了语言运行时最核心的那部分语义,后续换任何执行后端都能复用同一套行为契约。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 「错误恢复与诊断」
  2. 「运行时与内存管理」
  3. 「现代优化 Pass 管线」