Conditionally Disabling Code with Comptime in Zig

Mitchell Hashimoto

Zigのcomptimeでコードを条件付きで無効化する

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

これはZigのcomptime活用事例に関する連載の一部です。

Zigにはcomptimeと呼ばれる非常に強力な機能があります。comptimeを使えば、Zigのコードをコンパイル時に実行できます。これは特別なマクロ言語やAST操作ではありません。コンパイル時に実行される、ただの標準的なZigコードです。唯一の実質的な制限は、comptimeのコードは副作用を持てないこと(システムコール不可、IO不可など)です。

正直に言うと、Zigを見始めた当初は、これをギミックだと思っていました。「どうせcomptimeで実際にやりたいことなんて、たかが知れているだろう?」と。 本格的なプロジェクトでZigを使い始めて2年が経った今、comptimeは至るところで使われており、Zigの中で断トツにお気に入りの機能です。

comptime自体は大きなトピックです。ですから、comptimeのすべてを深掘りするのではなく、特に有用なパターンを1つ紹介したいと思います。それは、comptimeを使ってコードを条件付きで無効化するというものです。


なぜコードを条件付きで無効化するのか?

コードを条件付きで無効化するのは、ソフトウェア開発でよくあるパターンです。コードを条件付きで無効化したくなる、ごく一般的な理由をいくつか挙げます。

  1. プラットフォーム固有のコード:たとえば、macOSとLinuxで実装が異なる関数を持っている場合です。

  2. デバッグ用コード:デバッグ時にのみ役立つコードがあり、本番ビルドでは無効にしたい場合です。

  3. ビルド設定:ビルド時の設定に基づいて省略したり、挙動を変えたりしたい機能がある場合です。

PythonやJavaScriptのような動的言語では、通常は実行時に単純なif文を使って適切なコードパスを選択できます。動的言語はコードを実行時にのみ評価するため、うまく動かない可能性のあるコードパスに触れずに済みます。

しかしコンパイル言語では、コンパイラが実行時に通りうるすべてのコードパスをコンパイルしリンクしなければならないため、if文を使ってコードを条件付きで無効化することはできません。

コンパイルを通すという点以外にも、コードをコンパイル時に省略することには、バイナリを小さくできる、パフォーマンスコストになりうる実行時の条件分岐を避けられるといった利点もあります。


Zig以外のアプローチ

他のコンパイル言語がこの問題にどうアプローチしているかを見てみましょう。この部分に興味がなければ、次のセクションまで飛ばして、Zigのcomptimeでどう解決できるかをご覧ください。

C系言語

C系言語では、コードを条件付きで無効化するためにプリプロセッサが使われるのをよく見かけます。例を示します。

#define FLAG

void my_function() {
    #ifdef FLAG
    // This code will only be included if FLAG is defined.
    #else
    // This code will only be included otherwise.
    #endif
}

これは実質的に、コンパイル時にコードをテンプレート化する手法です。まずプリプロセッサが実行され、コードを(テキストレベルで)変換し、その後にコンパイラがコードをコンパイルします。1つ目の大きな欠点は、プリプロセッサが独自の構文とルールを持つ別の言語であることです。

2つ目の大きな欠点は、プリプロセッサが非常に限定的であることです。Cプログラマは通常、適切なプリプロセッサ定義を生成するために高機能なビルドシステムに頼ります。ビルドシステムはたいてい、これらの定義を生成するために独自の言語を使います。

Go

Goでは、コンパイル時のコード省略はビルドタグを使ってファイル単位で行われます。例を示します。

// +build mytag

func myFunction() {
    // This code will only be included if the file is built with the "mytag"
    // build tag.
}

プラットフォーム固有のコードについては、ファイル名のサフィックスを使うこともできます。これはGoのチュートリアルではないので詳細には触れませんが、要点は条件付きコンパイルがファイルレベルで行われるということです。

このアプローチについてはさまざまな主観的な意見があります。客観的に良くないと思う点は、タグを含めるかどうかを選択するのに依然として何らかのプリプロセッサ言語を使わなければならず、それが通常ビルドシステムやMakefileなどに委ねられることです。


Zigのcomptime

Zigのcomptimeを使えば、Zigを使ってコードを条件付きで無効化できます。プラットフォーム固有のコードの最もシンプルな例を示します。

const builtin = @import("builtin");

fn myFunction() void {
    if (comptime builtin.os.tag == .macos) {
        // This code will only be included if the target OS is macOS.
        return;
    }

    // This code will be included for all other operating systems.
}

まず注目すべきは、このコードが標準的なZigであることです。プリプロセッサ言語や特別な構文はありません。次に、Zigコンパイラは十分に賢く、if文が脱出条件で終わっているため、条件が自明に真であればそれ以降をコンパイルする必要がないことを理解することに気づいてください。この場合、ターゲットOSがmacOSであれば、コンパイラは最初のブロックだけをコンパイルします。

