卷 IV · 义CH 12深度 12/20

语句、状态与环境

上一章的求值器是个大计算器 —— 能算 1 + 2 * 3,但记不住任何东西。要让它成为一门真正的语言,得让它能存变量、读变量、改变量。这需要两样新东西:语句(产生效果,而不只是产生值)和环境(变量住的地方)。而环境的真身,朴素得让你安心:一张哈希表,加一根指向外层的指针。

语句变量环境作用域

表达式 vs 语句:产值 vs 产效果

先厘清一个你天天用、却未必精确说过的区别:

  • 表达式(expression)算出一个1 + 2a * bfoo()。它的意义在于「等于多少」。
  • 语句(statement)产生一个效果print x;(打印)、var a = 1;(声明变量)、{ … }(一段代码)。它的意义在于「做了什么」,本身不「等于」什么。

所以求值器要长出第二条腿。除了会 eval(表达式) → 值,还得会 exec(语句) → 干件事

function exec(stmt) {
  switch (stmt.k) {
    case "print":                       // 打印语句:算出表达式,输出
      console.log(stringify(eval(stmt.expr)));
      return;
    case "expr":                        // 表达式语句:算了,但丢掉值(为了副作用)
      eval(stmt.expr);
      return;
    case "varDecl":                     // 变量声明:算出初值,存进环境
      environment.define(stmt.name.lexeme, stmt.init ? eval(stmt.init) : null);
      return;
    // ……block / if / while / 函数,后面几章补上
  }
}

环境:一张哈希表,就这么简单

environment.define(...) 把变量存哪了?存进环境(Environment)—— 一个把「名字」映射到「值」的东西。剥开看,它就是一张哈希表

class Environment {
  constructor() {
    this.values = new Map();   // 名字 → 值。核心就这一张表
  }
  define(name, value) {        // 声明一个新变量
    this.values.set(name, value);
  }
  get(name) {                  // 读一个变量
    if (this.values.has(name)) return this.values.get(name);
    throw runtimeError(`用了没定义的变量「${name}」`);
  }
  assign(name, value) {        // 给一个已存在的变量赋新值
    if (this.values.has(name)) { this.values.set(name, value); return; }
    throw runtimeError(`给没定义的变量赋值「${name}」`);
  }
}

「变量」听起来是个语言概念,落到实现,就是一张哈希表里的一个键值对。var a = 1; 就是往表里 set("a", 1);读 a 就是 get("a")a = 2 就是 set("a", 2)。注意 defineassign 的区别:定义可以创建新键,赋值必须键已存在(否则就是「给没声明的变量赋值」这个错)。

那根指针:作用域从哪来

一张表还不够。程序有嵌套的作用域 —— 花括号里能定义只在里面可见的变量,还能遮蔽外面的同名变量。这就是环境需要的第二样东西:一根指向「外层环境」的指针

class Environment {
  constructor(enclosing = null) {
    this.values = new Map();
    this.enclosing = enclosing;   // ← 指向外层。全局环境的它是 null
  }
  get(name) {
    if (this.values.has(name)) return this.values.get(name);
    if (this.enclosing) return this.enclosing.get(name);   // 本层没有?找外层
    throw runtimeError(`用了没定义的变量「${name}」`);
  }
}

加了这根指针,环境就串成了一条。查一个变量的规则变成:先翻自己这层的表,没有就顺着指针问外层,外层没有再问更外层,直到全局;全局也没有,才报错。这条「由内向外逐层查找」的链,就是作用域链

而进入一个 { } 代码块,就是新建一层环境、把它的外层指向当前环境;块结束,就整层丢弃

case "block":
  execBlock(stmt.statements, new Environment(this.environment));  // 叠一层新环境
  return;
// execBlock 执行完,那层环境没人引用,自然被丢弃 —— 块里的变量随之消失
◆ 遮蔽,是查找顺序的自然结果

为什么块里的 var a 能「遮住」外面的 a没有任何一行特殊代码在处理「遮蔽」这件事。它是查找规则的免费副产品:查 a 时先翻最内层的表,翻到了就直接返回、根本不会去问外层。块里那个 a 挡在了前面,外面的 a 就被「遮」住了。块一结束,内层表被丢弃,外面的 a 原封不动地重见天日。

一个概念(作用域遮蔽),落到实现是一个数据结构(带外层指针的哈希表链)加一条查找规则(由内向外)。这是这本书反复出现的快感:玄乎的语言特性,底下是朴素的机制。

看环境链长出来又缩回去

下面这台用求值器一句一句执行程序,每执行完一条语句,就画出当前的环境链(顶 = 最内层)。选「遮蔽」那个例子,一步步走:

看那个「遮蔽」例子:进入 { } 时,栈顶叠出一层新的块环境,里面那个 a 盖住了全局的 aprint a 打出的是块里的;块一结束,那层环境整个消失,最后的 print a 又打出全局的 a变量不住在名字里,住在环境里 —— 同一个名字 a,在不同环境里是两个不同的格子。

回流

⟲ 回流 · 你天天在用这条作用域链

你写 JS / Kotlin 时对「作用域」的所有直觉,底下就是这条环境链。

为什么内层函数能读到外层变量,反过来不行?因为查找是单向的 —— 只能顺着 enclosing 指针由内向外,没有反向的指针。内层的环境指得到外层,外层指不到内层。你觉得天经地义的「里面能看见外面、外面看不见里面」,本质就是这根指针的方向。

JS 的 let 块级作用域 vs var 的函数级作用域,差别就在「进 { } 要不要叠一层新环境」:let 叠,所以块里的 let 出块即失效;老 var 不叠(只认函数边界),所以它「泄漏」出块 —— 这个让无数人踩过坑的行为,用环境模型一看就通透。

你在调试器里看到的那个 Scope / Variables 面板(Local、Closure、Global 一层层列出来)—— 那画的就是这条环境链本人。你每天盯着它看,现在你知道它是怎么来的了。

这一章的一句话

变量住在环境里,而环境就是一张哈希表加一根指向外层的指针;作用域链、遮蔽,全是「由内向外逐层查找」这条规则的自然结果。

程序能记事了。下一章给它加上分叉和循环 —— ifwhile,以及一个精彩的小问题:为什么 if 不能是个普通函数。