27

類別與實例

過於在乎物件可能會毀了你。只有當你夠在乎某件事物,它才會擁有自己的生命,不是嗎?而事物的重點美好的事物不就在於它們能將你與更大的美好連結起來嗎?

唐娜·塔特,《金翅雀》

在 clox 中,最後要實作的領域是物件導向程式設計(object-oriented programming,OOP)。OOP 是一系列相互交織的功能:類別、實例、欄位、方法、初始化器和繼承。使用相對高階的 Java,我們將所有這些內容塞進了兩個章節。現在我們用 C 語言編碼,感覺像是用牙籤搭建艾菲爾鐵塔的模型,我們將用三個章節來涵蓋相同的領域。這使得實作過程顯得悠閒許多。在經過諸如閉包垃圾回收器等艱難的章節之後,你值得好好休息一下。事實上,這本書從現在開始應該會變得輕鬆。

在本章中,我們將涵蓋前三個功能:類別、實例和欄位。這是物件導向的有狀態的一面。然後在接下來的兩個章節中,我們將在這些物件上附加行為和程式碼重用。

27 . 1類別物件

在基於類別的物件導向語言中,一切都從類別開始。它們定義了程式中存在哪些類型的物件,並且是用於產生新實例的工廠。從下而上,我們將從它們的執行階段表示開始,然後將其掛鉤到語言中。

到目前為止,我們已經非常熟悉向 VM 新增新物件類型的過程。我們從一個結構體開始。

} ObjClosure;
object.h
在 struct ObjClosure 之後新增
typedef struct {
  Obj obj;
  ObjString* name;
} ObjClass;
ObjClosure* newClosure(ObjFunction* function);
object.h,在 struct ObjClosure 之後新增

在 Obj 標頭之後,我們儲存類別的名稱。這對於使用者程式來說並非嚴格必要,但它可以讓我們在執行階段顯示名稱,例如堆疊追蹤。

新的類型需要在 ObjType 列舉中對應一個 case。

typedef enum {
object.h
在 enum ObjType
  OBJ_CLASS,
  OBJ_CLOSURE,
object.h,在 enum ObjType

並且該類型會有一對對應的巨集。首先,用於測試物件的類型

#define OBJ_TYPE(value)        (AS_OBJ(value)->type)

object.h
#define IS_CLASS(value)        isObjType(value, OBJ_CLASS)
#define IS_CLOSURE(value)      isObjType(value, OBJ_CLOSURE)
object.h

然後用於將 Value 轉換為 ObjClass 指標

#define IS_STRING(value)       isObjType(value, OBJ_STRING)

object.h
#define AS_CLASS(value)        ((ObjClass*)AS_OBJ(value))
#define AS_CLOSURE(value)      ((ObjClosure*)AS_OBJ(value))
object.h

VM 使用此函式建立新的類別物件

} ObjClass;

object.h
在 struct ObjClass 之後新增
ObjClass* newClass(ObjString* name);
ObjClosure* newClosure(ObjFunction* function);
object.h,在 struct ObjClass 之後新增

實作程式碼在這裡

object.c
allocateObject() 之後新增
ObjClass* newClass(ObjString* name) {
  ObjClass* klass = ALLOCATE_OBJ(ObjClass, OBJ_CLASS);
  klass->name = name; 
  return klass;
}
object.c,在 allocateObject() 之後新增

幾乎都是樣板程式碼。它將類別的名稱作為字串輸入並儲存它。每次使用者宣告一個新的類別時,VM 都會建立一個新的 ObjClass 結構體來表示它。

