Zig Build System Internals

Mitchell Hashimoto

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

原文は Mitchell Hashimoto により に公開されました。 このブログを購読する

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 buildでZigプロジェクトをビルドしたことがあれば、時に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")が解決される仕組みでもあります。具体的には、ビルドプロセス中にzig CLIの introspection APIを使って、stdパッケージが事前にstd.zigを指すように定義されます。

標準ライブラリを探す関数の実装を以下に示します。このディレクトリから、標準的な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に注目)

より具体的に言うと、エラー合併型のケース(2つ目のケース)は任意のエラー合併型にできます。上記リストのような推論されるエラー合併型でも、明示的に定義されたエラー合併型でも構いません。

ビルドシステムの仕組みを理解するという点では、これはそれほど重要な詳細ではありませんが、ツールの実装を調べることで発見できる、ちょっとクールな内部動作の一例です。また、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で保持しています。

すべてのステップは、StepdependOn関数を呼び出すことで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();
}

ステップの構造

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回だけ実行されるようになります。以降の実行は何も行わない noop となります。

この知識があれば、カスタムステップをどのように作成できるかの方向性はおおむね明らかになるはずです。組み込みのステップは、ほとんどのプロジェクトのビルドに必要なすべての機能を提供します。

宣言的 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の全能力を利用できるのです。

私は、日々使っているツールの下にあるレイヤーを理解することで、より優れたツール使いになれると心から信じています。それは機械の内部構造を露わにすることで謎を取り除き、私の経験では、内部構造はいつも想像していたよりシンプルです。次にビルドシステムで何かができるかどうか、あるいはなぜビルドシステムが思い通りに動かないのかと悩んだとき、このより深い知識が少しでも早く答えにたどり着く助けになることを願っています。

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

コメント