Rustにはそれだけの価値があったのか?
(medium.com/@jsoverson)- WebAssemblyに集中するためJavaScriptからRustへ移行した後、3年間でWick、本番環境へのデプロイ、ebook、約100個のcrates.ioパッケージを作りながら、Rustの実際の価値を評価した
- borrow checker、豊富な型システム、関数型パターン、
nullがないことにより、多くのエラーをコンパイル段階で防ぎ、少ないテストで大規模なコードベースを維持できるようになった - ClippyとCargo workspaceは強力だが、グローバルなlint設定やworkspaceの配布のようなツール・エコシステムの隙間が運用コストにつながる
- async、リファクタリング、generic・lifetime・trait constraintの管理は、JavaScriptやGoよりも摩擦が大きい領域として残っている
- Rustは堅牢で多用途だが、採用、学習、高速な反復、問題追跡のコストが大きいため、スコープが明確な場合や初期コストを負担できる場合により適している
WebAssemblyがRust選択を後押しした
- 数年前、WebAssemblyに100%集中するため既存の仕事を手放し、当時RustはWebAssemblyへのコンパイル対応が最も優れていた
- 機能豊富なWebAssemblyランタイムもRustベースだったため、選択肢の中でRustが最も現実的な選択肢だった
- その後、WebAssemblyを中核的なモジュールシステムとして使うアプリケーションフレームワーク兼ランタイムであるWickを作った
- 3年間で複数の本番環境デプロイ、ebook、crates.ioへの約100個のパッケージ公開を通じて、Rustの経験が蓄積された
少ないテストでより多くのコードを維持
- Rustでは一般的な言語と同じようにテストを書いているうちに、コンパイルさえ通れば失敗しようがないテストを書いていることに気づいた
unsafe {}ブロックや.unwrap()のようなpanicを起こしやすいメソッドを避ければ、多くの問題は基本的に回避できる- borrow checker、豊富な型システム、関数型パターンとライブラリ、
null値が存在しないことが、テストにかかる労力を減らす - Wickプロジェクトの70,000行以上のコードを、他の言語で必要だったであろうテストよりはるかに少ないテストで維持した
- テストが必要な場合は、Rustの統合テストハーネスのおかげでコードのそばに簡単に追加できる
Rustは他の言語でのコーディング習慣も変えた
- Rustコンパイラは、他の言語では正常だと思っていたコードにも絶えず不満を示し、その過程でコーディング習慣が変わった
- 今では他の言語でも、コード行の順序が不自然だったり戻り値を確認しなかったりすると居心地の悪さを感じる
- ランタイムエラーに遭遇したときも、以前よりはるかに強い拒否感を覚えるようになった
- Rustの厳格さは不便だが、コンパイラに守られる経験に慣れると他の言語に戻るのは難しい
Clippyはリンター以上に役立つ
- ClippyはRustのリンターだが、単なる検査ツールというより、代替コードを提案してくれる親切な補助ツールに近い
- Rust標準ライブラリは非常に大きく、機能が多くの型・trait・マクロ・関数に分散しているため、必要なAPIを見つけにくい
- 複数のルールは、標準ライブラリのメソッドや型でよりよく置き換えられる一般的なパターンを見つけ出す
- 数百のルールが性能、可読性、不要な間接化を扱い、可能な場合は代替コードも提供する
- プロジェクト全体のlint設定はCargoのIssueによって可能になりそうだったが、それまではWickでは数十個のcrateのinline lint設定をスクリプトで自動更新する必要があった
エコシステムには受け入れるべき隙間がある
- グローバルなClippy設定の問題は、Rustのツールやライブラリでしばしば遭遇するエコシステムの隙間の一例だ
- 関連Issueは現在はクローズされているが、何年もオープンのままで、解決まで時間がかかった
- Rustは長い間「最も愛されている言語」に選ばれるほど新規ユーザーを引きつけているが、その流れがライブラリやツールの劇的な改善にそのままつながっているわけではない
- 特定のユースケースに対応する一回限りのforkが生まれることが多く、WickでもPRを出そうとしたが似た状況に遭遇した
- 考えられる理由として、安定したAPIを維持するプレッシャーと、細分化された型システムがある
- ライブラリの所有者にとっては、小さな変更でもメジャーバージョン変更につながり得るため受け入れにくい
- すべての人の要求を満たすRustコードを書く負担も大きい
Cargo、crates.io、workspace配布の摩擦
- Wickのリポジトリ構造は人気プロジェクトを参考にして作り、最初は妥当に見えたが、配布段階で問題が明らかになった
- Cargoでモジュールサイズのcrateをビルド・テスト・利用するのは簡単だが、crates.ioへの配布は別問題だ
- crates.ioでは、参照するすべてのcrateが個別に配布されていなければパッケージをpublishできない
- ローカルファイルシステムにしか存在しないパッケージに依存したcrateを配布できないようにするのは妥当だ
- しかし、大きなプロジェクトを小さな内部モジュールに分割する自然な構造では、親crateの中にだけ存在するsub-crateを含めてpublishすることができない
- ローカルのdev dependencyがあるcrateでも、
Cargo.tomlにversionを含めなければpublishできる、という訂正があった - Cargo workspaceのサポート自体は素晴らしく、大規模プロジェクトの管理体験はほとんどの言語より優れている
- しかしworkspaceは配布問題を解決せず、設定方法がいくつかあっても簡単に配布できる「正解」は見つけにくい
- cargo workspace publish関連のユーティリティcrateが多数存在すること自体が問題を示している
- Wickをpublishするときは、手作業の反復作業と部分的にしか動かないツールを組み合わせる必要があり、1時間以上かかることがよくある
asyncは最大の摩擦の一つ
- Rustのasyncは、言語が最初に作られた後で追加された機能のように感じられ、実際の使用でも後付けの機能のように頻繁に妨げになる
- エラーを理解して解決するのが難しく、解決策を探すときも複数のランタイムとそれぞれのasync方式を基準に絞り込む必要がある
- あるasyncライブラリは特定のasyncランタイムの外では使えない可能性がある
- JavaScriptを20年使い、Goの経験もある立場からすると、Rust asyncは最大の挫折と摩擦の源だ
- 克服不可能な問題ではないが、asyncの問題がいつでも現れ得ることを常に想定しておく必要がある
- 他の言語ではasyncがほとんど見えないほど自然に動作する
リファクタリングはつらい作業になり得る
- Rustの豊富な型システムは長所であると同時に短所でもある
- Rustの型で考えることは良いが、Rustの型を管理することは悪夢になり得る
- データと関数シグネチャにはgeneric type、generic lifetime、trait constraintが入り得る
- constraint自体もさらにgeneric typeやlifetimeを持つことがあり、実際のコードより型constraintのほうが多い場合もある
- すべての
implごとにgenericを定義する必要があるため、最初に書くときも面倒で、リファクタリング時には小さな変更が連鎖的な修正に膨らみ得る - 同じconstraintやgenericの一覧を複数箇所で繰り返す必要があるとき、それをaliasしたり中央定義として参照したりする言語・ツールレベルの方法がなく、重複の負担が残る
最終判断: 強力だがコストが大きい
- Rustはシステムレベルのコード、CLIアプリ、Webサーバー、Webクライアントを同じ言語で書けるほど多用途だ
- WebAssemblyを使えば、同じバイナリでLLMをブラウザとコマンドラインで実行できる
- Rustプログラムは非常に堅牢になり得るし、Rustが防いでくれる問題を実感すると他の言語に戻るのは難しい
- Goに一時的に戻ったとき、開発速度は再び魅力的だったが、ランタイムpanicを経験した後でその利点は揺らいだ
- Rustには明確な欠点がある
- 採用が難しい
- 学習が遅い
- 高速な反復には硬直的すぎる
- 特にasyncコードではメモリと性能の問題を追跡しにくい
- すべてのライブラリが安全なコードに十分適しているわけではない
- 開発ツールにはさらに改善の余地が大きい
- 小さなチームで驚くべきことを成し遂げたが、大きな障害もあり、Rustのほうが適していた技術的理由もあったため、WickにとってRustに価値があったかを判断するにはまだ早いと考えている
- 素早く反復する必要があるなら、Rustは適していない可能性が高い
- スコープが分かっている場合や、より大きな初期コストを負担できる場合は、Rustを真剣に検討する価値がある
- WebAssemblyの観点が月を追うごとに強まり、一度書いた堅牢なソフトウェアを複数の場所で再利用できる可能性がより現実に近づいている
1件のコメント
Hacker Newsの意見
Rustはかなり使ってきたが、数年たってもいまだに生産性が低いと感じる
最近はZigをよく使っていて、書きたいコードだけに集中でき、どのツールやライブラリを使うべきか悩まなくてよいので、10倍くらい生産的に感じる
Rustがメモリ安全性を提供してくれて、それが重要だというのは分かるが、使い勝手が本当に悪い。Rustを書くたびに制約されている感じがして、いつもライブラリを探したり作業方法を検索したりしなければならず、ただ「コードをタイプする」ことができない
型システムも制御不能なほど膨らむことがあり、ある構造体で実際にどのメソッドを呼べるのか分かりにくいことも多い。Rustは優れたツールで、多くの問題を解決するが、よい汎用言語だとは思わない
Pythonをほぼ20年使ってきた立場から言っても、今はRustでもPythonと同じくらい速く作業できる
Rustは自分の基準では、適切なバランスを完全に外しているように思う。高水準アプリケーションを書くには低水準の細部にうるさすぎ、組み込みやOSを書くには複雑すぎる
前者ならC++、Java、Haskell、OCaml、あるいはGoにCを少し混ぜる選択をするし、後者ならマクロアセンブリのように使うCのほうがずっと適している
Graydon Hoareの当初の構想、つまり線形型、ガベージコレクション、スタック割り当て、グリーンスレッド、CPSを備えたOCaml/SML寄りのもののほうが、ずっとよい言語になっていたのではないかという気が今でもする
Cでは小さなミス一つが未定義動作や厄介ごとにつながることが多いが、Rustにはそれがないので、完全にゲームチェンジャーだった
Zigではライブラリを探したり、どうすべきか調べたりする必要がない、という意味なのか気になる
Rustは各ビットやバイトがどこへ行くのか、どのスレッドで使われるのか、どのような変更方法で扱われるのかを事前に決めるよう強いる。パーサーやマイクロコントローラのレベルでなければ、この過程は退屈に感じる
まず何かを動くようにしてから最適なAPI構造を決めるやり方が好きだが、Rustはそのプロセスと衝突する
Rustの型システムのほうが強力ではあるものの、Swiftでも性能の90%は得られ、ずっと自然に流れる
crates.ioに名前空間がない点が最大の批判点かもしれない
誰でもグローバルで一般的なパッケージ名を先取りでき、crates.ioリポジトリを避けない限り、ほとんどの人はそれを受け入れざるを得ない。しかも、そうして先取りされた一般名のパッケージの一部は、実際に使ううえで最良のパッケージではない
Java式の逆順DNS表記が冗長で面倒だったことへの反動だったのかもしれないが、GitHubのようにユーザー/グループの名前空間をパッケージ名の前に付ける方式がよい中間点だったように思う
その分析をcrates.ioチームに送り、自動化禁止ポリシーがある点も指摘したが、名前を先取りしたという十分な証拠ではない、という返答だった
crates.ioの問題は、明確なポリシーがあっても執行しないことだ。だから短く覚えやすいクレート名はすでにすべて取られていて、取り戻す方法もない
NPM、PyPI、RubyGems、ElixirのHex、HaskellのCabalなど、Rustが登場した2014〜2015年ごろ、Java以外のパッケージマネージャーで単一のグローバル名前空間ではなかった例はあまり思い浮かばない
一部はその後これを直そうとしたが、当時は単にパッケージマネージャーがそう動く時代だった
その後に出てきた他の言語の、より出来の悪い依存関係管理システムは、先行事例からほとんど学ばなかった
言語レベルでグローバルなパッケージデータベースが不要になる利点もある。example.com/your-thingにパッケージを置けば、そのままリリースされるようなものだ
もちろん望むなら、キャッシュや検索エンジンは別途提供できる
http-serverはよくないから使わず、MuffinTopを使うべきだ、というような話で、それをただ知っていなければならない
公認されたパッケージ名という概念は興味深いが、時間がたってエイリアスの背後にあるコードが変わると、実際には混乱を招く可能性が高い
結局、どのエコシステムでもドメインの専門家になる過程の一部として残り続ける気がする
ワークスペースのルートに
.cargo/config.tomlを作るとすべてのクレートに適用されるので、グローバルなClippy lintを設定できるファイル内では
[build]の下にrustflags = ["-Wclippy::lint_name_to_warn", "-Dclippy::lint_name_to_deny"]のように入れればよいただし、
rustflagsは追加ではなく上書き方式なので、RUSTFLAGS環境変数のような別の出典があると、この設定を上書きするlib.rsやmain.rsファイルへlintを追加するスクリプトを走らせている。簡単だRustが職業的に重要になるのは明らかに見えるので学んでいる。本当に好きになりたいし、長所も見えているが、これまで使ってきた言語の中でも最も不快な部類に入る。
習熟すれば嫌いな気持ちが消えることをずっと期待していたが、学習曲線を登るほど、特に愛着が湧くわけではない。
それでいい。上手に扱えても嫌いな言語は、これだけではないはずだ。ただ、Rustを愛していると言う人があまりに多いので、自分も楽しめると思っていた。
Rustコンパイラも、有効なより広いケースを受け入れるように改善されたように思う。
借用チェッカーを理解する鍵は、その基盤にあるメモリモデルを理解することだった。Rustのメモリモデルは、ジェネリクスのような抽象化のための拡張を除けばCと同じだ。
借用チェッカーの規則は最初は恣意的に見えるが、このメモリモデルと深く結び付いている。意図せず借用チェッカーに引っかかる時こそ本当に価値がある。注意が散漫になって作ったバグだからだ。
恐ろしいのは、CやC++のような言語なら、そのようなコードをそのまま受け入れて先へ進めてしまう点だ。
Rustの厳格な型システムと借用チェッカーは、コードを正しく構造化するよう穏やかに後押ししてくれ、自分が使うあらゆる言語でコード設計を改善してくれたと確信している。
人気のあるほとんどの言語が共有している、非常に基本的なプログラマ向けの使いやすさの機能で、C++でさえ昔からデフォルト引数を持っている。
10年間、ほぼ毎週pthreadを呼び出していた元C/C++プログラマだが、今は非同期Rustをどこでも使っている。
非同期がなぜあれほど嫌われるのか分からない。私の考えでは、誰もがあらゆるものに非同期を使うべきだ。見かけ上は単一スレッドの「単純な」作業まで含めて。
筆者のユースケースがWasmだったなら、間違いなく別の見方になっていたはずだ。
大きなバッファを使ったり再利用したりしてアロケーションコストを避ける作業は、昔ながらのスレッドプールの恩恵を受けることも多い。バンプアロケータやシャードアロケータである程度は解決できるが、ベクトル化可能なタイトなループでCPUバウンドになる場合は、スレッドプールのほうがうまく動く。
非同期は良い道具だが、最適な文脈ばかりではない。
Go、C#、TypeScriptにはそのような障壁はない。
例えばTokioから持ってくるデコレータなしでは、
main関数の中でawaitすることすらできないようだ。Engineのようないくつかの項目がSendではないのに、それを非同期クロージャの中で使おうとしている点だ。GPT-4は、スレッド内に
tokio Runtimeを作り、block_on()を使うよう提案した。明日試してみるつもりだ。これが自分にとって初めての本格的なRustプロジェクトだ。「今は非同期Rustをどこでも使う」という言葉が、「Tokioをどこでも使う」という意味なのかも気になる。
Rustプログラミングは虐待的な関係とはまったく違う。コンパイラは最大限助けようとしてくれるし、特にrustcのエラーメッセージは世界最高水準だ。
虐待的な関係により近いのは、Rustコンパイラではなくオペレーティングシステムのほうだ。ハードウェアも同様にアセンブリを正しく実行しなければならないので、そういう見方をすれば虐待的な関係と言える。
Rustのエラーメッセージは唯一無二で、近づけるコンパイラもない。
付け加えると、最近LaTeXを使ったが、エラーメッセージはひどかった。何が間違っているのかをエラーをにらみながら把握する過程は悪夢のようだった。
FutureがもはやSendとSyncではないというエラーを生み出すのは本当に嫌だ。コンソール全体がエラーで埋め尽くされ、本当の構文エラーはその中間のどこかに埋もれている。
主な不満は、いまだにライフタイムを完全には理解できていない点で、コンパイラも毎回助けられるわけではない。理解はできるが、コンパイラが保守的に判断するからだ。
C++ のテストでは、よく「コンパイルが通ればだいたい正しい」と言う
Rust が多くのエラーを処理してくれるので、よくあるテストケースが無意味になると思うなら、それは他の言語で正しいものをテストしていなかったというサインだと思う
テストすべきなのは言語自体の問題ではなく、ビジネスロジックである
テストコードを見て「JavaScript ならテストするが、Rust ではしなくていい」と感じるなら、そのテストは単に削除すればよい
「ビジネスロジック対言語の問題」という区別はない。言語はビジネスロジックが載る土台だからだ
失敗の仕方についてテストしないなら、テストに何の意味があるのか分からない
慣れ親しんだ多くの言語と違って C++ には IFNDR があり、冗談めかして「これは C++ プログラムか?」という問いに対する偽陽性と呼ばれる
標準に準拠した C++ コンパイラは、書いたコードが意味をなしていないと疑われる一部のケースについて知らせることを禁じられており、そのまま処理を続けて何かを出力しなければならない
それは動く実行ファイルかもしれないし、毎週金曜日に大惨事を起こす実行ファイルかもしれない。知る方法はない
ISO 標準はこうしたケースを識別してはいるが、あまりに曖昧で何が含まれるのか正確に把握しにくく、私の推測では、現在のほとんどの非 trivial な C++ ソフトウェアは実際には IFNDR だろう。言語全体を拒否したほうがよい
Rust は、コンパイラが行うすべてをプログラマーが完全に制御し、意識していなければならないという考えを、ついに打ち破ったように思う
実際には何十年も前からそうではなく、コンパイラはほとんど魔法に近かった。Rust は借用によってその流れを大きく巻き戻し、人々がコンパイラは自分よりよく知っているという点に安心できるようにした
さらに安心できるようになってほしい。アルゴリズム上必要な場合でなければ、コレクションを明示的に先頭から反復する必要がないようにすべきだ。多くの処理は暗黙的に並列化されるべきだ。Rust らしい Bash があればよいと思う
Rust は自分が何をしているかがかなり透明で、コンパイラの魔法には非常に保守的だ。言語はヒープ割り当てをせず、参照カウントもしないし、暗黙の数値型変換もない
暗黙にコピー可能だと宣言していない型はコピーせず、それも単純な浅い
memcpyでコピー可能な型でのみ合法であるRust は至るところでゼロコスト抽象化を使うため、どのようなコードにコンパイルされるか予測しやすく、たいていは単純だ。標準型の基本的なレイアウトもよく知られているので、
Vecの走査はポインタを増やすループにコンパイルされると分かるし、暗黙の並列性はない借用を「コンパイラがプログラマーよりよく知っていること」と表現するのは奇妙だ。借用は型検査に似ている。ある型を一時的なものだと宣言しておきながら、長く生きるもののように使おうとすればエラーになる
Foo構造体を返すと宣言した関数でBarを返すとエラーになるのと同じだ。コンパイラが「よりよく知っている」理由は、単にバグを書いたからである借用もガベージコレクションなしに直接ポインタ使用へコンパイルされ、C ABI の構造体と関数では文字どおり C ポインタと同一であることが保証される。コンパイラより自分のほうがよく知っていると思うなら、
unsafeでライフタイムを回避することもできるiter()呼び出しの名前を変えるだけでよいただし、それが暗黙的であるべきだという点には同意しない
ある意味では、Rust は C よりもプログラマーに多くの制御を与える。たとえば Rust はインラインアセンブリを標準でサポートするが、C のインラインアセンブリはベンダーごとの拡張に依存する
ただし、便利なデフォルトは大きく異なる。Rust で安全でない型キャストを行うには多くの手順と慎重さが必要で、C より多くの規則に従わなければならない
特に Rust の参照は事実上すべて
restrictのように動作し、生ポインタから安全な参照へunsafeキャストする際にこれを壊すのは非常に簡単だ。そのため、選択肢があるならそうしたコードを書かない強い動機が生まれるむしろ、より明示的な制御が欲しいし、より表現力のある型システムでその負担を減らしたい。理想的には Rust の型システムが Prolog の変種のようになってほしい
いつ型制約に投資し、いつしないかを学ぶのは重要な教訓である
Rust だけの問題ではないが、表現の仕方は少し異なるかもしれない
過度に型付けされた C++ と、過剰に抽象化され型付けされた Java を扱ったことがあるが、どちらも同じ種類のリファクタリング問題を抱えている
逆に、型が不足しドキュメントも不足している Go も多く見てきたが、特定の値があちこちに散らばってランタイムの地雷になり、リファクタリングが本当に深刻に難しくなることがある
初期の進捗感はより早く得られるが、たいていはバグをユーザーに配布することになる
このトレードオフに魔法のような正解はない。Rust はこの軸でかなり幅広い選択肢を提供しているほうだ
「Rustは一日中、毎日、以前の生活では完全に普通だと思っていたことについて叫び続ける」という話は、優れたCコンパイラでもすべてのフラグを有効にすれば似たようなことをする
選択的に叫ぶのを止められて、意図的に悪いコードを書けるようにしてくれる言語とコンパイラが好きだ。素早く書ける、動く悪いコードは、完璧だが永遠に時間がかかるコードより良いことが多い
悪いが動く概念実証を作ってから、より悪くない形に直せばよい
「新規ユーザーはうまく呼び込めるが、ライブラリやツールが劇的に良くなるわけではなく、特定のユースケースを処理する一回限りのフォークだけが生まれる」という点は、年数とは無関係だ
コア開発者を引きつけるのは難しく、魅力的にするには多くの努力が必要だ。さらに文化的な慣習は初期採用者が決めるもので、慣習がないことも悪い慣習と同じくらい有害な場合が多い
Pythonを見ると、開発環境と実行環境に緩く向き合った結果、Pythonプログラムを開発または実行する方法が50個ほど競合するようになった
最も広く使われているパッケージリポジトリであるPyPIは何年も混乱しており、既存パッケージの上に積み上げる人は少なく、名前はランダム単語生成器のように見え、エコシステムには悪意あるコードが多く、コマンドラインからパッケージ検索すらできない
これは言語のせいではなく、傍観者のようにしていたコミュニティとコアチームのせいだ。文化はその中心にある技術よりも重要だ
Pythonだけを取り上げて批判したいわけではなく、その問題をよりよく知っているからだ。Cは半世紀にわたって存在しているが、そのコミュニティも、より現代的な言語が用意した解決策の半分もきちんと整理できていない
コンパイラは直せと叫ぶが、完璧に磨き上げる前に、ただアイデアを試してみたいという状況のことだ