Skip to content

Instantly share code, notes, and snippets.

@onionmk2
Forked from matarillo/1_srp.md
Created February 20, 2025 02:26
Show Gist options
  • Select an option

  • Save onionmk2/b6b5ed19c51a2360e8841498a4ba2002 to your computer and use it in GitHub Desktop.

Select an option

Save onionmk2/b6b5ed19c51a2360e8841498a4ba2002 to your computer and use it in GitHub Desktop.
SOLID原則はソリッドではない

SOLIDはソリッドではない - 単一責任原則を検証する

https://naildrivin5.com/blog/2019/11/11/solid-is-not-solid-rexamining-the-single-responsibility-principle.html

2019年11月11日

最近、SOLIDの原則について考えていて、その有用性に疑問を感じている。 SOLIDの原則は曖昧で、範囲が広すぎて、混乱を招き、場合によっては完全に間違っている。しかし、これらの原則の動機は正しい。問題は、ニュアンスの異なる概念を簡潔な文に落とし込もうとすることにあって、翻訳の過程で価値の大部分を失っているのだ。 これはプログラマーを間違った道へと導いてしまう(私にとっては確かにそうだった)。

おさらいとして、SOLIDの原則は以下の通りである:

  • 単一責任原則
  • オープン/クローズの原則
  • リスコフの置換原理
  • インターフェース分離の原則
  • 依存関係の逆転原理

今回は「単一責任原則」を取り上げ、4回にわたって他の原則に取り組む。

単一責任原則

ウィキペディアには 次のように書かれている。

つまり、ソフトウェアの仕様のひとつに対する変更だけが、そのクラスの仕様に影響を与えることができる。

これはかなり曖昧だ。「仕様」とは何だろう? 私はこの23年間、仕様の定まったソフトウェアに携わったことがない。 そして、ここでの「影響」とは何を意味するのか?

ウィキペディアの記事では、「例」のセクションで説明している(強調は原文のまま):

マーティン[この言葉を作ったロバート・マーティン 1 ]は、責任とは変更理由であると定義している。

というのも、 すべて のコードには、バグを修正するか機能を追加するかという、少なくとも 2つ の変更理由があるからだ。では、それらが別の理由と見なされないのであれば、「理由」とは何なのか?

そこが曖昧なので、コードレビューに単一責任原則を適用するとだいたい泥沼化する。というのも、誰もがレビュー中のコードの質ではなく、原則をどう解釈するかについて話し始めるからだ。

とはいえ、コードが持つべき仕事/事柄/責任は1つだけというのは正しい 気がする 。 このRailsコントローラを考えてみよう:

class WidgetsController < ApplicationController
  def create
    @widget = Widget.create(widget_params)
    if @widget.valid?
      redirect_to :index
    else
      render :new
    end
  end

  def widget_params
    params.require(:widget).permit(:name, :price)
  end
end

これは非常にバニラな実装で、新しいウィジェットが有効であればデータベースに保存し、有効でなければ、バリデーションの問題を修正するためにユーザーをフォームに送り返す。

「バグフィックスと新機能」という変更理由はさておき、このクラスには変更する理由がたくさんありそうだ。 ウィジェットを要求するために必要なパラメータを追加するかもしれない。 ウィジェットが作成されたときに、ユーザを別の場所にルーティングする必要があると判断するかもしれない。 ウィジェットが作成されるたびに、管理者にメールを送信する必要があるかもしれない。つまり、このコードが単一責任原則に違反していることは明らかであり、したがって悪いことであり、変更されるべきなのだ。そうだろう?

ここでそれを受け入れるのは難しい。 このコードは、Railsが推奨するコードの書き方の規範になっているだけでなく、短く、直接的で、要点がまとまっている。 もちろん、時間が経てばこのコントローラにさらにコードを追加することもできるし、コントローラが大きく複雑になることもあるだろう。しかし、このコードの変更理由が 正確に1つ であるべきだとか、修正が必要だと言うのだろうか?それは意味がない。

科学のために、このコードを変更して責任の数を減らしてみよう。

