3
2

More than 1 year has passed since last update.

Zig の文書読んで所感を記す #2

Posted at

これは何?

Zig の文書読んで所感を記す の続き。

除算

除算の話を書き忘れていた。

除算への対応が見たことのない感じで驚いた。

zig0.9.1
const a: i64 = 10;
const b: i32 = 3;
try stdout.print("{} /  {} = {}\n", .{ a, b, (a / b) });
// ↑ 10 /  3 = 3

const c: f64 = 10;
const d: f32 = 3;
try stdout.print("{} /  {} = {}\n", .{ c, d, (c / d) });
// ↑ 1.0e+01 /  3.0e+00 = 3.3333333333333335e+00

try stdout.print("{} /  {} = {}\n", .{ c, b, (c / b) });
// ↑ error: incompatible types: 'f64' and 'i32'

と、除算の演算子は / である。この点は普通。
多少の暗黙の変換はあるが、浮動小数点数を整数で割るとエラー。これもまあそうかなと思う。

しかし、const をやめて var にすると

zig0.9.1
var a: i64 = 10;
var b: i32 = 3;
try stdout.print("{} /  {} = {}\n", .{ a, b, (a / b) });
// ↑ error: division with 'i64' and 'i32': signed integers must use @divTrunc, @divFloor, or @divExact

var ua: u64 = 10;
var ub: u32 = 3;
try stdout.print("{} /  {} = {}\n", .{ ua, ub, (ua / ub) });
// ↑ 10 /  3 = 3

var c: f64 = 10;
var d: f32 = 3;
try stdout.print("{} /  {} = {}\n", .{ c, d, (c / d) });
// ↑ 1.0e+01 /  3.0e+00 = 3.3333333333333335e+00

「符号付き整数の除算では / は使えないよ」というエラーになる。びっくりした。

正確には、符号付き整数の場合

  • 両辺ともコンパイル時に確定していること
  • 非負÷正 であること

でなければエラー。

なお。
エラーメッセージ内で紹介されている3つの関数はそれぞれ

名前 割り切れなかった場合
@divTrunc ゼロに近い整数に丸める
@divFloor より小さい値(負の無限大に近い値)に丸める
@divExact パニック

という動作になる。割り切れなかったらパニックにも驚いた。なるほど exact。
あと、@divExact でなくても、除算の結果がオーバーフローする場合 (i8 なら、(-128) ÷ (-1)) もパニック。

パニックが嫌な場合、 "std" にある math.divTrunc などを使うと error union で結果がもらえる。

整数除算が普通に / ではできないことに驚いたが、まあそう言われてみれば曖昧だったよなと思う。

剰余

除算と同様、コンパイル時に値が不明な符号付き整数では % は使えない。
@divTrunc とセットなのが @rem@divFloor とセットなのが @rem

さらに。除算と異なり、denominator は正でなければならない。この制限は整数の場合だけで、浮動小数点数の場合は負の値で剰余をとってもよい。

いろいろ試してみたところ、下表のようになった。

numerator denominator % @rem, @mod
comptime の符号付き整数
comptime の符号付き整数
comptime の符号付き整数 any
符号付き整数
符号付き整数
符号付き整数 any
comptime の浮動小数点数
comptime の浮動小数点数
comptime の浮動小数点数 any
浮動小数点数
浮動小数点数
浮動小数点数 any

浮動小数点数 の場合。
comptime だと @mod(10.0, -3.0) がコンパイルエラーなのに、非 comptime だと普通に結果がもらえるのは、たぶんバグなんだと思う。

zig0.9.1
var a: f32 = 10;
var b: f32 = -3;
try stdout.print("@divFloor({d}, {d}) = {d}\n", .{ a, b, @divFloor(a, b) });
//=> @divFloor(10, -3) = -4

try stdout.print("@mod({d}, {d}) = {d}\n", .{ a, b, @mod(a, b) });
//=> @mod(10, -3) = 1

try stdout.print("@divFloor(a, b) * b + @mod(a, b) = {d}\n", .{(@divFloor(a, b) * b) + @mod(a, b)});
//=> @divFloor(a, b) * b + @mod(a, b) = 13

と、 @divFloor(a, b) * b + @mod(a, b)a に戻らない。

usingnamespace

やけに長い綴りなのは、あんまり使うなというメッセージなんだと思う。

