AST Interpreter 的设计:为什么分 evaluate() 和 execute()

在 Tiny JavaScript Engine 里,Lexer 和 Parser 负责把源码变成 AST。

但 AST 本身不会运行。真正让程序"动起来"的,是 Interpreter。

当前 MiniJS 项目的解释器采用 AST Interpreter,也就是直接遍历 AST 节点执行程序。它最核心的设计是把执行逻辑拆成两个入口:

arduino 复制代码
Value evaluate(const Expr& expression);
void execute(const Stmt& statement);

一个负责表达式求值,一个负责语句执行。

这篇文章就讲清楚:为什么解释器要这样拆,ExprStmt 为什么分开,if / while / block / let / assignment 这些语法分别如何执行,以及为什么先做 AST Interpreter 能明显降低调试难度。

1. 表达式和语句的区别

在现代编程语言的语法分析(Parser)中,源代码在抽象语法树(AST)层面主要被划分为表达式(Expression)语句(Statement) 两大类。 这几乎是所有类C语言(Java、C++、Python、JavaScript、Rust等)语法推导的基石。

Parser之所以要严格区分它们,是因为语法规则完全不同:

  • 语句(Statement)执行一个动作 。它不产生值(或者说产生的是"空值" void)。
    • 例子if (a > b) { ... }(条件跳转)、for (...) { ... }(循环)、return x;(函数返回)、int x = 5;(变量声明)。
    • Parser逻辑 :语句通常以分号或大括号结尾,且不能作为其他表达式的组成部分。
  • 表达式(Expression)计算出一个值。它一定有一个返回类型(Type)。
    • 例子a + b(算术)、foo()(函数调用,它返回一个值)、a > b(比较运算,返回布尔值)。
    • Parser逻辑 :表达式可以嵌套(如 (a + b) * c),并且可以出现在赋值号右侧或函数参数中。

解释器设计的第一步,是区分表达式和语句。

表达式的核心特点是:它会产生一个值。

比如:

ini 复制代码
1 + 2 * 3
x
x = 10
foo(1, 2)
[1, 2, 3][0]

这些东西执行后都有结果。

复制代码
1 + 2 * 3

结果是 7

复制代码
x

结果是变量 x 当前绑定的值。

ini 复制代码
x = 10

结果通常就是赋进去的新值 10

语句的核心特点是:它描述一个执行动作,重点通常不是产生值,而是改变程序状态或控制流程。

比如:

ini 复制代码
let x = 10;
if (x > 0) { x = x + 1; }
while (x < 20) { x = x + 1; }
function add(a, b) { return a + b; }
return x;

这些语法关心的是:

  • 定义变量
  • 进入分支
  • 循环执行
  • 创建函数绑定
  • 从函数返回
  • 创建新的块级作用域

所以在解释器里,一个自然的分工就是:

rust 复制代码
表达式 -> evaluate() -> 返回 Value
语句   -> execute()  -> 产生副作用或控制流效果

这不是形式主义,而是解释器的基本抽象。

2. 为什么 AST 要分 Expr 和 Stmt?

Parser 生成 AST 时,就已经把节点分成了两类:

Parser 生成 AST 时,就已经把节点分成了两类:

arduino 复制代码
class Expr {
 public:
  virtual ~Expr() = default;
};

class Stmt {
 public:
  virtual ~Stmt() = default;
};

ExprStmt 分开后,解释器的意图非常清楚:evaluate 表示"算出一个值",execute 表示"执行一个动作"。这也是很多解释器、编译器前端都会采用的设计。

这几乎是所有类C语言(Java、C++、Python、JavaScript、Rust等)语法推导的基石。

之所以要严格区分,是因为它们的语法规则和语义完全不同

语句(Statement) 表达式(Expression)
本质 执行一个动作 计算出一个值
返回值 不产生值(void) 一定有一个返回类型(Type)
示例 ifforreturn、变量声明 算术运算、函数调用、比较运算
语法特点 以分号或大括号结尾,不能作为其他表达式的组成部分 可以嵌套,能出现在赋值号右侧或函数参数中

在解释器实现中,这种区分的价值体现在处理逻辑完全不同

  • 表达式evaluate() → 返回 Value
    • 例:1 + 2 * 3 结果是 7,x = 10 结果是 10
  • 语句execute() → 产生副作用或控制流效果
    • 例:let x = 10; 定义变量,return x; 从函数返回

