MicroArchitectures
H.Ueda
Programmer
ブログ
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() を使って &str を String に変換するたびに、ヒープメモリの割り当てが発生することです。元のテキストデータはすでにメモリ上にあるにもかかわらず、パースの過程でその内容をわざわざ別の場所へコピーしてしまっています。
ゼロコピーへの挑戦と挫折
このアロケーションを避けるため、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という選択肢は一考の価値があると言えるでしょう。
参照記事
- Rust vs Zig: We Rewrote the Same Parser Twice — Here’s What Won
- Training LLM, from Scratch, in Rust
- We Built a Kernel Module in Rust — And It Actually Worked
- Inside the Secret Tools Real Rust Teams Use (That Cargo Doesn’t Want You to Know About)
- Go Just Killed Rust’s Only Advantage (And Nobody’s Talking About It)
- How Const Generics Changed Rust Forever — Why You Should Use Them Now