解释器:环境与变量查找
本节阅读量:
第一章的解释器只需要递归求值:
1
2
|
Int(n) -> n
Add(a, b) -> eval(a) + eval(b)
|
第二章加入变量后,解释器不能只看当前节点。遇到 Var(x) 时,它还要知道:
解释变量需要的这份名字上下文叫 environment,环境。
环境是什么
本章的环境不是一张只保留最终结果的普通表,而是一串按照作用域进入顺序排列的绑定:
代码在:
1
|
code/02_let/src/interp/interpreter.cpp
|
环境类型写成:
1
|
using Env = std::vector<std::pair<std::string, long>>;
|
每一项都把一个源码名字和当前运行时整数联系起来:
这里选择 vector,是因为 push_back、pop_back 和反向查找可以直接表现“进入作用域、离开作用域、优先使用最近绑定”这三件事。unordered_map 也能通过额外保存旧绑定或建立作用域链来实现 shadowing,只是没有当前写法直观。
lookup 从后往前找
变量引用的求值规则是:
1
|
eval(Var(name), env) = lookup(env, name)
|
对应代码:
1
2
3
4
5
6
7
8
|
long lookup(const Env& env, const std::string& name) {
for (auto it = env.rbegin(); it != env.rend(); ++it) {
if (it->first == name) {
return it->second;
}
}
throw std::runtime_error("undefined variable: " + name);
}
|
重点是从后往前找。
如果环境是:
后面的 x -> 32 更近,所以 lookup(x) 应该得到 32。这就是同名遮蔽的基础。
如果一路没找到,就说明变量没有绑定:
eval_expr 多了环境参数
第一章的 eval 只接收一个 Expr。第二章内部多了一个辅助函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
long eval_expr(const Expr& expr, Env& env) {
switch (expr.kind) {
case ExprKind::integer: {
const auto& int_expr = static_cast<const IntExpr&>(expr);
return int_expr.value;
}
case ExprKind::add: {
const auto& add_expr = static_cast<const AddExpr&>(expr);
long lhs = eval_expr(*add_expr.lhs, env);
long rhs = eval_expr(*add_expr.rhs, env);
return lhs + rhs;
}
case ExprKind::variable: {
const auto& var_expr = static_cast<const VarExpr&>(expr);
return lookup(env, var_expr.name);
}
case ExprKind::let: {
const auto& let_expr = static_cast<const LetExpr&>(expr);
long value = eval_expr(*let_expr.value, env);
env.push_back({let_expr.name, value});
long result = eval_expr(*let_expr.body, env);
env.pop_back();
return result;
}
}
throw std::runtime_error("unknown expression kind");
}
|
这里仍然沿用第一章的 kind + switch 风格。每一种表达式都有明确分支。
加法分支把两次递归调用拆成了两条语句,因此解释器明确先求左边,再求右边。这和 IR lowerer 的遍历顺序一致,也为后面加入可变状态时保留了确定的求值顺序。
变量引用怎样求值
完整函数里的变量分支只做两件事:
1
2
|
1. 从 VarExpr 里拿出变量名。
2. 调用 lookup,在当前环境里查它的值。
|
VarExpr 本身不保存值。解释器每次遇到变量引用,都要根据当前环境重新查找,所以同一个名字在不同作用域里可以得到不同结果。
let 的求值顺序
let 的规则是:
1
2
3
4
5
6
|
eval((let x value body), env):
v = eval(value, env)
把 x -> v 放进 env
result = eval(body, env)
弹出 x -> v
return result
|
对照前面的完整 eval_expr(),这里有两个要点。
第一,value 在旧环境里求值:
1
|
long value = eval_expr(*let_expr.value, env);
|
这时新绑定还没有放进环境。
第二,body 求完以后要 pop_back():
这表示离开这个 let 的作用范围。内层绑定不应该继续影响外层。整个过程恰好对应 AST 的嵌套结构:进入 LetExpr::body 前压入绑定,body 返回后立即弹出。
这里修改的是解释器运行时使用的 Env。它只负责名字查值,与编译器里的 IR 名字和目标程序栈槽还不是一回事。
一个完整例子
程序:
求值过程:
1
2
3
4
5
6
7
8
|
eval value: 40 -> 40
push x -> 40
eval body: (+ x 2)
eval x -> lookup x -> 40
eval 2 -> 2
40 + 2 -> 42
pop x -> 40
return 42
|
运行:
1
2
3
|
cd code/02_let
make
./mini run examples/let.lang
|
输出:
同名遮蔽怎样求值
再看一个有两个同名绑定的程序:
1
|
(let x 10 (+ (let x 32 x) x))
|
求值过程是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
eval 外层 value: 10 -> 10
push x -> 10
eval 外层 body: (+ (let x 32 x) x)
eval 左边: (let x 32 x)
eval 内层 value: 32 -> 32
push x -> 32
eval 内层 body: x
lookup 从后往前找到 x -> 32
pop x -> 32
左边结果是 32
eval 右边: x
lookup 找到外层 x -> 10
32 + 10 -> 42
pop x -> 10
return 42
|
内层绑定存在时,环境里暂时有两个 x:
lookup 从后往前找,所以内层 body 得到 32。离开内层 let 后,pop_back() 移除 x -> 32,外层的 x -> 10 又重新可见。
运行:
1
|
./mini run examples/shadow.lang
|
输出:
对外的 eval
对外接口仍然保持简单:
1
2
3
4
|
long eval(const Expr& expr) {
Env env;
return eval_expr(expr, env);
}
|
调用者不需要自己准备环境。完整程序开始求值时,环境是空的。
所以直接运行未绑定变量:
会在 lookup 里报错。这是正确行为:变量名只有进入某个 let 的 body 后才有值。
检查一下环境规则
读完这一节,应该能解释下面三个时刻的环境为什么不同:
1
2
3
|
计算 let 的 value 新绑定还不存在
计算 let 的 body 新绑定位于环境末尾,最先被 lookup 找到
离开 let 新绑定已经 pop,外层绑定重新可见
|
2.3 Parser:读出 identifier 和 let
上一节