本教材的使用方式:本講義整合了第十一週四份投影片 (11-1 The Jack language、11-2 Compiling Jack、 11-3 Compiling Jack’s classes、11-4 Summing up and looking forward) 的全部內容,並補充 nand2tetris 第 10、11 章的相關細節。 每章結尾附有「本章重點」整理;第 5 章為綜合練習題,附完整詳解。 這是課程的最後一週:終於抵達抽象階梯的最頂端—— 高階語言 Jack,並學習把它編譯到 Hack VM。 搭配前兩週的 VM 翻譯器與第八週的組譯器, 我們就有了一條完整的編譯鏈: Jack \(\to\) Hack VM \(\to\) 組語 \(\to\) 機器碼。 注意:Jack 語言本身不列入考試——本課程在乎的不是 用 Jack 寫程式,而是編譯 Jack。

Jack 的精神

討論 Jack 時最常出現的兩句話:

  • 就像 C,但是……

  • 為了(編譯的)簡單起見……

Jack 的每一個設計決策都圍繞一個目標: 讓學生能在一週內寫出它的編譯器。 語言的不便之處(稍後一一列出)全是刻意用 「程式設計師多打幾個字」換取「編譯器少寫幾百行」。

變數、敘述與註解

  • 變數宣告像 C,但開頭加 var 關鍵字: var int x;var char a, b, c;

  • 內建型別只有 charintboolean; 函式庫另提供 ArrayString

  • 與 C 不同,宣告時不能初始化var int x = 0; 不合法。

  • 為求簡單,所有變數必須在函式開頭宣告。

  • 指定敘述像 C,但開頭加 letlet x = 5;。不支援 +=*= 等複合指定。

  • 註解與 C 相同(///*...*/), 換行與空白被忽略(只用來分隔權杖)。

這些額外關鍵字(varlet)純粹是讓解析與編譯 容易一點——BASIC 等早期語言的常見特徵: 看第一個權杖就知道這行是什麼敘述

函式

函式宣告像 C,但開頭加 function 關鍵字:

function int Main.max (int x, int y) {
    // 函式本體
}

無返回值的函式用 void 宣告。 返回語法同 C(return 42;return;), 但每個函式都必須以 return 結尾。 函式呼叫也像 C,唯一例外: 只呼叫函式並丟棄返回值的一行必須以 do 開頭——Main.print("Hello"); 不合法, do Main.print("Hello"); 才合法。

運算式

在所有程式語言中,運算式是敘述中會回傳值的部分。 字面值(5true"Hello")是運算式, 變數名也是。凡是能寫字面值的地方都能寫更複雜的運算式, 且長度與複雜度沒有上限doletreturn 後面都能接運算式, 函式呼叫的引數也能是運算式。

支援 為簡單起見不支援
算術:+-*/ 模除 %;位移 <<>>
邏輯:&|~(NOT)—— 兼作位元與邏輯運算 取址/解參考(單元 *&
比較:=(不是 ==)、>< ++--!=<=>=
字面值:整數、字串、truefalsenull(=false 運算式內的指定(賦值)
陣列下標 [];變數;函式呼叫;括號 () 三元運算子 ?:
沒有運算子優先順序(無括號時)! 1+2*3 可能是 7 也可能是 9

流程控制

Jack 支援與 C 相同的 if-elsewhile,但:

  • 不支援 else if——要寫成 else { if (...) {...} }(巢狀);

  • 不支援 do...whilefor

if (x > 5 & ~(y + f(x) = 7)) {
    // ...
} else { if (z = 2) {   // else if 不合法,巢狀才合法
    // ...
}}
while (x < 5 | Main.fibonacci(x) = 13) {
    // ...
}

型別

Jack 沒有轉型(cast)intcharboolean 之間支援隱含轉換char 依 Hack 字元集視為 inttrue \(= -1\)false \(= 0\)。例如:

var int x; var boolean y; var char z;
let x = -1; let y = x; let z = x + 71;

合法,且 ytruez"F"\(-1 + 71 = 70\),Hack 字元集的 F)。 實作上「免費」取得:把 charboolean 在記憶體中一律存成 int,然後完全忽略型別資訊。 (認真做型別檢查會極度複雜——甚至有論文證明 Java 的精確型別 檢查在理論上不可判定,除非接受檢查器可能不停機!)

一個「新」概念:類別(Classes)

Jack 用類別取代 C 的 struct。 類別是物件導向程式設計(OOP,下學期)的核心, 但幸運的是,Jack 缺少讓類別比 struct 複雜的所有(眾多) 特性。Jack 類別與 C struct 唯一的實質差別:

  • C 中與 struct 相關的函式與 struct 定義分離 (甚至可以在不同檔案);

  • Jack 中與類別相關的函式包含在類別定義之內。

類別變數:field 與 static

  • field 變數 = C 的一般 struct 成員;

  • static 變數為該類別所有成員共享 (C 的 struct 沒有這個);

  • 類別變數用一般語法宣告(var Foo myFoo;), 所有類別變數必須在類別開頭宣告;

  • Jack 不支援 myFoo.x 語法存取欄位—— 改用方法

方法(Methods)

方法是「屬於」類別的特殊函式,只能從該類別的 特定實例呼叫。方法定義內部: 類別變數的欄位像區域變數一樣直接使用 (取代 C 的 myFoo.x);類別變數本身可用 this 關鍵字存取;methodCall() 解讀為 this.methodCall()

C 版本:

void twiddleFoo(struct Foo *abc) {
    abc->x = abc->x + abc->y;
    abc->y = abc->y - 1;
}
twiddleFoo(myFoo);
twiddleFoo(myOtherFoo);

Jack 版本:

class Foo {
    method void twiddle() {
        let x = x + y;
        let y = y - 1;
    }
}
// 呼叫端:
do myFoo.twiddle();
do myOtherFoo.twiddle();

第一次呼叫時 thismyFoo、 第二次是 myOtherFoo——正如 C 版的 abc

建構子(Constructors)

建構子是用來建立新類別變數的特殊函式。 執行期,建構子在呼叫開始時自動在堆積上建立新變數, this 指向它,且建構子應以 return this; 結尾:

class Foo {
    field int x, y;
    constructor Foo newFoo(int a) {
        let x = 5;
        let y = a;
        return this;
    }
}
// 呼叫端:
let myFoo = Foo.newFoo(42);

這對應 C 的 malloc(sizeof(Foo)) +欄位初始化+ 回傳指標。呼叫建構子與函式時必須帶類別名前綴 (Foo.newFoo(),不能只寫 newFoo())。 注意:Jack 的類別配置在堆積上,所以必須像 C 的 malloc 指標一樣手動釋放——慣例是寫一個 dispose 方法,內部呼叫 Memory.deAlloc(this)。 (投影片的兩個範例其實都漏了釋放——都會漏記憶體!)

[] 語法與記憶體後門

Jack 中所有類別型變數都存在堆積, 只有 intcharboolean 存在堆疊。 實際上類別型變數就是指標——把 Foo 型變數 當 int 讀,會看到欄位所在的堆積位址。 運算式 myObject[i] 的意思是: 取 myObject 的位址、加 \(i\)、回傳該處的值。 ArrayString 類別的實作保證位址 就是陣列/字串的第一個元素,所以語法行為符合直覺。 (這是純 Jack 的做法——C 的 struct 放堆疊!)

這也給了我們一個記憶體後門

var int x;
let x = 16384;
let x[0] = 0;   // 把 RAM[0x4000](螢幕第一個字)設為 0

多檔案編譯與範例程式

每個 Jack 檔案含一個類別宣告(Foo.jack 含類別 Foo)。一切都必須在類別內; 不屬於任何類別的程式碼放 Main 類別。 程式從 Main.main() 開始(類比 C 的 main)。 每個 Jack 檔案分開編譯成 Hack VM, 再由 VM 翻譯器合併、組譯器產出機器碼。 Jack 檔案可以使用其他檔案定義的類別—— 編譯器直接假設它們存在 (省掉 C 的 makefile,代價是更差的錯誤處理)。

投影片以 Nisan and Schocken 的 Average 程式收尾: 讀入若干數字、用 Array.new 配置陣列、 while 迴圈累加、輸出平均值—— 其中 Keyboard.readIntOutput.printString 等都來自標準函式庫(寫編譯器時不需要管它們!)。

本章重點

  • Jack = 「像 C,但為編譯簡單而處處讓步」; 語言細節不考,編譯才是重點。

  • varletdo 前綴關鍵字 \(\Rightarrow\) 第一個權杖決定敘述種類。

  • 無運算子優先順序(1+2*3 不確定)、 無 else if、無 for、無轉型、 宣告不能初始化。

  • 類別 = struct +內建函式;fieldstatic /方法/建構子;物件在堆積、手動 dispose

  • myObject[i] = \(RAM[\texttt{myObject} + i]\) ——兼作陣列語法與記憶體後門。

編譯 Jack(Compiling Jack)

詞法分析(Lexing)

Jack 的詞法分析毫無驚喜,技術與之前完全相同。權杖如下:

類別 內容
關鍵字 classconstructorfunctionmethodfieldstaticintcharbooleanvoidvarletdoifelsewhilereturntruefalsenullthis
符號 + - * / & | ~ < > ={ } [ ] ( ) . , ;
整數字面值 十進位 \(0 \ldots 32767\)
字串字面值 雙引號夾住、不含換行與額外雙引號的字元序列
識別字 字母、數字、底線組成,不以數字開頭、非關鍵字

兩個值得注意的地方:

  • 識別字的定義比 Hack VM/組語更嚴格—— 這讓 myVar+4 不加空格也能無歧義地切出權杖;

  • 換行不是權杖!跟 C 一樣只是空白。 (對照:組語與 VM 都以換行分隔指令。)

Jack 的文法(EBNF)

回憶我們的 EBNF 方言:{} 表示「重複任意次 (含零次)」、[] 表示「可有可無」。

\[\begin{align*} \ensuremath{\textcolor{ntred}{\langle\text{\itshape class}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{class}'}}},\ \text{identifier},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\{}'}}},\ \{\ensuremath{\textcolor{ntred}{\langle\text{\itshape classVarDec}\rangle}}\},\ \{\ensuremath{\textcolor{ntred}{\langle\text{\itshape subroutineDec}\rangle}}\},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\}}'}}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape classVarDec}\rangle}} \;::=\;{} & (\ensuremath{\text{\textcolor{tokblue}{`\texttt{static}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{field}'}}}),\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape type}\rangle}},\ \text{identifier},\ \{\ensuremath{\text{\textcolor{tokblue}{`\texttt{,}'}}},\ \text{identifier}\},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{;}'}}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape type}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{int}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{char}'}}} \;|\; \ensuremath{\text{\textcolor{tokblue}{`\texttt{boolean}'}}} \;|\;\text{identifier}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape subroutineDec}\rangle}} \;::=\;{} & (\ensuremath{\text{\textcolor{tokblue}{`\texttt{constructor}'}}} \;|\; \ensuremath{\text{\textcolor{tokblue}{`\texttt{function}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{method}'}}}),\ (\ensuremath{\text{\textcolor{tokblue}{`\texttt{void}'}}} \;|\;\ensuremath{\textcolor{ntred}{\langle\text{\itshape type}\rangle}}),\\ & \text{identifier},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{(}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape parameterList}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{)}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape subroutineBody}\rangle}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape parameterList}\rangle}} \;::=\;{} & [\ensuremath{\textcolor{ntred}{\langle\text{\itshape type}\rangle}},\ \text{identifier},\ \{\ensuremath{\text{\textcolor{tokblue}{`\texttt{,}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape type}\rangle}},\ \text{identifier}\}]\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape subroutineBody}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{\{}'}}},\ \{\ensuremath{\textcolor{ntred}{\langle\text{\itshape varDec}\rangle}}\},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape statements}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\}}'}}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape varDec}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{var}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape type}\rangle}},\ \text{identifier},\ \{\ensuremath{\text{\textcolor{tokblue}{`\texttt{,}'}}},\ \text{identifier}\},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{;}'}}} \end{align*}\]