當 VM 不再需要類別時,它會像這樣釋放它

  switch (object->type) {
memory.c
freeObject() 中
    case OBJ_CLASS: {
      FREE(ObjClass, object);
      break;
    } 
    case OBJ_CLOSURE: {
memory.c,在 freeObject() 中

我們現在有一個記憶體管理器,因此我們也需要支援追蹤類別物件。

  switch (object->type) {
memory.c
blackenObject() 中
    case OBJ_CLASS: {
      ObjClass* klass = (ObjClass*)object;
      markObject((Obj*)klass->name);
      break;
    }
    case OBJ_CLOSURE: {
memory.c,在 blackenObject() 中

當 GC 到達類別物件時,它會標記類別的名稱,以保持該字串的存活。

VM 可以對類別執行的最後一個操作是列印它。

  switch (OBJ_TYPE(value)) {
object.c
printObject() 中
    case OBJ_CLASS:
      printf("%s", AS_CLASS(value)->name->chars);
      break;
    case OBJ_CLOSURE:
object.c,在 printObject() 中

類別只會說出自己的名稱。

27 . 2類別宣告

有了執行階段表示,我們就可以為語言新增類別的支援。接下來,我們進入剖析器。

static void declaration() {
compiler.c
declaration() 中
取代 1 行
  if (match(TOKEN_CLASS)) {
    classDeclaration();
  } else if (match(TOKEN_FUN)) {
    funDeclaration();
compiler.c,在 declaration() 中,取代 1 行

類別宣告是語句,剖析器會透過開頭的 class 關鍵字來識別它。其餘的編譯發生在這裡

compiler.c
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.");
}
compiler.c,在 function() 之後新增

緊接在 class 關鍵字之後的是類別的名稱。我們獲取該識別符號,並將其作為字串新增到周圍函式的常數表中。正如你剛才看到的,列印類別會顯示其名稱,因此編譯器需要將名稱字串儲存在執行階段可以找到的地方。常數表就是這樣做的。

類別的名稱也用於將類別物件繫結到具有相同名稱的變數。因此,我們在消耗其語彙單元後立即宣告一個帶有該識別符號的變數。

接下來,我們發出一個新的指令,以在執行階段實際建立類別物件。該指令將類別名稱的常數表索引作為運算元。

之後,但在編譯類別主體之前,我們定義類別名稱的變數。宣告變數會將其新增到範圍中,但回想一下前一章,我們必須在定義變數之後才能使用它。對於類別,我們在主體之前定義變數。這樣,使用者就可以在其自身的方法主體內引用包含的類別。這對於產生類別新實例的工廠方法之類的東西很有用。

最後,我們編譯主體。我們還沒有方法,所以現在它只是一對空的大括號。Lox 不要求在類別中宣告欄位,因此我們完成了主體以及剖析器目前為止。

編譯器正在發出一個新的指令,因此讓我們定義它。

  OP_RETURN,
chunk.h
在 enum OpCode
  OP_CLASS,
} OpCode;
chunk.h,在 enum OpCode

並將其新增到反組譯器中

    case OP_RETURN:
      return simpleInstruction("OP_RETURN", offset);
debug.c
disassembleInstruction() 中
    case OP_CLASS:
      return constantInstruction("OP_CLASS", chunk, offset);
    default:
debug.c,在 disassembleInstruction() 中

對於如此龐大的功能,直譯器的支援是最小的。

        break;
      }
vm.c
run() 中
      case OP_CLASS:
        push(OBJ_VAL(newClass(READ_STRING())));
        break;
    }
vm.c,在 run() 中

我們從常數表中載入類別名稱的字串,並將其傳遞給 newClass()。這會使用給定的名稱建立新的類別物件。我們將其推入堆疊,我們就完成了。如果類別繫結到全域變數,則編譯器對 defineVariable() 的呼叫會發出程式碼,以將該物件從堆疊儲存到全域變數表中。否則,它會正確地位於堆疊上,以用於新的局部變數。

有了它,我們的 VM 現在支援類別。你可以執行此操作

class Brioche {}
print Brioche;

不幸的是,列印是你可以使用類別執行的所有操作,因此接下來是讓它們更有用。

27 . 3類別的實例

類別在語言中有兩個主要目的

我們將在下一章中討論方法,因此目前我們只會擔心第一部分。在類別可以建立實例之前,我們需要它們的表示。

} ObjClass;
object.h
在 struct ObjClass 之後新增
typedef struct {
  Obj obj;
  ObjClass* klass;
  Table fields; 
} ObjInstance;
ObjClass* newClass(ObjString* name);
object.h,在 struct ObjClass 之後新增

實例知道它們的類別每個實例都有一個指向它是其實例的類別的指標。我們在本章中不會大量使用它,但當我們新增方法時,它將變得至關重要。

對本章更重要的是實例如何儲存其狀態。Lox 允許使用者在執行階段自由地向實例新增欄位。這意味著我們需要一種可以成長的儲存機制。我們可以使用動態陣列,但我們也希望盡快按名稱查詢欄位。有一種資料結構非常適合按名稱快速存取一組值,而且更方便的是我們已經實作了它。每個實例都使用雜湊表儲存其欄位。

我們只需要加入一個 include,就完成了。

#include "chunk.h"
object.h
#include "table.h"
#include "value.h"
object.h

這個新的結構體會得到一個新的物件類型。

  OBJ_FUNCTION,
object.h
在 enum ObjType
  OBJ_INSTANCE,
  OBJ_NATIVE,
object.h,在 enum ObjType

我想在這裡放慢一點速度,因為 Lox *語言* 的「類型」概念和 VM *實作* 的「類型」概念以可能會令人困惑的方式互相影響。在構成 clox 的 C 程式碼中,有許多不同的 Obj 類型ObjString、ObjClosure 等。每個類型都有自己的內部表示和語意。

在 Lox *語言* 中,使用者可以定義自己的類別例如 Cake 和 Pie然後建立這些類別的實例。從使用者的角度來看,Cake 的實例與 Pie 的實例是不同類型的物件。但是,從 VM 的角度來看,使用者定義的每個類別都只是 ObjClass 類型的值。同樣地,使用者程式中的每個實例,無論它屬於哪個類別,都是一個 ObjInstance。一個 VM 物件類型涵蓋所有類別的實例。這兩個世界之間的對應關係大致如下

A set of class declarations and instances, and the runtime representations each maps to.

理解了嗎?好,回到實作。我們也取得常用的巨集。

#define IS_FUNCTION(value)     isObjType(value, OBJ_FUNCTION)
object.h
#define IS_INSTANCE(value)     isObjType(value, OBJ_INSTANCE)
#define IS_NATIVE(value)       isObjType(value, OBJ_NATIVE)
object.h

還有

#define AS_FUNCTION(value)     ((ObjFunction*)AS_OBJ(value))
object.h
#define AS_INSTANCE(value)     ((ObjInstance*)AS_OBJ(value))
#define AS_NATIVE(value) \
object.h

由於欄位是在實例建立後才加入的,「建構子」函式只需要知道類別即可。

ObjFunction* newFunction();
object.h
在 *newFunction*() 之後加入
ObjInstance* newInstance(ObjClass* klass);
ObjNative* newNative(NativeFn function);
object.h,在 *newFunction*() 之後加入

我們在這裡實作該函式

object.c
在 *newFunction*() 之後加入
ObjInstance* newInstance(ObjClass* klass) {
  ObjInstance* instance = ALLOCATE_OBJ(ObjInstance, OBJ_INSTANCE);
  instance->klass = klass;
  initTable(&instance->fields);
  return instance;
}
object.c,在 *newFunction*() 之後加入

我們儲存對實例類別的參考。然後,我們將欄位表初始化為空的雜湊表。一個新的嬰兒物件誕生了!

在實例生命週期的較悲傷的末尾,它會被釋放。

      FREE(ObjFunction, object);
      break;
    }
memory.c
freeObject() 中
    case OBJ_INSTANCE: {
      ObjInstance* instance = (ObjInstance*)object;
      freeTable(&instance->fields);
      FREE(ObjInstance, object);
      break;
    }
    case OBJ_NATIVE:
memory.c,在 freeObject() 中

實例擁有其欄位表,因此在釋放實例時,我們也會釋放該表。我們不會明確釋放表中 *的* 項目,因為可能還有其他對這些物件的參考。垃圾回收器會為我們處理這些。在這裡,我們只釋放表本身的項目陣列。

說到垃圾回收器,它需要支援追蹤實例。

      markArray(&function->chunk.constants);
      break;
    }
