社内の勉強会でSOLID原則のI
を担当しまして、せっかくなので一部削除の上公開することにしました。
ですます調になっていないので悪しからず。。。
原則
コードを書く際、そのコード(= クライアント)にとって不要なインターフェースに依存することを強制されないようになっているべきである。
不要なインターフェースに依存していると何が起きるのか?
クライアントが自身の使わないインターフェースに依存していると、それらの不要なインターフェースに対する変更が、そのクライアントにまで影響を及ぼしてしまう可能性がある。
つまり、あらゆるクライアントが意図せず結合してしまうという事態におちいる。
具体例で言えば、仮にあるクライアントが「自身は使わないが他のクライアントは使っている」インターフェースを持つクラスに依存している場合、自分以外のクライアントのためにクラスに加えられた変更によって(無意味な)影響を受けてしまう、ということになる。
また、このような"fat"なインターフェースには、往々にして新たなクライアントに対する新たなインターフェースが追加されてしまい、よりfatになっていく。
さらにこのようなインターフェースを持つのがクラスであった場合、このクラスから派生したクラス(= クライアント)は、自分が使わないメソッドの実装を強いられることになる。
いちいちnil関数を定義するのも無駄な努力だし、仮に抽象クラスなどでnil関数を定義することで都度実装を避けることができたとしても、それはリスコフの置換原則に反しており、つまり保守性と再利用性に欠けるコードとなってしまう。
cf) リスコフの置換原則
サブタイプ(S)のオブジェクトはスーパータイプ(T)のオブジェクトの仕様に従わなければならない。
「仕様に従っている」とは、Tに関して定義された全てのプログラムP(p1(o: T), ..., pn(o: T))について、Sを渡しても動作を変えない(支障をきたさない・代替できる)ということである。
原則により実現されること
インターフェース分離の原則(ISP)を適用すると、巨大なインターフェースをより目的特化の小さなインターフェースに分離することで、クライアントが必要としているメソッドだけを知ることができる状態を作ることができる。
分割されたインターフェースはそれぞれ異なるクライアントで利用されるようになり、インターフェースの変更はそれを必要とするクライアントのみに影響を与えるのみとなる。
ISPでは、凝集度の低いインターフェースを持つ必要のあるオブジェクトの存在は否定していない。
しかし、クライアントがそれを単一クラスとして認識すべきではない、ということを示唆するものである。
単一クラスとして知るのではなく、クライアントは凝集度の高いインターフェースを持った抽象的なベースクラスを知るべきなのである。
この「抽象的なベースクラス」のことを、interfaces
, protocols
, signatures
などの言葉で表すプログラミング言語もある。
インターフェース汚染の例
携帯可能なゲーム機PORTUS(仮)
の例で考える。
ゲーム機には基本的な電源のON/OFFに加え、スリープ機能などが備わっており、ユーザーによるボタン押下(=入力)でそれらを実行できる。
ユーザーによる入力とそれに応じた出力を司るインターフェースが下記のように定義されることになった。
※ そもそもIとOが同じインターフェースなのは良いのか、というツッコミが入りそうですが。。
interface UserIO {
turnOn(): void
turnOff(): void
sleep(): void
showInterface(): void
}
記念すべきPORTUSの入出力v1は、このインターフェースに従って下記のように実装された。
class PortusUserIO implements UserIO {
constructor() {}
turnOn() {
// 起動処理
}
turnOff() {
// 停止処理
}
sleep() {
// スリープ処理
}
showInterface() {
// 画面表示処理
}
}
このゲーム機が人気を博し、外部モニターに接続して大画面で楽しめる高価版PORTUS plus
の発売が決まった。
そのため、ゲーム機の実装にモニター関連のコードが追加される。
interface Monitor {
register(host: MonitorHost): void
}
interface MonitorHost {
isConnected: boolean
onConnect(): void
}
Monitor
は外部モニターを抽象化したインターフェースである。
モニターはホストからの接続リクエストを受けてスリープ状態から復帰・自身にホストを登録し、登録完了後にホストにそれを通知する。
以降はホストから映像情報を常時送信し、モニターでは受け付けてそれを表示し続ける。
つまりこの場合、PORTUS plusがモニターのホストとなる。
そこで、IOにモニター入出力の機能を付加しようと考え、下記のようにUserIO
インターフェースを書き換えた。
interface UserIO extends MonitorHost {
turnOn(): void
turnOff(): void
sleep(): void
}
これに従い、PortusPlusIO
は次のように実装された。
class PortusPlusIO implements UserIO {
constructor(
public isConnected: boolean = false
) {}
turnOn() {
// 起動処理
}
turnOff() {
// 停止処理
}
sleep() {
// スリープ処理
}
onConnect() {
this.startSendSig()
}
private startSendSig() {
// シグナル送信を開始する処理
}
}
この実装には大きな問題がある。
旧来の無印PORTUSには、そもそも外部モニターとの接続口が存在しないので、PortusIO
にもモニター関連の実装は存在しなかったし、未来永劫その必要はない。
しかし、UserIO
がMonitorHost
を継承してしまうことにより、それを実装しているPortusIO
もモニターのホストとしての振る舞いを求められるようになってしまった。
class PortusIO implements UserIO {
constructor(
public isInSleep: boolean = false,
public isConnected: boolean = false
) {}
turnOn() {
// 起動処理
}
turnOff() {
// 停止処理
}
sleep() {
// スリープ処理
}
onConnect() {
// @NOTE: モニター接続できないので実装不要
}
}
無印PORTUSはモニター入出力端子を持たないため、振る舞いの中身も実装のしようがない。
結果、形だけのメソッドが出来上がってしまい、PortusIOを使う側も、本来知る必要のないonConnect
を知ることになり、自身では使うこともないisConnected
フラグをPortusIO初期化時に渡さなくてはならなくなった。
これは、UserIO
がインターフェース汚染されている状態である。
この場合、
-
MonitorHost
のクライアントはMonitor
-
UserIO
のクライアントはPortusIO
またはPortusPlusIO
、もしくは今後開発予定のある新コンソールの入出力系を使う、何かしらのコード
と、それぞれのクライアントが全く異なるものであるにも関わらず、安易にUserIO
にMonitorHost
を組み込んでしまったことがインターフェース汚染の原因となっている。
多重継承による解消
実際にモニターのホストとして動作しうるのはUserIO
の派生であるPortusPlusIO
なので、下記のようになっていれば良い。
class PortusPlusIO implements UserIO, MonitorHost {
constructor(
public isConnected: boolean = false
) {}
turnOn() {
// 起動処理
}
turnOff() {
// 停止処理
}
sleep() {
// スリープ処理
}
onConnect() {
this.startSendSig()
}
private startSendSig() {
// シグナル送信を開始する処理
}
}
この状態であれば、仮にモニター側の要件でMonitorHost
の要件が破壊的に変更されたとしても、影響を受けるのはPortusPlusIO
及びそのユーザーのみとなり、無印PORTUSに対してコードの変更を行う必要がなくなる。
委譲による解消
ここでは詳述しないが、MonitorHost
インターフェースを継承・実装するMonitorAdapter
のような名前のクラスを作成し、そこからPortusPlusIO
の接続開始処理を呼び出す(実際の処理を委譲する)ような形にすることで、インターフェースを分離することもできる。
- MonitorAdapter implements MonitorHost
- PortusPlusIO implements UserIO
各々は上記のように定義され、
class MonitorAdapter implements MonitorHost {
constructor(
public io: PortusPlusIO,
public isConnected: boolean = false
) {}
onConnect() {
this.io.startSendSig() // 具体的な処理を委譲する
this.isConnected = true
}
}
のような関係性となる。