Closed8

Zigの言語仕様で面白いと思ったところ

yubrotyubrot

Zigは汎用プログラミング言語で、いわゆる静的型付けのシステムプログラミング言語を志向した特徴を多く備える。特徴としてはRustなどに近いところがあるが、Zigには明確にターゲットとしているところがあり、そういった背景は以下の公式の記事に短くまとまっている:

以下、筆者自身がRustに慣れ親しんでいるのもあり、Rustと比較しつつLanguage Referenceの面白いところを取り上げていく。

yubrotyubrot

comptimeとinline

Zigの言語仕様でも特にインパクトが大きい特徴に comptime がある。comptimeはcompile-timeの略で、計算がランタイムではなくコンパイル時に行われることや、コンパイル時に既知であることを示す予約語となっている。

Zigはプログラムの随所にコンパイル時の計算が出てくる。最初のHello, Worldの例からしてそう:

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.print("Hello, {s}!\n", .{"world"});
}
  • @import はパッケージをインポートするための専用の構文ではなく、ビルトイン関数である。この関数がコンパイル時に計算され、stdパッケージをビルド対象に加えてstructを返す (Zigのソースファイルはstructに対応する)。
  • Rustでは print! のようなマクロで実現されている文字列のフォーマット出力 print もZigでは単に関数である。このフォーマット文字列もコンパイル時に計算される。

printformat 関数にフォワードされていて、 format 関数は以下のように実現されている:

// フォーマット文字列 `fmt: []const u8` をコンパイル時引数として受け取っている
pub fn format(
    writer: anytype,
    comptime fmt: []const u8,
    args: anytype,
) !void {
    // 埋め込まれるデータ (ex. `.{"world"}`) のstructの構造情報を、
    // `@TypeOf` `@typeInfo` といったビルトイン関数でコンパイル時に取得して扱っている
    const ArgsType = @TypeOf(args);
    const args_type_info = @typeInfo(ArgsType);
    // ifは条件式がcomptime-knownであるため、コンパイル時に展開される
    if (args_type_info != .Struct) {
        @compileError("expected tuple or struct argument, found " ++ @typeName(ArgsType));
    }

    ...

    // constは右辺の式によって暗黙にcomptimeとなるが、varもまたcomptimeを明示してコンパイル時に計算される変数とできる
    comptime var arg_state: ArgState = .{ .args_len = fields_info.len };
    comptime var i = 0;
    // inline whileによって、ループがコンパイル時にインライン展開される
    inline while (i < fmt.len) {

        const start_index = i;

        inline while (i < fmt.len) : (i += 1) {
            switch (fmt[i]) {
                '{', '}' => break,
                else => {},
            }
        }
        ...
    }
    ...
}

comptimeの動作はC++やDのtemplateに近いように感じるが、コンパイル時に扱う専用の構文が無く comptime inline といった予約語が出てくるのみな点が自分には目新しかった。

Rustでgenericsといった言語機能で実現されるパラメトリック多相も、Zigではcomptimeで実現される。Zigにおいて型とは単に type 型の値でしかなく、いわゆる型の式と一般的な式に区別がない。例えば標準の ArrayList は以下のように実装される:

pub fn ArrayList(comptime T: type) type {
    return ArrayListAligned(T, null);
}

pub fn ArrayListAligned(comptime T: type, comptime alignment: ?u29) type {
    if (alignment) |a| {
        if (a == @alignOf(T)) {
            return ArrayListAligned(T, null);
        }
    }
    return struct {
        ...
    };
}

型はcomptimeな引数として取り、 struct によって型を作って返却する。

ただし、ランタイムに type 型の値が存在するわけではない。 comptime 修飾のない type 型の使用は以下のようなコンパイルエラーが出る:

src/lib.zig:11:10: error: parameter of type 'type' must be declared comptime
fn hello(_: type) void {}
         ^~~~~~~
src/lib.zig:11:10: note: types are not available at runtime

inlineはswitchのパターン展開にも使えたりする。

const SliceTypeA = extern struct {
    len: usize,
    ptr: [*]u32,
};
const SliceTypeB = extern struct {
    ptr: [*]SliceTypeA,
    len: usize,
};

// いくつか異なる表現のスライスを取れるTagged unionについて...
const AnySlice = union(enum) {
    a: SliceTypeA,
    b: SliceTypeB,
    c: []const u8,

    fn len(self: AnySlice) usize {
        return switch (self) {
            // inline elseによって各tagについての処理をインライン展開する
            inline else => |s| s.len,
            // これは以下と同様:
            // .a => |s| s.len,
            // .b => |s| s.len,
            // .c => |s| s.len,
        };
    }
};
yubrotyubrot

算術関連の演算子

Rustでは、多くの言語で一般的な +, -, *, /, % といった演算子が定義されたうえで、 checked_add wrapping_add saturating_add といったメソッドが提供されているが、Zigでは + の他に +% +| といった専用の演算子が用意されている。

yubrotyubrot

Sentinel-Terminated Arrays/Pointers/Slices

const zero_terminated_array: [3:0]i32 = .{ 1, 2, 3 };
const zero_terminated_pointer: [*:0]i32 = &zero_terminated_array;

