依存性の逆転について
現代のプログラミングでは Plugin という仕組みや Polymorphism が一般的になっているため + 動的型付け言語を使用してきたため、依存性の逆転 が具体的になにを解決しているのかよくわかっていませんでした。なにしろ雰囲気でプログラミングをやってきたので。
現在 Clean Architecture を少しずつ読んでいます。読みながら極端に悪い依存性を持つ状態をイメージし、そこに依存性の逆転が持ち込まれると何が解決されるのかを見ることによってようやく理解できました。
以下は自分が理解するために書いたものです。
実行処理の上流の module が下流の module の実装に依存する状態
実行処理の流れがそのまま依存の流れになっている (依存先の詳細を知らなければならない) 状態です。A -> B -> C という順に処理が呼び出される場合、A は B に依存し、B は C に依存するという構造です。
例では Processor
は上流からの呼び出しに従い任意の device
を使って read
と write
を行います。
極端に悪い例
Mic
、Tablet
、Terminal
が公開しているメソッド名が統一されていないため、使う device
によって処理を切り替える必要があります。「依存先の詳細を知らなければならない」状態の悪さがわかると思います。
import Mic from './Mic';
import Tablet from './Tablet';
import Terminal from './Terminal';
export default class Processor {
device: Mic | Tablet | Terminal;
constructor (device: Mic | Tablet | Terminal) {
this.device = device;
}
read (): string {
switch (this.device.constructor) {
case Mic:
return (this.device as Mic).listen();
case Tablet:
return (this.device as Tablet).write();
case Terminal:
return (this.device as Terminal).in();
default:
return '';
}
}
write (s: string) {
switch (this.device.constructor) {
case Mic:
(this.device as Mic).say(s);
return;
case Tablet:
(this.device as Tablet).display(s);
return;
case Terminal:
(this.device as Terminal).out(s);
return;
default:
//
}
}
}
Plugin 化された例
メソッド名を統一することで随分すっきりしたコードになりました。
import Mic from './Mic';
import Tablet from './Tablet';
import Terminal from './Terminal';
export default class Processor {
device: Mic | Tablet | Terminal;
constructor (device: Mic | Tablet | Terminal) {
this.device = device;
}
read (): string {
return this.device.in();
}
write (s: string) {
this.device.out(s);
}
}
しかし、未だに下流の module に依存している状態です。Mic
、Tablet
、Terminal
のいずれかが変更されると、Processor
のコンパイルしなおしが必要になってしまいます。
依存性の逆転
そこで Mic
、Tablet
、Terminal
が Processor
用の Interface IDevice
に依存するように変更します。つまり、下流の module が上流の module の実装に依存するように、処理の流れとは逆にするということです。
export default interface IDevice {
out (s: string): void
in (): string
}
import IDevice from './IDevice';
export default class Processor {
device: IDevice
constructor (device: IDevice) {
this.device = device;
}
// snip
}
import IDevice from './IDevice';
export default class Mic implements IDevice {
out (s: string): void {
}
in (): string {
}
}
(他の Device は省略)
これですべての module が独立してコンパイルできるようになりました。また何か新しい Device が導入されたとしても、その Device は IDevice
を実装すれば Processor
にはなんの変更もせず使用することができます。
Architecture の観点から
この依存性の逆転はソースコードの依存性を一方向性を解決し、依存の方向をどちらへも向けることを可能にします。
Clean Architecture では、システム/ビジネスのポリシーやルールに基づいて、Open-Close の原則の方向を決めます。
そこでこのソースコードレベルの依存性によらない自由な依存の方向が必要になります。
まとめ
実際、Ruby などの動的型付け言語では plugin 化の段階ですでに依存性の逆転は達成されています。Duck Typing を満たす限りどんな Device も Processor
で使用できるからです。
依存性の逆転について、粗結合に優れるコードを書くための概念としては把握していました。しかし今回、極端に悪い例と実際に逆転を意識しないと実現できないコードを知ることにより、具体的に何を解決していたかを知ることができました。