memory.c
blackenObject() 中
    case OBJ_INSTANCE: {
      ObjInstance* instance = (ObjInstance*)object;
      markObject((Obj*)instance->klass);
      markTable(&instance->fields);
      break;
    }
    case OBJ_UPVALUE:
memory.c,在 blackenObject() 中

如果實例是存活的,我們就需要保留它的類別。此外,我們還需要保留實例欄位引用的每個物件。大多數不是根的存活物件之所以可存取,是因為某些實例在欄位中引用了該物件。幸運的是,我們已經有一個很好的 markTable() 函式可以輕鬆追蹤它們。

不太關鍵但仍然重要的是列印。

      break;
object.c
printObject() 中
    case OBJ_INSTANCE:
      printf("%s instance",
             AS_INSTANCE(value)->klass->name->chars);
      break;
    case OBJ_NATIVE:
object.c,在 printObject() 中

實例會列印其名稱,後面跟著「instance」(實例)。(「instance」部分主要是為了讓類別和實例不會列印相同的内容。)

真正的樂趣發生在直譯器中。Lox 沒有特殊的 new 關鍵字。建立類別實例的方法是將類別本身當作函式呼叫。執行時期已經支援函式呼叫,並且它會檢查被呼叫的物件類型,以確保使用者不會嘗試呼叫數字或其他無效的類型。

