Zig AstGen: ASTからZIRへ
原文は Mitchell Hashimoto により に公開されました。 このブログを購読する
本記事は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でまず注目すべき点は、プログラムの実際のロジックが、より細粒度の命令(instructions)に分解され始めていることです。ZIR命令はインデックスで参照でき、上記の出力では%記号の後に表示されています。たとえば、命令9は、hello関数の戻り値の型へresultへの参照を変換しています。
もっと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は公開された構造体ではなく、この構造体はZIR生成プロセスの内部状態を管理するためだけに使われます。外部の呼び出し元は、ASTツリーを受け取ってツリー全体のZIRを返すgenerate関数を呼び出します。
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...};最初のグループである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命令は値への参照を含みます。たとえば、単項!演算子(論理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_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命令はextraフィールドへの参照を含みます。これは「trailing」データとも呼ばれることがあります。これは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レンダラーがadd命令を理解し、extraからのデータを整形して出力する(命令%3)ため、ZIRのテキスト出力ではすぐにはわかりません。内部的には、次のようになっています。
// 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構造体を使って定義されます。スコープはポリモーフィックで、多くのサブタイプのいずれかになることができます。これはZigでは@fieldParentPtrを使った一般的なパターンで実装されています。このパターンの説明はZig内で一般的なものであるため、このページの範囲外とします。
スコープには(執筆時点で)7つの「タイプ」があります。
Scope.Top- ファイルを表す最も外側のスコープです。これは常に最も親のスコープであり、親スコープを持ちません。このスコープは追加のデータを追跡しません。GenZir- この構造体は一般的にZigにおける「ブロック」を表しますが、ASTノードをまたがる多くの追加の状態追跡にも使われます。現在のブロックラベル(あれば)、命令リスト、comptimeの位置にいるかどうかなどを追跡します。Scope.Namespace- 参照可能な宣言の順序付けされていない集合を含むスコープです。ここで重要なのは「順序付けされていない」という点で、このスコープは通常、構造体や共用体などのブロック内の子スコープです。例:構造体の変数はファイル内で後に定義された変数を参照できますが、関数本体ではできません。前者は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つの関数を再帰した後、ラベル付きbreakの値をどこに書き込むべきかをAstGenが知るための手段が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"、より具体的には2バイトの4と2が得られます。
次に、その文字配列を符号なし64ビット整数として解析しようと試みます。数値が大きすぎる場合は、「other parts」というコメントの部分へフォールスルーします。これは「大きな整数(big ints)」の格納に関する複雑な処理で、ここでは掘り下げません。この例では、すべての整数定数が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では変数への代入はコンテナ(構造体)やブロック(つまり関数本体)の中でのみ可能だからです。これはcontainerMembersやblockExprStmtsの中に見つかります。前者は構造体の本体に、後者は関数本体に使われます。最初に見るなら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プロセスです。
記事をランダムに読む
コメント
ログインしてコメントする