世界を動かす技術を、日本語で。

Rustで最も好きではない点は借用チェッカーです

概要

  • Rustのborrowchecker は安全性の象徴として評価されているが、実際には 使い勝手に大きな問題 を引き起こす。
  • 所有権ルールの厳格な適用 が、実用的なプログラムにとって過剰な制約となる場合が多い。
  • 実装上の制限 により、明らかに安全なコードまで拒否されるケースが頻発。
  • 根本的なモデル自体の不自然さ や、現実のプログラムとの乖離も指摘。
  • 解決策は存在するが、多くは本質的な問題ではなく、余計な作業を強いるだけ であるという批判。

Rustのborrowcheckerと安全性神話の再考

  • 2010年代のプログラミング言語 の中で、Rustは特に高評価を受けている。
  • Rust最大の売り は、速度・低レベル制御・高い安全性(バグ耐性)の両立。
  • borrowchecker はRustの象徴であり、所有権ルールをコンパイル時に強制。
  • ガベージコレクション言語並の メモリ安全性実行時コストゼロ で実現する仕組み。
  • Rustコミュニティ はborrowcheckerによる安全性を最大の価値とアピール。

borrowcheckerの本質的な問題点

  • borrowcheckerの最大の問題 は、参照の扱いが極めて面倒になること。
  • 全ての参照のライフタイムをコンパイル時に把握 することが非現実的。
  • 所有権モデル自体が過度に制限的 で、適切なプログラムまで拒否される傾向。
  • 実装の未完成さ もあり、正しい所有権モデルに従うコードまで弾かれる現状。
  • 小さなサンプル では分かりにくいが、既存プロジェクトの構造変更時に深刻化。

borrowcheckerが拒否する実例

  • 異なるフィールドへの同時ミュータブル参照 が拒否される例
    • struct Point { x: f64, y: f64 }
      impl Point {
        fn x_mut(&mut self) -> &mut f64 { &mut self.x }
        fn y_mut(&mut self) -> &mut f64 { &mut self.y }
      }
      fn main() {
        let mut point = Point { x: 1.0, y: 2.0 };
        let x_ref = point.x_mut();
        let y_ref = point.y_mut();
        *x_ref *= 2.0;
        *y_ref *= 2.0;
      }
      
    • 異なるフィールド にも関わらず、ミュータブル参照が同時に存在するだけで拒否。
  • 関数間での排他制約の誤認 による拒否
    • increment_counteritemsを変更しないことが明白でも、 borrowcheckerは判別不可
  • 分岐内での参照の生存期間の誤認 による拒否
    • match分岐ごとに参照が排他的に利用される設計でも、 borrowcheckerは区別できず拒否

「十分に賢いborrowchecker」は実現可能か

  • 実装上の限界 は将来的に改善される可能性はあるが、根本的課題は残存。
  • Polonius のような新しいborrowcheckerの開発も進行中だが、完成は遠い見込み。
  • コンパイラが本質的にプログラムの意味を深く理解できない限界 との類似性。
  • 今後も「明らかに正しいコード」が弾かれる事例は消えない予想

所有権モデル自体の不自然さ

  • 所有権ルール自体が現実のプログラムと乖離 する場合が多い。
  • 例:値の移動による変数の利用不可(move後の変数利用禁止)など。
  • 実際にはバグ防止に寄与しない状況でも、厳格なルール適用 による拒否。
  • 一時値やクロージャ内生成値の参照不可 など、明らかに不要な制限。
  • 双方向参照を持つデータ構造(例:系統樹) の実装困難。
  • GC言語では問題にならない構造も、Rustでは極端に複雑化

よくある反論と現実

  • 「borrowcheckerの痛みは先取りの痛み」 との主張
    • 事前に所有権設計を明示することで、後のバグを防止できるという理屈。
    • 実際には、実用性のない問題を解決するための余計な作業が多い 印象。
  • 「初心者の壁」説
    • 経験を積めば所有権モデルに自然に適応できるという主張。
    • 長年Rustを使っても根本的な不便さは解消されない という実感。
  • 「解決策は簡単」論
    • 例:CloneCopyの導入など、ちょっとした工夫で解決可能という意見。
    • 本質的には問題がないコードに対し、無駄な作業を強いる点が問題