我們使用新的案例來擴充該執行時期檢查。

    switch (OBJ_TYPE(callee)) {
vm.c
callValue() 中
      case OBJ_CLASS: {
        ObjClass* klass = AS_CLASS(callee);
        vm.stackTop[-argCount - 1] = OBJ_VAL(newInstance(klass));
        return true;
      }
      case OBJ_CLOSURE:
vm.c,在 callValue() 中

如果被呼叫的值評估左括號左側的運算式所產生的物件是一個類別,那麼我們會將其視為建構子呼叫。我們 建立 被呼叫類別的新實例,並將結果儲存在堆疊中。

我們更進一步了。現在我們可以定義類別並建立它們的實例。

class Brioche {}
print Brioche();

請注意第二行 Brioche 後面的括號。這會列印「Brioche instance」。

27 . 4取得和設定運算式

我們實例的物件表示法已經可以儲存狀態,因此剩下的只是將該功能公開給使用者。欄位是使用 get 和 set 運算式存取和修改的。Lox 並沒有打破傳統,它使用經典的「點」語法

eclair.filling = "pastry creme";
print eclair.filling;

句點對我的英國朋友們來說是 full stop的作用有點像中綴運算子。左側有一個運算式會先被評估並產生一個實例。之後是 .,後面跟著一個欄位名稱。由於前面有一個運算元,我們將其連結到剖析表,作為中綴運算式。

  [TOKEN_COMMA]         = {NULL,     NULL,   PREC_NONE},
compiler.c
取代 1 行
  [TOKEN_DOT]           = {NULL,     dot,    PREC_CALL},
  [TOKEN_MINUS]         = {unary,    binary, PREC_TERM},
compiler.c,取代 1 行

如同其他語言一樣,. 運算子的綁定很緊密,其優先順序與函式呼叫中的括號一樣高。在剖析器耗用點符號之後,它會分派到新的剖析函式。

compiler.c
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);
  }
}
compiler.c,在 call() 之後加入

剖析器預期在點符號之後立即找到屬性名稱。我們將該符號的詞素載入到常數表作為字串,以便該名稱在執行時期可用。

我們有兩種新的運算式形式getter 和 setter由這個函式處理。如果我們在欄位名稱後看到等號,那麼它一定是正在指派給欄位的設定運算式。但是我們並非*總是*允許在欄位後編譯等號。請考慮

a + b.c = 3

根據 Lox 的文法,這在語法上是無效的,這表示我們的 Lox 實作有義務偵測並回報錯誤。如果 dot() 無聲地剖析了 = 3 部分,我們將會錯誤地解譯程式碼,如同使用者撰寫了

a + (b.c = 3)

問題在於,設定運算式的 = 端優先順序比 . 部分低得多。剖析器可能會在優先順序太高而無法允許出現 setter 的情況下呼叫 dot()。為了避免錯誤地允許這種情況,我們僅在 canAssign 為 true 時才剖析和編譯等號部分。如果當 canAssign 為 false 時出現等號符號,dot() 會保持原樣並返回。在這種情況下,編譯器最終會回溯到 parsePrecedence(),它會在仍然作為下一個符號的意外 = 處停止並回報錯誤。

如果我們在允許的情況下找到 =,那麼我們就編譯後面的運算式。之後,我們發出新的 OP_SET_PROPERTY 指令。這會採用一個運算元,用於常數表中屬性名稱的索引。如果我們沒有編譯設定運算式,我們會假設它是 getter 並發出 OP_GET_PROPERTY 指令,該指令也採用一個運算元,用於屬性名稱。

現在是定義這兩個新指令的好時機。

  OP_SET_UPVALUE,
chunk.h
在 enum OpCode
  OP_GET_PROPERTY,
  OP_SET_PROPERTY,
  OP_EQUAL,
chunk.h,在 enum OpCode

並新增對它們進行反組譯的支援

      return byteInstruction("OP_SET_UPVALUE", chunk, offset);
debug.c
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:
debug.c,在 disassembleInstruction() 中

27 . 4 . 1直譯 getter 和 setter 運算式

切換到執行時期,我們先從 get 運算式開始,因為它們比較簡單。

      }
vm.c
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: {
vm.c,在 run() 中

當直譯器到達此指令時,點符號左側的運算式已經執行,產生的實例位於堆疊頂部。我們從常數池中讀取欄位名稱,並在實例的欄位表中查找它。如果雜湊表包含具有該名稱的項目,我們就會彈出實例,並將該項目的值推入作為結果。

當然,欄位可能不存在。在 Lox 中,我們已將其定義為執行時期錯誤。因此,我們新增對其的檢查,如果發生這種情況則中止。

          push(value);
          break;
        }
