Zig 言語を触ってみる
近年徐々に話題にあがりはじめている Zig という言語がある。
C 言語との ABI レベル相互運用が手軽にできるなど、低レイヤーがターゲットのプログラミング言語な気がしている。
Zig の安全性に対する評価は下記の記事にいろいろまとまっている。use-after-free や double free 、uninitialized memory などが none
という評価ということは、そこまで安全性が重視された言語ではないということ?
言語公式が推す売りポイントは
- No hidden control flow.
- No hidden memory allocations.
- No preprocessor, no macros.
Zig プロジェクトは下記のようにして開始できる。
mkdir zig-sandbox
cd zig-sandbox
zig init-exe
すると、ディレクトリ内に下記のようにファイルやディレクトリができあがる。
❯ ls --tree
.
├── build.zig
└── src
└── main.zig
生成された Hello, world するコードは下記。
const std = @import("std");
pub fn main() anyerror!void {
std.log.info("All your codebase are belong to us.", .{});
}
ビルドして実行するには下記のコマンドを打つ。
zig build run
ようやく Hello, World できたw
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, {s}!\n", .{"World"});
}
テストコードは同じファイル内に書けそうだけど、ちょっと詰まった。
書き方あってるんだろうか?Zig Learn に従って try expect を使用したら、コンパイルエラーに見舞われてテストが実行できなかった。zig-example というリポジトリがあって、そこで std.debug.assert を使っていたのでそれを使ってみている。
下記コードを参考にしてみた。
const std = @import("std");
const assert = @import("std").debug.assert;
pub fn main() void {
std.debug.print("Hello, {s}!\n", .{"World"});
}
test "while" {
var i: i32 = 2;
while (i < 100) {
i *= 2;
}
assert(i == 128);
}
リポジトリはこれ。
ちなみに型推論とか効くのかなと思って、型注釈を消してみたら次のコンパイルエラーになった。別の型として認識されている?? var
がどういう意味なのかをそもそも確かめる必要がありそう。
❯ zig test src/*.zig
./src/main.zig:9:5: error: variable of type 'comptime_int' must be const or comptime
var i = 2;
^
./src/main.zig:10:12: note: referenced here
while (i < 100) {
^
if 文。
test "if statement" {
const a = true;
var x: u16 = 0;
if (a) {
x += 1;
} else {
x += 2;
}
assert(x == 1);
}
for 文。
最初、print の引数に2つ以上要素を割り当てる際にどうすればいいかわからなかった。.{}
内に受け取りたい引数をカンマ区切りで入れれば解決できた。
test "for" {
const string = [_]u8{ 'a', 'b', 'c' };
for (string) |character, index| {
std.debug.print("character: {}, index: {} \n", .{ character, index });
}
}
最後に fibonacci 。条件では ||
ではなく or
を使う。
fn fibonacci(n: u16) u16 {
if (n == 0 or n == 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
test "fibonacci" {
const x = fibonacci(10);
assert(x == 55);
}
Bun のおかげで Zig がまた話題になっている!
Zig は C の置き換えみたいな感じなので、あとはどういう安全性を選ぶかで Zig か Rust か使い分けることになりそう。Zig はヒープのアロケーションは自前で管理。メモリの解放は defer でやることができるので、C と比べると多少安心かも。ただ、個人的には Go の defer すら書き忘れるので、自分の手では管理しない方が嬉しい。そういう面では私は Go / Zig はむしろちょっと難しいと感じる。ただこのあたりは linter 等で回避可能なので、CI ときちんと回すなりして開発プロセスを設計すれば問題ないと思われる。
公式ドキュメントには「Please note that Zig is not a fully safe language.」と書かれているように、Rust がいうレベルでの安全性は獲得できていないというのが今のところの理解(狙ってもいない?)。Rust も unsafe で突破すれば安全ではなくなるというのは確かだが、安全でない箇所を絞れるのと、普通に書いていると UB しないという点では大きく異なる気がしている。
使い分けとしては、Zig は GC なしでバイナリサイズが小さくほどよく安全なコードをモダンな言語で書きたいみたいなケースに向いているのでは。ほどよく安全でよければいいけど、Rust だと厳しすぎでもっと手軽に書きたいというケースは意外と多い気がしていて、そういう用途には普通に向いている気がする。
Zig は言語仕様が非常に短く 500 行程度の PEG で表現できてしまう。Rust との違いはこうした新規概念の少なさであり、私個人としてもとっつきやすかったと感じた。Rust が難しいのは所有権やライフタイムというメモリ管理周りもそうだが、(メモリ管理自体がそもそも本来とても難しい)さらに加えて C をはじめとした機能が少なめの言語に登場してこない、関数型由来の概念や Rust 独自の言語機能・概念のキャッチアップにあり、メモリ管理 + 新規概念の難しさがあると思う。Zig はメモリ管理の難しさ(なんだけど、C を使う人は多分大丈夫)しかないので、そういう意味では新規概念は別に覚えたくないし、慣れた文法で書きたいし、早くものを作りたいみたいなケースではやはりフィットしそう。
言語仕様は非常に短いものの、文法はモダンな言語らしく比較的一貫性が高く、トリッキーさが少なく何より初見の罠が少ない印象を持っている。さすが「No hidden control flow.」と感じる。わかりやすい。文法の一貫性と初見での罠の少なさという観点では似たように「Simplicity」を信条としていそうな Go より好きかも。ただ、罠が少ないと断言できるほど触ってないので、今後意見が変わることはあるかもしれない。
近年 Rust が採用される背景には、低レイヤーを扱えてハイパフォーマンスなのに安全という「安全」の部分にフォーカスがあたっているように思っている。Zig が採用されるプロダクトが増えるだろうが、その際に採用される背景としては「低レイヤー」「パフォーマンス」の方に注目が集まりそう。Zig の普及と Rust の普及は完全に別に行われると思う。安全性が必要不可欠なプロダクトはそれなりにあるし、一方でそこまで安全でなくてもよいが(ちょっと語弊があるかも?安全性を保証するために厳しく書きたくないが、とか Rust ほどじゃなくていいが、とか)、低レイヤーをいじれてパフォーマンスがとにかく必要不可欠、なプロダクトも多くあると思うから。
comptime のおかげでおそらくマクロやリフレクションのない言語設計ができているように感じる。マクロやリフレクションがないというのは結構重要かなと思っていて、実質すべてをいつも使う関数で書き切るのは、余計な文法を新しく覚えることなくそうしたメタプログラミング的なものを可能にでき大変よいのではないかと感じる。
私は言語間の比較は結構重要だと思っている。似たレイヤーでの利用が期待される Rust と Zig がそもそもどういう違いがあるかを把握しておくことで、よりよい言語選択ができる。たとえば Rust と Zig の比較は決して無理筋ではなく、比較結果を検討し「ではどちらを利用するか」の選択を行うのは重要な行為だと思う。プログラミング言語にはそれぞれ設計上の狙いがあり、その狙いと自身の解決したい問題領域のスイートスポットにフィットする言語を選ぶのは重要である。言語間比較はその手助けをできるだろう。一方で適切な比較軸で言語を比較するのは大変難しい。とくに後者は現状の筆者では Zig 側の経験が少なすぎて Rust と Zig の正確な比較は手に余る。もう少し学習や利用を重ねたタイミングで、改めて比べてみるとおもしろそうに感じている。
いずれにせよ Rust と Zig は完全に別軸のプログラミング言語である。Rust が選択されるプロダクトが出てこれば、Zig が選択されるプロダクトもこれからたくさん出てくるだろうと思う。Zig の普及にとても期待している。私は少なくとも C の代わりとして今後普通に使っていくつもりでいる。
さて、ちょこっと文法について復習しつつ解説していこうと思う。
最初におすすめなのはこのドキュメント。非常に簡潔にまとまっていて好きです。
if
if は文としても書けるし式としても書ける。モダンなプログラミング言語では if が式となる形式は多く採用されていると思うが、Zig もやはり if を式として書くことができる。代わりに三項演算子はない(if で書き切れる)👏🏻
const expect = @import("std").testing.expect;
// 文として書いた例
test "if statement" {
const a = true;
var x: u16 = 0;
if (a) {
x += 1;
} else {
x += 2;
}
try expect(x == 1);
}
// 式として書いた例。
test "if statement expression" {
const a = true;
var x: u16 = 0;
x += if (a) 1 else 2;
try expect(x == 1);
}
インデントにタブはダメみたい。Space 4 がインデントの標準かな?下記はタブを入れて落としてしまった例。
sandbox/zig/basics on 🌱 mast [📦🤷] via ↯ v0.9.1
❯ zig test while.zig
./while.zig:4:1: error: invalid character: '\t'
var i: u8 = 2;
^
フォーマッターがあるので使うようにするとよさそう。VSCode とかは save on format まだ効いてくれない。
フォーマッターはたとえば下記で回せる。
zig *.zig
while
他の言語と同じように書ける。
const expect = @import("std").testing.expect;
test "while" {
var i: u8 = 2;
while (i < 100) {
i += 2;
}
try expect(i == 128);
}
ただ、サンプルのこのコード、テストが落ちる…。期待値は100?しかも期待値がちょっとわかりにくい気がする。コンパイルのエラーメッセージはそこまでまだエルゴノミックじゃないかも。
sandbox/zig/basics on 🌱 mast [📦🤷] via ↯ v0.9.1
❯ zig test while.zig
Test [1/1] test "while"... FAIL (TestUnexpectedResult)
/opt/homebrew/Cellar/zig/0.9.1/lib/zig/std/testing.zig:303:14: 0x102fb196b in std.testing.expect (test)
if (!ok) return error.TestUnexpectedResult;
^
/Users/helloyuki/github/yuk1ty/sandbox/zig/basics/while.zig:8:5: 0x102fb1a07 in test "while" (test)
try expect(i == 128);
^
0 passed; 0 skipped; 1 failed.
error: the following test command failed with exit code 1:
zig-cache/o/75fda4b677fec6e6aa20d537f080bd50/test /opt/homebrew/Cellar/zig/0.9.1/bin/zig
fn expectEqual(expected: anytype, actual: anytype) anytype
のほうを使えば
try expectEqual(@as(u8, 100), i);
のように書けますよ。
エラーメッセージはRustほど親切ではないのは確かですね。
作者の方のツイート を見ると今はセルフホストコンパイラを作ったり LLVM 14 対応していたりとコンパイラがまだまだ発展中の段階なのでエラーメッセージの改善はやるにしてもまだだいぶ先の話だと思います。
expectEqual
試してみます!ありがとうございます!
エルゴノミクスはあとからついてこれば大丈夫なので、まずは必要な実装が完了するのが楽しみですね。
var
var だと型推論してくれない?var で宣言すると都度型注釈が必要になりそう。const で宣言した変数では不要みたい。おそらくコンパイラの内部で何か処理方法が切り替えられていそう。
sandbox/zig/basics on 🌱 mast [📦🤷] via ↯ v0.9.1
❯ zig test while.zig
./while.zig:5:5: error: variable of type 'comptime_int' must be const or comptime
var i = 2;
^
./while.zig:5:5: note: to modify this variable at runtime, it must be given an explicit fixed-size number type
var i = 2;
^
const → イミュータブルで宣言。JS の const みたいな。
var → ミュータブルで宣言。JS の let みたいな。
型推論については、とくに const と var の区別はなく「推論できたら推論する」とだけ書かれている。あとでコンパイラの中身を読んだらわかりそう。
@as
で type coersion を起こせる。@
で始まる記法にどういう意味があるのかは気になる。
下記はコードをそのまま引っ張ってきた。
const constant: i32 = 5; // signed 32-bit constant
var variable: u32 = 5000; // unsigned 32-bit variable
// @as performs an explicit type coercion
const inferred_constant = @as(i32, 5);
var inferred_variable = @as(u32, 5000);
変数には値の割り当てが必ず必要。未初期化状態の変数は下記で作れそう?undefined
はいわゆる any 型みたいなもので、どんな型に対しても一旦入れられる。
const a: i32 = undefined;
var b: u32 = undefined;
./while.zig:5:5: error: variable of type 'comptime_int' must be const or comptime
とあるようにvar i = 2;
と書くと2
をcomptime_int
型として推論しますが、comptime_int
型はconst
かcomptime
で修飾されていないと使えない型なのでvar
では使用できません。
なのでvar i: i32 = 2;
のように型を明示するか、comptime var i = 2;
のようにcomptime
で修飾する必要が有ります。
ココで推論できないのは数値を直で書いているからなので
fn func() i32 { return 2; }
pub fn main() void {
var i = func();
_ = i;
}
のように関数の戻り値など型が明確であればvar
でも推論してくれます。
なるほどー!ありがとうございます🙇🏻♀️
@ で始まるのは Builtin Functions ですね。 @as は広い型にキャストする用で、狭い型へのキャストで情報落ちの際はパニックさせるなら @intCast、狭い型へのキャストではみ出たビットは破棄するなら @truncate と使い分けが必要です。
あー、なるほど。ということは、@import も Builtin Functions なんですね。コードを読んだ瞬間に Builtin だとわかる設計いいですねー!
-
print
には必ず.{}
が必要??何も入れる必要がない場合でも2つ以上の引数を求められてしまう。
const argv = @import("std").os.argv;
const print = @import("std").debug.print;
pub fn main() anyerror!void {
print(".intel_syntax noprefix\n");
print(".globl main\n");
print("main:\n");
print(" mov rax, {d}\n", .{argv[1]});
print(" ret\n");
}
上記はコンパイルエラーになる。
❯ zig build run -- 42
./src/main.zig:5:10: error: expected 2 argument(s), found 1
print(".intel_syntax noprefix\n");
^
/opt/homebrew/Cellar/zig/0.9.1/lib/zig/std/debug.zig:63:5: note: declared here
pub fn print(comptime fmt: []const u8, args: anytype) void {
^
9cc-zig...The following command exited with error code 1
代わりに info
などを使うのだろうか。ちょっと使い分けはあとで調べる。
- runtime に値が判明するものを const には入れられない
これは普通にいいなと思った機能で、実行時にしか値がわからないものはいわゆるコンパイル時に解決する文脈には入れられない。たとえば、下記はコンパイルエラーになる。
const argv = @import("std").os.argv;
const print = @import("std").debug.print;
pub fn main() anyerror!void {
print(".intel_syntax noprefix\n", .{});
print(".globl main\n", .{});
print("main:\n", .{});
print(" mov rax, {d}\n", .{argv[1]});
print(" ret\n", .{});
}
ランタイムの値をコンパイルタイムの変数には入れられない、という旨のコンパイルエラーが送出される。
❯ zig build run -- 42
./src/main.zig:1:31: error: cannot store runtime value in compile time variable
const argv = @import("std").os.argv;
^
9cc-zig...The following command exited with error code 1:
コンパイル時に決定される内容と実行時に決定される内容が比較的厳密に管理されているのか。いいと思う。