- 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件のコメント
Hacker News のコメント
良い記事だった。ただ、PizzaOrder の例は、あまりにも多くの関心事を 1 つの struct に押し込んでいるように感じる
ordered_atを比較から除外したいのが目的なら、PizzaDetailsとPizzaOrderの 2 つの struct に分けるほうがよいと思うそうすれば
PartialEqを実装するときにdetailsだけを比較することを明確にできる注文時刻が違えば同じ注文ではないのだから、型レベルで等しいと定義するのは危険だ
PizzaDetailsにPartialEqを持たせるのは構わないが、注文の比較ロジックは別のビジネス関数に置くべきだPizzaDetailsを変更したときに、その変更が ピザの重複排除ロジック に影響しうる点が問題だstruct は単にデータを束ねる用途にだけ使うのが理想的だ
変更が他の場所に影響しないように、
PizzaComparatorやPizzaFlavorのような別の型を置く方法も考えられるProtobuf のように、フィールドに
{important_to_flavour=true}のような フィールド注釈 を付けられたらよいのだがたとえば文字列を大文字小文字を区別せずに比較したい場合、どう分ければよいのだろうか?
Rust の本当に素晴らしい点は、防御的プログラミングが不要な場面 が多いことだ
所有権や参照のルールのおかげで、特定のオブジェクトへのアクセスがプログラム全体で一意であることを保証できる
参照は null になれず、スマートポインタも null になれない
selfの所有権を渡せば、その後はメソッド呼び出しができないことも型システムが保証するそのおかげで、スレッド安全性、ライフタイム、複製可能性などが コンパイル時にグローバルに検証 される
他の言語では関数型スタイルで不変性を保って初めて得られる利点を、Rust は 型システム によって強制する
記事のテーマは borrow checker でも検出できない論理バグ だった
配列やベクタに直接インデックスアクセスするのは避けるのが賢明だと感じる
Cloudflare の unwrap インシデントがあった日に、私もスライスがベクタの末尾を越えるバグを見つけた
その後 イテレータベースのアプローチ に変えて、ずっと安全だと感じている
Rust の
unwrapは C のassertと同じだ。失敗したときに問題を知らせる役割をしているだけだRust でもバグは依然として書ける
Rust 開発者が防ぐべき習慣の 1 つは、不要な crate 依存 を追加することだ
Rust はこうした習慣を助長しがちな傾向がある。たとえば Rust Book で
randcrate を基本例として使っているのも、そうした空気を生んでいるもちろん、暗号関連パッケージを簡単に差し替えられるようにした戦略的な選択ではあるが、それでも習慣化するのは問題だ
しかし後になってその意図を理解し、考えが変わった
部分的等価性の実装は興味深かった
もう 1 つ気になるのは、真偽値パラメータ を避けるために enum を使う方法だ
私は bool をラップした struct を使っているが、これを普通の bool のように扱えないのが惜しい
enum を bool のように使う方法はあるのだろうか
必要なロジックを Trait にまとめたり、
impl <Enum>ブロックに共通メソッドを追加したりして対処するこうすると可読性もよく、各メンバーごとの振る舞いを明確に定義できる
impl Derefのようなものを使ってみることもできるかもしれないが、良いアイデアかどうかはよく分からない最初の例の
match構文は少し大げさに感じるVec.first()やVec.iter().nth(0)のほうが明確で意図に合っているmatchを使うとむしろ 問題より複雑な解決策 になってしまうifを除去できるならmatchも除去できるので、安全性の面で差はないfirst()のほうがずっと簡潔で明確だmatchには、「要素が 1 つ以上ある場合」も処理するよう促すという意味があるつまり、チェックと依存コードの分離を避けよ という原則を示している
こういう記事を読むたびに、なぜ コードパターンを監視する専任チーム がないのか不思議に思う
SOC や QA のように、コードベースのパターンを長期的に観察するチームがあればよいのにと思う
自動化されたコードスメル検出ツールには限界がある
lint ルール管理、文書化、開発者教育、共通ライブラリの保守などを担当している
複数のチームが同じ問題を繰り返している場合、それを統合できる 中核 API を設計する
ただ、コードが数百万行規模になると管理が非常に難しいのが現実だ
こうした 良いコーディングパターン をチーム内でどう奨励できるか悩んでいる
コードレビュー中は「スタイル論争」に発展して非生産的になることが多い
ところが不思議なことに、リンターが警告を出すとそうした論争はほとんど消える
TryFromトレイトが 1.34 バージョンで追加されたのは本当に有用だったおそらく
unwrap_or_else()を使っていたコードは、それ以前の時代の名残なのだろうFrom トレイトのドキュメント は、今ではいつ実装すべきかを非常に明確に説明している
unwrap_or_else()という名前がまるで「コンピュータを脅して命令している」ようで面白く聞こえるこうした 防御的プログラミングパターン は、大規模な AI コード生成の品質向上 にも役立つと思う
Clippy や Rust コンパイラが提供する具体的なフィードバックは、AI エージェントがミスを減らし、方向性を定めるうえで大きな役割を果たせる