vm.c
run() 中
        runtimeError("Undefined property '%s'.", name->chars);
        return INTERPRET_RUNTIME_ERROR;
      }
      case OP_EQUAL: {
vm.c,在 run() 中

還有另一個您可能已經注意到的失敗模式需要處理。上面的程式碼假設點符號左側的運算式確實評估為 ObjInstance。但是沒有任何東西能阻止使用者撰寫如下內容

var obj = "not an instance";
print obj.field;

使用者的程式是錯誤的,但 VM 仍然必須以某種優雅的方式來處理它。現在,它會將 ObjString 的位元錯誤地解譯為 ObjInstance,然後,我不知道,著火或發生其他絕對不優雅的事情。

在 Lox 中,只有實例才能有欄位。您無法將欄位填入字串或數字。因此,我們需要在存取其任何欄位之前檢查該值是否為實例。

      case OP_GET_PROPERTY: {
vm.c
run() 中
        if (!IS_INSTANCE(peek(0))) {
          runtimeError("Only instances have properties.");
          return INTERPRET_RUNTIME_ERROR;
        }

        ObjInstance* instance = AS_INSTANCE(peek(0));
vm.c,在 run() 中

如果堆疊上的值不是實例,我們會回報執行時期錯誤並安全退出。

當然,當沒有任何實例具有任何欄位時,get 運算式不是很有用。為此,我們需要 setter。

        return INTERPRET_RUNTIME_ERROR;
      }
vm.c
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: {
vm.c,在 run() 中

這比 OP_GET_PROPERTY 複雜一點。當它執行時,堆疊的頂部是其欄位正在設定的實例,而其上方是要儲存的值。和之前一樣,我們讀取指令的運算元並找到欄位名稱字串。使用該字串,我們將堆疊頂部的值儲存到實例的欄位表中。

接下來是一些 堆疊 操作。我們先將儲存的值彈出堆疊,然後彈出實例,最後將該值推回堆疊。換句話說,我們在保留堆疊頂端的同時,移除了堆疊中的第二個元素。setter 本身就是一個運算式,其結果是被賦予的值,所以我們需要將該值留在堆疊上。這就是我的意思:

class Toast {}
var toast = Toast();
print toast.jam = "grape"; // Prints "grape".

與讀取欄位不同,我們不需要擔心雜湊表沒有包含該欄位。setter 會在需要時隱式地建立該欄位。我們確實需要處理使用者試圖在非實例的值上錯誤地儲存欄位的情況。

      case OP_SET_PROPERTY: {
vm.c
run() 中
        if (!IS_INSTANCE(peek(1))) {
          runtimeError("Only instances have fields.");
          return INTERPRET_RUNTIME_ERROR;
        }

        ObjInstance* instance = AS_INSTANCE(peek(1));
vm.c,在 run() 中

就像 get 運算式一樣,我們會檢查值的類型,如果無效則報告執行階段錯誤。有了這些,Lox 對物件導向程式設計的狀態支援就到位了。試試看吧!

class Pair {}

var pair = Pair();
pair.first = 1;
pair.second = 2;
print pair.first + pair.second; // 3.

這感覺並不是真正的物件導向。它更像是一種奇怪的、動態類型的 C 變體,其中物件是鬆散的、類似結構的資料袋。有點像是動態的程序式語言。但這在表達能力上是一大步。我們的 Lox 實作現在允許使用者自由地將資料聚合為更大的單位。在下一章中,我們將為這些惰性的 blob 注入生命。

挑戰

  1. 嘗試存取物件上不存在的欄位會立即中止整個虛擬機器。使用者無法從這個執行階段錯誤中恢復,也無法在嘗試存取之前判斷欄位是否存在。使用者必須自行確保只讀取有效的欄位。

    其他動態類型語言如何處理遺失的欄位?你認為 Lox 應該怎麼做?實作你的解決方案。

  2. 欄位在執行階段是透過它們的字串名稱來存取的。但是這個名稱必須始終直接出現在原始碼中作為識別符記號。使用者程式無法強制建構一個字串值,然後將其用作欄位的名稱。你認為他們應該能夠這樣做嗎?設計一個可以實現這一點的語言功能並實作它。

  3. 相反地,Lox 沒有提供從實例中移除欄位的方法。你可以將欄位的值設定為 nil,但雜湊表中的條目仍然存在。其他語言如何處理這個問題?為 Lox 選擇並實作一個策略。

  4. 由於欄位在執行階段是透過名稱存取的,因此使用實例狀態的速度很慢。從技術上講,這是一個常數時間的操作感謝雜湊表但常數因子相對較大。這是動態語言比靜態類型語言慢的主要原因之一。

    動態類型語言的複雜實作如何應對和最佳化這一點?