Rustにおけるborrowcheckerとの付き合い方

  • 多くのケースで「とりあえずclone」や「データのコピー」で回避 可能。
    • パフォーマンス重視の言語であるにも関わらず、 論理的要請ではなくborrowchecker回避のためのコピー が推奨される矛盾。
  • Rc/Arc/RefCell/Boxの多用 による所有権の回避策の乱用。
  • コード構造の大幅なリファクタリング を強いられる場合も多い。
  • 「パズル的な楽しさ」 があるため、問題意識が薄れがちだが、本質的な生産性低下を招く危険。

まとめ

  • Rustのborrowcheckerは、安全性のために設計された強力な仕組み
  • しかし実際には、実装・モデルの両面で過剰な制約や不便さを生み出す
  • 解決策は存在するが、多くは本質的な問題ではなく、余計な作業を強いるだけ
  • 本当に必要な安全性と、現実的なプログラミング体験のバランス再考の必要性

Hackerたちの意見

指摘されたフィールドの不連続性の問題についてだけど、借用チェッカーが「関数をまたいで推論できない」わけじゃなくて、フィールドの借用がゲッターファンクションを通じて行われていて、そのゲッター自体が構造体全体を可変で借用してるからなんだよね。これを避けるには、フィールドをパブリックにして直接参照できるようにするか、他の関数にフィールドを渡す必要があるなら、構造体全体を渡すんじゃなくてフィールドの参照を渡せばいいんだ。特定のフィールドだけを借用することを表現する「ビュータイプ」のアイデアもあるけど、これはエルゴノミクスの改善であって、意味的な力の向上ではないんだ。

指摘されたフィールドの不連続性の問題についてだけど、借用チェッカーが「関数をまたいで推論できない」わけじゃなくて、フィールドの借用がゲッターファンクションを通じて行われていて、そのゲッター自体が構造体全体を可変で借用してる。そうだね、さらに言うと、ここで重要なRustの特性がもう一つあるんだ。関数のシグネチャだけがプログラムを型チェックするのに必要なもので、関数の本体の変更が呼び出し側に影響を与えてはいけないってこと。だから、関数のシグネチャで型を推論することはできないし、いろんな制約があるんだ。

記事の最初の例を使ってポイントを示すのは超簡単だよ。別々のメソッドの代わりに、fn x_y_mut(&mut self) -> (&mut f64, &mut 64)みたいなメソッドを定義して両方を返すようにすれば、別々のメソッドの代わりに使えるし、全部うまくいくんだ!これがスケールするわけじゃないけど、そもそもこんな構造にする必要があることはあんまりないからね。

この投稿は借用チェッカーの利点をほとんど無視してるよ。メモリ安全性のことは言ってないんだ。Rustのツリー型所有パターンに従って、借用チェッカーを過度に回避しないコードは、正しい可能性が高いってことを言いたいんだ。借用チェッカーの意図はそうじゃなかったかもしれないけど、結果としてそうなってるのは確かだよ。だから、借用チェッカーはGC言語に比べてコードをちょっと扱いにくくするけど、そのメリットはそれ以上に大きいし、メモリ安全性を超えたところにも広がってるんだ。

より安全で堅牢なプログラムを作る実用的な最終目標もあるけど、pgが『Beating The Averages』で話してるように、こういう慣習に従って協力する方法を学ぶことで、借用チェッカーがあるかのように考えることができるようになると、他の言語に戻ったときでもより良いプログラマーになれると思うんだ。

彼はそれを無視してるわけじゃないよ。この記事のポイントは、著者がそれを具体的な利点として感じていないってことなんだ。確かにそういうことには利点があるけど、著者は自分が書いているコードの種類においては、その手間をかける価値がないと感じているって言ってるんだ。