C言語のNULL終端文字列のような、終端が特定の値で表されるデータに対する専用の型が設けられている。C言語を意識するとこういう型を言語レベルで提供したほうが良いのか

yubrotyubrot

Optional

Optional型は ?T で表す。 null が値がないことを示す。

  • Optional pointer ?*T は内部的には(NULLを許容する)単なるポインタになる
  • a orelse b でRustの Option::unwrap_or_else 相当
  • a.? == a orelse unreachable
  • if (nullable_value) |nonnull_value| .. else ..
  • while (nullable_value) |nonnull_value| ..
    • Zigは変数をキャプチャするところの構文が |var..| .. で統一されているっぽい

Option型のような機能は、言語によって専用の構文を提供したり、あくまで標準ライブラリでデータ型として提供したりとまちまちな印象があるが、Zigは言語レベルで手厚いサポートをする言語デザインとなっている。(Rustはpostfix-?によるearly returnが楽に書ければ良しという言語デザイン)

yubrotyubrot

Error Handling

Zigのエラーハンドリングは結構割り切ったデザインになっている。割り切っているというより、Allow returning a value with an error #2647 のようなissueを見るに発展途上といえるかもしれない。

Zigのエラーは端的に言えば1以上の整数である。それぞれのエラーにユニークな整数が割り当たっている。エラーの同一性もエラー名によって判断されるため (エラーに限らず、Zigの型の比較は基本structural)、OCamlのPolymorphic variantsのタグ部分がかなり近いイメージになる。

エラーを返し得る型(Error union type)は errset!T で表される。 errset はError set type、取り得るエラーの集合を表現する型となっている。例えば以下のように記述できる:

const ParseU64Error = error {
    InvalidChar,
    OverFlow,
};

pub fn parseU64(buf: []const u8, radix: u8) ParseU64Error!u64 {
    var x: u64 = 0;
    for (buf) |c| {
        const digit = charToDigit(c);
        if (digit >= radix) return error.InvalidChar;

        // x *= radix
        var ov = @mulWithOverflow(x, radix);
        if (ov[1] != 0) return error.OverFlow;

        // x += digit
        ov = @addWithOverflow(ov[0], digit);
        if (ov[1] != 0) return error.OverFlow;
        x = ov[0];
    }
    return x;
}

errset はZigコンパイラが推論してくれるため省略できる。そのため、 parseU64 は以下のように記述でき、この場合 ParseU64Error の定義も省略できる:

pub fn parseU64(buf: []const u8, radix: u8) !u64 {
    ...
}

いろいろ:

  • anyerror はグローバルな全てのエラーからなるError set type
    • あらゆるError set typeのsupertype
  • error.FileNotFound(error { FileNotFound }).FileNotFound の糖衣構文
    • 上の例も、それぞれの単体のエラーが返値のError set typeへとcoercionされている
  • a catch b でRustの Result::unwrap_or_else 相当
  • a catch |err| b でエラーを取れる
  • if (failable_expr) |noerror_expr| .. else |err| ..
  • while (failable_expr) |noerror_expr| .. else |err| ..
  • try ...... catch |err| return err の糖衣構文
yubrotyubrot

メモリ管理

Zigはプログラマに代わってメモリ管理をすることがない。メモリ管理は常にプログラマの責務で、それゆえにランタイム無しに動作し、多くの環境で動作するとしている。

メモリ管理を手動で行うメジャーな言語としてC言語があるが、C言語は何らかのアロケータをデフォルトとして malloc free が使われるのに対し、Zigではアロケータも常に明示的に扱う存在となっている。例えば標準の ArrayListinit でアロケータを引数に取る (アロケートされた領域を解放する deinitdefer 文で遅延している)

test "ArrayList" {
    var list = std.ArrayList(i32).init(std.testing.allocator);
    defer list.deinit();
    try list.append(12);
    try list.append(34);
    try std.testing.expectEqual(list.items.len, 2);
}

Allocator 型の定義がある Allocator.zig を少し覗いてみる:

Allocator.zig
//! The standard memory allocation interface.
...
pub const Error = error{OutOfMemory};
...

// The type erased pointer to the allocator implementation
ptr: *anyopaque,
vtable: *const VTable,

pub const VTable = struct {
    /// Attempt to allocate exactly `len` bytes aligned to `1 << ptr_align`. ...
    alloc: *const fn (ctx: *anyopaque, len: usize, ptr_align: u8, ret_addr: usize) ?[*]u8,
    /// Attempt to expand or shrink memory in place. ...
    resize: *const fn (ctx: *anyopaque, buf: []u8, buf_align: u8, new_len: usize, ret_addr: usize) bool,
    /// Free and invalidate a buffer. ...
    free: *const fn (ctx: *anyopaque, buf: []u8, buf_align: u8, ret_addr: usize) void,
};

Allocator.zig 自身がstructの定義になってる。現在のZigにRustのtraitのような言語機能はないため、Allocatorの仮想関数テーブルの表現が単にstruct VTable で定義されている。

このスクラップは2023/02/12にクローズされました