Zig AstGen: ASTからZIRへ
Zigコンパイラの内部構造シリーズの一部です。
多くのコンパイラでは、抽象構文木(AST)を構築した次のステップとして、中間表現(IR)を生成します。ASTが木構造であるのに対し、IRでは通常、プログラムのさまざまなブロック(ファイル、関数など)ごとに命令の列を作り始めます。この命令列という形式の方が、最適化や実行可能な機械語への変換のための解析が容易になります。
Zigコンパイラには複数の中間表現があります。最初に作られるIRの形式は、Zig Intermediate Representation(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はまだu8やu16、u32などへ強制変換され得るため、型なしとされています。型なし定数が型付き定数へ代入されて初めて、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への代入は任意のcomptime式(すべての値がcomptimeで確定していれば、ほぼ言語全体が対象になります)であり得ます。これはZigの非常に強力な機能です。
こうした動的な可能性を考えると、resultへの代入に対応するZIRがずっと複雑になっていることがわかります。命令%4と%5はtで識別される値を読み込み、それをtype型(値ではなく型そのものを表す型)へ強制変換しています。そして命令%7ではおなじみのas_nodeによる強制変換が行われますが、今回は型オペランドが静的な値ではなくZIR命令%5の結果を参照しています。
最後に、さらに極端な例を挙げます。
const std = @import("std");const result: std.ArrayListUnmanaged(u8) = .{};この場合のZIRは膨大になるため、ここでは示しません。resultの型は、関数ArrayListUnmanagedのcomptime評価によって生成されるジェネリック型です。ZIRはresultに対して事前定義された型を指し示すことができません。型はcomptime評価が行われるまで単に不明だからです。
これがZIRが型なしである理由です。ZIRは、comptime評価とその後の意味解析のために準備された中間形式です。comptimeパスを終えるとすべての型が確定し、完全に型付きの中間表現を形成できるようになります(これはAIRと呼ばれ、AstGenの次のステージの成果物です)。
AstGenの構造
「AstGen」はASTをZIRへ変換するステージです。AstGenのソースはsrc/AstGen.zigにあります。AstGenは公開されたstructではなく、ZIR生成プロセスの内部状態を管理するためだけに使われるstructです。外部の呼び出し元は、ASTツリーを受け取ってツリー全体のZIRを返すgenerate関数を呼び出します。
AstGen structには内部状態のための多くのフィールドがあります。ここではすべてを扱うわけではありませんが、重要なものを以下に示します。以下の構造体のフィールドは、ソースコード上の順序とは異なって示しています。
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...};最初のグループであるgpa、arena、treeはAstGenプロセスへの入力です。gpaは生成プロセスを超えて存続するデータの確保に使われます。arenaはZIR生成中にのみ使われる一時的なデータの確保に使われ、ZIRを呼び出し元に返す前に解放されます。そしてtreeはZIRへ変換されるASTです。
なぜ「arena」と呼ばれるのですか? 「arenaアロケータ」は、個々の要素を一つずつ追跡して解放するのではなく、メモリの領域全体を一括で確保・解放するタイプのメモリアロケータです。共通のライフサイクルを持つ確保に対してよく使われます。個々の要素を追跡するよりも、メモリの塊全体を解放する方がはるかに簡単で高性能だからです。アロケータやZigの基礎について詳しくは、トーク「What’s a Memory Allocator Anyways?」をご覧ください。
2つ目のグループであるinstructions、extra、string_bytesはAstGenプロセスからの出力です。このグループは、生成されるZIRの中核をなすため非常に重要です。
instructionsは命令のリストです。このリストの各エントリが1つの命令に対応します。たとえば、このページで先ほど見たZIR出力では、リストの9番目のエントリがas_node(%7, %8)命令にあたります。extraはZIR命令が必要に応じて格納する追加データです。これはASTノードとそのextra_dataフィールドと同じパターンです。ASTのextra_dataを理解していない場合は、AstGen全体でこのパターンが使われているため、今一度復習しておくとよいでしょう。string_bytesは識別子、文字列リテラル、ドキュメントコメントなどのための文字列インターン用プールです。すべての静的文字列はAstGenの一部としてインターンされ、ZIR命令は文字列自体のコピーを保持するのではなく、この文字列バイト列リスト内のオフセットを参照します。
ZIR命令の構造
ASTをZIRへ変換する処理に進む前に、1つの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の値によって決まります。データはデータフィールド内に直接格納される場合もあれば(整数リテラルの値など)、データフィールドが別の場所にある情報への参照となる場合もあります。
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}, {}, {})該当する命令は%2のint(42)です。ご覧のとおり、静的な値42は命令自体に直接エンコードされています。内部的には、これは以下のように表現されています。
Zir.Inst{ .tag = .int, .data = .{ .int = 42, },}これがZIR命令として最もシンプルな形です。データが命令内に直接埋め込まれています。この場合、データの有効なタグは.intで、静的な整数値が埋め込まれています。
データが参照される形であったり、タグがInst構造体に直接収まらないような複雑な情報を含んでいたりする方が、実際にははるかに一般的です。その例を次に示します。
参照(Refs)
多くのZIR命令は値への参照を含みます。たとえば、単項の!演算子(論理否定)は、静的な値!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_trueです。bool_not命令は1つのオペランドを取ります。内部的には、これはおおよそ次のような構造のInstです(例にとって重要でない一部のフィールドは意図的に省略しています)。
Zir.Inst{ .tag = .bool_not, .data = .{ .un_node = .{ .operand = Ref.bool_true }, },}Ref.bool_trueは、静的な値trueを表すRef enumのタグの一つです。他にも多くのタグがあります。たとえば、すべての組み込み型に対してそれぞれタグが存在します。次の例は意味的にはナンセンスですが、有効な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の値がタグなのかインデックスなのかを判定するには、値がタグの数より大きいかどうかをチェックします。大きい場合は、値からタグの数を引いたものが命令のインデックスになります。
これは非常によく行われるため、このための公開関数が2つ用意されています。indexToRefとrefToIndexです。上記の例では、オペランドを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; }}追加データ(Extra Data)
一部のZIR命令は、ときに「trailing」データとも呼ばれる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」の略)というデータタグを使います。これはextra配列内で追加データが見つかる開始位置を指すpayload_indexフィールドを含みます。.addタグのコメントを見ると、extraフィールドに格納されている構造体はlhsとrhsを持つZir.Inst.Binであることがわかります。この場合はlhs = Ref.one、rhs = %2です。
ASTノードと同様に、AstGenのソースには構造化された追加データを型安全な方法でエンコードするヘルパー関数addExtraが含まれています。このパターンについては、Zig ParserページのASTノードのセクションでより詳しく説明されています。
追加データ自体も、静的な値や他のZig.Inst.Ref値などを含む場合があります。各タグがどのような値をエンコードしているかは、ソースコードを見て確認する必要があります。
その他のデータ型
他にも多くのデータ型がありますが、ここですべてを網羅的に扱うつもりはありません。上記のデータ型は、ZIR命令に関する情報をエンコードするために使われるより広範なパターンを理解する上で、特に重要だと考えたものです。
AstGenの構成要素
AstGenのプロセスには、ZIRを構築する際に使われる共通のコンポーネントがいくつかあります。これらのコンポーネントは共通のロジックを表しており、AstGenの全体的な動作を理解する上で欠かせません。これらのコンポーネントのほとんどは、AstGen内のすべての関数に引数として渡されます。
これはAstGenのすべての機能を網羅したリストではありませんが、いくつかの重要な項目を取り上げます。
スコープ
AstGenではスコープの概念が導入され、「親」スコープにある識別子を参照したり、未定義の識別子が使われた際にエラーを記録したり、識別子のシャドーイングを検出したりできるようになります。
スコープはAstGen.zig内にあるScope structを使って定義されています。スコープは多態的で、多くのサブタイプのいずれかになることができます。これはZigでは@fieldParentPtrを使った一般的なパターンで実装されています。このパターンの説明はZig内でよく使われるものであるため、このページの範囲外とします。
スコープには7つの「タイプ」があります(執筆時点)。
Scope.Top- ファイルを表す最も外側のスコープです。常に最も親にあたるスコープであり、親スコープを持ちません。このスコープは追加のデータを追跡しません。GenZir- このstructは一般的にZigにおける「ブロック」を表しますが、ASTノードをまたいだ多くの追加状態の追跡にも使われます。現在のブロックラベル(ある場合)、命令リスト、comptimeのコンテキストにいるかどうかなどを追跡します。Scope.Namespace- 参照可能な宣言の順序なし集合を含むスコープです。ここで重要なのは「順序なし」という点で、このスコープは通常、structやunionなどのブロック内の子スコープとして使われます。例として、struct内の変数はファイル内で後に定義された変数を参照できますが、関数本体ではそれができません。前者はNamespaceであり、後者はそうではありません。Scope.LocalValとScope.LocalPtr- 定義された単一の識別子(変数や定数など)です。識別子が定義されるたびに、このタイプの新しい子スコープが作成され、それが「スコープ内」にある状態になります。これはちょうど1つの宣言を表し、順序なしリストではないという点でNamespaceとは異なります。Scope.Defer-deferやerrdeferを取り囲むスコープです。deferが存在することを追跡し、すべての脱出箇所で命令を生成できるようにするために使われます。
スコープはparentフィールドを持ち、これを使ってスコープを上へたどることができます。これが識別子解決の仕組みです。
文字列インターン
文字列値(バイト配列)はZIR命令内に直接格納されません。文字列はインターンされ、単一の連続したstring_bytes配列に格納されます。ZIR命令内には文字列値の先頭へのインデックスが格納されます。つまり、共有される文字列(識別子など)は正確に一度だけ格納されるということです。
ZIRは、ここまでのパイプラインの中で初めて文字列を格納する構造です。ASTノードはトークンのインデックスを格納し、トークンはソースコード内での開始・終了オフセットを格納します。そのため、ASTとソースの両方を保持しておく必要があります。AstGenの後は、ASTとソースを解放できます。これにより、非常に大きなZigプログラムを解析しつつ、メモリ内には必要なものだけを保持することが可能になります。
結果位置(Result Locations)
ResultLoc構造体は、式ツリーの最終結果をどこに書き込むべきかを追跡します。ASTを走査してZIRを生成する際、何らかの書き込み操作の値が深くネストされていることがあり、AstGenは最終的な値をどこに書き込むべきかを知る必要があります。
次の例を考えてみましょう。
const x = blk1: { switch (someUnion) { .someTag => break :blk1 42, else => break :blk1 0, }}最も外側のスコープでは、定数xへ書き込んでいます。ASTツリーをイメージすると、そこからブロック、switch、switchのケース、そして最後にラベル付きbreak文へとネストしていきます。少なくとも4つの関数を再帰した後で、AstGenがラベル付きbreakの値をどこに書き込むべきかを知る手がかりとなるのがResultLocです。
ResultLocは多くの結果タイプを取り得るタグ付き共用体です。コメントが充実しているので、ソースを読むことをお勧めします。ここでは例としていくつかのタグを説明します。
.discard- 代入が破棄されることを意味します。破棄用の識別子_の右辺である場合です。この場合、AstGenは格納用の命令を生成する必要がないと判断し、値を単に破棄できます。.ty- 代入先が何らかの型付きの値であることを意味します。これは結果の値を返す前に強制変換するためのas_nodeを生成します。.ptr- 代入がメモリ上の位置へ書き込まれることを意味します。これは結果をそのメモリ位置へ書き込むためのstore命令を生成します。
ZIRの生成
それでは、AstGenがどのようにASTをZIRへ変換するのかを見ていきましょう。抽象的には、木を走査して各ノードに対して命令を出力することで、ASTはZIRへ変換されます。AstGenはAST全体を積極的に(eagerに)ZIRへ変換します(遅延評価はしません。このパターンは後のコンパイラステージで登場します)。
重要なことに、AstGenは初めての本格的な意味的検証の層を導入します。たとえば、AstGenは識別子が定義されていること、外側のスコープをシャドーイングしていないことなどを検証し、親スコープをどのようにたどるべきかを把握しています。以前、ASTの構築では構造的検証が導入されたことを思い出してください。そこではx pub == 7のようなトークン列が構文エラーになることは保証されましたが、識別子xが定義されているかどうかまでは検証されませんでした。そしてトークナイザ自体はトークン単位の検証のみを行います。72は有効なトークンだが72aはそうではない(識別子は数字で始められない)といった検証です。
ZIRがどのように生成されるかを学び始めるのに最も手頃なのは、expr関数を見ることです。この関数はZig言語におけるあらゆる有効な式に対するZIRを生成します。IR生成は深く再帰的なプロセスであるため、言語の最もシンプルな構成要素から始めて、そこから積み上げていくのが最もわかりやすいと私は考えています。
整数リテラル
シンプルな静的整数値から始めてみましょう。
42これはASTノードのタグ.integer_literalとして解析され、巨大な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"、より正確には4と2という2バイトが得られます。
次に、その文字配列を符号なし64ビット整数として解析しようとします。数が大きすぎる場合は、「other parts」というコメントの先へ進みます。そこは「大きな整数(big int)」の格納に関する複雑な処理で、ここでは扱いません。この例では、すべての整数定数が18,446,744,073,709,551,615(符号なし64ビット整数の最大値)以下であると仮定します。
符号なし? 負の数はどうなるのですか? 負の数の前にある接頭辞-は別のASTノードとして格納され、ZIRでは別途単項演算を生成します。整数リテラルは常に正の数です。
今回の例では42は有効なu64として解析されるため、数の解析は成功します。次に、値が0や1である特殊なケースを扱います。これらは特別なタグ付き参照を持っているからです。そうでない場合は.intのZIR命令を生成し、その命令のインデックスをresultに格納します。
最後にrvalueを呼び出します。これは先ほど説明したResultLocの意味論を値に適用し、そのまま返すべきか、既知の型へ変換すべきか、メモリ位置に格納すべきかなどを判断します。多くの場合、これは何もしない操作(no-op)となり、単に.intのZIR命令を返します。
加算
静的な整数値から発展させて、加算を行ってみましょう。
42 + 1これはASTノードのタグ.addとして解析され、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);}これは初めての再帰の例です。左辺と右辺の式それぞれについてZIRを再帰的に構築することで得られたlhsとrhsのデータを持つ.add ZIR命令を構築しています。左側の式は42、右側の式は1です。整数リテラルの説明から、これらが.int命令になることがわかります。
生成されるZIRは次のような見た目になります。
%1 = int(42)%2 = add(%1, @Ref.one)代入
次に、この加算を型なし定数に代入してみましょう。
const x = 42 + 1;これは式ではなく文であるため、exprの中にこのケースは見つかりません。statementという関数も見つかりません。Zigでは変数代入はコンテナ(struct)またはブロック(つまり関数本体)の中でのみ可能だからです。これはcontainerMembersかblockExprStmtsの中に見つかります。前者はstruct本体に、後者は関数本体に使われます。個人的にはcontainerMembersよりもblockExprStmtsの方が先に見るには簡単だと思いますが、学習目的としてはどちらも有効な次のステップです。
blockExprStmtsを見てみましょう。この中には変数宣言(定数を含む)に対してvarDeclへとつながるswitchがあります。varDeclは大きな関数です。ここでは関数全体を貼り付けることはしません。最も一般的なコードパスだけを貼り付けます。
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 assignmentyへの代入(命令%4)が直接命令%3から来ていることに注目してください。ZIRでは命令の結果を正確に参照できるため、明示的なメモリのロード/ストアを行う必要はありません。
後に機械語が生成される際には、バックエンドが(コンピュータアーキテクチャに応じて)ロード/ストアが必要だと判断する場合もありますが、中間表現の段階でこの判断を下す必要はありません。
読者への課題として、次のステップでは文const y = xに注目し、識別子の参照がどのように機能するかを学んでみることをお勧めします。これにより上記のZIRがどのように生成されるかが説明でき、今後のより複雑な内容への良い足がかりになります。
AstGenプロセスの完了
AstGenプロセスはファイルごとに一度実行され、ファイル全体のZIRを再帰的に構築します。関数の終わりには、呼び出し元はZir値を受け取ります。
このページの詳細を踏まえれば、あらゆるZigの言語構造を追い、ZIRがどのように生成されるかを学べるはずです。zig ast-checkコマンドを頻繁に使って、Zigコンパイラが実際に何を生成しているかを確認し、どの関数を学ぶべきかの指針にしてください。
次はSemaプロセスです。
記事をランダムに読む