第 28 章:方法与初始化器(Methods and Initializers)
原文:Robert Nystrom, Crafting Interpreters, Chapter 28。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。
当你站在舞池里,除了跳舞,无事可做。
Umberto Eco,《洛阿娜女王的神秘火焰》
现在,该让虚拟机为刚诞生的对象赋予行为了:方法与方法调用,以及作为特殊方法的初始化器。这些概念在前面的 jlox 解释器中都见过;这次的新内容是一项重要优化,它会让方法调用比未优化的基准实现快七倍以上。不过先把基础功能做对。
28.1 方法声明(Method Declarations)
在优化调用之前,必须先有可调用的方法,因此先从声明开始。
28.1.1 表示方法(Representing methods)
这次先处理对象模型。和 jlox 类似,每个类保存一张方法哈希表:键是方法名,值是方法体对应的 ObjClosure。
typedef struct {
Obj obj;
ObjString* name;
Table methods;
} ObjClass;
新建类时,方法表为空;类拥有该表的内存,释放类时必须释放表。垃圾回收器也要追踪表中的键和值:只要类仍可达(通常是通过某个实例),它的方法当然也必须存活。
ObjClass* newClass(ObjString* name) {
ObjClass* klass = ALLOCATE_OBJ(ObjClass, OBJ_CLASS);
klass->name = name;
initTable(&klass->methods);
return klass;
}
case OBJ_CLASS: {
ObjClass* klass = (ObjClass*)object;
freeTable(&klass->methods);
FREE(ObjClass, object);
break;
}
case OBJ_CLASS: {
ObjClass* klass = (ObjClass*)object;
markObject((Obj*)klass->name);
markTable(&klass->methods);
break;
}
markTable() 已会追踪每个表项的键字符串和值。和 jlox 的不同在于:原解释器运行时能直接遍历类声明 AST 中的全部方法;如今编译器必须把任意数量的方法信息挤进一串扁平字节码指令中。
28.1.2 编译方法声明(Compiling method declarations)
上一章的类体只能为空。现在在花括号间编译一系列方法声明;Lox 没有字段声明,所以右花括号前的任何内容都是方法。额外检查 TOKEN_EOF,避免遗漏右花括号时无限循环。
consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
while (!check(TOKEN_RIGHT_BRACE) && !check(TOKEN_EOF)) {
method();
}
consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
一个类可以有任意多个方法,没必要把它们塞进单条 OP_CLASS。编译器先生成创建空类的 OP_CLASS,将其存入同名变量;然后每个方法生成一条 OP_METHOD,逐条把方法加到类上。用户看见的是原子的类声明,VM 实际执行的是一串变更。
闭包曾采用过可变长伪指令,把每个捕获的 upvalue 信息紧跟在 OP_CLOSURE 后。这里每条定义方法的指令是独立操作。两种方案都可行;类声明很少位于热点循环,性能差异无关紧要。
定义一个方法时,运行时需要三样东西:方法名、方法体闭包、以及要绑定到的类。编译器先把方法名加入常量表,生成 OP_METHOD;再复用已有 function() 编译参数与函数体,运行时闭包便位于栈顶。
static void method() {
consume(TOKEN_IDENTIFIER, "Expect method name.");
uint8_t constant = identifierConstant(&parser.previous);
FunctionType type = TYPE_METHOD;
if (parser.previous.length == 4 &&
memcmp(parser.previous.start, "init", 4) == 0) {
type = TYPE_INITIALIZER;
}
function(type);
emitBytes(OP_METHOD, constant);
}
还缺类对象。顶层类位于全局变量表,局部类则在栈上;OP_METHOD 执行时不能猜测其位置。不过编译器知道类名,且没有其他声明能遮蔽它。读完类名令牌后保存该令牌,在开始编译类体前调用 namedVariable(),把类重新载入栈顶;最后弹出它。
Token className = parser.previous;
defineVariable(nameConstant);
namedVariable(className, false);
consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
while (!check(TOKEN_RIGHT_BRACE) && !check(TOKEN_EOF)) {
method();
}
consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
emitByte(OP_POP);
defineVariable() 刚把类从栈上弹掉,又立刻加载回来似乎多余;下一章为继承插入代码后,类不一直留在栈上会更简单。执行每条 OP_METHOD 时,栈顶是闭包,下一格是类;到全部方法结束时,类不再需要。
class Brunch {
bacon() {}
eggs() {}
}