表达式节点包括:

复制代码
NumberExpr
BoolExpr
NullExpr
StringExpr
VariableExpr
BinaryExpr
UnaryExpr
LogicalExpr
GroupingExpr
AssignExpr
ArrayExpr
IndexExpr
IndexAssignExpr
ObjectExpr
GetExpr
SetExpr
CallExpr
MethodCallExpr

语句节点包括:

复制代码
ExprStmt
LetStmt
BlockStmt
IfStmt
WhileStmt
FunctionStmt
ReturnStmt

为什么不把它们都做成一个 Node

因为解释语义不同。

这几乎是所有类C语言(Java、C++、Python、JavaScript、Rust等)语法推导的基石。

之所以要严格区分,是因为它们的语法规则和语义完全不同

    • 例:1 + 2 * 3 结果是 7,x = 10 结果是 10
    • 表达式天然需要返回一个运行时值:
    • 例:let x = 10; 定义变量,return x; 从函数返回

表达式天然需要返回一个运行时值:

arduino 复制代码
Value evaluate(const Expr& expression);

语句天然表示执行动作:

arduino 复制代码
void execute(const Stmt& statement);

如果所有节点混在一起,解释器就很容易变成这样:

arduino 复制代码
Value visit(const Node& node);

然后某些节点返回值,某些节点返回空值,某些节点只产生副作用,某些节点还要控制跳转。短期看函数少了,长期看语义反而糊了。

ExprStmt 分开之后,解释器的意图非常清楚:

lua 复制代码
evaluate 表示"算出一个值"
execute 表示"执行一个动作"

这也是很多解释器、编译器前端都会采用的设计。

3. Interpreter 的核心状态

当前解释器大致维护三类状态:

ini 复制代码
Environment global_environment_;
Environment* environment_ = &global_environment_;
Value lastValue_;

global_environment_ 是全局作用域。

environment_ 指向当前正在执行的环境。进入 block 或函数调用时,它会临时切到新的环境;离开时再恢复。

lastValue_ 保存程序最后一个表达式语句的结果。

例如:

ini 复制代码
let x = 10;
x + 5;

第一句 let x = 10; 会修改环境,不负责返回最终结果。

第二句 x + 5; 是表达式语句,解释器会把它的值保存到 lastValue_,所以整个程序结果是 15

顶层入口通常是:

scss 复制代码
Value Interpreter::interpret(const Program& program) {
  lastValue_ = Value();

  for (const StmtPtr& statement : program) {
    if (statement) {
      execute(*statement);
    }
  }

  return lastValue_;
}

也就是说,一个程序本质上是一组语句,解释器从上到下逐条执行。

4. evaluate():表达式如何求值

evaluate() 的任务是把一个 Expr 节点变成运行时 Value

例如数字字面量:将字符串转为数字类型

c 复制代码
if (const auto* number = dynamic_cast<const NumberExpr*>(&expression)) {
  return Value(std::stod(number->value()));
}

变量读取:从环境中找到这个变量的值

kotlin 复制代码
if (const auto* variable = dynamic_cast<const VariableExpr*>(&expression)) {
  return environment_->get(variable->name());
}

括号表达式:去解析括号内的表达式值

arduino 复制代码
if (const auto* grouping = dynamic_cast<const GroupingExpr*>(&expression)) {
  return evaluate(grouping->expression());
}

二元表达式:底层帮它实现计算

php 复制代码
if (const auto* binary = dynamic_cast<const BinaryExpr*>(&expression)) {
  const Value left = evaluate(binary->left());
  const Value right = evaluate(binary->right());

  switch (binary->op()) {
    case TokenType::Plus:
      return Value(left.asNumber() + right.asNumber());
    case TokenType::Star:
      return Value(left.asNumber() * right.asNumber());
    // ...
  }
}

这就是 AST Interpreter 的直观之处:AST 的结构和求值过程几乎一一对应。

复制代码
1 + 2 * 3

AST 是:

scss 复制代码
BinaryExpr(+)
  left: NumberExpr(1)
  right:
    BinaryExpr(*)
      left: NumberExpr(2)
      right: NumberExpr(3)

求值过程就是:

