HN 日本語サマリー

← 一覧へ戻る
プログラミング

RustからZigを使ってみた感想

What Zig felt like, coming from Rust (besok.github.io)

246 pointsby ksec296 コメント

要約

長年Rust開発者として活動してきた著者が、Zig言語を試した経験を共有する記事です。IDEサポートの不足が逆にCLIツールの活用を促し、Rustとは異なるフラットなファイル構造の利点を発見したこと、そしてZigの関数型プログラミングからの距離感について語っています。

全文翻訳

Rust開発者がZigを使ってみた感想

はじめに

私は過去7年間、主にオープンソースプロジェクトでRust開発者として活動しており、その言語とエコシステムにはかなりの習熟度があると自負しています。クリーンな関数、表現力豊かな型など、Rustの関数型寄りの側面に惹かれています。しかし、常に他の言語にも興味があり、ZigはC言語の後継候補として以前から注目していました。Zigはより低レベルで軽量であり、真剣に検討される言語としての地位を着実に確立しています。キャリアの初期にはC言語にも触れていたため、この比較は興味深いものになると感じていました。

最初に断っておくべきことがあります。私のZigの経験はこのプロジェクトから始まります。日頃からZigを使っている人にとっては、私の観察はナイーブで明白に聞こえるかもしれませんし、私が下したいくつかの決定は、Zigの深いイディオムよりもRustから引き継いだ習慣に影響された、最適とは言えないものであった可能性が高いです。それは構いません、誰でもどこかから始めるものですし、その間、私は長年培ってきた言語横断的な直感を、良くも悪くも頼りにしています。

公平な比較を行うために、私は既にRustで構築したものを再実装することにしました。それはおもちゃではなく、かといって広範囲なプロジェクトでもなく、理想的にはコミュニティが実際に利用できるものでした。私はJSONPathを選びました。これはRFC 9535で仕様化されたJSON用のクエリ言語です。Rust版は既に存在しており(jsonpath-rust)、目標はZigでも同じものを提供することでした(zig-jsonpath)。

IDEサポート

最初に意表を突かれたこと――正直なところ、これが記憶に残る部分になると誰が予想したでしょうか――それはIDEサポート、あるいはそのほとんど欠如でした。RustにはRustRoverを、他の言語には様々なJetBrains製品を使用していましたが、Zigはそれと比較して、シンタックスハイライトと基本的なオートコンプリート以上のものはほとんど提供されませんでした。驚くことではありませんでしたが、それは私を基礎に戻らせることを余儀なくしました。つまり、コマンドラインからほぼ完全に言語を操作する方法を学ぶことです。当初は欠点でしたが、経験のより興味深い部分の一つへと変わりました。単に、生のCLIツールに依存することがいかに簡単であるかを忘れていたことに気づいたのです。

ここで最初の本当の教訓は build.zig でした。これは驚くほど簡単にこれを処理します。私は最終的にこのセットアップに落ち着きました。

zig build test                                              # 全てのテストを実行
zig build test -Dfilter="filter match function basic"       # 一つのテストを実行
zig build test -Ddebug-query=true                           # デバッグ付きで全テストを実行
zig build compliance                                        # コンプライアンススイートを実行
zig build check                                             # 単体テスト + コンプライアンスを実行

これらの規約を受け入れてしまえば、作業するのは本当に爽快です。

間接的ではありますが、Zigには、より大きな連鎖反応、つまりフルIDEからの移行を促してくれたことに感謝しています。私はhelix + alacritty + zellijというセットアップに移行しました。

フラットな構造

Rustやほとんどの他の言語では、ファイルサイズとフォルダの深さの適切なバランスを見つけるために、かなりの時間を費やしてきました(行ったり来たりしながら)。ファイルは自由に分割でき、フォルダ階層を好きなだけ深く成長させることができます。Zigもこれを受け入れますが、なぜかそれをあまり奨励しません(低レベルシステム言語であるC言語も同様で、驚くことではありません)。必要であればファイルをネストしたりフォルダを深くしたりできますが、そうするとインポートに少し手間がかかり、本当の疑問は「なぜわざわざやるのか?」となります。ファイルやフォルダを分割することで、実際には可読性をどのように向上させるのでしょうか?理論上は、可読性の向上です。実際には、関連するものを一つの大きなファイルにまとめることで、セクションごとにスライスしてナビゲートでき、すべてが一箇所にあることには本当の利点があります。ほとんどの場合、Zigはフラットな構造を促します。モデルにコンパニオンが必要な場合は、隣に model_<companion> というファイルを作成して次に進みます。

