概要
内容と動機
これは、「A Philosophy of Software Design」の要約になります。
本書の内容としては、筆者が経験ベースで、ソフトウェアの複雑性の原因とどれにどう向き合うのかをまとめた内容です。
普段チームで開発する中でわかりづらさや、設計に関する違和感を感じることがあります。
その自分の違和感が本当に正しいものなのか、仮にそうだとしたらきちんと言語化してレビューしたいという動機から本書を読むことにしました。
170ページの本書を完璧に要約することは困難なので、幾分自分にとって印象深かったことが選ばれていると思います。
逆に、その過程で触れられなかった章もありますので、その点ご留意ください。
また、ここでは具体例は少ないですが、ある程度大きなコードベースで開発した方であれば想像しながら読めるのではと思っています。
もし、具体例を参照したい場合は、ぜひ本書を読んでください。
要約
本書を簡単に要約すると、以下のようになります。
Complexityは認知や変更を困難にする設計上のあらゆるものを意味します。
それは、dependency, obscurityの二つが原因です。
それらを解消するような疎結合で明快な設計、実装を実現するには三つが重要です。
一つ目に、そのように常に実装を最良の設計にメンテナンスしていくプログラミングへの姿勢があります。これは、将来の開発速度や拡張性への投資になります。
二つ目に、モジュールをシンプルで抽象化されたインターフェースと多くの機能性を持ったものにすることです。これは、インターフェースがシンプルなので認知負荷が低く、他のモジュールは変わりにくいインターフェースに依存できるので、疎結合な関係を実現できます。
三つ目に、実装では表現できない意図や値の意味などの背景をコメントで表現することです。これは、実装の読み込みや誤解によるバグの修正など開発時間を短くするための将来への投資になります。
所感
疎結合なモジュールにするべきという話はどこでも聞きますが、自分が感覚的に意識していたことを言語化してくれた点が多くそこは頷きながら読んでいました。
内容的には、抽象に依存して(dependency inversion principle)、モジュールやインターフェースの責務は単一にすれば(Single Responsibility Principle、Interface Segregation Principle)、拡張に対して開いていて修正に対して閉じている(Open-Closed Principle)設計に繋がるというSOLIDの原則の考え方がベースになっていました。(SOLIDの原則の話は出てきません)
しかし、ただ良い設計を説明するだけで、それが開発で取り入れられなければ絵に描いた餅であり、本書はそれを実践するための開発への取り組み方にも言及していた点が新しかったように感じました。
また、コメントは書かなくて済むような実装が望ましいという「神話」を信じていたので考え方を改めるきっかけにもなりました。
これ以降ではそれぞれのトピックごとの要約になります。
Complexity
Complexityとは?
Complexityとは認識や変更を困難にするソフトウェアの構造に関する全てのものを指します。また、変更の手間がその効果に割りが合わないものでもあります。そのため、どれだけ実装が「複雑」でも開発者が読み書きする必要がないようになっていれば、それはComplexityとは言いません。
(後述されるように、複雑な実装もシンプルなインターフェースで隠蔽化されていればComplexityとは言わない)
Complexityの症状
Complexityは一般的には以下の三つの現象で明らかになります。
1 . Change amplification
簡単なコードの変更のためには、複数箇所の他のコードの変更が必要になることで、Complexityは表出します。つまり、ある設計に関する意思決定が影響を受けるコード量が多い状態です。
2 . Cognitive load
二つ目に、Cognitive load があります。
これは、実装を理解するために時間を要する状態です。
コードが短ければ短いほど、簡潔だと思うかもしれないが、必ずしもそうではありません。
それが何をやっているのか理解するのが困難な場合は、より多くの行数を要するアプローチの方がよりシンプル(Complexityが少ない)になります。なぜなら、Cognitve load を減少させるからです。
3 . Unknown unknowns
Unknown unknowns は開発のためにどの実装を変更させるべきか、どのような情報を知らないといけないのかが分からないという状態を示します。
確かめるためには、システム全体の実装を読まないといけなくなるが、現実的にそれはできないことが多いです。結局、バグが起ってから初めて分かっていなかったことに気づくことになります。
Complexityの原因
Complexityには二つ原因があります。dependencyとobscurityです。
いくつもの小さなそれらが積み重なって大きなComplexityを生み出します。
その頃には、dependecyやobscurityを排除することは難しくなります。
1 . dependencies
dependencyは、コードが他のコードに関連しており、それ単体では理解したり変更することができない状態を意味します。
その場合、コードが変更されると他のコードも変更されないといけなくなります。
例えば、メソッドの引数の値や順番、返り値、名前などは、呼び出し側とそのメソッドの実装側とでdependencyを生み出します。これは、change amplification と cognitive load をもたらします。
dependencyは、基本的なソフトウェアの一部であり、完全に排除することはできるものではないです。
しかし、ソフトウェアデザインの一つの目標はdependencyの数を減らし、それらを出来るだけシンプルで明快にすることです。
2 . obscurity
重要な情報が明らかでない場合にobscurityが発生します。その場合は、実装を読み込んで理解するしか方法がありません。
これは、unknown unknowns と cognitive load をもたらします。
また、obscurityは分かりにくいdependencyや名前の一貫性のなさ、不十分なドキュメント化なども含みます。逆に、多すぎるドキュメントでの補完はobscurityの兆候でもあります。
プログラミングへの取り組み方
二つのアプローチ
tactical approach から、strategic approach に変える必要があります。
なぜならComplexityは少しづつ累積し、大きくなると解消できなくなるので、普段の開発で設計の妥協をすべきではないからです。
tactical approach
tactical approach とは、出来るだけ早く目の前の機能を実装することにフォーカスした開発を意味します。これは、近視眼的で最善の設計を見つけることに時間を使わない取り組み方です。つまり出来るだけ機能を早く、少ない実装で終わらせるために多少のComplextiyをもたらすことを厭いません。
しかし、Complexityは累積的で、tactircal approach を皆が実践することで大きくなっていきます。
tactical approach をとる開発者は、機能を早く作ることで持て囃される場合もありますが、破滅が待っています。結局他の開発者が改修しながら、実装しないといけなくなるので、彼らの進捗は遅くなります。
strategic approach
一方で、strategic approach とは将来の開発者のために、設計を改善しながら機能をする開発を意味します。
strategic approach をとる開発者は動くコードだけでは不十分だと言うことを認識しています。
むしろ最も重要なことは、設計をよくすることでありかつ動くコードです。
これの背景にある考え方としては、優れた設計が幾分現在の開発を遅らせても、将来彼らのチームの開発が容易になるだろうと言う投資的な発想です。
そのような開発者は、適切なコメントを書いたり、いくつかの実装方針のPro/Conを比較して最善のものを選びます。
例えば、GUIでファイルのテキストを編集するエディタのためのクラスを考えると、その抽象インターフェースは三つほど方針が考えられます。
- 行志向のインターフェース
- 個々の文字を受け取るインターフェース
- 文字列志向で、任意の長さの文字で扱うインターフェース
1は、切り取りや貼り付けの際に、行の一部や複数の行を結合したり分割する必要が生じます。
2は、文字列を操作するためには、ループで一つづつ操作をする実装が必要になります。
このように1, 2の場合は高次のレイヤーのモジュールでも具体的な文字列操作の実装が必要になります。
そのため、3のように範囲志向のテキスト操作のAPIが好ましいと考えられます。
このように、最初に思いついた設計で良しとする事なく、第二の可能性も考えて開発に取り組みます。
本書ではこれを design-it-twice と表現しています。
既存の実装の変更
既存のコードに対する変更にも strategic approach の考え方が適用されます。
「実装したいことを実現できる最小の変更は何か」という典型的なマインドセットがあります。
開発者は既存のコードを大きく変更することはバグをもたらすので、これを正当化します。
しかし、それは tactical approach です。
理想は、最初からその変更が念頭にあったとしたら、その構造になっていた状態にまで変更されることです。
もちろん、商業的な開発では期限がありますが、出来るだけこれらの妥協に抗ってより良い設計にしていく必要があります。
そのためには、「現在の制約があるとして、設計をよくするために私が最善を尽くせることは何か?」を自分自身に問いましょう。
投資の意義
strategic approach は投資的な開発への取り組み方ですが、どれだけ投資すれば割にあうでしょうか?
大体開発時間の10~20%を設計をよりよくすることに使うのが良いです。
それは、スケジュールをかなり遅らせるほどではなく、時間をかけてメリットを享受するには十分な時間です。
確かに初期はその時間だけ tactical approach よりも時間がかかりますが、次第にそれのおかげで開発時間が短くなります。
定量的なデータはありませんが、筆者の経験からすると以下のようになります。
また、一旦スパゲッティコードになるとそれを直すのがほぼ不可能になり、開発コストがそれ以降増大します。
そのため、継続的に strategic approach をとる必要があります。
会社の成功の観点からだと、質の高いエンジニアを雇うことが最も重要です。
彼らは高い生産性を持ち、良い設計について注意深いためです。
所感
自分がそうだった場合もありますし、実際にレビューしていると、出来るだけ少ない差分で済ませたいからそう実装したという方針が見られることがあります。
例えば、実装量を減らすためにフロントエンドで計算ロジックを書いて画面にそのまま値を表示するような実装を見たことがあります。
これは、その画面だけでしか計算されないものであればいいものの、複数の画面でその値を表示する必要がある時は、バックエンドで計算した値をAPI経由で返して表示した方が、画面の変更に柔軟に対応できます。
しかし、そのためにはフロントエンドにもバックエンドにも修正を加える必要が生じます。
どちらで実装すべきかPro/Conを考えるよう癖をつけるべきですし、レビューではその意思決定を積極的に聞こうと再認識しました。
また、仮にstrategic approachが現実的に無理でも、コメントを残すなりして、他の人がComplexityについて気づけるようにするくらいはできるのではと思いました。
moduleの設計
deep module
Complexityを減らすには、moduleの設計が重要になります。
moduleとは、インターフェースと実装をもつあらゆるコードのまとまりを指します。
インターフェースとは、他のmoduleが呼び出すために知らなければならないものであり、実装はインターフェースで表明した内容を実行するコードです。
一般的にはmoduleが提供する機能が多くなるとインターフェースがComplexityを露呈するようになります。
一方で、最良のmoduleはdeep moduleです。
これは、多様な機能を提供するがインターフェースがシンプルなmoduleを指します。
シンプルなインターフェースは以下の利点があります。
- インターフェースは他のmoduleが呼び出すために利用するものであるので、シンプルであればdependencyを下げることができる
- 要点が簡潔に表現されているので、obscurityも減らすことができる
(p23のFigure 4.1から抜粋)
では、シンプルなインターフェースとは何でもたらされるかというと、それは優れた抽象によってです。
抽象とは、重要でない詳細を省いた実体への簡潔な観点です。
抽象をするためには、何が重要か理解し、重要な情報の量を減らす設計を探すことです。
例えば、車はモーターやバッテリーの管理、ロックなどの仕組みを理解せずとも運転できる抽象を提供しています。
information hiding
抽象の過程では重要でない詳細を省くことが必要になりますが、そのための手段としてinformation hidingがあります。
information hidingとは、実装に関する知識をインターフェースに現れないようにし、他のmoduleから意識しなくて済むようにする手法です。
隠されるべき実装に関する知識は、実装するための方法(how)に関する詳細がよくあります。
例えば、どのようにストレージにアクセスするか、どのようにネットワークプロトコルを実装するか、どのようにJSONをパースするかなどです。
他には特定のメカニズムに関するデータ構造やアルゴリズムなどです。
反対の状態として、information leackageがあります。
これは、module間のdependencyを作ります。つまり、一つのmoduleに関する設計の変更が、利用する他の全てのmoduleへ変更が及びます。
ソフトウェア設計者としての最も重要なスキルの一つは、information leackageへの高い感度です。
もしinformation leackageに直面した時は、以下の観点で考えるべきです。
- これらのmoduleをどのように認識することができるか?
- そのための特定の知識はこのmoduleにだけ影響があるか?
このように考えて影響範囲が比較的近い場合は一つのmoduleにしてしまうのが理にかなっています。
また、そのような知識がインターフェースから漏れないように設計すべきです。
information leackageのよくある原因の一つにtemporal decomposition(時間的な分割)があります。
これは、それぞれの操作の手順を別のmoduleに分割することで、呼び出し元で正しい順番で利用する必要が生じます。
したがって、目的のための手段の実現方法に関する知識がmodule間で共有されることになります。
その場合は、それらの手段をまとめた一つのmoduleにすることで一つのインターフェースの中に手段の実現方法を隠すことができます。
このように、information hidingはしばしば少しmoduleを大きくすることで改善されることがあります。
一般的な目的のインターフェースにする
また抽象化したインターフェースを実現する上で、やや一般的な(general purpose)インターフェースにすることも重要です。
つまりmodulesの機能は現在の必要性を反映しつつ、インターフェースはそうではなく複数の利用に対応できるだけ一般的にするということです。
「やや」としたのは、完全に一般的にしすぎると現在の必要性に答えられなくなる場合があるからです。
そうすることで、general purposeの方がspecial purposeよりシンプルで深いインターフェースを実現でき、再利用可能性が高まるので将来の開発時間を下げることができます。
仮に現在の要望でしか利用されなくても、シンプルさという点で利点があります。
general purpose と special purpose のインターフェースのための正しいバランスを見つけるために以下の観点があります。
- 現在の全ての要望包括する最もシンプルなインターフェースは何か?
- どれほど多くの状況下でそれが使われるか?
- 現在の要望のためにそのインターフェースは簡単に使えるか?
moduleの分割と統合
次に、どのような場合にmoduleを分割するべきで、統合するべきかが問題になってきます。
どちらにせよ目的は全体のComplexityを下げることです。
分割のし過ぎは、複数のコンポーネントを辿るのが困難になり、それらを協働させるために追加の実装が必要になります。
これは、cognitive loadとunknown unknownsをもたらします。
そのため、近く関連する場合はコードをひとまとめにすることが最も恩恵があります。
そして、それぞれが独立している場合は分けるべきです。
コードが関連している兆候には以下のいくつかがあります。
- 情報を共有している
- 例えば、HTTPリクエストを文字列に読み込んで、パースする場合は、共にHTTPリクエストの構成への知識を共有しています
- 双方向に利用しあう
- 一方はそれにだけ依存するが、もう一方は他の複数からも依存される場合は分ける
- 概念的に重なり合う
- 高次のカテゴリにそれらが包含される場合はまとめる
- 互いに行き来しないとそれぞれが理解しづらい
逆に一般的な目的のコードと、専門的な目的のコードは別のmoduleで分けるべきです。
そうすることで、information leackageや専用のインターフェースの追加を避けることができます。
所感
適切な抽象をすることが重要であると、よく聞きますが実際にやるのはかなりスキルが要求されると思います。
特に高いレイヤーの実装では、このアプリケーションがどのような利用をされていて、どんな変更が生じるのか、その上で変わらないものは何かを理解する必要があります。
その上で、正しく抽象ができているか確認する方法として、本書では実装の始めにインターフェースに抽象を表すコメントを書けば、実装する前にレビューしたり、調整することができるとありました。
これは自分も取り入れてみようと思いました。
コメント
コメントの意義
正しくコメントを書くことで、システムデザインを改善することができます。
メソッド名と入出力の型のみでしか抽象化されたインターフェースを表現できません。
しかし、膨大なComplexityを捨象しているので、実装を読まなくても利用できるようにするためには、コメントでの補足が必要です。
そのため、ソフトウェアのメンテナンス性からコメントを書くことは、開発時間の投資になります。
なぜなら、コードの読み込みの時間や誤解によるバグの修正など回避することができるからです。
つまり、cognitive load や unknown unknowns を減らします。
また、コードをうまく書いていれば、コメントは不要であるというのは神話(広く信じられているが正しくないこと)です。
上述のように、入出力のデータ構造などは表現できるが、何を意図したのかやその結果の意味などのコンテキスト、それがどのような条件下で呼ばれるかなどはコメントでしか表現できないからです。
もちろん、コードを読めば分かりますが、抽象化されたインターフェースに隠された詳細な実装を読むには時間がかかり過ぎます。
そして、メソッドはネストされているので、トップレベルのメソッドを読むにはそれらを辿って読んでいく必要に迫られます。
書く上での基本的な考え方
コメントの目的、コードで表現できない設計やその振る舞いを読み手に明らかにすることです。
したがって、コードから明らかになっていないことをコメントに記すべきです。
つまり、初めてコードを見た人が同じコメントをかける場合、そのコメントは理解を促すものではありません。
そのようなコメントによって、読み手は素早く必要な情報を探したり、自信を持って変更を加えることができます。
それを実現するためには、自分のコードを初めて見た読み手が知るべきことは何かをを考えながら書くべきであり、レビュー時に読み手は何がわかりづらいかを伝えるべきです。
また、優れたエンジニアは、詳細に立ち戻ってから高次の観点からシステムを捉えられることができます。
つまり、システムで最も重要な側面はどちらか決め、低次の詳細を無視し、最も基本的な特徴の観点のみでシステムを捉えます。
そのようなコメントを記すためには、以下のような観点で考える必要があります。
- このコードが取り組んでいることは何か?
- コード全体を説明するための最も簡潔な表現は何か?
- コードに関して最も重要なことは何か?
インターフェースと実装で分けてコメントをする
そして、コメントにはインターフェース用のコメントと、実装用のコメントの二つがあります。
1 . インターフェース用のコメント
インターフェースへのコメントは、そのメソッドやクラスを利用するために必要な情報を提供します。
このコメントは、高次のレベルの内容も低次のレベルの内容も含まれています。
高次のコメントとは、より抽象度の高いもので、詳細を省くことで全体像や構造を理解するための直観を与えるものです。
例えば、クラス全体のできることや制限などです。
低次のコメントとは、より正確なコードの意味を明らかにするものです。
例えば、引数や返り値の意味、例外が起こる特定の場合などです。
2 . 実装用のコメント
実装用のコメントは、抽象を実装するために内部的にどのようにメソッドやクラスが作用しているのかについての情報を提供します。
逆に、インターフェースへのコメントで、このような内容を記さないといけない場合は、抽象に失敗しています。
この実装用のコメントは、読み手にコードが何を、なぜしているのかを理解してもらうためにあります。
所感
割と神話を信じていた点があったので、コメントを目的を持って書くようしようと思いました。