プログラミング
RustからZigを使ってみた感想
What Zig felt like, coming from Rust (besok.github.io)
要約
長年Rust開発者として活動してきた著者が、Zig言語を試した経験を共有する記事です。IDEサポートの不足が逆にCLIツールの活用を促し、Rustとは異なるフラットなファイル構造の利点を発見したこと、そしてZigの関数型プログラミングからの距離感について語っています。
全文翻訳
RustからZigを使ってみた感想
はじめに
私は過去7年間Rust開発者として、主にオープンソースプロジェクトに携わってきました。その過程で、言語とそのエコシステムに対する確かな感覚を培ってきたと自負しています。私はクリーンな関数、表現力豊かな型など、Rustの関数型寄りの側面に惹かれます。しかし、常に他の言語にも興味があり、Zigは以前からC言語の後継候補として注目していました。より低レベルで、軽量であり、真剣に扱われる言語としての地位を着実に確立しつつあります。キャリアの初期には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
テスト
rfc9535コンプライアンススイートは一旦置いておき、言語自体に純粋に焦点を当てます。
Rustでは、私は通常2つのテストアプローチを取ります。
インライン単体テスト:コードと同じファイルまたは同じフォルダ内に配置します。これは常に利用可能な便利なデフォルトであり、追加のセットアップは不要です。
統合テスト:メインソースツリーの外にある独立したフォルダ(testsなど)に配置します。これは例外であり、デフォルトではなく、時には全く存在しないこともあります。
Zigでも同様の分割を期待していました。理論上は似ています。同じファイル内に直接テストを書くことができます。問題は、少なくとも私にとっては、冗長性でした。既に定着していたフラットな構造を考えると、モデルごとに別のmodel_testファイルを作成するか、モデルファイル自体に直接テストをインライン化するかのどちらかでした。どちらのアプローチも、個々のファイルまたはフォルダ全体を散らかすことになりました。
私は後者のオプションを選択し、build.zigで明示的に設定する必要がありました。それが配線された後は、うまく機能し、クリーンに保たれました。
全体として、テストの作成と管理はRustの方が簡単だと感じます。しかしZigの場合、その追加の摩擦の多くは言語固有のものであり、テストインフラストラクチャ自体ではなく、Zigの手動メモリ管理に起因します。
関数型パラダイムではない
Rustは技術的には命令型言語ですが、ゼロコストイテレータ、遅延評価、ADT、パターンマッチング、モナディック型、トレイト、クロージャなど、関数型概念を強く取り入れています。HaskellやErlangにも触れてきた私は、関数型スタイルにかなり傾倒しており、それがこのライブラリにも表れています。モナディックなエラー制御(Queryableなどのコンビネータ経由)、モナディックスタイルのデータ型(map、flat_map、reduceなどを備えたData<T>)、純粋で不変な変換、ループではなくイテレータ上のコンビネータ、ローカル抽象化のためのクロージャ、小さな埋め込みDSLとしての宣言的マクロ、Sum型とProduct型など、FPイディオムに強く依存しています。
Zigにこれらすべてを持ち込めないことは最初からわかっていましたが、少なくとも核となる概念は維持できると期待していました。実際、Rustが不変性とコンビネータに依存するのに対し、Zigはインプレースミューテーションと命令型世界に最もネイティブなパターンへと私を駆り立てました。
両者が近いのはSum型です。
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.quer