これが大規模プロジェクトにスケールするとは思えません。つまり、ある時点では真の階層が必要になりますが、Zigではその必要となる閾値が予想よりもはるかに高いことが判明しました。Rustでは、デフォルトでフォルダ構造に早くから頼る傾向があります。Zigでは、それを遅らせ続け、このプロジェクトの終わりまで、一度も必要としませんでした。

この対比はZigだけでなく、他の言語でも、プロジェクトが本当に必要としているからフォルダ構造を使っているのか、それとも習慣なのかを再考するのに役立ちました。また、プロジェクトの実際の規模を測る正直な方法でもあります。初日からフォルダ構造に手を出さずにはいられないなら、それは感じているよりも小さいのかもしれません。

以下に、実際の違いを並べて示します。

Rust (src/):

src/
├── lib.rs
├── parser.rs
├── parser/
│   ├── errors.rs
│   ├── macros.rs
│   ├── model.rs
│   ├── tests.rs
│   └── grammar/
│       └── json_path_9535.pest
├── query.rs
└── query/
    ├── atom.rs
    ├── comparable.rs
    ├── comparison.rs
    ├── filter.rs
    ├── jp_query.rs
    ├── queryable.rs
    ├── segment.rs
    ├── selector.rs
    ├── state.rs
    ├── test.rs
    └── test_function.rs

Zig (src/):

src/
├── root.zig
├── parser.zig
├── model.zig
├── model_query.zig
└── query.zig

テスト

RFC 9535のコンプライアンススイートは一旦置いておき、純粋に言語自体に焦点を当てます。

Rustでは、テストには主に2つのアプローチを取る傾向があります。

  • インライン単体テスト:テスト対象のコードと同じファイルまたは同じフォルダ内に配置します。これは追加設定なしで常に利用できる便利なデフォルトです。
  • 統合テスト:メインのソースツリー外の独立したフォルダ(testsなど)に配置します。これはデフォルトではなく、例外であり、時には全く存在しないこともあります。

Zigでも同様の分割を期待していました。理論上は似ています。同じファイル内に直接テストを記述できます。問題は、少なくとも私にとっては、冗長性でした。既に定着していたフラットな構造を考えると、残された選択肢は、モデルごとに model_test ファイルを分けるか、モデルファイル自体に直接テストをインライン化するかのどちらかでした。どちらのアプローチも、個々のファイル、またはフォルダ全体を散らかす結果となりました。

私は後者のオプションを選択し、build.zig で明示的に設定する必要がありました。それが設定されてしまえば、うまく機能し、クリーンな状態を保てました。

全体として、テストの記述と管理はRustの方が簡単だと感じます。しかし、Zigの場合、その余分な手間の大半は言語固有のものであり、テストインフラストラクチャ自体ではなく、Zigの手動メモリ管理に起因します。

関数型パラダイムの欠如

Rustは技術的には命令型言語ですが、ゼロコストイテレータ、遅延評価、代数的データ型(ADT)、パターンマッチング、モナディック型、トレイト、クロージャなど、関数型コンセプトを強く引き継いでいます。HaskellやErlangにも触れた経験から、私は関数型スタイルにかなり傾倒しており、それがこのライブラリにも表れています。モナディックエラー制御、Data<T>のようなモナディックスタイルのデータ型、純粋な不変変換、イテレータ上のコンビネータ、クロージャ、宣言的マクロ、代数的データ型(Sum/Product型)など、FPイディオムに大きく依存しています。

Zigにはこれらすべてを持ち込めないことは承知していましたが、少なくとも中核的な概念は維持できると期待していました。実際、Rustが不変性やコンビネータに依存するのに対し、Zigはインプレースミューテーションと命令型世界に最も自然なパターンへと私を導きました。

両者が近いのは代数的データ型(Sum types)です。

Rustでは純粋かつ直接的です。

pub trait Query {
    fn process<'a, T: Queryable>(&self, state: State<'a, T>) -> State<'a, T>;
}

impl Query for Segment {
    fn process<'a, T: Queryable>(&self, step: State<'a, T>) -> State<'a, T> {
        match self {
            Segment::Descendant(segment) => segment.process(step.flat_map(process_descendant)),
            Segment::Selector(selector) => selector.process(step),
            Segment::Selectors(selectors) => process_selectors(step, selectors),
        }
    }
}

Zigではダックタイピングです。

pub fn query(node: anytype, iteration: *JsonPathIter) !void {
    const T = switch (@typeInfo(@TypeOf(node))) {
        .pointer => |p| p.child,
        else => @TypeOf(node),
    };
    if (!@hasDecl(T, "query")) {
        return; // コンパイル時トレイトなし; メソッドの存在のみチェック
    }
    try node.query(iteration);
}

再帰も両方で機能します。

Rust:

