Zig Build System Internals

Mitchell Hashimoto

Zigビルドシステムの内部構造

Zigにはプロジェクトをビルドするためのビルドシステムが組み込まれています。Zigがサポートするすべてのプラットフォームで動作し、シンプルな実行ファイルやライブラリから、複雑で成果物や手順が多数あるプロジェクトまでビルドできます。このページでは、Zigビルドシステムの内部がどのように動いているかを詳しく見ていきます。

ビルドシステムは、あらゆるソフトウェアプロジェクトにおいて極めて重要な要素です。うまく動いているときは魔法のように感じられます。コマンドを一つ実行すれば、複雑な一連の手順を経て、動作するバイナリ(あるいは他の成果物)が生成されるのです。一方で、うまく動かないときは、不透明でわかりにくい壁のように感じられ、なければいいのにと思ってしまいます。強力なツールにはよくあることです。その日や作業内容によって、魔法にもなれば頭痛の種にもなるのです。

ビルドシステムの内部を理解することは、その“魔法”を取り除き、日々の作業をより楽にするための方法です。このページでは一歩踏み込んで、Zigユーザーが日々の生産性を高められるよう、zig buildがどのように動いているかを解説します。

注記:本ページはZigビルドシステム自体の入門ではありません。ビルドシステムが内部でどのように動いているかという内部構造の入門です。ビルドシステムの入門については、公式ドキュメントZigビルドシステムについての優れたブログシリーズ、あるいは既存のC/C++プロジェクトがビルドシステムを活用する方法についてのブログ記事をご覧ください。

ハイレベルな動作

詳細に入る前に、Zigビルドシステムを俯瞰して説明します。

Zigビルドシステムは、設定にbuild.zigファイルを、実行にzig build CLIを使用します。build.zigファイルの中では、ビルド関数(fn build() voidのようなもの)を実装する必要があります。ビルド関数は、ステップや依存関係、成果物などを定義するためのビルダーAPI(std.build.Builder)にアクセスできます。ビルドシステムが何をどの順序で実行するかを判断するために使われる、概ね宣言的なAPIです。

以前にZigプロジェクトでzig buildを使ったことがあれば、時々2回コンパイルされているように見えることに気づいたかもしれません。意味解析やコード生成の出力が2回表示されるのです。これは、キャッシュされていないプロジェクトでは実際に2回コンパイルされているためです。まずビルド用バイナリをコンパイルし、次にそのビルド用バイナリがプロジェクトのコードをコンパイルするのです。

先ほど触れたとおり、zig build CLIはまずbuild.zigファイルを現在のシステム向けのビルド用バイナリにコンパイルし、次にそのバイナリをサブプロセスとして実行して実際のビルドを行います。ビルド用バイナリはZigのキャッシュシステムの恩恵を受けるため、build.zig(やインポートしているファイル)が変更されない限り、次回以降のzig buildコマンドでは再コンパイルのコストはかかりません。

ビルド用バイナリが実行されると、プロジェクトをビルドするために必要なステップが決定され、実行されます。ビルド用バイナリ自体にZigコンパイラは組み込まれていません。必要に応じてZigコンパイラへさらにサブプロセスで処理を委譲し、個々の成果物をビルドします。

ハイレベルな手順を図にすると、次のようになります。

                                   ┌────────────────────┐
                                   │                    │
                                   │     zig build      │
                                   │                    │
                                   └────────────────────┘
                                              │
        ┌────────────────────┐                │
        │                    │                ▼
        │     build.zig      │──┐  ┌────────────────────┐
        │                    │  │  │                    │
        └────────────────────┘  ├─▶│    build binary    │
        ┌────────────────────┐  │  │                    │
        │  lib/std/special/  │  │  └────────────────────┘
        │  build_runner.zig  │──┘             │
        │                    │                │
        └────────────────────┘                ▼
        ┌────────────────────┐     ┌────────────────────┐┌───────┐
        │                    │     │                    ││       │
        │    source files    │────▶│   zig build-exe    ││  ...  │
        │                    │     │                    ││       │
        └────────────────────┘     └────────────────────┘└───────┘
                                              │
                                              │
                                              ▼
                                   ┌────────────────────┐
                                   │    artifact(s)     │
                                   │ (exe, libs, etc.)  │
                                   │                    │
                                   └────────────────────┘