scss 复制代码
evaluate(+)
  evaluate(1) -> 1
  evaluate(*)
    evaluate(2) -> 2
    evaluate(3) -> 3
    apply * -> 6
  apply + -> 7

a. assignment 为什么是表达式?

赋值很容易让人困惑。

ini 复制代码
x = x + 1;

它看起来像一句"动作",为什么 assignment 被放在表达式体系里?

原因是:赋值本身也可以产生值。

比如很多类 C 语言里都允许:

ini 复制代码
a = b = 1;

或者:

csharp 复制代码
while ((x = next()) != null) {
  // ...
}

所以当前项目把变量赋值表示成 AssignExpr,由 evaluate() 处理:

ini 复制代码
if (const auto* assign = dynamic_cast<const AssignExpr*>(&expression)) {
  Value value = evaluate(assign->value());
  environment_->assign(assign->name(), value);
  return value;
}

它做了三件事:

markdown 复制代码
1. 计算右侧表达式
2. 更新当前环境链中已有变量
3. 返回赋进去的值

数组下标赋值也是类似:

css 复制代码
a[1] = 10;

解释器会:

scss 复制代码
evaluate(a)      -> 拿到数组引用
evaluate(1)      -> 算出下标
evaluate(10)     -> 算出新值
写入数组元素
返回新值 10

这说明一个节点虽然会产生副作用,但只要它在表达式位置出现,并且语义上会产生值,就应该放在 evaluate()

b. 短路逻辑:evaluate() 不一定求值所有子表达式

逻辑表达式也是 evaluate() 的典型例子。

css 复制代码
a || b
a && b

它们不能像普通二元表达式那样无脑先算左右两边,因为需要短路。

a || b

css 复制代码
先 evaluate(a)
如果 a truthy,直接返回 a
否则 evaluate(b)

a && b

css 复制代码
先 evaluate(a)
如果 a falsy,直接返回 a
否则 evaluate(b)

这意味着 evaluate() 不只是"递归算完所有孩子",它还要准确表达语言语义。

比如:

bash 复制代码
true || print(1)

如果支持短路,print(1) 不应该执行。

5. execute():语句如何执行

execute() 的任务是执行一条 Stmt

它不直接返回值,而是改变环境、控制流程,或者更新解释器状态。

let 声明

ini 复制代码
let x = 10;

执行逻辑是:

scss 复制代码
Value value = evaluate(letStmt->initializer()); //先计算初始化表达式
environment_->define(letStmt->name(), value);   //再把变量名和值写进当前环境

注意:let 是语句,不是表达式。

ini 复制代码
let x = 10

它的意义是声明绑定,不是产生一个可嵌入其他表达式的值。

表达式语句

ini 复制代码
x + 1;

表达式语句是语句和表达式之间的桥。

它本身是 Stmt,执行时会调用 evaluate()

ini 复制代码
lastValue_ = evaluate(exprStmt->expression());

这也是顶层程序能返回最后一个表达式结果的原因。

if 语句

ini 复制代码
if (x > 0) {
  x = x + 1;
} else {
  x = 0;
}

执行逻辑是:

scss 复制代码
evaluate(condition)
如果 truthy,execute(thenBranch)
否则,如果有 elseBranch,execute(elseBranch)

代码形态大概是:

scss 复制代码
if (evaluate(ifStmt->condition()).isTruthy()) {
  execute(ifStmt->thenBranch());
} else if (ifStmt->elseBranch() != nullptr) {
  execute(*ifStmt->elseBranch());
}

这里条件是表达式,所以用 evaluate()

分支是语句,所以用 execute()

这正好体现了两套入口的分工。

while 语句

css 复制代码
while (i < 3) {
  i = i + 1;
}

执行逻辑是:

scss 复制代码
while evaluate(condition).isTruthy():
  execute(body)

代码形态是:

less 复制代码
while (evaluate(whileStmt->condition()).isTruthy()) {
  execute(whileStmt->body());
}

同样,条件是表达式,循环体是语句。

block 语句

ini 复制代码
{
  let x = 10;
  x + 1;
}

block 的重点是作用域。

进入块时,解释器会创建一个子环境:

scss 复制代码
Environment blockEnvironment(environment_);
executeBlock(blockStmt->statements(), &blockEnvironment);