28.1.3 执行方法声明(Executing method declarations)
新增 opcode 并按含字符串常量操作数的指令反汇编:
OP_METHOD,
case OP_METHOD:
return constantInstruction("OP_METHOD", chunk, offset);
解释器读取方法名并交给一个辅助函数。闭包位于栈顶,类位于其下;把闭包放入类的方法表,然后弹出闭包即可。这里无需做运行时类型检查,因为该字节码只能由 clox 自己的编译器生成,VM 信任自己的编译器。
static void defineMethod(ObjString* name) {
Value method = peek(0);
ObjClass* klass = AS_CLASS(peek(1));
tableSet(&klass->methods, name, method);
pop();
}
case OP_METHOD:
defineMethod(READ_STRING());
break;
若 VM 可加载外部编译的字节码,恶意字节码可能使其崩溃甚至更糟,便不能如此信任。JVM 在运行前进行字节码验证;CPython 则把执行安全字节码的责任交给用户。
28.2 方法引用(Method References)
通常方法访问后立即调用:
instance.method(argument);
但这两步可分开:
var closure = instance.method;
closure(argument);
因此取属性必须返回一种以后仍可当函数调用的对象。直接返回类表中的闭包不够,因为 this 必须绑定为当初访问方法的实例。
class Person {
sayName() { print this.name; }
}
var jane = Person();
jane.name = "Jane";
var method = jane.sayName;
method(); // Jane
jlox 借现有的堆分配 Environment 记住接收者;clox 的局部变量和临时值在栈上,全局变量在哈希表中,闭包变量由 upvalue 保存,因此需要新的运行时类型。
28.2.1 绑定方法(Bound methods)
访问方法时,将方法闭包包进“绑定方法”对象,并同时保存接收者。调用该对象时,VM 会把接收者接到方法体的 this 上。这个名称取自 CPython;Python 的行为与 Lox 类似。
typedef struct {
Obj obj;
Value receiver;
ObjClosure* method;
} ObjBoundMethod;
ObjBoundMethod* newBoundMethod(Value receiver, ObjClosure* method) {
ObjBoundMethod* bound =
ALLOCATE_OBJ(ObjBoundMethod, OBJ_BOUND_METHOD);
bound->receiver = receiver;
bound->method = method;
return bound;
}
#define IS_BOUND_METHOD(value) isObjType(value, OBJ_BOUND_METHOD)
#define AS_BOUND_METHOD(value) ((ObjBoundMethod*)AS_OBJ(value))
接收者虽必为 ObjInstance,仍使用 Value,这样传给通用函数时无需反复把指针转回 Value。照例在对象类型枚举、释放、GC 标记和打印中加入分支:绑定方法不拥有接收者或闭包,所以只释放自己;但要标记两者。打印时和函数完全一样,因为对用户而言绑定方法就是可调用函数。
case OBJ_BOUND_METHOD: {
ObjBoundMethod* bound = (ObjBoundMethod*)object;
markValue(bound->receiver);
markObject((Obj*)bound->method);
break;
}
case OBJ_BOUND_METHOD:
printFunction(AS_BOUND_METHOD(value)->method->function);
break;
这也是 clox 最后一个新增的运行时对象类型:最后一组 IS_、AS_ 宏终于写完了。
28.2.2 访问方法(Accessing methods)
编译器的点号语法与 OP_GET_PROPERTY 已经正确,改变运行时即可。属性访问时实例在栈顶;先查字段,字段优先且遮蔽同名方法。没有字段时,向类查询方法;找不到则仍是未定义属性错误。
static bool bindMethod(ObjClass* klass, ObjString* name) {
Value method;
if (!tableGet(&klass->methods, name, &method)) {
runtimeError("Undefined property '%s'.", name->chars);
return false;
}
ObjBoundMethod* bound = newBoundMethod(peek(0), AS_CLOSURE(method));
pop();
push(OBJ_VAL(bound));
return true;
}
case OP_GET_PROPERTY: {
if (!IS_INSTANCE(peek(0))) {
runtimeError("Only instances have properties.");
return INTERPRET_RUNTIME_ERROR;
}
ObjInstance* instance = AS_INSTANCE(peek(0));
ObjString* name = READ_STRING();
Value value;
if (tableGet(&instance->fields, name, &value)) {
pop();
push(value);
break;
}
if (!bindMethod(instance->klass, name)) return INTERPRET_RUNTIME_ERROR;
break;
}
例如 brunch.eggs 会把栈顶的实例替换为携带同一接收者的绑定方法:

