A Philosophy of Software Design, 2nd Edition

https://web.stanford.edu/~ouster/cgi-bin/book.php

概要

感想

詳細メモ

Preface

開発をより良くするためにアジャイルなどの開発プロセスやデバッガなどのツールについては議論され続けているが、ソフトウェア設計については十分に議論されていない。

コンピュータサイエンスにおいて最も基本的な問題はどのように問題を分割するかである。 課題分割はプログラマが日々取り組んでいることであるが、このようなトピックを扱っている大学を見たことはない。 我々が教えるのは for ループやオブジェクト指向プログラミングであり、ソフトウェア設計ではない。 またプログラマーの生産性や能力には大きなばらつきがあるが、最高のプログラマは何が優れているのかを理解したりする試みもない。 多くの人々は開発能力は才能であり教えられるものではないと考えているが、Talent is Overrated, Geoff Colvin を始め、多くの分野で卓越した成果を残すためには才能よりも質の高い練習が関係しているという科学的な証拠は数多くある。

著者は長年ソフトウェア設計を教えられないか考えていたが、スタンフォード CS190 としてソフトウェア設計を教えることに決めた。 この本は CS190 で教えている内容をまとめたものである。

1: Introduction

ソフトウェアを書く上での最大の限界は自分たちが作っているシステムを理解する能力である。 より多くの機能が追加されていくことは、複雑さが増加していくことを意味する。 プログラマがシステムを修正する際にすべてを頭に入れておくことは難しくなっていく。 これは開発を遅らせ、バグを発生させ、さらに開発を遅らせ、コストを増加させる。 プログラムの規模やそれに携わる人の増加に伴って、複雑さを管理するのは困難になっていく。

どんなにいいツールを導入しようと限界がある。 もしもっと簡単にソフトウェアを変更したいのであれば、ソフトウェアをシンプルにすべきである。 シンプルな設計であれば、機能の追加に伴う複雑さに立ち向かうことができる。

この本は複雑さに対して 2 つのアプローチを取る。 1 つは複雑さを取り除き、コードをシンプルで明瞭なものに変更する手段。 例えば特殊なケースの実装を取り除いたり、マジックナンバーを定数化してそれを利用するように書き換えたりする事が考えられる。 もう 1 つは複雑さをカプセル化し、一度に全ての複雑さと向き合う必要がないようにする方法。 この例は、オブジェクト指向においてはクラス(本書では module という表現に相当)に分割することが考えられる。 クラスは独立性高く設計することが可能であり、そのような場合は他のモジュールを意識しなくても開発することができる。

ソフトウェアは非常に変更の影響を受けやすいものであり、したがってソフトウェア設計はそのシステムの稼働終了まで行われ続けなければならない。 にも関わらず、設計に関心が向けられるのはプロジェクトの立ち上げ時のみであり、それ以降設計に関心が向けられることはない。 このような工程はウォーターフォールであり、複数のフェーズでそれぞれの担当者が設計フェーズで決められた設計通りに開発する。

ウォーターフォールがうまくいくことは稀である。 ソフトウェアは物理的なシステムと比べて本質的に複雑であり、ソフトウェアの全貌を理解することは不可能である。 全貌を理解できないにも関わらず最初に設計を固め、その通りに実装を進める。 その結果、実装時に不都合が見つかるがその頃には全体設計を変更できるだけの余裕はなく、見える範囲において問題を回避するためのパッチを当てようとする。 結果として複雑さは爆発する。

ウォーターフォールにはこのような課題があるため近年ではアジャイルのようなインクリメンタルなアプローチが用いられる。 アジャイルは小さく設計・実装・評価を繰り返すことで描いていた設計の問題点を早めに検出できる。 つまり、ソフトウェア設計における意思決定の回数を増やすことでより良い選択を目指すアプローチである。