ビルダーをビルドする

zig buildの処理が行うことは、(1)ビルド用バイナリをビルドすること、(2)ビルド用バイナリを子プロセスとして実行することの2つだけです。最初のステップであるビルド用バイナリのビルドでは、build.zigファイルを入力として使いますが、この時点ではまだbuild関数は実行されません。

zig buildのソースはsrc/main.zigにあり、cmdBuild関数に実装されています。さほど複雑な関数ではないので、一度目を通すことをおすすめします。最初にビルド用バイナリをビルドするブロックがあり、その後にそのバイナリをサブプロセスとして実行するロジックが続きます。

ビルド用バイナリは、ビルドのメインエントリーポイントとしてlib/std/special/build_runner.zigを使用します。このファイルを見るとpub fn mainがあります。これが実際のビルド用バイナリのメインエントリーポイントです。

エントリーポイントのファイルは@buildをインポートします。これはcmdBuildがあなたのbuild.zigファイルを指すように定義する特別なパッケージです。ビルドランナーが最終的にあなたのbuild.zigファイル内のbuild関数を実行する仕組みは、これによるものです。

最終的なビルド用バイナリはどこにもインストールされません。Zigのキャッシュディレクトリ(通常はbuild.zigファイルからの相対パスでzig-cache)に保存されます。次のように探せば、ビルド用バイナリが存在することを確認できます。

$ find ./zig-cache -type f -name 'build'
./zig-cache/o/c4c75a71df444bff10945728759e174c/build

「build_runner.zig」を見つける

zig buildは、システム上のlib/std/special/build_runner.zigがどこにあるかをどうやって知るのでしょうか。

src/introspect.zigというファイルがあり、Zigのインストール場所を見つけるために使われるAPIが含まれています。現在の実行ファイルのあるディレクトリから開始して、lib/zig/std/std.zigまたはlib/std/std.zigが見つかるまで上位のディレクトリへ遡って探索します。zig buildを実行すると、zigバイナリのあるディレクトリからこの探索が始まります。

注記:これはプロジェクトで@import("std")が解決される仕組みでもあります。具体的には、stdパッケージはビルド処理中にzig CLIのintrospection APIを使ってstd.zigを指すように事前に定義されます。

stdlibを探す関数の実装を以下に示します。このディレクトリが分かれば、標準的なZigインストール内のあらゆるファイルを取得できます。

fn testZigInstallPrefix(base_dir: fs.Dir) ?Compilation.Directory {
    const test_index_file = "std" ++ fs.path.sep_str ++ "std.zig";

    zig_dir: {
        // Try lib/zig/std/std.zig
        const lib_zig = "lib" ++ fs.path.sep_str ++ "zig";
        var test_zig_dir = base_dir.openDir(lib_zig, .{}) catch break :zig_dir;
        const file = test_zig_dir.openFile(test_index_file, .{}) catch {
            test_zig_dir.close();
            break :zig_dir;
        };
        file.close();
        return Compilation.Directory{ .handle = test_zig_dir, .path = lib_zig };
    }

    // Try lib/std/std.zig
    var test_zig_dir = base_dir.openDir("lib", .{}) catch return null;
    const file = test_zig_dir.openFile(test_index_file, .{}) catch {
        test_zig_dir.close();
        return null;
    };
    file.close();
    return Compilation.Directory{ .handle = test_zig_dir, .path = "lib" };
}

手動でビルド用バイナリをビルドする

さらに魔法を取り除くために、ビルド用バイナリを手動でビルドしてみましょう。このページの後半ではこのバイナリを手動で実行しますが、まずはビルドだけを行います。