@import などでアクセス可能にした名前へのアクセスを容易にする機能。C++ の using namespace と同様、浅慮で使うと迷惑なやつだけど、C++ と違って「ヘッダファイルに書く」ができない(頑張ればできるのかな?)のであんまり困らないかも。

comptime 再び

zig の要だと思う。

前回書き足りてないのでもう一回書いておく。

関数引数の comptime は、

  • 呼ぶ側は、引数の値がコンパイル時に確定していなければならない。そうでなければコンパイルエラー。
  • 関数定義側は、その値はコンパイル時に確定している値として使える。

という意味。
C++ の constexpr と似ているが、 constexpr と違って「変えられないよ」という気持ちはない。コンパイル時に確定していなければならないけど、コンパイル時であれば変えられる。

zig0.9.1
fn hoge(comptime a: u32) !void {
    comptime var b = a;
    try stdout.print("b is {} -> ", .{b});
    b += 1;
    try stdout.print("{}\n", .{b});
}

pub fn main() !void {
    try hoge(1); //=> b is 1 -> 2
}

comptime var b = a; が面白かったので、色々試した。

定義 コンパイル可能?
comptime const a: comptime_int = 1; エラー(comptime const は冗長)
comptime const a: i32 = 1; エラー(comptime const は冗長)
comptime var a: comptime_int = 1;
comptime var a: i32 = 1;
comptime a: comptime_int = 1; エラー(文法おかしい)
comptime a: i32 = 1; エラー(文法おかしい)
const a: comptime_int = 1;
const a: i32 = 1;
var a: comptime_int = 1; エラー(実行時に変更するらサイズ確定させろ)
var a: i32 = 1;

これを見ると

  • varconst のいずれかが必須。
  • comptime_int 型を var にするなら comptime が必要。
  • comptime const は冗長でエラー。

という感じかな。

comptime は、コンパイル時に値が決まるということ。
コンパイル時にわかっている値ならば、それを根拠に型を決めることもできる。
なので。

zig0.9.1
fn fuga(comptime a: u32) !void {
    const t = [_]type{ u8, u9, u10, u11 };
    const m: t[a] = 0;
    try stdout.print("(a, m)=({}, {})", .{ a, ~m });
}

fn hoge(comptime a: u32) !void {
    comptime var b = a;
    try fuga(b);
    try stdout.print(" -> ", .{});
    b += 1;
    try fuga(b);
    try stdout.print("\n", .{});
}

pub fn main() !void {
    try hoge(0); //=> (a, m)=(0, 255) -> (a, m)=(1, 511)
    try hoge(2); //=> (a, m)=(2, 1023) -> (a, m)=(3, 2047)
}

「型の配列」を作って、引数で渡ってきた値をインデックスにして決めた型で変数作る、なんたてこともできる。

再帰呼び出しを使った結果を使って型を決めるなんてこともできる。
ループを使う場合は

zig0.9.1
fn fuga(comptime a: u32) type {
    const t = [_]type{ u8, u9, u10, u11 };
    return t[a % t.len];
}

pub fn main() !void {
    comptime var a = 0;
    comptime var i = 0;
    inline while (i < 10) {
        a += @popCount(u32, i);
        i += 1;
    }
    comptime var v: fuga(a) = 0;
    try stdout.print("~v={}\n", .{~v});
}

ループに inline 指定が必要。なるほどループアンロール。

comptime はブロックを形容することもできる。

zig0.9.1
fn fuga(comptime a: u32) type {
    const t = [_]type{ u8, u9, u10, u11 };
    return t[a % t.len];
}

pub fn main() !void {
    const x = comptime blk: {
        var a = 0;
        var i = 0;
        inline while (i < 10) {
            a += @popCount(u32, i);
            i += 1;
        }
        var v: fuga(a) = 0;
        break :blk v;
    };
    try stdout.print("~x={}\n", .{~x});
}

ブロックなので、中の値が外から見えない。中の値を利用したい場合はブロックの値を返してもらうのがいいね。
ブロック外で定義しておいて上書きでもいいけど。

コンパイル時に自由に計算できて、計算結果として型を返せるんだからだいたいなんでもできる。
強い。

組み込み関数

@ で始まるので、言語提供側は、ユーザーコードとの衝突を心配せずに自由に増やせる

@TypeOf のような大文字スタートは型を返す。
小文字スタートは @popCount のように値を返したり、@setCold のように何も返さなかったりする。

というルールだと思う。

面白かったもの、よく使いそうなものを適当にピックアップする。

