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

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だけど少し良い/違う」ものにするという面白い結果だよね。

Hacker Newsで議論の続きを見る