🦕

Zig言語のドキュメントを見て「なるほど!」と思ったところ

2022/07/15に公開

前回の続き。
https://zenn.dev/tetsu_koba/articles/7e1f383184c052
ちょっとZig言語が面白そうです。まだあまりzigでコードは書いていないのですが、ドキュメントやソースコードをながめて思ったことを書き散らかしてみます。
(他の人がすでに書いていることはなるべく書かない)

オブジェクト指向でなくてデータ指向

C言語に後にC言語っぽい文法の言語はたくさん出てきました。C++, Java, C#, Go, Rustなど。
C++やJavaはオブジェクト指向の考え方を取り込んだもので、GoやRustはいきすぎたオブジェクト指向を反省して、interface とか trait とか良いところだけを抽出した感じになっています。(個人の感想です)
Zigには純粋にC言語からの改善をしていて、オブジェクト指向の香りがあまりしません。
むしろ「データ指向設計」(Data-Oriented Design)が意識されているらしいです。

紹介してもらったこの動画が興味深いです。
https://media.handmade-seattle.com/practical-data-oriented-design/

これをきっかけに Data-Oriented Design に興味を持ちました。
この本(オンライン)を読んでいます。(まだ全部は読めていませんが)
https://dataorienteddesign.com/dodbook/

関数の引数はimmutable

他の言語だと関数の引数は値の入っているローカル変数と同じ扱いであることが多いがZigではそうではない。
引数が変更できないのは分かりやすさのためかなと思ったけど、それだけではないようだ。
https://ziglang.org/documentation/master/#toc-Pass-by-value-Parameters
ポインタや整数やフロートなどのプリミティブ型の引数は値渡し。つまりコピー。
struct やunion, arrayなどは値渡しにするか参照渡しにするかはZigがどちらか早い方を自動で判断する。引数がimmutableなのはどちらになっても副作用を起こさないためだった。
(ただし、extern された関数はC ABIに従う)

もうひとつ思いついた利点。
渡されたstruct/配列の中身を関数の中で変更して返すつもりなのに、参照渡し(ポインタ渡し)でなくて間違って「実体渡し」にしてしまった場合、渡されたものはコピーなのでその関数を呼んだ側のstruct/配列は元のままなので意味がない。
C言語ではこのような間違いをしてもコンパイルエラーにならないので悩むことになるかもしれないが、Zigだったらimmutableな引数を書き換えようとしたということでコンパイルエラーになってすぐに間違いに気がつける。
** 2024/04/17 追記
ちょっとこれは言い過ぎだったかなと思い直しました。ポインタ渡しの引数に実体を渡したらC言語でもさすがにコンパイルエラーになりそうです。設定によるかもしれませんが。
** 追記ここまで

structと関数

Cの関数に似ている。他の言語によくある「特定のstructと結びつくメソッド」というものはない。
勘違い。普通にstructの中に関数を書ける。そしてそのstruct(のポインタ)を第一引数にとる関数は他の言語でいうインスタンスメソッドのような呼び出し方ができる。
クラスという概念はないので、コールバック関数はCと同様に関数の型で指定する。

(golangのinterfaceのような複数の関数のグループをまとめて抽象的に扱うしくみは無い?)
**2023/01/13 追記
Zigでinterfaceは直接サポートされていないが、unionを利用して似たようなことができる
https://zig.news/kristoff/easy-interfaces-with-zig-0100-2hc5
**追記ここまで
**2023/01/30 書きました **

センチネル

sentinel(センチネル:番兵)
https://en.wikipedia.org/wiki/Sentinel_value

Cの文字列はNUL文字で終わるcharの配列だけど、Zigではそういう形式を一般化している。
最後にNULLが入るポインタの配列もZigでは"Sentinel-Terminated Pointers" として自然に扱える。
https://ziglang.org/documentation/master/#toc-Sentinel-Terminated-Pointers
この例ではCの可変個の引数を持つprintfの引数が"Sentinel-Terminated Pointers"として表現されている。

センチネルを使った配列/スライスなどで内容を検索するループを回す時に、「最後のセンチネルが来るまで該当するものがなかった場合」の判断がよくでてくるが、そんな場合にZigのfor 〜 else構文がぴったり。

オーバーフロー

四則演算などのデフォルトの振る舞いがオーバーフローを検出するようになっている。意図的にラップアラウンドを許す場合にはそのための演算子が別に用意されている。
演算のオーバーフローが起こった場合には見た目には不審な動作になってしまって原因箇所の特定に手間がかかる。