「正しい可能性が高い。」これは無意味な発言だ。ここで、Rustのような言語の正しさに関する無意味さを示す思考実験をしてみよう。究極の正しさを目指すとする - つまり、あらゆる可能な入力/初期状態に対して、プログラムが既知で決定論的な出力を生成すること。プログラムを書くために2つの言語から選ぶことができる: 1つ目は標準C、2つ目は、Rustスタイルのメモリメンバーシップだけでなく、全てのオブジェクトが定義された型を持ち、その型がオブジェクトが持つことができる値のセットや、そのオブジェクトに対する操作を決定する、という絶対的に厳格なプログラミング言語。基本的に、プログラムがコンパイルされれば、それは定義上正しいという考え方だ。問題は、プログラムを絶対に正しく開発するのにかかる時間はほぼ同じだということ。Cの場合、慎重に設計されたメモリ割り当て(例えば、最初に割り当てるメモリプールのようなもの)でプログラムを書くことになるし、ユニットテストを設計して、valgrindを実行するなどするだろう。2つ目の場合は、型や操作を慎重に設計するのにもっと多くの時間を費やすことになり、コードのコンパイル、エラー修正、繰り返しに多くの手間がかかり、プログラムの開発に時間がかかる。プログラマーが少し無能だと主張することもできる(例えば、valgrindを実行するのを忘れるなど)。だから、2つ目の言語は絶対に正しい可能性が高くなる。しかし、議論はまだ成り立つ - 2つ目の言語では、少し無能なプログラマーが怠けて広範囲な型を定義することができる(TypeScriptのanyのように)、それにより技術的には正しいが論理的なバグが生じる。結局のところ、究極の正しさを求めるなら、どの言語を選んでも関係ない。すべてはプログラマー次第だから。ただし、迅速なプロトタイピングが目的で、入力が特定の範囲に制約されていることが保証できるなら、範囲外のプログラムがメモリバグや何らかの失敗を引き起こすとしても、Cのような言語でプログラミングする方が効率的だし、2つ目の言語では基本的なことのためにもっと多くのコードを書くことを強いられる。」

Rustのツリー型所有権パターンに従ったコード これは結構重要な条件だね。ほとんどの低レベルシステムコードはこの所有権構造を持ってないし、持てないんだ。これが、RustがJavaで書かれていたかもしれないコードを置き換えるのにもっと浸透している理由だと思う。C++が得意な分野(データベースエンジンとか)では特にね。

あんまりRustを使わないけど、この記事の主旨には賛成だよ。ただ、借用チェッカーがRustが実際に広まった唯一の理由だと思う。新しい言語が成功するのは、何かを指摘して「この言語では絶対にできない」って言える何かがないと難しいと思う。そうじゃなかったら、Rustが十分な勢いを得るのは不可能だったし、その文化を作った人たちを引き寄せることもできなかったと思う。そうじゃなかったら、RustはDみたいになってたと思う。Dはほとんど使われてない言語だけど、聞いたことがある人は「どうやらC++より安全で良いらしいけど、C++で全部できるから切り替えない」って言うだろうね。

これも、OCamlが基本的にGC Rustで借用チェッカーがないという事実で少し裏付けられてると思うんだけど、結局は趣味の言語って感じだよね。

どうかな…借用チェッカーがなければ、ゴーランの「プロ」版みたいな、もっと良い型付けや簡潔なエラーハンドリングの構文、合計型を持つ素敵な言語ができるかもしれない。もしStringやArcオブジェクトみたいなものだけを使うなら、基本的にはできるけど、それが必須じゃない方がいいよね!

同意する。比較として、Golangは「奇妙な構文のないErlangのようなCSP」として売り出されたけど、人々はチャンネルがあまり良くないことに気づいたし、ゴルーチンは他の言語のスレッドよりもそれほど良くない。OTPの実際のコアはスーパーバイザーツリーだったけど、それは複雑すぎるから、Golangは基本的にもっと簡潔なJavaになってる。これは悪いことだとは思わないけど、メインストリームになるためには(1)他の言語にはないクールな新機能を発表する(2)その機能が実際にはかなりニッチで、平均的な開発者には理解されないことを受け入れる(3)奇妙な機能を削って「Cだけど少し良い/違う」ものにするという面白い結果だよね。