@addWithOverflow

昔から、アセンブラで加算するとオーバーフローフラグがもらえるのに、アセンブラ以外の言語で加算したらオーバーフローフラグ見えないよなぁと思っていた。

この関数は、オーバーフローフラグをもらえる加算命令。

役に立つ場面は稀だと思うけど、面白い。
乗算とかもある。

@as

キャスト。
@as(u16, foo()) のように使う。
安全なキャストしかできないっぽい。

@call

関数を呼ぶ。
ただ呼ぶなら普通に呼べばいいんだけど、 @call を使うと「末尾再帰しろ(できなかったらコンパイルエラー)」とか「インライン展開しろ(できなかったらコンパイルエラー)」とか色々できる。

@cmpxchgStrong

compare-exchange してくれる。 @cmpxchgWeak ってのもあって、Weak でいいなら Weak が速いからおすすめだよ、と書いてあるんだけど、だいたい Strong の方を使いそうな気がする。

@compileError

コンパイルエラーを起こすが、C/C++ の #error とも static_assert(false,"略") とも違う。

zig0.9.1
const x: u32 = 1;
if (x == 0) {
    @compileError("hoge"); // 到達しないのでエラーにならない
}

と、到達しなければエラーにならない。C++ の static_assert の振る舞い

c++17とか
constexpr int x = 1;
if constexpr (x == 0) {
    static_assert(false, "hoge"); // 到達しないけどエラー
}

とは異なる。

@divExact

コンパイル時なら、割り切れないとコンパイルエラー。それはわかる。
実行時だと、割り切れなかったらパニック。エラーではなく、パニック。恐ろしい。

同様に @shrExact なんかもある。

@embedFile

別のファイルの中身を文字列というか、バイトの配列にする。
//go:embed の文法よりも好み。

@maximum@minimum

まあ c++ の std::max なんかと同じだよねと思うとそうでもない。

「一方が非数でもう一方が非数でない場合、非数でない方の値を返す」という思いがけない仕様になっている。
それは非数の方を返すのが正解じゃないの、と思った。どうだろ。

@prefetch

プリフェッチする。
zig 以外の言語だと、コンパイラが気を使って発行するかアセンブラで書くしかなかったわけだけど、zig ではなんとユーザーが普通に書ける。面白い。

状況によってはパフォーマンスを相当上げることができるんじゃないかと思うけれど、そんな場面は稀だろうとも思う。

数学関数

@sqrt @sin @log10 他、色々ある。

ライブラリ関数にしなかったのはなぜなんだろう。

ビルドモード

4つある。たぶん、下表の通り。

ビルドモード 安全 最適化
Debug ほぼ無し
ReleaseFast 速度優先
ReleaseSafe バランス?
ReleaseSmall サイズ優先

たぶん、 ReleaseSafe でビルドして、要所要所を @setRuntimeSafety(<Debug 時のみ true>) とするのがよいんだと思うけど、まだ Debug 時のみ true を書く方法がわかっていない。

安全チェック無効でバランスにする方法とか、安全性チェック有効で速度優先にする方法とかは、わからない。

Undefined Behavior

UB は、ある。
あるんだけど、安全チェックが有効ならパニックで死ぬ模様。
オーバーフローとかも普通に死ぬので助かる。

メモリ

GC は無い。
無いので、OS のない世界でも動く。なんなら動的メモリ確保がない世界でも動くんだと思う。この辺りは C/C++ と立場が同じで、go なんかとは違う。

C なら mallocmalloc を使っている何かを使えばいいんだけど、 zig にはもっと色々ある。

アロケータ 特徴
std.heap.c_allocator libc にあるやつ。malloc と同じということかな。
std.heap.FixedBufferAllocator 最大値がコンパイル時にわかっている場合で、シングルスレッド。
std.heap.ThreadSafeFixedBufferAllocator 最大値がコンパイル時にわかっている場合で、スレッドセーフ。
std.heap.ArenaAllocator まとめて全部開放できる。
std.heap.GeneralPurposeAllocator 何使えばいいか考えるのがめんどくさいときはこれ。

C++ の vector なんかにはアロケータを指定する I/F があるけど「たとえばこんなアロケータがあるよ」は、無い(よね?)。zig は用意してくれていて便利だと思う。

go は、ローカル変数へのポインタを返すと、ヒープへのポインタにしてくれるという魔法みたいな機能があるけど、 zig にはそういうものはないし、それをチェックする仕組みもない。

