RustからZigに移って、使ってみるとどんな感じか

Rustを7年間書いてきた著者がZigでJSONPathライブラリを作り直した:IDEサポートなし、構造は強制的にフラット化、関数型パラダイムは使えず、allocatorが至る所に。4種類のメモリリークと二重解放の形を一つずつRustと対照し、最後にZigはCの真の後継者になる潜在能力があると述べた。

日本語
コピー

RustからZigに移ってどう感じたか

はじめに

私は過去7年間Rustの開発者で、主にオープンソースプロジェクトで仕事をしてきた。この言語とそのエコシステムについては、かなり確かな手応えを掴めているほうだと思っている。Rustの関数型寄りの側面、たとえばクリーンな関数や表現力のある型などが好みだ。ただ、他の言語にもずっと好奇心はあったし、Zigはしばらく前から気になる存在だった。Cの後継候補のひとつで、より低レベルで軽量、そして真剣に扱われる言語の仲間入りを着実に果たしつつある。キャリアの初期にCをしばらく書いていたので、この比較はやる価値があるように思えた。

先にひとつはっきりさせておきたい。私のZigの経験はこのプロジェクトから始まっている。毎日Zigを書いている人から見れば素朴で当たり前に見える観察もあるだろうし、途中で下した判断のいくつかは、まず間違いなく最適解ではない。Rustから持ち込んだ習慣に引きずられたもので、深いZigのイディオムから出たものではない。それでいい、誰だってどこかから始めるものだし、その間は長年溜めてきた言語をまたぐ直感に頼るしかない。良くも悪くも。

比較を公平にするために、Rustですでに書いたものを作り直すことにした。おもちゃではなく、といって手に負えないほど大きなプロジェクトでもなく、できればコミュニティが実際に使えるものがいい。JSONPathを選んだ。JSONのクエリ言語で、RFC 9535で仕様が定められている。Rust版はすでにある(jsonpath-rust)。同じものをZigに持ち込むのが狙いで、それがzig-jsonpathだ。

IDEサポート

最初に不意を突かれたのは、正直こんなものが一番記憶に残る部分になるとは誰が思うだろうか、IDEサポート、というよりそれがほぼ存在しないことだった。RustにはRustRover、他の言語にはJetBrainsの各種製品を使っているが、それと比べるとZigはシンタックスハイライトと基本的な補完以外ほとんど何もない。驚きではないが、おかげで基礎に戻された。コマンドラインを中心にこの言語と向き合うことを覚える羽目になった。最初は欠点のように感じたが、後から思えばこの経験で一番面白い部分のひとつだった。コマンドラインツールだけでどれだけ直接作業できるか、忘れていたのだ。

ここでの最初の本当の教訓はbuild.zigで、これが意外なほど楽にしてくれた。最終的に落ち着いたのはこの構成だ。

zig build test                                              # run all tests
zig build test -Dfilter="filter match function basic"       # run one test
zig build test -Ddebug-query=true                           # all tests with debug
zig build compliance                                        # compliance suite
zig build check                                             # unit tests + compliance

この流儀を受け入れてしまえば、本当にすっきりしている。

遠回りになるが、Zigには感謝している。完全なIDEからhelix + alacritty + zellijという構成に移る、もっと大きな連鎖のきっかけになったからだ。

フラットな構造

Rustや他のほとんどの言語では、ファイルサイズとディレクトリの深さのバランスを探して、いつもかなり時間を費やしていた。ファイルは好きに分割できるし、ディレクトリ階層はいくらでも深くできる。Zigもそれ自体は反対しないが、なぜかそれを促してこない(Cと同じで、低レベルのシステム言語としては驚きではない)。ファイルやディレクトリを入れ子にすることもできるが、そうするとimportに少し摩擦が出る。そして本当の問題はこうなる。そもそも何のために? ファイルとディレクトリを増やして分割することで、可読性は実際どれだけ良くなるのか。理屈の上では可読性の向上だ。実際には、関連するものをひとつの大きなファイルにまとめておけば、そのまま切り分けてセクションごとに読めるし、すべてが一箇所にあることには確かな利点がある。多くの場合、Zigはフラットな方向へ押してくる。何かに付随するmodelファイルが必要なら、その隣にmodel_ファイルを作って先へ進む。

