方法與初始化器
當你在舞池裡時,除了跳舞別無他法。
翁貝托·艾可,《洛安娜女王的神秘火焰》
現在是時候讓我們的虛擬機器用行為來賦予其初生的物件生命了。這意味著方法和方法調用。而且,由於它們是一種特殊的方法,也包括初始化器。
所有這些對我們之前的 jlox 解釋器來說都是熟悉的領域。這次旅程的新鮮之處在於,我們將實作一個重要的優化,使方法調用的速度比我們的基準效能快七倍以上。但在我們開始享受樂趣之前,我們必須先讓基本功能正常運作。
28.1方法宣告
在我們有方法調用之前,我們無法優化方法調用,而且在沒有方法可調用的情況下,我們無法調用方法,所以我們先從宣告開始。
28.1.1表示方法
我們通常從編譯器開始,但這次我們先搞定物件模型。clox 中方法的執行期表示與 jlox 相似。每個類別都儲存一個方法的雜湊表。鍵是方法名稱,每個值是方法主體的 ObjClosure。
typedef struct { Obj obj; ObjString* name;
在 struct ObjClass 中
Table methods;
} ObjClass;
一個全新的類別從一個空的方法表開始。
klass->name = name;
在 newClass() 中
initTable(&klass->methods);
return klass;
ObjClass 結構擁有這個表的記憶體,所以當記憶體管理器釋放一個類別的記憶體時,該表也應該被釋放。
case OBJ_CLASS: {
在 freeObject() 中
ObjClass* klass = (ObjClass*)object; freeTable(&klass->methods);
FREE(ObjClass, object);
提到記憶體管理器,GC 需要追蹤通過類別進入方法表。如果一個類別仍然可到達(很可能是通過某個實例),那麼它的所有方法當然也需要保留。
markObject((Obj*)klass->name);
在 blackenObject() 中
markTable(&klass->methods);
break;
我們使用現有的 markTable()
函式,它會追蹤每個表條目中的鍵字串和值。
從 jlox 來看,儲存類別的方法非常熟悉。不同之處在於該表如何被填充。我們之前的解釋器可以存取類別宣告的整個 AST 節點以及它包含的所有方法。在執行期,解釋器只是走過宣告的列表。
現在,編譯器想要轉移到執行期的每一條資訊都必須通過一系列扁平位元組碼指令的介面。我們如何將一個類別宣告(它可以包含任意數量的集合方法)表示為位元組碼?讓我們跳到編譯器中找出答案。
28.1.2編譯方法宣告
上一章給我們留下了一個可以解析類別但只允許空主體的編譯器。現在我們插入一些程式碼,在括號之間編譯一系列方法宣告。
consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
在 classDeclaration() 中
while (!check(TOKEN_RIGHT_BRACE) && !check(TOKEN_EOF)) { method(); }
consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
Lox 沒有欄位宣告,因此類別主體末尾的右大括號之前的任何內容都必須是一個方法。當我們到達最後一個花括號或到達檔案末尾時,我們會停止編譯方法。後者檢查確保我們的編譯器不會因使用者意外忘記右大括號而陷入無限迴圈。
編譯類別宣告的棘手之處在於,一個類別可以宣告任意數量的多個方法。執行期需要以某種方式查找並綁定所有這些方法。這會有很多東西要塞入單個 OP_CLASS
指令中。相反,我們為類別宣告產生的位元組碼將這個過程分成一系列的指令。編譯器已經發出了一個 OP_CLASS
指令,該指令會建立一個新的空 ObjClass 物件。然後它會發出指令,將類別儲存在一個以其名稱命名的變數中。
現在,對於每個方法宣告,我們發出一個新的 OP_METHOD
指令,將單個方法新增到該類別。當所有 OP_METHOD
指令都執行完畢後,我們就會得到一個完全成形的類別。雖然使用者將類別宣告視為單個原子操作,但 VM 將其實作為一系列突變。
要定義一個新方法,VM 需要三件事
-
方法的名稱。
-
方法主體的閉包。
-
要將方法綁定到的類別。
我們將逐步編寫編譯器程式碼,看看這些是如何傳遞到執行期的,從這裡開始
在 function() 之後新增
static void method() { consume(TOKEN_IDENTIFIER, "Expect method name."); uint8_t constant = identifierConstant(&parser.previous); emitBytes(OP_METHOD, constant); }
與其他需要在執行期使用名稱的指令 (例如 OP_GET_PROPERTY
) 一樣,編譯器會將方法名稱 Token 的詞素新增到常數表,取得一個表格索引。然後我們發出一個以該索引作為運算元的 OP_METHOD
指令。那就是名稱。接下來是方法主體
uint8_t constant = identifierConstant(&parser.previous);
在 method() 中
FunctionType type = TYPE_FUNCTION; function(type);
emitBytes(OP_METHOD, constant);
我們使用我們為編譯函式宣告編寫的相同 function()
輔助函式。該實用函式會編譯後續的參數清單和函式主體。然後它會發出程式碼來建立 ObjClosure 並將其保留在堆疊頂部。在執行期,VM 將在那裡找到閉包。
最後是要將方法綁定到的類別。VM 在哪裡可以找到它?不幸的是,當我們到達 OP_METHOD
指令時,我們不知道它在哪裡。如果使用者在區域範圍內宣告了類別,它可能在堆疊上。但是頂層類別宣告最終會將 ObjClass 放入全域變數表中。
別擔心。編譯器確實知道類別的名稱。我們可以在使用它的 Token 後立即捕獲它。
consume(TOKEN_IDENTIFIER, "Expect class name.");
在 classDeclaration() 中
Token className = parser.previous;
uint8_t nameConstant = identifierConstant(&parser.previous);
而且我們知道沒有其他具有該名稱的宣告可以遮蔽該類別。所以我們採用簡單的修正方法。在我們開始綁定方法之前,我們會發出任何必要的程式碼,將類別重新載入到堆疊頂部。
defineVariable(nameConstant);
在 classDeclaration() 中
namedVariable(className, false);
consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
在編譯類別主體之前,我們呼叫 namedVariable()
。該輔助函式會產生程式碼,將具有給定名稱的變數載入到堆疊上。然後我們編譯方法。
這表示當我們執行每個 OP_METHOD
指令時,堆疊頂部會有該方法的閉包,其下方就是類別。一旦我們到達方法的末尾,我們就不再需要類別,並告訴 VM 從堆疊中彈出它。
consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
在 classDeclaration() 中
emitByte(OP_POP);
}
將所有這些放在一起,以下是一個範例類別宣告,可以拋給編譯器
class Brunch { bacon() {} eggs() {} }
鑑於此,以下是編譯器產生的內容以及這些指令如何在執行期影響堆疊

