ZigでLuaを拡張する 1:Hello World
ZigでLua拡張モジュールを書くチュートリアル。hello worldから始め、Luaがロード可能な共有ライブラリのビルド、C ABI境界、境界を越えて値を受け渡すために必要なLua C APIの部分を扱う。
日本語
コピー
最近、Zig と Lua を人に教えるのは三度目と四度目になった。ブログを一本書くには十分な回数だ。
この記事は、読者が Lua の基本的な書き方を知っていることを前提とするが、Zig については何も知らなくてよい——一緒に「Hello World」を動かすところから始める。このシリーズの目標は、最終的に自分で書いた Zig の関数を Lua から呼び出せるようにすることだ。途中で Zig の文法、ビルドシステム、そして自分のコードの中で他人の Zig コードを使う方法について少し触れる。
文中のコードは Zig 0.17 と master ブランチの両方で動く。写経していて次のいずれかに遭遇したら、メールで知らせてほしい。記事を直す。
- Zig の最新安定版が 0.17.0 でない
- コードが最新安定版または master ブランチでコンパイルできない
Hello World - One
これが Zig 版の「Hello World」。
// hello.zig
const std = @import("std");
pub fn main() void {
std.debug.print("Hello World!\n", .{});
}
説明は後回しにして、まず動かそう。
Zig のインストール
Zig のリリース版は Ziglang.org からダウンロードできる。この方法を勧める。自分のシステムに合った OS とアーキテクチャを探して、対応するアーカイブをダウンロードする。最初の選択肢のグループが master ブランチで、次のグループが最新の安定版だ。解凍したら、アーカイブの中身を PATH 上に移動するか、シンボリックリンクを張る。アーカイブには zig バイナリとフォルダがいくつか入っていて、基本的にはまとめて移動させる。
こういう細部にこだわる人なら、おそらく助けは要らないはずだ。多くの Linux ディストリビューションが Zig をパッケージ化している。
上のファイルを実行するには、ターミナルで zig run hello.zig を実行する。run は zig のサブコマンドだ。引数なしで zig を実行すると、すべてのサブコマンドが一覧表示される。
Hello World を理解する
Zig を学ぶ前に Lua を知っているのは有利だ。多くの概念が対応しているからだ。Lua のファイルが暗黙的に一つの関数であるように、Zig のファイルも暗黙的に一つの名前空間、つまり Zig でいう struct だ。
@import() と comptime
Lua では、ファイルをロードするとその中のコードの「スクリプト」部分が実行される。同じように、Zig では、ファイルをコンパイルする(つまり「意味解析」する)と、その中のコードの「スクリプト」部分が実行される。上の Hello World プログラムには、まさにその部分がある。次の二行の Zig コードは、構文的にはほぼ同じだ。
const std = @import("std");
const the_answer = 42;
面白いのは、意味的にもほぼ一致することだ。どちらも定数を宣言し、そのスコープはそれが属する最大のブロック(関数内でなければ名前空間)、値は = の右側の内容になる。
一つの違いは、あの奇妙な @ 記号だと言うかもしれない。@import() は関数呼び出しに見えるが、ほぼそのとおりだ。先頭の @ 記号が、@import() がコンパイラ組み込み関数であり、ユーザーコードではないことを示している。コンパイラ組み込み関数には、普通の関数にはあり得ない特権がある。@import() の特権は、文字列リテラルを必ず渡さなければならないことだ。Zig の他のどの関数でも、次のような書き方は許される。
const literal = "literal string";
userlandFunction(literal);
しかしそれ以外に、@import() に特別なところはない。その仕事は、渡された文字列リテラルを、実在する Zig ソースファイル——自分のプロジェクト、依存しているプロジェクト、あるいは標準ライブラリのもの——と突き合わせることだ。だから厳密には、その働きをユーザーコードで再現することはできない。だが、コンパイル時に動くというその魔力は、実は @import() に固有のものではないし、const the_answer = 42; の行も特別ではない。次の Lua コードを見てほしい。
local std = require 'std'
local the_answer = 42
このコードを含むファイルがロードされると、Lua はこの二つの文を実行する。一つ目は、架空の std というライブラリを require(Lua の @import() だが、@import() と違って、実装はできなくともユーザーコードで上書きはできる)に渡し、結果を std というローカル変数に格納する。二つ目は 42 を the_answer という名前のローカル変数に格納する。
つまり要点はこうだ。Zig のコードはコンパイル時に実行されうるし、実際に実行される。経験則として、コンパイル時のコード実行は即時であり、唯一の例外は(普通の)関数の呼び出しだ。コンパイル時に foo という関数を呼びたいなら、comptime foo() と書く。ファイルのトップレベルスコープ(あるいは struct のような他のコンテナ)や、何かの型を暗黙に宣言する場面など、一部のコンテキストはそれ自体が comptime という語で始まる。余計な場所に comptime を付けるとコンパイラがエラーを出し、正しい書き方を示してくれる。
Comptime Zig と Lua の違い
Lua と Zig の違いの一つは、Zig のトップレベルスコープは関数ではないので、トップレベルスコープで次のような手続き的なコードを実行できないことだ。
var number = 1;
for (0..6) |i| {
number = number * (2 * i + 1);
}
コンパイル時に手続き的なコードを実行したいなら、block の中に入れる。
const number = comptime final: {
var number = 1;
for (0..6) |i| {
number = number * (2 * i + 1);
}
break :final number;
}
block には名前を付けられるし、値を生み出すこともできる。Lua での等価な書き方はこうだ。
local number = 1
do
for i =0, 6 do
number = number * (2 * i + 1)
end
end
Lua では do end という block は必須ではないが、Zig では必須だ。
もう一つの大きな違いは、Lua では関数宣言すら「匿名」にできることだ。
local main = function() print("Hello World!") end
Zig で関数を宣言する唯一の方法は、名前を付けることだ。関数を何かに代入したいなら、container スコープまで戻る必要がある。その例は後で見る。
公開宣言
Lua では変数がグローバルにもローカルにもなり得るのと同じように、Zig でも container スコープの値(宣言と呼ぶ)がそうだ。ただしデフォルトは逆で、Zig では「ローカル」がデフォルト、つまりその宣言は同じファイル内のコードからしか見えない。だから main には pub を付けなければならない。この関数は私たちのファイルに属さない Zig コードから呼ばれるからだ。たいていは Zig 標準ライブラリの start.zig ファイルから呼ばれるが、この挙動は上書きできる。Rust と同じく Zig は function ではなく fn を使い、C のように何も書かないこともない。戻り値の型は引数リストの後ろに置く。
std.debug.print の呼び出しは、.{} 以外は見慣れたものばかりのはずだ。この .{} 構文は、Zig で struct、array、union といった container を初期化する標準的な書き方で、中で .field = value と書いてフィールドを埋める。この小さな . はそこら中に出てきて、最初は居心地が悪いと感じる人も多い。Zig の文法を(言語理論の意味で)解析しやすくするために存在している。
ジェネリクス
関数 std.debug.print のシグネチャは次のとおり。
pub fn print(comptime fmt: []const u8, args: anytype) void {
// ...
}
これはジェネリック関数の一例だ。Zig では、(少なくとも原理上は)すべての普通の関数が、comptime 引数、anytype 引数の型、そしてクロージャが捕捉する container スコープの comptime 値に対してジェネリックになる。fmt 文字列は comptime 既知としてマークされ、これによって args の型がコンパイル時に検証され、必要なコードもコンパイル時に生成される。(ちなみに []const u8 は不変バイトの slice を表す。Rust で近い型は [&]u8 だろう。Zig の文字列リテラルは UTF-8 でエンコードされ、nul 終端されるが、Zig にそれ以上に専用の「文字列」型はない。)ここでは何も出力しないので .{} を渡す。この場合それは空の tuple として扱われる。
Zig に詳しい人なら驚きと喜びを感じるかもしれないが、std.debug.print をはじめ他の文字列フォーマット関数は、マクロも特別な組み込み権限も使わず、comptime Zig でユーザーランドに完全に実装されている。
最後に一つ。私たちのプログラムは実は期待どおりには動いていない。「Hello World!」は stdout ではなく stderr に出力された。これは std.debug.print の意図的な挙動だ。stdout に書き込むにはもう少しボイラープレートが必要で、それは Lua を設定したあとで補う。
ビルドスクリプトを追加する
ここまでは順調だ。Lua を使うには Zig のビルドシステムも使う必要がある。まず hello.zig ファイルを実行する build.zig スクリプトを追加しよう。
ここから始める。
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
_ = b;
}
スクリプトのあるディレクトリで zig build を呼ぶと、コンパイルされて実行される。ファイル名 build と唯一の引数は必須だ。
続く 2 行は慣例どおりこう書く。
--- build.zig
+++ build.zig
@@ -4,3 +4,5 @@
pub fn build(b: *std.Build) void {
- _ = b;
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
}
target オプションはコードをコンパイルする対象、つまり OS、マシンアーキテクチャ、ABI を指定する。.{} を渡すと(ここでは options 構造体のデフォルト値に強制変換される)、ユーザーはコマンドラインで -Dtarget= プレフィックスを使って好きなターゲットを指定でき、指定しなければ native ターゲットがデフォルトになる。optimize オプションは debug(デフォルト)、fast、safe、small のいずれかだ。後ろ 3 つは「release」ビルドの変種で、それぞれ実行速度、メモリ安全性の機能(オーバーフロー検査や境界検査など)、ディスク上のコードサイズを優先する。
次に hello.zig のコードを、ビルドシステムが言うところの「モジュール」に入れる。モジュールは実行ファイルにも、C 風のライブラリにも、さらに別の Zig モジュール(それを依存するプロジェクト内にあるかもしれない)にコンパイルすることもできる。モジュールにはルートソースファイルが必要で、私たちのルートソースファイルはターゲットと最適化オプションを受け取る。
--- build.zig
+++ build.zig
@@ -5,2 +5,8 @@
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
+
+ const module = b.addModule("hello-lua", .{
+ .root_source_file = b.path("hello.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
ついでに言うと、optimize の後ろの末尾カンマを省くと、Zig の自動フォーマッタ zig fmt は関数呼び出し全体を 1 行に収めてしまう。逆に 1 行で書いても optimize の後ろにカンマを残しておけば、zig fmt は上のように整形する。LSP サポートを使っていれば、この自動フォーマッタはおそらく保存時に走る。一部の構文エラーがあると zig fmt は何もしなくなるので、先に自分でエラーを潰しておく必要がある。
b.path() の呼び出しは、Zig Build システムがファイルシステムを扱う方法だ。この間接層があるおかげで、依存関係に属するかもしれないパスや、ビルド中のより早い段階で生成されるパスも、ビルドシステムが統一的に扱える。ここでは相対パスを使うべきだ。ついでに @import() の制約を 2 つ挙げておく。std のような名前付きモジュールのほかに、@import() は相対パスも受け付ける。この相対パスは、現在のモジュールのルートソースファイルがあるディレクトリの下のサブツリーを越えてはならない。また、1 つのファイルは最大 1 つのモジュールにしか属せない。
つまり A/root.zig、B/main.zig、B/utils.zig の場合、root.zig ファイルは @import を使って main.zig や utils.zig を取り込めないが、main.zig と utils.zig は互いを取り込める(同時に相互に取り込むことさえできる)。main.zig と root.zig の両方が utils.zig をインポートしたいなら、その両方を含むプロジェクトは、utils.zig を 2 つのプロジェクトが共通でインポートするモジュールとして構成しなければならない。
次にモジュールを実行ファイルにコンパイルする。
--- build.zig
+++ build.zig
@@ -8,5 +8,11 @@
const module = b.addModule("hello-lua", .{
.root_source_file = b.path("hello.zig"),
.target = target,
.optimize = optimize,
});
+
+ const exe = b.addExecutable(.{
+ .name = "hello-lua",
+ .root_module = module,
+ });
+ b.installArtifact(exe);
これで(今のところ)完全なビルドスクリプトだ。zig build で実行すると、hello.zig が hello-lua という名前の実行ファイルにコンパイルされ、zig build を実行した位置からの相対で zig-out/bin に置かれる。ビルドシステムはコンパイル後に成果物を実行することもできるが、それはこちらから明示的に要求した場合だけだ。
では、要求してみましょう:
--- build.zig
+++ build.zig
@@ -18,2 +18,8 @@
b.installArtifact(exe);
+
+ const run_step = b.step("run", "Run the executable");
+ const run = b.addRunArtifact(exe);
+ run.addPassthruArgs();
+ run_step.dependOn(&run.step);
+ run.step.dependOn(b.getInstallStep());
}
1行目で追加したコードは、Build グラフに「run」という新しい「ステップ」を作成します。このステップは zig build run を呼び出すことで実行できます。2番目の引数はヘルプテキストで、ユーザーが zig build -h を実行すると、このステップの横に表示されます。
2行目は exe を実行するステップをビルドグラフに作成し、3行目はユーザーが zig build run 内の -- 引数の後ろに追加したすべてのコマンドライン引数を exe の呼び出しに渡します。4行目と5行目は一見すると少し奇妙です。4行目は、名前を付けたステップ run_step が run.step に依存することを示しています。つまり zig build run の実行には run が必要であり、これは想定どおりです。
& 演算子は C とまったく同じです。Zig にはポインタはありますが、それ以上に賢いものはありません。step は run 上のフィールドで、LSP を有効にしていればポインタであることがわかります。. 演算子は、左辺の型に応じて C の . または -> 演算子として自動的に機能します。したがって &foo.bar は foo 上の bar フィールドのアドレスを取得します(foo がポインタ型の場合は foo.* 上のアドレスを取得します)。
最後の行は、実行ファイルをインストールしてから実行することを示しています。厳密には必須ではありませんが、