fn process_descendant<T: Queryable>(data: Pointer<T>) -> Data<T> {
    if let Some(array) = data.inner.as_array() {
        Data::Ref(data.clone()).reduce(
            Data::new_refs(/* children */).flat_map(process_descendant)
        )
    } else { Data::Nothing }
}

Zig:

fn collectDescendants(allocator, value: *std.json.Value, path, out) !void {
    try out.append(allocator, .{ .json = value, .path = try allocator.dupe(u8, path) });
    switch (value.*) {
        .array => |arr| for (arr.items) |*elem| try collectDescendants(allocator, elem, child_path, out),
        else => {},
    }
}

しかし、言語はすぐに、関数型スタイルから逸脱することを強制します。これは主に、アロケータを直接扱っており、真に純粋な関数型アプローチは、常に新しい構造体を構築することを意味するためです。これはメモリコストが高いか、それを避けるために必要な手動のブックキーピングのコストが高いかのどちらかです。

ミューテーション対不変モナドが中心的な違いです。

Rustは直接的なモナディック変換を行います。

pub fn flat_map<F>(self, f: F) -> Data<'a, T> {
    match self {
        Data::Ref(data) => f(data),      // *新しい* Data を返す
        Data::Refs(v) => Data::Refs(v.into_iter().flat_map(...).collect()),
        _ => Data::Nothing,
    }
}

Zigはミューテーションに切り替わります。

pub fn queryName(name: []const u8, iteration: *q.JsonPathIter) !void {
    while (i < iteration.cursors.items.len) {
        if (obj.getPtr(name)) |val| {
            iteration.cursors.items[i] = .{ .json = val, .path = new_path }; // インプレース上書き
        } else iteration.remove(i);                                          // リストを直接ミューテート
    }
}

Reduce 対 Fork。

Rust:

selectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)

Zig:

var lhs_branch = try iter.fork();   // カーソル状態のディープコピー
defer lhs_branch.deinit();          // その後破棄される

コンビネータ対ループ。

Rust:

items.iter().enumerate().filter(|(_, i)| cond(i)).map(|(idx, i)| Pointer::idx(i, path, idx)).collect()

Zig:

while (i < cursors.len) {
    if (actual_index < arr.items.len) { cursors[i] = .{...}; i += 1; }
    else iteration.remove(i);
}

総じて、これは各言語の設計目標とターゲットドメインを反映しており、妥当なトレードオフですが、主観的には、結果として得られたZigコードはRustの対応物よりも読みにくいと感じました。

アロケータ

アロケータは至る所にあります。ほとんどすべての関数がアロケータを受け入れ、すべての構造体がアロケータを保持しています。それは明示的であり、一度それをエントリーコストとして受け入れてしまえば、比較的容易に追跡できます。これは多かれ少なかれ言語の定義的な特徴なので、警告されていなかったとは言えません。

しかし、実際にはプロセスは退屈です。init/deinit の規約を綿密に守る必要があり、呼び出しスタックが長くなると、その規律は揺らぎます。C言語のサイレントセグメンテーションフォルトやメモリ破損と比較すれば明確な改善ですが、Rustから来た場合、依然として自分でルールを強制する必要があります。何かを割り当て、失敗パスを処理し、誰が deinit の責任を持つかを毎回決定します。

幸いなことに、Zigの TestAllocator がここで救世主となります。これはすべてを自動的にキャッチするわけではありません。失敗パスを実行するテストケースを書く必要がありますが、一度書けば、かなり信頼性が高くなります。そしてそれが落とし穴です。これはすべて紙の上では明白に見えますが、コードが複雑になると、これらのバグが絡み合い、隠れてしまいます。

ここでは、それぞれのケースを、Rustが同じ形状をどのように処理するかと比較して、最も影響の大きいケースを示します。

メモリリーク:deinit の忘れ
var iter = q.JsonPathIter.init(&root, std.testing.allocator);
try iter.append(&root, "$\\[\'a\']");
// BUG: iter.deinit() がない

検出される: MemoryLeakDetected が append 内の dupe 呼び出しを指します。

修正: init の直後に defer iter.deinit(); を追加します。

Rust: Drop はスコープの終了時に自動的に実行されるため、この特定のバグは単純に存在しません。技術的には、Rustでも Rc の参照サイクルや明示的な Box::leak のようなリークは依然として可能ですが、「決してリークしない」というのは厳密な保証ではなく、それを引き起こすには意図的に行う必要があります。

メモリリーク:エラーパスで deinit がスキップされる
fn build(json: *Value, a: Allocator) !q.JsonPathIter {
    var iter = q.JsonPathIter.init(json, a);
    try iter.append(json, "$\\[\'a\']"); // OK
    try iter.append(json, "$\\[\'b\']"); // 失敗 -> iter がリーク
    return iter;
}

