cv.cm

コーディング · プログラミング言語

Zig入門:初心者向けチュートリアル

約11分で読めます

結論

Zigは、Cの現代的な代替を目指した、汎用のシステムプログラミング言語兼ツールチェーンです。隠れた制御フローも、ガベージコレクターも、隠れたメモリ確保もありません。アロケーターを明示的に渡し、エラーはエラー共用体とtryで扱い、後始末はdeferで行います。コンパイル時のコード実行(comptime)が、マクロとジェネリクスに代わります。ファイルはzig run hello.zigで実行します。Zigはまだバージョン1.0より前なので、言語と標準ライブラリの細部はリリースごとに変わります。

Zigとは何か、誰が使うのか

ZigはAndrew Kelleyが作り、非営利団体のZig Software Foundationが開発しています。Cと同じ領域、つまりOSの構成要素、組み込みファームウェア、ゲームエンジン、データベース、高速なコマンドラインツールを対象にしています。ZigコンパイラはCやC++のコードもコンパイルでき(zig cc)、多くのターゲットへクロスコンパイルもできるため、Cのツールチェーンとしてだけ使うプロジェクトもあります。

Zigで書かれた有名なプロジェクトには、JavaScriptランタイムのBun、金融データベースのTigerBeetle、ターミナルエミュレーターのGhosttyがあります。

Zigのインストールとhello, worldの実行

ziglang.org/downloadから自分のシステム向けのビルド済みアーカイブをダウンロードし、展開して、フォルダーをPATHに追加するか、システムのパッケージマネージャーでZigをインストールします。別のインストーラーやランタイムはありません。

// hello.zig
const std = @import("std");
pub fn main() void {
    std.debug.print("Hello, world!\n", .{});
}
zig run hello.zig         # compile and run
# Hello, world!
zig build-exe hello.zig   # produce an executable named hello
zig init                  # create a project with build.zig

std.debug.printは標準エラー出力に書き込み、最近のどのバージョンでも最も簡単な出力方法です。2番目の引数は、書式のプレースホルダーに渡す値のタプルで、.{}は空のタプルです。標準出力に書き込むAPIはリリースごとに変わっているので、お使いのバージョンの標準ライブラリのドキュメントを確認してください。

変数と基本の型

constは変わらない値を、varは変更可能な変数を宣言します。コンパイラは厳格で、使われていないローカル変数はエラーになり、一度も変更されないvarもエラーになります。そのため、デフォルトではconstを使いましょう。

型例補足
符号付き整数i8, i32, i64i7のように、任意のビット幅を使えます
符号なし整数u8, u32, usizeusizeはポインターと同じサイズで、長さや添字に使います
浮動小数点数f32, f64IEEE 754
booltrue, false整数からの暗黙の変換はありません
配列[3]u8長さが固定で、コンパイル時にわかります
スライス[]const u8ポインターと長さの組。文字列はバイトのスライスです
オプショナル?i32値またはnull
エラー共用体!u32値またはエラー
const std = @import("std");
pub fn main() void {
    const age: u32 = 30;
    var count: i32 = 0;
    count += 1;
    const name = "Ana"; // string literal, coerces to []const u8
    std.debug.print("{s} is {d}, count = {d}\n", .{ name, age, count });
    var maybe: ?i32 = null;
    maybe = 5;
    const value = maybe orelse 0;
    std.debug.print("value = {d}\n", .{value});
}

書式のプレースホルダーは、出力のしかたを指定します。数値は{d}、文字列は{s}、1バイトを文字として出力するには{c}、任意の値のデバッグ出力には{any}です。

制御構文

if、switch、while、forは、どれも式として使えます。switchは取りうるすべての値を網羅しなければならないので、たいていはelseの分岐を加えます。

const std = @import("std");
pub fn main() void {
    const n: i32 = -2;
    const sign: []const u8 = if (n > 0) "positive" else if (n < 0) "negative" else "zero";
    const score: u8 = 85;
    const letter: u8 = switch (score) {
        90...100 => 'A',
        80...89 => 'B',
        else => 'C',
    };
    std.debug.print("{s} {c}\n", .{ sign, letter }); // negative B
    var i: u32 = 0;
    while (i < 3) : (i += 1) {
        std.debug.print("{d} ", .{i}); // 0 1 2
    }
    const items = [_]u8{ 10, 20, 30 };
    for (items, 0..) |item, index| {
        std.debug.print("\n{d}: {d}", .{ index, item });
    }
    std.debug.print("\n", .{});
}

[_]u8{ ... }と書くと、コンパイラが配列の長さを数えてくれます。for (items, 0..)は、要素とカウンターを同時にたどります。縦線の間の値はキャプチャと呼ばれます。

関数とcomptime

関数は、パラメーターの型と戻り値の型を宣言します。引数は関数の内部では変更できません。ジェネリックなコードはcomptimeパラメーターで書きます。型はコンパイル時に存在する値なので、関数は型を引数として受け取れます。