このやり方が大規模プロジェクトに拡張できるとは思わない。つまりある規模に達すれば、結局は本物の階層構造が要る。ただ、それを必要とする敷居は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

テスト

rfc9535の適合性スイートは脇に置いて、言語そのものについてだけ話す。

Rustではテストのやり方をだいたい2種類に固定している。

  • インラインのユニットテスト。テスト対象のコードと同じファイルか同じディレクトリに置く。便利なデフォルトで、常にそこにあり、追加設定は要らない。
  • 統合テスト。メインのソースツリーの外の独立したディレクトリ(たとえばtests)に置く。これは例外であって常態ではなく、ないこともある。

Zigもだいたい同じ分け方だろうと思っていた。紙の上では似ている。同じファイルに直接テストを書ける。問題は、少なくとも私にとっては、冗長さだった。すでに固まったフラットな構造を踏まえると、選択肢は2つしかない。modelごとに別のmodel_testファイルを作るか、テストをmodelファイル自体にインラインで書くか。どちらも最後には散らかる。個々のファイルが散らかるか、メインディレクトリ全体が散らかるかだ。

私は2つ目を選んだ。つまりbuild.zigで明示的に設定する必要があった。ただ、一度つないでしまえばよく動くし、かなりきれいに保てている。

要するに、Rust でテストを書いて管理するほうが楽だと感じる。ただ Zig 側で余分に生じる摩擦はほとんどが言語固有のもので、原因はテストの仕組みではなく Zig の手動メモリ管理にある。

関数型パラダイムがない

Rust は厳密には命令型言語だが、関数型の概念を大量に取り込んでいる。ゼロコストイテレータ、遅延評価、ADT、パターンマッチ、モナド的な型、trait、クロージャなどだ。自分は Haskell と Erlang もそれなりに使っていた時期があるので関数型寄りであり、それがこのライブラリにもはっきり出ている。FP のイディオムに大きく依存している。

  • Queryable や関連する型のようなコンビネータによるモナド的なエラー制御
  • Data のように mapflat_mapreduce などのメソッドを持つモナド的なデータ型
  • 純粋で不変な変換
  • ループの代わりにイテレータコンビネータ
  • クロージャによる局所的な抽象化
  • 宣言的マクロを小さな埋め込み DSL として使う
  • 直和型と直積型

これらすべてを Zig に持ち込めるとは最初から思っていなかったが、せめて中核の概念だけは残したかった。実際には、Rust が不変性とコンビネータに頼る場面で、Zig は破壊的な変更と、命令型の世界で最も素の書き方へと自分を押しやる。

両者が最も近づくのは直和型だ。

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; // no compile-time trait; just checks the method exists
    }
    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 => {},
    }
}

だが言語はすぐに関数型スタイルから逸脱させる。主な理由は allocator を直接扱わなければならないことで、本当に純粋関数型のやり方を通すなら新しい構造体を次々と構築することになる。それはメモリの上で高くつくか、それを避けるために手作業の帳尻合わせという形で高くつく。

核心的な違いは「変更」対「不変モナド」

Rust は素直なモナド変換を行う:

pub fn flat_map<F>(self, f: F) -> Data<'a, T> {
    match self {
        Data::Ref(data) => f(data),      // returns a *new* 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 }; // in-place overwrite
        } else iteration.remove(i);                                          // mutate list directly
    }
}

Reduce 対 Fork

Rust:

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

Zig:

var lhs_branch = try iter.fork();   // deep copy of cursor state
defer lhs_branch.deinit();          // then discarded

コンビネータ対ループ

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 のコードほど読みやすくないと感じる。

Allocator

Allocator はどこにでもある。ほぼすべての関数が受け取り、すべての構造体が保持する。明示的であり、入門の代償として受け入れてしまえば追うのは比較的素直だ。これはほぼこの言語の象徴的な特徴なので、誰も警告してくれなかったとは言えない。

だが実際にはこの作業は退屈だ。init/deinit の約束事を細心の注意で守らなければならず、呼び出しスタックが長くなるとその規律が緩み始める。C の無言のセグメンテーションフォルトやメモリ破壊に比べれば明らかな進歩だが、Rust から来た身としては、依然として手でルールを執行する側のままだ。メモリを確保し、失敗パスを処理し、誰が deinit するかを決める。そのたびごとに。