インクリメンタル・アプローチがソフトウェアに有効なのは、ソフトウェアが実装の途中でも大幅な設計変更が可能なほど柔軟だからである。 物理的なシステムにはこのような柔軟性はない。 インクリメンタルな開発とは常に設計の問題を考え続ける必要があり、それに終わりはないことを意味する。 また、継続的な再設計も行うべきである。 ソフトウェア開発者であるならば、常に作業中のシステムの設計を改善する機会を探し求めているべきであり、設計の改善に時間の何割かを費やすことを計画すべきである。 ソフトウェア開発者は常に設計の問題を考えるべきであり、複雑さを軽減することがソフトウェア設計の最も重要な要素であるならば、ソフトウェア開発者は常に複雑さについて考えるべきである。

本書の目標は 2 つある。 1 つはソフトウェア設計における複雑さの本質について説明することである。 複雑さとは何か、なぜそれが重要なのか、プログラムにおける不要な複雑さをどのように見分ければ良いのか。 2 つ目の目標は、複雑さを最小限に抑えるためにソフトウェア開発プロセスで使えるテクニックを提示することである。 優れたソフトウェア設計であることを保証するような手段ではなく、「クラスは深くあるべき」「エラーを存在しないように定義する」といった哲学的な高度なコンセプトを示す。 これらのコンセプトを知ることで、設計の選択肢を比較し、設計空間の探索を導くために使用することができる。

1.1 How to use this book

設計能力を高める方法のうち最もいいものの 1 つは、red flag(コードの一部が必要以上に複雑である可能性を示す兆候)を見抜くために学ぶことである。

本書の考えを適用する際には節度と思慮深さが重要である。 どんなルールにも例外があり、どんな原則にも限界がある。 どのようなアイデアもそれを極限まで追求すると、おそらく悪い方向に進んでしまう。 美しいデザインとは、相反するアイデアやアプローチのバランスを反映したものである。 いくつかの章には Taking it too far(やり過ぎ)という絵sクションがあり、いいことをやりすぎているときにそれを見分ける方法を説明している。

2: The Nature of Complexity

この章では複雑さという概念を抽象的に取り扱う。 以降の章ではより具体的なレベルで複雑さを認識する方法を紹介する。 複雑さを認識する能力は極めて重要な設計スキルである。 このスキルがあれば多くの労力を費やす前に問題を特定でき、多くの候補の検討と適切な選択が可能になる。 また、この章では残りの章の基礎となる仮定をいくつか示す。

2.1 Complexity defined

まずは複雑さの定義から始める。 複雑さとは、システムの理解や変更を困難にするような、ソフトウェアの構造に関連するあらゆるものを指す。 複雑さには以下のように様々な形がある。

  • コードの一部がどのように動作するかを理解するのが難しい

  • ちょっとした改良を実装するのに多大な労力を要する

  • 改良のためにシステムのどの部分を修正しなければならないかが明確でない

  • 別のバグをを発生させずにソフトウェアの修正するのが難しい

ソフトウェアシステムを理解し修正するのが難しいのならそれは複雑であり、理解し修正するのが簡単ならそれは単純である。

複雑さとは、特定の目標を達成しようとするときに開発者が特定の時点で経験するものであり、システムの全体的な規模や機能性とは必ずしも関係はない。 大規模なシステムを「複雑」と表現するが、仮にそれが作業しやすいのであれば、本書の定義の「複雑」には当てはまらない。

システムに非常に複雑な部分がいくつかあっても、その部分に触れる必要が殆どない場合、その部分はシステム全体の複雑性にあまり影響を与えない。 これを雑に数式として定義したものが以下である。

C=pcptpC=\sum_{p}c_p t_p

システム全体の複雑さ $C$ は各箇所 $p$ における複雑さ $c_p$ にその箇所で作業する時間 $t_p$ で重みづけしたものとして定義する。 ほぼ作業しない、つまり $t_p$ が限りなく小さい箇所における $c_p$ が極端に大きかったとしてもシステム全体に与える複雑さはほぼ影響がないことがわかる。

複雑さは書き手よりも読み手のときの方がわかりやすい。 あなたの書いたコードがあなたにとっては単純に見えても、他の人が複雑だと思えばそれは複雑なのである。 このようなときは他の開発者にとってなぜ複雑に見えるのかを探ってみる価値がある。 開発者としてのあなたの仕事は、自分が作業しやすいコードを作ることだけではなく、他の人も作業しやすいコードを作ることなのである。

