ソフトウェア設計原則
1. 目的
この文書は私にとって良いソフトウェア設計を定めるものである。私が設計において最も優先することは、コードリーディング時に人間がそのコードの意図、挙動、影響範囲を理解し、その妥当性を検証できることである。
コードは、コンピュータに処理を実行させるためだけのものではない。人間が実装内容を確認し、問題を発見し、変更の妥当性を判断するための文書でもある。特に、LLMやコード生成ツールが実装を行う場合でも、生成されたコードを人間が読んで検証できる構造を維持する。
2. 判断の基準
複数の設計が成立する場合は、原則として次を優先する。
- 人間が処理を追跡しやすいこと
- 上位から下位へ段階的に理解できること
- データの流れと状態変更が明示されていること
- 変更の影響範囲が限定されていること
- 所有権、寿命、失敗方法が分かりやすいこと
- 現在の要件に対して単純であること
- 将来の変更時に安全に修正できること
コードの短さ、抽象度の高さ、再利用可能性、技巧性は、それ自体を目的としない。
3. 基本原則
3.1 上位のコードから全体像を理解できるようにする
プログラムは、上位の関数やモジュールを読むことで、処理の主要な流れを説明できなければならない。上位層には、細かな手続きではなく、処理の意味と順序を記述する。
理想的な読み方は次の通りである。
- 上位層で処理全体を把握する
- 詳細が必要な処理だけ一段下へ降りる
- 必要に応じて実装の末端まで読む
読者に最初からすべての実装詳細を理解することを要求しない。ただし、上位層を単なる薄い関数呼び出しの列にしてはならない。処理の構造や判断が不明になるほど細かく分割することも避ける。
3.2 関数やモジュールは意味のまとまりで分割する
関数の長さや行数だけを基準に分割しない。一つの関数やモジュールには、一つの説明可能な責務を持たせる。
分割の基準は、次のような意味の境界である。
- 入力を解釈する
- 入力を検証する
- データを収集する
- 結果を分析する
- 状態を更新する
- 結果を出力する
単にコードを短くするためだけの関数分割は避ける。関数を読むたびに別の関数へ移動しなければ処理を理解できない場合、分割が過剰である可能性を検討する。
3.3 入力から出力へのデータの流れを明示する
処理は、可能な限り次の形で表現する。
入力 → 変換 → 結果外部から渡されたオブジェクトを関数内部で変更し、そこへ結果を書き込む設計よりも、処理結果を戻り値として返す設計を優先する。
避けたい形:
入力 + 変更対象 + エラー格納先
↓
複数の引数を内部で変更する優先する形:
入力
↓
成功時の結果 または 失敗情報複数の値を返す必要がある場合は、意味を持つ結果型としてまとめる。処理の結果がどこへ格納されたかを、呼び出し後に複数の変数から探させない。
3.4 状態変更より値の生成を優先する
既存の状態を段階的に変更して完成させる設計よりも、必要な情報を集めたうえで、新しい完成した値を生成する設計を優先する。可能な限り、オブジェクトは有効な状態で生成し、その後に多数の箇所から変更されないようにする。
次のような設計は慎重に扱う。
- 初期化後に複数の設定関数を呼ばなければ使えない
- 呼び出し順序によって正しさが変わる
- 一時的に不正な状態を経由する
- 複数の関数が同じ状態を少しずつ変更する
- どの処理が現在の値を設定したか分かりにくい
状態変更が必要な場合は、変更する主体、変更可能な範囲、許可される状態遷移を明確にする。
3.5 副作用は明示し、限定する
ファイル操作、通信、ログ出力、時刻取得、乱数生成、外部状態の更新などは、副作用として扱う。副作用を完全になくすことは目的ではない。どこで起きるか・何に影響するか・失敗した場合に何が残るかを予測できるようにすることが目的である。
計算処理と副作用を可能な限り分離する。例えば、次のような段階を分ける。
データを取得する
↓
データを解析する
↓
結果を保存・表示する解析処理の途中で、無関係なファイル出力やグローバル状態の変更を行わない。関数名やインターフェースから予想できない副作用を持たせない。
3.6 可変状態の範囲を小さくする
可変状態は、読者が追跡しなければならない情報を増やす。ゆえに変更可能な状態は、可能な限り次の条件を満たすようにする。
- スコープが小さい
- 所有者が少ない
- 更新場所が限定されている
- 不変条件が明確である
- 更新理由が説明できる
共有可変状態は原則として避ける。複数の処理から変更される状態が必要な場合は、その状態を管理する責務を一つの型またはモジュールへ集約する。
3.7 所有権と寿命を明確にする
メモリ、ファイル、ソケット、スレッド、ハンドルなどの資源について、次を明確にする。
- 誰が所有するか
- 誰が解放するか
- どこまで利用できるか
- 他の処理は所有するのか借用するのか
所有者の分からない資源を作らない。同じ資源を複数の場所が暗黙に所有しているような設計を避ける。参照や借用を利用する場合は、その寿命関係を理解するために、離れた場所の実装まで読まなければならない設計にしない。所有権の移動や共有が必要な場合は、それがインターフェースから読み取れるようにする。
3.8 抽象化は挙動を隠しすぎてはならない
抽象化の目的は、詳細を無条件に隠すことではない。抽象化は、利用者が内部実装を毎回読まなくても、次を判断できる境界でなければならない。
- 何を行うか
- 何を必要とするか
- 何を返すか
- 何を変更するか
- どのように失敗するか
- 何を保証するか
実装を読まなければ安全に使用できない実装は適切な抽象化ではない。名前が実際の挙動より広すぎる、または狭すぎる場合は、責務か命名を見直す。また、抽象化によって制御フロー、状態変更、性能特性、失敗可能性を過度に隠さない。
3.9 宣言と型を契約として使う
関数名、引数、戻り値、型は、単なる構文ではなく契約の表現である。宣言から、可能な限り次が分かるようにする。
- 必要な入力
- 得られる結果
- 入力を変更するか
- 所有権を受け取るか
- 失敗する可能性があるか
- 値が存在しない可能性があるか
真偽値だけを返し、結果や失敗理由を別の場所へ格納する設計は避ける。また、複数の意味を一つの基本型へ押し込めない。例えば、整数値が「識別子」「件数」「状態コード」のいずれなのか、文脈を読まなければ分からない設計を避ける。加えて、意味の異なる値には、可能な限り異なる型または明確な名前を与える。
3.10 不変条件を構造によって守る
値の正しさを利用者の理解にだけ依存させない。値や状態が満たすべき条件は、それを管理する型またはモジュールに集約する。
例えば、次のような条件をコード全体へ分散させない。
- 値は特定の範囲内である
- 接続済みの場合のみ操作できる
- 初期化後にのみ利用できる
- 二つの値の間に対応関係がある
- 状態遷移には決められた順序がある
不正な状態を表現しにくい設計を優先する。ただし、型や抽象化を過剰に増やし、単純な処理の理解を難しくしない。
3.11 エラー処理を通常の制御フローとして設計する
失敗経路は、正常経路と同じ程度に理解できなければならない。
次を明確にする。
- どの処理が失敗しうるか
- 失敗理由をどこで扱うか
- 失敗時に状態が変更されるか
- 部分的な結果が残るか
- 処理を継続できるか
- 呼び出し側が何を判断すべきか
エラーを無視したり、理由を失ったまま上位へ伝えたりしない。一つの関数が、成功・失敗・部分成功を曖昧な値で表現しない。
3.12 暗黙の依存を避ける
関数やモジュールが必要とする情報は、可能な限り引数や明示的な依存として渡す。次への暗黙的な依存を避ける。
- グローバル変数
- 隠れたシングルトン
- 呼び出し順序
- 作業ディレクトリ
- 環境変数
- 現在時刻
- 乱数生成器
- 外部設定
- 他の処理が事前に作成した状態
なお、すべてを引数にすればよいわけではない。関連する依存が多い場合は、意味のある単位へまとめる。ただし、何でも格納する巨大なコンテキストオブジェクトは避ける。
3.13 変更の影響範囲を限定する
一般論として、一つの要件変更が、無関係な多数のモジュールへ波及しない設計を目指す。変更されやすい知識は、その知識を扱う場所へ集約する。外部形式、プロトコル、設定値、環境依存処理などを、ドメイン上の判断へ直接混在させない。ただし、変更可能性を想像して、まだ存在しない要件のために抽象化を増やさない。実際に存在する変更理由に基づいて境界を作る。
3.14 汎用化は必要になってから行う
再利用可能性や拡張性は、それ自体を目的としない。現在一つしか存在しない処理のために、過度な抽象クラス、プラグイン機構、設定システム、テンプレート化を導入しない。将来の可能性ではなく、現在確認できる複数の具体例から共通部分を抽出する。抽象化を導入する場合は、少なくとも次を説明できなければならない。
- 何の重複または変更負担を減らすか
- どの責務を分離するか
- 導入後に理解がどのように容易になるか
再利用のために、現在のコードを読みにくくしない。
3.15 単純さは構造の単純さとして評価する
単純さを、行数の少なさやファイル数の少なさだけで評価しない。次の観点から単純さを判断する。
- 必要な概念の数
- 同時に覚える必要がある状態の数
- 制御フローの分岐
- 依存関係の数
- 状態変更の場所
- エラー経路の数
- 理解に必要なファイル間移動n
短くても暗黙的なコードより、多少長くても挙動が明示されたコードを優先する。一方で、説明のためだけに不要な層や型を増やさない。
3.16 最適化は測定に基づいて行う
性能のために、可読性、局所性、安全性を無条件に犠牲にしない。性能上の問題が疑われる場合は、まず測定する。効率のために設計が複雑になる場合は、次を明確にする。
- どの測定結果に基づくか
- どの制約を満たすためか
- どの原則を例外的に緩めるか
- 影響範囲はどこか
- 正しさをどのように検証するか
測定されていない性能上の懸念だけを理由に、理解しにくい設計を採用しない。
4. 局所性の位置づけ
局所性は、この文書における重要な設計上の価値である。コードの一部を理解するために、無関係な多数の場所を同時に参照しなくてよい状態を目指す。ただし、局所性を絶対的な目的とはしない。
局所性は、次を実現するための手段として扱う。
- 認知負荷の削減
- 変更影響の予測
- バグの原因範囲の限定
- コードレビューの容易化
- 抽象化への信頼
- 人間による検証可能性
局所性を高めるために、全体の構造が不明になったり、同じ知識が複数箇所へ重複したりする場合は、設計を再検討する。
5. 避ける設計
合理的な理由がない限り、次の設計を避ける。
- 関数の引数を変更して処理結果を返す
- 複数の出力先へ結果を書き込む
- 呼び出し側から予想できない副作用を持つ
- グローバルな可変状態へ依存する
- 初期化順序や呼び出し順序へ暗黙に依存する
- 一つのオブジェクトを多数の場所が変更する
- 失敗理由を失う真偽値や特殊値を返す
- すべてを保持する巨大な管理クラスを作る
- 一つの関数が複数の抽象度を混在させる
- 処理を追うために多数の薄い関数を往復させる
- 将来使うかもしれないという理由だけで汎用化する
- 名前だけを付け、挙動や契約を明確にしない抽象化を作る
- 実装を読まなければ利用方法や危険性が分からないAPIを作る
- 短さや技巧性のために制御フローを暗黙化する
6. 例外の扱い
この文書は、機械的に適用する絶対規則ではない。次のような理由がある場合は、原則から外れる設計を認める。
- 性能測定により必要性が示された
- 外部APIや既存システムの制約がある
- 安全性または正しさのために必要である
- 代替案の方が全体として複雑になる
例外を採用する場合は、その理由と影響範囲をコード、レビュー、設計記録のいずれかで説明する。単に一般的によく使われる、短く書ける、LLMが生成した、という理由は十分ではない。
7. コードレビューの確認事項
コードを生成または変更した後は、次を確認する。
全体構造
- 上位関数から処理の主要な流れを説明できるか
- 上位と下位で抽象度が適切に分かれているか
- 関数やモジュールの責務を一文で説明できるか
データと状態
- 入力から出力への流れが明確か
- 結果が戻り値として表現されているか
- 外部状態の変更は必要か
- 可変状態の所有者と更新場所は限定されているか
- 不正な中間状態を作っていないか
副作用と依存
- 副作用は名前や境界から予測できるか
- 暗黙の依存がないか
- 処理の理解にグローバルな知識を要求していないか
- I/Oと計算が不必要に混在していないか
契約と失敗
- 入力、出力、所有権、失敗方法が宣言から分かるか
- エラー理由が失われていないか
- 失敗時の状態を説明できるか
- 呼び出し側が適切な判断をできる情報があるか
複雑さ
- 抽象化が実際に認知負荷を下げているか
- 汎用化が現在の要件に必要か
- より直接的な実装で十分ではないか
- 変更の影響範囲を予測できるか
- 人間がこのコードの正しさを説明できるか
8. LLMへの指示
コードを生成、修正、レビューする際は、この文書を設計判断の基準として使用すること。複数の実装案が成立する場合は、次の案を優先すること。
- 人間が上位から段階的に読める
- データの流れが明示されている
- 結果を戻り値として返す
- 外部状態の変更が少ない
- 副作用が限定されている
- 所有権と寿命が明確である
- 失敗方法が型またはインターフェースに表れている
- 現在の要件に対して単純である
- 変更の影響範囲を説明できる
設計上の判断に迷う場合は、一般的な慣習だけで決めず、次を明示すること。
- 選択可能な案
- 各案のトレードオフ
- この文書に照らして推奨する案
- 原則から外れる必要がある場合の理由
コードの短さや生成速度よりも、人間による理解と検証を優先すること。