我們剩下的就是實作新的 OP_METHOD
指令的執行期。
28.1.3執行方法宣告
首先,我們定義運算碼。
OP_CLASS,
在 enum OpCode 中
OP_METHOD
} OpCode;
我們像其他具有字串常數運算元的指令一樣反組譯它。
case OP_CLASS: return constantInstruction("OP_CLASS", chunk, offset);
在 disassembleInstruction() 中
case OP_METHOD: return constantInstruction("OP_METHOD", chunk, offset);
default:
在解釋器中,我們也新增了一個新的 case。
break;
在 run() 中
case OP_METHOD: defineMethod(READ_STRING()); break;
}
在那裡,我們從常數表讀取方法名稱並將其傳遞到這裡
在 closeUpvalues() 之後新增
static void defineMethod(ObjString* name) { Value method = peek(0); ObjClass* klass = AS_CLASS(peek(1)); tableSet(&klass->methods, name, method); pop(); }
方法閉包位於堆疊頂部,在其將綁定到的類別上方。我們讀取這兩個堆疊槽,並將閉包儲存在類別的方法表中。然後我們彈出閉包,因為我們已經完成它了。
請注意,我們不會對閉包或類別物件執行任何執行期類型檢查。AS_CLASS()
呼叫是安全的,因為編譯器本身產生了導致類別位於該堆疊槽中的程式碼。VM 信任自己的編譯器。
在完成一系列 OP_METHOD
指令且 OP_POP
彈出類別後,我們將擁有一個具有良好填充的方法表類別,準備開始做事。下一步是將這些方法拉出來並使用它們。
28.2方法引用
大多數情況下,方法會被存取並立即呼叫,導致出現以下熟悉的語法
instance.method(argument);
但請記住,在 Lox 和其他一些語言中,這兩個步驟是不同的,並且可以分開。
var closure = instance.method; closure(argument);
由於使用者可以將操作分開,我們必須將它們分開實作。第一步是使用我們現有的點屬性語法來存取在實例的類別上定義的方法。它應該會回傳某種物件,讓使用者可以像呼叫函式一樣呼叫它。
顯而易見的方法是在類別的方法表中查找該方法,並回傳與該名稱關聯的 ObjClosure。但我們也需要記住,當您存取方法時,this
會被綁定到存取該方法的實例。這是我們在將方法新增到 jlox 時的範例。
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var method = jane.sayName; method(); // ?
這應該會印出 “Jane”,所以 .sayName
回傳的物件需要在稍後被呼叫時,以某種方式記住它是從哪個實例存取的。在 jlox 中,我們使用直譯器現有的堆積分配 Environment 類別來實作該「記憶」,該類別處理所有變數儲存。
我們的 bytecode VM 具有更複雜的架構來儲存狀態。區域變數和暫存變數在堆疊上,全域變數在雜湊表中,而閉包中的變數使用向上值。這使得在 clox 中追蹤方法的接收者需要更複雜的解決方案,以及一個新的執行階段類型。
28 . 2 . 1綁定方法
當使用者執行方法存取時,我們會找到該方法的閉包,並將其包裝在新的 「綁定方法」物件中,該物件會追蹤存取該方法的實例。這個綁定物件稍後可以像函式一樣呼叫。當被呼叫時,VM 會執行一些技巧來將 this
連接到方法主體內的接收者。
以下是新的物件類型
} ObjInstance;
在 struct ObjInstance 之後新增
typedef struct { Obj obj; Value receiver; ObjClosure* method; } ObjBoundMethod;
ObjClass* newClass(ObjString* name);
它將接收者和方法閉包包在一起。即使方法只能在 ObjInstance 上呼叫,接收者的類型也是 Value。由於 VM 根本不關心它擁有什麼類型的接收者,因此使用 Value 意味著我們不必在將指標傳遞給更通用的函式時,將指標轉換回 Value。
新的 struct 意味著您現在已經習慣的常見樣板程式碼。物件類型列舉中的新情況
typedef enum {
在 enum ObjType 中
OBJ_BOUND_METHOD,
OBJ_CLASS,
檢查值類型的巨集
#define OBJ_TYPE(value) (AS_OBJ(value)->type)
#define IS_BOUND_METHOD(value) isObjType(value, OBJ_BOUND_METHOD)
#define IS_CLASS(value) isObjType(value, OBJ_CLASS)
另一個將值轉換為 ObjBoundMethod 指標的巨集
#define IS_STRING(value) isObjType(value, OBJ_STRING)
#define AS_BOUND_METHOD(value) ((ObjBoundMethod*)AS_OBJ(value))
#define AS_CLASS(value) ((ObjClass*)AS_OBJ(value))
建立新的 ObjBoundMethod 的函式
} ObjBoundMethod;
在 struct ObjBoundMethod 之後新增
ObjBoundMethod* newBoundMethod(Value receiver, ObjClosure* method);
ObjClass* newClass(ObjString* name);
以及此處該函式的實作
在 allocateObject() 之後新增
ObjBoundMethod* newBoundMethod(Value receiver, ObjClosure* method) { ObjBoundMethod* bound = ALLOCATE_OBJ(ObjBoundMethod, OBJ_BOUND_METHOD); bound->receiver = receiver; bound->method = method; return bound; }
類似建構子的函式只是儲存給定的閉包和接收者。當不再需要綁定方法時,我們會釋放它。
switch (object->type) {
在 freeObject() 中
case OBJ_BOUND_METHOD: FREE(ObjBoundMethod, object); break;
case OBJ_CLASS: {
綁定方法有幾個參考,但它不擁有它們,因此它只會釋放自己。但是,這些參考會被垃圾收集器追蹤。
switch (object->type) {
在 blackenObject() 中
case OBJ_BOUND_METHOD: { ObjBoundMethod* bound = (ObjBoundMethod*)object; markValue(bound->receiver); markObject((Obj*)bound->method); break; }
case OBJ_CLASS: {
此 確保 方法的控制代碼會讓接收者保留在記憶體中,以便在稍後呼叫控制代碼時,this
仍然可以找到該物件。我們也會追蹤方法閉包。
所有物件支援的最後一個操作是列印。
switch (OBJ_TYPE(value)) {
在 printObject() 中
case OBJ_BOUND_METHOD: printFunction(AS_BOUND_METHOD(value)->method->function); break;
case OBJ_CLASS:
綁定方法的列印方式與函式完全相同。從使用者的角度來看,綁定方法就是函式。它是一個他們可以呼叫的物件。我們不會暴露 VM 使用不同的物件類型來實作綁定方法。
戴上您的 派對 帽,因為我們剛剛達到了一個小小的里程碑。ObjBoundMethod 是新增到 clox 的最後一個執行階段類型。您已經寫完了最後的 IS_
和 AS_
巨集。我們距離本書的結尾只有幾章,而且我們正接近一個完整的 VM。
28 . 2 . 2存取方法
讓我們讓新的物件類型做點事情。方法是使用我們在上一章中實作的相同「點」屬性語法存取。編譯器已經解析正確的表達式並為它們發出 OP_GET_PROPERTY
指令。我們需要做的唯一變更是在執行階段中。
當執行屬性存取指令時,實例位於堆疊頂端。該指令的工作是找到具有給定名稱的欄位或方法,並以存取的屬性取代堆疊頂端。
直譯器已經處理欄位,因此我們只需使用另一個區段擴展 OP_GET_PROPERTY
的情況。
pop(); // Instance. push(value); break; }
在 run() 中
取代 2 行
if (!bindMethod(instance->klass, name)) { return INTERPRET_RUNTIME_ERROR; } break;
}
我們將此插入到程式碼之後,以便在接收者實例上查找欄位。欄位優先於方法並會遮蔽方法,因此我們先查找欄位。如果實例沒有具有給定屬性名稱的欄位,則該名稱可能指的是方法。
我們取得實例的類別並將其傳遞給新的 bindMethod()
輔助程式。如果該函式找到方法,它會將方法放在堆疊上並回傳 true
。否則,它會回傳 false
以表示找不到具有該名稱的方法。由於該名稱也不是欄位,這表示我們有一個執行階段錯誤,會中止直譯器。
以下是精華內容
在 callValue() 之後新增
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; }
首先,我們在類別的方法表中查找具有給定名稱的方法。如果我們找不到方法,我們會報告執行階段錯誤並跳出。否則,我們會取得該方法並將其包裝在新的 ObjBoundMethod 中。我們從堆疊頂端取得接收者。最後,我們會彈出實例,並以綁定方法取代堆疊頂端。
例如
class Brunch { eggs() {} } var brunch = Brunch(); var eggs = brunch.eggs;
以下是在 VM 執行 brunch.eggs
表達式的 bindMethod()
呼叫時會發生的情況

這是一個複雜的底層機制,但從使用者的角度來看,他們只會得到一個可以呼叫的函式。
28 . 2 . 3呼叫方法
使用者可以在類別上宣告方法、在實例上存取它們,並將綁定方法放到堆疊上。他們只是無法使用這些綁定方法物件做任何有用的事情。我們遺漏的操作是呼叫它們。呼叫是在 callValue()
中實作的,因此我們在那裡為新的物件類型新增一個案例。
switch (OBJ_TYPE(callee)) {
在 callValue() 中
case OBJ_BOUND_METHOD: { ObjBoundMethod* bound = AS_BOUND_METHOD(callee); return call(bound->method, argCount); }
case OBJ_CLASS: {
我們從 ObjBoundMethod 中取出原始閉包,並使用現有的 call()
輔助程式,透過將其 CallFrame 推送到呼叫堆疊上來開始呼叫該閉包。這就是能夠執行以下 Lox 程式所需的全部內容
class Scone { topping(first, second) { print "scone with " + first + " and " + second; } } var scone = Scone(); scone.topping("berries", "cream");
這有三個大步驟。我們可以宣告、存取和呼叫方法。但缺少了某些東西。我們費盡心思將方法閉包包裝在一個綁定接收者的物件中,但是當我們呼叫方法時,我們根本沒有使用該接收者。
28 . 3This
綁定方法需要保留接收者的原因是它可以在方法的主體內存取。Lox 透過 this
表達式公開方法的接收者。現在是時候引入一些新的語法了。詞法分析器已經將 this
視為特殊的 Token 類型,因此第一步是在解析表格中連接該 Token。
[TOKEN_SUPER] = {NULL, NULL, PREC_NONE},
取代 1 行
[TOKEN_THIS] = {this_, NULL, PREC_NONE},
[TOKEN_TRUE] = {literal, NULL, PREC_NONE},
當剖析器在字首位置遇到 this
時,它會分派給新的剖析器函式。
在 variable() 之後新增
static void this_(bool canAssign) { variable(false); }
我們將為 jlox 中使用的 this
採用相同的實作技術。我們將 this
視為詞法作用域的區域變數,其值會被神奇地初始化。像區域變數一樣編譯它意味著我們可以免費獲得許多行為。尤其是,方法內參考 this
的閉包會執行正確的操作,並在向上值中捕獲接收者。
當呼叫剖析器函式時,this
Token 剛剛被取用並儲存為前一個 Token。我們呼叫現有的 variable()
函式,該函式會將識別碼表達式編譯為變數存取。它會取得一個布林參數,以判斷編譯器是否應該尋找後面的 =
運算子並剖析設定器。您無法指定給 this
,因此我們傳遞 false
以禁止這樣做。
variable()
函式不關心 this
是否有自己的 Token 類型,且不是識別碼。它很樂意將詞素「this」視為變數名稱,然後使用現有的範圍解析機制來查找它。現在,該查找將失敗,因為我們從未宣告名稱為「this」的變數。現在是時候考慮接收者應該存在記憶體中的哪個位置。
至少在被閉包捕獲之前,clox 會將每個區域變數儲存在 VM 的堆疊上。編譯器會追蹤函式堆疊視窗中的哪些插槽歸哪些區域變數所有。如果您還記得,編譯器會透過宣告一個名稱為空字串的區域變數來保留堆疊插槽零。
對於函式呼叫,該插槽最終會保留正在呼叫的函式。由於該插槽沒有名稱,函式主體永遠不會存取它。您可以猜到接下來會發生什麼。對於方法呼叫,我們可以重新調整該插槽的用途來儲存接收者。插槽零會儲存 this
綁定的實例。為了編譯 this
表達式,編譯器只需要給予該區域變數正確的名稱。
local->isCaptured = false;
在 initCompiler() 中
取代 2 行
if (type != TYPE_FUNCTION) { local->name.start = "this"; local->name.length = 4; } else { local->name.start = ""; local->name.length = 0; }
}
我們只想對方法執行此操作。函式宣告沒有 this
。而且,事實上,它們絕對不能宣告名為「this」的變數,因此如果您在函式宣告內(本身在方法內)撰寫 this
表達式,this
會正確解析為外部方法的接收者。
class Nested { method() { fun function() { print this; } function(); } } Nested().method();
這個程式應該會印出「巢狀實例」。為了決定要給區域插槽零什麼名稱,編譯器需要知道它是在編譯函式還是方法宣告,因此我們在 FunctionType 列舉中新增一個案例來區分方法。
TYPE_FUNCTION,
在 enum FunctionType 中
TYPE_METHOD,
TYPE_SCRIPT
當我們編譯方法時,我們會使用該類型。
uint8_t constant = identifierConstant(&parser.previous);
在 method() 中
取代 1 行
FunctionType type = TYPE_METHOD;
function(type);
現在我們可以正確地編譯對特殊「this」變數的參考,而編譯器會發出正確的 OP_GET_LOCAL
指令來存取它。閉包甚至可以捕獲 this
並將接收者儲存在向上值中。非常酷。
除了在執行時,接收器實際上並不在槽位零。解譯器尚未履行其承諾。這是修復方法
case OBJ_BOUND_METHOD: { ObjBoundMethod* bound = AS_BOUND_METHOD(callee);
在 callValue() 中
vm.stackTop[-argCount - 1] = bound->receiver;
return call(bound->method, argCount); }
當方法被呼叫時,堆疊的頂部包含所有參數,然後在其下方是所呼叫方法的閉包。這就是新 CallFrame 中的槽位零所在的位置。此程式碼行將接收器插入該槽位。例如,給定一個像這樣的的方法呼叫
scone.topping("berries", "cream");
我們這樣計算儲存接收器的槽位

-argCount
跳過參數,而 - 1
調整是為了 stackTop
指向剛好超過最後一個已使用的堆疊槽位的事實。
28 . 3 . 1誤用 this
我們的 VM 現在支援使用者正確使用 this
,但我們也需要確保它能正確處理使用者誤用 this
的情況。Lox 規定在方法主體之外出現 this
運算式是編譯錯誤。這兩個錯誤的使用應該由編譯器捕捉到
print this; // At top level. fun notMethod() { print this; // In a function. }
那麼編譯器如何知道它是否在方法內部呢?顯而易見的答案是查看當前編譯器的 FunctionType。我們確實在那裡新增了一個枚舉案例來特殊處理方法。但是,這將無法正確處理像前面例子那樣,你在一個函數內部的程式碼,而該函數本身又巢狀於一個方法內部。
我們可以嘗試解析 "this",如果未在任何周圍的詞法作用域中找到它,則報告錯誤。這會奏效,但需要我們重新調整一堆程式碼,因為目前解析變數的程式碼如果找不到宣告,則會隱式地將其視為全域存取。
在下一章中,我們需要有關最近的封閉類別的資訊。如果我們有該資訊,我們可以在這裡使用它來確定我們是否在方法內部。因此,我們不妨讓未來的自己生活更輕鬆一點,現在就將該機制設置到位。
Compiler* current = NULL;
在變數 *current* 之後新增
ClassCompiler* currentClass = NULL;
static Chunk* currentChunk() {
此模組變數指向一個結構,該結構表示正在編譯的目前最內層的類別。新類型如下所示
} Compiler;
在結構 *Compiler* 之後新增
typedef struct ClassCompiler { struct ClassCompiler* enclosing; } ClassCompiler;
Parser parser;
目前,我們僅儲存封閉類別的 ClassCompiler 的指標(如果有的話)。在其他類別的方法中巢狀類別宣告是不常見的事情,但 Lox 支援它。就像 Compiler 結構一樣,這表示 ClassCompiler 會形成一個連結串列,從正在編譯的目前最內層類別開始,一路延伸到所有封閉類別。
如果我們根本不在任何類別宣告內,則模組變數 currentClass
為 NULL
。當編譯器開始編譯類別時,它會將新的 ClassCompiler 推入該隱式的連結堆疊。
defineVariable(nameConstant);
在 classDeclaration() 中
ClassCompiler classCompiler; classCompiler.enclosing = currentClass; currentClass = &classCompiler;
namedVariable(className, false);
ClassCompiler 結構的記憶體直接位於 C 堆疊上,這是我們使用遞迴下降編寫編譯器所獲得的一個方便的功能。在類別主體末尾,我們從堆疊中彈出該編譯器,並還原封閉的編譯器。
emitByte(OP_POP);
在 classDeclaration() 中
currentClass = currentClass->enclosing;
}
當最外層的類別主體結束時,enclosing
將為 NULL
,因此這會將 currentClass
重設為 NULL
。因此,要查看我們是否在類別內(也因此在方法內),我們只需檢查該模組變數即可。
static void this_(bool canAssign) {
在 this_() 中
if (currentClass == NULL) { error("Can't use 'this' outside of a class."); return; }
variable(false);
這樣一來,在類別外部使用 this
會被正確地禁止。現在,我們的方法確實感覺像物件導向意義上的「方法」。存取接收器可以讓它們影響你呼叫方法的實例。我們正在逐步達成目標!
28 . 4實例初始化器
物件導向語言將狀態和行為連結在一起的原因(該範例的核心原則之一)是為了確保物件始終處於有效、有意義的狀態。當接觸物件狀態的唯一方法是透過其方法時,這些方法可以確保不會出錯。但這假設物件已經處於適當的狀態。那麼當物件第一次被建立時呢?
物件導向語言透過建構函式確保全新的物件被正確地設定,建構函式既產生新的實例,又初始化其狀態。在 Lox 中,執行階段會配置新的原始實例,而類別可以宣告初始化器來設定任何欄位。初始化器主要像一般方法一樣運作,但有一些調整
-
每當建立類別的實例時,執行階段會自動呼叫初始化器方法。
-
建構實例的呼叫者始終會在初始化器完成後取回實例,無論初始化器函數本身傳回什麼。初始化器方法不需要明確傳回
this
。 -
實際上,禁止初始化器傳回任何值,因為無論如何都看不到該值。
既然我們支援方法,要新增初始化器,我們只需要實作這三個特殊規則。我們將按順序進行。
28 . 4 . 1呼叫初始化器
首先,在新的實例上自動呼叫 init()
vm.stackTop[-argCount - 1] = OBJ_VAL(newInstance(klass));
在 callValue() 中
Value initializer; if (tableGet(&klass->methods, vm.initString, &initializer)) { return call(AS_CLOSURE(initializer), argCount); }
return true;
在執行階段配置新的實例後,我們會在類別上尋找 init()
方法。如果找到,我們會啟動對它的呼叫。這會為初始化器的閉包推入新的 CallFrame。假設我們執行此程式
class Brunch { init(food, drink) {} } Brunch("eggs", "coffee");
當 VM 執行對 Brunch()
的呼叫時,它的流程如下

當我們呼叫類別時,傳遞給類別的任何參數仍然位於實例上方的堆疊中。 init()
方法的新 CallFrame 共用該堆疊視窗,因此這些參數會隱式地轉發到初始化器。
Lox 不要求類別定義初始化器。如果省略,執行階段只會傳回新的未初始化實例。但是,如果沒有 init()
方法,那麼在建立實例時將參數傳遞給類別就沒有任何意義。我們將其設為錯誤。
return call(AS_CLOSURE(initializer), argCount);
在 callValue() 中
} else if (argCount != 0) { runtimeError("Expected 0 arguments but got %d.", argCount); return false;
}
當類別確實提供初始化器時,我們還需要確保傳遞的參數數量與初始化器的元數相符。幸運的是,call()
輔助函數已經為我們做到了這一點。
為了呼叫初始化器,執行階段會依名稱查找 init()
方法。我們希望它能快速執行,因為每次建構實例時都會發生這種情況。這表示應該充分利用我們已經實作的字串池。為此,VM 會為「init」建立一個 ObjString 並重複使用它。該字串直接位於 VM 結構中。
Table strings;
在結構 VM 中
ObjString* initString;
ObjUpvalue* openUpvalues;
我們在 VM 啟動時建立並池化該字串。
initTable(&vm.strings);
在 initVM() 中
vm.initString = copyString("init", 4);
defineNative("clock", clockNative);
我們希望它能持續存在,因此 GC 會將其視為根。
markCompilerRoots();
在 markRoots() 中
markObject((Obj*)vm.initString);
}
仔細看。看到任何可能會發生的錯誤嗎?沒有?這是一個很微妙的錯誤。垃圾收集器現在會讀取 vm.initString
。該欄位是從呼叫 copyString()
的結果初始化的。但是複製字串會配置記憶體,這可能會觸發 GC。如果收集器在錯誤的時間執行,它會在 vm.initString
初始化之前讀取它。因此,首先我們將該欄位歸零。
initTable(&vm.strings);
在 initVM() 中
vm.initString = NULL;
vm.initString = copyString("init", 4);
由於下一行將釋放指標,因此當 VM 關閉時,我們會清除指標。
freeTable(&vm.strings);
在 freeVM() 中
vm.initString = NULL;
freeObjects();
好的,這讓我們可以呼叫初始化器。
28 . 4 . 2初始化器傳回值
下一步是確保建構具有初始化器的類別實例始終傳回新的實例,而不是 nil
或初始化器的主體傳回的任何內容。目前,如果類別定義了初始化器,那麼在建構實例時,VM 會將對該初始化器的呼叫推入 CallFrame 堆疊。然後它會繼續執行。
當該初始化器方法傳回時,使用者對類別建立實例的呼叫將會完成,並將初始化器放置在那裡的任何值留在堆疊中。這表示除非使用者注意在初始化器的末尾放置 return this;
,否則不會產生任何實例。這不是很有幫助。
為了修正此問題,每當前端編譯初始化器方法時,它都會在主體末尾發出不同的位元組碼,以便從方法傳回 this
,而不是大多數函數傳回的通常的隱式 nil
。為了做到這一點,編譯器需要實際知道它何時正在編譯初始化器。我們透過檢查我們正在編譯的方法的名稱是否為「init」來偵測這一點。
FunctionType type = TYPE_METHOD;
在 method() 中
if (parser.previous.length == 4 && memcmp(parser.previous.start, "init", 4) == 0) { type = TYPE_INITIALIZER; }
function(type);
我們定義了一個新的函數類型,以區分初始化器和其他方法。
TYPE_FUNCTION,
在 enum FunctionType 中
TYPE_INITIALIZER,
TYPE_METHOD,
每當編譯器在主體末尾發出隱式傳回時,我們會檢查類型以決定是否插入特定於初始化器的行為。
static void emitReturn() {
在 emitReturn() 中
取代 1 行
if (current->type == TYPE_INITIALIZER) { emitBytes(OP_GET_LOCAL, 0); } else { emitByte(OP_NIL); }
emitByte(OP_RETURN);
在初始化器中,我們不會在傳回之前將 nil
推入堆疊,而是載入包含實例的槽位零。當編譯不帶值的 return
陳述式時,也會呼叫此 emitReturn()
函數,因此這也會正確處理使用者在初始化器中提前傳回的情況。
28 . 4 . 3初始化器中的錯誤傳回
最後一步,也是我們初始化器特殊功能列表中的最後一個項目,是讓嘗試從初始化器傳回任何其他內容成為錯誤。現在編譯器會追蹤方法類型,這就很簡單了。
if (match(TOKEN_SEMICOLON)) { emitReturn(); } else {
在 returnStatement() 中
if (current->type == TYPE_INITIALIZER) { error("Can't return a value from an initializer."); }
expression();
如果初始化器中的 return
陳述式具有值,我們會報告錯誤。我們仍然會繼續編譯該值,以便編譯器不會被尾隨的運算式弄糊塗,並報告一堆級聯錯誤。
除了繼承(我們很快就會講到)之外,我們現在有一個在 clox 中運作的相當完整功能的類別系統。
class CoffeeMaker { init(coffee) { this.coffee = coffee; } brew() { print "Enjoy your cup of " + this.coffee; // No reusing the grounds! this.coffee = nil; } } var maker = CoffeeMaker("coffee and chicory"); maker.brew();
對於一個可以放進舊式軟碟的 C 程式來說,這相當精緻。
28 . 5最佳化呼叫
我們的 VM 正確實作了方法呼叫和初始化器的語言語意。我們可以在此停止。但我們從頭開始建立 Lox 的整個第二個實作的主要原因是要比舊的 Java 解譯器執行得更快。目前,即使在 clox 中,方法呼叫也很慢。
Lox 的語義將方法調用定義為兩個操作—存取方法,然後調用結果。我們的 VM 必須將這些作為獨立操作來支援,因為使用者可以將它們分開。你可以存取方法而不調用它,然後稍後再調用綁定的方法。目前我們實作的任何東西都不是不必要的。
但是,總是將這些作為獨立操作執行會產生顯著的成本。每次 Lox 程式存取並調用方法時,運行時堆積都會配置一個新的 ObjBoundMethod,初始化其欄位,然後立即將它們取回。稍後,GC 必須花時間釋放所有這些短暫的綁定方法。
大多數情況下,Lox 程式會存取方法,然後立即調用它。綁定方法由一個位元組碼指令建立,然後由緊接著的下一個指令使用。事實上,它非常即時,編譯器甚至可以從文字上看到它正在發生—帶點的屬性存取後接開括號很可能是方法調用。
由於我們可以在編譯時識別這對操作,我們有機會發出一個新的、特殊的指令,該指令執行最佳化的方法調用。
我們從編譯帶點屬性表達式的函數開始。
if (canAssign && match(TOKEN_EQUAL)) { expression(); emitBytes(OP_SET_PROPERTY, name);
在 dot() 中
} else if (match(TOKEN_LEFT_PAREN)) { uint8_t argCount = argumentList(); emitBytes(OP_INVOKE, name); emitByte(argCount);
} else {
在編譯器解析屬性名稱後,我們尋找左括號。如果我們匹配到一個,我們會切換到新的程式碼路徑。在那裡,我們像編譯調用表達式一樣編譯參數列表。然後我們發出一個新的 OP_INVOKE
指令。它需要兩個運算元
-
常數表中屬性名稱的索引。
-
傳遞給方法的參數數量。
換句話說,這個單一指令依序結合了它所取代的 OP_GET_PROPERTY
和 OP_CALL
指令的運算元。它確實是這兩個指令的融合。讓我們定義它。
OP_CALL,
在 enum OpCode 中
OP_INVOKE,
OP_CLOSURE,
並將其新增到反組譯器
case OP_CALL: return byteInstruction("OP_CALL", chunk, offset);
在 disassembleInstruction() 中
case OP_INVOKE: return invokeInstruction("OP_INVOKE", chunk, offset);
case OP_CLOSURE: {
這是一種新的、特殊的指令格式,因此需要一些自訂的反組譯邏輯。
在 constantInstruction() 之後新增
static int invokeInstruction(const char* name, Chunk* chunk, int offset) { uint8_t constant = chunk->code[offset + 1]; uint8_t argCount = chunk->code[offset + 2]; printf("%-16s (%d args) %4d '", name, argCount, constant); printValue(chunk->constants.values[constant]); printf("'\n"); return offset + 3; }
我們讀取兩個運算元,然後印出方法名稱和參數計數。在直譯器的位元組碼分派迴圈中,才是真正開始運作的地方。
}
在 run() 中
case OP_INVOKE: { ObjString* method = READ_STRING(); int argCount = READ_BYTE(); if (!invoke(method, argCount)) { return INTERPRET_RUNTIME_ERROR; } frame = &vm.frames[vm.frameCount - 1]; break; }
case OP_CLOSURE: {
大部分工作發生在 invoke()
中,我們稍後會討論。在這裡,我們從第一個運算元查閱方法名稱,然後讀取參數計數運算元。然後我們將其交給 invoke()
來完成繁重的工作。如果調用成功,該函數會返回 true
。像往常一樣,false
返回表示發生了執行階段錯誤。我們在這裡檢查該錯誤,如果發生災難,則中止直譯器。
最後,假設調用成功,則堆疊上會有一個新的 CallFrame,因此我們在 frame
中重新整理目前幀的快取副本。
有趣的工作在這裡發生
在 callValue() 之後新增
static bool invoke(ObjString* name, int argCount) { Value receiver = peek(argCount); ObjInstance* instance = AS_INSTANCE(receiver); return invokeFromClass(instance->klass, name, argCount); }
首先,我們從堆疊中取出接收器。傳遞給方法的參數位於堆疊上的上方,因此我們會向下窺視那麼多插槽。然後,將物件轉換為實例並在其上調用方法就很簡單了。
這確實假設物件是實例。與 OP_GET_PROPERTY
指令一樣,我們也需要處理使用者錯誤地嘗試在錯誤類型的值上調用方法的情況。
Value receiver = peek(argCount);
在 invoke() 中
if (!IS_INSTANCE(receiver)) { runtimeError("Only instances have methods."); return false; }
ObjInstance* instance = AS_INSTANCE(receiver);
這是一個執行階段錯誤,因此我們回報該錯誤並退出。否則,我們會取得實例的類別,並跳到這個其他新的實用函數
在 callValue() 之後新增
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); }
此函數依序結合了 VM 如何實作 OP_GET_PROPERTY
和 OP_CALL
指令的邏輯。首先,我們在類別的方法表中按名稱查閱方法。如果我們找不到任何方法,我們會回報該執行階段錯誤並退出。
否則,我們會取得方法的回調函數,並將對它的調用推送到 CallFrame 堆疊上。我們不需要堆積配置和初始化 ObjBoundMethod。事實上,我們甚至不需要在堆疊上處理任何東西。接收器和方法參數已經在它們需要的位置。
如果你啟動 VM 並執行一個現在調用方法的小程式,你應該會看到與之前完全相同的行為。但是,如果我們做得正確,效能應該會大幅提高。我編寫了一個小型的微基準測試,執行了 10,000 次方法調用批次。然後它測試在 10 秒內可以執行多少批次。在我的電腦上,在沒有新的 OP_INVOKE
指令的情況下,它完成了 1,089 個批次。透過此新的最佳化,它在相同的時間內完成了 8,324 個批次。這快了 7.6 倍,這在程式語言最佳化方面是一個巨大的改進。

28 . 5 . 1調用欄位
最佳化的基本信條是:「你不得破壞正確性。」使用者喜歡語言實作更快地給出答案,但前提是答案是正確的。唉,我們更快的方法調用實作未能堅持該原則
class Oops { init() { fun f() { print "not a method"; } this.field = f; } } var oops = Oops(); oops.field();
最後一行看起來像是方法調用。編譯器認為它是,並盡職地為它發出 OP_INVOKE
指令。但是,事實並非如此。實際發生的是一個欄位存取,它返回一個函數,然後調用該函數。現在,我們的 VM 並沒有正確執行該操作,而是在找不到名為「field」的方法時回報執行階段錯誤。
先前,當我們實作 OP_GET_PROPERTY
時,我們處理了欄位和方法存取。為了消除這個新錯誤,我們需要對 OP_INVOKE
執行相同的操作。
ObjInstance* instance = AS_INSTANCE(receiver);
在 invoke() 中
Value value; if (tableGet(&instance->fields, name, &value)) { vm.stackTop[-argCount - 1] = value; return callValue(value, argCount); }
return invokeFromClass(instance->klass, name, argCount);
非常簡單的修復。在查閱實例類別上的方法之前,我們會尋找具有相同名稱的欄位。如果我們找到一個欄位,則我們會將其儲存在堆疊上,以取代接收器,在參數列表下方。這是 OP_GET_PROPERTY
的行為方式,因為後者指令會在後續括號中的參數列表被評估之前執行。
然後,我們嘗試像它希望的可調用項一樣調用該欄位的值。callValue()
輔助函數將檢查值的類型並適當地調用它,如果欄位的值不是像回調函數這樣的可調用類型,則回報執行階段錯誤。
這就是使我們的最佳化完全安全所需要的一切。不幸的是,我們會犧牲一些效能。但這有時是你必須付出的代價。有時,你會因為如果語言不允許某些令人煩惱的邊緣情況,你可以進行的最佳化而感到沮喪。但是,作為語言實作者,我們必須玩我們被賦予的遊戲。
我們在這裡編寫的程式碼遵循最佳化的典型模式
-
識別效能至關重要的常見操作或操作序列。在這種情況下,它是方法存取後接調用。
-
新增該模式的最佳化實作。這就是我們的
OP_INVOKE
指令。 -
使用某些條件邏輯來保護最佳化程式碼,該邏輯會驗證該模式是否實際套用。如果套用,則保持在快速路徑上。否則,回退到較慢但更穩健的未最佳化行為。在這裡,這意味著檢查我們實際上是否正在調用方法而不是存取欄位。
隨著你的語言工作從使實作完全正常工作轉向使其工作更快,你會發現自己花費越來越多的時間尋找這樣的模式,並為它們新增受保護的最佳化。全職 VM 工程師的大部分職業生涯都在此迴圈中度過。
但我們可以到此為止。有了這個,clox 現在支援物件導向程式語言的大部分功能,並具有令人尊敬的效能。
挑戰
-
雜湊表查找以尋找類別的
init()
方法是恆定時間的,但仍然相當慢。實作一些更快的東西。編寫基準測試並測量效能差異。 -
在像 Lox 這樣的動態類型語言中,單個調用點可能會在程式執行期間調用多個類別上的各種方法。即便如此,在實務上,大多數情況下,調用點最終會在執行期間對完全相同的類別調用完全相同的方法。即使語言表示它們可以,大多數調用實際上都不是多型性的。
進階語言實作如何根據該觀察進行最佳化?
-
當直譯
OP_INVOKE
指令時,VM 必須執行兩次雜湊表查找。首先,它會尋找可能遮蔽方法的欄位,並且僅在失敗時才會尋找方法。前者的檢查很少有用—大多數欄位不包含函數。但這是必要的,因為語言表示欄位和方法是使用相同的語法存取的,而且欄位會遮蔽方法。這是一個影響我們實作效能的語言選擇。這是正確的選擇嗎?如果 Lox 是你的語言,你會怎麼做?
設計注意事項:新穎性預算
我仍然記得第一次在 TRS-80 上寫一個小小的 BASIC 程式,讓電腦做出之前沒做過的事情。那感覺就像擁有超能力一樣。第一次拼湊出足夠的剖析器和直譯器,讓我可以用自己的語言寫一個小程序,讓電腦做某件事,那就像某種更高層次的元超能力。那種感覺過去是,現在也仍然很美好。
我意識到我可以設計一種外觀和行為都由我選擇的語言。這就像我一生都在一所要求穿制服的私立學校上學,然後有一天轉到一所公立學校,我可以穿任何我想穿的衣服。我不需要用大括號來表示程式碼區塊?我可以使用等號以外的符號來賦值?我可以不用類別來實現物件?多重繼承和多重方法?一個可以根據參數數量靜態重載的動態語言?
很自然地,我帶著那份自由奔跑。我做出了最奇怪、最隨意的語言設計決策。用單引號表示泛型。參數之間沒有逗號。重載解析可能會在運行時失敗。我為了不同而做出不同的事情。
這是一個非常有趣的體驗,我強烈推薦。我們需要更多奇怪的、前衛的程式語言。我想看到更多藝術語言。我偶爾還是會為了好玩而製作一些古怪的玩具語言。
然而,如果你的目標是成功,而「成功」的定義是有大量的用戶,那麼你的優先事項必須不同。在這種情況下,你的主要目標是盡可能多地將你的語言載入到人們的大腦中。這真的非常困難。這需要大量的人力,才能將一種語言的語法和語義從電腦轉移到數萬億個神經元中。
程式設計師對自己的時間自然很保守,對於哪些語言值得上傳到他們的「濕件」(wetware,指人腦)中也很謹慎。他們不想浪費時間在一種最終對他們沒有用的語言上。作為一個語言設計者,你的目標因此是要盡可能在最少的學習成本下,給他們最大的語言能力。
一種自然的方法是簡潔。你的語言擁有的概念和特性越少,需要學習的總量就越少。這也是為什麼最小的腳本語言往往能獲得成功的原因之一,即使它們不像大型工業語言那樣強大—它們更容易上手,一旦進入某人的大腦,使用者就會想繼續使用它們。
簡潔的問題在於,單純地刪減功能往往會犧牲效能和表達能力。找到能發揮超乎其本身權重的特性的確是一門藝術,但通常簡潔的語言只是單純地做得更少。
還有另一條可以避免大部分問題的路徑。訣竅在於要意識到使用者不必將你的整個語言都載入到他們的腦海中,只要載入他們腦中還沒有的那部分。正如我在先前的設計筆記中提到的,學習是關於轉移他們已經知道的和他們需要知道的之間的差異。
許多你語言的潛在使用者已經知道其他程式語言。你的語言與該語言共享的任何特性,在學習方面基本上都是「免費的」。它已經在他們的腦海中,他們只需要認識到你的語言做的是同樣的事情。
換句話說,熟悉感是另一個降低你語言採用成本的關鍵工具。當然,如果你完全最大化這個屬性,最終結果將是一種與現有語言完全相同的語言。這並不是成功的秘訣,因為那樣使用者根本沒有動力轉向你的語言。
因此,你確實需要提供一些引人注目的差異。一些你的語言可以做到而其他語言不能做到的事情,或至少不能做得那麼好的事情。我認為這是語言設計的根本平衡行為之一:與其他語言相似可以降低學習成本,而差異則可以提高引人注目的優勢。
我將這種平衡行為視為一個新奇預算,或如 Steve Klabnik 所說的,一個「怪異預算」。使用者對於他們願意接受學習新語言的新東西總量有一個低閾值。超過這個閾值,他們就不會出現。
每當你為你的語言添加其他語言沒有的新東西,或者你的語言以不同的方式執行其他語言所做的事情時,你都會花費一些預算。這沒關係—你需要花費它來讓你的語言引人注目。但你的目標是明智地花費它。對於每個功能或差異,問問自己它為你的語言增加了多少引人注目的力量,然後批判性地評估它是否值得。這個改變是否非常寶貴,值得你花費一些新奇預算?
在實踐中,我發現這意味著你最終會對語法非常保守,而對語義更加冒險。雖然換上一件新衣服很有趣,但用其他區塊分隔符替換大括號不太可能為語言增加多少真正的力量,但它確實花費了一些新奇預算。語法差異很難發揮其應有的作用。
另一方面,新的語義可以顯著提高語言的效能。多重方法、混入、特徵、反射、依賴類型、運行時元編程等等,都可以大幅提升使用者可以使用該語言做的事情。
唉,像這樣保守並不像是直接改變一切那麼有趣。但首先由你決定你是否要追求主流的成功。我們並不是都需要成為適合電台播放的流行樂團。如果你希望你的語言像自由爵士樂或嗡鳴金屬一樣,並且對比例較小(但可能更忠誠)的受眾規模感到滿意,那就去做吧。