幸い Zig の TestAllocator がここで助けてくれる。すべての問題を自動で捕まえるわけではなく、失敗パスに到達するテストケースは自分で書く必要があるが、一度書けばかなり信頼できる。そしてそこが落とし穴でもある。紙の上ではすべて明らかに見えるが、コードが複雑になると、それらのバグは絡み合って隠れる。

以下は最も厄介な例で、いずれも Rust が同じ形をどう扱うかと対比している。

メモリリーク: deinit を忘れた

var iter = q.JsonPathIter.init(&root, std.testing.allocator);
try iter.append(&root, "$['a']");
// BUG: no iter.deinit()

捕まえ方: MemoryLeakDetectedappend 内部の 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']"); // fails -> iter leaked
    return iter;
}

捕まえ方: FailingAllocator{ .fail_index = 1 }。2 回目の append を MemoryLeakDetected に落ち込ませる。

直し方: init の直後に errdefer iter.deinit();

Rust: この問題は完全に消える。Drop::drop? による早期リターンを含め、あらゆるスコープ離脱で無条件に発火する。

メモリ破壊: deinit が 2 回呼ばれた

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); // ownership moves to caller
}

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 now holds a (shallow) copy of result's pointers
    defer result.deinit();      // BUG: frees the same heap data cache.items still points to
    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();  // frees the SAME memory Layer 2 already freed
        cache.deinit();
    }
    for (queries) |qs| try cacheAndLog(json, qs, a, &cache);
}

捕まえ方: std.testing.allocator の下で実行する。processAll のクリーンアップ段階で 2 回目のクエリの cache.items[0].deinit() をエラーにし、DoubleFree が 2 か所の free の位置を同時に指し示すことで、これが一行の書き間違いではなく関数をまたいだ所有権のバグだと確認できる。

直し方: この値を所有する層は 1 つだけでなければならない。cachecacheAndLog より長生きする以上、所有権は 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);      // use it first
    try cache.append(result);   // then hand off ownership — no defer after this
}

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: no errdefer
    try self.cursors.append(self.allocator, .{ .json = v, .path = duped });
}

捕まえ方: FailingAllocator{ .fail_index = 1 } で配列の拡張(2 回目の確保)を失敗させ、duped(1 回目の確保)を孤立させる。

このリークは前のものとは違う。iter.deinit() 自体は問題なく動いていて、ただこの文字列を永遠に見ることがないだけだ。

修正方法

const duped = try self.allocator.dupe(u8, path);
errdefer self.allocator.free(duped);   // only fires if append below fails
try self.cursors.append(self.allocator, .{ .json = v, .path = duped });

Rust:構造的に正しい。Vec::push(item)item をムーブし、成功するか OOM でアボートするかのどちらかであり、標準 API には「確保済みだがリンクされていない」値を返してうっかり捨てさせてしまうような失敗しうる push は存在しない。errdefer がここで埋めている隙間は、そもそも最初から存在しなかった。

ライブラリとコア API

エコシステムはまだ若い。ライブラリは本当に乏しく、正規表現のような基礎的なものでさえ不完全だ。たとえば Zig で使える正規表現エンジン mvzr は Unicode プロパティエスケープ(\p{...})に対応しておらず、RFC 9535 の filter 関数を実装するときにこれがそのまま穴として露呈した。それに加えて、言語自身の標準ライブラリもバージョンごとに API を変える。これは最初から分かっていたことだが、記録しておく価値はある。

全体の印象

この言語は Rust とは違う(誰が分かるだろうか、なにせ)。だが受けた印象はとても良い。率直で現代的、そしてとんでもなく速い。C の真の後継者になる潜在力は本当にあると思う。一方でまだ若く、それは見て取れる。言語自体の形はまだどこか作りかけに見えるし、成熟するにつれて、より使い勝手の良い QoL 機能やシンタックスシュガーを取り込んでいくのだろう。

私としては、このエコシステムに貢献を続けたい。やる価値のあるプロジェクトがある限り、やっていくつもりだ。

リンク

免責事項:この記事全体のコードスタイルとエラー処理は AI の助けを借りて整えたものだ。

出典: besok← ホームへ戻る