🚀

ソフトウェア設計についての原則や法則についてまとめてみた

14 min read

ソフトウェア設計について、YAGNIやSOLIDなど多くの原則・法則があることが知られていますが、その解釈にはぶれが存在することが多いです。そこで、特に有名なものあるいは有用と感じることが多いものをいくつかピックアップして、その解釈やトレードオフについてまとめてみました。

注意としては、SOLIDが入ってることからわかる通り、主にOOPに関する文脈になります。また、各原則の定義については概ね知っている前提で書いているのであまり初学者向けの記事ではないかもしれませんのでご承知おきください。

YAGNI(You ain't gonna need it.)

YAGNIは、予測による実装が実際に役立つことは少ないという経験則から生まれた原則です。

一般にオーバーエンジニアリングが利益をもたらすケースは限定的で、どちらかというとプロジェクトに害を与えることが多いとされています。YAGNIは日々状況の変わるプロジェクトにおいて推測に基づいて実装を行うことの不確実さ、困難さを説いているだけでなく、ビジネス的な観点から投資効率を考えた優先順位に沿わない作りこみによる損失を考慮する必要性も説いています。

https://www.infoq.com/jp/news/2015/06/yagni/

YAGNIに従うべき理由としては、以下のようなことがよく言われます。

  • 必要最低限の実装で済ますことでコードがシンプルに保たれ、予期しない変更に対応しやすくなる
  • プロジェクトの状況は刻一刻と変化するため、予測に基づく実装が修正なしに役立つことが少ない
  • 将来に備えた実装を行うと一般的にはコードの量は増えるが、コード量の増加は保守コストを引き上げる傾向がある
  • プロジェクトにおける優先順位を重視するべきであり、いつ役立つかわからないものにコストをかけるというのは優先順位の観点から適切でないことが多い
  • 将来に備えた実装を行うと単純に実装コストが増えてリードタイムが増える傾向がある
  • メンバーは学習・成長するため、プロジェクトの初期に書いたコードよりも後に書かれたコードほど品質が良くなる傾向がある

しかし、オーバーエンジニアリングとそうでないものの区別は大変に難しいです。

例えば明確に予定があるなどして、それが起こる確率が高いのであれば、それに備える価値は高いと言えます。基本的には後から変更を入れるよりは、予め設計に考慮したほうがトータルのコストは安く済むはずです。他にも、例えばアーキテクチャ設計のようなものは時間をかけて将来を予測してしっかりと設計したほうが良い可能性もあります。これに関しては下記の素晴らしい記事に言及があります。

https://qiita.com/hirokidaichi/items/a746062917595619720b

結局のところ、将来の予定の確度やリスクの見積もり、増加する保守コストや手戻りが発生した時の修正コスト、または修正を怠る不作為が発生するリスクなどを総合的に判断して YAGNI に従うかどうかを決断する必要があります。しかしながら、いくつかの設計原則・法則はオーバーエンジニアリングをむしろ推進する方向に作用しがちです(例: 関心の分離、オープン・クローズドの原則)。そういった意味でもオーバーエンジニアリングを抑制する YAGNI は重要な原則と言えるでしょう。

KISSの原則(Keep it simple stupid.)

この原則は「単純で馬鹿なコードに保ちなさい」というような訳をあてるのが良いと思われます。

単純で馬鹿なコードというのは多くのことを考えない簡素な実装を指しています。つまり、この原則は賢すぎる複雑なコードは好ましくないということを言っています。これは YAGNI と似たような意図の原則です。

  • コードは複雑になるほど保守コスト・拡張コストが高くなるため、シンプルであるほど変更に強いといえる
  • ソフトウェアは頻繁に変更が行われるので、シンプルなコードを保つことが好ましい

賢いコードを書こうとすると複雑になりやすいので、この原則ではあえて馬鹿なコードでよいということを明示的に言っています。疎結合化のための抽象化、レイヤーの分離、レイヤーを跨ぐためのデータの変換、コードの再利用のための結合など、賢いコードだが過剰に実施すると害になるものは枚挙にいとまがありません。この話は simple と easy の話にも通じます。easy な部品を作ろうとすると過剰に賢いコードが生まれやすく、細かい挙動の調整が効かなくて拡張もしにくい、融通の利かないものが生まれやすくなります。対して simple な部品は有り体に言って愚直な実装であることが多く、記述量が多くなることもしばしばありますが保守・拡張がしやすく処理の見通しが良いことが多いです。

また、人間というのは不要なものをつけ足していきやすく、逆に不要なものをそぎ落としていくのが苦手な傾向があります。そういった観点からもKISSの原則を常に心がけることは非常に重要であると言えるでしょう。

SOLID

SOLIDは、SRP、OCP、LSP、ISP、DIPの5つの原則の頭文字をとったものです。

SRP: 単一責務の原則(Single Responsibility Principle)

この原則は命名が良くないため誤解が非常に多いです。これは発案者のアンクル・ボブ自身が認めるところであり(書籍『Clean Architecture』にて言及がある)、自身のブログにおいてもこの原則について補足・言及しています。

https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html

This principle is about people.

この原則は人間についてのものであることがここで明言されていますが、SPRの定義の別の言いかえも示してくれています。

Gather together the things that change for the same reasons. Separate those things that change for different reasons.

『Clean Architecture』ではさらに踏み込んだ定義をしています。

モジュールはたったひとつのアクターに対して責任を負うべきである。

この定義は最初の定義よりは変更の理由が明確になった分わかりやすくはなっていますが、それでもまだ少しわかりにくいです。

アンクル・ボブは『Clean Architecture』にて、SRPについて「コンウェイの法則から導かれる当然の帰結」とも言っています。つまりSRPは、コードを変更するという意思決定に関与する登場人物が複数存在する組織、例えばKPIを観測する運営チーム、ユーザーの情報を調べるカスタマーサポートチーム、経理処理を行う経理チームなど、ユースケースの異なるいくつかの属性のユーザー(アクター)が関与している場合において、各ユーザーの要求に対してコードの修正を独立して行えるようにコンポーネントやモジュールを分割しましょうという意図があるものと考えられます。

この原則がケアしている問題は主として2つあります。ひとつは、ユースケースの異なる2つのメソッドが意図せず1つの処理を共有してしまって、片方のユースケースの都合による変更が他のユースケースで用いられるメソッドの挙動を変更してしまうことです。もうひとつは、単純にコードの変更箇所の衝突によるマージ作業のリスクです。

(個人的な見解としては、設計の時点では関心を分離の方が直感的であるため、SRPを意識することはあまりないです。SRPは関心の分離の類型と見なしても問題ありません。ただし、関心の分離は過度の分割を行うことを抑制しないのでYAGNIを意識する必要があります。その観点から言うと、SRPは必要十分な分割単位を説いていると言えるかもしれません。)

OCP: オープン・クローズドの原則(Open–Closed Principle)

OCPは、ソフトウェアの拡張を行うときに既存のコードの修正するのではなくコードの追加で対応できるようにするべきであるという原則です。

OCPも少し誤解されやすいのですが、「拡張に開いている」とは新しい機能を追加できることを意味し、「修正に閉じている」とはこの新機能の追加が既存の機能の動作を変更しないことを意味します。

OCPに則るためには拡張を予測して作りこむ場合がありますが、これは YAGNI と競合します

この時、OCP と YAGNI のどちらを優先するべきでしょうか。これは下記の記事が詳しいです。

https://enterprisecraftsmanship.com/posts/ocp-vs-yagni/

結論としては、APIのインターフェイスに関して完全に制御下にあるなどして容易に変更が可能な場合であれば YAGNI に従うべきであり、APIのユーザーが社外にいるなどして容易に変更することができない場合は OCP に従うべきということになります(勿論、細かい条件によってどちらを優先するかは変わります)。

ちなみに、前もって関心の分離やカプセル化を施すこともOCPに基づいているという解釈ができる場合があるので、OCPは設計においてさじ加減が難しい話題の筆頭であるといえるでしょう。

LSP: リスコフの置換原則(Liskov Substitution Principle)

LSPは、ある基底クラスに対する派生クラスがあるとき、その派生クラスの実装に関する制約事項を規定する原則です。LSPを遵守がされていないプログラムでは、基底クラスを使用している個所にて、本来不要な場面で実体の種類を検査する必要性が出てくるため、保守性・拡張性が非常に低下し、その影響も波及しやすく開発に大きな悪影響を与えます。

LSPの違反は抽象化の誤りや多重継承を制御しきれなくなった時に発生しがちで、抽象化の誤りは言及しても仕方ないので置いておくとして、多重継承についてはそもそも継承を濫用していることが本質的な問題であることが多いです。ゆえに、LSPは継承の良くない使い方を諫めるくらいの位置づけにあると考えると良いです。LSPに違反するコードを書きそうになったら抽象化が適切かを疑うのが良いでしょう。

ISP: インターフェイス分離の原則(Interface Segregation Principle)

インターフェイスはプロジェクトを続けていくうちに徐々に肥大化していく傾向がありますが、肥大化したインターフェイスというのは幾つかのユースケースでは利用しないメソッドが含まれることがしばしばあり、クライアントが使わないメソッドの実装を強いられて不便に感じることがあります。加えて、利用しないメソッドのシグネチャが変わるなどの変更が発生した日には使わないもののためにコードを修正する羽目になりますし、もしかすると障害の原因になることもあるかもしれません。

ISPは、クライアントが本当に利用したいインターフェイスのみを利用できるようにすることで、依存を最小限にし、変更の影響範囲を最小にすることを目的としています。これはクライアントごとにインターフェイスを分割すると読むこともできるため、SRPとよく似た原則と言っても良いでしょう。

基本的には順守して損がないように思える原則ですが、場合によっては下記の記事で例示されているようにコードの表現上の問題であえてISPに違反する選択をすることもあるようです。しかしながら、ユースケースに対する抽象化として考えるならばどちらかというと最小単位で分割したほうが明示的な表現になる傾向があるので、特別な理由がない限りはISPに従うのが良いでしょう。

http://codeofrob.com/entries/my-relationship-with-solid---seeing-i-to-i.html

DIP: 依存関係逆転の原則(Dependency Inversion Principle)

この原則は一見すると難しく思えますが、ソースコードの依存関係が具象ではなく抽象を参照するようにすると柔軟な設計になるということを言っています。実装としては、上位レベルのレイヤに定義したインターフェイスを参照し、下位レベルのレイヤでそのインターフェイスを実装して具象を作れば、依存関係が下位から上位の方向になってDIPを満たします。

DIPに違反すると、つまり上位から下位の方向の依存を作ると、下位レイヤの変更が上位レイヤに波及しやすくなるほか、循環的な依存関係を作ることになるためコードの変更の影響を制御することが困難になります。

レイヤの上位下位に関しては安定度という観点から語られることが多いですが、これはあまり本質的な表現ではないと思われます。上位下位を分ける基準を強いて挙げるなら、より抽象度の高いものを上位に置き、技術詳細を下位に寄せていくのが良いです。一般に抽象度の高い処理にとっては技術詳細には興味がないため、技術詳細の変更による影響を受けたくはありません。その要求を実現するためにDIPが存在し、DIPを適用するとインターフェイスによって実装が分離され(疎結合化)、技術詳細が隠蔽されて下位レイヤ内に変更の影響を閉じ込めることができます。

なお、ビジネスロジックは一般に抽象度が高いためドメイン層が自然と上位に来るはずです。アーキテクチャレベルの設計、とりわけクリーンアーキテクチャのような設計パターンにおいては、DIPはドメイン層を他のレイヤと疎にして他のレイヤの変更からビジネスロジックを保護することを目的としていると解釈すると良いでしょう。

DIPはDIの基本的原理でもあり、DIが普及している今日日、今更疑う余地もなさそうな原則になりますが、これもあえて無視することがあります。というのも、DIPに基づいて実装をインターフェイスで分離する場合は下位レイヤに実装を移すことになりますが、もし元々実装を置いていたパッケージの変更と分離された実装が同時に変更されることが多いとすると、再利用・リリース等価の原則(REP) に基づくならわざわざ分離する必要はない可能性があります。

https://phpmentors.jp/post/47148000374

インターフェイスを作ると基本的に間接層が増えて実装が煩雑になっていき、プログラムの構造の把握も困難になっていきます。何事も過ぎたるは猶及ばざるが如しです。DDDのリポジトリの実装をインターフェイスで分離するかどうかというのはよく話題に上りますが、リポジトリの場合はそれ自体が中間層として機能するため、わざわざインターフェイスを用意せずとも一定の疎結合は実現できていると言えます。結局のところ、DIPとREPどちらを優先するかはそのプロジェクトの設計思想によって変わることでしょう。

DRY(Don't repeat yourself)

DRY は誤解されることが多い原則で、「コードを重複させない」という意味ではありません。勿論コードの重複も含みますが、DRYはより広い範囲に適用されるべき原則であり、正確には「システムの知識のすべてが曖昧さのない1つの表現をだけを持つべき」ということが意図されています。これは、例えばあるひとつのDDLからデータベースのマイグレーションファイルとボイラープレートコードの自動生成を行うようなことを指します。DDLからの自動生成を用いない場合、データベーススキーマとコードについて人間が整合性を取ることになり、人為的ミスによって不整合を起こす可能性が高くなります。他にも、APIの実装からドキュメントを自動生成することなどもよくある話題になります。このように、整合性を取る必要のある重複した知識全てがDRYの適用対象になります。

参考: artima - Orthogonality and the DRY Principle

DRY のトレードオフ

https://www.infoq.com/jp/news/2012/05/DRY-code-duplication-coupling/

DRY は一見すると絶対に守ったほうが良いルールであるように思えますが、実際は重複を許容したほうがシンプルになるケースも少なくありません。例えばコードならDRYを遵守することによって可読性が下がったり複雑度が上昇したりする場合があります。以下、コーディングの文脈におけるDRYの扱いについて言及します。

DRYはとりわけ抽象化に問題を発生させやすく、望ましくないコードの結合によって保守性を悪化させることがしばしばあります。例えば部分的に同じ手続きを持つ別の処理というものには時々遭遇します。それらが本質的に異なるものである場合、共通のコードをまとめてしまうと、それぞれが持っていたユースケースに基づく要求によって、処理をまとめた個所に条件分岐が増えていって複雑化して保守性の低下を招くことがよくあります。コードの重複は抽象化を見直すきっかけにはなりますが、必ずしも重複を解消することが正しいとは限らないということに留意が必要です。

DRYに従うかどうかの判断に困ったときの判断基準としては、前述した抽象化に関する話のほかにも、重複する要素が不整合を起こしたことを検知する仕組みが存在するならDRYに従わなくてもよくて、そうでない場合はDRYに従ったほうが良いと言われることもあります。これはつまるところ、重複に人間が気付いて制御できるかどうかが重要ということです。

まとめると、DRYを適用するかどうかは、重複に気づけるかどうか、もし重複に由来する事故が発生した時にどのようなことが起こるか、抽象化や可読性、複雑度などに問題が発生しないかといった要素について考慮を巡らせて、総合的に判断する必要があります。

デメテルの法則

デメテルの法則は暗黙の依存を作るべきではないという意味合いの法則になりますが、単に getter や setter を与えればよいというわけではなく、設計的にはきちんとドメインの知識を与えるところまでやったほうが良いでしょう。

https://tech-blog.rakus.co.jp/entry/20200701/programming

漏れのある抽象化の法則(Leaky abstraction)

※ややマイナーですが、割と重要な理論です

Joel Spolsky 氏が2002年に公開した下記の記事が初出とのこと。

https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/

この法則によると、些細なものを除くすべての抽象化はある程度の漏れがあるとされています。例えばTCP/IPによるネットワークアクセスや巨大な2次元配列のメモリアクセス、データベースへのクエリ処理などは、すべて実行時のパフォーマンスという形で詳細な振る舞いが抽象化されたレイヤから漏れ出てきます。もしもパフォーマンスの最適化のためにこの振る舞いをコントロールしようと考えると、本来は抽象化されて覆い隠されているはずの実装の詳細に対するコードをクライアント側に書くことになります。これはI/Oを取り扱うときに限らず、例えば利用上の制約事項や副作用、あるいは準正常系や異常系の挙動などからも抽象化が漏れることが多く、複雑なものを単純に扱えるように抽象化を行っているならば少なからず漏れが発生するものと考えられます。

ここから言えることは、完全な抽象化は幻想でしかないということを受け入れるべきでしょう。勿論、抽象化の漏れが与える影響が些細であればこれ自体は大した問題ではありません。しかしながら、もしも漏れ出た事象がクライアントにとっての重要な関心事で無視できない程度の影響がある場合、抽象化の根底にある詳細を学ぶことが必要となり、漏れの影響をコントロールするための実装が必要になることもあります。また、漏れ出た事象から生じた実装がアプリケーションの設計を破綻させないように、コードへの影響を最小限に抑える方法を知る必要もあることでしょう。

これは逆に、強い制約を持つ実装やパフォーマンス上の問題を発生させやすいものはうまく抽象化したつもりでも漏れが発生しやすいという話でもあります。抽象化を設計するときは、詳細がどのようにリークする可能性があるのかを気にかける必要があります。

ちなみに、Leaky Abstractions の法則の元々の意図としては、抽象化はある程度のレベルまでは学習を省略してそれを扱うことを助けてくれますが、最終的には抽象化で覆い隠されているはずの事象を学ぶ必要性が出てくるということを説いています。元記事でも抽象化によって作業時間を節約できるが学習時間を節約することは出来ないという表現が為されています。

抽象化とパフォーマンスの最適化

DDD のデザインパターンはDBへのアクセスをよく抽象化して覆い隠しており、プログラムの流れをわかりやすい構造にして保守性・拡張性を高めてくれます。しかし、特に集約に関してですが、高いレベルで抽象化が行われているがゆえに、素直にトランザクションスクリプトで実装した時に比べてDBへのアクセスが増加する傾向があり、パフォーマンス上の問題を抱えやすくなります。

Leaky abstractions の法則に関して、ソフトウェア設計で重要なポイントは、抽象化は複雑さを覆い隠すことよってその根底にある複雑さに対するより細かい制御を犠牲にしているため、漏れ出た事象への対処を行えるようにすればするほど複雑な詳細が漏れ出すということです。

  • システムに複雑さが存在する場合、それは何らかの理由で必要だから存在している
  • 抽象化によって複雑さが覆い隠されると、複雑さに対する制御が制限される
  • 「何らかの理由」に該当する個所を制御したいケースに遭遇したならば、抽象化を破らなければならなくなる場合がある

結局のところ、これは最適化と抽象化は一般的にトレードオフの関係にあるという話であると言えます。パフォーマンスなどの非機能要件が重要な場合は、部分的に抽象化を多少崩し、詳細を扱うことを検討すると良いでしょう。

参考: java - Exception to the Law of Leaky Abstractions - Stack Overflow

おまけ: その他、設計に関する雑多な話題

個別の記事にするほどでもないものや、他の方の記事で十分に解説されているようなものをまとめました。

関心の分離(SoC)について

関心の分離は抽象化に関する基本原則であると言っても過言ではありません。しかしながら、前述のSRPの項目で軽く触れましたが関心の分離はSRPと似ているので詳細は解説は割愛することにしました。関心の分離の解説としては下記の記事がとても詳しいので、詳細を知りたい人はこちらを参照すると良いと思われます。

https://qiita.com/MinoDriven/items/37599172b2cd27c38a33

抽象化の適切さの判断基準

OOPにおいては多くの問題は適切でない抽象化によって引き起こされます。具象の実装の問題は影響範囲が限定的で定量的な判断基準が提供されることも多いので比較的解消しやすいですが、抽象化の問題は影響範囲が広いことが多いため一般に解決が困難です。

以下に挙げる項目はよくある話として常に気にしておきたいものになります。

  • クラスやメソッドには命名に対して適切な振る舞いのみが実装されているか
    • 複数の関心が含まれていないかどうか
    • 可読性・保守性の問題でもある(命名から予想できない振る舞いが含まれていると非常に読みづらいし、バグの原因にもなる)
    • 命名が難しい場合は抽象化が適切ではない可能性がある
  • 継承関係に不自然な分類が混入していないかどうか
    • 継承は本質的には対象を分類するものであるため、分類として考えた時に不自然だと感じるものがあるのであれば抽象化が適切でない可能性がある
  • 過剰に継承を使用していないか
  • 継承を利用したコードで不自然な記述が頻出していないか
    • もしもインスタンスのクラスの種類を調べて分岐する処理があまりにも多いなら抽象化が適切でない可能性がある
  • 早すぎる抽象化になっていないか
    • 「早すぎるxxx」というのは一種のバズワードとして独り歩きしかねないので注意が必要
    • この場合は「今ある問題または仕様のみを反映した抽象化か」という話
    • つまりYAGNI
  • easy を優先しすぎて simple でなくなっていないか
    • 過剰なカプセル化によって暗黙的挙動が増えたり、副作用がまとめて1つの処理に詰め込まれたりというのはよく発生する
    • 複雑さが増すくらいならば、手続きが増えるくらいはどうということはない
    • つまりKISS
  • カプセル化によって隠蔽しようとした知識が意図せず漏れていないか

Clean Architecture における UseCase について

Clean Architecture において、サーバサイドの実装を行っている人にとっては Presenter が非常に理解しがたいことが多いのですが、その理由について下記の記事がとてもよい解説になっています。

https://qiita.com/os1ma/items/c02af5b7783b58165c8d

自動テストについて

TDDについて

下記の記事にまとめました。

https://zenn.dev/nanagi/articles/a1259bc95fb20f

テストの量の最適は難しい

あるテストケースを書くかどうかの基準については、実装コストや保守コストに見合った効果が得られるかどうかが重要であるため、自動テストの量に関する定量的な指標を用意するのは困難です。よく言われるカバレッジ100%という目標も、テストの本質を見失いやすいため適切ではないという主張が多く、基本的には「最適」な量のテストを見極めるというのは不可能と言って差し支えないでしょう。しかしながら、最適よりも多い状態と少ない状態、どちらかが好ましいかを考えると多い方を選んだ方が良いことが多いのではないかと思われます(無論あえて少ない状態を選択する状況もあり得ますが)。そういった意味でもTDDあるいはテストファーストは悪くない手法であると言えるでしょう。勿論これは程度問題になりますので、常にテストの量があまりにも過剰すぎないか、あるいは少なすぎないかについて気に掛ける必要はあるでしょう。

テスト容易性は設計のカナリアである

ユニットテストを記述することが困難な実装は設計的に問題を抱えていることが多く、特定のクラスと密結合していたり、外部システムとの結合が多かったり、内部状態によって実行結果が変わったりと様々なケースがあります。ユニットテストを記述しづらいと感じた時はリファクタリングを検討すると良いでしょう。ただし、テストが簡単になるからと言って、ただ処理をまとめるために過剰にカプセル化を行うようなことはしないように注意を払う必要があります。カプセル化にも当然のようにトレードオフが存在します。テストをしやすくするためではなく、本質的に必要な抽象化を行うべきです。

実はモックオブジェクトも設計のカナリアとして用いられます。モックを使用した複雑なテストを書く必要が出てきたときは、一度そのモックする対象の振る舞いを分離できないかを検討するのが良いでしょう。

モックだらけのテストを見たら結合テストを検討する

複雑なアルゴリズムのテストなどでは確かにユニットテストが有効です。境界条件などの詳細な条件に対してすべて問題なく動作することを保証するために、適切な作業量で適切な効果を得ることができます。しかしながら、Clean Architecture における UseCase など、DBなどの外部システムとの結合度が高いコードに対するユニットテストでは大量のモックオブジェクトを必要とすることがあります。このようなテストはモックを順番に呼び出すだけのテストになりがちで、一体何をテストしたかったのかがわからなくなることもしばしばあります。モックオブジェクトの多いテストは実装コストも保守コストも比較的高いことから、テストの意義がぼやけると余計に費用対効果が悪くなっていきます。この種のコードでは、恐らく結合テストを使用するのが良いでしょう。コードの種類とそれに対する適切なテストの選択については下記の記事を参照すると良いです。

http://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/

参考: 「ほとんどのユニットテストが役に立たない理由」を読んで | POSTD

なお、結合テストはスローテストになりがちですが、並列化やユニットテストと結合テストを分けて結合テストだけCIでの実行にするなどの対策によって問題を緩和することが可能です。特に大規模プロジェクトにおいてはテストの実行時間を気にして結合テストに対して消極的になることもあるため、結合テストの導入を考えるなら自動テストの並列化の重要度はより高まるといえます。

Discussion

ログインするとコメントできます