28.2.3 调用方法(Calling methods)
在 callValue() 中为绑定方法添加分支。调用时取出原闭包,用已保存的接收者覆盖调用者栈槽里的可调用对象,再复用 call() 建立调用帧。
case OBJ_BOUND_METHOD: {
ObjBoundMethod* bound = AS_BOUND_METHOD(callee);
vm.stackTop[-argCount - 1] = bound->receiver;
return call(bound->method, argCount);
}
至此可以声明、访问和调用方法:
class Scone {
topping(first, second) {
print "scone with " + first + " and " + second;
}
}
var scone = Scone();
scone.topping("berries", "cream");
但接收者虽被保存,还没有在方法体中使用。下一节解决 this。
28.3 this
词法器早已把 this 识别为特殊 token,只需在 Pratt 解析表中接入前缀解析函数。函数名末尾的下划线是因为 this 在 C++ 中为保留字,clox 支持用 C++ 编译。
[TOKEN_THIS] = {this_, NULL, PREC_NONE},
static void this_(bool canAssign) {
variable(false);
}
把 this 当成值会被神奇初始化的词法局部变量。这样编译成普通变量访问,可白得闭包捕获行为:方法内部闭包引用 this 时,会把接收者捕获到 upvalue。variable(false) 禁止给 this 赋值;它不在乎 token 是否为普通标识符,只把字面量 this 当变量名交给既有作用域解析。
每个函数调用帧的槽位零原先保存被调用函数,且编译器为它声明了一个空名字的局部变量,函数体无法访问它。方法调用可重新利用这个槽位:保存接收者,并将该局部变量命名为 this。
Local* local = ¤t->locals[current->localCount++];
local->depth = 0;
local->isCaptured = false;
if (type != TYPE_FUNCTION) {
local->name.start = "this";
local->name.length = 4;
} else {
local->name.start = "";
local->name.length = 0;
}
普通函数绝不能声明名为 this 的局部变量。这样方法内嵌普通函数引用 this 时,会正确解析到外层方法的接收者:
class Nested {
method() {
fun function() { print this; }
function();
}
}
Nested().method(); // Nested instance
所以在 FunctionType 中新增 TYPE_METHOD,编译方法时传入它。现在编译器能产生正确的 OP_GET_LOCAL 0,但运行时还须真正将接收者写入槽位零:
case OBJ_BOUND_METHOD: {
ObjBoundMethod* bound = AS_BOUND_METHOD(callee);
vm.stackTop[-argCount - 1] = bound->receiver;
return call(bound->method, argCount);
}
-argCount 跨过参数,-1 则因为 stackTop 指向最后一个已用槽位之后的位置。

