ブログ

Rust vs Zig:パース処理の再実装で見えてきた、メモリ管理への価値観の違い

今回は、Rust vs Zig: We Rewrote the Same Parser Twice — Here’s What Won という記事を参考に、同じパーサーをRustとZigの両方で書き直した際の実録と、そこから得られた知見についての紹介です。

やっぱりRustの制約がきついんですね。ポインタでリンクを貼りまくる構造は借用と相性悪そう。RCとか使うともう少し楽に書けるのかしら? でも、zig も学習データが少なくてバイブコーディングと相性悪いしなぁ。


システムプログラミングにおいて、パフォーマンスと安全性のバランスは常に議論の的になります。特に、頻繁に実行されるパース処理のような「ホットパス」では、わずかなメモリ割り当て(アロケーション)が全体のボトルネックになることも少なくありません。

結論:どちらが「勝者」だったのか

結論から申し上げますと、今回の「設定ファイルパーサーの再実装」という特定のユースケースにおいては、Zigの方が開発チームの意図を自然に表現できたと感じる結果となりました。

これはRustが劣っているという意味ではなく、Rustの「所有権とライフタイム」という厳格な仕組みが、今回のような「メモリ上のデータをゼロコピーで参照し続ける」という要件において、副次的な複雑さを生んでしまったためです。

Rust版での課題:暗黙のアロケーションとライフタイムの壁

最初に作成されたRust版のパーサーは、非常にクリーンで安全なものでした。しかし、詳しく見てみると、パフォーマンス上の懸念が隠れていました。

Rustによる初期実装(所有型)

enum Tok {
    Key(String),
    Val(String),
    Eq,
}

fn lex(src: &str) -> Vec<Tok> {
    let mut out = Vec::new();
    for line in src.lines() {
        let (k, v) = line.split_once('=').unwrap();
        // ここでStringへの変換(ヒープアロケーション)が発生
        out.push(Tok::Key(k.trim().into()));
        out.push(Tok::Eq);
        out.push(Tok::Val(v.trim().into()));
    }
    out
}

この実装の問題点は、into() を使って &strString に変換するたびに、ヒープメモリの割り当てが発生することです。元のテキストデータはすでにメモリ上にあるにもかかわらず、パースの過程でその内容をわざわざ別の場所へコピーしてしまっています。

ゼロコピーへの挑戦と挫折

このアロケーションを避けるため、Rustでは参照(&str)を保持する実装も可能です。しかし、そうすると構造体や列挙型にライフタイム注釈(<'a>)を付ける必要が出てきます。

flowchart TD
    A[ソーステキスト] --> B{パース手法の選択}
    B -- 所有型 --> C[Stringへのコピー発生]
    B -- 参照型 --> D[ライフタイム 'a の管理が必要]
    C --> E[実行速度低下 / メモリ消費増]
    D --> F[コードの複雑化 / コンパイルエラーとの戦い]

実際に参照型への書き換えを試みたところ、ライフタイムの指定がコード全体に広がり、シグネチャが非常に複雑になってしまいました。開発チームは、納期とコードの保守性を考慮し、あえて「低速だが読みやすい」所有型バージョンを一旦リリースすることにしたそうです。

Zig版でのアプローチ:スライスがデフォルトの世界

一方で、Zigによる書き直しでは、Rustで苦労した「ゼロコピー」が驚くほどスムーズに実現できました。

Zigによる実装例

const Tok = union(enum) {
    key: []const u8,
    val: []const u8,
    eq,
};

fn lex(src: []const u8, out: *std.ArrayList(Tok)) !void {
    var it = std.mem.splitScalar(u8, src, '\n');
    while (it.next()) |line| {
        const i = std.mem.indexOfScalar(u8, line, '=') orelse continue;
        // スライス([]const u8)は元のバッファを指すだけで、コピーは発生しない
        try out.append(.{ .key = std.mem.trim(u8, line[0..i], " ") });
        try out.append(.eq);
        try out.append(.{ .val = std.mem.trim(u8, line[i+1..], " ") });
    }
}

Zigにおいて、スライス([]const u8)はポインタと長さのペアに過ぎません。Rustのような厳密な借用チェッカーがない代わりに、プログラマがメモリの生存期間を意識する必要があります。しかし、今回のような「パース中ずっとソーステキストがメモリに存在する」ことが確実なケースでは、このシンプルさが強力な武器になります。

RustとZigの比較まとめ

今回のパーサー実装を通じて見えてきた、両言語の特徴を整理してみました。

項目 Rust Zig
メモリ安全性 コンパイル時に厳格に保証 プログラマの責任(明示的)
ゼロコピーの実装 ライフタイム注釈により複雑化しやすい デフォルトのスライスで容易に記述可能
アロケーション 標準ライブラリで隠蔽されがち 明示的なアロケータ渡しが基本
抽象化の方向性 型システムによる安全性と表現力 予測可能性とハードウェアへの近さ

まとめ:適材適所の選択

今回のケースでは、Zigの「メモリの使われ方が一目でわかる」という特性が、パーサーの最適化において有利に働きました。

Rustは非常に優れた言語ですが、時にその安全性のガードレールが、特定の低レイヤ最適化を行いたい場合に摩擦(フリクション)を生むことがあります。たとえば、すべての構造体にライフタイムを記述していく作業は、パズルを解くような感覚に近いかもしれません。

一方で、Zigは「隠れた挙動がない」ことを哲学としています。スライスが元のデータを指していることは自明であり、それ以上の型レベルでの証明を求められません。

どちらの言語が優れているかという議論よりも、「今作ろうとしているもののライフサイクルを、言語の仕組みがどれだけ自然に扱えるか」で選ぶのが良いのではないかと思います。パース処理のような、メモリレイアウトがパフォーマンスに直結する分野において、Zigという選択肢は一考の価値があると言えるでしょう。

参照記事