約20年前、言語の選択は基本的にウェブサーバー用に何を選ぶかに絞られていた。選択肢は - Java(大学でOOPを学んだ人や「エンタープライズ」ソフトウェア開発が多い場所で人気) - Ruby on Rails(当時のホットな新しいもの) - LAMPスタックのPとなるPythonやPerl - 「パフォーマンス」のためのC++ これらはすべてキッチンシンクの選択肢で、結局すべてをやる必要があった。もし過去に戻って、何か非常に一般的なことをしない言語を作って、仕事の妨げになると言ったら、誰もそれを選ばなかっただろう。

記事の後半で、正しさが文化的に強制されることについて話してるのが面白かった: >「もっとあいまいだけど、Rustの正しさへの強い文化的親和性は重要だ。」例えば、YouTubeでRustのカンファレンスのチャンネルを見てみて。多くのトークが正しさについて話してるのがわかるよ。これはJuliaやPythonのカンファレンスではあまり見ないことだね。面白い鶏と卵のアプローチが生まれる。借用チェッカーは確かに厳しすぎるかもしれないし、エッジケースやバグもあるけど、その存在が正しさを重視するオーディエンスを引き寄せたのかもしれない。もし明日借用チェッカーを廃止しても、このオーディエンスはそういう原則に基づいたスタイルを維持するかもしれない。Rustの他のユーティリティもそれに基づいて作られてるからね。すごく興味深いよ。でも、何か新しくて派手なものがないと、人を引き込むのは難しいよね。明らかにセールスピッチを拒否する人たちにとっても。

でも、C++で全部できるから切り替えるつもりはないよ。 これって、Rustについてよく言われることだよね。「上手くなればいいし、ツールを使って気をつければ、同じメモリ安全性が得られる」って。

ジャバは、特にジェネリクスが導入された後、型システムのせいで使うのが大変だったよ。これは俺の意見じゃなくて、ちょっと大げさだと思ってたけど、確かに広まってたのは事実だね。型システムの扱いは、言語が進化するにつれて少しずつ楽になって、特定の型が推論できるようになった。リリースノートを見る限り、借用チェッカーでも似たようなことが起きてる印象がある。でも、実際にその苦労を経験した人たちは、その体験を再構築するのが難しいし、評判が今も正確かどうか判断するのはいつも難しいよね。次に借用チェッカーを持つ言語がどんな感じになるのか、ちょっと気になるな。

これ、数年前にバイオインフォマティクスの一部のサークルで流行ってたことを思い出す。人々はJavaがC++より速いって主張してたんだ。それを「証明」するために、あるタスクのためにかなり効率的なJavaコードを書いて、それをC++に書き直してた。std::shared_ptrを多用してガーベジコレクションに似たものを得ようとしてたんだ。だから、実際のJavaコードがC++で書かれたJavaコードより速いのも無理はないよ。私はC++をほぼ30年書いてて、Rustも数年やってる。Rustの借用チェッカーに苦労することもあるけど、ほとんどいつも自分のせいなんだ。RustでC++を書こうとしてしまうから、C++の考え方でRustを考えてしまうんだ。教訓はいつも同じ。言語Xを使いたいなら、Xを書くことを学ばなきゃいけない。Y言語をXで書くんじゃなくてね。グラフやツリーの実装でインデックス(またはノードIDや不透明ハンドル)を使うのは、C++でもRustでも良いアイデアだよ。シリアライズが簡単で早くなるし、ノードへのポインタを持てないデータ構造を使うこともできる。さらに、ポインタや別々のメモリアロケーションは、数十億のものがあるときにかなりのスペースを取るから、メモリを節約できることもあるんだ。例えば、人間のゲノムを扱うときとかね。

インデックスを使うのが答えになるなら、少なくともOPの主張に対抗するべきだと思うよ。このアプローチは、そもそも借用チェッカーが導入された理由に反してるから。投稿からの引用: 「Rustコミュニティの全ては、コンパイラによる正しさの確保にコミットしてるんだ。そして彼らは、人間が手動で参照を扱うことを信用できないという前提で借用チェッカーを作った。なのに、その借用チェッカーが参照を使えなくする時、彼らの解決策は…手動で管理しろって、全く安全性も言語サポートもゼロで?!? 皮肉がすごい。」