注意所有變數都宣告在類別開頭(\(\textcolor{ntred}{\langle\text{\itshape classVarDec}\rangle}\))或 函式開頭(\(\textcolor{ntred}{\langle\text{\itshape varDec}\rangle}\))——建符號表時, 在需要產生任何涉及變數的程式碼之前, 變數的全部資訊都已到手

\[\begin{align*} \ensuremath{\textcolor{ntred}{\langle\text{\itshape statements}\rangle}} \;::=\;{} & \{\ensuremath{\textcolor{ntred}{\langle\text{\itshape letStatement}\rangle}} \;|\; \ensuremath{\textcolor{ntred}{\langle\text{\itshape ifStatement}\rangle}} \;|\;\ensuremath{\textcolor{ntred}{\langle\text{\itshape whileStatement}\rangle}}\\ & \quad \;|\;\ensuremath{\textcolor{ntred}{\langle\text{\itshape returnStatement}\rangle}} \;|\;\ensuremath{\textcolor{ntred}{\langle\text{\itshape doStatement}\rangle}}\}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape letStatement}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{let}'}}},\ \text{identifier},\ [\ensuremath{\text{\textcolor{tokblue}{`\texttt{[}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{]}'}}}],\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{=}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{;}'}}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape ifStatement}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{if}'}}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{(}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{)}'}}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\{}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape statements}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\}}'}}},\\ & \quad [\ensuremath{\text{\textcolor{tokblue}{`\texttt{else}'}}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\{}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape statements}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\}}'}}}]\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape whileStatement}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{while}'}}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{(}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{)}'}}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\{}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape statements}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\}}'}}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape doStatement}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{do}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{;}'}}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape returnStatement}\rangle}} \;::=\;{} & \ensuremath{\text{\textcolor{tokblue}{`\texttt{return}'}}},\ [\ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}}],\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{;}'}}} \end{align*}\]