const std = @import("std");
fn add(a: i32, b: i32) i32 {
    return a + b;
}
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}
pub fn main() void {
    std.debug.print("{d}\n", .{add(2, 3)});            // 5
    std.debug.print("{d}\n", .{max(i32, 3, 7)});       // 7
    std.debug.print("{d}\n", .{max(f64, 1.5, 0.5)});   // 1.5
}

コンパイラは、maxが呼ばれた型ごとに、別々のバージョンを作ります。標準ライブラリのジェネリックなデータ構造も同じ仕組みで作られています。型を受け取り、新しい構造体型を返す関数です。

エラー共用体・try・deferによるエラー処理

エラーは値です。戻り値の型!u32は「u32またはエラー」を意味します。try exprはエラーがあれば呼び出し元にそのエラーを返し、catchはその場で処理します。エラーを黙って無視することはできません。

const std = @import("std");
fn parseAge(s: []const u8) !u32 {
    const n = try std.fmt.parseInt(i32, s, 10);
    if (n < 0) return error.Negative;
    return @intCast(n);
}
pub fn main() void {
    const age = parseAge("-5") catch |err| {
        std.debug.print("error: {s}\n", .{@errorName(err)});
        return;
    };
    std.debug.print("age {d}\n", .{age});
}
// error: Negative

deferは現在のブロックを抜けるときに文を実行し、errdeferはブロックがエラーで抜けるときだけ実行します。これにより、後始末のコードを、リソースを取得するコードの隣に置けます。

メモリとアロケーター

Zigには、ガベージコレクターも、隠れたグローバルなアロケーターもありません。ヒープメモリを必要とする関数はstd.mem.Allocatorパラメーターを受け取るので、メモリをどこから取るか(ページアロケーター、アリーナ、固定バッファ、リークを検出するデバッグ用アロケーターなど)は呼び出し側が決めます。

const std = @import("std");
pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const buf = try allocator.alloc(u32, 5);
    defer allocator.free(buf);
    for (buf, 0..) |*slot, i| {
        slot.* = @intCast(i * i);
    }
    std.debug.print("{any}\n", .{buf}); // { 0, 1, 4, 9, 16 }
}

|*slot|は各要素へのポインターをキャプチャするので、ループから書き込めます。slot.*はポインターを参照外しします。DebugとReleaseSafeのビルドでは、整数のオーバーフローや範囲外の添字アクセスは、未定義動作ではなく、わかりやすいパニックでプログラムを止めます。

小さな完成プログラム:数値の平均

const std = @import("std");
fn average(numbers: []const i64) ?f64 {
    if (numbers.len == 0) return null;
    var sum: i64 = 0;
    for (numbers) |n| sum += n;
    const total: f64 = @floatFromInt(sum);
    const len: f64 = @floatFromInt(numbers.len);
    return total / len;
}
pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const buf = try allocator.alloc(i64, 16);
    defer allocator.free(buf);
    var it = std.mem.tokenizeScalar(u8, "4 8 15 16 23 42", ' ');
    var count: usize = 0;
    while (it.next()) |word| : (count += 1) {
        buf[count] = try std.fmt.parseInt(i64, word, 10);
    }
    if (average(buf[0..count])) |avg| {
        std.debug.print("count={d} avg={d:.2}\n", .{ count, avg });
    }
}
// count=6 avg=18.00

このプログラムは、文字列を空白で分割し、各部分をtryで解析し、数値をdeferで解放するヒープメモリに保存して、空のリストではnullになるオプショナルの平均を返します。

次に学ぶこと

  • ziglang.org/documentationにある言語リファレンス。インストールしたバージョンのページを選びます。
  • ziglang.orgのLearnセクション。「Getting Started」と言語の概要があります。
  • 壊れた小さなプログラムを1つずつ直していくZiglingsは、人気のあるコミュニティの練習問題集です。
  • 標準ライブラリのソースを読む。Zigで書かれており、現在のAPIの最良のリファレンスです。

よくある質問

Zigは本番で使える段階ですか?

本番でZigを使っている企業もありますが、言語は1.0より前なので、コンパイラをアップグレードすると互換性のない変更が入るものと想定し、その対応の時間を見込んでおく必要があります。

ZigはCやRustの代わりになりますか?

ZigはCに最も近く、手動のメモリ管理に、より良い安全性チェック、エラー処理、コンパイル時のコードを加えたものです。Rustは所有権システムと借用チェッカーでメモリ安全性を保証しますが、Zigにはそれがありません。

ZigからCライブラリを使えますか?

はい。ZigはCのヘッダーファイルをインポートしてC関数を直接呼び出せ、ZigコンパイラはCやC++のソースファイルもビルドできるため、言語を混ぜるのは簡単です。

Zigのcomptimeとはどういう意味ですか?

comptimeは、コンパイル中に評価されるコードやパラメーターを示します。Zigは、別のマクロ言語の代わりに、これをジェネリクス、定数テーブル、コンパイル時チェックに使います。

コーディング講座の一覧