Join Nostr
2026-09-12 06:34:03 UTC

yfaming on Nostr: 今天看完了 Crafting Interpreters 第 28 章,Methods and Initializers。 ...

今天看完了 Crafting Interpreters 第 28 章,Methods and Initializers。

这一章实现了类的方法。

## 方法声明
我们为 `ObjClass` 添加字段 `Table methods` 用于保存类声明的方法。table 的 key 是方法名,value 是 `ObjClosure`。

具体地,先通过 `OP_CLASS` 指令声明类,它是空的,不包括任何方法。然后把对应的 `ObjClass` 放到栈上。接下来,对于每个方法,编译得到 `ObjClosure` 并放到栈上。并通过 `OP_METHOD` 指令定义为方法。执行 `OP_METHOD` 时,会将 `ObjClosure` 放到类的 `methods` table 里。在类声明结束时,还需要添加一条 `OP_POP` 指令,将类对应的 `ObjClass` 从栈上清理掉。

## 方法和访问和调用
在 Lox 中,我们可以通过 `ins.method()` 进行方法调用,也可以通过 `var f = ins.method` 将方法赋值给一个变量。这里的关键是,`ins.method` 的值不仅仅是一个函数(或 closure),它还需要「记得」它绑定的实例是哪一个。

我们通过 `ObjBoundMethod` 来表示一个绑定了实例的方法。它有一个 receiver 字段表示绑定的实例,还有一个 method 字段表示对应的方法。

我们用 `OP_GET_PROPERTY` 指令访问实例的字段和方法。执行时,优先按名字查找字段,没有的话则查找方法。找到方法后,生成 `ObjBoundMethod` 对象放到栈上。

然后,我们通过 `OP_CALL` 指令来调用 ObjBoundMethod。至此,我们可以当作函数调用的对象包括 `ObjBoundMethod`、`ObjClass`、`ObjClosure` 和 `ObjNative`。

## `this`
方法内部需要通过 `this` 访问当前实例。clox 支持 `this` 的方式非常巧妙,甚至有点 hack。

编译时,将 `this` 当作方法里定义的局部变量。开始编译方法时,将 `this` 作为第一个局部变量,放到方法的局部变量数组里。使用 `this` 时,则视为普通的变量进行 resolve。

运行时,由于 `this` 是第一个局部变量,因此需要把它放到方法调用的 CallFrame 的第一个 slot 里面。形如 `instance.method()` 进行方法调用时,被调用的是上面定义的 `ObjBoundMethod`。执行 `OP_CALL` 时,如果被调用的是 `ObjBoundMethod`,则将 `ObjBoundMethod.receiver` 即当前实例,放到参数列表中第一个参数的下面,并使得它的此次调用对应的新的 CallFrame 的第一个 slot,与 `this` 对应。

这个 slot 之前保存的是被调用的函数/方法对象。好在被覆盖也没什么后果。

在进行函数/方法调用时,CallFrame 及操作数栈是相互配合的细节,非常重要。我发现,读到这一节,才意识到 clox 在函数调用时,操作数栈的细节处理问题。
调用开始时,栈的内容为:

```
<fn> | arg1 | arg2 ... | argN
```
`CallFrame.slots` 指向 `<fn>`。操作数栈的 top 指向 `argN` 的后面一个位置。
注意这里,clox 把第 0 个位置放上了 `<fn>` 而不是 Lox 代码里定义的局部变量。事实上在编译函数时,clox 在用户代码之前,先手动往局部变量数组里添加了一个元素来占位。

如果是方法,则将 `this` 定义为第一个局部变量,即 `arg0`,并把对应实例放到 `<fn>` 位置。即形成:

```
<instance> | arg1 | arg2 ... | argN
```
当函数调用结束后,返回值放到 `<fn>`/`<instance>` 的位置。操作数栈的 top 则指向下一个位置。


## `init`

类的初始化方法 `init` 需要特殊处理。
创建实例时,我们是把类当作函数调用的。在这段逻辑中,我们还要检查类是否定义了 `init` 方法,如果定义了,则继续调用 `init` 以完成初始化。进一步,我们要限制 `init` 的返回值,避免返回其他东西,保证调用完成后,留在栈顶的是类的实例。

优化方法调用的性能
目前方法调用需要两条指令,比较慢。第一条是用 `OP_GET_PROPERTY` 访问方法,第二步是 `OP_CALL` 发起调用。但方法调用是高频操作,我们可以在编译时将它识别出来,并为它生成一条新的指令,专用于方法调用,并提高性能。

虚拟机经常会连续执行相同的字节码指令序列。一种经典的优化技术是定义一条新的指令,称为超级指令,它将这些指令融合为一条与整个序列行为相同的指令。这样可以减少 decode 与 dispatch 开销。

在编译时,当我们遇到 `.` 时,多进行一步检查,如果发现下一个 token 为 `(` 即可确定为方法调用,于是我们生成 `OP_INVOKE` 指令。而不是像之前那样,生成 `OP_GET_PROPERTY` 和 `OP_CALL` 两条指令。

`OP_INVOKE` 用一条指令完成 `OP_GET_PROPERTY` 和 `OP_CALL` 两条指令的工作,因此可以提高性能。

这里还有更重要的原因,之前的 `OP_GET_PROPERTY` + `OP_CALL` 方案中,执行 `OP_GET_PROPERTY` 指令时,需要为 `ObjBoundMethod` 对象分配内存,而 `OP_INVOKE` 方案是不需要进行内存分配的。
接下来,开始 Crafting Interpreters 第二部分,用 C 实现的 Lox 字节码解释器 clox。
在第一部分,用 Java 实现的 tree-walk interpreter jlox 中,我们完整实现了 Lox 解释器,包括 scanner、parser、resolver、interpreter 等等。
但是,第一部分的解释器利用了 Java 语言的许多特性。
比如,Lox 的值,全部用 Java 的 Object 类型表示。
Lox 里的 return,用 Java 的异常来实现。
而 Java 自带 GC,所以 Lox 中对象的生命周期,我们也完全没考虑过。

在第二部分,我们将使用 C 语言实现解释器。C 比 Java 更加 low-level,它不支持 OOP,不支持异常和 GC。
因此,这几个问题,我们就需要在 C 里面手动处理了。
而且,第二部分我们实现的是字节码解释器。我们需要定义字节码,并在 parsing 将 ast 编译为字节码。


用不同语言实现同一门语言,但采用略有不同的技术,可以加深我们的理解,并学习 compiler/interpreter 领域的不同主题。