blockEnvironment 的 parent 指向当前环境。这样块内读变量时,如果当前块找不到,就会继续找外层。

ini 复制代码
let x = 1;
{
  let y = x + 1;
}

块内能读到外层 x

但块内声明的变量不会泄漏出去:

ini 复制代码
{
  let y = 10;
}
y; // runtime error: undefined variable

executeBlock() 的关键动作是临时切换当前环境:

ini 复制代码
Environment* previous = environment_;
environment_ = environment;

try {
  for (const StmtPtr& inner : statements) {
    execute(*inner);
  }
} catch (...) {
  environment_ = previous;
  throw;
}

environment_ = previous;

这里的 try/catch 很重要。即使 block 内部发生 return 或运行时错误,也要恢复之前的环境。

这就是解释器里非常典型的状态保护。

6. 函数和 return:控制流不是普通值

函数声明是语句:

css 复制代码
function add(a, b) {
  return a + b;
}

执行函数声明时,不会立刻执行函数体,而是把函数绑定到当前环境:

scss 复制代码
environment_->define(functionStmt->name(), Value(functionStmt, environment_));

这里的 Value(functionStmt, environment_) 保存了两件事:

复制代码
函数声明 AST
定义函数时的环境

第二项很关键,它是闭包能力的基础。

函数调用是表达式:

scss 复制代码
add(1, 2)

所以它由 evaluate() 处理。调用时会:

markdown 复制代码
1. 找到函数值
2. 检查参数个数
3. 创建调用环境
4. 绑定形参
5. executeBlock 执行函数体
6. 捕获 return 信号并转成返回值

return 怎么穿透多层语句?

例如:

bash 复制代码
function test(x) {
  if (x > 0) {
    return 1;
  }
  return 2;
}

return 1 出现在 if 的 block 里面,但它要直接结束整个函数,而不是只结束当前 block。

当前实现用内部 ReturnSignal 表达这种控制流:

arduino 复制代码
if (const auto* returnStmt = dynamic_cast<const ReturnStmt*>(&statement)) {
  throw ReturnSignal(evaluate(*returnStmt->value()));
}

函数调用处捕获它:

kotlin 复制代码
try {
  executeBlock(declaration->body(), &callEnvironment);
  return Value::undefined();
} catch (const ReturnSignal& signal) {
  return signal.value();
}

如果顶层程序捕获到 ReturnSignal,说明出现了函数外 return,解释器会报运行时错误:

bash 复制代码
return outside function

这也是解释器设计里很重要的一点:有些控制流不适合伪装成普通返回值,而应该作为特殊信号向上传播。

7. Runtime Value:evaluate() 的结果是什么?

evaluate() 返回的不是 C++ 的 intdoublebool,而是统一的运行时值:

kotlin 复制代码
class Value;

当前 Value 支持:

javascript 复制代码
Number
Boolean
Null
Undefined
String
Array
Object
Function

这一步很重要。解释器执行的是动态语言,表达式运行结果可能是数字、布尔值、数组、对象或函数。

例如:

复制代码
1 + 2

返回 number。

复制代码
x > 0

返回 boolean。

csharp 复制代码
[1, 2, 3]

返回 array。

css 复制代码
function add(a, b) { return a + b; }
add

返回 function。

统一的 Value 让解释器可以用同一套接口处理运行时数据:

scss 复制代码
value.isTruthy()
value.asNumber()
value.asArray()
value.toString()

运行时错误也集中在这里附近发生,比如:

  • 对非数字做算术
  • 除零
  • 数组越界
  • 调用非函数
  • 访问不存在变量

8. 为什么先做 AST Interpreter 能降低调试难度?

语言引擎后续会演进到 Bytecode VM。

但一开始先做 AST Interpreter,是更稳的路线。

原因是 AST Interpreter 离源码语义最近。

Parser 得到:

scss 复制代码
BinaryExpr(+)
  NumberExpr(1)
  BinaryExpr(*)
    NumberExpr(2)
    NumberExpr(3)

解释器就直接递归求值。

如果结果不对,问题范围通常只有三层:

复制代码
Parser 有没有生成正确 AST?
Interpreter 有没有正确执行节点?
Runtime Value 有没有正确表达结果?

而如果一开始就做 Bytecode VM,问题范围会扩大:

