「依存性逆転の原則」の自分なりの解釈
「依存性逆転の原則(DIP:Dependency Inversion Principle)」に関して考える機会があり、自分なりに言語化できそうなのでまとめます。ご指摘は大歓迎です。
目次
TL;DR
- より安定したモジュールの方向へ依存するよう設計し、ソフトウェアの安定性を高めることが重要
- 不安定なモジュールが 依存されている と修正範囲が大きくなるため、 依存されない ようにしたい。そのために、依存方向を逆転させたい
- DIPは「モジュールへの依存方向は逆転可能」ということを示し、「その方法」を解説している
考えるきっかけ
先日行われた 第1回 Clean Architecture 読書会 に参加しました。
その後に、以下のツイートをしています。
DIP完全に理解した
— at_grandpa (@at_grandpa) 2018年12月20日
例えば、「開放閉鎖原則」と「依存性逆転の原則」って、本質的に同じでは?(要出典
— at_grandpa (@at_grandpa) 2018年12月20日
これをきっかけに、 @hidenorigoto さんがブログを投稿されています。
違うと思います。DIPはOCPを達成するためのHow。 https://t.co/C4HiwMSOe8
— Hidenori Goto (@hidenorigoto) 2018年12月20日
@77web @at_grandpa @kenji_s @okapon_pon DIPについて、今の私の考えをまとめてみました〜。よろしかったらフィードバックください!
— Hidenori Goto (@hidenorigoto) 2018年12月23日
この流れに乗って、もう一度 DIP について考えてみました。
具体例から紐解いていく
具体例を用いてDIPを紐解いていきます。よくある例として「何らかの処理をしてDBに保存する」というモジュールを考えてみます。
べた書きのモジュールA
簡単な例をCrystalのコードで示します。(擬似コードなので、実際には動きません)
sample.cr
module PackageA class A def initialize @db = DB.connect end def execute # いろんな処理 ... @db.transaction do |db| db.delete(...) db.insert(...) end end end end PackageA::A.new.execute
class A が PackageA に属しています。簡単な図に表すとこのようになります。
このままではロジックとDB周りの処理が混在していて保守性が高くないので、DB部分をモジュールBに抽出しましょう。
モジュール分割による依存性の出現
DB部分をモジュールBに移動しました。
sample.cr
module PackageB class B def initialize @db = DB.connect end def update(data) @db.transaction do |db| db.delete(...) db.insert(...) end end end end module PackageA class A def initialize(@b : B) end def execute # いろんな処理 ... @b.update(data) end end end PackageA::A.new(PackageB::B.new).execute
パッケージやクラスを図に表すと以下になります。
移動した結果、依存性が現れています。今回の場合は「AがBに依存している」となります。AがBのメソッドを参照しています。
依存性には方向があるので A→B
と表すことにします。図にも赤矢印で示しました。これは以下の状態を意味します。
- Bを修正したり交換したりすると、Aを修正しなければならない
- Bのメソッド名や返り値など「外部に公開している部分」を修正した場合や、BをB'に交換した場合、Aも修正しなければならない
- Aを修正したり交換したりしても、Bは修正しなくて良い
- Aのメソッド名や返り値など「外部に公開している部分」を修正した場合や、AをA'に交換した場合でも、Bは修正しなくて良い
確かに、依存性には方向があることがわかります。
モジュールの安定性
ここで「モジュールの安定性」について説明します。安定性とは、先程の @hidenorigotoさんの記事 にもあったように「修正されにくい度合い」です。プログラミングをしているといろんなモジュールを作成しますが、頻繁に修正が入るモジュールがあったり、数年修正していないモジュールがあったりします。ここに安定性の差があります。
安定依存の原則 (SDP: Stable Dependencies Principle)
設計原則の一つに「安定依存の原則」があります。「依存の方向は、より安定した方向に向かわなければならない」というものです。実装を進めているとモジュール間の依存は必ず発生します。その方向を安定したモジュールに向かうようにすることで、ソフトウェア全体の修正頻度・修正範囲が低く小さく抑えられるというものです。安定依存の原則に従えば、ソフトウェアの安定性を高めることができます。
例えば、 安定した Stableモジュール:S
と不安定な Unstableモジュール:U
があったとき、
- 依存関係が
S→U
の場合(不安定方向に依存)- Sは安定しているので頻繁に修正・交換はされない
- Uは不安定なので頻繁に修正・交換される。このとき、Uに依存しているSも修正が必要
- 依存関係が
U→S
の場合(安定方向に依存)- Sは安定しているので頻繁に修正・交換はされない
- Uは不安定なので頻繁に修正・交換される。このとき、Uは依存されていないので、他のモジュールに影響を与えない。修正範囲は少なくて済む
となります。後者のほうが修正の範囲や頻度が少なくなります。安定方向に依存することで、こういったメリットがあります。
今回のケースで考えてみましょう。依存の方向は A→B
でした。Aが不安定な場合とBが不安定な場合それぞれを考えてみます。
- Aが不安定な場合
- 依存の方向は
A→B
なので、Aの修正・交換が頻繁に行われようとも、Bの修正は不要
- 依存の方向は
- Bが不安定な場合
- 依存の方向は
A→B
なので、Bの修正・交換が頻繁に行われると、Aの修正も必要になる
- 依存の方向は
前者の場合、既にソフトウェアとして安定しているので問題ありません。しかし後者の場合、この設計はまずそうです。今はデータストアがDBですが、「S3に保存したい」や「CSVに保存したい」という要求があった場合、その都度Aを修正しなければなりません。これは依存の方向が不安定な方向に向いていることが原因です。この方向を逆転し、安定したモジュールへ向くようにできれば、修正の範囲や頻度を抑えることができます。
では、「依存方向の逆転」を検討してみましょう。
間違った「依存方向の逆転」
A→B
を B→A
にしたいです。現在の依存関係は「AがBのメソッドを参照している」という「参照関係」です。これを逆転させるということは「BがAのメソッドを参照している」ということでしょうか。やってみましょう。
sample.cr
module PackageB class B def initialize(@a : A) @db = DB.connect end def update(data) data = @a.execute # ここでAのメソッドを参照している @db.transaction do |db| db.delete(...) db.insert(...) end end end end module PackageA class A def execute # いろんな処理 ... end end end # 変更前の記述 # PackageA::A.new(PackageB::B.new).execute PackageB::B.new(PackageA::A.new).update
- BのコンストラクタでAのオブジェクトを受け取るようにした
- Bの
update
メソッドでAのexecute
メソッドを参照し、データを受け取るようにした - メインの処理で、Bの
update
を呼ぶようにした
これで B→A
の依存関係を構築できました。Bをどんなに修正しようともAは修正不要です。しかし、何か違和感を感じます。この違和感は「DBという低レイヤーのモジュールが、ビジネスロジックの中心に存在している」ということが原因です。メインの処理ではBのupdate
を呼んでいますが、何をupdateしているのかわかりません。本来であれば、ビジネスロジックの中心であるAの execute
がメインの処理で呼ばれ、そのロジックの中で最後に「DBの更新」が行われるべきです。
つまり、無理やり依存性を逆転させたことで、本来のモジュールの関係(依存関係ではなくドメイン的な関係)を壊してしまったのです。これは良い設計とは言えません。
以上のことから、本来実現したいことは以下のようになります。
- 安定方向に依存させるために、依存性を逆転させたい
- AとBのドメイン的な関係は壊したくない
これを実現させる方法がDIPで説明されています。やってみましょう。
DIPに基づいた「依存方向の逆転」
DIPに基づいた「依存方向の逆転」は、インターフェースや抽象クラスを用いて実現します。Crystalでは抽象メソッドを用います。以下が実装です。
module PackageB # PackageAで定義した抽象クラスを継承 class B < PackageA::Datastore def initialize @db = DB.connect end def update(data) @db.transaction do |db| db.delete(...) db.insert(...) end end end end module PackageA # 抽象クラスを定義 abstract class Datastore abstract def update(data : Array(String)) end class A def initialize(@datastore : Datastore) end def execute # いろんな処理 ... @datastore.update(data) end end end PackageA::A.new(PackageB::B.new).execute
パッケージとクラスの関係は以下のようになりました。
ここで注目するのは、プレイヤーが3つ出てきたことです。PackageA::A
、PackageA::Datastore
、PackageB::B
です。それぞれの依存関係を整理しましょう。
- 依存関係
PackageA::A → PackageA::Datastore
- Datastoreを修正したり交換したりすると、Aを修正しなければならない
- 参照依存
- Datastoreのメソッド名や返り値など「外部に公開している部分」を修正した場合や、DatastoreをDatastore'に交換した場合、Aも修正しなければならない
- Aを修正したり交換したりしても、Datastoreは修正しなくて良い
- Aのメソッド名や返り値など「外部に公開している部分」を修正した場合や、AをA'に交換した場合でも、Datastoreを修正しなくて良い
- Datastoreを修正したり交換したりすると、Aを修正しなければならない
- 依存関係
PackageB::B → PackageA::Datastore
- Datastoreを修正したり交換したりすると、Bを修正しなければならない
- 継承依存
- Datastoreのメソッド名や返り値など「外部に公開している部分」を修正した場合や、DatastoreをDatastore'に交換した場合、Bも修正しなければならない
- Bを修正・交換しようとしたりしても、 Datastoreを継承している以上、インターフェースは修正できない
- これは制約
- Datastoreを修正したり交換したりすると、Bを修正しなければならない
ここでとても大切なことは、安定依存の原則に従うなら、A,Bに依存されている抽象クラス PackageA::Datastore
は 非常に安定していなければならない ということです。不安定だった場合、AにもBにも修正が頻繁に発生してしまうので、良い設計とは言えません。慎重に定義しなければならない箇所です。
次に注目すべきは、不安定モジュールBへ伸びる依存の矢印です。今取り組んでいることは「Bが不安定だ。それが依存されている。依存性を逆転させてBが依存されないようにしよう。」というアプローチです。抽象クラスを用いた上記の設計によって、Bは依存されなくなりました。結果、(抽象クラスを継承した上で)Bをどんなに修正しても、AやDatastoreの修正は不要です。また、抽象クラスを継承していれば、B'やB''、またはCに変更したとしても、AやDatastoreの修正は不要です。Aが依存しているのは安定したDatastoreであり、それ以外のクラスが修正されようとも関係はありません。Datastoreは、BやB'が継承している以上、修正されることはありません。
DIPを語るとき「何が『逆転』なのか」という話題が挙がりますが、上記のことから考えると以下のように言えるのではないでしょうか。
- もともとは依存の矢印が不安定モジュールに向かっていたが、設計を工夫することでその矢印を逆転でき、不安定モジュールが依存されないようにできる
話題の中心は常に「不安定モジュール」です。依存の矢印が不安定モジュールに向いていることを避けなければならないのです。だからその矢印を逆転させよう、というアプローチです。この矢印さえ逆転できれば、依存元だったモジュール(今回はPackageA::A
)の設計が多少変わろうと(PackageA::Datastore
が追加されるなど)関係ないのです。依存の方向を逆転でき、不安定モジュールが依存されないようになった!それだけで万々歳です。
今の自分は、この言語化に納得しています。
【追記 2018/12/27 12:04】
「Clean Architecture 達人に学ぶソフトウェアの構造と設計」の「第11章 DIP:依存関係逆転の原則」においては、
図中の曲線を横切る処理の流れは、ソースコードの依存性とは逆向きになることに注意しよう。ソースコードの依存性と処理の流れは逆向きになる。だからこそ「依存関係逆転の原則(DIP)」と名付けたのである。
Robert C.Martin; 角 征典; 高木 正弘. Clean Architecture 達人に学ぶソフトウェアの構造と設計 (アスキードワンゴ)
と記されていました。「処理の流れ」を「モジュール間のドメイン的な関係」と表現すれば、今回の話と一致するので納得できます。しかし、「処理の流れと依存性が逆向きである」ということから「依存関係逆転の原則」と名付けるのはしっくりきていません。あくまで「依存の方向を逆転する」ことが本質であり、処理の流れはそこに関係はないと考えています。結果的にそうなっているだけだと考えています。このあたりも、議論の余地はあると思います。
【追記終わり】
まとめ
冒頭のTL;DRを多少追記して再掲します。
- より安定したモジュールの方向へ依存するよう設計し、ソフトウェアの安定性を高めることが重要
- 不安定なモジュールが 依存されている と修正範囲が大きくなるため、 依存されない ようにしたい。そのために、依存方向を逆転させたい
- 逆転できれば良く、相手側のモジュールは多少設計が変更されても構わない
- DIPは「モジュールへの依存方向は逆転可能」ということを示し、「その方法」を解説している
当初、DIPを知ったときは「逆転させたら良い設計なのかー」と単純に思っていました。しかし、いろいろ試行錯誤し、実際にコードを書いて試してみると、そうではないことがわかりました。そもそもなぜ依存を逆転させるのか?何が問題なのか?なんのためなのか?というところから始まり、調査していくとソフトウェアの安定性が関係していることに行き着き、安定性を高めるためには依存の方向が重要であると体験し、不安定モジュールに向かう依存方向を逆転させる方法がDIPであると気づきました。一律に「DIPをすれば良い設計」ではなく、場合によっては使わない場面もある、ということもわかりました。
今回の考察にあたり、下記のブログやツイート、サイトを参考にさせていただきました。知の高速道路を走らせていただきました。ありがとうございました。特に @hidenorigoto さんの記事は、考察のあとにもう一度読んだところ「最終的な結論は同じ形」になっており、自分の考えにより納得できることとなりました。
「書き換え可能性がDIPの本質」「ソフトウェア構造全体としての安定性を達成する」がすごくしっくりきました。読書会でDIPを常に適用するのか?という質問に対し、バランスを取るのが大事(必ずしもそうではない)+安定依存の原則を紹介したのですが、後藤さんのまとめが端的に表していると思いました
— おかぽん (@okapon_pon) 2018年12月23日
この記事は自分の解釈のまとめなので、間違っている場合も多々あります。その際は、ぜひご指摘いただきたいです。最近は設計について議論することが多くなってきましたが、この考察も何かの議論のネタになったら嬉しいです。 @at_grandpa までメンションください。より良いソフトウェアを実装できるように、どんどん知識をブラッシュアップしていきたいです。