第 27 章:类与实例(Classes and Instances)
原文:Robert Nystrom, Crafting Interpreters, Chapter 27。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。
太在意对象会毁掉你。不过,如果足够在意一件东西,它不就会拥有自己的生命吗?而事物,尤其美丽事物的要义,不正是把你连接到某种更大的美吗?
Donna Tartt,《金翅雀》
clox 最后尚未实现的领域是面向对象编程。OOP 是互相缠绕的功能包:类、实例、字段、方法、初始化器与继承。Java 的 jlox 用两章解决;如今在 C 中实现,像用牙签搭埃菲尔铁塔,因此分三章细看。本章先处理类、实例和字段,即面向对象的状态部分;后两章再添加行为和代码复用。
不同语言的 OOP 配方差异很大:Self 有对象没有类,CLOS 有方法却不把方法绑到特定类,早期 C++ 没有运行时多态,Python 多重继承而 Java 不支持,Ruby 的方法附在类上却也能定义单对象方法。
27.1 类对象(Class Objects)
类定义程序中存在哪些对象种类,也是制造实例的工厂。从运行时表示开始,类对象保存名称:
typedef struct {
Obj obj;
ObjString* name;
} ObjClass;
名称不影响用户程序的语义,却可用于栈踪迹等可读诊断。按新增对象类型的固定流程,加入 OBJ_CLASS、检查/转换宏与构造函数:
ObjClass* newClass(ObjString* name) {
ObjClass* klass = ALLOCATE_OBJ(ObjClass, OBJ_CLASS);
klass->name = name;
return klass;
}
#define IS_CLASS(value) isObjType(value, OBJ_CLASS)
#define AS_CLASS(value) ((ObjClass*)AS_OBJ(value))
变量名写成 klass 并非刻意卖萌,而是为了让 clox 更易被 C++ 编译,因为 class 是 C++ 保留字。类对象释放时只释放自身,GC 追踪时标记名称;打印类仅显示其名称:
case OBJ_CLASS: {
FREE(ObjClass, object);
break;
}
case OBJ_CLASS: {
ObjClass* klass = (ObjClass*)object;
markObject((Obj*)klass->name);
break;
}
case OBJ_CLASS:
printf("%s", AS_CLASS(value)->name->chars);
break;

27.2 类声明(Class Declarations)
类声明由 class 开始,在 declaration() 中先分派:
static void declaration() {
if (match(TOKEN_CLASS)) {
classDeclaration();
} else if (match(TOKEN_FUN)) {
funDeclaration();
} else if (match(TOKEN_VAR)) {
varDeclaration();
} else {
statement();
}
}
随后编译类名、绑定变量和暂时为空的类体:
static void classDeclaration() {
consume(TOKEN_IDENTIFIER, "Expect class name.");
uint8_t nameConstant = identifierConstant(&parser.previous);
declareVariable();
emitBytes(OP_CLASS, nameConstant);
defineVariable(nameConstant);
consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
}
编译器把类名存为当前外层函数常量表里的字符串,因为运行时创建和打印类需要名称。相同标识符也声明为变量,OP_CLASS 在运行时产生对象,defineVariable() 将其绑定。理论上类可设计为表达式,例如 var Pie = class {};本书把它作为声明,因为类通常需要名字。
尤其要注意,类变量在编译类体之前就定义。一般变量在初始化器完成后才定义,避免读取未初始化值;类体中的方法却可安全引用包含它的类名,例如工厂方法创建自身实例,因此类名应已可用。
当前没有方法,类体只能是一对空花括号;Lox 不要求在类中预先声明字段。新增 opcode 及其反汇编器支持:
OP_CLASS,
case OP_CLASS:
return constantInstruction("OP_CLASS", chunk, offset);
VM 读取类名字符串、分配类并压栈;随后全局定义或局部变量逻辑自然将它保存:
case OP_CLASS:
push(OBJ_VAL(newClass(READ_STRING())));
break;
所以现在可执行:
class Brioche {}
print Brioche;
局部类(函数或块内的类)在许多语言里罕见甚至禁止,但 Lox 是动态脚本语言,类只是另一种声明,既然变量和函数可在块内声明,类也可。
27.3 类的实例(Instances of Classes)
类有两个作用:创建实例,以及容纳定义实例行为的方法。本章尚无方法,先表示实例。实例指向所属类,且使用哈希表保存运行时可自由增加的字段:
typedef struct {
Obj obj;
ObjClass* klass;
Table fields;
} ObjInstance;
动态语言允许用户在运行时添加字段,故需要可增长、可按名字快速查找的存储;哈希表正好满足。静态语言常要求字段预声明,编译器就能固定每个实例所需内存和字段偏移;Lox 中字段访问通常是哈希查询,平均常数时间却比 C++ 的固定偏移指针访问重得多。
这里要区分 Lox 语言和 VM 的“类型”。对用户而言,Cake 与 Pie 的实例是不同类型;对 VM 而言,用户定义的每个类都是一个 ObjClass 值,所有用户实例均是同一种 C 对象 ObjInstance:

