这一章支持了 class。作为动态类型语言,Lox 的 class 与 Python 的有些相似。用 `class` 声明类,用 `init` 方法初始化实例。
为此,增加了声明类的文法规则,并增加了 Get 表达式以支持访问实例的字段(和方法),增加了 Set 表达式以支持对实例的字段赋值。同时还支持了 This 表达式,以表示 `this`。
类用 `LoxClass` 表示,它保存着类的名字以及方法列表。方法用现成的 `LoxFunction` 表示。
同时 `LoxClass` 也实现了 `LoxCallable` 接口,以便创建类的实例。
类的实例用 `LoxInstance` 表示,它保存着所属的类,及字段值。
对于 `this` 的支持值得一提。
我们知道,Resolver 中的 scope 链,与 Interpreter 中的 Environment 链必须保持一致。scope 与 Environment 本质是同一个东西,只不过 scope 是编译时的,Environment 是运行时的。
Resolver 处理 class 声明时(`visitClassStmt`),创建了新的 scope,并将 this 定义在新的 scope 里。这也意味着,class 的 method 也是在新的 scope 中定义的。
但 `Interpreter.visitClassStmt` 却没有创建新的 Environment。而是在访问实例的属性时(`visitGetExpr`),如果这个属性对应的是一个 method,就调用 `LoxFunction.bind` 方法。`LoxFunction.bind` 会以 method 的 closure 为 enclosing Environment 创建新 Environment,把 this 绑定为对应实例, 然后组装为新的 LoxFunction 并返回。
这样的话,类的方法调用时,Environment 就与 Resolver 的 scope 一致了,而且确定了 this 所指向的值。
具体地,形如 `ins.method()` 的方法调用,`ins.method` 被 parse 为 `Expr.Get`。对 `ins.method` 求值,即对 `Expr.Get` 求值,就会执行这里所说的逻辑。得到新的 LoxFunction 后,再进行函数调用,就和其他函数没有什么区别了。
关于创建类的实例,其实包含两个步骤。
第一步是真正的创建实例,得到 LoxInstance,它是由 LoxClass 负责的。LoxClass 实现了 LoxCallable 接口,调用时返回一个 LoxInstance。
第二步是初始化,由用户提供的 init 方法完成。为了方便使用,Lox Interpreter 确保 init 方法返回实例,且禁止它返回其他值。
至此,本书第一部分,用 Java 实现的 tree-walk interpreter 只剩下最后一章 Inheritance 了。而我敲下的代码,刨除注释和空行,只有 1916 行。神奇!
quoting今天终于看完了 Crafting Interpreters 第 11 章,Resolving and Binding。
nevent1q…2g7k
在第 10 章 Functions 的实现存在 bug,并不是严格的静态作用域。
```
var a = "global";
{
fun showA() {
print a;
}
showA();
var a = "block";
showA();
}
```
按照静态作用域规则,这段代码应当打印两次 global。但第 10 章的实现,却是先打印 global,然后打印 block。
本章定义了专门的 Resolver pass 来解决这个问题。
Pass,是指对「程序表示」进行一次相对独立的分析或转换过程。这里「程序表示」可以是 token、AST、HIR/MIR、CFG、SSA IR、机器指令表示,等等。一个 pass 通常接收一种程序表示,对其进行遍历、分析或修改,然后把结果交给下一个 pass。
本章实现的 name resolution 是 compiler 的一个典型 pass。其他 pass 还包括类型检查、各种优化等等。
name resolution 是指,确定名字具体指向哪个声明的过程。即,代码中的这个名字(变量名、函数名、类名等),到底指的是谁?
此前的 Lox 实现,是在运行时进行 name resolution 的。声明变量时,将其名字和值写入 Environment。后续使用时,顺着 Environment 链进行查找。即,每次使用变量时,都要进行一次 name resolution(但其实现有 bug)。但是,静态作用域意味着,变量的使用总是指向同一个声明,这只需查看代码文本即可确定。更好(性能更好)的解决方案是,每次变量使用只解析一次。
Resolver 在 parsing 之后、执行之前运行。Resolver 以 visitor pattern 访问 ast,并确定定义变量的作用域。并记录下来,供运行时使用。(Resolver 运行在 compile-time,不会受 run-time 信息的干扰,所以,上面那个 bug 就不会发生了。)
具体地,Resolver 访问 ast,并记录进入和退出作用域的动作(语句块、函数定义),构建 scope 链。这个 scope 链和 Interpreter 的 Environment 链是一一对应的。只不过 scope 是 compile time 的,Environment 是 runtime 的。
- 当遇到声明变量(函数)语句时,就在当前 scope 里记录下这个变量(函数)。
- 当遇到赋值表达式和变量表达式时,顺着 scope 链查找变量是在哪个 scope 中定义的,并记录下 distance。distance 表示顺着 scope 链查找多少步,才能找到定义变量的 scope。到了运行时,顺着 Environment 链查找 distance 步,就能找到变量定义的在的 Environment,从而查找到变量的值了。
而到了运行时,Interpreter 遇到赋值表达式和变量表达式时,就根据记录下来的 resolution 信息(distance),确定变量所属的 Environment,然后读写变量的值。
Resolver 的核心逻辑就这几条,算是简单。但它需要实现 Stmt 和 Expr 的 visitor,不得不实现许多方法了。
需要提一下的是,全局作用域不包含在 Resolver 构建的 scope 链中。所以,如果 Interpreter 运行时找不到变量,需要到 global Environment 里面再找一次。
但是 Environment 链是包含了全局 Environment 的,没太明白为何搞得不一致。而且,我的理解,如果 scope 链包含了全局 scope 的话,就可以在 Resolver 中识别出「使用未定义的变量」这种错误了。
另外,Resolver 还顺手添加了一个检查,确保 return 语句不能出现在函数外面。
最后,问了一下 ChatGPT,对于 tree-walk interpreter 来说,name resolution 比较优秀做法是怎样的。它的答案是,局部变量用 `(depth, slot)` 二元组表示。这里 `depth` 对应书中的 distance。而 slot,则需要将 Environment 里采用数组来保存变量和值的对应关系,而不是用 HashMap。本章结尾的 Challenges 题目的第 4 题,说的正是这个方法。
ChatGPT 还提到,EOPL 第 3.7 节 Implementing Lexical Addressing,SICP 5.5.6 节 Lexical Addressing,都在讲这个主题。
nevent1q…zydw