検出される: FailingAllocator{ .fail_index = 1 } が2回目の append を MemoryLeakDetected に強制します。

修正: init の直後に errdefer iter.deinit(); を追加します。

Rust: 真に排除されます。Drop::drop は、? からの早期リターンを含む、あらゆるスコープ終了時に無条件に呼び出されます。

メモリ破損:二重の deinit 呼び出し
fn runQuery(json: *Value, qstr: []const u8, a: Allocator) !q.JsonPathResult {
    var iter = q.JsonPathIter.init(json, a);
    errdefer iter.deinit();
    try q.query(qstr, &iter);
    return iter.toResult(parsed); // 所有権が呼び出し元に移動
}

fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator, cache: *std.ArrayList(q.JsonPathResult)) !void {
    var result = try runQuery(json, qstr, a);
    try cache.append(result);   // cache は result のポインタの(浅い)コピーを保持する
    defer result.deinit();      // BUG: cache.items がまだ指している同じヒープデータを解放する
    printResults(&result);
}

fn processAll(json: *Value, queries: [][]const u8, a: Allocator) !void {
    var cache = std.ArrayList(q.JsonPathResult).init(a);
    defer {
        for (cache.items) |*r| r.deinit();  // Layer 2 が既に解放したのと同じメモリを解放
        cache.deinit();
    }
    for (queries) |qs| try cacheAndLog(json, qs, a, &cache);
}

検出される: std.testing.allocator の下で実行すると、processAll のクリーンアップ中の2番目のクエリの cache.items[0].deinit() で失敗します。DoubleFree が両方の解放サイトを指し、これが関数をまたいだ所有権のバグであり、単一行のタイポではないことを確認します。

修正: 一つのレイヤーのみが値を所有できます。cache は cacheAndLog より長く生存するため、所有権はレイヤー3に属します。レイヤー2は、それを引き渡した後に defer deinit を呼び出してはいけません。

fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator,
                cache: *std.ArrayList(q.JsonPathResult)) !void {
    var result = try runQuery(json, qstr, a);
    printResults(&result);      // まず使用する
    try cache.append(result);   // その後、所有権を引き渡す — これ以降は defer しない
}

Rust: この正確な形状はコンパイルできません。cache.push(result) は result を移動させます。その行の後、result は使用可能なバインディングとしては存在しないため、誤って drop(result) を呼び出す方法はありません。

メモリ破損:構造体への移動中に割り当てが失敗し、割り当てが孤立する
pub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {
    const duped = try self.allocator.dupe(u8, path);
    // BUG: errdefer がない
    try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
}

検出される: FailingAllocator{ .fail_index = 1 } が配列の成長(2回目の割り当て)を失敗させ、duped(1回目の割り当て)を孤立させます。

これはケース1とは異なる方法でリークします。iter.deinit() は正常に実行されますが、この特定の文字列は決して認識されません。

修正:

const duped = try self.allocator.dupe(u8, path);
errdefer self.allocator.free(duped);   // 下の append が失敗した場合のみ発火する
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });

Rust: 構築により真です。Vec::push(item) は item を移動させ、成功するか、OOMで中止されるかのどちらかです。標準APIには、誤って失う可能性のある「割り当てられたがリンクされていない」値を返すような失敗可能なプッシュはありません。errdefer がここで埋めるギャップは、そもそも存在しません。

ライブラリとコアAPI

エコシステムはまだ非常に若いです。ライブラリは本当に不足しており、正規表現のような基本的なものでさえまだ成熟していません。例えば、Zigで利用可能な正規表現エンジン mvzr は、Unicodeプロパティエスケープ(\[p{...}])をサポートしていません。これは、RFC 9535のフィルタ関数を実装中に直接的なギャップとして浮上しました。それに加えて、言語自体の標準ライブラリもバージョンごとにAPIが変更されます。これらはどれも予想外ではありませんでしたが、記録のために言及する価値はあります。

全体的な印象

Rustとは異なる言語ですが(誰がそんなことを予想したでしょうか)、本当に良い印象を残しました。それは率直で、モダンで、驚くほど高速です。C言語の真の後継となる大きな可能性を秘めていると信じています。一方で、まだ若く、それが表れています。言語自体の形状は、いくつかの場所で未完成のように感じられ、成熟するにつれて、よりクールなQoL機能やシンタックスシュガーを取り込んでいくと予想されます。

私自身は、エコシステムへの貢献を続けたいと思っており、価値のあるプロジェクトを見つけるたびに、貢献していくつもりです。

リンク

免責事項: この記事全体のスタイリングとエラーハンドリングは、AIの助けを借りてクリーンアップされました。