Zig AstGen: AST => ZIR

Mitchell Hashimoto

Zig AstGen: AST => ZIR

本文是 Zig compiler internals(Zig 編譯器內部運作)系列文章的一部分。

在建構完抽象語法樹(AST)之後,許多編譯器的下一步是產生中間表示(IR)。AST 是樹狀結構,而 IR 通常會開始為程式的各個區塊(例如檔案、函式等)建立一連串的指令。這種指令序列的格式更容易進行分析,以利最佳化並轉換為可執行的機器碼。

Zig 編譯器擁有多種中間表示。第一種被建立的 IR 形式稱為 Zig Intermediate Representation(Zig 中間表示,簡稱 ZIR)。AST 會透過內部稱為「AstGen」的階段直接轉換為 ZIR。ZIR 是一種針對每個 Zig 檔案產生的無型別 IR 形式。

ZIR 看起來是什麼樣子?

在深入探討 ZIR 的內部結構與建立流程之前,讓我們先快速看一個 ZIR 的範例。你現在不需要看懂這些內容。

以下是一個非常小的 Zig 檔案:

const result = 42;export fn hello() u8 {    return result;}

以及該檔案所產生的 ZIR:

%0 = extended(struct_decl(parent, Auto, {  [24] result line(0) hash(193c9ec04fd3f33e5e849a85f0c3b7fd): %1 = block_inline({    %2 = int(42)    %3 = break_inline(%1, %2)  }) node_offset:1:1  [32] export hello line(2) hash(322ad1340dd6faf97c80f152e26dfdd4): %4 = block_inline({    %11 = func(ret_ty={      %5 = break_inline(%11, @Ref.u8_type)    }, body={      %6 = dbg_stmt(2, 5)      %7 = extended(ret_type()) node_offset:4:5      %8 = decl_val("result") token_offset:4:12      %9 = as_node(%7, %8) node_offset:4:12      %10 = ret_node(%9) node_offset:4:5    }) (lbrace=1:22,rbrace=3:1) node_offset:3:8    %12 = break_inline(%4, %11)  }) node_offset:3:8}, {}, {})

注意:ZIR 不一定是穩定的格式,因此上述輸出可能與現今 Zig 編譯器所輸出的結果不盡相同。無論如何,對於初次認識 ZIR 而言,已經足夠接近了。

ZIR 最需要留意的是,程式的實際邏輯開始被拆解成更細粒度的指令。ZIR 指令可以透過索引來參照,如上方輸出中 % 符號後所示。舉例來說,指令 9 是將 result 的參照轉換為 hello 函式的回傳型別。

想看更多 ZIR 嗎? 如果你從原始碼自行編譯 Zig,可以使用 zig ast-check -t <file> 指令來傾印任何 Zig 檔案的 ZIR。撰寫簡單的 Zig 結構並傾印其 ZIR,是學習 AstGen 階段的好方法。在此頁面不會說明從原始碼編譯 Zig 的步驟。

為什麼 ZIR 是無型別的?

ZIR 是一種無型別的中間格式。這並不表示 ZIR 完全沒有型別的概念,而是指型別尚未被完整求值。Zig 的語言定義特色之一,就是將型別作為第一級的值,並以編譯期求值(comptime 評估)作為泛型型別的機制。因此,型別在 AST 與 ZIR 格式中仍然可能是未知的

這在範例 ZIR 輸出中最容易看出。首先,讓我們看看一個大致上已具型別的形式:

const result: u32 = 42;
%0 = extended(struct_decl(parent, Auto, {  [10] a line(0) hash(63c8310741c228c128abf6692d07292d): %1 = block_inline({    %2 = int(42)    %3 = as_node(@Ref.u32_type, %2) node_offset:1:16    %4 = break_inline(%1, %3)  }) node_offset:1:1}, {}, {})

指令 %3 將無型別的 42 值轉換為 u32。在這個小範例中,ZIR 看起來似乎已完全具備型別,但仍有無型別的指令。指令 %2 是一個「無型別」常數 42。之所以說它是無型別,是因為常數 42 仍可被強制轉型為 u8u16u32 等。直到程式碼將這個無型別常數賦值給一個具型別的常數時,ZIR 才會發出型別強制轉換指令 as_node

接下來,讓我們看看一個確定是無型別的形式:

const t = bool;const result: t = 42;
%0 = extended(struct_decl(parent, Auto, {  [16] t line(0) hash(243086356f9a6b0669ba4a7bb4a990e4): %1 = block_inline({    %2 = break_inline(%1, @Ref.bool_type)  }) node_offset:1:1  [24] result line(1) hash(8c4cc7d2e9f1310630b3af7c4e9162bd): %3 = block_inline({    %4 = decl_val("t") token_offset:2:10    %5 = as_node(@Ref.type_type, %4) node_offset:2:10    %6 = int(42)    %7 = as_node(%5, %6) node_offset:2:14    %8 = break_inline(%3, %7)  }) node_offset:2:1}, {}, {})

在此情境中,result 的型別是常數 t。你可能會認為在此例中常數 t 是顯而易見的,但 Zig 允許將 t 賦值為任何編譯期運算式(只要所有值皆為編譯期可知,幾乎等同於整個語言)。這是 Zig 極為強大的特色之一。

考量到這種動態的可能性,你可以發現賦值給 result 的 ZIR 要複雜得多。指令 %4%5 載入由 t 所標識的值,並將其強制轉型為 type 型別(一種表示型別而非值的型別)。接著,指令 %7 也有我們熟悉的 as_node 強制轉型,但這次型別運算元參照的是 ZIR 指令 %5 的結果,而非靜態值。

作為最後一個更極端的範例:

const std = @import("std");const result: std.ArrayListUnmanaged(u8) = .{};

我不會在此展示其 ZIR,因為它非常龐大。result 的型別是透過對函式 ArrayListUnmanaged 進行編譯期求值所產生的泛型型別。ZIR 無法為 result 指向一個預先定義的型別,因為它直到編譯期求值完成前根本還不知道是什麼。

這就是為什麼 ZIR 是無型別的。ZIR 是為編譯期求值與進一步的語意分析所準備的中介形式。經過編譯期處理後,所有型別都將為已知,並可形成一個完全具型別的中間表示(這被稱為 AIR,是 AstGen 之後階段的產物)。

AstGen 的結構

「AstGen」是將 AST 轉換為 ZIR 的階段。AstGen 的原始碼位於 src/AstGen.zig。AstGen 並非公開匯出的結構體,且該結構體僅用於管理 ZIR 產生過程中的內部狀態。外部呼叫者會呼叫 generate 函式,該函式接收一棵 AST 並回傳整棵樹的 ZIR。

AstGen 結構體有許多用於內部狀態的欄位。我們不會逐一介紹全部,但會在下方展示其中一些重要的欄位。下方結構體的欄位並未按照原始碼中的順序呈現:

const Astgen = struct {    gpa: Allocator,    arena: Allocator,    tree: *const Ast,    instructions: std.MultiArrayList(Zir.Inst) = .{},    extra: ArrayListUnmanaged(u32) = .{},    string_bytes: ArrayListUnmanaged(u8) = .{},    // other fields, not covered...};

第一組 gpaarenatree 是 AstGen 流程的輸入。gpa 用於配置在產生過程結束後仍會存活的資料。arena 用於配置僅在 ZIR 產生期間使用的暫時性資料,並會在將 ZIR 回傳給使用者前釋放。而 tree 則是正在被轉換為 ZIR 的 AST。

為什麼「arena」被稱為「arena」? Arena allocator(arena 配置器)是一種記憶體配置器,會一次配置並釋放整塊記憶體區域,而非逐一追蹤並釋放每個項目。它常用於共享相同生命週期的配置,因為一次釋放整塊記憶體,遠比逐一追蹤個別項目來得簡單且高效。若想了解更多關於配置器與 Zig 的基礎知識,請參閱演講 “What’s a Memory Allocator Anyways?(《記憶體配置器究竟是什麼?》)”

第二組 instructionsextrastring_bytes 是 AstGen 流程的輸出。這一組非常重要,因為它是最終 ZIR 的核心結構:

  • instructions 是指令清單。清單中的每一項對應到單一指令。舉例來說,回顧本頁稍早的 ZIR 輸出,清單中的第 9 項就是 as_node(%7, %8) 指令。
  • extra 是 ZIR 指令可能需要儲存的額外資料。這與 AST 節點及其 extra_data 欄位所遵循的模式相同。如果你不了解 AST 的 extra_data,現在正是複習的好時機,因為該模式在 AstGen 中無所不在!
  • string_bytes 是用於識別字、字串常值、文件註解等的 字串駐留池。所有靜態字串都會在 AstGen 階段被駐留,ZIR 指令則參照此字串位元組清單中的偏移量,而非儲存字串本身的副本。

ZIR 指令的結構

在深入探討將 AST 轉換為 ZIR 的運作方式之前,我將花相當多的篇幅來說明單一 ZIR 指令的格式。建議你仔細閱讀並理解本節,因為這是理解 ZIR 建構方式的基礎。

ZIR 本質上就是一串指令清單。指令的格式與 AST 節點所使用的模式有很強的相似性,因此本頁可能會略過一些結構細節。在這些情況下,我會特別指出其相似之處。

pub const Inst = struct {    tag: Tag,    data: Data,    pub const Tag = enum(u8) {        add,        addwrap,        // many more...    };    pub const Data = union {        // various fields that can be set depending on tag    };};

這與 AST 節點非常相似。「tag」模式與 AST 節點相同,但標籤集合則完全不同。舉例來說,整數常值的 AST 無論整數大小皆為 .integer_literal。而對 ZIR 而言,則會根據整數是否能容納於 u64 而轉譯為 .int.int_big

每個指令都帶有相關的資料。資料聯合(union)中哪個欄位為作用中,取決於 tag 的值。資料可以直接儲存在 data 欄位內(例如整數常值),也可能是對位於其他地方資訊的參照。

如同 AST 節點,ZIR 也有一個 extra 欄位。它的行為與 AST 節點的 extra_data 欄位大致相同:某些資料項可能指向 extra 欄位中的一個項目,並從該處開始的多個欄位將包含與該 ZIR 指令相關的特定值。若想了解更多細節,除了下方範例外,也請參閱 Zig Parser 頁面。

靜態值

最簡單的 ZIR 指令是那些包含靜態值的指令。讓我們看看一個簡單的靜態整數值及其所產生的 ZIR。

const x = 42;
%0 = extended(struct_decl(parent, Auto, {  [7] x line(0) hash(5b108956afe84d38689dcd3f2d652f04): %1 = block_inline({    %2 = int(42)    %3 = break_inline(%1, %2)  }) node_offset:1:1}, {}, {})

相關的指令是 %2int(42)。如你所見,靜態值 42 被直接編碼到指令本身中。在內部,其表示方式如下所示:

Zir.Inst{    .tag = .int,    .data = .{        .int = 42,    },}

這是 ZIR 指令最簡單的形式:資料直接內嵌於指令之中。在此例中,資料的作用中標籤為 .int,其內嵌了靜態的整數值。

更常見的情況是,資料是被參照的,或是標籤包含了無法直接容納於 Inst 結構中的更複雜資訊。接下來將展示這類範例。

參照

許多 ZIR 指令包含對值的參照。舉例來說,一元 ! 運算子(布林 NOT)可前綴於任何運算式,例如靜態值 !true、變數 !myVar、函式呼叫 !myFunc() 等。由於參照非常常見,因此理解 ZIR 如何編碼參照非常重要。

ZIR 參照擁有專屬的型別 Zir.Inst.Ref。這是一個 非窮舉列舉(non-exhaustive enum)。已定義的標籤適用於屬於原始型別或非常常見的值。否則,該值就是對另一個 ZIR 指令索引的參照。

帶標籤的參照

讓我們透過一個範例來看看其運作方式:

const x = !true;

這會產生以下的 ZIR:

%0 = extended(struct_decl(parent, Auto, {  [7] result line(0) hash(0be0bb45d9cb29941abcc19d4176dde6): %1 = block_inline({    %2 = bool_not(@Ref.bool_true) node_offset:1:16    %3 = break_inline(%1, %2)  }) node_offset:1:1}, {}, {})

請看指令 %2。它包含了我們的 bool_not 指令(即 ! 運算子),其參數為 @Ref.bool_truebool_not 指令接受單一運算元。在內部,這是一個結構大致如下的 Inst(為簡化範例,刻意省略了一些不重要的欄位):

Zir.Inst{    .tag = .bool_not,    .data = .{        .un_node = .{ .operand = Ref.bool_true },    },}

Ref.bool_trueRef 列舉中代表靜態值 true 的標籤之一。還有許多其他的標籤。例如,每個內建型別都有對應的標籤。雖然以下範例在語意上不合理,但它確實會產生合法的 ZIR(編譯器稍後會對其報錯):

const x = !u8; // ZIR: bool_not(@Ref.u8_type)

對於眾所皆知的原始型別與值,會使用 Ref 的帶標籤值。

指向指令的參照

對於那些非眾所皆知的值,Ref 所表示的就是指令索引。這裡是另一個範例及其所產生的相關 ZIR。

const input = true;const x = !input;
%0 = extended(struct_decl(parent, Auto, {  [13] input line(0) hash(1af5eb6ed7d8836d8d54ff390bb38c7d): %1 = block_inline({    %2 = break_inline(%1, @Ref.bool_true)  }) node_offset:1:1  [21] result line(1) hash(4ba2a2df9e05a2d7963b6c1eb80fdcea): %3 = block_inline({    %4 = decl_val("input") token_offset:2:17    %5 = as_node(@Ref.bool_type, %4) node_offset:2:17    %6 = bool_not(%5) node_offset:2:16    %7 = break_inline(%3, %6)  }) node_offset:2:1}, {}, {})

觀察參照的關鍵指令是 %6。我們再次看到熟悉的 bool_not 指令,但這次運算元是對另一個指令的參照:%5。如果你持續追蹤指令鏈,應該能直觀地理解它是以布林值的形式讀取 input 的值。

在內部,該指令看起來像這樣:

Zir.Inst{    .tag = .bool_not,    .data = .{        .un_node = .{ .operand = Ref.typed_value_map.len + 5 },    },}

或者更簡單地說:已標記值的總數加上 5。同樣地,要判斷一個 Ref 值是標籤還是指令索引,我們可以檢查該值是否大於標籤的數量。如果是,則該值減去標籤長度即等於指令索引。

這種情況非常常見,因此有兩個公開函式可用於此轉換:indexToRefrefToIndex。在上述範例中,我們將運算元設為 indexToRef(5),而如果你呼叫 refToIndex(operand),就會得到 5

const ref_start_index: u32 = Inst.Ref.typed_value_map.len;pub fn indexToRef(inst: Inst.Index) Inst.Ref {    return @intToEnum(Inst.Ref, ref_start_index + inst);}pub fn refToIndex(inst: Inst.Ref) ?Inst.Index {    const ref_int = @enumToInt(inst);    if (ref_int >= ref_start_index) {        return ref_int - ref_start_index;    } else {        return null;    }}

額外資料

有些 ZIR 指令包含對 extra 欄位的參照,該欄位有時也被稱為「尾端」資料。這與 AST 節點中的 extra_data 欄位非常相似,因此此處不會詳細說明其編碼/解碼方式。如果你還不了解或不太熟悉 AST 節點中的 extra_data 欄位,建議先複習該章節。

讓我們看看一個額外資料的範例:

const x = 1 + 2;
%0 = extended(struct_decl(parent, Auto, {  [10] x line(0) hash(48fa081b63af0a1c7f2d11a7bf9fbbc3): %1 = block_inline({    %2 = int(2)    %3 = add(@Ref.one, %2) node_offset:1:13    %4 = break_inline(%1, %3)  }) node_offset:1:1}, {}, {})

這個輸出的 ZIR 建立在我們上面已學過的所有知識之上!有一個靜態值(指令 %2)、有一個帶標籤的參照(指令 %3 中的 @Ref.one),還有一個參照另一個指令的參照(指令 %3 中的 %2 運算元)。

二元加法運算使用了 extra。這在 ZIR 文字輸出中並不明顯,因為 ZIR 的渲染器理解 add 指令並會漂亮地印出來自 extra 的資料(指令 %3)。在內部,它看起來像這樣:

// InstructionZir.Inst{    .tag = .add,    .data = .{        .pl_node = .{ .payload_index = 7 },    },}// Exra data[ ..., Ref.one, %2, ... ]

該指令使用 pl_node(即「payload node」)資料標籤。其中包含一個欄位 payload_index,指向 extra 陣列中可找到額外資料的起始欄位。透過查看 .add 標籤的註解,儲存在 extra 欄位中的結構是 Zir.Inst.Bin,它有 lhsrhs。在此例中,lhs = Ref.onerhs = %2

與 AST 節點類似,AstGen 原始碼中包含一個輔助函式 addExtra,它以型別安全的方式對結構化的額外資料進行編碼。此模式在 Zig Parser 頁面中關於 AST 節點的章節有更詳細的說明。

額外資料本身可能包含靜態值、其他 Zig.Inst.Ref 值等。你必須查看每個標籤的原始碼,才能了解它編碼了哪些值。

更多的資料型別

還有許多其他的資料型別,但我不會在此全部詳細說明。我認為上述的資料型別對於理解用來編碼 ZIR 指令資訊的更廣泛模式特別重要。

AstGen 的組成元件

AstGen 流程擁有多個在建構 ZIR 時會用到的共通、共享元件。這些元件代表了共通或共享的邏輯,對於理解 AstGen 的完整行為至關重要。這些元件中的大多數都是 AstGen 內每個函式的參數。

這並非 AstGen 所有功能的完整清單,但突顯了其中幾個關鍵項目。

作用域

AstGen 引入了作用域(scope)的概念,使得參照「父」作用域中的識別字、對使用未定義識別字時記錄錯誤,或偵測識別字遮蔽(shadowing)成為可能。

作用域是使用位於 AstGen.zig 中的 Scope 結構體來定義的。作用域是多型的,可以是多種子型別之一。這在 Zig 中是透過使用 @fieldParentPtr 的常見模式來實作的。由於這是 Zig 中常見的模式,本頁不會對此模式多加說明。

在撰寫本文時,有七種作用域「型別」:

  • Scope.Top - 代表檔案的最外層作用域。這永遠是最上層的父作用域,沒有父作用域。此作用域不會追蹤任何額外資料。
  • GenZir - 此結構體大致上代表 Zig 中的一個「區塊」,但在各 AST 節點之間用於大量的額外狀態追蹤。它追蹤目前的區塊標籤(若有)、指令清單、是否位於編譯期位置等。
  • Scope.Namespace - 此作用域包含一組可被參照的無序宣告集合。「無序」是關鍵字,此作用域通常是區塊內用於結構體、聯集等的子作用域。範例:結構體變數可以參照檔案中稍後定義的變數,而函式主體則不行。前者是 Namespace,後者則不是。
  • Scope.LocalValScope.LocalPtr - 已被定義的單一識別字(例如變數或常數)。隨著識別字被定義,會建立一個此型別的新子作用域,使其現在「在作用域內」。這與 Namespace 不同,因為它只代表單一宣告,且不是無序清單。
  • Scope.Defer - 圍繞 defererrdefer 的作用域。這用於追蹤 defer 是否存在,以便在所有離開點產生對應的指令。

作用域有一個 parent 欄位,可用於向上遍歷各個作用域。這就是識別字解析的運作方式。

字串駐留

字串值(位元組陣列)不會直接儲存在 ZIR 指令中。字串會被駐留並儲存在單一連續的 string_bytes 陣列中。指向字串值開頭的索引會儲存在 ZIR 指令內。這表示共享的字串只會被儲存一次(例如識別字)。

到目前為止,ZIR 是流程中第一個實際儲存字串的結構。AST 節點儲存的是詞彙單元(token)索引,而詞彙單元則儲存其在原始碼中的起始與結束偏移量。這需要 AST 與原始碼都保持可用。在 AstGen 之後,AST 與原始碼就可以被釋放。這使得解析極大的 Zig 程式並僅在記憶體中儲存我們所需的內容成為可能。

結果位置

ResultLoc 結構體會追蹤運算式樹最終結果應該寫入何處。當 AST 被遍歷並產生 ZIR 時,某些寫入操作的值可能會深深嵌套,而 AstGen 需要知道最終值要寫到哪裡。

請參考以下範例:

const x = blk1: {  switch (someUnion) {    .someTag => break :blk1 42,    else => break :blk1 0,  }}

在最外層作用域,我們正在寫入常數 x。如果你將 AST 樹視覺化,我們接著必須深入一個區塊、然後是一個 switch、再接著是一個 switch 分支,最後是一個帶標籤的 break 陳述式。在至少遞迴四個函式之後,ResultLoc 就是 AstGen 知道要將帶標籤 break 的值寫到何處的方式。

ResultLoc 是一個帶標籤的聯合(tagged union),擁有多種可能的結果型別。它有完善的註解,因此建議閱讀原始碼。此處將解釋其中幾個範例標籤:

  • .discard - 表示賦值被丟棄,因為它是捨棄識別字 _ 右側的值。在此情況下,AstGen 知道不需要產生任何儲存指令,我們可以直接將該值丟棄。
  • .ty - 賦值是針對某個具型別的值。這會產生一個 as_node 來在回傳結果值之前對其進行強制轉型。
  • .ptr - 賦值正被寫入某個記憶體位置。這會產生一個 store 指令,使結果被寫入該記憶體位置。

產生 ZIR

現在讓我們來了解 AstGen 如何將 AST 轉換為 ZIR。抽象來說,AST 是透過遍歷樹狀結構並為每個節點發出指令而轉換為 ZIR 的。AstGen 會急切地將整個 AST 轉換為 ZIR(它不會惰性求值——這種模式將在後續的編譯器階段中出現)。

重要的是,AstGen 引入了我們第一層重要的語意驗證。例如,AstGen 會驗證識別字是否已定義、是否遮蔽了外層作用域,並知道如何遍歷父作用域。回想一下,先前 AST 的建構引入了結構驗證:它確保了如 x pub == 7 等詞彙序列會引發語法錯誤,但它不會驗證任何識別字 x 是否已定義。而詞彙分析器(tokenizer)本身僅強制執行單一詞彙驗證:它驗證 72 是合法的詞彙,但 72a 不是(識別字不能以數字開頭)。

學習 ZIR 如何產生的最簡單起點是查看 expr 函式。此函式會為 Zig 語言中任何合法的運算式產生 ZIR。我發現從語言最簡單的組成開始,再逐步建構上去是最容易學習的方式,因為 IR 的產生是一個深度遞迴的過程。

整數常值

讓我們從一個簡單的靜態整數值開始:

42

這會被解析為標籤為 .integer_literal 的 AST 節點,並透過巨大的 switch 從 expr 導向 integerLiteral 函式。integerLiteral 函式重現如下:

fn integerLiteral(gz: *GenZir, rl: ResultLoc, node: Ast.Node.Index) InnerError!Zir.Inst.Ref {    const tree = astgen.tree;    const main_tokens = tree.nodes.items(.main_token);    const int_token = main_tokens[node];    const prefixed_bytes = tree.tokenSlice(int_token);    if (std.fmt.parseInt(u64, prefixed_bytes, 0)) |small_int| {        const result: Zir.Inst.Ref = switch (small_int) {            0 => .zero,            1 => .one,            else => try gz.addInt(small_int),        };        return rvalue(gz, rl, result, node);    } else |err| switch (err) {        error.InvalidCharacter => unreachable, // Caught by the parser.        error.Overflow => {},    }    // ... other paths}

這裡有很多內容!這就是為什麼我從最簡單的東西開始。如果我從像是函式宣告之類的東西開始,就會陷入太多細節而難以學習。即使是看整數常值,你可能仍會感到有點不知所措,但這已經是最簡單的情況了,所以讓我們來深入探討。

首先,我們使用 AST 來查詢整數常值的詞彙單元。接著,我們可以使用詞彙單元的起始位置,透過 tree.tokenSlice 取得與該詞彙單元相關的位元組。這會得到字串 "42",更精確地說:兩個位元組 42

接下來,我們嘗試將該字元陣列解析為無號 64 位元整數。如果數字太大,就會落入「其他路徑」註解所指的複雜邏輯,該部分負責處理「大整數」的儲存,我們在此不會深入探討。在此範例中,我們假設所有整數常數皆小於或等於 18,446,744,073,709,551,615(無號 64 位元整數的最大值)。

無號?那負數呢? 負數前方的 - 前綴會被儲存為單獨的 AST 節點,並在 ZIR 中另外產生一個一元運算。整數常值永遠是正數。

在我們的範例中,解析數字將會成功,因為 42 會被解析為合法的 u64。接著,我們處理值為 01 的特殊情況,因為它們擁有特殊的帶標籤參照。否則,我們會產生一個 .int ZIR 指令,並將指令索引儲存於 result 中。

最後,我們呼叫 rvalue,它會套用稍早介紹的 ResultLoc 語意來決定是否需要按原樣回傳值、將其轉換為已知型別、將其儲存至記憶體位置等。在許多情況下,這將是一個無動作(no-op),僅會直接回傳 .int ZIR 指令。

加法

讓我們從靜態整數值進一步來做加法:

42 + 1

這會被解析為標籤為 .add 的 AST 節點,並從 expr 導向 simpleBinOp

fn simpleBinOp(    gz: *GenZir,    scope: *Scope,    rl: ResultLoc,    node: Ast.Node.Index,    op_inst_tag: Zir.Inst.Tag,) InnerError!Zir.Inst.Ref {    const astgen = gz.astgen;    const tree = astgen.tree;    const node_datas = tree.nodes.items(.data);    const result = try gz.addPlNode(op_inst_tag, node, Zir.Inst.Bin{        .lhs = try reachableExpr(gz, scope, .none, node_datas[node].lhs, node),        .rhs = try reachableExpr(gz, scope, .none, node_datas[node].rhs, node),    });    return rvalue(gz, rl, result, node);}

這是我們第一個遞迴的案例。這會建立一個 .add ZIR 指令,其資料 lhsrhs 分別透過遞迴建構左、右運算式的 ZIR 來填入。左運算式是 42,右運算式是 1。從探討整數常值時我們已知,這將會轉變為 .int 指令。

最終產生的 ZIR 看起來像這樣:

%1 = int(42)%2 = add(%1, @Ref.one)

賦值

接下來,讓我們將加法的結果賦值給一個無型別常數:

const x = 42 + 1;

你不會在 expr 中找到對應的處理,因為這不是一個運算式,而是一個陳述式。你也不會找到 statement 函式,因為在 Zig 中,變數賦值只能在容器(結構體)或區塊(例如函式主體)內進行。你會在 containerMembersblockExprStmts 中找到相關邏輯。前者用於結構體主體,後者用於函式主體。我認為先看 blockExprStmts 會比較容易,因為 containerMembers 但兩者作為下一步的學習都是合適的。

讓我們看看 blockExprStmts,它有一個 switch 會為變數宣告(包含常數)導向 varDeclvarDecl 很大!我不會在下方貼出完整的函式。我只會貼出最常見的程式碼路徑:

const type_node = var_decl.ast.type_node;const result_loc: ResultLoc = if (type_node != 0) .{    .ty = try typeExpr(gz, scope, type_node),} else .none;const init_inst = try reachableExpr(gz, scope, result_loc, var_decl.ast.init_node, node);const sub_scope = try block_arena.create(Scope.LocalVal);sub_scope.* = .{    .parent = scope,    .gen_zir = gz,    .name = ident_name,    .inst = init_inst,    .token_src = name_token,    .id_cat = .@"local constant",};return &sub_scope.base;

讓我們從最上方開始。這一段只是遞迴地求值型別運算式(若有的話)與初始化運算式。對於常數 const x: t = init 而言,t 是型別運算式,而 init 是初始化運算式。在我們的範例中,我們沒有型別運算式,而初始化運算式就是我們已經知道如何建構的 .add 指令。

接下來,我們建立一個新的 LocalVal 作用域來表示這個值。該作用域擁有識別字名稱 x、值指令 init_inst,並指向傳給 varDecl 函式的父作用域。此作用域是此函式的回傳值,而呼叫者 blockExprStmts 會從該陳述式開始,將目前的作用域替換為這個新的作用域,以便未來的陳述式與運算式可以參照此賦值。

我們先前的範例回傳的是 ZIR 指令索引,而這個範例回傳的是一個作用域。請注意,對 x 的具名賦值並未產生 ZIR 指令。如果你查看產生的 ZIR,你不會在任何地方看到 x

%2 = int(42)%3 = add(%2, @Ref.one)

x 位於作用域中,而作用域正在追蹤值指令 inst。如果 x 曾被參照,我們就會知道它的值來自該指令,並可據此進行參照。舉例來說,讓我們看看以下在函式主體中的 ZIR:

const x = 42 + 1;const y = x;
%2 = int(42)%3 = add(%2, @Ref.one) // x assignment%4 = ensure_result_non_error(%3) // y assignment

請注意,y 的賦值(指令 %4)直接來自指令 %3。我們不需要在 ZIR 中進行顯式的記憶體載入/儲存,因為我們可以精確地參照指令結果。

請注意,當最終產生機器碼時,後端可能會判斷需要進行載入/儲存(取決於電腦架構),但中間表示不需要做出此決定。

作為給讀者的練習:作為下一步,我會去查看陳述式 const y = x 並學習識別字參照如何運作。這將解釋上述 ZIR 是如何產生的,也是邁向未來更複雜內容的良好墊腳石。

完成 AstGen 流程

AstGen 流程會針對每個檔案執行一次,並遞迴地為整個檔案建構 ZIR。在函式結束時,呼叫者會收到一個 Zir 值。

有了本頁的詳細資訊,你應該能夠追蹤任何 Zig 語言結構並了解其 ZIR 是如何產生的。請記住,經常使用 zig ast-check 指令來檢視 Zig 編譯器實際產生的內容,並將其作為研究應研讀哪些函式的指引。

接下來是 Sema 流程

原文由 Mitchell Hashimoto 發布

本文章由 muse-spark-1.2-contributor 進行翻譯