人間の直感に合った言語ってのもあるんだよね。C++やRustはそういう言語じゃないから、深く学ぶ必要がある。TypeScriptやPython、Goみたいな言語はもっと直感に合ってて、詳細やパターンをそんなに学ばなくても自然に流れてくる。これってめっちゃ大きなことで、言語を習得するのに1週間で十分、2週間でマスターできちゃう。C++みたいな言語は…1年でマスターするのも無理だよ。これが言語の優劣を示すわけじゃないけど、直感ってのは一つのトレードオフだね。

借用チェッカーに対する愛情はすごくあると思う。Rustコミュニティの多くの人がエコシステムに取り組んでるから(例えば https://github.com/linebender)、何年もかけてAPIを構築してるんだよね。そういう場合、すごく制限のある言語が役立つんだ。APIの形を言語レベルで定義するから、Rustに慣れてると自分のAPIにもすぐに慣れられる。だから、制限が「恣意的」かどうかはあまり関係ない。逆にゲーム開発みたいなものだと、コードには明確な終了日があって、開発中にプログラムの形が大きく変わるから、時間をかけて硬直性を高めたくないんだよね。

彼の借用チェッカーの過剰の例の一つ:struct Id(u32); fn main() { let id = Id(5); let mut v = vec![id]; println!("{}", id.0); } は、現代のC++では合法ですらない。これは単なるムーブセマンティクスだよ。ムーブすると、古い名前ではもう存在しない。彼はRustの二つの重要な問題を指摘してる。プログラムを変更する必要があるとき、所有権の管理をやり直すのはかなり時間がかかる。これに数日を費やすのはRustでは日常茶飯事だ。Rustはその分野で技術的負債を前払いさせる。もう一つの大きな問題は逆参照。Rustはその分野で良い解決策をまだ持ってない。よくあるのは、AがBを所有し、BがAを参照できるようにしたいということ。Rustはそれを直接許可しない。一般的に使われる三つの回避策がある。 - すべてのアイテムを配列に入れて、インデックスで参照する。で、その管理をランタイムコードで行う。Bevyゲームエンジンはこれを行っている大きなRustシステムの一例だ。問題は、無効になった後もインデックスを保持している形でダングリングポインタを再作成してしまうこと。これで生ポインタのほとんどの問題が出てくる。少なくとも、正しい型の構造体へのインデックスにはなるけど、それが保証されるのはそれだけだ。Rustのクレートでそのアプローチのバグを見つけたことがある。 - 生ポインタを使ったunsafeコード。これはあまり良い結果にならないことが多い。これをやってるクレートは、Rustコードでデバッガを使わざるを得なかった唯一のケースだ。 - Rc/RefCell/ランタイムの.borrow()。これで全てのチェックがランタイムに移る。安全だけど、二つのものが同じアイテムを借りるとランタイムでパニックになる。これはRustの根本的な問題だ。前にも言ったけど、これを解決するには、明示的な.borrow().borrow_mut()の呼び出しのスコープをチェックして、同じオブジェクトの全てのスコープが互いに重ならないことを確認するアナライザーが必要だ。これは、すべての.borrow()呼び出しがローカルスコープの結果を生成するなら、概念的にはそれほど難しくない。完全なコールチェーン分析が必要になるけどね。これはデッドロックの静的検出に似ていて、これは研究の知られた分野だけど、まだ実際には見られていない。Rustの開発者とこの話をしたことがある。問題はジェネリクス。ジェネリックを呼び出すと、呼び出しコードはそのジェネリックが生成するコードが何か分からない。何を借りるかも分からない。ジェネリック展開の後にこの静的分析を行う必要がある。Rustはそれを避けている。ジェネリクスはすべてのケースでコンパイルされるか、全くされない。こうした制限されたジェネリック展開は、C++のテンプレートインスタンス化の失敗に伴う巨大なコンパイルエラーメッセージを避ける。テンプレート展開後の静的分析は望ましくないとされている。これを修正するには、「この関数は'foo'を借りるかもしれない」といった注釈が必要になるかもしれない。これだとすぐに煩雑になる。人々は手動で推移的閉包を行うのが嫌いだ。Javaのチェック例外を思い出してほしい。これはプログラミング言語理論の誰かにとって良い博士論文のテーマだ。解決策があれば役立つ、よく知られた難しい問題だ。簡単な一般的な解決策はない。

問題は、無効になった後もインデックスを保持している形でダングリングポインタを再作成してしまうこと それは本当だけど、ランタイムの緩和策として、アロケーションに世代カウンター(デバッグビルドのみにするかも)を追加すれば、use-after-freeをキャッチできる。少なくとも、これによりセキュリティの脆弱性になる可能性は低くなるよね。敏感な情報をこれらの配列の中に入れない限りは。

C++から来た人にはいつも言ってるんだけど… std::moveが全ての周りにあると想像してみて(まあ、Copyのものを除いて)… そしたら全部理解できるようになるよ。問題は、このメンタルモデルが、値渡し(コピーや参照渡し)が常に機能する他の言語で働いてきた人には全く馴染みがないってことだ。

現代のC++では正当性がないんだよね。それはただのムーブセマンティクス。ムーブしたら、古い名前ではもう存在しない。実際、逆なんだよ。Rustは破壊的ムーブを持ってるけど、現代のC++は非破壊的ムーブ。だからRustでは、オブジェクトをムーブした後はもう使えなくて、さらに使おうとするとコンパイラがエラーを出す。一方で、C++のオブジェクトはムーブ後も生きていて、言語によっては使うことが禁止されてないけど、特定のユーザーが提供したムーブ関数によって一部または全部の使用が禁止されることもあるから、そのムーブ関数のドキュメントを参照しないといけない。この違いをよく説明している記事があるよ: https://www.foonathan.net/2017/09/destructive-move/

Rustの借用チェッカーは「一つのオーナー」モデル(木構造)を強制するように設計されてる。複数の参照が必要な場合は、Rc + Weakを使うことができるよ。[0] ダブルリンクリストの実装例: struct Node { pub data: T, pub prev: Option<Weak<Node>>, pub next: Option<Weak<Node>>, } さらに、木構造の代わりにサイクルがある場合は、rust-ccのようなサイクルをサポートするガベージコレクターを使うことができる。だから、静的にはできないけど、Rustはそういう設計じゃないからね。ただし、'staticライフタイムを使うと問題は消える(またはアリーナを使う)。ノードは削除されたとマークできるから、ポインタは常に有効なんだ。同じように、ノードがあまり削除されない場合は、完全に削除せずに削除されたとマークすることもできる(少なくとも兄弟ノードが更新されるまで): struct Node { pub data: Option<T>, pub prev: Option<Weak<Node>>, pub next: Option<Weak<Node>>, } ノードが削除されても(そのペイロードがドロップされても)、リンクリストはまだ歩けるよ。[0]: https://doc.rust-lang.org/std/rc/struct.Weak.html [1]: https://github.com/frengor/rust-cc

移動させると、古い名前では消えちゃう。(強調は俺の) ちょっと思いついたんだけど、移動が新しい場所への参照を更新できるなら、Rustみたいな言語がもっと使いやすくなるかも。ローカル変数をコンテナに移動させると、その変数もターゲットを参照するように更新されるって感じ。 「壊れた」例は簡単に修正できるよ:fn main() { let id = Id(5); let mut v = vec![id]; let id = v[0].0; // 新しい名前を使おう! println!("{}", id ); } でも、もし言語が「移動して参照をその場で更新する」をサポートしてたらどうなるかな?例えば:let mut v = vec![@id]; // 新しいシンボル ここで'@'(または何でも)っていうのは、新しい演算子で、「オブジェクトを移動させて、参照idを移動した値を指すように更新する」って意味なんだ。これは、特定の属性でマークされたパラメータにだけ使えるようにするべきだね。

作者に言いたいのは、俺は借用チェッカーの擁護者か、もしくは過激派かもしれないってこと。そういう役割を喜んで引き受けるよ。システムプログラミング言語に借用チェッカーがないと、もはやC++のような覇権を持つことはないと思ってるから(C++がその権力を手放すときが来たら、だけど)。C++がその権力を持ち続けるか、借用チェッカーのようなものがない別の言語に取って代わられたら、ちょっと悲しいかも。Rustである必要はないけど、Rustの借用チェッカーには(大体は合理的な)制限があって、例えばいくつかの手続き間のことが不可能になったり、一つの関数内では可能だったりする(例えば、&mut Vecとそれから派生した&mut u32を両方同時に共有参照として使って、その後どちらか一方を排他的に使うこと)。もしかしたら、もっと強力で全知的な借用チェッカーを持つ別の言語が現れて、Rustを追い抜くかもしれない。それは確実に起こり得ることで、そうなったらその言語を楽しむことになるだろう。でも、借用チェッカーは(非GC)プログラミング言語において絶対に大きな存在で、今後無視できないものだと思う。(ただ、Zigはここで俺を間違わせていて、すごくクールなことをたくさんやってる。Ziglangの世界でのメモリ安全性の脆弱性がどうなるかはまだ分からないけど。)メモリは常に誰かの所有物で、その有効性は常に誰かによって決まる。言語によってその有効性が強制されるのは本当に貴重だよね。いくつかのケースでGCが欲しいのはもちろん全く正当なことだ。そういう場合はGCライブラリを使うか、GC言語が適切なツールだと思うならそれを使えばいい。

そういう場合はGCライブラリを使えばいい 俺の意見では、GCは少なくともルートセットを見つけるために言語実装からの協力が必要だ。回避策は効率が悪かったり、使いにくかったりする。効率の悪いGCは多くのシナリオでは問題ないと思うけど。

作者がこれを書く動機は十分に理解できる。ただ、作者はRustの全体的な精神を考慮していないし、建設的でない結論は誰にも役立たない。Rustの精神の大きな部分は、恐れのない並行性なんだ。単純に見える誤検出の例は、並行コードでは非自明になる。作者は大規模な並行プログラムを書かないことを認めていて、これが借用チェッカーの有用性を感じない理由を明確に説明している。だから問題は、Rustが彼らに合わないわけではなく、Rustの中心的な言語機能が彼らを助けるのではなく、逆に妨げているということだ。この文章の結論はこうあるべきだった:もし俺みたいに並行プログラムを書かないなら、列挙型やマッチは素晴らしい。arc/boxの構文がなくなれば、言語はもっと俺に合うと思う。ちなみに、もしあなたのコードがカードの家のようなら、それはおそらく早すぎる最適化のせいだ。これを回避する良い方法は、できるだけ抽象化を少なくしてarc/boxを最初に大量に使い、その後プロファイルして最適化することだ。

そうそう、著者の理論的な「ガベージコレクタ付きのRust」だと、めっちゃ多くの並行性バグが出るだろうね。もはやRustじゃなくて、もっと機能的な構文のC#になっちゃうよ。「恐れ知らずの並行性」って、借用チェッカーがもたらす最高のものの一つだと思うし、結構多くの人がその価値を見落としてるよね。

「借用チェッカーの痛みは、既存のプロジェクトに所有権の構造を少し変更しようとした時に感じる。そして、借用チェッカーがコードのコンパイルを拒否する。小さな糸を引っ張ると、借用チェッカーが満足するまでに半分のコードをほどかないといけないことがわかる。多分、僕は複雑なことをやるための「高度な」Rustプログラムを書いてないだけなんだろうけど、3年間プロとしてRustを書いてきて、こんなことには一度も遭遇したことがない。ただのデータポイントとして言っておくよ。もちろん、部分的な借用があればもっと楽になるし、ポロニウスも(投稿で言及されている「有名な」問題を解決するはずで、将来的には自己参照構造体も可能にするかもしれない)あればいいけど、実際にこれが必要になる状況にはほとんど遭遇しない。(例: 僕にとってもっと一般的なニーズは、より強力なconstevalだ。)プロとしてRustを書く前は、OCamlをプロとして書いてた。「ガベージコレクタ付きのRust」を望む人には、OCamlを使うことを勧めるよ!言語は非常に似てるから。」

それ、わかる。僕も一度、全てを所有させようとした時に経験したことがある。今は、明日がないかのようにクローンしまくって、後で最適化するって自分に言い聞かせてる。

ちょっと混乱してるんだけど、3年間プロでRustを書いてて、これに遭遇しないってどういうこと?2020年か2021年にRustを書き始めたとき、すでに借用チェッカーで問題があったんだよね。もしかして、OCamlで習ったイディオムをそのままRustでも使ってるのかな?

OCamlにはRustのエコシステムサポートがないからね。それに、個人的には見た目がダサいと思ったけど、これは主観的な意見でちょっと小さいことかも。

主に借用から所有権に移るときに経験したかな。例えば、フィールドの所有権を持つ構造体があって、それをライフタイム付きの借用に移すみたいな。まあ、特にホットパスにないコードなら、シンプルに保ってクローンすることもできるから、そんなに一般的ではないけどね。

いくつかの人工的な制限はあるけど、その利点が大好きだ!関数がオブジェクトへの排他的な参照を得ると、呼び出し元が使っている間に触れられないことが確実にわかるから、自由に変更できる。呼び出し元が関数に渡したオブジェクトのどこかを参照し続けようとする場合に備えて、入力の深いコピーを防御的に作る必要がない。逆に、ライブラリのユーザーとして、どの関数のAPIを見ても、その引数を一時的にしか見ないのか(その場合、変更や破壊をしても問題ない)、それとも保持するのか、あるいは呼び出し元と呼び出し先で共有されているのかがわかる。これは特にマルチスレッドコードで重要で、関数が参照を保持しすぎたり、予期せずに何かを変更したりすると、デバッグが難しいバグを引き起こす可能性がある。借用チェッカーの制限を理解し、それに対処する方法を知れば、そんなに難しくない。厳しいコンパイラーに対処する方が、予期せずに変更された状態から生じる神秘的なバグに対処するよりも好ましいと思う。ある意味、借用チェッカーはインターフェースをシンプルにする。ルールは制限的かもしれないけど、どこでも同じルールが適用される。1回学べば、参照を使うすべてのAPIから何を期待できるかがわかる。賢くなろうとするライブラリには例外がない。シングルスレッドプログラムに例外はない。DLLに例外はない。-fpointers-go-sidewaysで構築されたプログラムにも例外はない。チェスのゲームのようにトリッキーかもしれないけど、ゲームのルールだけを考えればいいし、相手がチェスボードに駒を貼り付けたかどうかのような奇妙なことを考える必要はない。

そうそう!キャリアの中で最も厄介なバグの一つは、Javaが別のコンポーネントから受け取ったHashSetを変異させることに起因してたんだ。そのコンポーネントは独自にこれらのHashSetインスタンスをキャッシュすることに決めてた。ドーン!リクエストが失敗し始めるのは、以前に無関係なリクエストをしてキャッシュされたオブジェクトを変異させた場合だけっていう、怖い失敗シナリオが生まれた。これは所有権のセマンティクスがそのバグを防げた例だね。(キャッシュされたHashSetへの参照は共有/不変の参照としてしか渡せなかったはずだし、キャッシュされたHashSetの変異は起こらなかったはず)。所有権モデルはメモリの安全性以上のことを意味してる。だから、週末を使ってRustを学ぶと、どんな言語でもより良いプログラマーになれるって言ってるんだ(GC言語でも適切な所有権について考えるようになるから)。

関数がオブジェクトへの排他的な参照を得ると、使ってる間は呼び出し元に触られないって確信できるけど、自由に変更できるんだ。この現実的な問題が2つの方法で解決できるのが好きだな:1. オブジェクトへの非排他的な可変参照を避ける 2. 可変オブジェクトを避ける 前者は複雑さと硬直性をもたらす(Rust)、後者はシンプルさと柔軟性をもたらす(Clojure)。