Open18

【読書メモ】A Philosophy of Software Design

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

はじめに

A Philosophy of Software Design はスタンフォード大学の John K. Ousterhout 教授が書いた本

タイトルは日本語では「ソフトウェア設計の哲学」と訳すのだろうか

moga さんの下記の記事で知った

https://zenn.dev/moga/articles/using-technologies-2022

Amazon で Kindle 版が 1,152 円と安かったので昨年12月25日に購入した

主な内容はソフトウェアの複雑さを減らすにはどうすれば良いのかのテクニック集

気づいたら半分くらい読んでいたので忘れないようにまとめておこうと思う

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

1. Introduction

この本のゴールは2つ

  • ソフトウェアの複雑さについて解説すること
  • 複雑さを最小化するためのテクニックを示すこと
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

2.1 Complexity defined

この本では複雑さの定義は「ソフトウェアシステムの構造に関連するあらゆるもので、システムの理解や変更を困難にするもの」

システムの複雑さを C はシステムの構成パーツ p の複雑さを c_p、開発者が p に費やす時間の割合を t_p として次のように決定される

C = \sum_{p} c_p t_p

複雑なパーツであっても(うまくモジュール化されているなどの理由で)ほとんどの開発者が中身を知る必要が無くなれば、そのパーツの複雑さはほぼ 0 になる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

2.2 Symptoms of complexity

複雑さによる症状

  • Change amplification: 変更の増幅
  • Cognitive load: 認知の付加
  • Unknown unknowns: 不明の不明点

1点目はシンプルな変更であっても様々なコードを修正する必要に迫られること

2点目は目的を果たすために調べたり理解したりする時間が増えること

3点目は目的を果たすためにどこに手を付ければ良いのかが明白ではないこと

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

2.3 Causes of complexity

複雑さの原因は下記の2点

  • dependencies: 依存関係
  • obscurity: 不明瞭さ

依存関係

あるコード A を理解したり修正したりするのに他のコード B が必要になるとき、A は B に依存しているとこの本では考える

依存関係は完全に取り除くことはできない、例えばクラスや関数を定義することも依存関係を増やすことになるから

なるべく依存関係の数を減らし、残った依存関係を可能か限りシンプルかつ明白にできればそれで十分

不明瞭さ

重要な情報が明らかになっていないこと

例えばドキュメントが不十分なことが挙げられる、ただし説明するために多くのドキュメントが必要なことも危険信号

他にも何かを変更するためには A と B の両方を修正しなければいけないが、その事が明らかになっていない状況

3つの症状との関係性

依存関係は変更の増幅と認知の負荷を招く

不明瞭さは不明の不明点と認知の負荷を招く

依存関係と不明瞭さを改善できれば複雑さを減らすことができる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

2.4 Complexity is incremental

複雑さは徐々に増えていく

複雑さは大きな 1 つの判断ミスから引き起こされるのではなく、小さなたくさんの判断ミスの集合

今やっている変更で多少複雑になっても仕方がないと自分を説得するのは簡単

しかしそれらが積み重なって複雑さが対処しきれないくらいに大きくなっている

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

3.1 Tactical programming

戦術的プログラミング

何かを動くようにすることに重点を置いたプログラミングのアプローチ

例えば新機能追加やバグ修正

戦術的プログラミングの問題は生産性が高い時期を短期間しか維持できないこと

一度、戦術的プログラミングに陥ると途中で抜け出すのは難しい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

3.2 Strategic programming

戦略的プログラミング

コードが動くだけでは十分ではないと考えることから始まる

今の仕事を終わらせるのと引き換えにコードを複雑にすることを許容しない

戦略的プログラミングには投資家のマインドセットが必要

短期的には目標達成までの速度は遅くなるが、長期的には投資を回収できる(複雑さが原因でスローダウンすることを軽減できる)

積極的な投資としては例えば新しいクラスを設計するときによりシンプルにする方法を探す

受動的な投資としては設計判断ミスが原因で問題に直面した時に無視したりその場しのぎしたりするのではなく修正する時間を設ける

継続的に小さな改善をシステム設計に加えていくこと、この点が戦術的プログラミングとは対照的

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

3.3 How much to invest?

設計改善にどれくらいの時間を使えば良いか

開発時間の1〜2割を使って小さな改善を継続的に多数行うのが一番のアプローチ

戦術的プログラミングの方が開始時が進捗が早いが次第に進捗が遅くなっていき、一定時間が経過すると戦略的プログラミングの方が進捗が早くなる

戦術的プログラミングが原因で技術的負債が引き起こされる

技術的負債は未来から時間を借りている、お金の負債と同様に技術的負債には利息がつく

お金の負債と異なるのは多くの技術的負債は完済することができないこと、利息は払い続けることになる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

3.4 Startups and investment

スタートアップと投資

戦略的アプローチが困難な環境もある

スタートアップでは1〜2割の時間を使うことすら難しいかもしれない

結果として戦術的アプローチを取らざるを得ない

後から人を雇ってキレイにすれば良いと考えてしまうかもしれない

しかし一度複雑になったコードを修正するのは不可能に近い

すごいエンジニアを雇用できれば可能性はある

しかしすごいエンジニアは良いデザインに深く注意を払う傾向がある

従ってコードベースが汚ければ、採用することは困難になる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

4.1 Modular design

ソフトウェアシステムを比較的独立したモジュールの集合に分解する

モジュールの一例は関数、クラス、サブシステム、サービスなど

理想的にはモジュールは他と完全に独立する

開発者はモジュールに変更を加える時に他のモジュールの関する知識を必要としなくて済む

しかし、現実にはモジュールは他のモジュールとやり取りする必要がある

結果としてモジュール間の依存関係が生じる

