Environment(环境)和作用域链(Scope Chain)共同解决了 JS 最核心的三个设计难题:变量存储、变量隔离、和变量查找。本篇与下一篇函数系统有较高相关性。
在解释器里,表达式求值、函数调用、闭包、递归都离不开一个基础设施:Environment。
它负责回答几个非常朴素但关键的问题:
ini
let x = 10; 这个 x 存在哪里?
x + 1; 读取 x 时去哪里找?
{ let x = 2; } 里面的 x 会不会影响外面的 x?
{ x = 3; } 这种赋值应该改当前作用域,还是外层作用域?
y; 如果变量不存在,怎么报错?
这些问题看起来像语言细节,但其实非常有工程味。因为同样的模型可以迁移到很多地方:
- 脚本引擎
- 配置语言
- 模板引擎
- 数据库表达式执行器
- 规则引擎
- DSL 解释器
只要你需要"名字 -> 值"的查找,并且有嵌套作用域,就会遇到 Environment。
1. Environment 是什么?
最简单地说,Environment 是一张变量表。
它保存:
| 变量名 | 运行时值 |
|---|
在 C++ 里,可以用 unordered_map 表达:
c
std::unordered_map<std::string, Value> values_;
比如执行:
ini
let x = 10;
let y = 20;
当前环境里大概就是:
| x | 10 |
|---|---|
| y | 20 |
但只有一张表还不够。
因为语言里会有嵌套作用域:
ini
let x = 1;
{
let y = x + 1;
}
块里面要能读到外层的 x,但块里面声明的 y 不能泄漏到外面。
所以 Environment 还需要一个 parent,指向外层环境。
当前项目里是这样写的:
c
class Environment {
public:
Environment();
explicit Environment(std::shared_ptr<Environment> parent);
void define(std::string name, Value value);
Value get(const std::string& name) const;
void assign(const std::string& name, Value value);
private:
std::shared_ptr<Environment> parent_;
std::unordered_map<std::string, Value> values_;
};
概念上,你也可以把它理解成:
arduino
Environment* parent
unordered_map<string, Value> values
2. 作用域链:一层找不到,就去外层找
多个 Environment 可以串成一条链:
rust
Block Environment
values:
y -> 2
parent -> Global Environment
values:
x -> 1
这就是作用域链。
变量查找时,规则很简单:
javascript
先查当前 Environment
当前没有,就查 parent
parent 没有,就继续向外
一直找到根环境
如果还没有,报 undefined variable
代码也很直接:
vbnet
Value Environment::get(const std::string& name) const {
const auto it = values_.find(name);
if (it != values_.end()) {
return it->second;
} else if (parent_) {
return parent_->get(name);
}
throw RuntimeError("undefined variable: " + name);
}
例如:
ini
let x = 1;
{
let y = x + 1;
y;
}
执行到 x + 1 时,当前 block 环境里没有 x,于是去 parent,也就是全局环境里找,找到 x = 1。
所以 y 最终是 2。
3. define:声明变量只写当前环境
let 声明使用 define()。
c
void Environment::define(std::string name, Value value) {
values_[std::move(name)] = value;
}
它的规则是:
只写当前 Environment
不向 parent 查找
为什么?
因为声明变量是在当前作用域创建新绑定。
例如:
ini
let x = 1;
{
let x = 2;
x;
}
x;
外层的 let x = 1 写入全局环境:
makefile
Global:
x -> 1
进入块之后,创建新的 block 环境:
vbnet
Block:
parent -> Global
块里的 let x = 2 写入当前 block 环境:
rust
Block:
x -> 2
parent -> Global:
x -> 1
所以块里面读 x,读到的是 2。
离开块之后,当前环境恢复成全局环境,再读 x,读到的是 1。
这就是变量遮蔽。
4. 变量遮蔽:同名变量,内层优先
变量遮蔽的意思是:内层作用域可以声明一个和外层同名的变量,读取时优先读内层。
例子:
ini
let x = 1;
{
let x = 2;
x; // 2
}
x; // 1
查找过程是:
sql
在 block 内读 x:
Block 有 x -> 返回 2
不再继续查 Global
离开 block 后读 x:
当前是 Global
Global 有 x -> 返回 1
遮蔽不是覆盖。
内层的 x 和外层的 x 是两个不同环境里的两个绑定。
可以把它想象成两张表里都有同一个 key:
scss
Block.values_["x"] = 2
Global.values_["x"] = 1
查找时从近到远,所以内层赢。
这也是为什么 define() 不能一路向外找。否则 let x = 2 就会把外层 x 改掉,遮蔽就不存在了。
5. block 如何创建块级作用域?
代码块会创建新的 Environment。
比如:
ini
{
let y = 10;
y;
}
解释器执行 block 时,会做三件事:
markdown
1. 创建新的 block_environment,parent 指向当前 environment
2. 临时把当前 environment 切换成 block_environment
3. 执行完 block 后,恢复之前的 environment
当前解释器里大概是:
ini
if (const auto* blockStmt = dynamic_cast<const BlockStmt*>(&statement)) {
auto block_environment = std::make_shared<Environment>(environment_);
executeBlock(blockStmt->statements(), block_environment);
return;
}
executeBlock() 负责切换和恢复:
ini
void Interpreter::executeBlock(const Program& statements,
std::shared_ptr<Environment> environment) {
auto previous = environment_;
environment_ = environment;
try {
for (const StmtPtr& inner : statements) {
if (inner) {
execute(*inner);
}
}
} catch (...) {
environment_ = previous;
throw;
}
environment_ = previous;
}
这里有一个很重要的细节:catch (...) 里也会恢复环境。
为什么?
因为 block 里面可能发生运行时错误,也可能出现 return 这种控制流信号。
无论怎么离开 block,都不能让解释器的 environment_ 永远停在错误的内层环境里。
这就是解释器状态管理里很工程化的一点。
6. 块级作用域为什么不会泄漏变量?
因为 block 使用新的 Environment,所以里面声明的变量不会泄漏出去。
例如:
ini
{
let y = 10;
}
y;
执行过程是:
yaml
进入 block:
创建 Block Environment
define y = 10
离开 block:
恢复到外层 Environment
读取 y:
外层找不到 y
没有更多 parent
报 undefined variable: y
这就是为什么这种代码应该报错:
javascript
RuntimeError: undefined variable: y
如果 block 不创建新 Environment,而是直接复用外层环境,那么 y 就会泄漏出去。
这会让语言作用域变得混乱。
7. assign:赋值要向外层查找已有变量
声明变量和赋值变量是不一样的。
声明:
ini
let x = 1;
是在当前环境创建新绑定。
赋值:
ini
x = 3;
是修改一个已经存在的绑定。
所以 assign() 的规则和 define() 不一样。
assign() 会沿着作用域链向外找,找到哪个已有绑定,就修改哪个:
c
void Environment::assign(const std::string& name, Value value) {
const auto it = values_.find(name);
if (it != values_.end()) {
it->second = value;
return;
} else if (parent_) {
parent_->assign(name, value);
return;
}
throw RuntimeError("undefined variable: " + name);
}
看例子:
ini
let x = 1;
{
x = 3;
}
x;
进入 block 后,当前环境里没有 x。
执行 x = 3 时:
ini
Block 找 x:
没有
Parent Global 找 x:
找到 x
修改 Global.x = 3
所以最后 x 的值是 3。
这就是"向外层作用域赋值"。
8. 遮蔽之后赋值,会改谁?
再看一个更容易混淆的例子:
ini
let x = 1;
{
let x = 2;
x = 3;
}
x;
这里最后的 x 是多少?
答案是 1。
原因是块里已经有自己的 x:
makefile
Block:
x -> 2
Global:
x -> 1
执行块里的 x = 3 时,assign() 从当前环境开始找。
它在 block 里就找到了 x,所以直接修改 block 里的 x:
makefile
Block:
x -> 3
Global:
x -> 1
离开 block 后,block 环境不可见。
所以外层 x 仍然是 1。
这说明赋值不是"永远改全局",也不是"永远改当前作用域"。
它的规则是:
从当前作用域开始,找到最近的已有绑定,然后修改它。
9. undefined variable 如何报错?
如果变量读取一路查到最外层还找不到,就应该报错。
例如:
ini
x + 1;
查找过程是:
当前 Environment 没有 x
没有 parent
报错
代码在 get() 的最后:
arduino
throw RuntimeError("undefined variable: " + name);
赋值也一样:
ini
x = 1;
如果 x 从来没有被声明过,那么 assign() 也会一路向外查找,最后报:
javascript
RuntimeError: undefined variable: x
这点很重要。
如果解释器在赋值时自动创建变量,就会变成另一种语言语义:
ini
x = 1; // 自动变成全局变量?
JavaScript 的历史里确实有类似复杂行为,但对一个 Tiny JS 来说,明确要求先 let 声明会更简单,也更适合控制语言边界。
10. 函数调用也依赖 Environment
Environment 不只服务于 block,也服务于函数调用。
例如:
css
function add(a, b) {
return a + b;
}
add(1, 2);
函数调用时会创建新的调用环境:
ini
auto call_environment = std::make_shared<Environment>(function.closure);
然后把参数绑定进去:
makefile
call_environment->define("a", 1);
call_environment->define("b", 2);
函数体执行时,当前环境切换到这个 call environment。
所以函数里的 a、b 都是本次调用自己的局部绑定。
递归也靠这个机制:
scss
function fact(n) {
if (n <= 1) {
return 1;
}
return n * fact(n - 1);
}
每次调用 fact 都会有一个新的环境:
scss
fact(5): n -> 5
fact(4): n -> 4
fact(3): n -> 3
这些 n 互不污染。
11. 闭包为什么让 Environment 需要更长生命周期?
如果只有普通 block,环境可以在 block 结束后销毁。
但闭包会让环境活得更久。
例如:
ini
function makeCounter() {
let count = 0;
function next() {
count = count + 1;
return count;
}
return next;
}
let c = makeCounter();
c();
makeCounter() 已经返回了,但 next 还要继续访问 count。
所以 count 所在的 Environment 不能随着 makeCounter() 结束就消失。
这也是当前项目用 std::shared_ptr<Environment> 的原因:
ini
FunctionValue
declaration -> next 的 AST
closure -> Environment(count = 0)
只要 next 这个函数值还活着,它捕获的 Environment 就会活着。
后续如果做 GC,则需要进一步解决引用环和统一堆管理问题。
12. 这套模型为什么可迁移?
Environment 本质上是一个很通用的名字解析模型。
不只是 JavaScript 引擎会用。
模板引擎也会用:
{{ user.name }}
它需要从当前上下文查 user。
配置语言也会用:
ini
timeout = baseTimeout + retryDelay
它需要在当前配置作用域和父作用域里查变量。
数据库表达式执行器也会用:
price * quantity
它需要从当前行、外层查询上下文或参数环境里查名字。
规则引擎也会用:
if user.age > 18 then allow
它需要把 user 解析成运行时输入对象。
所以,Environment 不只是解释器里的一个小类,而是一种通用的"名字绑定 + 作用域链"设计。
13. 面试里怎么讲?
如果面试官问:
你的解释器里变量作用域怎么实现?
可以这样回答:
我用 Environment 表示词法环境。每个 Environment 内部有一个 unordered_map<string, Value> 保存当前作用域的变量绑定,同时有一个 parent 指向外层环境。变量读取时先查当前环境,找不到就沿 parent 向外查;声明变量时只写当前环境;赋值时沿作用域链查找最近的已有绑定并更新;如果读或写一路查不到,就抛出 undefined variable 运行时错误。
如果继续问:
块级作用域怎么实现?
可以回答:
执行 block 时创建一个新的 Environment,它的 parent 指向当前环境。然后临时把解释器的 current environment 切换到这个新环境,执行完 block 后恢复原环境。这样 block 内可以访问外层变量,但 block 内 let 声明的变量不会泄漏出去。
如果问:
变量遮蔽怎么实现?
可以回答:
遮蔽其实是自然发生的。因为 let 只在当前 Environment 里 define,如果内层和外层有同名变量,读取时会先命中内层 Environment,于是内层变量遮蔽外层变量。离开内层作用域后,当前环境恢复到外层,所以外层变量不受影响。
如果问:
为什么 { x = 3; } 能修改外层变量?
可以回答:
因为赋值不是 define,而是 assign。assign 从当前 Environment 开始查,如果当前没有 x,就递归去 parent 里找。找到最近的已有绑定后更新它。所以当 block 里没有声明 x,但外层有 x 时,赋值会修改外层的 x。
如果问:
这套模型有什么工程价值?
可以回答:
它不只适用于 JS 解释器。任何需要名字解析和嵌套上下文的系统都可以用类似模型,比如模板引擎、配置语言、规则引擎、数据库表达式执行器。核心都是一张当前作用域的 map,加一条 parent 链。
总结
Environment 的设计很小,但它撑起了语言运行时里非常多的能力。
它的核心只有两部分:
makefile
values_: 当前作用域的变量表
parent_: 外层作用域
然后围绕它定义三种操作:
makefile
define: 只写当前作用域
get: 当前找不到就向外找
assign: 修改最近的已有绑定,找不到就报错
块级作用域靠创建新的 Environment。
变量遮蔽靠内层 Environment 优先查找。
向外层赋值靠 assign() 沿 parent 链递归。
undefined variable 靠查找失败后统一抛出运行时错误。
闭包则进一步要求 Environment 有更长的生命周期。
掌握这套模型之后,解释器里的变量、作用域、函数调用和闭包都会变得清晰很多。它也不是只能用于编译原理项目,而是一种可以迁移到很多工程系统里的基础设计。
(今天依旧十分焦虑。