🙌

Rustの誤解と改善

2025/02/18に公開

ChatGPT にこんな要求をしました。

Rust自体は難しい言語ではなく、むしろ単純な機能の組み合わせで豊かな表現力を提供しています。なのに、他言語の複雑な機能をRustで再現しようとして混乱する人が跡を絶たず、いつの間にかRustは難しいという誤解が定着してしまいました。そこで、他言語の考え方をRustに持ち込んで失敗した例をいくつか挙げ、改善案を教えて下さい。

本記事は ChatGPT の出力をまとめたものです。

GPT-4o

色々試したのですが、まず2025年2月18日時点のGPT-4oから。

確かに、Rustの設計はシンプルな概念の組み合わせですが、他言語の習慣を持ち込むことで混乱が生じることが多いですね。いくつか典型的な失敗例を挙げ、それぞれの改善案を考えてみましょう。

よろしくお願いします。

クラスベースのオブジェクト指向をそのままRustに適用しようとする

失敗例: C++やJavaのようにクラスを作りたがり、すべてを struct + impl でカプセル化しようとする。特に、継承を再現しようとして無理にトレイトを多用する。

と述べた後、コード例とともに以下の問題点を挙げています。

問題点:

  • 継承がないRustでオブジェクト指向を無理に模倣すると、動的ディスパッチ(dyn)を乱用してパフォーマンスが悪化する。
  • Rustの所有権システムと組み合わせるとライフタイムの扱いが難しくなる。
  • 単純な列挙型や match を使った方がシンプルになることが多い。

伝統的なオブジェクト指向のやり方ですね。そもそも C++ でポリモーフィズムを書くとき「仮想関数テーブル」の存在を認識しているでしょうか。何もかも継承で書くと無駄に関数ポインタが増えていきます。Tagged union で済むなら、その方がよっぽどシンプルです。

普段からコンパイル後の視点を意識しておけばよかった話ですね。

例外ベースのエラーハンドリングを持ち込む

失敗例: 他の言語のように try/catch の感覚で panic!() を使いまくる。

問題点:

  • Rustはパニックを回復不能な状態(バグや致命的エラー)とみなしている。
  • panic!() を多用すると、スレッドがクラッシュするため安全でない。
  • Result<T, E> を使った方が明示的で安全。

もしかして catch_unwind しまくる人ですか?

エラー処理の方法は言語によって違うので、1 つの考え方にとらわれていると他で困るのは当然ですね。言いたいことは分かりますが、Resultpanic! の違いは「安全」「安全でない」ではないので表現は若干ズレています。

すべてをミュータブルにしようとする

失敗例: C++やPythonのように、何でも mut をつけて変更しようとする。

問題点:

  • Rustではミュータブルな参照は同時に1つしか持てないため、所有権の問題が発生しやすい。
  • 変更が不要なら、mut をなくすことで安全性が向上する。

いわゆる手続き型と関数型の違いですね。C 系に慣れてると、とりあえず変数を宣言しておいて、後から値に手を加えまくる発想になりやすい。別に「Rust vs. 他言語」という構図ではないので、別の考え方にも慣れろとしか言いようがありませんね。

グローバル変数を多用する

失敗例: CやPythonのように、グローバルな static mut 変数を多用する。

問題点:

  • static mut はスレッドセーフではなく、unsafe を使わないと変更できない。
  • スレッド間でデータ競合が発生しやすい。

改善案: スレッドセーフな MutexAtomic を使う。

これもありますね。ただ最後の改善案が若干気に入りません。おそらく、CやPythonの気持ちでグローバル変数を使おうとしている多くの状況では、&mut self をとるメソッドをもつ構造体を 1 つ持っておけばいいんじゃないでしょうか?たとえば string interning ライブラリを提供するなら、StringInterner 構造体に newintern / get メソッドをもたせておけばいいわけです。これを Mutex で強引に解決するのは、それこそ他言語の考え方を持ち込んでいるに過ぎません。

まずは、より簡単な機能の組み合わせで済むか考えるべきですね。

o3-mini

次は o3-mini です。

Rustはシンプルな機能の組み合わせで表現力を発揮する一方、他言語の「当たり前」をそのまま移植しようとすると、かえってRustの意図する設計思想や仕組みと食い違い、混乱を招くことがあります。以下に、いくつかの「他言語の考え方を持ち込んで失敗した例」と、その改善策を挙げます。

よろしくお願いします。

Null の概念のそのままの移植

  • 失敗例:
    JavaやC/C++でおなじみの「null」を、Rustでも扱いたいと考え、null相当の概念を無理に実装しようとする。
    → 結果として、実行時の不具合や安全性の低下を招く。

  • 改善策:
    Rustは「null」を排除し、Option<T> 型で「値がある/ない」を明示的に扱います。
    Option を利用し、matchif let でパターンマッチングすることで、コンパイル時に漏れを防ぐ安全なコードを書く。

「null相当の概念を無理に実装」って何でしょうね。unsafe でヌルポインタ?どうしたらそんな発想に……

自分の観測範囲だと Option はすんなり受け入れている人が多かったので、あまり共感する部分がありません。

クラシックな継承モデルの再現

  • 失敗例:
    JavaやC++でのクラス継承や仮想関数の考え方をRustにそのまま適用し、「スーパークラス・サブクラス」の関係を無理に再現しようとする。
    → 結果として、Rustでは存在しない継承の概念に固執し、設計が複雑化する。

  • 改善策:
    Rustは**継承ではなく「トレイト」と「コンポジション」**による設計を推奨しています。
    → オブジェクト指向の多態性を実現したい場合は、トレイトによるインターフェース定義や、ジェネリクスを活用する。これにより、柔軟かつコンパイル時に安全な設計が可能になる。

「コンポジション」の時点でオブジェクト指向にとらわれてないか?

Rust に OOP を持ち込もうとする話は 4o の回答と共通していますね。

例外処理の概念の押し付け

  • 失敗例:
    PythonやJavaの例外処理に慣れていると、Rustでも同様に例外(try/catch に相当する機構)を期待して、エラーが発生したら例外を投げる実装を試みる。
    → 実際、Rustには例外機構がなく、パニック(panic!)は基本的に致命的なエラーに使うため、通常のエラーハンドリングとしては適さない。

  • 改善策:
    Rustでは、Result<T, E> 型を利用してエラーを明示的に扱います。
    match? 演算子を使ってエラー伝播を行い、例外ではなく明示的なエラー処理の流れを設計する。これにより、エラーが発生した場合の挙動が明確になり、予期せぬクラッシュを防止できる。

これはさっきの 4o よりまともなことを言っていそうですね。

ちなみに今は C++ にも std::expected があります。

所有権・借用ルールへの無理解

  • 失敗例:
    他言語では自由に変数を変更できるため、「自由なミュータビリティ」を前提にRustでも同じような書き方をしてしまい、借用ルール違反でコンパイラエラーが発生する。
    → 結果として、なぜエラーになるのか理解できず、コードが書けなくなる。

  • 改善策:
    Rustの所有権・借用モデルは、安全性を確保するための根幹です。
    → まずは、所有権、借用、ライフタイムの基本概念を学習し、コンパイラのエラーメッセージをヒントに正しい設計を模索すること。例えば、必要な場面では&mutを適切に使い、不要なコピーを避けることで、意図した動作を実現できる。

そもそも、そんな書き方は他言語でも避けるべきですね。「自由なミュータビリティ」という言葉が怖すぎる、そんな人はマルチスレッドで全てをめちゃくちゃにしていそう。

「❌ Rust が難しい」「⭕ プログラミングが難しい」と言われる理由の 1 つだと思います。

マクロの過剰利用

  • 失敗例:
    Lisp系言語などでマクロを多用していた開発者が、Rustでも同じように複雑なマクロを組み立て、コードの抽象化を図ろうとする。
    → しかし、過度なマクロ利用は可読性を損ない、デバッグも困難になる。

  • 改善策:
    Rustのマクロは、コードの繰り返しを減らすための補助ツールとして有効ですが、基本的にはジェネリクスや関数、トレイトなどの標準機能を優先する。
    → まずはシンプルな構造で実装し、本当にマクロが必要な箇所だけに限定して使うことで、保守性の高いコードを書くことができる。

Lisp 系を知らないため状況が想像できないのですが、そんなことがあるんですか……?

Rust のマクロは C / C++ に比べてマシですが、個人的にはまだ不満があります。今後良くなるといいですね。

GPT-4o mini

最後は 4o mini にも聞いてみます。

Rustが難しいと感じられがちな理由の一つは、他の言語から来た人々がRust独自の設計哲学や特徴を理解せずに、既存の慣習を持ち込んでしまうことです。Rustは多くの点で簡潔で直感的ですが、他の言語に慣れていると、そのシンプルさが逆に難しさに感じられることがあります。以下は、他の言語の考え方をRustに持ち込んで失敗しがちな例と、その改善案です。