メソッド 1 つをとっても引数が変われば呼び出すコードに影響がある

他にもあるメソッドを呼び出す前に他のメソッドを呼び出す必要があるなどコードで表現できない依存関係もある

依存関係を管理するにはモジュールをインタフェースと実装の 2 に分ける

インタフェースにはモジュールを使う人が知らなければならないことをまとめる

インタフェースは「何をするか」、実装は「どのようにするか」

実装はインタフェースがした約束を実現する

モジュールを使う人は実装を知らなくても良いようにする

この本ではモジュールとはインタフェースと実装を持つすべてのものという意味で扱う

インタフェースが実装よりもはるかにシンプルであれば良いモジュール

シンプルなインタフェースは複雑さを最小化する

また、実装に変更を加えてもインタフェースに変わらなければ他のモジュールは影響されなくて済む

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

4.2 What's in an interface?

インタフェースの中身は何?

インタフェースはフォーマルとインフォーマルの 2 つの情報から構成される

フォーマル情報はプログラミング言語で明示的に指定することができる

例えばメソッドの名前や戻り値、引数の情報はフォーマル情報

一方、インフォーマル情報はプログラミング言語で指定できないもの

例えば「この関数を実行すると引数で指定された名前のファイルが削除される」などの機能

他の例は「この関数を呼び出す前に初期化関数を呼び出す必要がある」などの前提条件

インフォーマル情報はコメントとして表現される

多くのインタフェースではインフォーマル情報の方が多くて複雑

良いインタフェースの利点はモジュール利用者が知る必要のあることを示唆できる点

これにより不明な不明点(unknown unknowns)を削減できる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

4.3 Abstractions

抽象化(捨象)はモジュラー設計に強く関係している

抽象化はエンティティ(クラスやメソッド)の単純化された見え方

エンティティを理解するのに重要ではない詳細を省略している

抽象化は複雑な事がらを扱うのを簡単にしてくれる

モジュールはインタフェースを通じて実装の詳細を抽象している

抽象化する時には何が重要ではないか (unimportant) を判断するのが大切

不必要な詳細をインタフェースに含めてしまうと抽象化のメリットが発揮されない

反対に省略し過ぎて重要な詳細まで隠してしまわないように気を付ける

良い抽象化の例はファイルシステム、ユーザーはストレージデバイスのブロックなどの詳細を意識する必要がない

ただしデータベースのようなアプリケーションはファイルシステムに隠された詳細を必要する

よく設計されたファイルシステムは隠された詳細にもアクセスできる手段を提供している

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

4.4 Deep modules

良いモジュールは強力な機能を提供する一方でシンプルなインタフェースを持つ

このようなモジュールをこの本では「深い」(deep) と形容している

モジュールの深さは費用対効果を考える方法の 1 つ

効果は機能、費用はインタフェース

モジュールのインタフェースがシンプルではない場合、モジュールを利用する開発者にとっての複雑さとなり得る

素晴らしいインタフェースの例の Unix のファイルシステム

インタフェースは下記の 5 つの関数だけ

  • open
  • read
  • write
  • lseek
  • close

ほとんどのファイルアクセスはシーケンシャルなのでデフォルトの動作はシーケンシャルアクセスを想定している

ただし lseek を使うことでランダムアクセスも可能になっている

Unix のファイルシステムは例えばファイルがディスク上でどのように表現されるかの複雑さを隠している

その他の素晴らしいインタフェースの例はガーベージコレクション

ガーベージコレクションはインタフェースが全く無い(パラメータを調整する方法などはあるだろうけど)

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

4.5 Shallow modules

浅いモジュールは提供される機能と比べて複雑なインタフェースを持つ

例えばリンクドリストは浅いモジュールの 1 つ

浅いモジュールは場合によっては不可避で有用だが、複雑さを減らすことにはあまり貢献しない

モジュールが浅い婆は抽象化のメリット(実装を知らなくて良い)よりもインタフェースを学ぶ時間や労力のデメリットが勝ってしまう

小さいクラスは浅くなりやすい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Classitis

深いクラスの価値は広くは認められていない

従来の知見ではクラスは小さくあるべきだと考えられている

プログラミング教本などでは大きなクラスを小さなクラスに分割すべきだと教えられる

また「N 行以上のメソッドは複数のメソッドに分けるべきだ」とも教えられる(N は10くらい)

結果としてたくさんの浅いクラスが作成されて複雑さが増大する

クラスは小さくあるべきだと考える極端な考えを著者は Classitis 症候群と呼んでいる

クラスが小さければクラス自体はシンプルになるが、システム全体では複雑さが増える

小さなクラスは冗長なプログラミングスタイルになりやすい、それぞれのクラスにボイラープレートが必要になるので

ボイラープレートについて

ボイラープレートという言葉を今まであまり考えずに使ってきたけど人に説明する時にはなんと話せば良いだろう

例えば class キーワードはもちろんのこと、クラス名、コンストラクタ、ファイルもボイラープレートと見なせる

クラスを増やすのにかかるミニマムチャージのような固定的コストとも考えることができるのかも知れない

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

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 システムコールを使うことでランダムアクセスも可能
  • インタフェースは多機能だが開発者が知るべきことが少なければ、インタフェース全体の複雑さはよく使われるケースの複雑さとほぼ同じになる
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

4.8 Conclusion

  • モジュールをインタフェースと実装を分離することで実装の複雑さを隠すことができる
  • モジュールの利用者は抽象化されたインタフェースだけを理解すればよくなる
  • モジュールを設計する上で最も重要なことは「深く」すること、つまり多機能でシンプルなインタフェースを備えること
  • 一般的なケースのためのシンプルなインタフェースを設ける
  • 一方で特殊なケースのために選択肢を提供する