32 ポイント 投稿者 GN⁺ 2025-12-07 | 1件のコメント | WhatsAppで共有
  • Rustの型システムとコンパイラを積極的に活用して、バグを事前に防ぐコーディング習慣を紹介
  • ベクタのインデックス参照、Defaultの乱用、不完全なmatch、不要な真偽値パラメータなど、**脆弱なコード臭(Code Smell)**の事例を示し、代替案を説明
  • コンパイラが不変条件を強制するように構造を設計することが中核原則であり、パターンマッチング・非公開フィールド・#[must_use]属性などを活用
  • TryFromの使用、構造体の完全分解、一時的可変性、コンストラクタ検証など、実コードレベルの防御技法を具体的に提示
  • こうしたパターンはリファクタリング時の安定性確保と長期的な保守性向上に不可欠

防御的プログラミングの概要

  • // this should never happen コメントが付いた箇所は、暗黙の不変条件が壊れる位置である
    • 多くの場合、開発者がすべての境界条件や将来のコード変更を考慮できていない
  • Rustコンパイラはメモリ安全性を保証するが、ビジネスロジックの誤りは依然として起こり得る
  • 長年の実務経験から得られた**小さな習慣的パターン(idiom)**が、コード品質を大きく向上させる

Code Smell: ベクタのインデックス参照

  • if !vec.is_empty() { let x = &vec[0]; } という形は、長さ確認とインデックス参照が分離されており、実行時パニックの危険がある
  • スライスのパターンマッチング(match vec.as_slice())を使えば、コンパイラがすべての状態を強制的に検査する
    • 空のベクタ、単一要素、重複要素など、あらゆる場合を明示的に処理できる
  • コンパイラが不変条件を保証するよう設計する代表的な例

Code Smell: Defaultの無分別な使用

  • ..Default::default() は、新しいフィールド追加時の見落としリスク暗黙的な値設定の問題を招く
  • すべてのフィールドを明示的に初期化すれば、コンパイラが新しいフィールド設定を強制する
  • let Foo { field1, field2, .. } = Foo::default(); の形で、デフォルト値の構造体を分解したうえで選択的に上書きできる
    • デフォルト値の維持と明示的オーバーライドのバランスを取れる

Code Smell: 脆弱なTrait実装

  • 構造体フィールドを完全分解して比較すると、新しいフィールド追加時にコンパイルエラーで警告できる
    • 例: PartialEq 実装時に let Self { size, toppings, .. } = self;
  • extra_cheese のような新フィールドが追加されると、比較ロジックの再検討を強制できる
  • Hash, Debug, Clone など他のトレイトにも同じ原理を適用できる

Code Smell: FromではなくTryFromが必要

  • 変換が常に成功するとは限らない場合、Fromではなく**TryFromで失敗可能性を明示**する
  • unwrap_or_else の使用は、潜在的な失敗を隠している兆候であり、早期失敗(fail fast)の方が安全

Code Smell: 不完全なmatch

  • _ => {} のようなcatch-allパターンは、新しいvariant追加時の見落としリスクがある
  • すべてのvariantを明示的に列挙すれば、コンパイラが新しいケースの処理漏れを警告する
  • 同じロジックは Variant3 | Variant4 の形でグループ化できる

Code Smell: _プレースホルダの乱用

  • _ だけを使うと、どの変数が省略されたのか不明確になる
  • has_fuel: _, has_crew: _ のように明示的な名前で可読性を向上できる

Pattern: 一時的可変性(Temporary Mutability)

  • データが初期化中だけ可変であるべき場合、let mut data = ...; data.sort(); let data = data; の形を使う
  • ブロックスコープを活用すれば、一時変数の外部露出を防止できる
    • 例: let data = { let mut d = get_vec(); d.sort(); d };
  • 複数の一時変数を使う初期化過程でも、明確なスコープ分離が可能

Pattern: コンストラクタ検証の強制

  • 構造体生成時に検証ロジックを必ず通すよう強制する
    • _private: () フィールドを追加すると、外部から直接生成できなくなる
    • #[non_exhaustive] 属性は、クレート外からの生成を防ぎ、将来の拡張も示唆する
  • 内部モジュールでも強制したいなら、非公開型(Seal)を持つネストしたモジュール構造を使う
    • Seal が内部にしか存在しないため、new() 以外では直接生成できない
  • フィールドを非公開にしてgetterを提供すれば、不変な状態を維持できる
  • 適用基準
    • 外部コードを防ぐ: _private または #[non_exhaustive]
    • 内部コードも防ぐ: 非公開モジュール + Seal
    • 検証ロジックをコンパイラレベルの保証へ変換する

Pattern: #[must_use]属性の活用

  • #[must_use] は、重要な戻り値の無視を防ぐ
    • 例: #[must_use = "Configuration must be applied to take effect"]
  • 利用者が戻り値を無視すると、コンパイラ警告が発生する
  • Result など標準ライブラリでも広く使われている、シンプルだが強力な防御手段

Code Smell: 真偽値パラメータ

  • fn process_data(..., compress: bool, encrypt: bool, validate: bool) の形は、意味が曖昧で順序ミスの危険がある
  • enum Compression, enum Encryption などで意図を明示的に表現する
  • オプションが複数ある場合は**パラメータ構造体(Params struct)**を使う
    • ProcessDataParams::production() など事前設定メソッドで再利用性を向上できる
  • 新しいオプション追加時も既存の呼び出し側への影響を最小化できる

Clippy Lintsによる自動化

  • 主な防御パターンはClippy lintで自動検査可能
    • indexing_slicing: 直接インデックス参照を禁止
    • fallible_impl_from: FromではなくTryFromを推奨
    • wildcard_enum_match_arm: _ パターンを禁止
    • fn_params_excessive_bools: 真偽値パラメータ過多を警告
    • must_use_candidate: #[must_use] 候補を提案
  • #![deny(clippy::...)] または Cargo.toml 設定で、プロジェクト全体に適用できる

結論

  • Rustの型システムとコンパイラを積極的に活用し、不変条件を明示的かつ検証可能にすることが、防御的プログラミングの核心
  • こうしたパターンは、リファクタリング時の安定性確保、バグ発生可能性の最小化、長期保守性の強化に寄与する
  • コンパイルされないバグこそ最良のバグ」という原則を実践するアプローチである

1件のコメント

 
GN⁺ 2025-12-07
Hacker News のコメント
  • 良い記事だった。ただ、PizzaOrder の例は、あまりにも多くの関心事を 1 つの struct に押し込んでいるように感じる
    ordered_at を比較から除外したいのが目的なら、PizzaDetailsPizzaOrder の 2 つの struct に分けるほうがよいと思う
    そうすれば PartialEq を実装するときに details だけを比較することを明確にできる

    • 良い指摘だと思う。ただ、それでも論理的には 誤ったモデリング だと思う
      注文時刻が違えば同じ注文ではないのだから、型レベルで等しいと定義するのは危険だ
      PizzaDetailsPartialEq を持たせるのは構わないが、注文の比較ロジックは別のビジネス関数に置くべきだ
    • 構造を分けるアプローチはよいが、PizzaDetails を変更したときに、その変更が ピザの重複排除ロジック に影響しうる点が問題だ
      struct は単にデータを束ねる用途にだけ使うのが理想的だ
      変更が他の場所に影響しないように、PizzaComparatorPizzaFlavor のような別の型を置く方法も考えられる
      Protobuf のように、フィールドに {important_to_flavour=true} のような フィールド注釈 を付けられたらよいのだが
    • 単に別の比較方法のために構造を分けるのは一般化しにくい
      たとえば文字列を大文字小文字を区別せずに比較したい場合、どう分ければよいのだろうか?
  • Rust の本当に素晴らしい点は、防御的プログラミングが不要な場面 が多いことだ
    所有権や参照のルールのおかげで、特定のオブジェクトへのアクセスがプログラム全体で一意であることを保証できる
    参照は null になれず、スマートポインタも null になれない
    self の所有権を渡せば、その後はメソッド呼び出しができないことも型システムが保証する
    そのおかげで、スレッド安全性、ライフタイム、複製可能性などが コンパイル時にグローバルに検証 される

    • 私も、Rust の真の利点は「気にしなくてよいこと」にあると思う
      他の言語では関数型スタイルで不変性を保って初めて得られる利点を、Rust は 型システム によって強制する
    • ただ、このコメントは元記事とは関係がないように見える
      記事のテーマは borrow checker でも検出できない論理バグ だった
    • 記事の内容は主に、プログラムを繰り返し改善していく際に 論理的なミスを避けるためのコーディングパターン に焦点を当てていた
  • 配列やベクタに直接インデックスアクセスするのは避けるのが賢明だと感じる
    Cloudflare の unwrap インシデントがあった日に、私もスライスがベクタの末尾を越えるバグを見つけた
    その後 イテレータベースのアプローチ に変えて、ずっと安全だと感じている

    • unwrap インシデントを「事故」と見る必要はないと思う
      Rust の unwrap は C の assert と同じだ。失敗したときに問題を知らせる役割をしているだけだ
      Rust でもバグは依然として書ける
    • 結局は同じ問題だ。Rust 界隈では C を捨てようと言われるが、C でもインデックスの代わりに ハンドル を使うのは一般的だ
  • Rust 開発者が防ぐべき習慣の 1 つは、不要な crate 依存 を追加することだ
    Rust はこうした習慣を助長しがちな傾向がある。たとえば Rust Book で rand crate を基本例として使っているのも、そうした空気を生んでいる
    もちろん、暗号関連パッケージを簡単に差し替えられるようにした戦略的な選択ではあるが、それでも習慣化するのは問題だ

    • 私もその例のせいで、最初は Rust に拒否感があった
      しかし後になってその意図を理解し、考えが変わった
  • 部分的等価性の実装は興味深かった
    もう 1 つ気になるのは、真偽値パラメータ を避けるために enum を使う方法だ
    私は bool をラップした struct を使っているが、これを普通の bool のように扱えないのが惜しい
    enum を bool のように使う方法はあるのだろうか

    • 私もほとんど常に enum + match! を好む
      必要なロジックを Trait にまとめたり、impl <Enum> ブロックに共通メソッドを追加したりして対処する
      こうすると可読性もよく、各メンバーごとの振る舞いを明確に定義できる
    • impl Deref のようなものを使ってみることもできるかもしれないが、良いアイデアかどうかはよく分からない
  • 最初の例の match 構文は少し大げさに感じる
    Vec.first()Vec.iter().nth(0) のほうが明確で意図に合っている

    • 私も同意する。match を使うとむしろ 問題より複雑な解決策 になってしまう
      if を除去できるなら match も除去できるので、安全性の面で差はない
      first() のほうがずっと簡潔で明確だ
    • 同じ動作をもっと簡単に表すなら、itertools の exactly_one を使うこともできる
    • ただし match には、「要素が 1 つ以上ある場合」も処理するよう促すという意味がある
      つまり、チェックと依存コードの分離を避けよ という原則を示している
  • こういう記事を読むたびに、なぜ コードパターンを監視する専任チーム がないのか不思議に思う
    SOC や QA のように、コードベースのパターンを長期的に観察するチームがあればよいのにと思う
    自動化されたコードスメル検出ツールには限界がある

    • 私の会社(約 300 人規模)には、こうした役割の 技術的負債専任チーム がある
      lint ルール管理、文書化、開発者教育、共通ライブラリの保守などを担当している
      複数のチームが同じ問題を繰り返している場合、それを統合できる 中核 API を設計する
    • 大手テック企業には大抵こうしたチームが存在する
      ただ、コードが数百万行規模になると管理が非常に難しいのが現実だ
  • こうした 良いコーディングパターン をチーム内でどう奨励できるか悩んでいる
    コードレビュー中は「スタイル論争」に発展して非生産的になることが多い
    ところが不思議なことに、リンターが警告を出すとそうした論争はほとんど消える

  • TryFrom トレイトが 1.34 バージョンで追加されたのは本当に有用だった
    おそらく unwrap_or_else() を使っていたコードは、それ以前の時代の名残なのだろう
    From トレイトのドキュメント は、今ではいつ実装すべきかを非常に明確に説明している

    • Rust をまだ学んでいる最中なのだが、unwrap_or_else() という名前がまるで「コンピュータを脅して命令している」ようで面白く聞こえる
  • こうした 防御的プログラミングパターン は、大規模な AI コード生成の品質向上 にも役立つと思う
    Clippy や Rust コンパイラが提供する具体的なフィードバックは、AI エージェントがミスを減らし、方向性を定めるうえで大きな役割を果たせる