class WidgetsController < ApplicationController
  def create
    @widget = WidgetCreator.create(params)
    WidgetRouter.route(self, @widget)
  end
end

class WidgetCreator
  def self.create(params)
    Widget.create(params.require(:widget).permit(:name, :price)
  end
end

class WidgetRouter
  def self.route(controller, widget)
    if widget.valid?
      controller.redirect_to :index
    else
      controller.render :new
    end
  end
end

各クラスの責任は確かに軽くなり、変わる理由も少なくなった。しかし、これを改善と見るのは難しい。確かに、ウィジェットの作成方法が複雑になれば、別のクラスを持つことに価値があるかもしれない。また、作成時のルーティングが多くの微妙なルールに左右されるのであれば、それを抽出することに価値があるかもしれないが、今回はそうではない。決してこのコードが優れているわけではない。

このことが私に教えてくれるのは、単一責任原則はそのままでは役に立たず、盲目的に固執すれば、解決しようとしている以上の問題を引き起こすかもしれないということだ。

とはいえ、単一責任原則の意図は正しい。それは、モジュールの要素がどの程度まとまっているかという 凝集性 についての方向性を与えようとしているのである。 問題は、結束はそれほど単純明快ではないということだ。

凝集性

凝集性 とは、コンピュータ・サイエンスで長い間議論されてきた概念で、要素(コードの一部)が一緒になっているモジュール(コードのグループ化を意味する)は、要素が一緒になっていないモジュールよりも保守性が高く、理解しやすいというものだ。

凝集性だって単一責任原則と同様に曖昧だが、 原則 としては提示されておらず、遵守しなければならない客観的な尺度としても提示されていない。

強力な規定措置がないということは、責任の数を数えるのをやめて、今あるコードとそれに加えたい変更について話し始めることができるということだ。 元のコントローラに対する2つの変更を見てみよう。これらの変更はどちらも単一責任の原則に違反することになる。しかし、クラスの凝集性に重大な影響を与えるのは1つだけだ。

最初の例では、ウィジェットが作成されるたびにメールを送信するコードを追加する。

class WidgetsController < ApplicationController
  def create
    @widget = Widget.create(widget_params)
    if @widget.valid?
      WidgetMailer.widget_created(@widget) # <------
      redirect_to :index
    else
      render :new
    end
  end

  def widget_params
    params.require(:widget).permit(:name, :price)
  end
end

ウィジェットの作成とそれに関するEメールの送信は、一緒にあるべきもののように思えるので、この変更はこのクラスのまとまりに実質的な影響を与えないと主張したい2.

ウィジェットを保持するテーブルのデータベース統計を記録する、別の変更を見てみよう:

class WidgetsController < ApplicationController
  def create
    @widget = Widget.create(widget_params)
    if @widget.valid?
      DatabaseStatistics.object_created(:widget) # <-----
      redirect_to :index
    else
      render :new
    end
  end

  def widget_params
    params.require(:widget).permit(:name, :price)
  end
end

コントローラーはデータベースとは何の関係もない。だから、この変更は、私たちがこの変更に疑問を持つのに十分なほど、クラスのまとまりを弱めるように感じる。

しかし、どちらの場合も単一責任原則に違反している。 このことは、凝集性の概念を単一責任原則に当てはめることが絶対に間違っていることを物語っている。

私からのアドバイスだ: 単一責任について話すのをやめて、凝集性の話を始めよう。

次回は「オープン/クローズの原則」を取り上げる。この原則は、全く役に立たないほど混乱している。

Footnotes

  1. ロバート・マーティン、別名 "アンクル・ボブ "は、私の個人的価値観と矛盾する発言をオンライン上で行っている。 とはいえ、彼はソフトウェアとオブジェクト指向設計の世界で影響力を持っており、彼のアイデアは多くの開発者によって教えられているため、彼の考えを批判することには価値があるのだ。アンクル・ボブのオンライン上での行動についてもっと知りたいのであれば、Twitterで彼を見つけるのがよいだろう。

  2. また、この変更がリファクタリング後のバージョンでどのように物事を複雑にしていたかにも注目すべきだ。このコードを WidgetRouter に追加する必要があり、それは非常に間違っていると感じられるはずであり、したがって、この1行のコードを追加するためには、より大規模なリファクタリングが必要になるのだ。

オープン/クローズドの原則は混乱させるし、まあ、間違っている(SOLIDはソリッドではない)

https://naildrivin5.com/blog/2019/11/14/open-closed-principle-is-confusing-and-well-wrong.html

2019年11月14日

SOLID 原則は、当初考えられていたほど「solid(堅牢)」ではないことに気づきつつあるのである。前回の投稿 では、単一責任原則の問題点を概説したが、今回は5つの原則の中で最も理解しづらいオープン/クローズドの原則について述べたいと思う。

この原則は、ソフトウェアは「拡張に対してオープンであり、修正に対してクローズドであるべき」というものである。この要約は非常にわかりづらく、深く掘り下げてみると悪いアドバイスばかりであった。この原則は完全に無視すべきだ。その理由を見ていこう。

オープン/クローズドの原則の意味するところ

この原則は(SOLIDにおける理解では)、ロバート・マーチン 1 がバートランド・メイヤーの著書『 Object-Oriented Software Construction 』での記述を基に書いた論文で提唱されたものである。

マーチンはメイヤーの言葉を次のようにパラフレーズしている。

ソフトウェアの構成要素(クラス、モジュール、関数など)は、拡張に対してオープンであり、修正に対してクローズドであるべきだ。

そして、「拡張に対してオープン」とは、アプリケーションの要件が変更されたり、新しいアプリケーションのニーズに対応したりするために、モジュールを「新しく異なる方法で動作させることができる」ことを意味すると定義している。一方、「修正に対してクローズド」とは、「誰もそのソースコードを変更することを許されない」ことを意味すると定義している。

はあ?これは全く逆のように思えるのだが。

コードに不必要な柔軟性を追加すること(拡張に対してオープンにすること)は、複雑さとキャリングコストを生み出す。究極的な柔軟性を実現するために、存在しないありとあらゆるユースケースを想像する必要がある。これは時間の無駄であり、より複雑でわかりにくいコードを生み出し、必要のないすべての柔軟性を永続的にメンテナンスすることを要求するのである。

ソースコードを変更できないという考え方ほど奇妙ではないが、バグを修正するためにコードを変更できないとしたら、どうすればよいのだろうか。削除して最初からやり直すのか。この原則のこの部分は明らかに間違っているように思えて、自分の現実認識を疑ってしまう。

この論文を掘り下げてみると、クラスは抽象基底クラスに依存すべきであり、それによって特定のクラスの実装を、そのクラスの利用者に影響を与えることなく入れ替えることができるようにすべきだと述べているようである。そして、これが一つの 原則 であるため、私の解釈では、常にこのようにすべきだということになる。

これは悪いアドバイスである。柔軟性はほとんど必要とされず、ほとんどの場合、解決するよりも多くの問題を生み出す。また、システムの振る舞いを理解するのが難しくなることもあり得る。

柔軟性はコストがかかる

他の条件が同じであれば、より柔軟性の高いコードは、構築、テスト、メンテナンスがより難しくなる。必要のない機能をコードに組み込むことは余分な作業だ。キャリングコストの概念だけでも、クラスを「拡張に対してオープン」にするために必要な作業は避けるべきだ。柔軟性が必要ないのであれば、構築してはいけない。

そのキャリングコストの一つが、システムの振る舞いを理解する能力だ。高い柔軟性を持つコードは、ナビゲートすべきコードパスを多く生成し、この論文で示されているような柔軟性(抽象基底クラスを追加すること)は、それらのコードパスを発見するのを難しくする。

何について話しているのかを見てみよう。この論文では、Serverに依存するClientの例が示されている。

A client class depends on a concrete server class directly

論文の ClientServer の関係の再現(新しいウィンドウでより大きなバージョンを表示

そのコードはJavaでは次のようになる(Rubyには型アノテーションがないため、これを見るのは難しい)。

public class Client {
  private Server server;

  public Client() {
    this.server = new Server();
  }

  public void saveSomeData(String data) {
    this.server.post("/foo", data);
  }
}

public class Server {
  public void post(String url, String data) {
    // ....
  }
}

オープン/クローズドの原則によると、このクラスは、常に具象のServerインスタンスを使用するため、拡張に対してオープンではなく、また、別のタイプのサーバーに変更したい場合は、ソースコードを変更しなければならないため、修正に対してクローズドでもない。

これらの変更を行う 必要がある というのは、既定路線の結論ではない。また、実際に柔軟性が必要な場合、このクラスがその柔軟性を追加しなければならない場所であるかどうかも明らかではない。

それでも、この論文(つまり原則)では、この問題に対処する方法をこのように述べている。すなわち、サーバーの実装のための抽象基底クラスを導入し、Clientにはその基底クラスに依存させるべきだと。

A client class depends on an abstract server class with a concrete implementation of that abstract class

論文のClientAbstractServerServerの関係の再現(新しいウィンドウでより大きなバージョンを表示

Javaでは、次のようになる。

public abstract class AbstractServer {
  abstract void post(String url, String data);
}

public class Server extends AbstractServer {
  public void post(String url, String data) {
    // ....
  }
}

public class Client {
  public Client(AbstractServer server) {
    this.server = server;
  }
}

そして、Clientインスタンスを作成するときは、常に具象の実装を渡す。

Client client = new Client(new Server())

Clientは、AbstractServerの別の実装を渡すことができるため、拡張に対してオープンになり、そのためにソースを変更する必要がないため、修正に対してクローズドになった。

これはより柔軟な設計だが、果たしてより良いものだろうか。私は、これが 絶対的に より良いものだとは思えない。複数のServerが必要ない場合、コードに不要な機能を追加してしまい、それを維持しなければならなくなる。この例では些細なことに思えるかもしれないが、このようにして構築された全体のコードベースを想像してみてほしい。私はそのようなコードベースで作業したことがあるが、楽しいものではなかった。そのためのコードを書くには、必要のない抽象基底クラス(またはインターフェース)を作るという余分な手順が必要だった。

しかし、そのことはシステムの振る舞いを説明し、予測することを 本当に 難しくしてしまった。

システムの振る舞いを理解することが最も重要

プログラマーとして、システムの実際の振る舞いを 頻繁に 説明し、理解しなければならない。バグを診断して修正したり、システムで何が起こったのかを他の人に説明したり、機能を追加するために変更を加えたりしなければならないのだ。

オープン/クローズドの原則に違反している最初の実装では、柔軟性がないため、システムの振る舞いを説明するのは非常に簡単だ。Clientは常にServerを使用するため、コードを通るパスは明確である。

しかし、2番目の実装では、より難しくなる。ServerAbstractServerの唯一の実装かどうかわからないと仮定すると、システムの観測された振る舞いを理解するためには、Clientのすべての使用箇所を追跡して、どのAbstractServerの実装が使用されたかを把握し、どのパスがどれを使用したかを把握しなければならない。

AbstractServer の実装が1つしかないことを発見するためにそれを行うことを想像してみてほしい。

システムが必要とする以上に柔軟になるようにクラスを設計すると、複雑さが生まれる。プログラマーが必要になるかもしれないと考えている柔軟性を追加する場合、今の時点で柔軟性を追加することで後で時間を節約しようというアイデアがある。しかし、どのような柔軟性が必要なのかは、常にはわからないのだ。システムを 本当に 柔軟にするためには、必要なことだけを実装し、十分にテストされていなければならない。

私のアドバイスは次のとおりだ。オープン/クローズドの原則は完全に無視すること。目の前の問題を解決するためのコードを書くこと。

残りのSOLID原則を見ていくと、必要のない場合にも柔軟性を追加するという繰り返しのテーマが見えてくるだろう。それは一体何のためなのか...よくわからない。

次はリスコフの置換原則だ。

Footnotes

  1. ロバート・マーチン(通称「アンクル・ボブ」)は、私の個人的な価値観と一致しないようなオンライン上の発言をしているので、私は彼の仕事を熱心にフォローしておらず、彼を高く評価してもいない。それでも、彼はソフトウェアやオブジェクト指向設計の世界で影響力を持っており、多くの開発者に教えられている彼のアイデアを批判することには価値がある。アンクル・ボブのオンライン上の行動について詳しく知りたい方は、Twitterで彼を探してみてほしい。

Liskov置換原則は...設計原則ではない(SOLIDはソリッドではない)

https://naildrivin5.com/blog/2019/11/18/liskov-substitution-principle-is-not-a-design-principle.html

2019年11月18日

元の投稿 で述べたように、私はSOLID原則が...思われるほどソリッド(堅牢)ではないことに気づいている。最初の投稿では、単一責任原則に私が見る問題点を概説した。2番目の投稿では、オープン/クローズド原則は混乱を招き、ほとんどの合理的な解釈では悪いアドバイスを与えるため、無視することを推奨した。さて、Liskov置換原則について話そう。これは、結局のところ、設計のアドバイスではないのだ。

この原則は、「プログラム内のオブジェクトは、プログラムの正しさを変えることなく、そのサブタイプのインスタンスと置き換え可能であるべきだ」と述べている。これを理解するには、「プログラムの正しさ」が何を意味するのかを知る必要がある。

それを理解するには、この原則がどこで開発されたかを見るのが役立つ。そして実際、この原則の名前の由来となったBarbara Liskovによって開発されたり、命名されたりしたのではない。

LiskovとJeannette Wingは、 サブタイプ をプログラムの正しさに関連付ける方法を定義しようとする 論文著した 。その論文の中で、彼女らは、オブジェクト x の代わりにオブジェクト y を使用したが、 yx と同じプロパティがすべてない場合、 yx のサブタイプではないと述べている。

では、この原則はどのようにして生まれたのだろうか。驚くことではないが、答えは良くも悪くもUncle Bob Martin 1 だ。彼はLiskovの研究を参照した 論文 の中で、この原則について説明している。

Martinの論文では、この原則が解決しようとしている問題について強い主張をしておらず、この原則の存在を正当化する複雑な例を示しているが、この原則を理解したり適用したりする方法については何の指示もない。

ただ混乱していて曖昧だと片付けたくなるが、「正しさ」の使用に固執していることがとても気になる。

そもそも「プログラムの正しさ」とは正確には何なのだろうか?

Wikipediaでは、プログラムの正しさを次のように定義している

アルゴリズムが仕様に関して正しいと言われるのは、そのアルゴリズムが仕様に関して正しいと言われるときである。機能的正しさとは、アルゴリズムの入力-出力動作(つまり、各入力に対して予想される出力を生成すること)を指す。

この定義は妥当に思えるが、しかしここでも再び仕様が定まっているいう要件に直面する。ほとんどのソフトウェアの開発においては、定まった仕様が存在することはまれであるだけでなく、アジャイルソフトウェア開発(皮肉にもMartinによって開発され、支持されている)では、仕様を定めることをしばしば避け、ユーザーのフィードバックを得ながらソフトウェアを反復することを好む。

そのため、仕様を必要とする正しさに基づいて、定まっていない仕様でどのように設計を評価すればよいのか悩んでしまう。

しかし、正しさの定義を見たときにわかるような定義であっても、奇妙な道に導かれてしまう。

多数のファイルの内容をソートしたいとしよう。ファイルのディレクトリがあり、そのすべての行をソートして単一のファイルに出力したいとする。ソートの詳細は渡されたオブジェクトに委ねたいので、中心となるルーチンは次のようになるかもしれない。

def sort_files(files_dir, destination_file, sorter)
  files_in_dir = readdir(files_dir)
  sorter.sort_contents(files_in_dir, destination_file)
end

sort_files の呼び出し側は、 sorter に任意の実装を提供できる。そして、それらの実装がプログラムの正しさを変えない限り、Liskov置換原則に違反しないため、設計は良いと考えられる。

2つの可能なソートアルゴリズムを考えてみよう。1つ目は、 MemQuicksort と呼ぶことにする。これは、すべてのファイルの行をメモリに読み込み、クイックソートを行う。そして、ソートされた結果を destination_file に書き込む。これは、プログラムの要件を満たしているように思える。

次に、 FileMergeSort と呼ぶ別の実装があるとしよう。これは、基本的にディスク上のファイルをソートし、すべての行をメモリに読み込むことを避けるためにマージソートを使用する。より多くのディスク容量を必要とするが、それほど多くのメモリは必要としない。これも、プログラムの要件を満たしているように思える。どちらの実装も、同じ入力を与えれば、同じ出力を生成する。

それとも違うだろうか?

これら2つの実装は、ソフトウェアの振る舞い方を根本的に変えてしまう。そしてそれは「出力」とみなされるのではないだろうか? ソースコードの制御外の状況(つまり、ディスクの容量、メモリの容量、ファイルのサイズ)によっては、プログラムがまったく動作しないかもしれない。あるいは、望ましいよりも遅く動作するかもしれない。あるいは、必要なメモリのために実行コストが高すぎるかもしれない。

ご覧のとおり、このプログラムへの入力は、ファイルのあるディレクトリ、宛先ファイル、使用するソートアルゴリズムだけではない。プログラムが実行されるコンピュータ、割り当てられたメモリ、ディスクのサイズなど、いくつかの暗黙の入力がある。

つまり、正しさの定義では、プログラムの実際の動作を含む、 すべて の入力と すべて の出力を説明する必要がある、ということだ。そうだろう? そうだとすれば、 どの サブタイプであれ、これらのいくつかに何らかの影響を与えないことがどうしてありえようか? そもそもサブタイプを作成する理由は、動作を変更するためだ。

これは、使用している正しさの定義によっては、 すべて のサブタイプがこの原則に違反することを示している。そして、単一責任原則について議論する際には、問題のコードではなく「責任」とは何かについて議論することが多いのと同じように、Liskov置換原則は、コードについて話すのではなく、「正しさ」についての議論に堕してしまうのではないかと思わずにはいられない。

ここでの私の見解は、サブタイプに焦点を当てることは、設計を分析するための正しいレンズではない、ということだ。それは、設計を改善する方法について何の明快さも提供しない。このレンズはどのレベルでも設計のアドバイスとは見なしがたい。

私のアドバイス: これは設計の指針ではないので、無視して、サブタイプについて話すのをやめ、抱えている問題を解決するソフトウェアを構築することに集中するべきだ。

次は、インターフェース分離原則だ。これは、求められていないときに柔軟なコードを作るためのもう1つの処方箋だ。

Footnotes

  1. Robert Martin、通称「Uncle Bob」は、オンライン上で私の個人的な価値観と一致しない発言をしているので、私は彼の仕事を詳しくフォローしておらず、彼を尊敬してもいない。それにもかかわらず、彼はソフトウェアとオブジェクト指向設計の世界で影響力を持っており、多くの開発者に教えられているため、彼のアイデアを批判することには価値がある。Uncle Bobのオンライン上での行動について詳しく知りたい場合は、Twitterで彼を見つけてほしい。

インターフェース分離原則は役に立たないが無害だ (SOLIDはソリッドではない)

2019年11月21日

元の投稿で述べたように、私はSOLID原則が...思われるほどソリッド(堅牢)ではないことに気づいている。その投稿では、単一責任原則に私が見る問題点を概説した。2番目の投稿では、オープン/クローズド原則は混乱を招き、ほとんどの合理的な解釈では悪いアドバイスを与えるため、無視することを推奨した。3番目の投稿では、Liskov置換原則が間違った問題に焦点を当てすぎていて、実際には使えるデザインのガイダンスを与えていないことについて話した。

今回は、インターフェース分離原則について話したい。これは、結合の問題に対して非常に奇妙な解決策を処方するものだ。実際には、結合 凝集性について直接話し合い、どちらか一方に最適化しすぎないように注意すべきなのだ。

Wikipediaの記事には次のように書かれている。

[インターフェース分離の原則 (ISP)] は、非常に大きなインターフェースを、より小さく、より具体的なものに分割し、クライアントが関心を持つメソッドについてのみ知る必要があるようにする...ISPは、システムを分離された状態に保ち、リファクタリング、変更、再デプロイを容易にすることを目的としている。

これ はかなり合理的に思える。しかし、原則として述べられているのは、「クライアントは、使用しないメソッドに依存することを強制されるべきではない」(強調は私による)とある。

まず、Rubyのような動的プログラミング言語は、この原則に自動的に準拠していると言ってよい。なぜなら、クライアントが依存しているものの定義は、クライアントが使用しているものだからだ。Rubyは型を定義しないので、ルーチンに渡すオブジェクトがそのルーチンが呼び出すメソッドに応答する限り、コードは「機能する」 1

したがって、JSやRubyのような動的言語で作業している人にとって、この原則は述べられているとおり完全に無意味だ。

とはいえ、Wikipediaの詳細では、別の問題と解決策が提示されている。つまり、クラスにはメソッドを多く含めるべきではない、というものだ。これは、先に話した凝集性に関係するが、同時に別のコアコンセプト、つまり 結合性 にも関係し始める。

ひょっとすると、ひょっとすると、ひょっとすると...実際には結合性に関するものなのかもしれない。

結合性 とは:

...ソフトウェアモジュール間の相互依存の度合い。2つのルーチンやモジュールがどれだけ密接に接続されているかを測る尺度...

密結合のコード、つまり多くの相互依存関係を持つコードは、疎結合のコードよりも悪いと通常考えられている。Wikipediaでは、密結合コードの欠点を次のように概説している。

  • 1つのモジュールを変更すると、通常、他のモジュールにも変更の波及効果が生じる。
  • モジュール間の依存関係が増えるため、モジュールの組み立てにより多くの労力や時間を要する可能性がある。
  • 特定のモジュールは、依存するモジュールを含めなければならないため、再利用やテストが難しくなる可能性がある。

これらはすべて正当な指摘だ。しかし、この原則が求めるような極端なことをすると、トレードオフが生じる。 分離された システムは、個々の部分がシンプルであっても、理解するのが難しい場合がある。密結合で 凝集性の高い コードは、分離されたコードよりもはるかに理解しやすい。

実際、結合性は、ほとんど常に凝集性と一緒に語られる。そこには緊張関係があるからだ。コードを分離したいが、同時に凝集性も高めたい。両方を同時に実現することはできない。バランスを取る必要があり、「常に分離せよ」というアドバイスではそのバランスを見つけることはできない。

設計とは、凝集性と結合性のバランスを取ることだ (原則に盲目的に従うことではない)

例を挙げて、分離を過度に重視すると設計が悪くなる可能性があることを見てみよう。データベース内のウィジェットに関するデータにアクセスするクラスを考えてみる。

public class WidgetRepository { 
  public Set<Widget> find(String query) {
    // ...
  }

  public Widget load(int id) {
    // ...
  }

  public void save(Widget w) {
    // ...
  }
}

このインターフェースには多くの凝集性がある。ウィジェットの検索、ロード、保存はかなりうまく組み合わさっている。しかし、WidgetRepositoryに依存しているにもかかわらず、これらのメソッドの一部しか呼び出さないクラスは、技術的には「使用しないメソッドに依存することを強制されている」ことになる。

この解決策は、実際のプロジェクトに適用されているのを見たことがある。それは、すべてのメソッドを独自のインターフェースにするというものだ。

public interface WidgetLoader {
  pulbic Widget load(int id);
}
public interface WidgetSaver {
  public void save(Widget widget);
}
public interface WidgetFinder {
  public Set<Widget> find(String query);
}

public class WidgetRepository implements 
    WidgetLoader,
    WidgetSaver,
    WidgetFinder { 

    // ...
}

これは、述べられているインターフェース分離原則に、あらゆる面で準拠している。クライアントは、使用しないメソッドに依存する必要はない。findを呼び出すだけでよければ、WidgetFinderに依存する。saveも呼び出す必要がある場合は、WidgetSaverにも依存する。

これは、特にプロジェクト全体に広く適用する場合(原則ではそうすべきだと言っている!)、良い設計ではない。これでは、命名が爆発的に増え、凝集性のある概念を持たない大量のオブジェクトが生まれてしまう。しかし、SOLIDの原則に違反することなく、分離を達成できるのだ!

とはいえ、インターフェースは、結合性と凝集性を評価するためのレンズになるので、それを見てみよう。

インターフェースは、結合性と凝集性の物語を語る

在庫が少なくなっているすべてのウィジェットを再発注する必要があるとしよう。そのロジックは、数量が10未満のすべてのウィジェットをデータベースから検索し、フルフィルメントプロバイダにAPIコールを行うというものだ。

これをWidgetRepositoryに追加してみよう。

public class WidgetRepository { 
  public void reOrderWidgets() {
    for (Widget w: this.find("quantity < 10")) {
      // call the fulfillment API
    }
  }

  public Set<Widget> find(String query) {
    // ...
  }

  public Widget load(int id) {
    // ...
  }

  public void save(Widget w) {
    // ...
  }
}

これは理想的ではないように思える。ウィジェットの再発注は、ウィジェットのデータベースへのアクセスとあまり関係がないので、インターフェースの凝集性が低くなる。また、WidgetRepositoryの利用者は、ウィジェットを再発注できるようになるが、これは望ましくない結合の形態だ。 なぜ 望ましくないのかを正確に説明するのは難しいが、凝集性と結合性の観点から考えることができる。

reOrderWidgetsメソッドは、WidgetRepositoryのインターフェースの凝集性を低下させ、システム内の概念の結合度を高める。ウィジェットのデータベースにアクセスしたいだけのクライアントが、再発注のロジックにも結合してしまう。

これでもよいかもしれない。しかし、そうではないかもしれない。私たちは、この変更案の実際の影響について議論する方法を持っている。そして、これでは良く ない と仮定すると、インターフェースを分離するだけでは不十分だ。全く別のクラスを作りたいと思う。

class WidgetReOrdering {
  private WidgetRepository widgetRepository;

  public WidgetReOrdering(WidgetRepository widgetRepository) {
    this.widgetRepository = widgetRepository;
  }

  public void reOrderWidgets() {
    for (Widget w: this.widgetRepository.find("quantity < 10")) {
      // call the fulfillment API
    }
  }
}

解決策はインターフェースと実装を分離することだったが、述べられているインターフェース分離原則が実際にどのように役立ったのかを理解するのは難しい。代わりに、凝集性と結合性について直接話し合うことで、問題のある設計を回避した。重要なのは、私たちが懸念していた結合は、コードではなく概念的なものだったということだ。もしWidgetRepositoryにウィジェットを削除するための新しいメソッドが必要だったとしたら、それはコードの結合度を高めたことになるが、概念の結合度は高めなかっただろう。

これこそ が設計へのアプローチの仕方だ。どんな犠牲を払ってでも結合度を下げることが正しいやり方ではない。

私のアドバイス: インターフェースを分離することは、結合度を下げ、凝集性を高めるための技術だが、極端に行うと凝集性を下げてしまうこともある。常にそうするべきではない。システムの凝集性と結合性のバランスを取ることに集中すべきだ。

さて、最後の原則である依存性逆転の原則に移ろう。

Footnotes

  1. もちろん、Rubyのような言語では、この原則に従うことは不可能だとも言える。なぜなら、Rubyでは、プライベートメソッドやインスタンス変数を含め、いつでも好きなメソッドを呼び出すことができるからだ。結論としては、Rubyistにとってこの原則は無意味だということだ。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment