在 Tiny JavaScript Engine 里,Lexer 和 Parser 负责把源码变成 AST。
但 AST 本身不会运行。真正让程序"动起来"的,是 Interpreter。
当前 MiniJS 项目的解释器采用 AST Interpreter,也就是直接遍历 AST 节点执行程序。它最核心的设计是把执行逻辑拆成两个入口:
arduino
Value evaluate(const Expr& expression);
void execute(const Stmt& statement);
一个负责表达式求值,一个负责语句执行。
这篇文章就讲清楚:为什么解释器要这样拆,Expr 和 Stmt 为什么分开,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;
};
把 Expr 和 Stmt 分开后,解释器的意图非常清楚:evaluate 表示"算出一个值",execute 表示"执行一个动作"。这也是很多解释器、编译器前端都会采用的设计。
这几乎是所有类C语言(Java、C++、Python、JavaScript、Rust等)语法推导的基石。
之所以要严格区分,是因为它们的语法规则和语义完全不同:
| 语句(Statement) | 表达式(Expression) | |
|---|---|---|
| 本质 | 执行一个动作 | 计算出一个值 |
| 返回值 | 不产生值(void) | 一定有一个返回类型(Type) |
| 示例 | if、for、return、变量声明 |
算术运算、函数调用、比较运算 |
| 语法特点 | 以分号或大括号结尾,不能作为其他表达式的组成部分 | 可以嵌套,能出现在赋值号右侧或函数参数中 |
在解释器实现中,这种区分的价值体现在处理逻辑完全不同:
- 表达式 →
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);
然后某些节点返回值,某些节点返回空值,某些节点只产生副作用,某些节点还要控制跳转。短期看函数少了,长期看语义反而糊了。
把 Expr 和 Stmt 分开之后,解释器的意图非常清楚:
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++ 的 int、double 或 bool,而是统一的运行时值:
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 项目来说,这是最能体现解释器设计思维的一层。