自分の経験した話。
ある製品の後継機種を作ったときに前機種のコードをベースに修正していった。後継機は前機種より扱えるデータサイズが大きくなっている。目立つ箇所は修正してほぼ動作したと思ったが、あるパラメータを一定値より大きな値をすると謎な動きになるという現象がでた。それは処理の途中の変数でオーバーフローを起こしていたためだった。前機種ではbit幅が十分足りていたが、後継機では足りないことに気がつけなかった。
メモリサイズ削減をしていると16bitで足りる変数はuint16とか使うけど、仕様変更などでそれで足りなくなるのを全てチェックしきれないことは多いと思うのでありがたい。

言語仕様とライブラリの明確な分離

メモリアロケータ、スレッドや非同期の実装、文字や文字列の扱いはライブラリで扱うことになっていて言語仕様としてはそれに必要な最低限なものになっている。
なので、OS無しでファイルシステムも無しで扱う文字はasciiだけという環境のマイコンなら、そのためのスリムなライブラリと組み合わせて使えばよい。
私は今までに言語を動かすためのランタイム環境の移植などをけっこうやってきたので、このすっきり感は好き。

comptime

C言語だとマクロを使うことでCの言語仕様を離れたトリッキーなことができたけど、他人が書いたマクロは理解し難いものになることが多かった。
Zigではcomptime(=コンパイル時の処理)でCの奇怪なマクロを撲滅したようだ。他と同じ構文で書けるところがシンプルでありがたい。

ブロックに名前をつけられる  (2022/07/17追記)

Cでも関数内の任意の箇所で{}で囲むことでローカル変数のスコープを区切ったブロックを作ることができた。Zigだとそれにラベルをつけられるようになった。そしてbreakでラベルで指定してそのブロックから抜けることができるようになった。そのためネストしたブロックから一気に抜けることもできる。
これと defer, errdefer のおかげでgotoを撲滅することに成功している。(gotoを使わざるをえなかったケースを全てカバーできたので、gotoをなくすことができた。)

ラベル付きブロックのこんな使い方を見つけた。
https://mitchellh.com/zig/build-internals

fn testZigInstallPrefix(base_dir: fs.Dir) ?Compilation.Directory {
    const test_index_file = "std" ++ fs.path.sep_str ++ "std.zig";

    zig_dir: {
        // Try lib/zig/std/std.zig
        const lib_zig = "lib" ++ fs.path.sep_str ++ "zig";
        var test_zig_dir = base_dir.openDir(lib_zig, .{}) catch break :zig_dir;
        const file = test_zig_dir.openFile(test_index_file, .{}) catch {
            test_zig_dir.close();
            break :zig_dir;
        };
        file.close();
        return Compilation.Directory{ .handle = test_zig_dir, .path = lib_zig };
    }

    // Try lib/std/std.zig
    var test_zig_dir = base_dir.openDir("lib", .{}) catch return null;
    const file = test_zig_dir.openFile(test_index_file, .{}) catch {
        test_zig_dir.close();
        return null;
    };
    file.close();
    return Compilation.Directory{ .handle = test_zig_dir, .path = "lib" };
}

testZigInstallPrefix の関数の中で、 lib/zig/std/std.zigに対する処理とlib/std/std.zigで似たようなことをしている。でもここでしか使っていないのでわざわざ関数に切り出すほどでもない。そんなときに意味のあるコードの塊をラベル付きのブロックにした。ラベルを指定してのbreakで途中から抜けることもできるようになっている。

文字列リテラルはNUL終端されている (2023/01/11 追記)

Zigの文字列は[]u8。長さで管理されCと異なりNUL文字で終端されない。
(キャラクターコードの規定は言語としてはしないが、stdライブラリではutf-8を想定している。)
文字列リテラルはimmutableなものとしてリードオンリーのメモリセクションに配置されるが、このときにNUL文字で終端されると仕様書に明記されている。
そのため、Cの関数に文字列リテラルを渡すときにはいちいちNUL文字を追加する必要がなく、そのまま渡すことができる。

ちなみに、最近Rustでもその便利さが伝わったらしく、c"Hello" のように頭にcをつけるとNUL文字で終端する文字列リテラルを定義できる機能が追加されたと知りました。
https://github.com/rust-lang/rfcs/pull/3348

(他にも何か見つけたら追記する)

関連

https://zenn.dev/tetsu_koba/articles/2da58bd66586aa
https://zenn.dev/tetsu_koba/articles/7e1f383184c052
https://zenn.dev/tetsu_koba/articles/262ef6d8539f2e
https://zenn.dev/tetsu_koba/articles/1e3832a9b5b247

Discussion