复制代码
Parser 是否正确?
AST 是否正确?
Bytecode Compiler 是否正确?
指令设计是否正确?
VM 栈操作是否正确?
跳转偏移是否正确?
调用帧是否正确?
Runtime 是否正确?

调试复杂度会明显上升。

所以更合理的路线是:

复制代码
先用 AST Interpreter 确认语言语义
再用同一批测试约束 Bytecode VM

未来 VM 做出来之后,可以让同一段源码分别走两条路径:

rust 复制代码
source -> Parser -> AST Interpreter -> result
source -> Parser -> Compiler -> Bytecode VM -> result

只要结果一致,就说明 VM 大概率对齐了 AST Interpreter 的语义。

换句话说,AST Interpreter 不只是第一版执行器,它还是后续 VM 的语义基准。

9. 面试里怎么讲这个设计?

如果面试官问:

你的解释器是怎么执行 AST 的?

可以这样回答:

我把 AST 分成表达式和语句两类。表达式会产生运行时值,所以用 Value evaluate(const Expr&);语句主要产生副作用或控制流效果,所以用 void execute(const Stmt&)。例如 if 的 condition 是表达式,要先 evaluate 成 truthy/falsy;then/else 分支是语句,所以用 execute。while 也是不断 evaluate 条件,再 execute body。let 会 evaluate 初始化表达式,然后 define 到当前 Environment。赋值是表达式,evaluate 右侧后更新环境并返回新值。block 会创建子 Environment,执行完恢复外层环境。函数 return 用一个内部信号从嵌套语句里穿透到函数调用处。

这个回答能体现几个关键点:

  • 你理解表达式和语句的语义区别。
  • 你知道解释器不是简单遍历 AST,而是在维护环境和控制流。
  • 你知道作用域、赋值、block、函数调用该怎么落到 Runtime 上。
  • 你知道 AST Interpreter 可以作为后续 Bytecode VM 的语义基准。

如果继续被追问:

为什么不让所有 AST 节点都返回 Value?

可以回答:

因为语句的主要语义不是产生值,而是改变环境或控制流。如果所有节点都返回 Value,就会把声明、block、if、while、return 这类控制结构伪装成普通表达式,解释器边界会变模糊。分成 evaluate 和 execute 后,哪些节点负责求值、哪些节点负责执行动作非常清楚,后续扩展也更稳。

总结

AST Interpreter 的核心不是"递归遍历一棵树"这么简单。

真正重要的是这套分工:

scss 复制代码
Expr -> evaluate() -> Value
Stmt -> execute()  -> side effect / control flow

表达式负责产生值。

语句负责执行动作。

let 修改环境。

assignment 更新环境并返回新值。

if evaluate 条件,execute 分支。

while 反复 evaluate 条件并 execute 循环体。

block 创建子作用域并恢复外层环境。

return 用控制流信号穿透嵌套语句。

这套设计让解释器结构清楚、调试路径短,也为后续 Bytecode VM 提供了可靠的语义参照。对于一个 Tiny JavaScript Engine 项目来说,这是最能体现解释器设计思维的一层。

相关推荐
触底反弹2 小时前
🧠 搞懂 Token,才算真正入门大模型——从分词原理到 Embedding 语义实战
javascript·人工智能·算法
等咸鱼的狸猫3 小时前
JavaScript 隐式类型转换:从入门到精通
javascript
kyriewen5 小时前
我用 Codex 重写了同事维护三年的代码,他没说谢谢——而是找了领导
前端·javascript·ai编程
铁皮饭盒5 小时前
S3已成为文件存储标准,阿里/腾讯/华为云都支持,Bun率先原生支持
前端·javascript·后端
Cobyte5 小时前
22.Vue Vapor 组件 props 的实现
前端·javascript·vue.js
浮生望7 小时前
JS字符串与回文算法:从包装类到双指针的面试进阶之路
javascript·算法
疯狂的魔鬼7 小时前
一套 Schema 驱动四视图:记 useCrudSchemas 的设计与实践
前端·javascript·typescript
weedsfly8 小时前
栈和堆:JavaScript 内存的“旅馆”和“仓库”
前端·javascript·面试
半个落月8 小时前
JavaScript 字符串面试题:反转、回文与双指针
javascript