Zigのcomptimeでコードを条件付きで無効化する
これはZigにおけるcomptimeのユースケースについてのシリーズの一部です。
Zigには、comptimeという非常に強力な機能があります。comptimeを使うと、Zigのコードをコンパイル時に実行できます。これは特別なマクロ言語でも、ASTを操作する仕組みでもありません。コンパイル時に実行される、通常のZigコードそのものです。実質的な制限は、comptimeのコードでは副作用を起こせないことだけです(システムコールやI/Oなどはできません)。
正直に言うと、初めてZigを調べ始めたとき、これはちょっとした見せ物だと思っていました。「そもそもcomptimeで実際にやりたいことなんて、どれくらいあるんだろう?」と考えていたのです。ところが、本格的なプロジェクトでZigを使い始めて2年が経った今では、comptimeはあらゆるところで使われていて、Zigでいちばん好きな機能と言っても過言ではありません。
comptimeそのものが大きなテーマです。そこで、comptimeについてすべて掘り下げる代わりに、今回は特に便利なパターンを1つ紹介します。comptimeでコードを条件付きで無効化する方法です。
なぜコードを条件付きで無効化するのか
コードを条件付きで無効化するのは、ソフトウェア開発でよく使われるパターンです。条件付きでコードを無効化したい理由として、特によくあるものをいくつか挙げます。
プラットフォーム固有のコード: macOSとLinuxで実装が異なる関数がある場合です。
デバッグ用コード: デバッグ時にだけ役立つコードがあり、本番ビルドでは無効化したい場合です。
ビルド設定: ビルド時の設定に応じて、ある機能を省略したり変更したりしたい場合です。
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つは、プリプロセッサが独自の構文とルールを持つ別の言語だということです。
もう1つの大きな欠点は、プリプロセッサが非常に限定的なことです。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.
}これは、通常のZigであることによってcomptimeがどれほど強力になるかを示しています。この例では、comptimeの条件とcomptimeではない条件を組み合わせています。最初の条件がcomptimeで、残りは実行時の条件です。
最初のcomptime条件では、ビルド設定でlibadwaitaが有効になっていることと、ビルド時点で利用できるバージョンが1.4.0以上であることを確認しています。これが偽なら、Zigコンパイラは残りの条件がブロックを含める結果になることはありえないと判断できるため、ブロックの残りをまったくコンパイルしません。
重要なのは、ライブラリが利用できない場合やバージョンが適切でない場合、あるいは新しいバージョンにしか存在しない型を参照した場合でも、実行時の条件やコードブロックでadwaitaライブラリを使って、コンパイラエラーにならずに済むということです。
なぜadwaita.versionAtLeast(1, 4, 0)を2回呼び出しているのか: 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が自明に偽であるにもかかわらず、コンパイラは条件分岐の両方のブランチをビルドします。これを直すには、関数呼び出しの前に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のチェックが偽なら、コンパイラは機能固有のコードを含めません。
この微妙な挙動は、非常に見落としやすいものです。このようなcomptimeのチェックを使うときは、両方の環境でプログラムをビルドできることを確認するため、必ずCIに両方のブランチを追加しています。
comptimeはすばらしい
私はcomptimeが大好きです。手に入れるまで必要だと気づかなかった機能であり、ほかの言語を使っていると恋しくなる機能でもあります。今回の記事ではcomptimeの具体的なユースケースを1つだけ紹介しました。しかし、comptimeにできることがこれだけだったとしても、十分に決定的な機能だと思います。
comptimeによる条件付きコンパイルのおかげで、同じファイルと多くのコードを共有しながら、クロスプラットフォームのコードを書けます。プラットフォーム固有のコードを管理するために、複雑なビルドシステムや外部ツールを用意する必要はありません。Zigのコードを書けば、あとはコンパイラが処理してくれます。
繰り返しになりますが、comptimeでできることや、comptimeが持つ強力な性質はほかにもたくさんあります。たとえば、comptime内の型は対象システムに合わせられます(そのため、ポインタのサイズなども正しくなります)。まだまだ紹介しきれないことがたくさんあります。詳しく知りたい方は、Zigのドキュメントやほかのブログ記事を読んでみてください。そして、ぜひZigを試してみてください。
記事をランダムに読む