よろしくお願いします。

メモリ管理の誤解:

失敗例:
他の言語ではガーベジコレクションが提供されているため、メモリ管理にあまり気を使わずに済みます。しかし、Rustでは所有権(Ownership)と借用(Borrowing)の概念が厳格に適用され、手動でメモリ管理をしないといけません。これを理解せずに、Rustのメモリ管理が「面倒だ」と感じてしまうことがあります。

改善案:
Rustの所有権と借用のルールは、プログラムが効率的に動作するために重要な部分です。このルールは、メモリリークやデータ競合のリスクを排除し、パフォーマンスの向上をもたらします。まずは、所有権の概念を十分に理解し、コード内でどの変数が所有権を持つか、どの変数が借用しているかを把握することが重要です。コンパイラがエラーを出すのは、Rustがコードの安全性を保証するためなので、エラーメッセージをよく読んで理解することがポイントです。

そういう理由でそもそも Rust が適していない場面というのはありますよね。GC のある言語と無い言語、どちらが良い悪いではなく、適切な選択が重要ですね。

ポインタと参照の混乱:

失敗例:
他の言語でポインタを使って操作していた経験から、Rustのポインタ(参照)を正しく使えないことがあります。例えば、参照と可変参照を混同してしまったり、ポインタを使う感覚で借用の範囲を超えてアクセスしてしまうことがあります。

改善案:
Rustの参照には「不変参照(immutable reference)」と「可変参照(mutable reference)」があります。それぞれの違いを理解し、同時に両方を使わないようにすることが重要です。可変参照は一度に1つだけ持つことができ、他の変数がその値を借用することはできません。この点を理解することで、Rust特有のメモリ安全性を保ちながら、意図した通りにコードを書くことができます。

「ポインタを使う感覚で借用の範囲を超えてアクセス」、そもそもが良くない例ですね。C / C++ ならきっと未定義動作です。

型推論の誤解:

失敗例:
Rustの型推論は強力ですが、他言語の習慣で型を明示的に指定することに慣れていると、型推論がどこまで働くかがわからず、型エラーを引き起こしてしまうことがあります。また、Rustでは型の推論がコンパイラに負担をかけない範囲で行われるため、最適な型を選ぶことが重要です。

改善案:
型推論を信頼することは大事ですが、コンパイラのエラーメッセージをよく読んで、必要な場合は明示的に型を指定してあげることが解決策になります。特に、ライフタイム(Lifetime)やトレイト(Trait)を扱う際には、明示的な型指定が有効です。

あ〜〜〜 can't call method `...` on ambiguous numeric type `{integer}` とか。Rust の場合だと完全な型推論は無理なので、難しいですね。

エラーハンドリングの混乱(ResultOption):

失敗例:
他の言語で例外処理を使い慣れている場合、RustのResultOptionのエラーハンドリングに戸惑うことがあります。Rustでは、エラーや値の無効状態を明示的に扱うことが要求されるため、ResultOptionを使わずにエラーを無視したり、予期しない値を受け入れたりしてしまう場合があります。

改善案:
Rustのエラーハンドリングは、エラーが発生した場合でもプログラムを安全に終了させるための仕組みとして重要です。Result型やOption型を使いこなし、パターンマッチングでエラーを適切に処理することを学びましょう。特にunwrap()expect()の使い方には注意が必要で、これらを使うのは本当に安全であると確認できた場合のみです。

はい。

関数型プログラミング(FP)との違い:

失敗例:
Rustは一部の関数型プログラミングの特徴を持っていますが、完全なFP言語ではありません。例えば、他の言語のように高階関数やラムダ式を頻繁に使ってしまい、Rustの特徴的な書き方(例えば、forループやmatch式)を避けてしまうことがあります。

改善案:
Rustの関数型プログラミングの特徴(mapfilterfoldなど)を活かしつつ、Rustらしい書き方を理解しましょう。Rustは、構造体や列挙型(enum)といった強力な型システムを使って、コードの表現力を高めることができます。関数型の考え方を使う際も、Rustらしい型安全性やパターンマッチングを活かすことが大切です。

うわ、思想だ!逃げろ逃げろ。

Clippy に何も言われなければ何だっていいと思います。

まとめ

細かい部分は微妙ですが、とりあえず色々挙げてくれてよかったですね。

オブジェクト指向の発想、手続き型の発想、グローバル変数の利用など、他言語に慣れてしまっているからこそ失敗する場面が多くあります。新しいことを始めるならまず基本に立ち返るのが大切ですね。

Discussion