Rustを書き直すとしたら
(josephg.com)- Rustはメモリ安全性と性能、代数的データ型、Cargoの利点がある一方で、安定チャネルでの言語進化が遅く、「まだ完成していない第1世代製品」のように感じられるという問題意識から出発している
- コミュニティが大きくなった後、合意ベースの意思決定がボトルネックとなり、コルーチンのように実装はされていても stable Rust では長く使えない機能が積み上がっている
- 仮想的な「seph edition」は、既存の Rust と Cargo のエコシステムを維持しつつも breaking change を許容し、効果システム、コンパイル時 capability、構造体フィールド borrow、comptime などを試そうという構想である
- サプライチェーンリスクを減らすには、ファイル書き込み、ネットワーク、FFI、
unsafeのような機微な機能に capability を付け、呼び出し側が明示的に許可すべきだとしている - 提案の大半は既存の Rust と互換性がなく、capability を追加するだけでも semver 互換性を壊す新たな失敗条件が生まれるため、新しい edition やコンパイラ fork が必要になる
Rustが「第1世代製品」のように感じられる理由
- Rustは当初、代数的データ型、性能を損なわないメモリ安全性、モダンなパッケージマネージャのおかげで非常に魅力的な言語に見えた
- 約4年間使った後では、言語が「いつも少し足りない状態」に留まっているように感じられる
- stable Rust に新機能が入る頻度が下がり、言語進化の速度も大きく鈍っているという不満が核心にある
- Rust unstable book には約700個の unstable featureがあり、そのかなりの部分は標準ライブラリの変更である
- Coroutines は RFC から7年経った機能で、コンパイラには実装されているが stable Rust の利用者はいまだ使えない
- Rust の RFC プロセスは、良いアイデアが長く滞留する場所のように見える
Mutex改善の議論は、25人が2年間で200件以上のコメントを残したが明確な結論に至らなかった事例として挙げられている
仮想的な「seph edition」の構想
- コンパイラを fork しつつ既存の Rust コードはそのままに、別の Rust edition として「seph」を追加する方式が想定されている
- この edition では breaking change を許容しつつ、コンパイラが mainline Rust を引き続きコンパイルできるなら、Cargo の既存 crate を使い続けられる
- 変えたい軸は大きく5つある
- 関数 trait と効果システム
- コンパイル時 capability
Pin、move、構造体 borrow の再設計- Zig 風の
comptime - 小さな構文・標準ライブラリ改善
関数 trait と効果システム
- Rust には構造体に対する trait はあるが、関数にもより豊かな trait/effect を付けられるべきだという提案である
- 関数が持ちうる属性は次のように整理される
- panic する可能性があるか
- スタックサイズが固定か
- 最後まで実行されるか、
yieldやawaitを行うか - coroutine の場合の continuation 型
- 純粋関数か
- 間接的に
unsafeコードを実行するか - 終了が保証されるか
- 関数のパラメータと戻り値の型を、その関数に対する associated type のように公開すれば、現在の stable Rust では不可能な型参照が可能になる
- 例として
some_iter::Outputのように関数の戻り値型へ直接名前を付け、構造体フィールドに入れるコードが示される - Linux kernel のように、特定のコードブロックが絶対に panic しない保証が必要なとき、
#[disallow(Panic)]のような印で再帰呼び出しまで含めて panic 可能性を検査できる - コンパイラはすでに
Fn、FnOnce、FnMutのような関数関連 trait を扱っているが、現状の形はあまりに貧弱だと評価されている - さらに詳しい参考資料として Yoshua Wuytsの effect system に関する記事と発表 が挙げられている
コンパイル時 capability でサプライチェーンリスクを減らす
- ほとんどの Rust プロジェクトは多くのサードパーティ crate を取り込み、小さなユーティリティ crate でも悪意あるアップデートによってサプライチェーンリスクを生みうる
- メモリ安全性において
unsafeが明示的な opt-in であるように、ファイルシステム・ネットワーク・FFI・raw pointer 関連の機微な機能も明示的に許可すべきだとしている - 標準ライブラリのセキュリティ上センシティブな関数に marker tag を付ける方式が提案されている
- 例:
std::fs::write(path, contents)は任意のパスにファイルを書き込めるため、#[cap(fs_write)]のようなタグを持つ - コンパイラはその関数を呼び出す call tree 全体を自動的に taint する
- 例:
- サードパーティ crate が
fs_writecapability を必要とする場合、呼び出し側はCargo.tomlや呼び出し箇所の annotation で明示的に許可しなければならない - 許可しない場合、コンパイラは
foo::do_stuff()がローカルファイルシステムへの書き込みを行うが、foocrate はその capability で信頼されていないというエラーを出せる human-sizeやserdeのような多くのユーティリティ crate は特別な capability を必要としないため、作者が悪意あるコードを追加してもファイル書き込みやネットワーク動作をコンパイル段階で防げる- 代替案として、センシティブな API が別個の
Capabilityパラメータを受け取るように変える方式もある- 例:
std::fs::writeがFsWriteCapability値を要求する Capabilityオブジェクトの生成は root crate でのみ可能に制限する
- 例:
- この方式はボイラープレートが増えるがより柔軟で、
build.rsスクリプトやunsafeblock にも似た扱いが必要になる - crates.io がハッキングされて
serdeに cryptolocker コードが混入する状況でも、capability 方式ならそのコードは複数の開発者マシンで実行されたりバイナリに含まれたりする前にコンパイルエラーになる
Pin、move、構造体 borrow の再設計
Pinは borrow checker の隙間を回避するための複雑なハックであり、後方互換性を維持しようとして生まれた応急処置だと評価されている- 実際に必要なのは、移動可能な型を示す
Movemarker trait に近いものだと考えられている - 現在の Rust には
Pintrait はなく、Unpinと!Unpinがあるが、この二重否定の形が概念をより難しくしている Pinは参照型にしか適用できないため、Boxで包むコードがあちこちに生まれるという批判がある- 例として Tokio stream wrapper、
ouroboros、async-trait、self_cellのような helper library が挙げられている
- 例として Tokio stream wrapper、
Future::poll(self: Pin<&mut Self>, ..)のように pinned 値を受け取る関数も複雑になり、projection を扱うための別 crate が必要になる- 関数内部では borrow checker が変数を「owned」「borrowed」「mutably borrowed」の状態で管理しているが、その状態はプログラマには直接見えない
async fnはコンパイラが hidden struct を作って中断状態を保存し、このときあるフィールドが別のフィールドを borrow する形が生じうる- Rust には構造体フィールドが borrowed state にあることを表す構文がなく、フィールド間 lifetime も直接表現できない
- 提案される方向は、borrow checker を拡張して構造体フィールド borrowを直接書けるようにすることだ
- 例:
y: &'Self::x Vec<usize>のような「local borrow」構文 xが borrow されていることをコンパイラが把握し、関数内部の borrowed 変数と同じ制約を適用する
- 例:
- この構文は self-referential struct や、AST における
source: Stringとast_nodes: Vec<&'Self::source str>のような形を扱うのにも使える - borrowed field を持つ構造体は移動できないため
Moveを実装しないことになり、さらに自分自身を安全に移動させるMovertrait も検討されている
Zig 風の comptime と Rust macro の問題
- Rust コンパイラは実質的に Rust、Rust macro 言語、proc macro まで複数の言語を同時に扱う構造に見える
- Rust 自体は良いが、macro 言語は良くないという評価が核心である
- Zig の comptime は、コンパイラが小さなインタプリタでコードの一部をコンパイル時に実行する方式である
- 関数、パラメータ、
if、loop をコンパイル時計算として指定でき、non-comptime のコードは実際のプログラムへ emit される - Zig の
std printcase study は format string を comptime パラメータとして受け取り、comptimeloop で解析して出力コードを生成する例である - Rust の
println!()実装はformat_args_nlのような内部関数を呼び出しており、この関数はコンパイラにハードコードされていると推測される - Rust compiler の作者たちでさえ Rust macro 言語を使いたがっていないように見える、という批判が続く
構文と標準ライブラリの小さな修正案
Range<T>がT: CopyのときCopyを実装する問題を直したいという項目がある- associated type を含む derive の問題も修正対象として挙げられている
- 関連 issue: derive with associated types
- 例: Playground
if let式は logical AND をサポートすべきだという提案がある- 望ましい形:
if let Some(x) = some_var && some_expr { } - 現在の回避策は
(some_var, check_foo())のタプルマッチングだが、some_varがNoneでもcheck_foo()が実行され、通常のifの short-circuit 動作と異なる - 例: Playground
- 望ましい形:
- raw pointer ergonomics も改善対象として挙げられている
- 参照では
myref.xと書けるが、pointer では(*myptr).xや(*(*myptr).p).yのように書かなければならない unsafeコードは可能な限り読み書きしやすくあるべきだという立場である
- 参照では
- built-in collection type はグローバル allocator ではなく、コンストラクタで
Allocatorを明示的に受け取るように変えたいという提案がある asyncにも改善が必要だが、別の記事が必要なほど扱う内容が多いとしている
互換性と実現可能性
- 提案の大半は既存の Rust と互換性がない
- セキュリティ capability の追加も、crate に semver 互換性を壊す新しい種類の失敗条件をもたらすため、新たな Rust edition が必要になる
- 数年前なら RFC を書いていたかもしれないが、長い GitHub RFC コメントの過程を経て、また1つの未実現アイデアになる状況は避けたいとしている
- 結局はコンパイラを自分で fork する想像に戻るが、取り組むべきプロジェクトが多すぎるという現実的な制約が残る
1件のコメント
Hacker News の意見
Rust の RFC プロセスが良いアイデアの墓場だという見方とは逆に、Rust のコアチームがプログラミング言語に新しい機能を追加しにくくしているのは正しい方向だと思う
言語の表面が肥大化し、一貫性がなく、予測不能になるのを防ぐべきだ。Swift も最初は好きだったが、
isMultiple(of:)のような重複した関数名、SwiftUI のための波括弧のパース規則、参照/値型と可変性の規則、クロージャ引数の省略表記などが増え続け、結局あきらめた。良いアイデアはよくあるものなので、Rust はできるだけスリムに保ってほしいたとえば
impl Traitを返す関数は使えるし、構造体は任意のフィールドを持てるが、impl Traitとして返された値を構造体のフィールドに入れることはできない。型に名前を付けられないからだ。if a && bとif let Some(x) = xはできるが、if let Some(x) = x && bはできない。こういう点は直ってほしい。コンパイラのコード行数という基準では Rust は大きくなるだろうが、学習して使う複雑さという基準では、こうした機能の穴のほうがむしろ言語を複雑にしている。Pinは標準ライブラリに実装するうえで大仕事ではなかったが、決してスリムな機能ではなく、認知負荷が非常に大きい。単純なコンパイラと使いにくい言語より、複雑な借用チェッカーと読みやすい Rust コードのほうがよいと思う仮に待機中のアイデアが 100% 受け入れられて最終的に作業されるとしても、入ってくる速度と出ていく速度の差のためにそう見える。RFC の著者たちより先に進みたいなら RFC を書くのではなく、ドキュメントとテストまで備えた本番投入可能な実装を作り、ツリーにきれいにマージできるようにするほうがよい
他の型の const generic としても、配列長としても使えない。単に値が必要なら関数を定義して返せばよいので、現状ではあまり役に立たない。きちんとサポートされれば、暗号化ライブラリの
generic_arrayやtypenumのような補助クレートをなくせる。それでも Rust チームが機能追加に慎重であるべきという点には同意する依存関係の状況はかなり深刻なのに、それを認めようとする人はあまりいないように見える。最近見た例は cargo-watch クレート
要はファイル変更を監視してコンパイラを再実行するだけの単純なアプリで、実装は1,000行にも満たない。ところが依存関係をベンダリングすると、8,000個を超えるファイルにわたってRustコードがほぼ400万行にまで膨れ上がる。単純なファイル監視ツールとしては過剰
https://crates.io/crates/cargo-watch
C/C++は、広く使われている言語の中でnpm式のパッケージマネージャが一般的ではないほぼ唯一の例なので、ほとんどのライブラリは自己完結しているか、依存関係が少なく選択的なことが多い。
efswは依存関係のない7,000行のC++ファイルシステム監視ツール。ゲームプログラミング界隈の単一ヘッダーライブラリであるstb_*、cgltf、そしてDear ImGuiは、使った中で最も快適だった。新しいパッケージマネージャでは、推移的依存関係の禁止が全体として利益になる可能性があると思う。大きなライブラリ依存はユーザーに直接インストールさせるか、コールバックを提供するか、標準機能にできるhttps://github.com/SpartanJ/efsw
https://github.com/nothings/stb
https://github.com/jkuhlmann/cgltf
https://github.com/ocornut/imgui
ファイル監視ツールは、特に複数プラットフォームをサポートするなら決して単純ではないと思う
cargo watchの一部はWin32 APIラッパーライブラリを取り込んでいるが、これはWin32呼び出しへの自動生成バインディングなので巨大にならざるを得ない。多くの言語の標準ライブラリも数百万行あり、アプリはその一部だけを使う。C++のBoostも怪物のように大きいが、開発者は一部の拡張だけを使う。むしろisOddやis evenのようなパッケージ1つがエコシステムを壊しかねないnpm式の地獄より、多くの人がメンテし依存している、より少数の大きな依存関係のほうがましで、Rustは概ねそちらに近いcargo-watchの依存関係の大半は、clap、cargo_metadata、watchexecという3つの直接要件から来ているようだ。clapはプラットフォーム別のCLI要素を多く引き込み、cargo_metadataはserde系を多く取り込まざるを得ない。watchexecには改善の余地があり、同じ組織が管理するcommand-groupに依存していて、これが無条件にTokioを要求する。より大きな問題は、ほかのクレートの過剰な依存関係を簡単には直せないこと。古いwatchexecに縛られたクレートがあると、Cargo.lockの調整で合わせられる場合もあるが、通常は難しく、[patch]で回避する必要がある。特定バージョンのクレートを置き換える「stand-in」を簡単に定義する方法があればよいが、既存のパッケージマネージャにとっては大きな研究課題になりそうRustはもはや興味深い新興言語ではなく、広範な採用へ向かう段階にある。機能開発が遅くなるのは自然で健全
今は設計ミスのほうが低速な開発よりはるかに有害。Rustが興味深いのは格好いい機能のためではなく、メモリ安全でガベージコレクションがなく、本番投入可能な新しい種類の言語だから。重要な場所に実際に導入されることのほうが、言語をさらに良くすることより興味深く、そのためには慎重に運営されているという信頼があるほうが容易になる
スプレッド演算子、ジェネレータ関数、
async、アロー関数、leftpad、新しいDateなど、重要な機能のリストは尽きない。JSはRustよりはるかに古く、はるかに広く使われており、複数の本番実装があるため、新機能はすべて足並みをそろえて実装しなければならない。ES5あたりで停滞期はあったが、違いはECMAScript標準委員会が正気を取り戻したことにあるように見えるRustaceanたちはあらゆるものをRustで書き直すことに強迫的なので、Rustを書き直すという記事ならメタな風刺ジョークだろうと本気で思った
意思決定の遅さを先に批判しておきながら、安定化されていない理由として意思決定とはあまり関係のない機能を並べているのは少し変
たとえば コルーチンは、きちんと解決するのが難しいエッジケースがあって止まっています。コンパイラの中に「ただ有効にすればいい」完成済みの実装があるわけではなく、多くの場合には動くものの、安定版で有効にできる状態ではない未完成の実装です。関数トレイトも、いくつかの技術的理由や将来の機能との相互作用のため、現在の形では安定化しないことが明示的に決定されています。過去に戻れば別の設計にしたであろう部分もありますが、その多くはチームもリソースもずっと小さかった Rust 初期の決定に関係しています。今ならより良い選択ができるからといって Rust 2.0 的な断絶を作ろうという考えもあり得ますが、Python 2 から 3 への移行の大惨事があまりに大きかったため、荒い角を受け入れるほうがよいと考える人が多いです。Rust の週刊ニュースレターを見ると、RFC の承認と安定化の決定は毎週処理されています。時間がかかりすぎる場合はありますが、人・調整・時間の不足は、技術的な問題より解決が難しいことが多いです
この記事は Josh Triplett の Reddit での回答もあわせて読むべきです。記事の主要な例の一つである Mutex は、単に間違っています
https://old.reddit.com/r/rust/comments/1fpomvp/rewriting_rus...
追記: 同じコメントはこちらにもあります
https://news.ycombinator.com/item?id=41655268
Rust を学んだとき最初に感じたのは、想像できるほぼすべての機能がすでに入っているように見えることでした
Rust チームが何かを拒否していないという意味ではありませんが、それでも人々はさらに多くの機能を求めます。正当なものもありますが、中には開発者の 2% しか使わない機能を、開発者の 1% しか理解できない言語にどうしても入れるべきだ、というふうに見えるものもあります。複雑な言語がさらに複雑になる必要はありません。Zig はよりシンプルで、おそらくより速く、コミュニティの騒動もずっと少ないです。Zig にもっと資金が回ってほしいです
Rust は可能なすべての機能を入れようとしているわけではありません。ただし GAT や TAIT のように、それがどんな問題を解決するのか明確に知らないと、そう感じられることはあります。Zig は優れた現代的な言語かもしれませんが、メモリ安全性を目標にするなら選択肢にはなりません
たとえば
Pinと future の相互作用は言語にとんでもない複雑さを追加しており、その一部は不要だと思います。Pinがまったくない Rust 風の言語があればと思います。借用チェッカーも、構文と実装の両面で単純化する方法がありそうですが、まだ具体的には考えられていません。今では言語をフォークしない限り変えるのは難しいでしょうが、借用チェッカーを使う言語は Rust が最後ではないはずです。次世代では、より大きな言語にしなくても改善できる余地が多いと期待していますRust に関連する騒動は見かけますが、たいていは Rust の使用や採用に抵抗する側から起きています。最近の Rust for Linux の騒動のようなものです。コミュニティ内部でよくあることではないように思いますが、見落としている可能性はあります。Zig は素晴らしいですが、まだ プロダクション対応 ではありません
リンク先コメントの投稿者は、複数言語における同期プリミティブを広範に分析したうえで、Rust の
MutexやRwLockのような同期プリミティブを、主要 OS ごとに下位の OS ネイティブ機能を直接使うように書き直したLinux の
futexのようなものを使って、より高速で小さく、全体的に優れたものにし、その過程で Rust の並列プログラミング本も事実上書いた。Rust 以外の並列プログラミングにも有用https://www.oreilly.com/library/view/rust-atomics-and/978109...
7年間コルーチン方面で遊んでいただけでもない。非同期関数、非同期関数を含められるトレイト、
AsyncWriteとAsyncReadの標準化に必要な複数の機能を追加し、nightly にはジェネレータ実装もある。完全に一般的なコルーチンの複雑さを受け入れるのか、ジェネレータで止めるのかも議論中。AsyncIteratorのように進みが遅い機能もあるが、活発な作業も多い。一方では言語が遅すぎると言い、もう一方では速すぎると不満を言うのは、いつも興味深い関数トレイトとエフェクトシステムについても最近大きな設計探索があり、
async、try、constの組み合わせごとに関数を何度も書かずに済む解を出したいと考えている。悪意あるクレートのサンドボックス化は言語レベルの問題ではなく、検証器とランタイムサンドボックスの組み合わせが必要で、WebAssembly コンポーネントのほうが可能性がありそう。ただし、アロケータや非同期ランタイムの選択、64ビットプラットフォーム前提のようなコンパイル時 capabilityには強い関心があり、proc macro のサンドボックス化は悪意の防止ではなく正確なキャッシュのために望んでいる自己参照構造体は構文の問題ではなく、借用チェッカーが扱うには非常に難しい問題。部分借用はクロージャのキャプチャではすでにサポートされているが、公開 API に露出させるときに安定したセマンティックバージョニングをどう維持するかが鍵になる。名前付きの「borrow group」のような案が有力。
comptime方面も複数の方向で作業中で、macro_rulesを強化する RFC も最近書かれた。Rangeのimplは、エディションを通じた非互換変更と結び付いてすでに進行中。if letと論理 AND の結合には不安定機能があり、安定化に近い。ポインタのフィールドアクセス構文を改善する提案も複数あったが、言語表面をさらに増やすことが得なのか損なのかは未解決の問い。Rust のエディションのおかげで、十分に説得力のある設計なら変えられるものも多い。不安定機能が700個あるのは実際の問題で、安定化の見込みが低いものを整理する大きな作業が必要より宣言的なマクロ、
-sysクレートのロジックを共有ライブラリへ委譲すること、cfg(version)/cfg(accessible)はユーザー実装の必要性を大きく減らすだろうが、ランタイムは依然として残る。考えれば考えるほど、cackle のACLは、proc macro、ビルドスクリプト、ランタイムコード全体で作業を追跡し、依存関係ツリーの使用を監査する拡張可能な方法として良さそうに見える。cargo-redpenも呼び出し監査ツールとして発展中だと聞いたが、cackle のようにより高いレベルを想像しているhttps://github.com/cackle-rs/cackle
人々が Rust は大きな言語なのでこれ以上大きくなってほしくないと不満を言うのは理解できるが、現在の半生の async 実装を維持したところで、言語が小さくなったり単純になったりするわけではない。単に言語が悪くなるだけ。部分借用も公開 API ではなく、同じクレート内だけでも動くとよい。実際のプログラミング中には絶えずぶつかる問題だ。悪意あるクレートのサンドボックス化がなぜ言語レベルで不可能なのかも疑問。呼び出しツリーに
unsafeなサードパーティコードがなく、システムコールもしない関数なら、すでに渡された引数、ローカル変数、スコープ内のグローバルだけを扱える。この壁を強化してセキュリティ境界として使えるなら、依存関係のサプライチェーンリスクを大きく減らせそうに思えるRust の使命は最初から、性能、安全性、表現力を混ぜ合わせる難しいものだったし、Mozilla が抜けたことで創業者モードを失い、元のコアチームも大半が去ったので、進行が遅くなったのは驚きではない
個人的には、間違った道に進むよりはましだと思う
Rust を書き直すなら、機能をさらに入れるよりも減らす方向に行くと思う
QBE が LLVM に対してそうしているように、コードの10%で Rust の70%を提供するような形。マクロやまれにしか使われない機能の一部を外せば可能かもしれない
https://c9x.me/compile/
Rust であれ何であれ、実際に試してみるまではどれくらい可能かは分かりにくい