28.3.1 误用 this(Misusing this)
类体之外的 this 是编译错误:
print this;
fun notMethod() { print this; }
只看当前 FunctionType 不够,因为普通函数可能嵌套在方法里。下一章也需要最近的外层类信息,于是维护一个 ClassCompiler 链栈;嵌套类虽罕见,Lox 支持它。
typedef struct ClassCompiler {
struct ClassCompiler* enclosing;
} ClassCompiler;
static ClassCompiler* currentClass = NULL;
static void this_(bool canAssign) {
if (currentClass == NULL) {
error("Can't use 'this' outside of a class.");
return;
}
variable(false);
}
编译类时,在 C 栈上创建一个 ClassCompiler,令其 enclosing 指向旧的 currentClass;类体结束后恢复旧指针。最外层类结束时旧指针为 NULL,因而这也是“是否正在类内”的可靠判断。
28.4 实例初始化器(Instance Initializers)
面向对象把状态和行为绑在一起,是为了让对象始终保持有效、有意义的状态;但新对象刚创建时如何建立这种状态?构造器负责创建对象并初始化它。Lox 运行时先分配裸实例,类可以声明初始化器设置字段。
Lox 初始化器是普通方法加三条规则:创建实例时自动调用;调用类的人总能拿回实例;初始化器禁止返回值。它仿佛被包在下面的代码中,注意 init() 的返回值被丢弃:
fun create(klass) {
var obj = newInstance(klass);
obj.init();
return obj;
}
Lox 仍允许外部直接读写字段,不像 Ruby 或 Smalltalk 那样彻底封装状态;这是这门玩具脚本语言不那么严谨的一面。
28.4.1 调用初始化器(Invoking initializers)
创建实例后,从类的方法表查找 init;找到即调用它。传给类的参数本来就在实例之上的栈中,新初始化器调用帧共享该栈窗口,参数自然被转发。
case OBJ_CLASS: {
ObjClass* klass = AS_CLASS(callee);
vm.stackTop[-argCount - 1] = OBJ_VAL(newInstance(klass));
Value initializer;
if (tableGet(&klass->methods, vm.initString, &initializer)) {
return call(AS_CLOSURE(initializer), argCount);
} else if (argCount != 0) {
runtimeError("Expected 0 arguments but got %d.", argCount);
return false;
}
return true;
}
class Brunch { init(food, drink) {} }
Brunch("eggs", "coffee");

