Zig Sema: ZIRからAIRへ
原文は Mitchell Hashimoto により に公開されました。 このブログを購読する
これはZigコンパイラ内部シリーズの一部です。
「AstGen」の次のコンパイラステージは「Sema」です。Semaは、AstGenステージが出力したZIRを受け取ってAIRを生成する役割を担います。AIRは「Analyzed Intermediate Representation(解析済み中間表現)」の略で、完全に型付けされた中間表現です。一方、ZIRは型付けされていない中間表現です。AIRはそのまま直接機械語へと下げていくことができます。
AstGenのページでも触れたように、ZIRが型付けされていない理由の一つは、Zigプログラムを完全に型付けするには、ジェネリック型などを完全に具体化するためのcomptime評価が必要になるからです。そのため、SemaはZigプログラムのすべてのcomptime評価も行います。ここが魔法が起こる場所です!
AIRはZIRやASTのようにファイル単位ではなく、関数単位で生成されます。このページでは、関数本体をZIRからAIRへ変換する処理に焦点を当てます。より大きなコンパイルプロセスがどのようにSemaを呼び出すかについては、別のページで取り上げます。
注意: ファイルスコープで生成されるAIRも一部存在するため、AIRが関数単位で生成されると言うのは100%正確ではありません。ただし、ファイルスコープのAIRの処理を理解するには、コンパイルプロセス全体についてより深く触れる必要があり、それは今後の記事に譲ります。このページは、その理解に向けた重要な土台となります。
AIRはどのような見た目か
内部構造や構築方法を詳しく見る前に、まずはAIRの実例を見てみましょう。シンプルなZigプログラムと、それから生成されるAIRがこちらです。
export fn add(a: u32, b: u32) u32 {
return a + b;
}# Begin Function AIR: add:
%0 = arg("a", u32)
%1 = arg("b", u32)
%2!= dbg_stmt(2:5)
%3 = add(%0!, %1!)
%4!= ret(%3!)
# End Function AIR: add任意のプログラムのAIRは、zig build-obj --verbose-air <file.zig>を実行することで確認できます。ただし、これにはZigコンパイラのデバッグビルドが必要です。Zigコンパイラをソースからビルドする方法については、ZigのWikiのガイドを参照してください。
AIRは関数単位で生成・表示されます。上記の出力では、add関数のAIR出力を示すコメント風の行が確認できます。また、AIRはエクスポートされた関数や参照されている関数に対してのみ生成されることにも注意してください(遅延生成されます)。そのため、デバッグ目的では、AIRを確認したい関数に通常exportを付けています。
コンパイラのこれまでのステージについての解説を読んできた方は、AIRの表示形式がZIRと非常によく似ていることに気づくでしょう。両者は似ており、命令のタグ名を共有している場合も多くありますが、AIRはまったく別の独立した中間表現です。
%1は命令のインデックスです。その後に!が付いている場合、その命令がZigプログラムの他の既知の部分から一切使われていない/参照されていないことを意味します。上記の例では、%0と%1はどちらも加算命令で使われて%3を作り、%3はreturn命令で使われています。一方、デバッグ文の%2やreturnの結果である%4は使われていません。AIRを最終的な形式に変換するバックエンドは、必要に応じてこの情報を利用できます。
注意: 冒頭で触れたように、関数スコープではなくファイルスコープで生成されるAIRも存在します。たとえば、ファイルスコープの変数初期化やcomptimeブロックなどです。現在、このAIRを表示する方法はありません。ただし、ファイルスコープのAIRは常にcomptime評価されるため、通常は単なるconstant命令の並びに過ぎません。
AIRの構造
ZIRがどのようにAIRへ変換されるかを見る前に、まずはAIRと個々のAIR命令のフォーマットについて説明します。AIRの構造を理解しておくと、その構築過程がずっと理解しやすくなります。
AIRの構造はZIRと非常によく似ていますが、微妙な違いがあります。
pub const AIR = struct {
instructions: std.MultiArrayList(Inst).Slice,
extra: []const u32,
values: []const Value,
};ZIRと同様に、AIRも本質的にはinstructionsフィールドに格納された一連の命令です。そしてZIRやASTと同様に、命令はextraスライスに命令固有の追加データを格納する場合があります。これら2つのフィールドの動作はZIRやASTとまったく同じです。もし現時点で直感的に理解できない場合は、一度戻ってそれらの構造を復習することをおすすめします(特に、extraデータがどのように埋められるかを詳しく解説しているASTの構造の部分です)。
注意: ここではMultiArrayListの説明も省略します。というのも、これはZIRやASTで使われているのとまったく同じパターンで、パーサの解説のページで詳しく説明済みだからです。
AIRに新たに存在するフィールドがvaluesスライスです。これにはZIRのcomptime実行によって得られた既知の値が格納されます。命令はcomptimeで確定した値を参照することがあります。たとえば、ファイルのルートにconst a = 42;とあれば、その値はcomptimeで確定しているため、42という値はvaluesリストに格納されます。comptimeの値の例は後ほどさらに見ていきます。
文字列定数用の文字列テーブルのようなフィールドが存在しないことにも注目してください。AIRの構築プロセスや、AIRを利用する将来のCodegenプロセスは、引き続きZIRにアクセスできるため、必要に応じてZIR側の文字列テーブルを参照できます。
単一のAIR命令の構造
単一のAIR命令の構造はInst構造体です。
pub const Inst = struct {
tag: Tag,
data: Data,
};この構造体はZIRのInst構造体と構造的に同一です。tagはenumで、そこにタグ固有のdataがあり、これは取りうるデータ型のunionです。AIRのTagとData型はZIRのものとは別物ですが、機能的には同一です。
基本的な例を見てみましょう。セマンティック解析によってある値が定数であると判断されると、.constant命令が作成されます。constantタグの命令のデータはty_plフィールドで、定数の型と、comptimeで確定した値を保持するvalues配列へのインデックスであるペイロードが含まれます。
const c = 42;%0 = constant(comptime_int, 42)Inst{
.tag = .constant,
.data = .{
.ty_pl = . {
.ty = Type.initTag(.comptime_int),
.pl = 7, // index into values
},
},
};上記の例は、Zigのコード、表示されたAIR、そして内部的な命令表現を示しています。cがどこにも現れていないことに注意してください。なぜなら、このAIRを生成するために解析されたZIRは、代入の右辺(rhs)である定数42だけを対象としているからです。
Value、Type、TypedValue
Sema全体で非常によく使われる型が3つあります。Value、Type、そしてTypedValueです。Valueは整数や構造体などのcomptimeで確定した値を表します。Typeはu8のようなcomptimeで確定した型を表します(注意: すべての型はcomptimeで確定しています)。そしてTypedValueは、正確な型情報を持つ値、つまり値42と型u16をペアにしたものです。
少し混乱しやすい点として、Zigでは型自体も正当な値になり得るということがあります。型は、型が「type」である値になり得るのです。たとえばZigではconst c = u8と書くことができます。cは型が「type」で値が「u8」である値です。以下でこうしたケースの例を数多く示すので、より直感的に理解できるようになるでしょう。
Values
Value構造体は以下のとおりです。
pub const Value = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}値は、その値がどのような種類(kind)であるかを示すtagを持ちます。ここであえて「type」ではなく「kind」という言葉を使っているのは、値は型付けされていないものの、場合によっては型を自明に知ることができるためです。一部のtag値はペイロードを持ちませんが、そうでない場合はペイロードが必要です。ptr_otherwiseフィールドは、値を取得するために必要な追加情報を含むペイロードへのポインタです。
Payload型は、より具体的なペイロード型のPayload型フィールドへのポインタです。これは、Zig内部でポリモーフィックな型を実現する一つの方法です。そして@fieldParentPtr組み込みを使って完全な型を特定します。これはZigでよく見られるパターンであり、ここで詳しく説明することは本稿の範囲外です。仕組みを理解するには、@fieldParentPtrに関するガイドを検索してみてください。
整数
整数値を見てみましょう。定数42は、以下のようなValueで表されます。
Value{
.ptr_otherwise = &Payload.U64{
.tag = .int_u64,
.data = 42,
},
};注意: この値は厳密には正確ではありません。ptr_otherwiseフィールドは完全なPayload.U64構造体ではなくPayloadを指しています。しかし、上記の方が意図が分かりやすいため、本稿ではこの形式で表記します。
ご覧のとおり、値42はint_u64フラグで表されています。これは、int_u64がu64に収まるすべての値を表すために使われているからです。これは42がu64型であることを意味するわけではありません — それでもu8やu16などである可能性があります。Typeを伴わないValueは型付けされていません。あるいは、少し型があるように感じて気になるのであれば、値は厳密には型付けされていないと考えてください。
型の値(TypedValueではない)
Zigでは型もまた値になり得ます。たとえばconst c = u8という文は完全に正当なZigコードです。型u8を定数cに代入しており、c自体は型がtypeである値(型を保持する値)です。これは非常に混乱しやすく、実際のセマンティック解析で型の値が使われる場面ではさらに混乱が増すため、ここで早めに説明しておきます。
値としての定数u8は、以下のようなValueで表されます。
Value{
.tag_if_small_enough = .u8_type,
};この値はペイロードを持ちません。値はu8_typeというタグによって正確に表されます。この値はu8という型そのものです。しかし、この値自体は依然として型付けされていません。ただし、この場合の型は自明に分かります。なぜならu8_typeに対して有効な型はtypeしかないからです。しかしValue構造体自体は技術的には依然として型付けされていません(たとえば、将来Zigにすべての整数型を表すinttypeというキーワードが導入されれば、この値がinttypeでもtypeでも有効になり得るでしょう)。
より複雑な型の値を見てみましょう。const c = [4]boolです。
Value{
.ptr_otherwise = &Payload.Ty{
.tag = .ty,
.data = Type{
// .tag = .array
// type data including element type (bool), length (4), etc.
},
},
};この値は.ty(「type」の略)というタグを持ちます。これは何らかの型である値です。ペイロードは、4つのboolean要素を持つ配列を記述するType構造体です。Type構造体の詳細は次のセクションで説明します。ここで重要なのは、Valueが配列の値ではなく配列の型の値を表しているという点です。
まだ混乱が残る場合は、Typeに定義されているtoValue関数を見てみてください。Typeは常に値に変換できます。なぜなら型は値になり得るからですが、逆は常に成り立つわけではありません。
Types
Type構造体はValueと同等ですが、型固有の型を使っている点が異なります。
pub const Type = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}ここでは付け加えることはあまりありません。この動作はValueとほぼ同じだからです。少なくとも一つの具体例として、[4]boolがどのように表されるかを見てみましょう。
Type{
.ptr_otherwise = &Payload.Array{
.tag = .array,
.data = .{
.len = 4,
.elem_type = Type{.tag_if_small_enough = .bool},
},
}
}TypedValue
TypedValueは、単にTypeとValueを組み合わせたもので、値に正確な型情報を紐づけます。この2つが揃って初めて、値の正確な型が確定します。
pub const TypedValue = struct {
ty: Type,
val: Value,
};Semaの構造
Zigコンパイラで「AstGen」の次のフェーズは、通称「Sema」と呼ばれています。Semaは、このフェーズを主に担う構造体の名前でもあります。Semaのソースはsrc/Sema.zigにあります。非常に大きなファイルで(執筆時点で18,000行以上)、自ら「Zigコンパイラの心臓部」と称しています。
Semaには多くの公開APIがありますが、最も重要なのはanalyzeBodyです。Sema構造体は内部状態のためのフィールドを多数持っています。すべてを網羅するわけではありませんが、主なものを以下に示します。なお、類似したフィールドの説明をしやすくするため、フィールドの順序はソースコードとは異なります。
pub const Sema = struct {
mod: *Module,
gpa: Allocator,
arena: Allocator,
perm_arena: Allocator,
code: Zir,
owner_decl: *Decl,
func: ?*Module.Fn,
fn_ret_ty: Type,
air_instructions: std.MultiArrayList(Air.Inst) = .{},
air_extra: std.ArrayListUnmanaged(u32) = .{},
air_values: std.ArrayListUnmanaged(Value) = .{},
inst_map: InstMap = .{},
// other fields...
};最初のグループは、Semaプロセスに必要な主要な入力です。gpaは、Semaプロセスや宣言のライフタイムを超えて存続するデータを確保するために使われます。arenaは、Sema終了後に解放される一時的なデータの確保に使われます。そしてperm_arenaは、現在セマンティック解析中の宣言のライフタイムに紐づくデータを確保するために使われます。
modは解析対象のモジュールです。モジュールについては本ページでは扱いませんが、単一のプログラム内のすべてのZigコードをカプセル化するものです。本ページではモジュール関連のAPIは無視します。
2つ目のグループは、Semaによってセマンティック解析される対象を記述する入力です。codeは、解析中の宣言を含むファイルのZIRです。owner_declは、通常現在解析中の宣言で、関数やcomptimeブロック、テストなどです。funcとfn_ret_tyは、関数を解析する際の追加情報です。
3つ目のグループは、Semaプロセスの出力です。これらが主にAir構造体を構築するフィールドであることにお気づきでしょう。これらはSemaプロセス全体を通じて埋められ、最終的なAirの結果を構築するために使われます。
inst_mapフィールドは特に重要で、Sema全体で使われます。これはZIRからAIRへのマップです。すべてのZIR命令がAIR命令になるわけではありませんが、このマップはAIR命令が後で解決される特定のZIR命令(たとえば加算のオペランドや関数パラメータなど)を参照できるように、頻繁に使われます。
関数本体の解析
Semaの中核となる関数はanalyzeBodyです。これは「本体(body)」— 関数本体、ループ本体、ブロック本体など — のZIRを解析し、その本体のAIRを生成するために使われます。最もシンプルな本体は関数本体です。まずは、非常にシンプルな関数と、それが生成するAIRを見てみましょう。
export fn add() u32 {
return 40 + 2;
}# Begin Function AIR: add:
%1 = constant(comptime_int, 40)
%2 = constant(comptime_int, 2)
%3 = constant(comptime_int, 42)
%4 = constant(u32, 42)
%0!= dbg_stmt(2:5)
%5!= ret(%4!)
# End Function AIR: add補足: 関数のAIRは、関数をエクスポートしてzig build-obj --verbose-air example.zigを実行することでダンプできます。
AIR命令の詳細を知らなくても、何が起こっているかは大まかに理解できるはずです。処理の大まかな流れは以下のとおりです。
- 型付けされていない定数intの
40が現れます(命令%1) - 型付けされていない定数intの
2が現れます(命令%2) - comptimeで加算が実行され、結果が型付けされていない定数intの
42であることが分かります(命令%3)。 - 定数
42が型u32と紐づけられます(命令%4)。値42はu32に自動的に収まるため、変換は不要です。この型付けは、戻り値の型u32に合わせるために必要です。 %4で生成された値を返します(命令%5)。
なるほど!多少冗長ではありますが、この関数addがどのように実装されているかは明確に分かります。また、comptime評価が実行され、加算結果がこの段階ですでに事前計算されていることも分かります。これが最終的に機械語へと下げられる際には、実際の加算結果はすでに計算済みとなっています。
次に、このAIRがどのように一歩ずつ生成されたのかを見ていきましょう。
関数をステップ実行する
主要なAIR生成ループはanalyzeBodyInner(analyzeBodyから呼ばれる)にあります。これはZIR命令を順番に反復処理し、個々のZIR命令ごとに0個以上のAIR命令を生成します。
const result = while (true) {
const inst = body[i];
const air_inst: Air.Inst.Ref = switch (tags[inst]) {
.alloc => try sema.zirAlloc(block, inst),
.alloc_inferred => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_mut => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_mut)),
.alloc_inferred_comptime => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_comptime_mut => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_mut)),
.alloc_mut => try sema.zirAllocMut(block, inst),
// hundreds more...
};
try sema.inst_map.put(sema.gpa, inst, air_inst);
i += 1;
}これにより、与えられた一連のZIRに対してAIRがどのように生成されるかを非常に理解しやすくなっています。ZIRをダンプして命令を一つずつ追うことで、AIRがどのように生成されるかを特定できます。上記の例のadd関数のZIRは以下のとおりです。
%0 = extended(struct_decl(parent, Auto, {
[25] export add line(6) hash(4ca8d4e33898374bdeee80480f698dad): %1 = block_inline({
%10 = func(ret_ty={
%2 = break_inline(%10, @Ref.u32_type)
}, body={
%3 = dbg_stmt(2, 5)
%4 = extended(ret_type()) node_offset:8:5
%5 = int(40)
%6 = int(2)
%7 = add(%5, %6) node_offset:8:15
%8 = as_node(%4, %7) node_offset:8:15
%9 = ret_node(%8) node_offset:8:5
}) (lbrace=1:21,rbrace=3:1) node_offset:7:8
%11 = break_inline(%1, %10)
}) node_offset:7:8
}, {}, {})関数の本体はZIR命令%3から始まり%9で終わります。%3は、add関数の本体を解析する際にanalyzeBodyInnerが最初に目にする命令です。%2や%10といったそれ以前(およびそれ以降)のZIR命令は、もっと早い段階で解析されます。そのプロセスについては、後ほど少し触れます。
%3: dbg_stmt
最初の命令は.dbg_stmtです。メインループを見ると、これがzirDbgStmtにつながることが分かります。その関数を、少し簡略化した形で以下に再掲します。
fn zirDbgStmt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!void {
const inst_data = sema.code.instructions.items(.data)[inst].dbg_stmt;
_ = try block.addInst(.{
.tag = .dbg_stmt,
.data = .{ .dbg_stmt = .{
.line = inst_data.line,
.column = inst_data.column,
} },
});
}これは、ZIRからAIR命令が変換されるシンプルで分かりやすい例です。これ以上シンプルな例はありません。この場合、dbg_stmtというZIRはほぼそのままdbg_stmtというAIR命令に変換されます。これにより、前に示した%0のAIR命令が生成されます。
%0!= dbg_stmt(2:5)%4: extended(ret_type())
.extendedというZIR命令はzirExtendedを呼び出し、そこで子オペコードをループして.ret_typeをzirRetTypeにマッピングします。その関数を以下に再掲します。
fn zirRetType(
sema: *Sema,
block: *Block,
extended: Zir.Inst.Extended.InstData,
) CompileError!Air.Inst.Ref {
const src: LazySrcLoc = .{ .node_offset = @bitCast(i32, extended.operand) };
try sema.requireFunctionBlock(block, src);
return sema.addType(sema.fn_ret_ty);
}解析中の関数の戻り値の型は、Sema上のfn_ret_tyフィールドから取得できます。addType関数は型定義のための命令を追加します。今回の関数では、結果の型はu32であり、これはよく知られた型なので追加の命令は生成されません。
試しに、戻り値の型をよく知られていない型であるu9に変えてみると、AIRは次のような命令を生成します。
%5 = const_ty(u9)%5: int(40)
次は、定数40に対する.int命令です。これはメインの解析ループ内でzirInt関数の呼び出しにつながります。zirInt関数はint値を読み取り、addConstantを呼び出します。両方の関数を以下に示します。
fn zirInt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air.Inst.Ref {
const int = sema.code.instructions.items(.data)[inst].int;
return sema.addConstant(ty, try Value.Tag.int_u64.create(sema.arena, int));
}pub fn addConstant(sema: *Sema, ty: Type, val: Value) SemaError!Air.Inst.Ref {
const gpa = sema.gpa;
const ty_inst = try sema.addType(ty);
try sema.air_values.append(gpa, val);
try sema.air_instructions.append(gpa, .{
.tag = .constant,
.data = .{ .ty_pl = .{
.ty = ty_inst,
.payload = @intCast(u32, sema.air_values.items.len - 1),
} },
});
return Air.indexToRef(@intCast(u32, sema.air_instructions.len - 1));
}addConstant関数は、comptimeで確定した値を追加するためにSema内の多くの場所で呼ばれます。まずaddTypeを使って型を追加しますが、これは前の命令でも見ました。今回の場合、型はcomptime_intであり、これはよく知られた型なので新しい命令は生成されません。次に、値がair_valuesに追加され、最後にconstantというAIR命令がair_instructionsに追加されます。これにより、次のようなAIR命令が生成されます。
%1 = constant(comptime_int, 40)注意すべき点として、.constant命令のペイロードはair_valuesスライスへのインデックスを参照しているということがあります。すべてのcomptimeで確定した値はair_valuesに格納され、命令内での参照はすべてair_valuesスライスへのインデックスとして格納されます。
%6は定数が2である点を除けば%5と同一なので、説明は省略します。
%7: add(%5, %6)
次に、初めての実際の論理演算、すなわち加算が登場します。.add命令はzirArithmetic関数につながります。この関数は多くの二項算術演算で使われます。その関数を以下に示します。
fn zirArithmetic(
sema: *Sema,
block: *Block,
inst: Zir.Inst.Index,
zir_tag: Zir.Inst.Tag,
) CompileError!Air.Inst.Ref {
const inst_data = sema.code.instructions.items(.data)[inst].pl_node;
sema.src = .{ .node_offset_bin_op = inst_data.src_node };
const lhs_src: LazySrcLoc = .{ .node_offset_bin_lhs = inst_data.src_node };
const rhs_src: LazySrcLoc = .{ .node_offset_bin_rhs = inst_data.src_node };
const extra = sema.code.extraData(Zir.Inst.Bin, inst_data.payload_index).data;
const lhs = sema.resolveInst(extra.lhs);
const rhs = sema.resolveInst(extra.rhs);
return sema.analyzeArithmetic(block, zir_tag, lhs, rhs, sema.src, lhs_src, rhs_src);
}上部にある定数への変数代入はすべて、ZIR命令をデコードしています。特に重要な代入はlhsとrhsです。resolveInst関数は、与えられたZIR命令に対するAIR命令のインデックスを見つけるために使われます。したがって、ZIR命令の%5と%6が与えられると、lhsとrhsはそれぞれ結果として得られるAIRのインデックスに設定されます。これらのAIR命令は、引数に対する.constant命令を作成した以前のループの反復処理で設定されたものです。
ZIRからAIRへのマッピングはどこで維持されているのか? ZIRからAIRへのマッピングは、analyzeBodyInnerループによってinst_mapフィールドで維持されています。他にも更新され得る箇所がいくつかありますが、メインの場所は本体(body)のループです。
次に処理はanalyzeArithmeticへと繋がります。これは長い関数で、ほぼすべての行を、この算術演算をcomptime解析できるかどうかを判断するために費やしています。重要な部分を以下に再掲します。
const maybe_lhs_val = try sema.resolveMaybeUndefVal(block, lhs_src, casted_lhs);
const maybe_rhs_val = try sema.resolveMaybeUndefVal(block, rhs_src, casted_rhs);
if (maybe_lhs_val) |lhs_val| {
if (maybe_rhs_val) |rhs_val| {
if (is_int) {
return sema.addConstant(
scalar_type,
try lhs_val.intAdd(rhs_val, sema.arena),
);
}
}
}
return block.addBinOp(.add, casted_lhs, casted_rhs);まずresolveMaybeUndefValを呼び出します。これはAIR命令のインデックスを受け取り、そのcomptimeで確定した値を読み込もうとします。値はcomptimeで確定できない場合もあるため、これはOptionalを返し、その場合はnullになります。
次に、Optionalをアンラップしようとします。lhsとrhsの両方についてcomptimeで確定した値を見つけることができ、かつ両方が整数型であれば、comptime加算を行って最終的な定数を生成できます。今回のプログラムで、結果42を持つ.constantというAIR命令が生成されるのは、この処理によるものです。
%3 = constant(comptime_int, 42)値がcomptimeで確定しない場合のフォールスルーとしてblock.addBinOpも含めました。これは(comptimeではなく)ランタイム計算のために.addというAIR命令を追加します。試してみるには、定数の2をvar b: u32 = 2のような変数に変えて、その変数を加算に使ってみてください。変数はcomptimeで演算できないため、.add演算が生成されます。
%8: as_node(%4, %7)
次の命令は、安全な型強制(coercion)を実装します。ZIRを見ると、これは加算結果を戻り値の型に変換しています。より具体的に、これまでに分かっていることに即して言えば、comptimeのintである42をu32に変換しなければなりません。
.as_node命令はzirAsNodeを呼び出し、最終的にはcoerceの中核的なロジックへとつながります。coerce関数は、ある型から別の型への安全な型強制を行うためにSema全体で使われています。
この関数の詳細な学習は読者に委ねます。ロジック自体はかなり単純ですが、多くの型強制のケースを扱わなければならないため冗長になっています。comptime_intからu32への変換では、値がu32に収まることが判断され、値はそのまま返されます。型変換のために追加の処理は必要ありません。
%9: ret_node(%8)
最後に、return命令はZIRではret_nodeとしてエンコードされています。これはzirRetNodeにつながり、結果を伴う.retというAIR命令を作成します。この命令の作成過程には、これまでに触れていない新しい要素はありません。
zirRetNode関数はalways_noreturnを返します。この値によりanalyzeBodyInnerループは終了し、関数本体のAIR生成が完了します。
returnは常に関数本体のAIR生成を完了させるのか? 複数のreturn文がある場合は? returnは、常に現在のブロックに対するAIR生成を完了させます。到達不能なコードは不正でありAstGenの段階で検出されるため、複数のreturn文が存在する唯一の方法は、それらが異なるブロックにある場合です。したがって、関数の文脈では、複数のanalyzeBodyInner呼び出しへ再帰するため、複数のreturn文があっても問題ありません。
comptimeが利用できない場合
最初に見た例は、すべてのロジックがcomptimeで実行可能だったため、少し退屈でした。comptimeはconstの値に対してのみ利用可能で、varの値には利用できません。そのため、varを使うことでランタイム加算のAIRを見てみましょう。
export fn add() u32 {
var b: u32 = 2;
return 40 + b;
}# Begin Function AIR: add:
%2 = constant(comptime_int, 2)
%3 = constant(u32, 2)
%6 = constant(comptime_int, 40)
%8 = constant(u32, 40)
%0!= dbg_stmt(2:5)
%1 = alloc(*u32)
%4!= store(%1, %3!)
%5!= dbg_stmt(3:5)
%7 = load(u32, %1!)
%9 = add(%8!, %7!)
%10!= ret(%9!)
# End Function AIR: add定数の2を、変数に代入された値2に変えるだけで、はるかに多くのAIRが生成されます! .allocによる割り当て、.storeによる値の格納、そしてランタイムの.add演算も確認できます。
これは、AIRがどのように生成されるかを学ぶために追跡して調べるのに適したプログラムです。以前の例と多くのコードパスを共有しているため、これは読者への演習として残しておきます。
comptimeにおけるターゲットエミュレーション
comptimeで評価されるコードについて、Zigコンパイラは必要に応じてターゲットプラットフォームの特性をエミュレートします。これは、Zigにおいてcomptime評価を安全かつ可能にする極めて重要な機能です。
その一例は、@floatCastの実装で見ることができます。
const target = sema.mod.getTarget();
const src_bits = operand_ty.floatBits(target);
const dst_bits = dest_ty.floatBits(target);
if (dst_bits >= src_bits) {
return sema.coerce(block, dest_ty, operand, operand_src);
}
return block.addTyOp(.fptrunc, dest_ty, operand);この関数はgetTargetを介してターゲットに関する情報を取得し、次にターゲットプラットフォーム上でfloatがサポートできるビット数を判断します。変換先のビット数が変換元より多ければ、安全に型を強制できます。そうでなければ、floatは切り詰められます。
Semaプロセスの完了
Semaは関数ごとに一度呼び出されます。これは、ファイル単位ではなく関数単位で実行される最初のプロセスです。すべての宣言を順に処理するのではなく、Semaは参照されている宣言ごとに一度だけ呼び出されます。参照されているすべての宣言を特定するために、Semaはプロセスのエントリーポイント関数から開始します。
これはZigの「遅延解析(lazy analysis)」を実現しています。これは、宣言が参照されない限り、特定のエラーがコンパイラエラーとして報告されないことを意味します。これにより、参照されているコードのみがコンパイルされるため、コンパイルプロセスは非常に高速になり、結果として生成されるコードも小さくなりますが、時として混乱を招く挙動につながることもあります。
本ページで説明した基礎知識があれば、任意のZigプログラムを追って、それがどのようにAIRへ変換されるかを判断できるはずです。zig ast-checkやzig build-obj --verbose-airといったコマンドを頻繁に使って、Zigコンパイラが何を生成しているかを確認することを忘れないでください。
この後、AIRは「codegen」プロセスに引き渡され、最終的な形式へと下げられます。Codegenは、共通のコンパイラ「フロントエンド」と複数の「バックエンド」との境界です。バックエンドはLLVMである場合もあれば、WASMのようなネイティブバックエンドである場合もあります。
記事をランダムに読む
コメント
ログインしてコメントする