例として、Zigコンパイラ自体のbuild.zigファイルをビルドします。Zigのソースコードをクローンし、zigをインストールしてください(できればチェックアウトしたソースからコンパイルしたバージョンが理想ですが、最近のバージョンであればどれでも構いません)。次に、チェックアウトしたディレクトリからビルド用バイナリをビルドします。

$ zig build-exe \
    --pkg-begin '@build' build.zig \
    --pkg-end \
    -femit-bin=custom-builder \
    lib/std/special/build_runner.zig

これだけです!コマンドを実行すると、custom-builderという実行ファイルが作成されるはずです。これはzig buildが呼ばれたときに作成される実行ファイルと同一のものです。

このコマンドライン呼び出しを見ると、メインパッケージが標準ライブラリのbuild_runner.zigファイルであり、build.zigファイルが@buildパッケージとして公開されていることがとても明確になります。

これでzig buildが裏側で行っていることの半分が完了です。

ビルド用バイナリを実行する

ビルド用バイナリがビルドされた後、zig buildは子プロセスを生成して直ちにそれを実行します。

このバイナリは、次の順序で4つの位置引数を期待します。

  1. Zigコンパイラへのパス
  2. build.zigがあるビルドのルートへのパス。ただし重要なのは、もはやbuild.zigファイル自体へのアクセスは必要ないということです。これはバイナリにすでにコンパイルされたbuild.zigのコードから、ビルド相対のパスを解決するためだけに使われます。
  3. ローカルキャッシュディレクトリへのパス(存在しなくてもかまいません)。
  4. グローバルキャッシュディレクトリへのパス(存在しなくてもかまいません)。

zig buildの実装ではこれらの引数が自動的に設定され、さらに追加の引数があればそれらをビルド用バイナリへ転送して実行します。このバイナリが実際のプロジェクトのビルドを行います。

補足ですが、ビルド用バイナリのmainエントリーポイント関数はlib/std/special/build_runner.zigで定義されています。複雑ではないので、ファイルに目を通すことをおすすめします。実際のビルダーAPI(std.build.Builder)の方はより複雑なので、まずはbuild_runner.zigファイルを読んで大まかな制御フローを理解することから始めるのがよいでしょう。

手動でビルド用バイナリを呼び出す

前のセクションでcustom-builderバイナリを作成した場合、ここでそれを手動で呼び出してZigコンパイラのフルビルドを実行できます。

念のためですが、実際にはこれを手動で行う必要は決してありません。zig buildが自動でやってくれるからです。ここではzig buildが内部でどのように動いているかを示すために、あえてビルド用バイナリを手動で実行しているだけです。

$ ./custom-builder $(which zig) . ./cache ./global-cache

例としてZigコンパイラを使いましたが、この同じパターンはZigビルドシステムを使うあらゆるプロジェクトで同様に機能します。

サポートされている引数とフラグ

ビルド用バイナリは、zig buildがサポートするほぼすべてのフラグをサポートしています。実際、zig buildは、ビルド用バイナリのビルド処理自体を直接制御する少数のフラグを除き、すべての引数をそのまま子プロセスであるビルド用バイナリにコピーして渡します。

この動きは、ビルド用バイナリを--helpフラグ付きで実行することで確認できます(4つの必須の位置引数とともに)。前のセクションでビルド用バイナリを手動でビルドした場合は、今すぐ試してみることができます。

$ ./custom-builder $(which zig) . ./cache ./global-cache --help

4つの位置引数は、zigがPATH上にあり、現在の作業ディレクトリがbuild.zigファイルのあるビルドのルートであることを前提としています。

ヘルプの表示はzig build --helpとほぼ同じに見えるはずです。なぜなら実際に同じものだからです。zig build --helpはまずビルド用バイナリをビルドし、その後--helpフラグを子プロセスへ転送します。出力は完全に一致するはずです。

ビルド関数を呼び出す

私が特に面白いと感じたのは、ビルドランナーがbuild.zigファイル内のbuild関数をどのように呼び出しているかという点です。ビルドランナーは、複数の関数シグネチャをサポートするために、Zigのcomptime機能を使ってbuild関数のシグネチャを内省しています。

