作用域链与 Environment:变量查找、遮蔽和赋值怎么实现

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。

所以函数里的 ab 都是本次调用自己的局部绑定。

递归也靠这个机制:

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 有更长的生命周期。

掌握这套模型之后,解释器里的变量、作用域、函数调用和闭包都会变得清晰很多。它也不是只能用于编译原理项目,而是一种可以迁移到很多工程系统里的基础设计。

(今天依旧十分焦虑。

相关推荐
YHL1 小时前
🤖 Agent 智能体开发实战 · 第一课:Tool Use —— 让大模型自动干活
前端·javascript·人工智能
HUMHSX3 小时前
Vue 组件通信核心知识点梳理
javascript·vue.js·ecmascript
风止何安啊3 小时前
🚦 前端并发请求 “交通管制”:别让你的接口堵成早高峰
前端·javascript·面试
熊猫钓鱼>_>3 小时前
2026 前端的三场底层革命:当 Vue、React Native 和 Three.js 同时去掉中间层
前端·javascript·vue.js·react native·架构·webgl·three.js
名字还没想好☜3 小时前
React useEffect 依赖数组踩坑:闭包陷阱与清理函数
前端·javascript·react.js·react·useeffect
柒和远方5 小时前
LeetCode 4. 寻找两个正序数组的中位数 —— 二分划分的艺术
javascript·python·算法
默_笙5 小时前
😢 我被 JS 的 this 整不会了:同一个函数,换个“地区”调用,结果就不一样
javascript
Tom@敲代码5 小时前
js学习笔记-01
javascript·笔记·学习
木西5 小时前
告别机械重复:使用 Node.js + Playwright 构建 24 小时全自动测试网领水脚本
前端·javascript·node.js