2.2 Symptoms of complexity

複雑さは、以下に説明する 3 つの形式で現れる。

  1. 変更量の増加(Change amplification) 複雑さの最初の兆候は、一見単純に見える変更だとしても、多くの異なる場所でコードの修正が必要になることである。 例えばいくつかのページがあり、それぞれに背景色のバナーが表示されるような Web サイトを考える。 各ページで色を明示的に指定していた場合は、それら全てを修正しなければならない。

  2. 認知負荷(Cognitive load) これはタスクを完了するために開発者がどれだけの知識を必要とするかを指す。 認知的負荷が高いということは、開発者が必要な情報を習得するのに多くの時間を費やさなければならないことを示し、重要なことを見逃してバグが発生するリスクが高くなる。 例えば C 言語の関数がメモリを確保し、そのメモリへのポインタを返し、呼び出し元がそのメモリを解放すると仮定する。 もしメモリ解放に失敗すればメモリリークが発生する。 リファクタリングによってメモリを割り当てたモジュールがメモリ解放にも責任を持つようすれば、呼び出し元はメモリの利用状況に注意する必要がなくなり、認知的負荷が下がる。 認知的負荷は多くのメソッドをもつ API、グローバル変数、矛盾、モジュール間の依存関係など、様々な形で発生する。 認知負荷はコード行数とは関連しない。 極端な例として難読化された 1 行とほぼ注意を要さずに理解可能な 50 行のコードを比べると後者のほうがシンプルである。

  3. 未知の未知(Unknown unknowns) これはタスクを完了するために度のコードを修正しなければならないか、あるいはタスクを成功するために開発者がどのような情報を持っていなければならないかが明らかでないことである。 例えば膨大なページで公開しているバナーの背景色が以下のように定義されているとする。 背景色を変更したい場合は中央管理されている String colorHex の値を変更することだけを考えるが、実際には ColoredBanner2 で定義されている強調色も変更された色に合わせて修正しなければならない。

    public class ColoredBanner1 {
      private final String bannerBackgroundHex;
      
      public Banner1(String colorHex) {
        this.bannerBackgroundHex = colorHex;
      }
    }
    
    public class ColoredBanner2 {
      private final String bannerBackgroundHex;
      private final String emphasizedColor;
      
      public Banner2(String colorHex) {
        this.bannerBackgroundHex = colorHex;
        this.emphasizedColor = "#096148";
      }
    }

    開発者はこの事実に気づきにくく、強調色の更新を忘れたままリリースしてしまうかもしれない。 仮に気づいていたとしても、どのページが強調色を利用しているかは明らかではないので、全てのページの検索が必要になるかもしれない。

上述した 3 つのうち Unknown Unknowns は最悪のものである。 Unknown Unknowns とは、知るべきことがあっても、それが何であるか、あるいは問題があるかどうかさえ知る方法がないことを意味する。 変更を加えたあとにバグが現れるまで、それに気づくことができない。 変更量の増加についても、変更範囲が広いことは不快ではあるが、どこを修正すべきかがきちんとわかっていれば、修正後に期待通りに動作する可能性は高くなる。 認知負荷についても同様で、どの情報を読み取るべきかが明らかであれば、その変更は依然として正しい可能性が高くなる。 Unknown unknowns に向き合う唯一の手段はシステム内の全てのコードを読み解くことだが、これはどのような規模のシステムであろうと不可能である。 また、採用すべき変更は文章化されていない微妙な設計上の決定に依存する可能性があるため、これでも十分ではない場合がある。

優れた設計の最も重要な目標の 1 つはシステムが明白であることである。 明白なシステムでは、開発者は既存コードがどのように機能するのか、変更を加えるには何が必要なのかすぐに理解できる。 明白なシステムとは、開発者が深く考えずに何をすべきか簡単に推測でき、しかもその推測が正しいと革新できるシステムである。 第 18 章ではコードをよりわかりやすくするためのテクニックについて説明する。

2.3 Cause of complexity