zig0.9.1
fn hoge(x: anytype) *@TypeOf(x) {
    var i = x;
    return &i;
}

pub fn main() !void {
    var a = hoge(@as(u8, 123));
    try stdout.print("a.*={}\n", .{a.*});
    // a.*=123

    var b = hoge(@as(f16, 123));
    try stdout.print("a.*={}, b.*={}\n", .{ a.*, b.* });
    // a.*=87, b.*=1.23e+02

    var c = hoge("hoge");
    try stdout.print("a.*={}, b.*={}, c.*={s}\n", .{ a.*, b.*, c.* });
    // a.*=0, b.*=0.0e+00, c.*=hoge
}

C/C++ と同様、普通にぶっ壊れる。

せめて debug 時の実行時だけでも検出して死んでほしかったなぁと思う。関数ローカルなメモリに色を付けてコンパイル時に、あるいは実行時に検出するってのはできそうな気がするんだけど、そうでもないのかなぁ。

少なくともこの点については、go の方がずっと安全。すばらしい。
とはいえ、C/C++ に慣れている人はこの罠にはかかりにくいと思う。

書式

go と同様、言語ベンダが書式を決めてくれている。素晴らしい。

  • スペース四個でインデント。タブではない。
  • 1行は 100文字を超えないのを目安に。

なんだけど、自動整形が on なら勝手にやってくれると思う。

シンボルは

  • 大文字スタート CamelCase
    • 型・型を返す関数(として使えるもの)
  • 小文字スタート camelCase
    • 型を返さない関数(として使えるもの)
  • 小文字スタート snake_case
    • 型ではない値
    • 名前空間として使われる型

ということらしい。大文字 SNAKE_CASE は使わないのかな。

コメント

go と同様、書式は緩め。引数をこう書けとか、そういう感じではない。

たとえば、 std/fs/path の join はこうなっている。