类可以没有初始化器,此时返回未初始化实例;没有 init 却传参则为错误。有初始化器时 call() 已会检查参数数量。为快速反复查找,VM 驻留并复用 intern 后的字符串 "init":
typedef struct {
// ...
ObjString* initString;
} VM;
void initVM() {
vm.initString = NULL;
// ...
vm.initString = copyString("init", 4);
}
该字段是 GC 根,关闭 VM 时要清空它。关键细节是先置为 NULL:copyString() 会分配内存,可能触发 GC;若 GC 在赋值完成前读取未初始化的 vm.initString,会产生微妙的错误。
28.4.2 初始化器的返回值(Initializer return values)
有初始化器的类调用完成于初始化器返回之时;若照普通函数隐式返回 nil,调用者就得不到实例。编译器识别方法名 init,并用 TYPE_INITIALIZER 区分它;在函数体末尾或无值 return; 时,返回槽位零中的实例而非 nil。
static void emitReturn() {
if (current->type == TYPE_INITIALIZER) {
emitBytes(OP_GET_LOCAL, 0);
} else {
emitByte(OP_NIL);
}
emitByte(OP_RETURN);
}
28.4.3 初始化器中的错误返回(Incorrect returns in initializers)
return value; 在初始化器里无意义,因此报告编译错误,但仍编译后面的表达式,避免尾随表达式引发一串误报。
if (match(TOKEN_SEMICOLON)) {
emitReturn();
} else {
if (current->type == TYPE_INITIALIZER) {
error("Can't return a value from an initializer.");
}
expression();
consume(TOKEN_SEMICOLON, "Expect ';' after return value.");
emitByte(OP_RETURN);
}
除继承外,现在 clox 已有相当完整的类系统:
class CoffeeMaker {
init(coffee) { this.coffee = coffee; }
brew() {
print "Enjoy your cup of " + this.coffee;
this.coffee = nil; // 不要重复使用咖啡渣!
}
}
var maker = CoffeeMaker("coffee and chicory");
maker.brew();
对一个能装进老式软盘的 C 程序而言,已经相当花哨了。或许对今天的读者,“几条推文”会是更好理解的体积参照。
28.5 优化调用(Optimized Invocations)
语义现在正确,但方法调用仍慢。Lox 将调用定义为“访问方法”和“调用结果”两步,必须支持分开执行;但绝大多数程序会紧挨着写 object.method()。当前实现每次都分配短命 ObjBoundMethod,立刻拆开它,之后 GC 还得回收它。
编译器看见点号属性访问后立刻出现 (,便可生成融合了 OP_GET_PROPERTY 和 OP_CALL 的超级指令 OP_INVOKE。超级指令减少解码与分派开销,但 opcode 有限,只有足够常见的序列才值得融合。
static void dot(bool canAssign) {
consume(TOKEN_IDENTIFIER, "Expect property name after '.'.");
uint8_t name = identifierConstant(&parser.previous);
if (canAssign && match(TOKEN_EQUAL)) {
expression();
emitBytes(OP_SET_PROPERTY, name);
} else if (match(TOKEN_LEFT_PAREN)) {
uint8_t argCount = argumentList();
emitBytes(OP_INVOKE, name);
emitByte(argCount);
} else {
emitBytes(OP_GET_PROPERTY, name);
}
}
它有两个操作数:名称在常量表的索引及参数数目。反汇编器为此格式读取两项;解释器读取二者,调用 invoke(),成功后刷新缓存的当前调用帧。
static bool invokeFromClass(ObjClass* klass, ObjString* name,
int argCount) {
Value method;
if (!tableGet(&klass->methods, name, &method)) {
runtimeError("Undefined property '%s'.", name->chars);
return false;
}
return call(AS_CLOSURE(method), argCount);
}
static bool invoke(ObjString* name, int argCount) {
Value receiver = peek(argCount);
if (!IS_INSTANCE(receiver)) {
runtimeError("Only instances have methods.");
return false;
}
ObjInstance* instance = AS_INSTANCE(receiver);
return invokeFromClass(instance->klass, name, argCount);
}
接收者在参数之下,故 peek(argCount) 可取得它。此时接收者和参数已经处在调用约定所需的槽位,无需分配绑定方法,也无需搬动栈。
作者的 10 秒微基准中,未优化实现完成 1,089 批、优化后完成 8,324 批,即 7.6 倍。这主要说明原始实现很慢,不能因此自满。

28.5.1 调用字段(Invoking fields)
优化绝不能破坏正确性。下面的 oops.field() 在语法上像方法调用,实则是取出字段中的函数再调用;若 OP_INVOKE 只查方法表,会错误地报告找不到方法。
class Oops {
init() {
fun f() { print "not a method"; }
this.field = f;
}
}
var oops = Oops();
oops.field();
修复与 OP_GET_PROPERTY 一样:先查字段。命中时将字段值放在接收者原位置(参数列表之下),然后用 callValue() 调用它;未命中才走类方法的快速路径。
static bool invoke(ObjString* name, int argCount) {
Value receiver = peek(argCount);
if (!IS_INSTANCE(receiver)) {
runtimeError("Only instances have methods.");
return false;
}
ObjInstance* instance = AS_INSTANCE(receiver);
Value value;
if (tableGet(&instance->fields, name, &value)) {
vm.stackTop[-argCount - 1] = value;
return callValue(value, argCount);
}
return invokeFromClass(instance->klass, name, argCount);
}
这会牺牲一点性能,却保证语义。典型优化流程正是:识别性能关键的常见序列;实现优化版本;用条件确认模式确实适用,否则回退到更慢但健壮的路径。VM 工程师大量时间都在这个循环中度过。语言设计者若能控制语言,也可能限制某些表达力以换取更快实现,但不能由实现者单方面牺牲程序正确性。
挑战(Challenges)
- 查找类的
init()虽是常数时间仍较慢。实现更快方案,编写基准并测量差异。 - 动态类型语言中,同一调用点可在一次运行里调用多种类的多种方法,但实践中大多数调用点通常长期调用同一个类上的同一个方法。高级实现如何利用这一观察优化?
OP_INVOKE要先查字段、再查方法;前者很少命中,却因字段遮蔽方法而必需。你能否在不改变 Lox 语义的前提下减少这种开销?
设计笔记:新颖性预算(Novelty Budget)
学习语言时,用户能吸收的新概念有限。Lox 在类、接收者、初始化器和点调用上选用熟悉的 class、this、init 及既有语法,而不为每项功能发明新名词。创新应投入真正带来价值之处;其余地方借用读者已有经验,是对读者注意力的一种尊重。每种语言都有“新颖性预算”,设计者应花得谨慎。