2.2 までで複雑さの大まかな症状がと複雑さがソフトウェア開発を困難にする理由がわかったので、次のステップとして問題を回避するシステムを設計できるように、複雑さの原因を理解できるようにする必要がある。 複雑さは依存関係と曖昧さという 2 つの要因によって引き起こされる。 この節ではこれらの要素について概要を示し、後続の章でそれらが下位レベルの設計における意思決定にどのように関連するか説明する。

まずは依存関係について。 この本はそもそも、特定のコード単体では理解不可能な依存関係が存在すると仮定し、それを上手に取り扱うためのものである。 依存関係はソフトウェアの基本的な部分であり、完全に排除することはできない。 実際、我々は日々意図的に依存関係を導入している。 新しいクラスを作成するたびにその API の周りに依存関係が作成される。 ただしソフトウェア設計の目標の 1 つは、依存関係の数を減らし、残る依存関係をできるだけシンプルにすることである。

次に曖昧さについて。 曖昧さは重要な情報が明らかでない場合に発生する。 簡単な例を出すと、あまりにも一般的すぎて何を表しているのかの有用な情報がほぼない命名(e.g. time)が挙げられる。 曖昧さは依存関係と関連付けられることも多く、ある依存が存在するかが明らかでない場合にも発生する。 例えば新たなエラーステータスを追加した場合、それに対応するエラーメッセージを DB のテーブルに追加する必要があるときに、エラーステータスを追加した開発者にとってメッセージテーブルの存在が明らかでない場合が考えられる。 また一貫性のなさも曖昧さに寄与する。 例えば同一の変数を複数の目的で使い回していたりすると、ある変数があったときに、それがどちらの目的に関連するものなのかが明確ではなくなる。

多くの場合、曖昧さはドキュメンテーションが不十分であることに起因する。 第 13 章ではこのトピックを扱う。 ただし、わかりにくいことは設計上の問題でもあり、システムの設計が明確で明白であれば、必要なドキュメントは少なくなる。 広範なドキュメントが必要になるということは、多くの場合、設計が完全に正しくないという危険信号である。 曖昧さを軽減する最善の方法は、ソフトウェア設計をシンプルにすることである。

依存関係と曖昧さは 2.2 で説明されている複雑さに起因する 3 つの症状の原因となる。 依存関係は変更量の増加と高い認知負荷に繋がる。 曖昧さは Unknown unknowns を生み出し、認知負荷にも寄与する。 したがって、依存関係と曖昧さを最小限に抑える設計手法を見つけることができれば、ソフトウェアの複雑さは軽減できる。

2.4 Complexity is incremental

複雑さは単一の間違いによって引き起こされるのではなく、たくさんの小さな間違いによって蓄積される。 単一の依存関係または曖昧さ自体がソフトウェアシステムの保守性に大きな影響を与える可能性は殆どない。 複雑さは数百、数千規模の小さな依存関係や曖昧な要素が時間の経過と共に蓄積されるために発生する。 最終的にはこれらの小さな問題が非常に多く存在するため、システムに対するあらゆる変更がそれらのいくつかの要素を受けることになる。

複雑さは増加する性質を持っており、コントロールするのは難しい。 現在の変更によって多少の複雑さが生じたとしても大したことはないと自分自身を納得させるのは簡単である。 しかしこのアプローチを全ての開発者が変更の度に採用すると、複雑さが急速に増大する。 複雑さが蓄積すると単一の依存関係や曖昧さを修正するだけでは大きな違いが生じないため、それらを取り除ききるの極めて困難である。 複雑さの増加を遅らせるためには第 3 章で説明するゼロトレランスの哲学を採用する必要がある。

2.5 Conclusion

複雑さは依存関係と曖昧さの蓄積から生じる。 複雑さが増すと、変更量の増加、高い認知負荷、Unknown Unknowns が生じる。 その結果、各新機能を実装するにはより多くの変更が必要になる。 また開発者は変更を安全に行うために十分な情報を取得するのにより多くの時間を費やし、最悪の場合、必要な情報を全て見つけることができないこともある。 肝心なのは、複雑さによって既存のコードベースを変更するのが難しくなり、危険が伴うことである。

3: Working Code Isn't Enough (Strategic vs. Tactical Programming)

Last updated

Was this helpful?