意味解析と InternPool:コンパイラの心臓部
前提知識
- ›第 1 回:Zig コンパイラのアーキテクチャ
- ›第 2 回:ソースコードから ZIR へ
- ›型システムと型推論の基本的な理解
- ›Zig の comptime 評価に関する知識
意味解析と InternPool:コンパイラの心臓部
Sema(semantic analysis の略)は、ZIR が本当の意味を持つフェーズです。型の解決、comptime 式の評価、型チェック、そして AIR(Analyzed IR)の生成がここで行われます。約 37,000 行に及ぶ src/Sema.zig はコンパイラ全体で最大のファイルであり、冒頭のコメントにはこう書かれています。「これは Zig コンパイラの心臓部である」
ただし、Sema は単独では動きません。InternPool という、すべての型と値を u32 インデックスとして一元管理するデータストアに依存しています。型と値を単一のインターニングプールに統合するこの設計は、Zig コンパイラのアーキテクチャにおける最も特徴的な選択のひとつです。
Sema の役割とコアな状態
Sema は、型のない ZIR を型付きの AIR へと変換します。関数ごと(あるいは comptime ブロックごと)にインスタンス化され、スタック上に存在します。src/Sema.zig#L41-L77 から、主なフィールドを見てみましょう。
pt: Zcu.PerThread, // thread-safe Zcu access
gpa: Allocator, // general purpose allocator
arena: Allocator, // temporary arena, cleared on Sema destroy
code: Zir, // input ZIR
air_instructions: std.MultiArrayList(Air.Inst) = .{}, // output AIR
air_extra: std.ArrayList(u32) = .empty,
inst_map: InstMap = .{}, // maps ZIR indices → AIR indices
owner: AnalUnit, // the root entity being analyzed
func_index: InternPool.Index,// current function being analyzed
fn_ret_ty: Type, // return type of current function
branch_quota: u32 = default_branch_quota,
branch_count: u32 = 0,
classDiagram
class Sema {
+pt: PerThread
+code: Zir
+air_instructions: MultiArrayList
+inst_map: InstMap
+owner: AnalUnit
+func_index: Index
+fn_ret_ty: Type
+branch_quota: u32
+analyzeBodyInner()
}
class Zir {
+instructions: Slice
+extra: []u32
+string_bytes: []u8
}
class Air {
+instructions: Slice
+extra: ArrayList
}
Sema --> Zir : reads
Sema --> Air : writes
inst_map は特に重要なフィールドです。ZIR の命令インデックスを、対応する AIR の命令参照へとマッピングします。ZIR 命令が別の命令を参照している場合(例:%5 が %3 の結果を参照する)、Sema はこの inst_map を使って AIR 側の対応を探します。
owner: AnalUnit は、何を解析しているかを示します。インライン関数呼び出しによって別の関数のボディが解析されることになっても、Sema のライフタイム中に owner が変わることはありません。
analyzeBodyInner ディスパッチループ
Sema の核となるのが analyzeBodyInner です。src/Sema.zig#L1154 から始まるメインのディスパッチループで、ZIR 命令を順に処理し、それぞれのハンドラに振り分けます。
flowchart TD
LOOP["analyzeBodyInner loop"] --> READ["Read ZIR instruction tag"]
READ --> SWITCH["inst: switch(tags[inst])"]
SWITCH -->|".alloc"| A["zirAlloc()"]
SWITCH -->|".bit_and"| B["zirBitwise()"]
SWITCH -->|".bitcast"| C["zirBitcast()"]
SWITCH -->|".call"| D["zirCall()"]
SWITCH -->|".cmp_lt"| E["zirCmp()"]
SWITCH -->|"..."| F["200+ other handlers"]
A --> AIR["Produce AIR instruction"]
B --> AIR
C --> AIR
D --> AIR
E --> AIR
AIR --> NEXT["i += 1; continue loop"]
このディスパッチには Zig のラベル付き switch パターン(inst: switch (tags[...]))が使われており、一部のハンドラはインデックスを進めることなく continue :inst で再ディスパッチできます。これは、1 つの ZIR 命令に複数のパスが必要になる制御フロー変換で活用されています。
各ハンドラ関数は一貫したパターンに従っています。
- ZIR 命令からオペランドを取り出す(多くの場合
extraDataを使用) - オペランドの型を解決する(
inst_mapのエントリを参照) - 型チェックと型強制を行う
- comptime で評価可能であれば、コンパイル時に結果を計算する
- そうでなければ、AIR 命令を出力する
ハンドラ名には命名規則があります。zirAlloc、zirBitwise、zirCall のように、すべて zir プレフィックスに続いて対応する ZIR 命令タグ名を付けた形になっています。fn zir で grep すると、200 を超えるハンドラ関数が見つかります。
ヒント: 意味解析の問題をデバッグする際は、まず ZIR 命令タグを特定しましょう(
zig dump-zirで ZIR をダンプできます)。そのタグに対応するzir*ハンドラ関数を Sema.zig から探すのが最短ルートです。
InternPool:型と値の統合ストア
src/InternPool.zig の InternPool は、コンパイラの中で最も重要なデータ構造のひとつです。冒頭のコメントはシンプルです。「インターンされたオブジェクトはすべて値と型を持つ。このデータ構造は自己完結している。」
設計上の核心的な決断は、型と値を同じものとして扱うことにあります。どちらも InternPool への u32 インデックスです。型 u32 もインデックス。値 42 もインデックス。型 *const u8 もインデックス。この統一によってコンパイラ全体がシンプルになります。ルックアップの仕組みもインターニングの仕組みも、等値比較もすべてひとつ——インデックスを比較するだけでよいのです。
このプールは並行アクセスのためにシャーディングされています。
locals: []Local, // one per thread, indexed by tid
shards: []Shard, // power-of-two count for concurrent writers
各 Local は独自のアロケーションアリーナを持ち、Shard 配列はロックを使って複数スレッドによる同時インターニングを可能にします。tid_shift_30、tid_shift_31、tid_shift_32 フィールドはビットシフト量をキャッシュしており、インデックスの上位ビットにスレッド ID を埋め込むことで、スレッド間の調整なしにグローバルでユニークなインデックスを生成できます。
事前インターンされた型
Index 列挙型の先頭には、ルックアップ不要の共通型があらかじめインターンされています。
pub const Index = enum(u32) {
u0_type, i0_type, u1_type,
u8_type, i8_type, u16_type, i16_type,
u32_type, i32_type, u64_type, i64_type,
// ... many more ...
bool_type, void_type, type_type,
anyerror_type, comptime_int_type, noreturn_type,
// ...
};
これらはコンパイル時に既知であり、列挙型の値として直接埋め込まれています。ある型が bool かどうかの確認は、index == .bool_type という単純な整数比較で済みます。ハッシュルックアップも間接参照も必要ありません。
型と値:InternPool.Index の薄いラッパー
ergonomic な API を提供するために、コンパイラは InternPool.Index を 2 つのニュータイプ構造体でラップしています。
ip_index: InternPool.Index,
pub fn zigTypeTag(ty: Type, zcu: *const Zcu) std.builtin.TypeId {
return zcu.intern_pool.zigTypeTag(ty.toIntern());
}
ip_index: InternPool.Index,
どちらもサイズはちょうど u32 1 つ分です。プールからデータをコピーすることは決してなく、InternPool 経由でプロパティを参照するメソッドを提供するだけです。これはパフォーマンス上きわめて重要な設計です。Type を受け渡すのは整数 1 つを渡すことと同じであり、2 つの型が等しいかどうかはインデックスの比較だけで判断できます。
classDiagram
class InternPool {
+locals: []Local
+shards: []Shard
+Index: enum(u32)
+zigTypeTag(Index) TypeId
+typeOf(Index) Index
+indexToKey(Index) Key
}
class Type {
+ip_index: Index
+zigTypeTag(Zcu) TypeId
+abiSize(Zcu) u64
}
class Value {
+ip_index: Index
+typeOf(Zcu) Type
+isUndef(Zcu) bool
}
Type --> InternPool : wraps Index
Value --> InternPool : wraps Index
ヒント: Sema のコードを読んでいると
.toIntern()や.fromInterned()が頻繁に登場します。これらはラッパー型と生のInternPool.Index値を相互変換するためのメソッドです。
Nav と AnalUnit:コンパイルの粒度
コンパイラにおける作業の粒度を定義する概念が 2 つあります。Nav(Named Addressable Value)と AnalUnit(Analysis Unit)です。
AnalUnit は Kind と id を組み合わせたパック構造体(u64)です。
pub const AnalUnit = packed struct(u64) {
kind: Kind,
id: u32,
pub const Kind = enum(u32) {
@"comptime", nav_val, nav_ty, type, func, memoized_state,
};
};
各 AnalUnit は意味解析の 1 作業単位を表します。関数ボディの解析、comptime ブロックの評価、型の解決、Nav の値解決——それぞれが独立した AnalUnit です。これらは差分コンパイル(第 5 回で取り上げます)を支える依存グラフのノードになります。
Nav は名前付き宣言を表し、3 段階のライフサイクルを持ちます。
stateDiagram-v2
[*] --> unresolved: Declaration discovered
unresolved --> type_resolved: Type analysis complete
type_resolved --> fully_resolved: Value analysis complete
fully_resolved --> [*]: Sent to linker
Nav 構造体には、名前、完全修飾名、オプションの解析情報(名前空間と ZIR インデックス)、そして unresolved → type_resolved → fully_resolved と遷移する status ユニオンが格納されています。リンカに送られるのは、ランタイム型を持ち fully_resolved 状態になった Nav だけです。
この 2 フェーズの解決(先に型、次に値)は重要な設計です。型解決をすべて完了させてからコード生成を始めることができるため、型と値の解決を混在させた場合に生じる循環依存を回避できます。
AIR:型付き中間表現
AIR は Sema の出力です。src/Air.zig で定義されており、ZIR と同じフラットな構造(MultiArrayList の instructions と extra 配列)を持ちますが、いくつかの重要な違いがあります。
- すべての参照に型が付く。 ZIR が「これとこれを足す」と言うのに対し、AIR は「この 2 つの
i32値を加算してi32を返す」と明示します。 - AIR は関数単位。 ZIR がファイル全体をカバーするのに対し、AIR は単一関数にスコープされます。
- comptime が解決済み。
if (comptime_condition)の分岐は評価され、デッドブランチは除去されます。 - ジェネリックのインスタンス化が完了している。 具体的なインスタンスごとに独自の AIR が生成されます。
38 行目 の AIR 命令タグは、add、add_safe、add_optimized、add_wrap、add_sat のように明示的に型付けされた操作として定義されています。加算だけで 5 種類の命令があり、それぞれに正確なセマンティクスが定められています。これは、より少なく汎用的な命令を持つ ZIR とは対照的です。
flowchart LR
subgraph "ZIR (untyped)"
Z1[".add %3, %5"]
end
subgraph "Sema"
S["Type check\nCoerce operands\nChoose AIR tag"]
end
subgraph "AIR (typed)"
A1[".add_safe %3:i32, %5:i32 → i32"]
end
Z1 --> S --> A1
AIR にはさらに、Air/Liveness.zig で別途計算される生存期間情報も含まれており、各命令時点でどの値がまだ生きているかをコード生成器に伝えます。これにより、別途生存期間解析パスを設けることなくレジスタ割り当てが可能になります。
次回予告
ここまでで、ZIR から AIR への変換——コンパイラの核心部分——を見てきました。第 4 回では、AIR をバックエンドへと追っていきます。コード生成(AIR → MIR → マシンコード)とリンクの仕組みを取り上げ、2 フェーズのコード生成アプローチ、バックエンド固有の表現をつなぐ AnyMir ユニオン、そして最終バイナリを組み立てるセルフホスト型リンカを詳しく解説します。