对象定义加入 table.h,以及 OBJ_INSTANCE、宏、构造与释放:
ObjInstance* newInstance(ObjClass* klass) {
ObjInstance* instance = ALLOCATE_OBJ(ObjInstance, OBJ_INSTANCE);
instance->klass = klass;
initTable(&instance->fields);
return instance;
}
#define IS_INSTANCE(value) isObjType(value, OBJ_INSTANCE)
#define AS_INSTANCE(value) ((ObjInstance*)AS_OBJ(value))
case OBJ_INSTANCE: {
ObjInstance* instance = (ObjInstance*)object;
freeTable(&instance->fields);
FREE(ObjInstance, object);
break;
}
实例拥有字段表数组,但不拥有其中引用的对象,GC 负责后者。若实例存活,GC 必须保留其类和全部字段键值;打印则显示 ClassName instance,避免与类的打印结果混淆:
case OBJ_INSTANCE: {
ObjInstance* instance = (ObjInstance*)object;
markObject((Obj*)instance->klass);
markTable(&instance->fields);
break;
}
case OBJ_INSTANCE:
printf("%s instance", AS_INSTANCE(value)->klass->name->chars);
break;
Lox 没有专门 new,而是把类本身当函数调用。callValue() 遇到类就以实例替换原栈中 callee;现阶段忽略传入参数,下一章初始化器会改变此处:
case OBJ_CLASS: {
ObjClass* klass = AS_CLASS(callee);
vm.stackTop[-argCount - 1] = OBJ_VAL(newInstance(klass));
return true;
}
现在:
class Brioche {}
print Brioche(); // Brioche instance
27.4 取值与赋值表达式(Get and Set Expressions)
实例已有状态存储,剩下的是向用户暴露它。字段使用经典点号:
eclair.filling = "pastry creme";
print eclair.filling;
点号左侧先求值为实例,右侧不是普通表达式而是一个标识符,故它更像后缀表达式。解析表将 . 作为与调用括号同样高优先级的中缀规则:
[TOKEN_DOT] = {NULL, dot, PREC_CALL},
dot() 消费字段名,将它作为字符串常量保存;在允许赋值的低优先级上下文里遇到 = 生成 setter,否则生成 getter:
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 {
emitBytes(OP_GET_PROPERTY, name);
}
}
编译器使用 property 而不是 field,因为以后点号也可取得方法;字段只是由实例状态支持的 property 子集。canAssign 防止错误接受 a + b.c = 3 并解析成 a + (b.c = 3):若上下文优先级过高,dot() 留下 =,最终由 parsePrecedence() 报无效赋值目标。
新增指令与反汇编器均采用名称常量操作数:
OP_GET_PROPERTY,
OP_SET_PROPERTY,
case OP_GET_PROPERTY:
return constantInstruction("OP_GET_PROPERTY", chunk, offset);
case OP_SET_PROPERTY:
return constantInstruction("OP_SET_PROPERTY", chunk, offset);
27.4.1 解释 getter 与 setter 表达式(Interpreting getter and setter expressions)
执行 getter 前,点号左侧结果在栈顶。必须先检查是实例,不能把字符串或数字的内存按 ObjInstance 解释;Lox 只允许实例有 property:
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(); // Instance.
push(value);
break;
}
runtimeError("Undefined property '%s'.", name->chars);
return INTERPRET_RUNTIME_ERROR;
}
字段存在时以字段值替换实例;不存在即运行时错误。当前尚无方法,之后字段未命中还会查方法表。
setter 执行时,栈从低到高为实例、待存值。它写入字段表,字段不存在时隐式创建;然后弹值、弹实例、再压回值,保持赋值表达式的结果为被赋值:
case OP_SET_PROPERTY: {
if (!IS_INSTANCE(peek(1))) {
runtimeError("Only instances have fields.");
return INTERPRET_RUNTIME_ERROR;
}
ObjInstance* instance = AS_INSTANCE(peek(1));
tableSet(&instance->fields, READ_STRING(), peek(0));
Value value = pop();
pop();
push(value);
break;
}

因此:
class Pair {}
var pair = Pair();
pair.first = 1;
pair.second = 2;
print pair.first + pair.second; // 3
这还像动态类型的 C:对象只是松散的结构体数据袋,尚未很“面向对象”;但用户已能自由聚合数据。下一章会让这些静态数据团拥有行为。
挑战(Challenges)
- 读取不存在字段会立即中止 VM,用户既不能恢复也无法预先查询字段是否存在。调查其他动态语言的策略,选择并实现适合 Lox 的语义。
- 字段名必须是源代码中点号后的标识符,用户不能构造字符串再以其作为字段名。是否应支持?设计并实现相应语言特性。
- Lox 不能删除字段;设为
nil后哈希表 entry 仍存在。考察其他语言,选择并实现一种删除策略。 - 动态字段按字符串名做哈希查询,虽然平均常数时间,常数因子仍大,是动态语言较慢的主要原因之一。研究成熟动态语言实现如何优化实例状态访问。