build_runner.zigrunBuild関数を以下に再掲して、その動きを示します。

fn runBuild(builder: *Builder) anyerror!void {
    switch (@typeInfo(@typeInfo(@TypeOf(root.build)).Fn.return_type.?)) {
        .Void => root.build(builder),
        .ErrorUnion => try root.build(builder),
        else => @compileError("expected return type of build to be 'void' or '!void'"),
    }
}

これにより、build関数のシグネチャは次の両方を取ることができます。

  • fn build(*std.build.Builder) void
  • fn build(*std.build.Builder) !void!voidに注目してください)

より正確に言えば、エラー共用体(ErrorUnion)のケースは、あらゆるエラー共用体を取り得ます。上記のリストのような推論されるエラー共用体でもよいですし、明示的に定義されたエラー共用体でもかまいません。

ビルドシステムの仕組みを理解するという観点では、これはそれほど重要ではない詳細ですが、ツールの実装を調べることで発見できる面白い内部構造の一例です。また、comptimeのクールなユースケースを知ることもできました。

ビルドステップ

ここまでで、zig buildbuild.zigファイルを使って専用のビルド用バイナリを作成し、そのビルド用バイナリがプロジェクトをビルドするために実行される仕組みを見てきました。では、ビルド用バイナリは実際に何をしていて、それはbuild.zigファイルとどのように関係しているのでしょうか。

ビルド用バイナリは、build.zigファイルでユーザーが定義したbuild関数を呼び出します。このビルド関数にはstd.build.Builderへのポインタが引数として渡され、ビルドで利用可能なフラグやターゲット、ターゲットの依存関係などを宣言的に定義するために使われます。最後に、ビルドランナー(build_runner.zig)が、指定されたターゲットに対して依存順にステップを実行します。

ステップを定義する

Builder引数は多くの機能を持っていますが、その中核となる目的は一連のステップを構築することです。

「トップレベルステップ」は、名前で呼び出し可能なステップ、すなわちzig build <name>で実行できるステップに対して使われる特別な区分です。あらかじめ定義されたトップレベルステップは「install」と「uninstall」の2つです。追加のトップレベルステップはstep関数で作成できます。呼び出し可能な名前が割り当てられている点を除けば、トップレベルステップは他のあらゆるステップと機能的には同等です。Builderはトップレベルステップの集合をArrayListで保持しています。

すべてのステップは、Stepに対してdependOn関数を呼び出すことで、0個以上の依存関係を持つことができます。これにより、依存関係はシンプルなArrayListで保持されます。

トップレベルステップを呼び出す

1つ以上のトップレベルステップは、Buildermake関数を呼び出すことで実行されます。これは内部で単一のトップレベルステップを実行するmakeOneStepを呼び出します。makeOneStepは非常にシンプルで、その実装全体を以下に示します。

fn makeOneStep(self: *Builder, s: *Step) anyerror!void {
    if (s.loop_flag) {
        warn("Dependency loop detected:\n  {s}\n", .{s.name});
        return error.DependencyLoopDetected;
    }
    s.loop_flag = true;

    for (s.dependencies.items) |dep| {
        self.makeOneStep(dep) catch |err| {
            if (err == error.DependencyLoopDetected) {
                warn("  {s}\n", .{s.name});
            }
            return err;
        };
    }

    s.loop_flag = false;

    try s.make();
}

makeOneStepは、追加された順に依存関係を1つずつたどり、makeOneStepを再帰的に呼び出します。各ステップのloop_flagは、(より凝ったグラフ構造を構築する代わりに)循環を検出するために使われます。最後に、ステップ自体がmake経由で実行されます。

ステップの構造

Step構造体は、状態を伴った比較的シンプルなインターフェースのような構造体です。ステップ固有のロジックはmakeFn関数ポインタにカプセル化され、残りのフィールドは共有の状態です。

pub const Step = struct {
    id: Id,
    name: []const u8,
    makeFn: fn (self: *Step) anyerror!void,
    dependencies: ArrayList(*Step),
    loop_flag: bool,
    done_flag: bool,

    // ...
};