comptimeキーワードについての補足: 上記のcomptimeキーワードは不要です。利用可能な情報がすべてコンパイル時に分かっている場合、Zigは自動的に条件をコンパイル時に評価します。builtinはすべてコンパイル時定数なので、comptimeキーワードは冗長です。例をより明示的にするためにあえて付けました。

私が実際に取り組んでいるプロジェクトからの、より複雑な例を示します。

if ((comptime adwaita.versionAtLeast(1, 4, 0)) and
    adwaita.enabled(&config) and
    adwaita.versionAtLeast(1, 4, 0)) {
    // This code will only be included if we're building with
    // libadwaita available and the version is at least 1.4.0
    // at both build and runtime.
}

これは、comptimeが標準のZigであることの強力さを示しています。この場合、comptimeの条件とそうでない条件を混在させています。最初の条件はcomptimeで、残りは実行時の条件です。

最初のcomptime条件では、ビルド設定でlibadwaitaが有効になっており、かつビルド時に手元にあるバージョンが少なくとも1.4.0であることをチェックしています。これがfalseであれば、Zigコンパイラは残りの条件がどうであれブロックが含まれることはあり得ないと判断し、ブロックの残りをまったくコンパイルしません。

重要なのは、これにより、実行時条件やコードブロックの中でadwaitaライブラリを使っても、ライブラリが利用できない場合やバージョンが適切でない場合、あるいは新しいバージョンにしか存在しない型を参照した場合でも、コンパイラエラーにならないということです。

なぜ2つのadwaita.versionAtLeast(1, 4, 0)呼び出しがあるのか? 1回目はlibadwaitaが利用可能でバージョンが少なくとも1.4.0であることを確認するビルド時のチェックです。2回目は、動的にリンクされたライブラリが少なくともバージョン1.4.0であることを確認する実行時のチェックです。これにより、実行されるシステムよりも新しいバージョンのライブラリが入っているシステムでこのコードをコンパイルすることなどが可能になります。

この点をさらに強調するために、これをCでどうやっていたかを考えてみましょう。CではおそらくCMakeのようなビルドシステムを使い、一度きりのプログラムを生成してコンパイルを試みることで、プリプロセッサフラグを定義すべきかどうかを判断していたでしょう。うんざりします。🤮


comptimeの落とし穴

comptimeを使ってコードを条件付きで無効化する際には、注意すべき落とし穴がいくつかあります。まず、comptimeはcomptimeキーワードが使われない限り、関数の境界を越えません。たとえば、上記の条件分岐を関数に切り出した場合、うまく動きません。

fn myFunction() void {
    if (hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

fn hasFeature() bool {
    return false;
}

上記の例では、hasFeatureが自明にfalseを返すにもかかわらず、コンパイラは条件分岐の両方の分岐のコードをビルドします。これを修正するには、関数呼び出しの前にcomptimeを付けます。

fn myFunction() void {
    if (comptime hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

残念ながら、hasFeatureがcomptimeと非comptimeの条件を混在させている場合は、これではうまくいきません。その場合は、関数をインライン化する必要があります。

fn myFunction() void {
    if (hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

inline fn hasFeature() bool {
    return (comptime comptimeCheck()) and runtimeCheck();
}

これで期待通りに動作します。comptimeチェックがfalseであれば、コンパイラは機能固有のコードを含めません。

この微妙な挙動は見落としがちです。私はいつも、このようなcomptimeチェックがあるときは、両方の分岐でプログラムがビルドできることを保証するために、CIに両方のケースを追加するようにしています。


comptimeは素晴らしい

私はcomptimeが大好きです。手に入れるまで自分が必要としていることすら気づかなかった機能であり、他の言語で作業していると恋しくなる機能です。このブログ記事ではcomptimeの特定のユースケースを1つだけ紹介しましたが、たとえcomptimeの利点がこれだけであったとしても、依然としてキラー機能だと言えます。

comptimeによる条件付きコンパイルがあるからこそ、同じファイルや多くの共通コードを共有しながら、クロスプラットフォームなコードを書くことができます。プラットフォーム固有のコードを管理するために、複雑なビルドシステムや外部ツールを必要としません。ただZigのコードを書けば、あとはコンパイラが面倒を見てくれます。

改めて言いますが、comptimeでできることは他にもたくさんあり、comptimeには他にも多くの強力な特性があります。たとえば、comptimeにおける型はターゲットシステムに一致します(つまりポインタサイズなども正しくなります)。他にもまだまだあります!ぜひZigのドキュメントや他のブログ記事をチェックして、さらに学んでみてください。そしてぜひZigを試してみてください!

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

コメント