zig言語を触ってみる

最近Zig言語が気になっているので、色々触りながら学んでみる。
執筆時点でのバージョン
- zig 0.15.1

環境構築
まずはzig本体をインストール。macOSなのでHomebrewから。
$ brew install zig
VSCodeで書いていくので、拡張機能も入れる。
ZLS(zigのLSP)を追加で入れる必要があるという記事もみたが、今試した限りではVSCode拡張側でインストールしてくれる感じになっていた。

Hello, World
まずはHello, Worldから。
const std = @import("std");
pub fn main() !void {
std.debug.print("Hello, World!", .{});
}
これをmain.zigに書いてzig run
で実行。
$ zig run src/main.zig
Hello, World!

FizzBuzz
ついでにFizzBuzzもやってみる。
const std = @import("std");
pub fn main() !void {
var i: i32 = 1;
while (i < 100) {
if (i % 15 == 0) {
std.debug.print("fizz buzz", .{});
} else if (i % 3 == 0) {
std.debug.print("fizz", .{});
} else if (i % 3 == 0) {
std.debug.print("buzz", .{});
} else {
std.debug.print("{}", .{i});
}
i += 1;
}
}
- 変数は
var
、定数はconst
で定義する -
while
やif
は他の言語と大体一緒。括弧は必須っぽい
comptime_intについて
引っかかったところとして、整数はcomptime_int
として推論されるが、これはvar
で宣言されたmutableな変数に対しては使えない。
$ zig run src/main.zig
src/main.zig:4:9: error: variable of type 'comptime_int' must be const or comptime
var i = 1;
^
src/main.zig:4:9: note: to modify this variable at runtime, it must be given an explicit fixed-size number type
そのため、明示的にi32
などを指定する必要がある。

Allocator
Zigの最大の特徴であるAllocatorを使ってみる。ZigにはGCやARC、所有権モデルなどは存在せず、全てのメモリ管理を手動で行う必要がある。
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
// Allocatorでメモリを確保
const buffer = try allocator.alloc(u8, 10);
// deferでスコープを抜けたらbufferを解放する
defer allocator.free(buffer);
// bufferに値を書き込む
for (buffer, 0..) |*byte, i| {
byte.* = @intCast(i);
}
// bufferの内容を出力
for (buffer) |byte| {
std.debug.print("{d}\n", .{byte});
}
}
-
std.heap
以下のallocatorからヒープにメモリを確保できる- 他にも
c_allocator
とかwasm_allocator
とか色々ありそう
- 他にも
- エラーを送出する可能性のあるコードは
try
で実行する。好き -
defer
があるので、解放処理はこれで書く -
for
の構文が少し独特だが、慣れるとわかりやすくて良い

Cの関数を呼ぶ
zig側からCの関数を呼んでみる。
Cの実装
まずは整数の足し算を行うだけのadd.c
とadd.h
を作成する。
// add.c
int add(int a, int b)
{
return a + b;
}
// add.h
int add(int a, int b);
これらのファイルを以下のように配置。
├── include/
│ └── add.h
├── src/
│ ├── add.c
│ └── main.zig
├── build.zig
└── build.zig.zon
build.zigの設定
次にbuild.zig
で設定を行う。以下の2行をbuild関数の最後に追記。
exe.addCSourceFiles(.{ .files = &[_][]const u8{"src/add.c"} });
exe.addIncludePath(b.path("include"));
main.zig
最後に、main.zig
で@cImport
からimportする。
const std = @import("std");
// Cライブラリをimport
const c = @cImport(
@cInclude("add.h"),
);
pub fn main() !void {
// addを呼び出す
std.debug.print("{}", .{c.add(10, 20)});
}
実行時はzig src/main.zig
だと@cImport
が上手く動かず、zig build run
を使ったら動いてくれた。ファイルパスを指定した実行だとbuild.zig
の設定が反映されてなさそう。

raylibを使ってみる
raylibを@cImport
で動かしてみる。
結構苦労して試行錯誤した結果、@Juhaさんのおかげで動かせました。感謝
raylibのインストール
今回はhomebrewからインストールした。
$ brew install raylib
build.zig
build.zig
に以下の一行を追加。
exe.linkSystemLibrary("raylib");
main.zig
const std = @import("std");
const raylib = @cImport(
@cInclude("raylib.h")
);
pub fn main() !void {
raylib.InitWindow(800, 600, "Hello, Raylib!");
defer raylib.CloseWindow();
while (!raylib.WindowShouldClose()) {
raylib.BeginDrawing();
defer raylib.EndDrawing();
raylib.ClearBackground(raylib.RAYWHITE);
raylib.DrawText("Hello, Raylib!", 190, 200, 20, raylib.LIGHTGRAY);
}
}
これをzig build run
で実行すると、以下のウィンドウが表示される。

comptime
Allocatorと並ぶもう一つの目玉機能、comptimeも試してみる。
const std = @import("std");
pub fn main() !void {
// comptime addはコンパイル時に評価される
std.debug.print("{}", .{comptime add(1, 2)});
}
fn add(a: i32, b: i32) i32 {
return a + b;
}
zigでは型を値(type
型)として扱えるので、comptimeと合わせてGenericsのようなものを実現できる。
const std = @import("std");
pub fn main() !void {
std.debug.print("{}", .{add(i32, 1, 2)});
}
fn add(comptime T: type, a: T, b: T) T {
return a + b;
}
控えめに言ってめちゃくちゃ良い。他言語にも欲しい。

