A Philosophy of Software Design (John Ousterhout)
書籍情報
書籍目次
- Preface
- 1 Introduction
- 2 The Nature of Complexity
- 2.1 Complexity defined
- 2.2 Symptoms of complexity
- 2.3 Causes of complexity
- 2.4 Complexity is incremental
- 2.5 Conclusion
- 3 Working Code Isn't Enough
- 3.1 Tactical programming
- 3.2 Strategic programming
- 3.3 How much to invest?
- 3.4 Startups and investment
- 3.5 Conclusion
- 4 Modules Should Be Deep
- 4.1 Modular design
- 4.2 What's in an interface?
- 4.3 Abstractions
- 4.4 Deep modules
- 4.5 Shallow modules
- 4.6 Classitis
- 4.7 Examples: Java and Unix I/O
- 4.8 Conclusion
- 5 Information Hiding (and Leakage)
- 5.1 Information hiding
- 5.2 Information leakage
- 5.3 Temporal decomposition
- 5.4 Example: HTTP server
- 5.5 Example: too many classes
- 5.6 Example: HTTP parameter handling
- 5.7 Example: defaults in HTTP responses
- 5.8 Information hiding within a class
- 5.9 Taking it too far
- 5.10 Conclusion
- 6 General-Purpose Modules are Deeper
- 6.1 Make classes somewhat general-purpose
- 6.2 Example: storing text for an editor
- 6.3 A more general-purpose API
- 6.4 Generality leads to better information hiding
- 6.5 Questions to ask yourself
- 6.6 Conclusion
- 7 Different Layer, Different Abstraction
- 7.1 Pass-through methods
- 7.2 When is interface duplication OK?
- 7.3 Decorators
- 7.4 Interface versus implementation
- 7.5 Pass-through variables
- 7.6 Conclusion
- 8 Pull Complexity Downwards
- 8.1 Example: editor text class
- 8.2 Example: configuration parameters
- 8.3 Taking it too far
- 9 Better Together Or Better Apart?
- 9.1 Bring together if information is shared
- 9.2 Bring together if it will simplify the interface
- 9.3 Bring together to eliminate duplication
- 9.4 Separate general-purpose and special-purpose code
- 9.5 Example: insertion cursor and selection
- 9.6 Example: separate class for logging
- 9.7 Example: editor undo mechanism
- 9.8 Splitting and joining methods
- 9.9 Conclusion
- 10 Define Errors Out Of Existence
- 10.1 Why exceptions add complexity
- 10.2 Too many exceptions
- 10.3 Define errors out of existence
- 10.4 Example: file deletion in Windows
- 10.5 Example: Java substring method
- 10.6 Mask exceptions
- 10.7 Exception aggregation
- 10.8 Just crash?
- 10.9 Design special case
- 11 Design it Twice
- 12 Why Write Comments? The Four Excuses
- 12.1 Good code is self-documenting
- 12.2 I don't have time to write comments
- 12.3 Comments get out of date and become misleading
- 12.4 All the comments I have seen are worthless
- 12.5 Benefits of well-written comments
- 13 Comments Should Describe Things that Aren't Obvious from the Code
- 13.1 Pick conventions
- 13.2 Don't repeat the code
- 13.3 Lower-level comments add precision
- 13.4 Higher-level comments enhance intuition
- 13.5 Interface documentation
- 13.6 Implementation comments: what and why, not how
- 13.7 Cross-module design decisions
- 13.8 Conclusion
- 13.9 Answers to questions from Section 13.5
- 14 Choosing Names
- 14.1 Example: bad names cause bugs
- 14.2 Create an image
- 14.3 Names should be precise
- 14.4 Use names consistently
- 14.5 A different opinion: Go style guide
- 14.6 Conclusion
- 15 Write The Comments First
- 15.1 Delayed comments are bad comments
- 15.2 Write the comments first
- 15.3 Comments are a design tool
- 15.4 Early comments are fun comments
- 15.5 Are early comments expensive?
- 15.6 Conclusion
- 16 Modifying Existing Code
- 16.1 Stay strategic
- 16.2 Maintaining comments: keep the comments near the code
- 16.3 Comments belong in the code, not the commit log
- 16.4 Maintaining comments: avoid duplication
- 16.5 Maintaining comments: check the diffs
- 16.6 Higher-level comments are easier to maintain
- 17 Consistency
- 17.1 Examples of consistency
- 17.2 Ensuring consistency
- 17.3 Taking it too far
- 17.4 Conclusion
- 18 Code Should be Obvious
- 18.1 Things that make code more obvious
- 18.2 Things that make code less obvious
- 18.3 Conclusion
- 19 Software Trends
- 19.1 Object-oriented programming and inheritance
- 19.2 Agile development
- 19.3 Unit tests
- 19.4 Test-driven development
- 19.5 Design patterns
- 19.6 Getters and setters
- 19.7 Conclusion
- 20 Designing for Performance
- 20.1 How to think about performance
- 20.2 Measure before modifying
- 20.3 Design around the critical path
- 20.4 An example: RAMCloud Buffers
- 20.5 Conclusion
- 21 Conclusion
- Index
- Summary of Design Principles
- Summary of Red Flags
Preface
ソフトウェア設計の現状
- 電子計算機向けプログラムの歴史は80年以上に及ぶが、ソフトウェア設計の方法論や良いプログラムの要件についての議論は乏しい
- 開発プロセス(アジャイル開発)、開発ツール(デバッガ、バージョン管理、テストカバレッジ)、プログラミング技法(OOP、関数型)、デザインパターン、アルゴリズムに関する議論は豊富に存在する
- ソフトウェア設計の核心的問題は依然として未解決のまま残っている
- David Parnas の1971年の論文「モジュール分割の基準について」以降、ソフトウェア設計の技術的水準はほとんど進歩していない
問題分解の重要性と教育の欠如
- 複雑な問題を独立して解決できる部分に分割する「問題分解」は、コンピュータサイエンスにおける最も根本的な課題である
- 問題分解はプログラマが日常的に直面する設計の中心的課題であるが、大学の授業ではほとんど取り上げられていない
- for ループやオブジェクト指向プログラミングは教えられるが、ソフトウェア設計そのものは教えられない
プログラマの能力差と設計スキルの性質
- プログラマ間には品質と生産性において大きな差がある
- 優れたプログラマの多くは、自分の強みとなる具体的な技術を言語化することが難しい
- ソフトウェア設計スキルは先天的な才能であるという認識が広まっている
- 科学的証拠(Geoff Colvin 著『Talent is Overrated』等)によれば、多くの分野での卓越した成果は先天的能力よりも質の高い練習に関係している
スタンフォード大学 CS 190 の授業スタイル
- 著者はソフトウェア設計の授業を開設し、その成果として本書を執筆した
- 授業の形式:
- ソフトウェア設計の原則を提示し、学生がプロジェクトを通じて原則を習得・実践する
- 英語の作文授業に類似した反復プロセスを採用する(ドラフト→フィードバック→修正)
- 大規模なソフトウェアをゼロから開発し、コードレビューで設計上の問題を特定・改善する
- 設計原則の特徴:
- 高度かつ哲学的な内容を含む(例: 「エラーを存在しないものとして定義する」)
- 抽象的な学習より、コードを書き、ミスをし、修正と原則の関係を見る実践的学習が効果的
著者の背景と経験
- 著者はソフトウェア設計の正式な教育を受けておらず、個人的な経験から知見を得ている
- 多様な言語で約25万行のコードを記述した
- 携わったプロジェクト:
- 3つのオペレーティングシステムの設計
- 複数のファイル・ストレージシステム
- デバッガ、ビルドシステム、GUIツールキット等のインフラツール
- スクリプト言語
- テキスト、図、プレゼンテーション、集積回路向けのインタラクティブエディタ
- こうした経験から、避けるべきミスと活用すべき技法の共通パターンを抽出している
本書の位置付けと読者への要請
- 本書はソフトウェア設計についての意見書であり、最終的な答えを提示するものではない
- 本書の目的はソフトウェア設計に関する議論を始めることにある
- 読者へのアドバイス:
- 本書の提案は批判的に受け取ること
- 複雑さの軽減という全体目標が、個々の原則よりも重要である
- 提案が複雑さを実際に軽減しない場合は、採用を強制されない
- フィードバックの募集:
- 連絡先: [email protected]
- Google Group「software-design-book」への参加も可能
- 具体的なバグ、改善提案、重要な設計原則を示す簡潔な例を求めている
- 寄せられたフィードバックは将来の版に反映予定
1 Introduction
ソフトウェア開発と複雑性の本質
- ソフトウェア開発は人類史上最も純粋な創造的活動の一つであり、プログラマーは物理法則のような実際の制約に縛られない
- ソフトウェア開発における最大の制約は、作成するシステムを理解する能力である
- プログラムが進化し機能が増えるにつれ、コンポーネント間に微妙な依存関係が生じ複雑性が蓄積される
- 複雑性の蓄積は開発速度を低下させ、バグを増加させ、コストを押し上げる
- プログラムが大規模になり、関与する人数が増えるほど、複雑性の管理は困難になる
複雑性に対処する2つのアプローチ
- 第1のアプローチ ——複雑性の排除:
- コードをよりシンプルかつ明確にすることで複雑性を取り除く
- 特殊ケースの排除や識別子の一貫した使用などが具体的手段
- 第2のアプローチ ——複雑性のカプセル化(モジュラー設計):
- 複雑性を封じ込め、プログラマーがシステム全体の複雑性に一度にさらされないようにする
- ソフトウェアシステムをクラスなどのモジュールに分割する
- モジュールは互いに独立して設計され、他のモジュールの詳細を理解せずに作業できる
ウォーターフォールモデルとその問題点
- ウォーターフォールモデルの概要:
- 要件定義・設計・コーディング・テスト・保守という離散したフェーズにプロジェクトを分割する
- 各フェーズは次のフェーズが始まる前に完了し、システム全体の設計を一度に行う
- 設計フェーズの終了時に設計が凍結される
- ウォーターフォールモデルがソフトウェアに不適合な理由:
- ソフトウェアシステムは物理システムより本質的に複雑であり、実装前に全設計上の問題を把握できない
- 設計上の問題は実装が進んでから明らかになるが、その時点での大規模設計変更は困難
- 開発者は全体設計を変更せずに問題を修正しようとし、複雑性の爆発的増加を招く
インクリメンタル開発(アジャイル開発)
- 機能の小さなサブセットから設計・実装・評価を繰り返すアプローチを採用する
- 各イテレーションで既存設計の問題を発見し、次の機能設計前に修正する
- システムがまだ小規模なうちに初期設計の問題を修正できる
- 後続機能は前の実装で得た経験の恩恵を受け、問題が少なくなる
- ソフトウェアの可塑性により、実装途中での大規模設計変更が可能である
ソフトウェア設計の継続的プロセス
- インクリメンタル開発により、ソフトウェア設計は決して終わらない継続的プロセスである
- 初期設計がベストであることはほぼなく、経験を通じてより良い方法が明らかになる
- 開発者は常に設計の改善機会を探し、一定の時間を設計改善に充てるべきである
- 複雑性の削減がソフトウェア設計において最も重要な要素であり、設計全体を通じて複雑性を意識すべきである
本書の目的と活用方法
- 本書の目標:
- 第1目標: ソフトウェア複雑性の本質を説明すること(「複雑性」の定義、重要性、識別方法)
- 第2目標: 開発プロセスにおける複雑性最小化の技法を提示すること
- 単純なレシピではなく、「クラスは深くあるべき」などの上位概念を哲学的に提示する
- 本書の活用方法:
- コードレビューと組み合わせて使用することが最も効果的
- 他者のコードを読む際に設計上の問題を発見し、改善案を提案する訓練を行う
- レッドフラグ(コードが必要以上に複雑であることを示すサイン)を認識する能力を養う
- レッドフラグを発見した際は立ち止まり、問題を排除する別の設計を探す
- 適用上の注意:
- すべての規則には例外があり、すべての原則には限界がある
- 設計原則を極端に適用すると悪い結果をもたらす
- 優れた設計は競合するアイデアとアプローチのバランスを反映する
- 対象言語と適用範囲:
- 例題はJavaおよびC++を使用し、オブジェクト指向言語のクラス設計を主に論じる
- メソッドに関するアイデアはCなどの非オブジェクト指向言語の関数にも適用可能
- サブシステムやネットワークサービスなど、クラス以外のモジュールにも設計思想は適用できる
2 The Nature of Complexity
章の目的と概要
- 本書はソフトウェアシステムの複雑性を最小化する設計を論じる
- 本章は複雑性の定義、症状、原因を高レベルで整理する
- 複雑性を認識する能力は重要な設計スキルであり、問題の早期発見と設計方針の改善に役立つ
- 本章は以降の章の基礎となる前提を提示する
複雑性の定義
- 複雑性とは、ソフトウェアシステムの構造においてシステムの理解と変更を困難にするもの全般を指す
- コストとベネフィットの観点でも捉えられる:
- 複雑なシステムでは小さな改善にも多大な作業が必要
- シンプルなシステムではより少ない労力で大きな改善が可能
- 複雑性はシステム全体の規模や機能の多さとは必ずしも一致しない:
- 大規模で高機能なシステムが作業しやすければ複雑ではない
- 小規模・単純なシステムでも複雑になりうる
- システムの全体的複雑性は、各パーツの複雑性と開発者がそのパーツに費やす時間の割合によって決まる
- 複雑性は書き手よりも読み手に明確に現れる:
- 自分には単純に見えても他者が複雑と感じる場合は複雑とみなす
- 開発者の役割は自分が扱いやすいコードだけでなく、他者も扱いやすいコードを作ること
複雑性の症状
- 変更の増幅 (Change amplification):
- 一見単純な変更が多くの箇所の修正を要する状態
- 例: 各ページにバナーの背景色をハードコードしているWebサイトは、色変更時に全ページの修正が必要
- 良い設計はひとつの設計判断が影響するコード量を最小化する
- 認知的負荷 (Cognitive load):
- タスクを完了するために開発者が把握すべき情報量の多さ
- 認知的負荷が高いほど習得時間が増加し、見落としによるバグリスクが上昇する
- コード行数の少なさは複雑性の低さを意味しない:
- 行数が少なくても理解困難なフレームワークは存在する
- 行数が多くても認知的負荷を下げる実装の方がシンプルな場合がある
- 未知の未知 (Unknown unknowns):
- どのコードを修正すべきか、またはどの情報が必要かが不明瞭な状態
- 変更後にバグが現れるまで問題に気づけない
- 3つの症状のうち最も深刻:
- 変更の増幅は修正箇所が明確であれば対処可能
- 認知的負荷は読む情報が明確であればコストが上がっても正確な変更が可能
- 未知の未知は何をすべきか、提案した解決策が正しいかすら不明
- 良い設計の目標はシステムを明白 (obvious) にすること:
- 認知的負荷と未知の未知の反対概念
- 開発者が深く考えなくても正確な推測ができる状態
複雑性の原因
- 依存関係 (Dependencies):
- あるコードが他のコードを考慮せずに理解・変更できない状態
- 例: ネットワークプロトコルの送信側と受信側のコード、メソッドのシグネチャと呼び出し元
- 依存関係はソフトウェアの本質的な要素で完全な排除は不可能
- 設計目標は依存関係の数を減らし、残存する依存関係をシンプルかつ明白にすること
- 非明白な依存関係をより明白でシンプルな依存関係に置き換えることが有効
- 曖昧性 (Obscurity):
- 重要な情報が明白でない状態
- 例: 汎用的すぎる変数名、単位が不明なドキュメント、同一変数名を2つの目的に使用する不一致
- 依存関係が存在することが明白でない場合に多く関連する
- ドキュメントの不足だけでなく設計の問題でもある:
- クリーンで明白な設計はドキュメント量を削減する
- 大量のドキュメントが必要な場合は設計に問題がある兆候
- 曖昧性を減らす最善策はシステム設計をシンプルにすること
- 依存関係と曖昧性が3つの症状を生む:
- 依存関係 → 変更の増幅と高い認知的負荷
- 曖昧性 → 未知の未知と認知的負荷
複雑性の漸進的な蓄積
- 複雑性は単一の壊滅的なエラーではなく、小さな問題の積み重ねで生じる
- 数百・数千の小さな依存関係や曖昧性が積み重なり、あらゆる変更に影響する
- 漸進的な性質ゆえ制御が困難:
- 個々の変更で生じる小さな複雑性は些細に見えるが急速に蓄積する
- 一度蓄積すると単一の依存関係や曖昧性の修正では大きな改善が得られない
- 複雑性の増大を抑制するには「ゼロ容認」の哲学が必要
結論
- 複雑性は依存関係と曖昧性の蓄積から生じる
- 複雑性の増大により変更の増幅、高い認知的負荷、未知の未知が発生する
- 各機能実装に必要なコード変更量が増加し、安全な変更に必要な情報収集時間も増加する
- 最悪の場合、必要な情報を全て入手できない事態となる
- 複雑性は既存コードベースの変更を困難かつリスクの高いものにする
3 Working Code Isn't Enough
戦術的プログラミングの問題点
- 戦術的プログラミングとは、現在のタスクを迅速に完了させることを最優先とするアプローチ
- 将来の設計より目先の動作を重視するため、近視眼的な判断を繰り返す
- 複雑性は「小さな妥協の積み重ね」によって段階的かつ急速に増大する
- 一度複雑性が蓄積すると、リファクタリングより次の機能実装が優先され続け、コードは悪化の一途を辿る
- 「戦術的竜巻」と呼ばれる極端な戦術的プログラマーの特徴:
- 他者より圧倒的に速くコードを書き、短期的にはヒーロー扱いされる
- 後続のエンジニアに混乱の後始末を強い、組織全体の見かけ上の生産性を歪める
戦略的プログラミングの原則
- 「動作するコード」を主目標とせず、優れた設計を生み出すことを第一の目標とする
- 投資マインドセットを採用し、長期的な設計改善のために短期的な速度を犠牲にする
- 投資の形態:
- 事前投資: 新しいクラス設計に複数の案を試みて最もシンプルなものを選ぶ、良いドキュメントを作成する
- 事後投資: 設計上の問題を発見したら放置せず修正する、継続的に小さな改善を積み重ねる
適切な投資量
- 開発時間全体の10〜20%を設計への投資に充てることを推奨する
- 短期的には初期プロジェクトが10〜20%遅くなるが、数ヶ月後には戦術的アプローチと同等以上の速度を達成する
- 戦略的アプローチの長期的メリット:
- 過去の投資による利益が将来の投資コストをカバーし、実質的に投資が無償となる
- 複雑性の蓄積を防ぎ、長期的な開発速度を維持する
- 戦術的アプローチの長期的代償:
- 初期は10〜20%速く進むが、複雑性の蓄積により長期的には10〜20%遅くなる
- 劣化したコードベースは開発速度を少なくとも20%低下させる
スタートアップにおける設計投資
- スタートアップが戦術的アプローチを選択しがちな背景:
- 早期リリースへの強いプレッシャーから、10〜20%の設計投資すら余裕がないと判断される
- 成功後に資金を調達してコードを整理できるという誤った合理化が行われる
- 戦術的アプローチの現実的なリスク:
- コードがスパゲッティ化すると修正はほぼ不可能となり、製品の存続期間を通じて高い開発コストを負い続ける
- コード品質の低さは優秀なエンジニアの採用を困難にし、コスト増大と設計劣化の悪循環を生む
- Facebookの事例(戦術的アプローチ):
- 「速く動いて壊せ」を社訓とし、新入エンジニアが初週から本番コードを書くことを奨励した
- コードは不安定で理解困難な状態に陥り、最終的に「堅固なインフラで速く動く」へ方針転換した
- GoogleとVMwareの事例(戦略的アプローチ):
- 高品質なコードと良い設計を重視し、トップ技術人材を集める強い企業文化を築いた
- 複雑な問題を信頼性の高いシステムで解決し、持続的な成功を収めた
結論
- 良い設計は継続的な投資なしには実現できず、小さな問題が大きな問題に積み重なる前に対処する必要がある
- 設計改善の先送りは習慣化しやすく、一度遅延が始まると永続的な遅延となり、戦術的文化へのスリップを招く
- 問題の先送りは問題をより大きく、解決をより困難にする
- 最も効果的なアプローチは、あらゆるエンジニアが日々継続的に小さな設計投資を行うこと
4 Modules Should Be Deep
モジュラー設計の基本原則
- ソフトウェアの複雑性を管理する重要な手法として、モジュラー設計がある
- モジュラー設計では、システムを比較的独立したモジュールの集合に分解する
- モジュールはクラス、サブシステム、サービスなど様々な形態をとる
- 理想的には各モジュールが完全に独立しているべきだが、現実にはモジュール間の依存関係が生じる
- 依存関係の管理のため、各モジュールはインターフェースと実装の2つの部分に分けて考える:
- インターフェース: モジュールを使用するために他の開発者が知る必要のある情報(「何をするか」)
- 実装: インターフェースが約束した内容を実際に実行するコード(「どのようにするか」)
- モジュラー設計の目標は、モジュール間の依存関係を最小化することにある
- 最良のモジュールは、インターフェースが実装より大幅にシンプルなもの
インターフェースの構成要素
- インターフェースは形式的な要素と非形式的な要素の2種類から構成される
- 形式的な要素:
- コード上で明示的に定義され、プログラミング言語によって検証可能
- メソッドのシグネチャ(パラメータ名・型、戻り値の型、例外情報)
- クラスのすべてのパブリックメソッドのシグネチャおよびパブリック変数
- 非形式的な要素:
- プログラミング言語では規定・強制できない
- 高レベルの動作(例: 引数で指定されたファイルを削除する関数であること)
- 利用上の制約(例: あるメソッドは別のメソッドより先に呼び出す必要があるなど)
- コメントによってのみ記述でき、記述の完全性や正確性は言語によって保証されない
- 多くのインターフェースにおいて、非形式的な側面は形式的な側面より大きく複雑
抽象化の概念と誤り
- 抽象化とは、重要でない詳細を省略したエンティティの簡略化されたビューである
- モジュールのインターフェースは、そのモジュールの機能の抽象化を提供する
- 抽象化が陥る2つの誤り:
- 重要でない詳細を含めてしまう: 抽象化が不必要に複雑になり、認知負荷が増加する
- 重要な詳細を省略してしまう: 情報が不足し、開発者がモジュールを正しく使えなくなる(偽の抽象化)
- ファイルシステムを例に取ると:
- 省略してよい詳細: ストレージデバイス上のブロック選択メカニズム
- 省略できない詳細: データをセカンダリストレージにフラッシュするルール(データベースなどが必要とするため)
- 抽象化設計の鍵は「何が重要か」を理解し、重要な情報の量を最小化する設計を探ることにある
深いモジュール
- 「深いモジュール」とは、強力な機能を持ちながらシンプルなインターフェースを持つモジュールのこと
- モジュールの深さはコストと利益の観点で考えられる:
- 利益: モジュールの機能性
- コスト(システム複雑性における): インターフェース
- インターフェースが小さくシンプルであるほど、システムに与える複雑性は少ない
- 深いモジュールの代表例:
- Unix I/Oインターフェース: open / read / write / lseek / close の5つのシステムコールのみで、背後に数十万行のコードが存在する
- ガベージコレクター(GoやJavaなど): インターフェースが事実上ゼロで、裏側で自動的に動作し、オブジェクト解放のインターフェースを排除することで全体インターフェースを縮小させる
浅いモジュール
- 浅いモジュールの発生は危険信号
- 「浅いモジュール」とは、提供する機能に対してインターフェースが相対的に複雑なモジュールのこと
- 浅いモジュールは複雑性の管理にほとんど寄与しない
- 小さいモジュールは浅くなりがちであり、複雑性との戦いにおいて有益でない
- 浅いメソッドの極端な例:
private void addNullValueForAttribute(String attribute) {
data.put(attribute, null);
}
- 問題点:
- メソッドを呼び出すより直接変数を操作した方が簡単
- ドキュメントがコード本体より長くなる可能性がある
- 開発者が習得すべき新たなインターフェースを増やすだけで、相応の利益を提供しない
クラシティス(Classitis)
- 「クラシティス」とは「クラスは良いものだから、多ければ多いほど良い」という誤った考えから生じる症状
- 現代のプログラミングでは「クラスは小さくあるべき」という通念が広まっており、クラスを小さく分割することが推奨されがち
- クラシティスの問題点:
- 個々のクラスは単純でも、システム全体の複雑性が増大する
- 小さいクラスは機能が乏しいため大量に必要となり、それぞれのインターフェースが積み重なってシステムレベルの複雑性が生じる
- 各クラスのボイラープレートにより、冗長なプログラミングスタイルが生まれる
JavaとUnix I/Oの比較例
- Javaにおけるクラシティスの事例:
- ファイルからシリアライズされたオブジェクトを読み込む際、3つの異なるオブジェクトを生成する必要がある(
FileInputStream、BufferedInputStream、ObjectInputStream)
- バッファリングが自動で有効化されず、開発者が明示的に
BufferedInputStream を生成しなければならない
- この設計はエラーが発生しやすく(バッファリング忘れによるパフォーマンス低下)、利便性が低い
- バッファリングはほぼ全ての利用者に必要なため、デフォルトで提供されるべき
- Unix I/Oにおける設計の優位性:
- シーケンシャルI/Oが最も一般的なため、それをデフォルトの動作とした
- ランダムアクセスは
lseek で対応可能だが、シーケンシャルアクセスのみを行う開発者はそのメカニズムを知る必要がない
- 一般的なケースをシンプルにするという設計思想を実践している
結論
- モジュールのインターフェースと実装を分離することで、実装の複雑性をシステムの残りの部分から隠蔽できる
- モジュールやクラスを設計する上で最も重要なことは、「深く」設計すること
- 深いモジュールとは、一般的なユースケースに対してシンプルなインターフェースを持ちながら、重要な機能を提供するもの
- このアプローチにより、隠蔽できる複雑性の量を最大化できる
5 Information Hiding (and Leakage)
情報隠蔽の基本概念
- 情報隠蔽はDeep Moduleを実現するための最も重要な技法であり、David Parnasが提唱した
- 各モジュールはいくつかの設計決定をカプセル化し、その詳細を実装内部に埋め込んでインターフェースに露出させない
- 隠蔽される情報の例:
- B-treeのデータ構造とアクセス方法
- ファイル内の論理ブロックと物理ディスクブロックの対応
- TCPネットワークプロトコルの実装
- マルチコアプロセッサのスレッドスケジューリング
- JSONドキュメントのパース方法
- 情報隠蔽は複雑性を2つの方法で削減する:
- インターフェースを単純化し、利用者の認知負荷を軽減する
- 外部に依存を持たせないことで、設計変更の影響範囲を単一モジュールに限定する
- privateアクセス修飾子による宣言は情報隠蔽と同義ではない(getterやsetterがあれば情報は露出する)
- 完全な情報隠蔽が最善だが、部分的な情報隠蔽も依存関係の削減に寄与する
情報漏洩とその危険性
- 情報漏洩は情報隠蔽の対義であり、ある設計決定が複数のモジュールに反映されている状態を指す
- 情報漏洩はモジュール間に依存関係を生み出し、設計変更が複数モジュールへの修正を要求する
- 漏洩の形態:
- インターフェースを通じた漏洩(より明示的で発見しやすい)
- バックドア漏洩(例: 同一ファイルフォーマットを複数クラスが把握している状態)
- バックドア漏洩はインターフェース経由の漏洩より悪質であり、発見が困難
- 情報漏洩への対処:
- 影響を受けるクラスが小さく関連が深い場合は単一クラスに統合する
- 影響を受ける全クラスから情報を抽出し、それだけをカプセル化する新クラスを作る(ただし、新クラスが大半の知識をインターフェースに露出する場合は効果が薄い)
時系列分解による情報漏洩
- 時系列分解とは、システムの構造を処理の実行順序に対応させる設計スタイル
- 例: ファイルを読み込むクラス、変更するクラス、書き出すクラスに分けると、読み込みと書き出しの両クラスがファイルフォーマットの知識を持ち、情報漏洩が発生する
- 解決策はファイルの読み書き機能を単一クラスに統合すること
- 処理の順序ではなく、各タスクを実行するために必要な知識に焦点を当ててモジュールを設計する
- 順序は時にコード内に反映されるが、それが情報隠蔽と一致する場合のみモジュール構造に反映すべき
HTTPサーバを用いた実例
- 過剰なクラス分割の問題:
- HTTPリクエストの受信を「ネットワークから文字列へ読み込むクラス」と「文字列をパースするクラス」に分離した例は時系列分解の典型
- Content-Lengthヘッダの解析なしにリクエスト全体を読み込めないため、両クラスがHTTP構造の知識を持つことになり情報漏洩が発生
- 呼び出し元は特定の順序で2つのメソッドを呼ぶ必要があり、複雑性が増加
- 読み込みとパースを単一クラスに統合することで情報隠蔽が改善し、インターフェースも単純化される
- クラスをやや大きくすることで情報隠蔽が向上する2つの理由:
- 特定の機能に関連するコードを集約できる
- インターフェースの抽象レベルを引き上げ、複数ステップを1つのメソッドに集約できる
- HTTPパラメータ処理の良い例:
- パラメータの所在(ヘッダ行かボディか)を呼び出し元から隠蔽した
- URLエンコーディングをデコードしてから値を返すことで、エンコーディング知識を隠蔽した
- HTTPパラメータ処理の悪い例:
getParams() でMap全体を返すとパラメータの内部表現が露出する
- 内部表現が変更されるとインターフェースも変更され、全呼び出し元の修正が必要になる
- 呼び出し元がMapを変更しないよう注意する責任を負わせることになる
- HTTPパラメータ処理の改善例:
getParameter(String name) で単一パラメータをstring型で返す
getIntParameter(String name) でstring型から整数型への変換も内部で行う
- 内部表現を隠蔽し、型変換も呼び出し元から隠蔽できる
- HTTPレスポンスのデフォルト値:
- レスポンスのHTTPバージョンは呼び出し元が明示指定するのではなく、HTTPクラスが自動的に付与すべき
- DateヘッダなどもHTTPライブラリが適切なデフォルト値を提供すべき
- デフォルト値は「最も一般的なケースを可能な限り単純にする」原則を体現する
- 稀に上書きが必要な場合のみ、呼び出し元がそのデフォルト値の存在を意識する(部分的情報隠蔽)
クラス内部での情報隠蔽
- 情報隠蔽は外部公開APIだけでなく、クラス内部にも適用できる
- クラス内のprivateメソッドも、それぞれが特定の情報や機能をカプセル化するよう設計すべき
- 各インスタンス変数が使われる箇所を最小化することでクラス内部の依存関係と複雑性を削減できる
情報隠蔽の過剰適用への注意
- モジュール外部で必要とされる情報を隠蔽してはならない
- 設定パラメータがモジュールのパフォーマンスに影響し、用途によって異なる設定が必要な場合は、そのパラメータをインターフェースに露出すべき
- 設計者の目標はモジュール外部に必要な情報量を最小化することであり、モジュールが自動的に設定を調整できれば最善
結論
- 情報隠蔽とDeep Moduleは密接に関連しており、多くの情報を隠蔽するモジュールはより深くなる傾向がある
- システムをモジュールに分解する際は、実行時の処理順序に引きずられてはならない(時系列分解は情報漏洩と浅いモジュールを招く)
- アプリケーションのタスクに必要な知識の種類を考え、各モジュールがその知識をカプセル化するよう設計することで、シンプルでDeepな設計が実現できる
6 General-Purpose Modules are Deeper
汎用設計と特化設計のトレードオフ
- 汎用アプローチ: 幅広い問題に対応できる仕組みを実装し、将来的な再利用に備える
- 特化アプローチ: 現在の要件に絞り、必要な機能のみを実装する
- 著者の推奨は「やや汎用的(somewhat general-purpose)」な設計である
- モジュールの機能は現在の要件を反映する
- インタフェースは特定の用途に縛られず、複数の利用パターンを支持できる程度に汎用化する
- 過度に汎用化して現在の用途で使いづらくなることは避ける
事例: テキストエディタのテキストクラス設計
- 特化設計の問題点:
backspace(Cursor cursor) や delete(Cursor cursor) のようにUIの操作ごとにメソッドを定義した
deleteSelection(Selection selection) のようにUI固有の型をテキストクラスに持ち込んだ
- メソッド数が増大し、各メソッドは浅く、1か所からしか呼ばれない
- テキストクラスとUIクラスの間に情報漏洩が発生した
- バックスペースキーやセレクションなどUIの抽象概念がテキストクラスに侵食した
- 両クラスが密結合し、それぞれを独立して開発できなくなった
- 汎用設計への改善:
insert(Position position, String newText) と delete(Position start, Position end) の2メソッドに集約した
- UI固有の型
Cursor の代わりに汎用型 Position を使用した
changePosition(Position position, int numChars) により位置操作を汎用化した
- バックスペースとDeleteキーの実装はUIレイヤーのコードで表現できる
- テキストクラスは他用途(文字列置換など)にも転用可能になった
汎用性が情報隠蔽を向上させる
- テキストクラスとUIクラスの間に明確な分離が生まれる
- テキストクラスはバックスペースキーの処理など、UIの詳細を知る必要がなくなる
- 新しいUI機能を追加してもテキストクラスに変更が不要になる
- 開発者の認知負荷が低減する
- 少数のシンプルなメソッドを学ぶだけでよい
- 同じメソッドをさまざまな用途に再利用できる
- 「偽の抽象化(false abstraction)」の問題:
- 特化した
backspace メソッドは情報を隠すと見せかけて、実際には開発者がその内部を確認しなければならない
- 詳細が重要な場合は明示的かつ明瞭にする方が適切である
- インタフェースの裏に隠すことで難解さを生み出す
汎用インタフェース設計のための自己確認
- 「現在の要件をすべてカバーする最もシンプルなインタフェースは何か」:
- APIの機能を損なわずにメソッド数を減らせれば、汎用化が進んでいる証拠である
- ただし、メソッドを減らすために引数が複雑化するなら汎用化とは言えない
- 「このメソッドは何種類の状況で使われるか」:
- 特定の1用途のみに対応するメソッドは過度な特化のサインである
- 複数の特化メソッドを1つの汎用メソッドに置き換えられないか検討する
- 「このAPIは現在の用途において使いやすいか」:
- 過剰な汎用化も問題となる
- 1文字単位の insert/delete しか持たないテキストクラスは汎用だが、エディタ実装には不向きで効率も悪い
- 文字範囲操作のような組み込みサポートが必要な場合もある
結論
- 汎用インタフェースはメソッド数が少なく、各メソッドは深い
- クラス間の分離が明確になり、特化設計による情報漏洩を防ぐ
- モジュールをやや汎用的に設計することは、システム全体の複雑性を低減する最善の手法の一つである
7 Different Layer, Different Abstraction
階層ごとに異なる抽象化の原則
- ソフトウェアシステムは層で構成され、上位層は下位層の機能を利用する
- 適切に設計されたシステムでは、各層は隣接する層と異なる抽象化を提供する
- ファイルシステムの例:
- 最上位層はファイル抽象化(可変長バイト配列)を実装する
- 中間層は固定サイズのディスクブロックのメモリキャッシュを実装する
- 最下位層はデバイスドライバで、ブロックをストレージとメモリ間で転送する
- TCPの例:
- 最上位層は信頼性のあるバイトストリームを提供する
- 下位層は限定サイズのパケットをベストエフォートで送信する
- 隣接する層が類似した抽象化を持つ場合、クラス分解に問題がある可能性を示す
パススルーメソッド
- パススルーメソッド: 同一または類似のシグネチャを持つ別のメソッドを呼び出すだけのメソッド
- 問題点:
- クラスを浅くする(インターフェースの複雑さを増すが、機能は増えない)
- クラス間の依存関係を生じさせる
- クラス間の責任分担の混乱を示す
- 解決方法:
- 上位クラスの呼び出し元が下位クラスを直接呼び出す(上位クラスから機能の責任を除去する)
- クラス間で機能を再分配する
- クラスをマージする
- 例:
public class TextDocument ... {
private TextArea textArea;
private TextDocumentListener listener;
...
public Character getLastTypedCharacter() {
return textArea.getLastTypedCharacter();
}
public int getCursorOffset() {
return textArea.getCursorOffset();
}
public void insertString(String textToInsert, int offset) {
textArea.insertString(textToInsert, offset);
}
public void willInsertString(String stringToInsert, int offset) {
if (listener != null) {
listener.willInsertString(this, stringToInsert, offset);
}
}
...
}
- あるテキストエディタのコードには、ほぼすべてがパススルーメソッドで構成されたクラスが含まれていた
- そのクラスの15個のパブリックメソッドのうち、13個がパススルーメソッドという状況だった
インターフェースの重複が許容されるケース
- 同一シグネチャを持つメソッドが常に問題となるわけではなく、各メソッドが有意な機能を提供する場合は許容される
- ディスパッチャー:
- 引数を使って複数のメソッドの中から1つを選択して呼び出すメソッド
- ディスパッチャー自身のシグネチャは呼び出すメソッドと同一の場合が多い
- どのメソッドを実行するかを選択するという有用な機能を提供する(例: WebサーバーのHTTPリクエスト処理)
- 同一インターフェースを持つ複数の実装(例: OSのディスクドライバ):
- 各ドライバは異なる種類のディスクをサポートするが、同一インターフェースを持つ
- 認知負荷を下げる効果がある
デコレーターパターン
- デコレーターは既存オブジェクトを取得し、その機能を拡張する(下位オブジェクトと同一または類似のAPIを提供する)
- 例: JavaのBufferedInputStream(InputStreamをラップしてバッファリングを追加する)
- 問題点:
- デコレータークラスは浅くなりがち(多くのパススルーメソッドを含む)
- パターンを乱用すると浅いクラスが大量に生じる
- デコレータークラスを作成する前に検討すべき代替案:
- 新機能を下位クラスに直接追加する(機能が汎用的、または下位クラスと論理的に関連する場合)
- 特定ユースケース用の機能は、そのユースケースとマージする
- 既存のデコレーターに新機能をマージして、単一の深いデコレーターを作る
- 基底クラスに依存しないスタンドアローンクラスとして実装する
インターフェースと実装の違い
- クラスのインターフェースは通常その実装と異なるべきである
- 両者が類似した抽象化を持つ場合、クラスはおそらく浅い
- テキストエディタの例:
- 行単位のAPIを持つテキストクラスは浅く、使いにくい(行の分割・結合を呼び出し元に強制する)
- 文字単位のインターフェース(任意位置への文字列挿入・削除)を提供することで、クラスが深くなり、上位コードが簡潔になる
- 内部実装は行単位でも、インターフェースを文字単位にすることでAPIと実装の差が価値ある機能を提供する
パススルー変数
- パススルー変数: 中間メソッドで使用されないにもかかわらず、メソッドの長い連鎖を通じて渡される変数
- 問題点:
- 中間メソッドがその変数の存在を意識する必要が生じる
- 新しい変数が追加された場合、多数のインターフェースとメソッドを変更する必要がある
- 解消方法:
- 最上位と最下位のメソッド間で共有されているオブジェクトがあれば、そこに情報を格納する
- グローバル変数として格納する(ただし、複数インスタンスの作成やテストが困難になるなどの問題がある)
- コンテキストオブジェクトの導入(最も一般的な解決策):
- アプリケーションのすべてのグローバル状態を格納する
- システムのインスタンスごとに1つのコンテキストオブジェクトが存在する
- 複数のインスタンスが単一プロセス内に共存できる
- コンストラクタでのみ明示的な引数として現れる
- テストでグローバル設定を変更しやすい
- コンテキストオブジェクトの問題点:
- グローバル変数と同様の欠点を多く持つ
- 規律なく使うと、システム全体に非自明な依存関係を生む大きなデータの詰め込み場所になりうる
- スレッドセーフの問題が発生する可能性がある(変数をイミュータブルにすることで対処)
結論
- インターフェース、引数、関数、クラス、定義などの設計要素はすべて複雑さを追加する
- 設計要素が正味のメリットをもたらすには、その要素がない場合に生じる複雑さを消滅させなければならない
- パススルーメソッドやデコレーターのように異なる層が同じ抽象化を持つ場合、追加のインフラに対して十分なメリットを提供できていない可能性が高い
- パススルー引数もメソッドに変数の存在を意識させる(複雑さを追加する)一方で機能は追加しない
8 Pull Complexity Downwards
基本原則: 複雑さを下方に引き込む
- モジュール開発時に避けられない複雑さが生じた場合、ユーザーに処理させるよりモジュール内部で処理すべき
- モジュールの利用者数は開発者数を上回るため、開発者がより多くの負担を負うべき
- シンプルな実装よりシンプルなインターフェースを持つことが重要
- 逆の方向(例外を投げてユーザーに処理させる、設定パラメータで判断を委ねる)は複雑さを増幅させる
例1: テキストエディタのテキストクラス
- 行指向インターフェース:
- 実装はシンプルだが、上位ソフトウェアに複雑さを押し付ける
- ユーザーインターフェース操作(文字挿入、選択の削除)に対応するため行の分割・結合が上位コードで必要となる
- 文字指向インターフェース:
- テキストクラス内部で行の分割・結合を行い、上位ソフトウェアをシンプルに保つ
- 複雑さをクラス内部にカプセル化し、システム全体の複雑さを低減する
例2: 設定パラメータ
- 設定パラメータは複雑さを上方に移動させる手法
- 賛成意見:
- ユーザーが自分の要件に合わせてシステムを調整できる
- 低レベルのコードがドメイン知識を持たない場合に有効
- 問題点:
- ユーザー・管理者が適切な値を判断できないケースが多い
- 適切な値をシステムが自動的に算出できる場合でも判断を外部に委ねてしまう
- 例: ネットワークプロトコルのリトライ間隔は、成功したリクエストの応答時間から自動計算が可能
- 設定パラメータを避けるべき理由:
- ユーザーが開発者より優れた値を決定できるか問い直すべき
- 合理的なデフォルト値を自動算出し、例外的な状況のみ設定を求める形が理想
- 設定パラメータは不完全な解決策であり、システムの複雑さを増加させる
過剰適用への注意
- 全機能を1つのクラスに集約するような極端な適用は不適切
- 複雑さを下方に引き込む条件:
- (a) 引き込む複雑さがクラスの既存機能と密接に関連している
- (b) 引き込みによって他の多くの箇所がシンプルになる
- (c) クラスのインターフェースがシンプルになる
- 反例: テキストクラスにバックスペースキーの機能を追加した場合
- 上位コードをあまりシンプルにせず、ユーザーインターフェースの知識をテキストクラスの核心機能と無関係に持ち込む結果となる
- 情報漏洩を招くだけで複雑さの削減にはつながらない
結論
- モジュール開発者は、ユーザーの負担を減らすために、自ら追加の負担を引き受ける機会を探すべき
9 Better Together Or Better Apart?
基本的な問い: まとめるか分けるか
- ソフトウェア設計における根本的な問いは、2つの機能を同じ場所にまとめるか、分けて実装するかという問題
- 関数、メソッド、クラス、サービスなど全ての設計レベルで適用される
- 目標はシステム全体の複雑さを削減し、モジュール性を向上させること
分割のコスト
- コンポーネントの数自体が複雑さをもたらす
- 管理するコンポーネントが増えるほど追跡が困難になる
- 分割によりインターフェースが増加し、それぞれが複雑さを追加する
- 分割によってコンポーネントを管理するための追加コードが必要になる場合がある
- 分割はコンポーネント間の距離を生む
- 依存関係がある場合、開発者は両者を行き来しなければならない
- 依存関係に気づかないとバグを引き起こす可能性がある
- 分割は重複を生む可能性がある
コードをまとめるべき判断基準
- 情報を共有している場合: どちらも特定のドキュメント形式の構文に依存するなど
- 一緒に使用される場合(双方向の関係がある場合): 一方を使う人は他方も使う
- 一方向の関係では強い根拠にならない(例: ディスクブロックキャッシュとハッシュテーブルは分離すべき)
- 概念的に重複する場合: 両方を包含する単純な上位カテゴリが存在する
- 一方を理解するのに他方を見ることが必要な場合
まとめるべき具体的なケース
- 情報が共有される場合:
- HTTPサーバーでリクエストの読み込みとパースを別々のメソッドに分けた実装では、読み込みメソッドがリクエストの終端を識別するためにパース処理を必要とした
- 同じ情報を共有するため、一箇所でまとめてread・parseすることで、コードが短くシンプルになる
- インターフェースが簡略化される場合:
- 複数モジュールを結合することで、よりシンプルなインターフェースを定義できる
- 例: Java I/OライブラリでFileInputStreamとBufferedInputStreamを統合すれば、大多数のユーザーがバッファリングを意識する必要がなくなる
- 重複を排除できる場合:
- 繰り返されるコードパターンを別メソッドとして切り出してリファクタリングする
- 有効な条件: 繰り返しコードが長く、置き換えメソッドのシグネチャがシンプルな場合
- コードが多くのローカル変数にアクセスするなど環境と複雑な関係を持つ場合は、置き換えメソッドのシグネチャが複雑になりうる
- エラー処理における重複: エラー返却前に同じクリーンアップ処理が必要な場合はgoto文で単一化する手法も有効
汎用コードと専用コードの分離
- 複数の目的に使える機構を持つモジュールはその汎用機構のみを提供すべき
- 特定用途に特化したコードは、その用途に関連する別モジュールに置くべき
- システムの下層は汎用的、上層は専用的な傾向がある
- 特化コードを上層に引き上げることで、下層を汎用的に保つ
- 赤旗(Special-General Mixture): 汎用機構のコードに特定用途向けコードが混在している場合、機構が複雑化し情報漏洩が起きる
例1: 挿入カーソルと選択範囲
- GUIエディタで挿入カーソルと選択範囲を1つのオブジェクトで管理した実装の問題点:
- 上位コードは依然として選択とカーソルを別々のエンティティとして扱う必要がある
- カーソル位置を取得するためにブール値をテストする必要があり、実装が複雑
- 改善策: 選択とカーソルを分離し、Positionクラスを新設
- 選択は2つのPositionで、カーソルは1つのPositionで表現
- インターフェースと実装の両方がシンプルになった
例2: ログ記録用の別クラス化(分離が不適切な例)
- エラーが検出された時点でロギングを行わず、専用クラスに委譲した設計の問題点:
- ロギングメソッドが浅い(本体は1行程度)にも関わらず、多くのドキュメントが必要
- 各メソッドは1箇所からのみ呼ばれる
- 呼び出し元とロギングメソッドを行き来しなければ理解できない
- 改善策: ロギングメソッドを廃止し、エラー検出箇所に直接ログ出力を記述する
例3: エディタのUndo機構(汎用部分の分離)
- テキストクラスにUndo機構全体を実装した設計の問題点:
- 汎用的なUndo管理コアと、特定操作(テキスト、選択、カーソル)のUndo処理が混在
- テキストクラスとUIコードの間で情報漏洩が発生
- 新しいUndo対象を追加するたびにテキストクラスへの変更が必要
- 改善策: 汎用的なUndo機構をHistoryクラスとして独立させる
- Historyクラス: アクションの管理・グルーピングとUndo/Redo操作を担当
- 個別のActionクラス: 特定操作(テキスト挿入、選択変更など)のUndo/Redoを実装
- 上位UIコード: アクションのグルーピングポリシーを担当
- 3つのカテゴリは互いを理解せずに実装できる
メソッドの分割と統合
- メソッドの長さだけは分割の根拠にならない
- 長いメソッドが必ずしも悪いわけではない(シンプルなシグネチャと読みやすさがあれば問題なし)
- 分割するとインターフェースが増え、複雑さが増す
- メソッドの設計目標: 明確でシンプルな抽象化を提供すること
- 各メソッドは一つのことを完全に行うべき
- インターフェースは実装よりはるかにシンプルであるべき(深いメソッド)
- メソッドを分割すべき2つの形:
- サブタスクを別メソッドとして抽出: 親メソッドが子メソッドを呼び出す形。子メソッドは汎用的で他のコンテキストでも使用可能
- 独立した2つのメソッドに分割: 元メソッドが複数の無関連な処理を持つ場合。各メソッドのインターフェースが元より簡単になるべきで、大半の呼び出し元がどちらか一方のみを使う構成が理想
- 赤旗(Conjoined Methods): 一方を理解するために他方の実装も見る必要がある場合は、分割が不適切
- メソッドをまとめるべき場合:
- 浅いメソッドを深いメソッドに置き換えられる
- コードの重複を排除できる
- 中間データ構造や依存関係を排除できる
- より良いカプセル化やシンプルなインターフェースが実現できる
結論
- モジュールの分割・統合の判断は複雑さに基づくべき
- 情報隠蔽が最も優れ、依存関係が最小で、インターフェースが最も深い構造を選択する
10 Define Errors Out Of Existence
例外処理が生む複雑性
- 例外とは、プログラムの通常の制御フローを変えるあらゆる非通常条件を指す
- 例外処理コードは通常ケースのコードより本質的に難しい
- 例外発生時の対処法は2つある:
- 例外にもかかわらず処理を継続する(例: パケット再送)
- 処理を中止してエラーを上位に報告する(システム状態の不整合への対処が必要)
- 例外処理コードはさらなる例外を生む可能性がある
- 言語の例外サポートは冗長で可読性が低い(Javaのtry-catch構文が典型例)
- 例外処理コードはテストが困難でバグが潜伏しやすい
- 分散システムにおける重大障害の90%以上が不正なエラー処理に起因するという研究結果がある
過剰な例外定義の問題
- 「より多くのエラーを検出する方が良い」という考えが過剰防衛的なスタイルを生む
- 不要な例外の増加はシステムの複雑性を高める
- 例外はクラスのインターフェースの一部であり、例外が多いクラスのインターフェースは複雑になる
- 例外はスタックを複数レベルさかのぼって伝播するため、直接の呼び出し元だけでなく上位の呼び出し元にも影響する
- 例外をスローするのは容易だが、処理するのは難しく、複雑性は例外処理コードに集中する
例外を定義によって排除する手法
- APIの定義を変更することで、例外を処理する必要をなくす(「定義による排除」)
- Tclのunsetコマンドの例:
- 元の定義: 「変数を削除する」 → 変数が存在しない場合に例外をスロー
- 改善された定義: 「変数が存在しないことを保証する」 → 変数が存在しなくても正常終了
- Windowsとunixにおけるファイル削除の比較:
- Windows: 開かれているファイルを削除できず、エラーが発生する
- Unix: 削除マークを付けて即時成功を返し、全プロセスがファイルを閉じた後に実際に削除する
- Javaのsubstringメソッドの例:
- 現状: 範囲外インデックスでIndexOutOfBoundsExceptionをスロー
- 改善案: 範囲外インデックスを自動調整し、重複する文字のみを返す(Pythonのリストスライスがこの方式を採用)
- エラーを定義で排除することでAPIがシンプルになり、バグも減少する
例外マスキング
- 低レベルで例外を検出・処理し、上位レベルのコードには通知しない手法
- 分散システムで特に有効:
- TCPはパケットロスを検出・再送し、クライアントから隠蔽する
- NFSはサーバーダウン時にアプリケーションをハングさせ、エラーを上位に伝播させない
- 例外マスキングによりクラスのインターフェースが簡略化され、「深い」クラスになる
- 複雑性を下位レベルに引き下げることの一例
例外の集約
- 多数の例外を単一のコードで処理する手法
- Webサーバーにおける欠損パラメータ処理の例:
- 個別のサービスメソッドで各例外をキャッチする代わりに、例外を上位のディスパッチャーまで伝播させる
- トップレベルのハンドラーが全例外を処理し、適切なエラーレスポンスを生成する
- カプセル化と情報隠蔽の観点から優れた設計パターン
- RAMCloudにおけるクラッシュリカバリの例:
- 個別エラー(オブジェクト破損など)をより大きなエラー(サーバークラッシュ)に昇格させる
- 単一のクラッシュリカバリ機構で複数種類のエラーをまとめて処理する
- リカバリ機構の頻繁な実行によりバグの早期発見も促進される
- 例外集約と例外マスキングの違い:
- 集約: 例外をスタックの上位まで伝播させてから処理する
- マスキング: 低レベルメソッドで例外を処理する
- 例外集約は複数の特殊目的メカニズムを単一の汎用メカニズムに置き換える
クラッシュ戦略
- 処理が困難でまれにしか発生しないエラーに対しては、アプリケーションをクラッシュさせる
- メモリ不足エラーの例:
- mallocがNULLを返すたびに確認するのはコードを複雑にする
- ckalloc ラッパー関数を使い、メモリ不足時にエラーメッセージを出力して終了させる
- C++/JavaのnewがスローするOut of Memory例外もキャッチする意味はほとんどない
- I/Oエラー、ネットワークソケットエラー、内部的な不整合もクラッシュが適切な場合が多い
- アプリケーションの性質によってクラッシュの適否は異なる:
- レプリケーションストレージシステムではI/Oエラーをリカバリする必要がある
特殊ケースの設計による排除
- エラーと同様に、特殊ケースも設計によって排除すべき
- 特殊ケースを多用するコードはif文が増え、理解しにくくバグが生まれやすい
- テキストエディタにおける選択範囲の例:
- 「選択なし」状態を別途管理すると多くの特殊ケースチェックが必要になる
- 選択は常に存在するが、場合によっては空(開始位置と終了位置が同じ)とすることで特殊ケースがなくなる
- 空の選択に対するコピーや削除操作も特殊ケースチェックなしで実装できる
- 「選択なし」というユーザー視点の概念を、実装の内部表現に直接反映する必要はない
限界と結論
- 例外情報がモジュール外部で不要な場合にのみ、例外の排除やマスキングが有効
- 過剰な適用は危険:
- ネットワーク通信モジュールで全例外をマスクした場合、アプリケーションがメッセージロスやサーバー障害を検知できなくなる
- 重要な例外は公開する必要があり、重要でないものは積極的に隠蔽する
- 複雑性削減のための推奨戦略:
- セマンティクスの再定義によるエラー条件の排除(最善策)
- 低レベルでの例外マスキング
- 複数の特殊ケースハンドラーを単一の汎用ハンドラーへ集約
Chapter 11 Design it Twice
基本原則
- ソフトウェア設計は困難であり、最初のアイデアが最良の設計になることは稀
- 主要な設計上の意思決定において、複数の選択肢を検討することで、より優れた結果が得られる
- このアプローチを「Design it Twice(2度設計する)」と呼ぶ
設計代替案の検討プロセス
- テキストエディタのファイル管理クラスを例として、以下の3つの代替インターフェース設計を提示:
- 行指向インターフェース: 行単位での挿入・変更・削除操作
- 文字指向インターフェース: 個々の文字単位での挿入・削除操作
- 文字列/範囲指向インターフェース: 行境界をまたぐ任意の文字範囲を対象とした操作
- 各代替案のすべての機能を詳細に定義する必要はなく、主要なメソッドの概要を把握するだけで十分
- 互いに大きく異なるアプローチを選ぶことで、より多くの学びが得られる
- 1つの合理的なアプローチしかないと確信している場合でも、第2の設計を検討する
代替案の評価基準
- 上位ソフトウェアにとっての使いやすさ:
- 行指向インターフェース: 部分行や複数行操作(コピー&ペーストなど)の際に、上位ソフトウェアが行の分割・結合を行う必要がある
- 文字指向インターフェース: 複数文字を変更する操作にループ処理が必要となる
- 文字指向のアプローチは、各文字ごとにテキストモジュールへの呼び出しが発生するため、他の方式より著しく低速になる可能性が高い
- インターフェースのシンプルさ: 代替案間でインターフェースの単純さを比較
- 汎用性: より汎用的なインターフェースかどうかを評価
- 実装効率: より効率的な実装を可能にするかどうかを評価
設計の統合と改善
- 比較後、最良の設計を選定できる立場に置かれる
- 最良の選択肢は既存の代替案の1つである場合も、複数の代替案の特徴を組み合わせた新設計となる場合もある
- 既存の代替案に魅力的なものがない場合は、特定した問題点を活かして新たな設計を考案する
- テキストクラスの例では、行指向・文字指向の両方式が上位ソフトウェアに余分なテキスト操作を要求するという「赤信号」から、範囲指向APIという解決策が導出される
多層的な適用
- インターフェース設計: 最初にインターフェースの選定に適用
- 実装設計: 実装段階(リンクリスト、固定サイズブロック、ギャップバッファなど)にも適用可能
- 実装における最重要事項はシンプルさとパフォーマンス
- 上位レベルの設計: UIの機能選定やシステムのモジュール分解などにも適用
時間的コスト
- 代替案の検討に過度な時間は必要とせず、小規模なクラスであれば1〜2時間程度で済む
- この時間投資は、クラスの実装に費やす数日〜数週間と比較して小さい
- 設計実験により大幅に改善された設計が生まれ、かけた時間以上の効果が得られる
- 大規模モジュールでは設計探索により多くの時間がかかるが、実装も長くなり、より良い設計による恩恵も大きくなる
優秀な人材とこの原則の関係
- 優秀な人材がこの原則を取り入れることを難しいと感じる傾向がある:
- 幼少期から最初のアイデアで十分な結果を出せることを学習し、悪い作業習慣が身につきやすい
- 「優秀な人間は最初から正解を出せる」という誤った信念が複数の設計検討を妨げる
- より困難な問題に直面した際、最初のアイデアだけでは不十分となる
- 大規模ソフトウェアシステムの設計は、最初の試みで完璧に仕上げられるほど簡単ではない
- 最初のアイデアをすぐに実装することは、真の潜在能力を発揮できない原因となる
- 問題が本質的に困難であることを認識し、慎重に思考することが重要
設計スキルへの効果
- 設計を2度行うことは、設計の質だけでなく設計スキルそのものも向上させる
- 複数のアプローチを考案・比較するプロセスを通じて、設計を良くする・悪くする要因を学べる
- 長期的には、悪い設計を排除し、優れた設計を見極める能力が向上する