类与实例
过度关心物体,会毁掉你。只有—当你足够关心某样东西,它才会拥有自己的生命,不是吗?而且,东西—美丽的事物—的意义不就在于,它们能让你与更广阔的美联系在一起吗?
唐娜·塔特,金翅雀
clox 中最后要实现的部分是面向对象编程。OOP 是许多交织在一起的功能的集合:类、实例、字段、方法、初始化器和继承。使用相对高级的 Java,我们将所有这些内容都压缩到两章中。现在我们用 C 编码,感觉像是用牙签搭建埃菲尔铁塔模型,我们将用三章来覆盖相同的内容。这样可以让我们悠闲地漫步在实现过程中。在像闭包和垃圾回收器这样艰苦的章节之后,你应该得到休息。事实上,从这里开始,这本书应该会变得轻松起来。
在本章中,我们将介绍前三个特性:类、实例和字段。这是面向对象状态化的方面。然后在接下来的两章中,我们将把行为和代码重用挂载到这些对象上。
27 . 1类对象
在基于类的面向对象语言中,一切从类开始。它们定义了程序中存在哪些类型的对象,并且是用于生成新实例的工厂。自下而上,我们将从它们的运行时表示开始,然后将其与语言挂钩。
到目前为止,我们已经熟悉了向 VM 添加新对象类型的过程。我们从结构体开始。
} ObjClosure;
在 struct ObjClosure 后添加
typedef struct { Obj obj; ObjString* name; } ObjClass;
ObjClosure* newClosure(ObjFunction* function);
在 Obj 标头之后,我们存储类的名称。这对于用户的程序来说不是严格必需的,但它让我们可以在运行时显示名称,例如在堆栈跟踪中。
新类型需要在 ObjType 枚举中对应一个 case。
typedef enum {
在 enum ObjType 中
OBJ_CLASS,
OBJ_CLOSURE,
并且该类型获得一对相应的宏。首先,用于测试对象的类型
#define OBJ_TYPE(value) (AS_OBJ(value)->type)
#define IS_CLASS(value) isObjType(value, OBJ_CLASS)
#define IS_CLOSURE(value) isObjType(value, OBJ_CLOSURE)
然后用于将 Value 转换为 ObjClass 指针
#define IS_STRING(value) isObjType(value, OBJ_STRING)
#define AS_CLASS(value) ((ObjClass*)AS_OBJ(value))
#define AS_CLOSURE(value) ((ObjClosure*)AS_OBJ(value))
VM 使用此函数创建新的类对象
} ObjClass;
在 struct ObjClass 后添加
ObjClass* newClass(ObjString* name);
ObjClosure* newClosure(ObjFunction* function);
实现代码在这里
在 allocateObject() 后添加
ObjClass* newClass(ObjString* name) { ObjClass* klass = ALLOCATE_OBJ(ObjClass, OBJ_CLASS); klass->name = name; return klass; }
几乎都是样板代码。它接收类的名称作为字符串并将其存储起来。每当用户声明一个新类时,VM 都会创建一个新的 ObjClass 结构体来表示它。
当 VM 不再需要一个类时,它会像这样释放它
switch (object->type) {
在 freeObject() 中
case OBJ_CLASS: { FREE(ObjClass, object); break; }
case OBJ_CLOSURE: {
我们现在有一个内存管理器,所以我们还需要支持对类对象的跟踪。
switch (object->type) {
在 blackenObject() 中
case OBJ_CLASS: { ObjClass* klass = (ObjClass*)object; markObject((Obj*)klass->name); break; }
case OBJ_CLOSURE: {
当 GC 遇到一个类对象时,它会标记类的名称以保持该字符串存活。
VM 对类执行的最后一个操作是打印它。
switch (OBJ_TYPE(value)) {
在 printObject() 中
case OBJ_CLASS: printf("%s", AS_CLASS(value)->name->chars); break;
case OBJ_CLOSURE:
类只是说自己的名字。
27 . 2类声明
有了运行时表示,我们准备将类支持添加到语言中。接下来,我们将进入解析器。
static void declaration() {
在 declaration() 中
替换 1 行
if (match(TOKEN_CLASS)) { classDeclaration(); } else if (match(TOKEN_FUN)) {
funDeclaration();
类声明是语句,解析器通过开头的 class
关键字识别它们。其余的编译工作在下面进行
在 function() 后添加
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."); }
紧随 class
关键字之后是类的名称。我们获取该标识符并将其作为字符串添加到周围函数的常量表中。正如你所看到的,打印一个类会显示它的名称,因此编译器需要将名称字符串存储在运行时可以找到的地方。常量表是实现此目的的方法。
类的名称还用于将类对象绑定到同名变量。因此,我们在使用其标记之后立即声明了一个具有该标识符的变量。
接下来,我们发出一个新的指令,以便在运行时实际创建类对象。该指令将类的名称在常量表中的索引作为操作数。
在那之后,但在编译类的主体之前,我们定义了类名称的变量。声明变量会将其添加到作用域中,但回想一下前面的章节,我们在定义之前不能使用该变量。对于类,我们在主体之前定义变量。这样,用户可以在其自身方法的主体内引用包含类。这对生成新类实例的工厂方法很有用。
最后,我们编译主体。我们还没有方法,所以现在它只是一个空的括号对。Lox 不要求在类中声明字段,因此我们现在已经完成了主体—以及解析器—。
编译器正在发出新的指令,所以让我们定义它。
OP_RETURN,
在 enum OpCode 中
OP_CLASS,
} OpCode;
并将其添加到反汇编器
case OP_RETURN: return simpleInstruction("OP_RETURN", offset);
在 disassembleInstruction() 中
case OP_CLASS: return constantInstruction("OP_CLASS", chunk, offset);
default:
对于这样一个看起来很大的功能,解释器支持是很少的。
break; }
在 run() 中
case OP_CLASS: push(OBJ_VAL(newClass(READ_STRING()))); break;
}
我们从常量表中加载类的名称字符串,并将其传递给 newClass()
。这会创建一个带有给定名称的新类对象。我们将它压入堆栈,我们就完成了。如果类绑定到全局变量,那么编译器对 defineVariable()
的调用将发出代码,将该对象从堆栈存储到全局变量表中。否则,它在堆栈上的位置正是新的局部变量所需的位置。
好了,我们的 VM 现在支持类了。你可以运行它
class Brioche {} print Brioche;
不幸的是,打印是你能对类做的所有操作,所以接下来是让它们更有用。
27 . 3类的实例
类在语言中扮演着两个主要角色
-
它们是创建新实例的方式。有时这会涉及
new
关键字,有时是类对象上的方法调用,但你通常需要以某种方式提及类来获得新的实例。 -
它们包含方法。这些定义了类所有实例的行为。
我们将在下一章介绍方法,所以现在我们只关心第一部分。在类能够创建实例之前,我们需要一个表示它们的表示形式。
} ObjClass;
在 struct ObjClass 后添加
typedef struct { Obj obj; ObjClass* klass; Table fields; } ObjInstance;
ObjClass* newClass(ObjString* name);
实例知道它们的类—每个实例都指向它是其实例的类。我们在本章中不会经常使用它,但当我们添加方法时,它将变得至关重要。
对本章来说更重要的是实例如何存储其状态。Lox 允许用户在运行时自由地将字段添加到实例中。这意味着我们需要一个可以增长的存储机制。我们可以使用动态数组,但我们也希望能够尽快通过名称查找字段。有一个数据结构非常适合快速访问一组按名称索引的值,而且—更方便的是—我们已经实现了它。每个实例使用哈希表存储其字段。
我们只需要添加一个包含文件,就可以了。
#include "chunk.h"
#include "table.h"
#include "value.h"
这个新的结构体获得了一个新的对象类型。
OBJ_FUNCTION,
在 enum ObjType 中
OBJ_INSTANCE,
OBJ_NATIVE,
我想在这里放慢速度,因为 Lox 语言的“类型”概念和 VM 实现的“类型”概念,在某些方面相互交织,可能会让人感到困惑。在创建 clox 的 C 代码内部,有许多不同类型的 Obj—ObjString、ObjClosure 等。每个都有其自己的内部表示形式和语义。
在 Lox *语言* 中,用户可以定义自己的类—例如 Cake 和 Pie—然后创建这些类的实例。从用户的角度来看,Cake 的实例与 Pie 的实例是不同类型的对象。但是,从 VM 的角度来看,用户定义的每个类都只是 ObjClass 类型的另一个值。同样,用户程序中的每个实例,无论它属于哪个类,都是 ObjInstance。这一个 VM 对象类型涵盖了所有类的实例。这两个世界相互映射,大致如下
明白了吗?好的,回到实现。我们也得到我们常用的宏。
#define IS_FUNCTION(value) isObjType(value, OBJ_FUNCTION)
#define IS_INSTANCE(value) isObjType(value, OBJ_INSTANCE)
#define IS_NATIVE(value) isObjType(value, OBJ_NATIVE)
以及
#define AS_FUNCTION(value) ((ObjFunction*)AS_OBJ(value))
#define AS_INSTANCE(value) ((ObjInstance*)AS_OBJ(value))
#define AS_NATIVE(value) \
由于字段是在实例创建后添加的,因此“构造函数”只需要知道类。
ObjFunction* newFunction();
在 newFunction() 后添加
ObjInstance* newInstance(ObjClass* klass);
ObjNative* newNative(NativeFn function);
我们在这里实现该函数
在 newFunction() 后添加
ObjInstance* newInstance(ObjClass* klass) { ObjInstance* instance = ALLOCATE_OBJ(ObjInstance, OBJ_INSTANCE); instance->klass = klass; initTable(&instance->fields); return instance; }
我们存储对实例类的引用。然后我们将字段表初始化为空哈希表。一个新的婴儿对象诞生了!
在实例生命周期的悲伤末端,它将被释放。
FREE(ObjFunction, object); break; }
在 freeObject() 中
case OBJ_INSTANCE: { ObjInstance* instance = (ObjInstance*)object; freeTable(&instance->fields); FREE(ObjInstance, object); break; }
case OBJ_NATIVE:
实例拥有自己的字段表,因此在释放实例时,我们也释放表。我们不会显式地释放表中的 *条目*,因为可能还有其他对这些对象的引用。垃圾收集器会为我们处理这些。这里我们只释放表本身的条目数组。
说到垃圾收集器,它需要支持通过实例进行跟踪。
markArray(&function->chunk.constants); break; }
在 blackenObject() 中
case OBJ_INSTANCE: { ObjInstance* instance = (ObjInstance*)object; markObject((Obj*)instance->klass); markTable(&instance->fields); break; }
case OBJ_UPVALUE:
如果实例还活着,我们需要保留它的类。此外,我们需要保留实例字段引用的每个对象。大多数不是根节点的活动对象都是可达的,因为某个实例在字段中引用了该对象。幸运的是,我们已经有一个不错的 markTable()
函数来简化跟踪它们。
不太关键但仍然很重要是打印。
break;
在 printObject() 中
case OBJ_INSTANCE: printf("%s instance", AS_INSTANCE(value)->klass->name->chars); break;
case OBJ_NATIVE:
一个 实例打印它的名称,后跟“instance”。(“instance”部分主要是为了让类和实例不要打印相同的内容。)
真正有趣的事情发生在解释器中。Lox 没有特殊的 new
关键字。创建类实例的方法是将类本身调用为函数。运行时已经支持函数调用,并且它检查被调用对象的类型以确保用户不会尝试调用数字或其他无效类型。
我们用一个新的案例扩展了这个运行时检查。
switch (OBJ_TYPE(callee)) {
在 callValue() 中
case OBJ_CLASS: { ObjClass* klass = AS_CLASS(callee); vm.stackTop[-argCount - 1] = OBJ_VAL(newInstance(klass)); return true; }
case OBJ_CLOSURE:
如果被调用的值—即在左括号左侧评估表达式后产生的对象—是一个类,那么我们将其视为构造函数调用。我们 创建一个 被调用类的新的实例,并将结果存储在堆栈中。
我们又向前迈进了一步。现在我们可以定义类并创建它们的实例。
class Brioche {} print Brioche();
请注意第二行中的 Brioche
后面的括号。这将打印“Brioche instance”。
27 . 4获取和设置表达式
我们对实例的对象表示形式已经可以存储状态,所以剩下的就是将该功能公开给用户。使用获取和设置表达式访问和修改字段。为了不打破传统,Lox 使用经典的“点”语法
eclair.filling = "pastry creme"; print eclair.filling;
句点—对于我的英国朋友来说是句号—的作用类似于中缀运算符。左侧有一个表达式,它首先被评估并产生一个实例。之后是 .
,后跟一个字段名称。由于存在前一个操作数,因此我们将其挂接到解析表中,作为中缀表达式。
[TOKEN_COMMA] = {NULL, NULL, PREC_NONE},
替换 1 行
[TOKEN_DOT] = {NULL, dot, PREC_CALL},
[TOKEN_MINUS] = {unary, binary, PREC_TERM},
与其他语言一样,.
运算符具有紧密绑定,优先级与函数调用中的括号一样高。解析器消耗了点标记后,它将调度到一个新的解析函数。
在 call() 后添加
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); } }
解析器期望在点标记后立即找到一个 属性 名称。我们将该标记的词素加载到常量表中,作为字符串,以便在运行时可以使用该名称。
我们有两个新的表达式形式—获取器和设置器—这个函数处理这两个形式。如果我们在字段名称后面看到等号,它一定是一个将值赋予字段的设置表达式。但我们并不 *总是* 允许在字段后面编译等号。考虑
a + b.c = 3
根据 Lox 的语法,这是语法上无效的,这意味着我们的 Lox 实现有义务检测并报告错误。如果 dot()
默默地解析了 = 3
部分,我们将错误地将代码解释为用户写了
a + (b.c = 3)
问题是,设置表达式的 =
部分的优先级远低于 .
部分。解析器可能会在优先级过高的上下文中调用 dot()
,不允许设置器出现。为了避免错误地允许这种情况,我们只在 canAssign
为真时解析和编译等号部分。如果在 canAssign
为假时出现等号标记,dot()
将保持不变并返回。在这种情况下,编译器最终会回退到 parsePrecedence()
,该函数将在仍然作为下一个标记的意外 =
处停止,并报告错误。
如果我们在 *允许* 的上下文中找到 =
,那么我们将编译后面的表达式。之后,我们将发出一个新的 OP_SET_PROPERTY
指令。它接受一个操作数,用于常量表中属性名称的索引。如果我们没有编译设置表达式,则假定它是一个获取器,并发出 OP_GET_PROPERTY
指令,该指令也接受一个操作数,用于属性名称。
现在是定义这两个新指令的好时机。
OP_SET_UPVALUE,
在 enum OpCode 中
OP_GET_PROPERTY, OP_SET_PROPERTY,
OP_EQUAL,
并添加对反汇编它们的支
return byteInstruction("OP_SET_UPVALUE", chunk, offset);
在 disassembleInstruction() 中
case OP_GET_PROPERTY: return constantInstruction("OP_GET_PROPERTY", chunk, offset); case OP_SET_PROPERTY: return constantInstruction("OP_SET_PROPERTY", chunk, offset);
case OP_EQUAL:
27 . 4 . 1解释获取和设置表达式
滑动到运行时,我们将从获取表达式开始,因为它们比较简单。
}
在 run() 中
case OP_GET_PROPERTY: { ObjInstance* instance = AS_INSTANCE(peek(0)); ObjString* name = READ_STRING(); Value value; if (tableGet(&instance->fields, name, &value)) { pop(); // Instance. push(value); break; } }
case OP_EQUAL: {
当解释器遇到这条指令时,点标记左侧的表达式已经执行,结果实例位于堆栈顶部。我们从常量池中读取字段名称,并在实例的字段表中查找它。如果哈希表包含具有该名称的条目,我们将弹出实例并推送条目的值作为结果。
当然,字段可能不存在。在 Lox 中,我们定义了这是一种运行时错误。因此,我们添加一个检查,如果发生这种情况,则中止。
push(value); break; }
在 run() 中
runtimeError("Undefined property '%s'.", name->chars); return INTERPRET_RUNTIME_ERROR;
} case OP_EQUAL: {
存在 另一个您可能已经注意到的失败模式。上面的代码假设点标记左侧的表达式确实评估为 ObjInstance。但是,没有什么可以阻止用户写这个
var obj = "not an instance"; print obj.field;
用户的程序是错误的,但 VM 仍然必须以某种优雅的方式处理它。现在,它会将 ObjString 的位错误地解释为 ObjInstance,我不知道,它会着火,或者发生一些肯定不优雅的事情。
在 Lox 中,只有实例允许拥有字段。您不能将字段塞入字符串或数字中。因此,我们需要检查值是否是一个实例,然后再访问它的任何字段。
case OP_GET_PROPERTY: {
在 run() 中
if (!IS_INSTANCE(peek(0))) { runtimeError("Only instances have properties."); return INTERPRET_RUNTIME_ERROR; }
ObjInstance* instance = AS_INSTANCE(peek(0));
如果堆栈上的值不是实例,我们报告一个运行时错误并安全退出。
当然,当没有实例有任何字段时,获取表达式并不十分有用。为此,我们需要设置器。
return INTERPRET_RUNTIME_ERROR; }
在 run() 中
case OP_SET_PROPERTY: { ObjInstance* instance = AS_INSTANCE(peek(1)); tableSet(&instance->fields, READ_STRING(), peek(0)); Value value = pop(); pop(); push(value); break; }
case OP_EQUAL: {
这比 OP_GET_PROPERTY
稍微复杂一些。当它执行时,堆栈顶部包含要设置其字段的实例,而在其上方,是待存储的值。和以前一样,我们读取指令的操作数并找到字段名称字符串。使用它,我们将堆栈顶部的值存储到实例的字段表中。
之后是一些 堆栈 操作。我们将存储的值从堆栈中弹出,然后弹出实例,最后将值压回堆栈。换句话说,我们将 *第二个* 元素从堆栈中删除,而保留顶部元素。设置器本身是一个表达式,其结果是分配的值,因此我们需要将该值保留在堆栈中。我的意思是
class Toast {} var toast = Toast(); print toast.jam = "grape"; // Prints "grape".
与读取字段不同,我们不需要担心哈希表不包含该字段。设置器隐式创建必要的字段。我们需要处理用户错误地尝试将字段存储在非实例的值上的情况。
case OP_SET_PROPERTY: {
在 run() 中
if (!IS_INSTANCE(peek(1))) { runtimeError("Only instances have fields."); return INTERPRET_RUNTIME_ERROR; }
ObjInstance* instance = AS_INSTANCE(peek(1));
与获取表达式完全一样,我们检查值的类型,如果它无效,则报告运行时错误。有了这个,Lox 对面向对象编程的支持的状态方面就到位了。试试看
class Pair {} var pair = Pair(); pair.first = 1; pair.second = 2; print pair.first + pair.second; // 3.
这并不真正感觉像 *面向对象*。它更像是 C 的一种奇怪的、动态类型的变体,其中对象是松散的类似结构的数据包。有点像动态的程序语言。但这在表达能力方面是一个巨大的进步。我们的 Lox 实现现在允许用户将数据自由地聚合到更大的单元中。在下一章中,我们将给这些惰性块注入生命。
挑战
-
尝试访问对象中不存在的字段会立即中止整个 VM。用户无法从这个运行时错误中恢复,也无法查看字段是否存在 *在尝试访问它之前*。用户有责任自己确保只读取有效的字段。
其他动态类型的语言如何处理缺失的字段?您认为 Lox 应该怎么做?实现您的解决方案。
-
在运行时,可以通过字符串名称访问字段。但是,该名称必须始终直接出现在源代码中作为标识符标记。用户程序不能强制性地构建字符串值,然后将其用作字段的名称。你认为他们应该能够这样做吗?设计一个语言功能来实现这一点,并进行实现。
-
相反,Lox 没有提供从实例中移除字段的方法。你可以将字段的值设置为
nil
,但哈希表中的条目仍然存在。其他语言如何处理这种情况?选择并实施一种 Lox 的策略。 -
由于字段是在运行时按名称访问的,因此处理实例状态速度很慢。从技术上讲,这是一个常数时间操作—感谢哈希表—但常数因子相对较大。这是动态语言比静态类型语言速度慢的主要原因之一。
动态类型语言的复杂实现如何应对和优化这个问题?