エラーハンドリング
エラーはRustのResult<T, E>
に相当するものが言語機能として組み込まれている。
例として、自作したエラー型を返す関数を定義してみる。
const std = @import("std");
// error型の実体はほぼenum
const ExampleError = error{Example};
// エラーを返す関数
// 失敗する可能性のある関数の戻り値は{ErrorType}!{ResultType}で指定
// エラー型を省略した場合は推論される
fn foo() !u8 {
return ExampleError.Example;
}
// anyerrorは全てのErrorを含有する型
// Rustのanyhowっぽい
pub fn main() anyerror!void {
// 失敗する可能性のある関数の呼び出しにはtryが必要
// Rustの?演算子のイメージ
_ = try foo();
// catchで失敗時のデフォルト値を指定できる(unwrap_orのイメージ)
const result = foo() catch 10;
std.debug.print("{}", .{result});
}
-
try
やcatch
などのキーワードを使うが、例外のような機構があるわけではなく、中身は実質的にResult - 言語機能として組み込まれている分、Resultを使うよりわかりやすく書けて良い
- error型はunionを定義することもできるので、複数のエラーの合成も容易
- エラー時専用にdefer処理を書ける
errdefer
がある

build.zig.zon
zig 0.11-0.13あたりで、build.zig.zon
を用いたパッケージ管理のシステムが追加された。zig fetch
コマンドで依存関係を追加できる。
例として、raylibのzigラッパーであるライブラリraylib-zigを追加してみる。
$ zig fetch --save git+https://github.com/raylib-zig/raylib-zig#devel
これを実行すると、build.zig.zon
に以下が追加される。
.{
// 省略
.dependencies = .{
.raylib_zig = .{
.url = "git+https://github.com/raylib-zig/raylib-zig?ref=devel#d64fc43f38949231dc7d6f1c016db8fcae858b8c",
.hash = "raylib_zig-5.6.0-dev-KE8REDc2BQCri1t11guC1tZA-Luc7NuVeml_59LSELLe",
},
},
}
続いて、build.zig
にもいくつか追加する。
const raylib_dep = b.dependency("raylib_zig", .{
.target = target,
.optimize = optimize,
});
const raylib = raylib_dep.module("raylib"); // main raylib module
const raygui = raylib_dep.module("raygui"); // raygui module
const raylib_artifact = raylib_dep.artifact("raylib"); // raylib C library
exe.linkLibrary(raylib_artifact);
exe.root_module.addImport("raylib", raylib);
exe.root_module.addImport("raygui", raygui);
これでraylib-zigがimportできるようになる。
const raylib = @import("raylib");
pub fn main() anyerror!void {
const screenWidth = 800;
const screenHeight = 450;
raylib.initWindow(screenWidth, screenHeight, "raylib-zig [core] example - basic window");
defer raylib.closeWindow();
raylib.setTargetFPS(60);
while (!raylib.windowShouldClose()) {
raylib.beginDrawing();
defer raylib.endDrawing();
raylib.clearBackground(.white);
raylib.drawText("Congrats! You created your first window!", 190, 200, 20, .light_gray);
}
}
cargo
やdotnet
、npm
などに慣れている身としては、build.zig
周りの設定が必要なのがちょっと辛い。とはいえ、現実的に運用できる範囲にはなっていそう。

テスト
テスト専用の構文があり、インラインでテストが書ける。最近の言語っぽい感じ。
const std = @import("std");
pub fn main() anyerror!void {}
test "example" {
try std.testing.expectEqual(3, 1 + 2);
}
test "example fail" {
try std.testing.expectEqual(3, 1 + 1);
}
書いたテストはzig test
で実行できる。
$ zig test src/main.zig
expected 3, found 2
2/2 main.test.example fail...FAIL (TestExpectedEqual)
/opt/homebrew/Cellar/zig/0.15.1/lib/zig/std/testing.zig:110:17: 0x1042847b3 in expectEqualInner__anon_510 (test)
return error.TestExpectedEqual;
^
/Users/yusuke/Desktop/Projects/Sandbox/zig-sandbox/src/main.zig:10:5: 0x104284887 in test.example fail (test)
try std.testing.expectEqual(3, 1 + 1);
^
1 passed; 0 skipped; 1 failed.
error: the following test command failed with exit code 1:
.zig-cache/o/ef39a59f5151a7bb7eb77c45d7dd3c4e/test --seed=0xa5c784ca

所感
Pros
- C言語の正統進化といった趣のわかりやすい言語仕様
- Rustよりはシンプルで習得しやすい
- ResultやOptionに相当するものが言語機能として搭載されている
- 専用の構文があるため、RustやGoに比べてわかりやすく書ける
- comptimeはめっちゃ良い
- マクロのようなわかりにくさや、C++のテンプレートのような複雑さがない
- それでいて柔軟に対応できる設計
- ポインタも含めて明示的なため、unsafe Rustを書くよりはかなりやりやすい印象
Cons
- 手動メモリ管理は好みが分かれそう
- 個人的には割と好きだが、規模が大きくなってきたときに大変そうではある
- ビルド周りはかなりしんどい
- 良くも悪くも明示的な仕様であり、自分で面倒を見なければならない範囲がかなり広い
- CMakeよりはマシだと思うが...
- LSP/VSCode拡張の完成度がまだ微妙
- 基本的なエラーはエディタ上で見れるが、ほとんどのコンパイルエラーはビルドしてみないとわからない
- build.zigとかはLSPがほとんど役に立たない
- エラーメッセージも微妙なことが多い
- 解析速度はrust-analyzerよりも速いので、補完などはかなり快適
- まだ1.0に到達していないため、ガンガン破壊的変更が入る
- サンプルコードやAIの書いたコードが動かないことが多々ある
総評として、better Cとしての方向性は素晴らしいが、ところどころ未完成な部分があり(特にLSP周り)、本格的に導入するには時期尚早な印象。言語自体はかなり良くできているので、1.0がリリースされたら低レイヤー周りを書くのに積極的に使っていきたい。