zig
/// Naively combines a series of paths with the native path seperator.
/// Allocates memory for the result, which must be freed by the caller.
pub fn join(allocator: Allocator, paths: []const []const u8) ![]u8 {

文字コード

ここでも意外な対応が。

UTF-8 必須とか、U+2029 (PARAGRAPH SEPARATOR)みたいな変な文字があったらエラーとか、そのあたりはいい。というか、こういうことが書いてあるのが素晴らしい。

驚いたのは、CR+LF 非推奨と明言していること。禁止じゃないけど非推奨。CR は、自動整形ツールに殺されるんだと思う。試してないけど。

UTF-8 BOM があったらどうなるんだろ。これも試してない。

ビルドシステム

go の重大なアドバンテージとして、 mac上で windows用・arm7-linux用・arm64-linux用 などのバイナリが簡単に作れるというものがあった。
C/C++ でもちろんできるんだけど、何をビルドするにはどこにある何をインストールすればいいのかは、わりと難しい。

zig の場合、まだあんまり使ってないのでよくわかってないけど、 go 以上にその辺りが便利になっている模様。

とりあえず

shell
$ zig init-exe 

を実行すると、

directory-tree
.
├── build.zig
└── src
    └── main.zig

となる。この build.zig が言ってみれば makefile というか CMakeLists.txt というか、そういう趣旨のファイルになっている。

(makefile や CMakeLists.txt と違って) 拡張子を見て分かる通り、これは zig のソースコードになっている。ビルドシステム用の文法を新たに覚える必要がない。

この状態で、 build.zig のあるディレクトリで

$ zig build -Dtarget=x86_64-macos-gnu -p x64mac # for x86_64 macOS
$ zig build -Dtarget=aarch64-macos-gnu -p m1mac  # for Apple M1 macOS
$ zig build -Dtarget=arm-linux-gnueabi -Dcpu=cortex_a53 -p rp3b+32 # for Raspberry Pi 3B+ with 32bit linux
$ zig build -Dtarget=x86_64-windows-gnu -p x64win # for x86_64 Windows

などとすればクロスビルドができる。
それらしいファイルはできている。実行して確認してないけど。

go と違うのは、コード生成が挟まっていたりしたら build.zig に書けばいいという点。文書生成なんかも必要なら build.zig に書いて zig コマンドでビルドできたりする。んだと思う。

C/C++ 言語との関わり

go の場合。cgo という仕組みで C言語との関わりを持つ。go の側が GC有りで C言語側がGC無しなのでそこはユーザーが頑張らないと死ぬんだと思うけど、それさえちゃんとやれば便利に使える。まだ必要な場面に遭遇してないのでちゃんと使ったこと無いけど。

一方 zig は、zig が C/C++ をコンパイルする。
なんだかよくわからないんだけど、ユーザーからは zig が C/C++ コンパイラを内蔵しているように見える感じになっている。

実際

directory-tree
.
├── build.zig
└── src
    └── main.cpp

として、 build.zig

build.zig
const std = @import("std");
const Builder = std.build.Builder;

pub fn build(b: *Builder) void {
    const target = b.standardTargetOptions(.{});
    const mode = b.standardReleaseOptions();

    const exe = b.addExecutable("main", null);
    exe.setTarget(target);
    exe.setBuildMode(mode);

    exe.addCSourceFile("src/main.cpp", &[_][]const u8{"-std=c++17"});
    exe.linkLibCpp();
    exe.install();

    const run_cmd = exe.run();
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

などとした上で先程と同様

$ zig build -Dtarget=x86_64-macos-gnu -p x64mac # for x86_64 macOS
$ zig build -Dtarget=aarch64-macos-gnu -p m1mac  # for Apple M1 macOS
$ zig build -Dtarget=arm-linux-gnueabi -Dcpu=cortex_a53 -p rp3b+32 # for Raspberry Pi 3B+ with 32bit linux
$ zig build -Dtarget=x86_64-windows-gnu -p x64win # for x86_64 Windows

のようにすると、C++ のクロスビルドができる。
exe.addCSourceFile の第2引数で -std=c++17 などの指定もできる。

一方。
go などがやっている、サーバー上の git リポジトリとのスムーズな連携とか、ruby などがやっている、ライブラリをサーバで公開する方法の標準とかは無い模様。
残念なような、それほどでもないような。

たらい回し

のツイートをするために、初めて仕様確認以外の目的で zig のコードを書いた。

苦しんだのは、コマンドライン引数を整数に変換する部分。

コマンドライン引数にある 15 のようなものを i32 として評価したいと思っただけなんだけど、わりとめんどくさかった。

zig0.9.1
const std = @import("std");
const process = std.process;

としておいて、

zig0.9.1
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const a = gpa.allocator();
const args = try process.argsAlloc(a);
defer process.argsFree(a, args);
const i = try std.fmt.parseInt(i32, args[1], 10);

と、わりと書くことが多い。なんで allocator が必要なのかがよくわからないと思ってソースを見てみたら案の定 Windows 対策。
UTF-16LE で渡ってくる引数を UTF-8 に変換するためにメモリが必要っぽい。なるほど。
環境非依存にするために、macOS などで使う場合も巻き込まれて allocator が引数としては必要になる。実際には macOS ではヒープは使ってないんじゃないかな。

逆に言うと。動的メモリ確保をユーザの知らないところでやるなんて許されないということだと思う。

まだわかってなくて気になること

まだわかってなくて気になることを、今思いつく範囲で書くと。

  • パニックからリカバーできるかどうか。
  • 標準で使えるコンテナ型にどんな物があって、どう使うか。
  • f().* = g(a)+h(i(b)+j(c)); みたいな場合の関数 f, g, h, i, j の評価順序が決まっているのかどうか。
  • 文書に項目だけあって中身がない Atomic などのこと。

まあいずれも調べればわかるんじゃないかと思う。

まとめ

全体的には、C言語がやりたかったことを現代の知見で実現するという気持ちを感じる。
言語組み込みのコンテナが全然ないとか、ユーザに無断で動的メモリ確保しない努力とか、その辺り。

C++ は「ユーザー定義型と組み込み型の差を少なくする」という野望を掲げた結果、ユーザー定義型の値が振る舞いを持つ必要が生じ、例外が必要になるというゴールに到達したという理解なんだけど。

zig は逆に例外は入れたくないのでユーザー定義型を組込型と同じように振る舞わせるのは諦めるという選択をしたんじゃないかなぁと想像する。

言語仕様としては大規模だけど、やりたいことは C言語でできること。
C言語っぽいという範囲内で、コンパイル時にやれることをたくさんやる。
実行時の振る舞いも C言語っぽくシンプルに。
という選択なんだと思う。

型演算し放題とか、エラーユニオン周辺とか、わりと楽しそう。
もうちょっと遊んでみる予定。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2