nameは、トップレベルステップである場合を除き、デバッグ目的でのみ使われます。loop_flagdependenciesについてはすでに説明しました。done_flagは、ステップが正確に1回だけ実行されるようにするためのものです。2回目以降の実行は何もしません。

この知識があれば、カスタムステップをどのように作成すればよいかが大まかに見えてくるはずです。組み込みのステップは、ほとんどのプロジェクトをビルドするために必要なすべての機能を提供します。

宣言的 vs. 命令的

build関数はステップの集合とそれらの依存関係を定義しますが、それらを実行はしません。一つの言い方をすれば、build.zigファイルはビルドステップを宣言的に定義しているのです。これは、Zigを含むビルドシステム全般で人々が陥りがちな落とし穴を避けるために理解しておくべき重要な概念です。

ステップが宣言的に定義されているため、ロジックが実際にいつ、どこで実行されるかを意識する必要があります。あるステップが、前のステップで生成されたファイルを読み取るような場合、それはmakeFnを持つカスタムステップを使って行わなければなりません。ステップを作成した直後にbuild関数内でファイルを読み取ることはできません。なぜなら、その時点ではまだ実行されていないからです。

一方で、ビルドステップが実行される前にすでに存在している一連のファイルに対して、プログラムで一連のステップを生成しようとしているのであれば、それはbuild関数内で直接行うことができますし、そうすべきです。ビルド実行時にステップを動的に定義することはできません。

上級者向け補足:実行時に既存のステップグラフへ向けてステップを動的に定義することはできません。ただし、他のステップを動的に作成して直接実行するカスタムステップを作ることは可能です。

コンパイルステップ

build.zigがステップをどのように定義し、それらのステップがどのように構成され、呼び出されるかが理解できました。ZigのBuilder構造体には、実行ファイルやオブジェクトなどをビルドするための高レベルなヘルパーが用意されています。その仕組みをもう少し深く見てみましょう。

コンパイル関連のすべての機能は、LibExeObjStepとして単一の実装を共有しています。実行ファイル、ライブラリ、あるいはその他のオブジェクトタイプのいずれがビルドされるかは、このステップ構造体のフィールド値によって決まります。この詳細は通常、addExecutableaddSharedLibraryといったヘルパーの背後に隠蔽されています。

LibExeObjStepの実装はlib/std/build.zigにあります。このステップは豊富な機能を持ち、ビルドプロセスにおいて非常に重要な部分なので、コード量は比較的多いですが、全体に目を通すことを強くおすすめします。

このステップの実装は、zigコンパイラへサブプロセスとして処理を戻すことで動作します。makeの実装では、ステップに設定された内容をもとにコマンドライン引数の一式を組み立て、zig build-exezig build-objなど、何らかのZigコマンドを呼び出します。Zig CLIへのパスがビルド用バイナリにとって最初に必須の位置引数であることは、この用途が主な理由です。

重要なのは、これはビルド用バイナリが完全なZigコンパイラを内包していないことを意味するという点です。さらに、望むならビルド用バイナリが異なるバージョンのZigを指すようにすることも可能です!

まとめ

Zigビルドシステムの内部を学んで得た一番の収穫は、それが“ただのZig”であるということです。build.zigファイルの中では、現在のシステム向けの完全な実行ファイルにコンパイルされるため、何でもすることができます。Zigは、筋の通った構造と一連の組み込みステップを提供してくれますが、プロジェクトをビルドするためにZigの持つすべての力を利用できるのです。

私は、普段使っているツールの下にあるレイヤーを理解することが、より優れたツール使いになるための近道だと心から信じています。それは神秘のベールを取り払い、機械の内部構造を露わにしてくれます。そして私は、内部構造はいつも想像していたよりシンプルだと感じています。次にビルドシステムで何かができるかどうか迷ったり、なぜ思い通りに動かないのか疑問に思ったりしたとき、このより深い知識が答えにより早くたどり着く助けになれば幸いです。

原文は Mitchell Hashimoto により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。