章节目录

解释器:环境与变量查找

本节阅读量:

第一章的解释器只需要递归求值:

1
2
Int(n)       -> n
Add(a, b)    -> eval(a) + eval(b)

第二章加入变量后,解释器不能只看当前节点。遇到 Var(x) 时,它还要知道:

1
当前哪些名字是可见的?

解释变量需要的这份名字上下文叫 environment,环境。

环境是什么

本章的环境不是一张只保留最终结果的普通表,而是一串按照作用域进入顺序排列的绑定:

1
[x -> 40, y -> 2]

代码在:

1
code/02_let/src/interp/interpreter.cpp

环境类型写成:

1
using Env = std::vector<std::pair<std::string, long>>;

每一项都把一个源码名字和当前运行时整数联系起来:

1
[("x", 40), ("y", 2)]

这里选择 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);
}

重点是从后往前找。

如果环境是:

1
[x -> 10, x -> 32]

后面的 x -> 32 更近,所以 lookup(x) 应该得到 32。这就是同名遮蔽的基础。

如果一路没找到,就说明变量没有绑定:

1
undefined variable: x

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():

1
env.pop_back();

这表示离开这个 let 的作用范围。内层绑定不应该继续影响外层。整个过程恰好对应 AST 的嵌套结构:进入 LetExpr::body 前压入绑定,body 返回后立即弹出。

这里修改的是解释器运行时使用的 Env。它只负责名字查值,与编译器里的 IR 名字和目标程序栈槽还不是一回事。

一个完整例子

程序:

1
(let x 40 (+ x 2))

求值过程:

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
42

同名遮蔽怎样求值

再看一个有两个同名绑定的程序:

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:

1
[x -> 10, x -> 32]

lookup 从后往前找,所以内层 body 得到 32。离开内层 let 后,pop_back() 移除 x -> 32,外层的 x -> 10 又重新可见。

运行:

1
./mini run examples/shadow.lang

输出:

1
42

对外的 eval

对外接口仍然保持简单:

1
2
3
4
long eval(const Expr& expr) {
    Env env;
    return eval_expr(expr, env);
}

调用者不需要自己准备环境。完整程序开始求值时,环境是空的。

所以直接运行未绑定变量:

1
x

会在 lookup 里报错。这是正确行为:变量名只有进入某个 let 的 body 后才有值。

检查一下环境规则

读完这一节,应该能解释下面三个时刻的环境为什么不同:

1
2
3
计算 let 的 value   新绑定还不存在
计算 let 的 body    新绑定位于环境末尾,最先被 lookup 找到
离开 let            新绑定已经 pop,外层绑定重新可见

2.3 Parser:读出 identifier 和 let

上一节

2.5 IR:把用户名字变成内部名字

下一节