【読書メモ】A Philosophy of Software Design
はじめに
A Philosophy of Software Design はスタンフォード大学の John K. Ousterhout 教授が書いた本
タイトルは日本語では「ソフトウェア設計の哲学」と訳すのだろうか
moga さんの下記の記事で知った
Amazon で Kindle 版が 1,152 円と安かったので昨年12月25日に購入した
主な内容はソフトウェアの複雑さを減らすにはどうすれば良いのかのテクニック集
気づいたら半分くらい読んでいたので忘れないようにまとめておこうと思う
1. Introduction
この本のゴールは2つ
- ソフトウェアの複雑さについて解説すること
- 複雑さを最小化するためのテクニックを示すこと
2.1 Complexity defined
この本では複雑さの定義は「ソフトウェアシステムの構造に関連するあらゆるもので、システムの理解や変更を困難にするもの」
システムの複雑さを
複雑なパーツであっても(うまくモジュール化されているなどの理由で)ほとんどの開発者が中身を知る必要が無くなれば、そのパーツの複雑さはほぼ 0 になる
2.2 Symptoms of complexity
複雑さによる症状
- Change amplification: 変更の増幅
- Cognitive load: 認知の付加
- Unknown unknowns: 不明の不明点
1点目はシンプルな変更であっても様々なコードを修正する必要に迫られること
2点目は目的を果たすために調べたり理解したりする時間が増えること
3点目は目的を果たすためにどこに手を付ければ良いのかが明白ではないこと
2.3 Causes of complexity
複雑さの原因は下記の2点
- dependencies: 依存関係
- obscurity: 不明瞭さ
依存関係
あるコード A を理解したり修正したりするのに他のコード B が必要になるとき、A は B に依存しているとこの本では考える
依存関係は完全に取り除くことはできない、例えばクラスや関数を定義することも依存関係を増やすことになるから
なるべく依存関係の数を減らし、残った依存関係を可能か限りシンプルかつ明白にできればそれで十分
不明瞭さ
重要な情報が明らかになっていないこと
例えばドキュメントが不十分なことが挙げられる、ただし説明するために多くのドキュメントが必要なことも危険信号
他にも何かを変更するためには A と B の両方を修正しなければいけないが、その事が明らかになっていない状況
3つの症状との関係性
依存関係は変更の増幅と認知の負荷を招く
不明瞭さは不明の不明点と認知の負荷を招く
依存関係と不明瞭さを改善できれば複雑さを減らすことができる
2.4 Complexity is incremental
複雑さは徐々に増えていく
複雑さは大きな 1 つの判断ミスから引き起こされるのではなく、小さなたくさんの判断ミスの集合
今やっている変更で多少複雑になっても仕方がないと自分を説得するのは簡単
しかしそれらが積み重なって複雑さが対処しきれないくらいに大きくなっている
3.1 Tactical programming
戦術的プログラミング
何かを動くようにすることに重点を置いたプログラミングのアプローチ
例えば新機能追加やバグ修正
戦術的プログラミングの問題は生産性が高い時期を短期間しか維持できないこと
一度、戦術的プログラミングに陥ると途中で抜け出すのは難しい
3.2 Strategic programming
戦略的プログラミング
コードが動くだけでは十分ではないと考えることから始まる
今の仕事を終わらせるのと引き換えにコードを複雑にすることを許容しない
戦略的プログラミングには投資家のマインドセットが必要
短期的には目標達成までの速度は遅くなるが、長期的には投資を回収できる(複雑さが原因でスローダウンすることを軽減できる)
積極的な投資としては例えば新しいクラスを設計するときによりシンプルにする方法を探す
受動的な投資としては設計判断ミスが原因で問題に直面した時に無視したりその場しのぎしたりするのではなく修正する時間を設ける
継続的に小さな改善をシステム設計に加えていくこと、この点が戦術的プログラミングとは対照的
3.3 How much to invest?
設計改善にどれくらいの時間を使えば良いか
開発時間の1〜2割を使って小さな改善を継続的に多数行うのが一番のアプローチ
戦術的プログラミングの方が開始時が進捗が早いが次第に進捗が遅くなっていき、一定時間が経過すると戦略的プログラミングの方が進捗が早くなる
戦術的プログラミングが原因で技術的負債が引き起こされる
技術的負債は未来から時間を借りている、お金の負債と同様に技術的負債には利息がつく
お金の負債と異なるのは多くの技術的負債は完済することができないこと、利息は払い続けることになる
3.4 Startups and investment
スタートアップと投資
戦略的アプローチが困難な環境もある
スタートアップでは1〜2割の時間を使うことすら難しいかもしれない
結果として戦術的アプローチを取らざるを得ない
後から人を雇ってキレイにすれば良いと考えてしまうかもしれない
しかし一度複雑になったコードを修正するのは不可能に近い
すごいエンジニアを雇用できれば可能性はある
しかしすごいエンジニアは良いデザインに深く注意を払う傾向がある
従ってコードベースが汚ければ、採用することは困難になる
4.1 Modular design
ソフトウェアシステムを比較的独立したモジュールの集合に分解する
モジュールの一例は関数、クラス、サブシステム、サービスなど
理想的にはモジュールは他と完全に独立する
開発者はモジュールに変更を加える時に他のモジュールの関する知識を必要としなくて済む
しかし、現実にはモジュールは他のモジュールとやり取りする必要がある
結果としてモジュール間の依存関係が生じる
メソッド 1 つをとっても引数が変われば呼び出すコードに影響がある
他にもあるメソッドを呼び出す前に他のメソッドを呼び出す必要があるなどコードで表現できない依存関係もある
依存関係を管理するにはモジュールをインタフェースと実装の 2 に分ける
インタフェースにはモジュールを使う人が知らなければならないことをまとめる
インタフェースは「何をするか」、実装は「どのようにするか」
実装はインタフェースがした約束を実現する
モジュールを使う人は実装を知らなくても良いようにする
この本ではモジュールとはインタフェースと実装を持つすべてのものという意味で扱う
インタフェースが実装よりもはるかにシンプルであれば良いモジュール
シンプルなインタフェースは複雑さを最小化する
また、実装に変更を加えてもインタフェースに変わらなければ他のモジュールは影響されなくて済む
4.2 What's in an interface?
インタフェースの中身は何?
インタフェースはフォーマルとインフォーマルの 2 つの情報から構成される
フォーマル情報はプログラミング言語で明示的に指定することができる
例えばメソッドの名前や戻り値、引数の情報はフォーマル情報
一方、インフォーマル情報はプログラミング言語で指定できないもの
例えば「この関数を実行すると引数で指定された名前のファイルが削除される」などの機能
他の例は「この関数を呼び出す前に初期化関数を呼び出す必要がある」などの前提条件
インフォーマル情報はコメントとして表現される
多くのインタフェースではインフォーマル情報の方が多くて複雑
良いインタフェースの利点はモジュール利用者が知る必要のあることを示唆できる点
これにより不明な不明点(unknown unknowns)を削減できる
4.3 Abstractions
抽象化(捨象)はモジュラー設計に強く関係している
抽象化はエンティティ(クラスやメソッド)の単純化された見え方
エンティティを理解するのに重要ではない詳細を省略している
抽象化は複雑な事がらを扱うのを簡単にしてくれる
モジュールはインタフェースを通じて実装の詳細を抽象している
抽象化する時には何が重要ではないか (unimportant) を判断するのが大切
不必要な詳細をインタフェースに含めてしまうと抽象化のメリットが発揮されない
反対に省略し過ぎて重要な詳細まで隠してしまわないように気を付ける
良い抽象化の例はファイルシステム、ユーザーはストレージデバイスのブロックなどの詳細を意識する必要がない
ただしデータベースのようなアプリケーションはファイルシステムに隠された詳細を必要する
よく設計されたファイルシステムは隠された詳細にもアクセスできる手段を提供している
4.4 Deep modules
良いモジュールは強力な機能を提供する一方でシンプルなインタフェースを持つ
このようなモジュールをこの本では「深い」(deep) と形容している
モジュールの深さは費用対効果を考える方法の 1 つ
効果は機能、費用はインタフェース
モジュールのインタフェースがシンプルではない場合、モジュールを利用する開発者にとっての複雑さとなり得る
素晴らしいインタフェースの例の Unix のファイルシステム
インタフェースは下記の 5 つの関数だけ
- open
- read
- write
- lseek
- close
ほとんどのファイルアクセスはシーケンシャルなのでデフォルトの動作はシーケンシャルアクセスを想定している
ただし lseek
を使うことでランダムアクセスも可能になっている
Unix のファイルシステムは例えばファイルがディスク上でどのように表現されるかの複雑さを隠している
その他の素晴らしいインタフェースの例はガーベージコレクション
ガーベージコレクションはインタフェースが全く無い(パラメータを調整する方法などはあるだろうけど)
4.5 Shallow modules
浅いモジュールは提供される機能と比べて複雑なインタフェースを持つ
例えばリンクドリストは浅いモジュールの 1 つ
浅いモジュールは場合によっては不可避で有用だが、複雑さを減らすことにはあまり貢献しない
モジュールが浅い婆は抽象化のメリット(実装を知らなくて良い)よりもインタフェースを学ぶ時間や労力のデメリットが勝ってしまう
小さいクラスは浅くなりやすい
Classitis
深いクラスの価値は広くは認められていない
従来の知見ではクラスは小さくあるべきだと考えられている
プログラミング教本などでは大きなクラスを小さなクラスに分割すべきだと教えられる
また「N 行以上のメソッドは複数のメソッドに分けるべきだ」とも教えられる(N は10くらい)
結果としてたくさんの浅いクラスが作成されて複雑さが増大する
クラスは小さくあるべきだと考える極端な考えを著者は Classitis 症候群と呼んでいる
クラスが小さければクラス自体はシンプルになるが、システム全体では複雑さが増える
小さなクラスは冗長なプログラミングスタイルになりやすい、それぞれのクラスにボイラープレートが必要になるので
ボイラープレートについて
ボイラープレートという言葉を今まであまり考えずに使ってきたけど人に説明する時にはなんと話せば良いだろう
例えば class
キーワードはもちろんのこと、クラス名、コンストラクタ、ファイルもボイラープレートと見なせる
クラスを増やすのにかかるミニマムチャージのような固定的コストとも考えることができるのかも知れない
4.7 Examples: Java and Unix I/O
Java ではファイルを読み込むのに3つの異なるオブジェクトを作成する必要がある
FileInputStream fileStream = new FileInputStream(fileName);
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);
-
FileInputStream
には I/O バッファリングの機能はないのでBufferedInputStream
が必要になる -
ObjectInputStream
はシリアライズされたオブジェクトを読み込むために必要になる - 多くの場面ではバッファリングは必要なので
BufferedInputStream
を明示的に指定しなければならないのは煩雑だ - バッファリングが必要ないユースケースもあるのでそのような時のために選択肢があること自体は悪いことではない
- しかし、インタフェースは一般的なケースをできるだけシンプルに扱えるようになっているべきだ
- バッファリングをデフォルトで有効にした上で、バッファリングが必要ないユースケースに対応できるようにはバッファリングを無効にするインタフェースを設ければ良い
- Unix のシステムコールでは最も一般的なシーケンシャル I/O をデフォルトの動作にしている
-
lseek
システムコールを使うことでランダムアクセスも可能 - インタフェースは多機能だが開発者が知るべきことが少なければ、インタフェース全体の複雑さはよく使われるケースの複雑さとほぼ同じになる
4.8 Conclusion
- モジュールをインタフェースと実装を分離することで実装の複雑さを隠すことができる
- モジュールの利用者は抽象化されたインタフェースだけを理解すればよくなる
- モジュールを設計する上で最も重要なことは「深く」すること、つまり多機能でシンプルなインタフェースを備えること
- 一般的なケースのためのシンプルなインタフェースを設ける
- 一方で特殊なケースのために選択肢を提供する