注意 \(\textcolor{ntred}{\langle\text{\itshape ifStatement}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape whileStatement}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape statements}\rangle}\)遞迴\(\textcolor{ntred}{\langle\text{\itshape statements}\rangle}\) 內不能宣告新變數(宣告只出現在 \(\textcolor{ntred}{\langle\text{\itshape varDec}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape classVarDec}\rangle}\)), 所以不必追蹤獨立的作用域

\[\begin{align*} \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}} \;::=\;{} & \ensuremath{\textcolor{ntred}{\langle\text{\itshape term}\rangle}},\ \{(\ensuremath{\text{\textcolor{tokblue}{`\texttt{+}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{-}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{*}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{/}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{\&}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{|}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{<}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{>}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{=}'}}}),\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape term}\rangle}}\}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape term}\rangle}} \;::=\;{} & \text{integer literal} \;|\; \text{string literal} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{true}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{false}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{null}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{this}'}}}\\ &\;|\;\text{identifier},\ [\ensuremath{\text{\textcolor{tokblue}{`\texttt{[}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{]}'}}}] \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{(}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{)}'}}}\\ &\;|\;((\ensuremath{\text{\textcolor{tokblue}{`\texttt{-}'}}} \;|\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{\~{}}'}}}),\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape term}\rangle}}) \;|\;\ensuremath{\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}} \;::=\;{} & \text{identifier},\ [\ensuremath{\text{\textcolor{tokblue}{`\texttt{.}'}}},\ \text{identifier}],\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{(}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expressionList}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{)}'}}}\\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expressionList}\rangle}} \;::=\;{} & [\ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \{\ensuremath{\text{\textcolor{tokblue}{`\texttt{,}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}}\}] \end{align*}\]

看看運算式遞迴得多兇!而這種遞迴是捕捉 Math.sqrt((x+y)*Main.fibonacci(z)) 這類運算式 絕對必要的。我們拖延了很久,但現在必須 用解析樹來處理它們——也就是終於把解析程式碼產生分開。 幸好 Jack 是 LL(2) 語言(而且接近 LL(1)), 過程不會太痛苦。

用 XML 儲存解析樹

解析趟與後續程式碼產生趟之間,解析樹要存在哪?理想上要: 人類可讀(方便除錯)、夠標準 (讀寫檔案不費事)、夠彈性 (不必整棵樹載入記憶體就能瀏覽)。 nand2tetris 正確地指出 XML 三項全中—— 多數語言都有現成的標準函式庫。 當然,C 不是「多數語言」…… 所以課程把第 8–10 週的權杖程式碼改造成 陽春的 XML 處理函式庫放進骨架; 詞法器也用同一函式庫把權杖輸出成 XML 格式。

一個(相當)通用的 LL(2) 解析器

解析權杖串列時,維護兩個指標:current (第一個尚未解析的權杖)與 lookahead(第二個)。 每個非終端符號有自己的函式(parse_termparse_statements……)。parse_abc 的契約:

  • 呼叫開始時,current 是一個 \(\textcolor{ntred}{\langle\text{\itshape abc}\rangle}\) 非終端符號的第一個權杖;

  • 產生該非終端符號的全部 XML (用 write_tag 寫開閉標籤、 copy_tag 複製權杖);

  • 返回時 current 指向 \(\textcolor{ntred}{\langle\text{\itshape abc}\rangle}\) 結束後的第一個權杖。

達成的手段:遞迴。 唯一需要 lookahead 的地方是解析 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\): 若 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 的第一個權杖是識別字, 它可能是變數,也可能是 \(\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}\) 的函式名—— 看下一個權杖是不是 \(\text{\textcolor{tokblue}{`\texttt{(}'}}\)\(\text{\textcolor{tokblue}{`\texttt{.}'}}\) 才能分辨。

投影片逐步解析了:

while (count < 100) {
    let count = count + 1;
}

關鍵步驟(縮排表示遞迴深度):

  1. parse_while_statement:寫 <whileStatement>,複製 while(

  2. parse_expression:寫 <expression>

  3. parse_term:由 lookahead (是 <,不是 (.)判定 count 不是 \(\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}\),複製識別字; current 不是 [\(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 結束;

  4. current<,運算式未完: 複製 <,再遞迴 parse_term 處理 100(整數字面值直接收尾);

  5. current),不是運算子 \(\Rightarrow\) \(\textcolor{ntred}{\langle\text{\itshape expression}\rangle}\) 結束、寫閉標籤返回;

  6. 複製 ){,進入 parse_statements \(\to\) parse_let_statement(同樣模式)……

  7. current},不是 let/while/if/do/return \(\Rightarrow\) \(\textcolor{ntred}{\langle\text{\itshape statements}\rangle}\) 結束,複製 }、寫閉標籤、返回。

每一步只看 current(偶爾加 lookahead) 就能決定動作——這就是 LL(2) 的實際體感。

從解析樹到 AST

我們的文法已經比 Nisan and Schocken 原版刪掉許多多餘的 非終端符號(如 \(\textcolor{ntred}{\langle\text{\itshape op}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape varName}\rangle}\))。 建解析樹時,還應把 \(\textcolor{ntred}{\langle\text{\itshape type}\rangle}\) 替換成它的定義。 更進一步:if{}() 這些權杖 對「把權杖串變成 \(\textcolor{ntred}{\langle\text{\itshape ifStatement}\rangle}\)」有用, 但每個 \(\textcolor{ntred}{\langle\text{\itshape ifStatement}\rangle}\) 都長一樣—— 把它變成程式碼時真的需要這些節點嗎? 這正是 AST(抽象語法樹)的用意,且沒有唯一正解。 建議做法:先寫包含解析樹每個節點的程式碼、 用 diff 對照提供的 XML 輸出測試, 再考慮要從 AST 略去什麼(把 copy_tag 換成 advance_tag)。

語意分析:變數映射與符號表

Jack 宣告 VM 段 宣告位置
var local \(\textcolor{ntred}{\langle\text{\itshape subroutineBody}\rangle}\) 開頭的 \(\textcolor{ntred}{\langle\text{\itshape varDec}\rangle}\)
函式參數 argument \(\textcolor{ntred}{\langle\text{\itshape subroutineDec}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape parameterList}\rangle}\)
static static \(\textcolor{ntred}{\langle\text{\itshape class}\rangle}\) 開頭的 \(\textcolor{ntred}{\langle\text{\itshape classVarDec}\rangle}\)
field this \(\textcolor{ntred}{\langle\text{\itshape class}\rangle}\) 開頭的 \(\textcolor{ntred}{\langle\text{\itshape classVarDec}\rangle}\)
that:讓「[] 與 field 並用」的 運算式順利編譯(this 給欄位、that 給 []
temp:編譯單一敘述時的暫存; pointerconstant 的用途已明

Jack 被非常小心地設計成不需要獨立一趟來建符號表或 做其他語意分析。解析之後直接進入程式碼產生, 從頭到尾掃一遍檔案、邊讀 XML 邊產出程式碼。 處理 \(\textcolor{ntred}{\langle\text{\itshape subroutineDec}\rangle}\) 時,遇到開標籤就:

  1. 為該副程式建一張新符號表

  2. 若是方法,先加一筆 argument 項目給 this、位移 0(原因見第 3 章!);

  3. \(\textcolor{ntred}{\langle\text{\itshape parameterList}\rangle}\) 的每個變數加一筆 argument 項目;

  4. \(\textcolor{ntred}{\langle\text{\itshape subroutineBody}\rangle}\) 每個 \(\textcolor{ntred}{\langle\text{\itshape varDec}\rangle}\) 加 local 項目;

  5. 用符號表為 \(\textcolor{ntred}{\langle\text{\itshape statements}\rangle}\) 產生程式碼;

  6. \(\textcolor{ntred}{\langle\text{\itshape subroutineBody}\rangle}\) 結尾時釋放符號表 (不再需要)。

每筆符號表項目包含:變數名(查詢鍵)、 型別(字串)、種類(local/argument……)、 位移(貪婪分配)。 例如 Nisan and Schocken 圖 11.2 的表把 this 映射到 argument 0otherargument 1dxlocal 0dylocal 1

程式碼產生:遞迴的真正威力

程式碼產生使用與解析非常相似的遞迴結構。

回憶 \(\ensuremath{\textcolor{ntred}{\langle\text{\itshape whileStatement}\rangle}} \;::=\;\ensuremath{\text{\textcolor{tokblue}{`\texttt{while}'}}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{(}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{)}'}}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\{}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape statements}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{\}}'}}}\), 而你從第 5 週就知道怎麼用 goto 與標籤做 while 迴圈! compile_while 可以:

  1. 產生兩個未用過的 VM 標籤,如 while_start_4while_end_4

  2. 輸出 label while_start_4

  3. 前進到 \(\textcolor{ntred}{\langle\text{\itshape expression}\rangle}\),呼叫 compile_expression——輸出「把迴圈條件的結果 推上堆疊」的 VM 碼;

  4. 輸出 notif-goto while_end_4

  5. 前進到 \(\textcolor{ntred}{\langle\text{\itshape statements}\rangle}\),呼叫 compile_statements 輸出迴圈本體;

  6. 輸出 goto while_start_4label while_end_4

  7. 前進越過 } 與閉標籤,返回。

回憶 \(\ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}} \;::=\;\ensuremath{\textcolor{ntred}{\langle\text{\itshape term}\rangle}},\ \{(\text{op}),\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape term}\rangle}}\}\)compile_expression 可以:

  1. 呼叫 compile_term——輸出「把第一個 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 的結果推上堆疊」的 VM 碼;

  2. 只要還有 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\): 存下運算子、前進到下一個 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\)、 呼叫 compile_term, 再輸出對堆疊頂兩值執行該運算的 VM 碼—— + 輸出 add* 輸出 call Math.multiply 2 (Hack VM 沒有乘法指令!);

  3. 返回。

記住 Jack 沒有運算子優先順序(除非加括號), 所以這種由左至右的做法不會破壞任何東西—— 這正是 () 只作為 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 一部分出現的原因。

到此,完整 Jack-to-Hack 編譯器只剩一塊拼圖: 類別型變數與方法的程式碼產生——見下一章。

本章重點

  • 詞法:識別字比 VM 嚴格(可無空格切分)、 換行只是空白。

  • 變數全部宣告在開頭 \(\Rightarrow\) 建符號表不需獨立一趟; \(\textcolor{ntred}{\langle\text{\itshape statements}\rangle}\) 不含宣告 \(\Rightarrow\) 不需作用域堆疊。

  • 運算式的遞迴文法逼出真正的解析樹(XML 儲存); Jack 是 LL(2)——只有 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 開頭的識別字需要 lookahead。

  • 變數映射:var\(\to\)local、參數\(\to\)argument、 static\(\to\)static、field\(\to\)this。

  • 程式碼產生 = 對樹遞迴:while 用標籤對+ not/if-goto;運算式由左至右、 * 呼叫 Math.multiply

編譯 Jack 的類別

物件的運作方式

類別的實例稱為物件。在 Jack 中, 每個物件都是一個指標式陣列。對任何類別 Foo

var Foo myFoo;
// (初始化 myFoo 的程式碼)
do Output.printInt(myFoo);   // 印出 myFoo 存放的 RAM 位址!

Foo 依序定義欄位 xyzmyFoo.x 存於 \(RAM[\texttt{myFoo}]\)myFoo.y 存於 \(RAM[\texttt{myFoo}+1]\)myFoo.z 存於 \(RAM[\texttt{myFoo}+2]\)。 因此 myFoo[i] 求值為 \(RAM[\texttt{myFoo}+i]\) 是自然的——本例中 myFoo[2] 就是 myFoo.z

所有物件欄位都配置在堆積;指標本身像一般 var 一樣存在堆疊。 (與 C 的 struct 非常相似,除了 C 的 struct 可以放堆疊、 且要處理不同欄位大小!)

副程式的期望行為

類別可含函式、方法與建構子,合稱副程式 (subroutines)(不尋常的術語——通常 subroutine 指 void 函式——但沿用 Nisan and Schocken)。 所有副程式 myClass.mySub 應該:

  • 編譯呼叫時:輸出把各引數(\(\textcolor{ntred}{\langle\text{\itshape expression}\rangle}\)) 推上堆疊的 VM 碼,接著輸出 call myClass.mySub

  • 編譯 \(\textcolor{ntred}{\langle\text{\itshape parameterList}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape varDec}\rangle}\) 時:建符號表, 用它把變數名換成編號的 local/argument;

  • 編譯返回時:把返回的 \(\textcolor{ntred}{\langle\text{\itshape expression}\rangle}\) 推上堆疊 (無返回值就推假值),再輸出 return

  • 編譯 \(\textcolor{ntred}{\langle\text{\itshape doStatement}\rangle}\) 時:\(\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}\) 之後 務必 pop temp 0——丟棄無人要的返回值, 避免堆疊上的「記憶體洩漏」。

三種副程式的差異

函式可以無視宿主類別(除了 static 變數)。 但建構子與方法都關聯到其類別的當前物件 (current object)

  • 建構子被呼叫時自動建立當前物件—— 用 Memory.alloc 在堆積配置適當大小的段 (重點是在呼叫結尾把當前物件返回);

  • 方法通常用 myVar.myMethod() 語法呼叫(而非 myClass.myMethod()), 以 myVar 為當前物件。

在方法與建構子的本體內:this 求值為當前物件; 宿主類別的欄位 x 解讀為「thisx」; 宿主類別的方法可用 myMethod() 呼叫 (解讀為 this.myMethod())。 這對之後的 OOP 至關重要;在 Jack 的脈絡裡, 它只是讓你能寫 myToken.write(output) 而不是 write_token(myToken, output)

(附註:Jack 支援 C 式的 myObject.myField 欄位存取——要編譯它, 必須在編譯某檔案時取得另一個檔案的類別欄位資訊, 那需要跨所有檔案的完整語意分析趟——不是不可能,但很煩。)

類別符號表與 this 的角色

與副程式符號表一樣,我們只在類別自身的程式碼內 需要知道 field/static 變數存在哪。遇到 \(\textcolor{ntred}{\langle\text{\itshape class}\rangle}\) 開標籤: 建新符號表 \(\to\) 為每個 \(\textcolor{ntred}{\langle\text{\itshape classVarDec}\rangle}\) 的變數加項目 (field 與 static 分開編位移\(\to\) 在其後每個 \(\textcolor{ntred}{\langle\text{\itshape subroutineDec}\rangle}\) 的程式碼產生中使用 \(\to\) 遇閉標籤釋放。

我們終於用上 Hack VM 的 this 段! 不變式:this 0 永遠存放在當前物件 指向的位址。只要維持它,Jack 的「第 \(i\) 個欄位」 就永遠映射到 VM 的 this i

編譯 \(\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}\) 時:

  • 僅方法:把當前物件推上堆疊作為新的第一個引數 (並加進符號表),再編譯其餘 \(\textcolor{ntred}{\langle\text{\itshape expressionList}\rangle}\)call 的引數數量相應 \(+1\)

  • 分辨方法呼叫與其他呼叫:看有沒有 \(\text{\textcolor{tokblue}{`\texttt{.}'}}\)、 以及 \(\text{\textcolor{tokblue}{`\texttt{.}'}}\) 左邊的識別字是不是變數

編譯 \(\textcolor{ntred}{\langle\text{\itshape subroutineDec}\rangle}\) 時:

  • 方法:把 pointer 0 設為 argument 0 (即呼叫者傳來的當前物件);

  • 建構子:呼叫 Memory.alloc 配置新物件 (大小由類別符號表算出),把 pointer 0 設為基底位址;

  • 兩者:在副程式本體中避免再動 pointer 0

函式 建構子 方法
呼叫語法 myClass.mySub(a,b) myVar.mySub(a,b)mySub(a,b)
呼叫時 一般行為 myVar 加為 argument 0
開始時 一般行為 this 基底 \(\leftarrow\) 新物件 this 基底 \(\leftarrow\) myVar
本體中 一般行為 欄位讀作 this 的欄位; myMethod(a) 讀作 this.myMethod(a)
返回時 一般行為(建構子應永遠 return this

編譯 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\)

compile_term 的目標:產生「求出 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 的值、 留在堆疊頂」的 VM 碼。 整數字面值 85 \(\to\) push constant 85; 括號運算式 \(\to\) 呼叫 compile_expression。 最難的情況是識別字字串字面值。 識別字:查類別與副程式符號表——

種類(位移 \(i\) 輸出 說明
argument push argument i
var push local i
static push static i 全類別物件共享,函式內也合法
field push this i 合法 Jack 必在方法/建構子內;當前物件在 pointer 0,物件是陣列、第 \(i\) 個欄位在位置 \(i\)

若識別字同時出現在類別表與副程式表, 以副程式表優先(區域遮蔽)。

字串字面值:官方做法與它的漏洞

官方 nand2tetris 做法: 用 String.new 建一個上限長度合適的新 String \(\to\) 用一連串 String.appendChar 填入字元 (Hack 字元集對齊 ASCII,char 直接當 int 用)\(\to\) 把新 String 的位址推上堆疊。問題解決了,對吧?

有人在迴圈裡呼叫 Output.printString("Uh-oh!") 會發生什麼事? 字面值的程式碼每圈都跑一次……每次都建新 String…… 每次都配置在堆積……然後永遠沒人釋放Jack 的「Hello, world!」自帶記憶體洩漏!

該修嗎?要先講清楚「修」是什麼意思。 某種意義上它沒壞——語言照規格運作; 自動釋放字面值會弄壞既有的 Jack 程式(和測試腳本)。 更嚴肅地說,看看 C 就知道沒有簡單答案:

  • char myString[] = "Hello";—— 在堆疊上放一份副本,函式返回即消失,期間可修改。 但從返回 char * 的函式 return myString; 就留下懸空指標。嗯。

  • char *myString = "Hello";—— 程式開始時建一份靜態副本,指標指向它。 但 myString[0] = ’J’; 直接 segfault。糟糕。

講師的非官方 Hack:修改文法,讓 \(\textcolor{ntred}{\langle\text{\itshape letStatement}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape expressionList}\rangle}\) 都能直接接受 字串字面值。\(\textcolor{ntred}{\langle\text{\itshape letStatement}\rangle}\) 用官方做法 (明確建立指標的人自己負責 String.dispose); \(\textcolor{ntred}{\langle\text{\itshape expressionList}\rangle}\)(函式引數)則自動釋放

  • compile_expression_list 把字串字面值引數的 清單回傳給 compile_subroutine_call

  • call 之後,引數仍留在堆疊上 (目前堆疊指標之上)——取回這些字面值、 逐一呼叫 String.dispose

  • 陷阱:第一個引數會被返回值覆寫 (即使函式是 void!)——所以若第一個引數是字串字面值, 就把它再推一次當作額外的最後一個引數 (call 的引數計數相應增加), 釋放時改釋放最後一個。

本章重點

  • 物件 = 堆積上的指標式陣列;第 \(i\) 個欄位在 \(RAM[\text{物件位址}+i]\)

  • 副程式 = 函式+方法+建構子;do 敘述後 pop temp 0、void 返回推假值。

  • 不變式:this 0 = 當前物件位址。 方法:呼叫端把物件推為 argument 0、 定義端 pointer 0 \(\leftarrow\) argument 0; 建構子:Memory.alloc \(+\) pointer 0 \(\leftarrow\) 基底。

  • \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 識別字查表:argument/local/static/this \(i\); 副程式表優先於類別表。

  • 字串字面值官方做法會洩漏記憶體 (「Hello, world!」都漏!);C 的兩種字串也各有陷阱; 講師的修法 = 引數位置自動 dispose + 第一引數重推的技巧。

總結與展望(Summing Up)

知道我們不知道什麼

課程沒有做的事(每一項都可以是暑期專題!):

  • HDL(如 Verilog)描述遠更複雜的電路、 減少人工接線錯誤;

  • 學更進階的組語(如 MIPS)並在低功耗環境開發;

  • 硬體能力:進階運算、中斷、管線化、快取;

  • 支援多行程的作業系統;

  • 更好的編譯器:型別檢查、程式驗證、 程式碼最佳化

  • 更多語言特性:真正的物件導向並行

  • 網路連線(Twitch plays Tetris?)。

編譯器類專題找程式語言研究群; 硬體/OS 類找(系統)資安與 HPC 研究群。

我們做到了什麼

但別忘了我們做到的事——完整的四層編譯與建造:

  1. 把 C 風格語言(Jack)編譯到堆疊機: 用文法與遞迴把複雜運算式轉成長串後綴式指令; 用指標式陣列實作使用者自訂型別; 用不洩漏的演算法配置與釋放堆積記憶體; 用符號表管理不同作用域的變數; 把 while 等複雜流程敘述化為簡單的 goto。

  2. 把堆疊機編譯到組語: 用裸組語實作堆疊本身;用堆疊實作函式呼叫; 把虛擬記憶體段映射回實體記憶體(堆疊與堆積皆然); 合併多檔案以支援函式庫。

  3. 把組語編譯到原生機器碼: 把標籤映射到 ROM 位址;貪婪配置變數到指定記憶體區; 用記憶體映射 I/O 寫螢幕、讀鍵盤; 理解指令集本身及其設計理由。

  4. 從零建造執行機器碼的電腦: R-S 閂鎖 \(\to\) D 正反器 \(\to\) 暫存器與記憶體; 用多工器/解多工器在元件間路由指令; 用簡單邏輯閘實作複雜算術—— 一路回到卑微的 NAND 閘、真值表與布林代數。

還記得開場演講裡那些真的用麵包板與 NAND 晶片蓋出來的 Hack 電腦嗎?你現在有能力做出它們了。真正的從零開始。 恭喜完成課程,考試順利!

十一週的抽象階梯(全景回顧)

層級 本課程對應的週次
高階語言(Jack) 第 11 週
中介表示法(Hack VM) 第 9–10 週
組語(Hack assembly) 第 5、8 週
指令集架構(Hack ISA) 第 7 週
微架構(Hack CPU) 第 7 週
元件(ALU、暫存器、RAM) 第 2–3 週
邏輯閘(NAND 等) 第 1 週
電晶體(CMOS) 第 4 週
物理 (隔壁系)

綜合練習題(附詳解)

練習 1:Jack 語法判斷

下列各行是否為合法 Jack?不合法者說明原因。 (a) var int x = 0; (b) Main.print("hi"); (c) let x += 1; (d) if (x = 5) {...} else if (y = 2) {...} (e) while (~(x > 10)) { let x = x + 1; }

(a) ✗——宣告時不能初始化,要拆成 var int x; let x = 0;。 (b) ✗——丟棄返回值的呼叫必須加 dodo Main.print("hi");。 (c) ✗——不支援複合指定,要寫 let x = x + 1;。 (d) ✗——不支援 else if,要寫 else { if (y = 2) {...} }。 (e) ✓——~ 是 NOT、比較用單一 =、 while 語法正確。

練習 2:運算子優先順序

(a) Jack 中 1 + 2 * 3 可能求值為哪些結果?為什麼? (b) 若用第 2.7 節的 compile_expression (由左至右),會得到哪個?寫出產生的 VM 碼。 (c) 要保證得到 7,Jack 程式設計師該怎麼寫?

(a) 7 或 9。Jack 規格沒有運算子優先順序 (沒有括號時),編譯器可自由選擇結合方式: \(1 + (2 \times 3) = 7\)\((1 + 2) \times 3 = 9\) 都符合規格。

(b) 由左至右:先算 \(1 + 2\)、再乘 3,得 9

push constant 1
push constant 2
add
push constant 3
call Math.multiply 2

(注意 * 編譯成呼叫 Math.multiply—— Hack VM 沒有乘法指令。)

(c) 加括號:1 + (2 * 3)。 括號運算式是 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\),會先被完整求值。

練習 3:隱含型別轉換

執行下列 Jack 程式後,yz 的值 (以 16 位元 int 表示)各是多少?

var int x; var boolean y; var char z;
let x = 3;
let y = (x > 2);
let z = 65 + x;

y\(x > 2\) 為 true,Jack 的 true \(= -1\)0xFFFF)——所以 y \(= -1\)。 (VM 層 gt 本來就推 0xFFFF, 「隱含轉換」實際上什麼都不用做。) z\(65 + 3 = 68\),即字元 "D" (Hack 字元集對齊 ASCII)。 記憶體中 yz 都只是 int—— 編譯器完全忽略型別資訊。

練習 4:EBNF 對照

用 2.2 節的文法,指出解析 let a[i+1] = f(x) - 2; 時, (a) 這整行匹配哪個非終端符號及哪個選項; (b) i+1f(x)f(x) - 2 各匹配什麼; (c) 解析器在讀到哪個權杖時知道有 [...] 部分?

(a) \(\textcolor{ntred}{\langle\text{\itshape letStatement}\rangle}\),帶可選的 \([\ensuremath{\text{\textcolor{tokblue}{`\texttt{[}'}}},\ \ensuremath{\textcolor{ntred}{\langle\text{\itshape expression}\rangle}},\ \ensuremath{\text{\textcolor{tokblue}{`\texttt{]}'}}}]\) 部分。 (b) i+1\(\textcolor{ntred}{\langle\text{\itshape expression}\rangle}\)\(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) i、運算子 +\(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 1); f(x)\(\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}\)(也是一種 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\)); f(x) - 2\(\textcolor{ntred}{\langle\text{\itshape expression}\rangle}\)。 (c) 讀完識別字 a 後,current 指向 [——單靠 current 即可分辨(不需 lookahead): 是 [ 就解析下標,是 = 就直接進右值。

練習 5:為什麼是 LL(2)?

(a) 解析 \(\textcolor{ntred}{\langle\text{\itshape term}\rangle}\) 時,哪種開頭權杖需要看 lookahead? 列出 lookahead 的三種情況與對應動作。 (b) 為什麼其他敘述(let/if/while/do/return)都只需 LL(1)?

(a) 開頭是識別字時。看 lookahead

  • (. \(\Rightarrow\) 這是 \(\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}\)(函式名或類別/變數名開頭);

  • [ \(\Rightarrow\) 帶下標的變數 (identifier + [expression]);

  • 其他 \(\Rightarrow\) 純變數。

(嚴格說 [ 的情況用 current 也能處理—— 真正必須 lookahead 的是區分變數與 \(\textcolor{ntred}{\langle\text{\itshape subroutineCall}\rangle}\)。)

(b) 因為 Jack 刻意設計成每種敘述由第一個關鍵字唯一 決定:看到 let 就是 \(\textcolor{ntred}{\langle\text{\itshape letStatement}\rangle}\)、 看到 while 就是 \(\textcolor{ntred}{\langle\text{\itshape whileStatement}\rangle}\)…… 這正是那些「多餘」關鍵字(letdovar)存在的理由。

練習 6:符號表建構

為下列副程式建出完整符號表(名稱、型別、種類、位移):

class Point {
    field int x, y;
    static int count;
    method int distSq(Point other) {
        var int dx, dy;
        // ...
    }
}

類別符號表(field 與 static 分開編位移):

名稱 型別 種類 位移
x int field 0
y int field 1
count int static 0

副程式符號表distSq方法, 所以先加 this):

名稱 型別 種類 位移
this Point argument 0
other Point argument 1
dx int var(local) 0
dy int var(local) 1

方法內 x \(\to\) this 0other \(\to\) argument 1dx \(\to\) local 0

練習 7:手工編譯 while

把下列 Jack 編譯成 Hack VM(nlocal 0):

while (n > 0) {
    let n = n - 1;
}
label while_start_0
push local 0
push constant 0
gt                     // n > 0
not                    // 條件取反
if-goto while_end_0    // 不成立則離開
push local 0
push constant 1
sub
pop local 0            // n = n - 1
goto while_start_0
label while_end_0

模式:條件推上堆疊 \(\to\) not \(+\) if-goto 結束標籤 \(\to\) 本體 \(\to\) goto 開始標籤。標籤編號必須全域唯一 (巢狀迴圈各有自己的一對)。

練習 8:手工編譯運算式

在練習 6 的 distSq 方法內,把 let dx = x - other[0]; 編譯成 VM 碼。 提示:other[0]that 段。

push this 0        // x(field 0,經 this 段)
push argument 1    // other(物件位址)
push constant 0
add                // other + 0
pop pointer 1      // that 0 -> other[0]
push that 0        // other[0] 的值
sub                // x - other[0]
pop local 0        // dx =

關鍵分工:欄位走 this (pointer 0,方法入口設定、本體不動)、 [] 下標走 that(pointer 1,隨用隨設) ——這正是 2.6 節「that 讓 [] 與 field 並用的運算式順利編譯」的意思。

練習 9:方法呼叫的編譯

p 是型別 Pointlocal 2。 (a) 把 do p.distSq(q);qlocal 3) 編譯成 VM 碼。 (b) 編譯器如何分辨 p.distSq(q)方法呼叫、 而 Math.sqrt(x)函式呼叫?

(a)

push local 2           // p 作為隱藏的第一個引數(this)
push local 3           // q
call Point.distSq 2    // 2 個引數(p 算一個!)
pop temp 0             // do 敘述:丟棄返回值

(b) 看 . 左邊的識別字是否在符號表中p 查得到(是變數)\(\Rightarrow\) 方法呼叫, 推 p 為 argument 0、 用 p型別(Point)組出完整函式名; Math 查不到(是類別名)\(\Rightarrow\) 函式/建構子呼叫,不推額外引數。

練習 10:建構子的編譯

類別 Point 有 2 個 field(練習 6)。 寫出編譯 constructor Point new(int ax, int ay) 開頭與結尾所產生的 VM 碼骨架,並解釋每一步。

function Point.new 0     // 0 個 local
push constant 2          // 物件大小 = field 數 = 2(查類別符號表)
call Memory.alloc 1      // 在堆積配置 2 個字,返回基底位址
pop pointer 0            // this 段指向新物件
// ...本體:let x = ax; -> push argument 0 / pop this 0
//          let y = ay; -> push argument 1 / pop this 1
push pointer 0           // return this;
return

要點:(1) 大小來自類別符號表的 field 計數; (2) pop pointer 0 建立「this 0 = 當前物件」 的不變式;(3) 建構子不是方法——呼叫端不推隱藏引數, ax 就是 argument 0; (4) 結尾 push pointer 0 把新物件位址返回給呼叫者。

練習 11:do 與 void 的堆疊衛生

(a) 為什麼 do 敘述編譯後必須 pop temp 0? 不做會怎樣? (b) void 函式的 return; 為什麼要推假值? (c) 呼叫 void 函式後,呼叫端拿到的「返回值」是什麼?

(a) VM 的 call 保證把一個返回值推上堆疊。 do 敘述不使用它——不彈掉的話, 每執行一次 do,堆疊就淨增一個無主之值。 迴圈裡的 do 會讓堆疊持續長高, 最終溢出堆疊段(256–2047)—— 「堆疊上的記憶體洩漏」。

(b) 因為 return 的實作(第十週)無條件把 堆疊頂複製到 ARG 的位置——堆疊頂必須有東西。 慣例是 push constant 0return

(c) 那個假值 0。呼叫端(do 敘述)隨即 pop temp 0 丟棄它——兩端配合,堆疊收支平衡。

練習 12:字串字面值洩漏

(a) 逐步說明 do Output.printString("Hi"); 在官方編譯法下產生哪些堆積操作。 (b) 這段程式在迴圈裡跑 1000 次,堆積淨增多少個 String 物件? (c) 講師的修法中,為什麼「第一個引數是字串字面值」 需要特殊處理?

(a) (1) push constant 2 \(+\) call String.new 1——在堆積配置一個 容量 2 的 String;(2) 兩次 push constant 字元碼 \(+\) call String.appendChar 2 填入 Hi; (3) String 位址留在堆疊頂,作為引數 call Output.printString 1; (4) pop temp 0沒有任何一步釋放那個 String。

(b) 每圈建一個、無人釋放:淨增 1000 個。 這就是「Hello, world! 有記憶體洩漏」的具體形式。

(c) 修法要在 call 之後從堆疊上方 (SP 之上的殘留區)取回字面值位址來 dispose。 但 return 的實作會把返回值寫進 argument 0 的位置(即使函式是 void)—— 第一個引數的殘留值被覆寫,位址就丟了。 解法:第一個引數若是字串字面值, 再推一次當額外的最後一個引數 (call 計數 \(+1\);被呼叫者不會用到它,無害), 事後改從最後一個引數的位置取回並 dispose

附錄:速查表

Jack 權杖總表

類別 內容
關鍵字(21 個) class constructor function method field staticint char boolean voidvar let do if else while returntrue false null this
符號(19 個) + - * / & | ~ < > ={ } [ ] ( ) . , ;
整數字面值 \(0 \ldots 32767\)
字串字面值 "..."(不含換行與雙引號)
識別字 字母/數字/底線,非數字開頭、非關鍵字

Jack \(\to\) VM 對照配方

Jack 構造 產生的 VM 碼
整數字面值 \(n\) push constant n
true push constant 0 \(+\) not (或 push constant 1 \(+\) neg
falsenull push constant 0
this push pointer 0
var/argument/static/field \(i\) push local/argument/static/this i
a[e] 讀取 a+e \(\to\) pop pointer 1 \(\to\) push that 0
x + y 兩邊各自入棧 \(\to\) add* \(\to\) call Math.multiply 2/ \(\to\) call Math.divide 2
while (c) {s} label S \(\to\) c 入棧 \(\to\) not \(\to\) if-goto E \(\to\) s \(\to\) goto S \(\to\) label E
if (c) {s1} else {s2} c 入棧 \(\to\) not \(\to\) if-goto ELSE \(\to\) s1 \(\to\) goto END \(\to\) label ELSE \(\to\) s2 \(\to\) label END
do f(...); 引數入棧 \(\to\) call \(\to\) pop temp 0
方法呼叫 v.m(...) push v(隱藏引數)\(\to\) 其餘引數 \(\to\) call Class.m n+1
方法定義開頭 push argument 0 \(\to\) pop pointer 0
建構子開頭 push constant 欄位數 \(\to\) call Memory.alloc 1 \(\to\) pop pointer 0
return e; e 入棧 \(\to\) return; void:push constant 0 \(\to\) return

變數種類 \(\to\) 記憶體段

Jack 宣告 VM 段 生命週期
var local 單次副程式呼叫
參數 argument 單次副程式呼叫
static static 整個程式(同類別共享)
field this 物件存活期間(堆積)

名詞中英對照

英文 中文 英文 中文
expression 運算式 object 物件
statement 敘述 field / static 欄位/靜態變數
operator precedence 運算子優先順序 method 方法
implicit conversion 隱含轉換 constructor 建構子
class 類別 subroutine 副程式
parse tree / AST 解析樹/抽象語法樹 current object 當前物件
recursive descent 遞迴下降 string literal 字串字面值
lookahead 前瞻 memory leak 記憶體洩漏
symbol table 符號表 dangling pointer 懸空指標
scope shadowing 作用域遮蔽 bootstrapping 自舉

參考資料

  • John Lapinskas, The Jack languageCompiling JackCompiling Jack’s classesSumming up and looking forward(11-1 至 11-4), COMSM1302, University of Bristol.

  • Nisan & Schocken, The Elements of Computing Systems (nand2tetris),Ch. 9(Jack)、Ch. 10(Compiler I: Syntax Analysis)、Ch. 11(Compiler II: Code Generation)、 附錄 5(Hack 字元集)、附錄 6(OS API).

  • nand2tetris Project 10 & 11 (nand2tetris.org)——編譯器兩階段開發與 漸進式測試程式.