SOLID原則の一つ、リスコフの置換原則について説明します。
#リスコフの置換原則とは
名前からなんだか難しそうだけど、実例を踏まえて見てみるとそんなことはないです。
リスコフの置換原則は要するに
- 基本型Base、Baseの派生型Derivedがあるとする。
- Baseとして扱ってるものはBaseでもその派生型であるDerivedであっても扱えるようにすべき。
- 将来的にSpecificDerivedが作られても扱えるようにすべき
- Derivedの派生クラスDerivedDerivedが作られても扱えるようにすべき
たったこれだけです。
実例を踏まえて解説してみます。
#違反例その1
public void Func(ISampleInterface sample)
{
var sample1 = sample as ConcreteSample1;//やばい!
if(sample1 != null)//特定の処理に限定している!よくない!
sample1.Hogehoge();
}
サンプルではISampleInterfaceというインターフェイスを引数としています。
実装者はISampleInterfaceのメンバではなく、それを実装したConcreteSample1クラスが持つHogehogeを使いたかったので、一度ConcreteSample1に変換をしてから、Hogehogeを使いましたとさ。
何がいけないのか?
Funcを使う人の視点に立ってみるとわかります。
instance.Func(test);
このFuncはConcreteSample1インスタンスありきのメソッドのため、使用者はConcleteSample1の存在を知りつつFuncを使わないといけません。
コメントででも書かれてないと別のインスタンスを入れると別の挙動をすることが使用者にはわからないです。
※コメントに書くのもやめましょう。静的にコンパイラによって判断してもらえることが最強のエラー対策です。開発者全員がFuncのことに想いを馳せつつ開発するのはよくないです。
また、長期的に考えて、ConcreteSample2というクラスが作られたとします、その時
Funcに機能が不足していると、今現在の実装から違反例その2のようなことをしがちです。
#改善してみる
public void Func(ConcreteSample1 sample1)
{
sample1.Hogehoge();//Hogehoge実行するだけならFuncいらねーじゃんっていう野暮な突っ込みは期待してない。現実にはもっと複雑なケースがあるのだ。
}
sample1がConcreteSample1に対して決め打ちしているのなら引数はConcreteSample1にすべきです。
使用者もConcreteSample1を渡すことがわかっていれば最初から渡します。
#違反例その2
public void Func(ISampleInterface sample)
{
var sample1 = sample as ConcreteSample1;
if(sample1 != null)
sample1.Hogehoge1();
var sample2 = sample as ConcreteSample2;
if(sample2 != null)
sample2.Hogehoge2();
var sample3 = sample as ConcreteSample3;
if(sample3 != null)
sample3.Hogehoge3();
}
このような実装に至るのは私が考えるに2パターンぐらいあって
- もうすでに違反しているんだから仕方ないし付け足しとけ
- クラスはわかりやすく分類するためだけに存在するという認識で、実際に処理が分岐しているのはFuncなのでそこで分岐させることを思いついた
2つ目のパターンですが例としてデスクトップ上にあるオブジェクトを想像してみてください。
要件としてはダブルクリックした場合オブジェクトに応じて以下のことをします
- フォルダならその中のものを表示する
- ファイルならそのファイルに紐づいているアプリケーションで開く
こういう要件が与えられた際に、以下のことをすることを思いついてませんか?
public void DoubleClick(IObject obj)
{
var folder = obj as Folder;
if(folder != null) folder.Open();
var file = obj as File;
if(file != null) file.LaunchApplication();
}
現実のプロジェクトは後から要件を追加されることもあります。ということでフォルダに対して仕様を追加してみます。
- デスクトップ上にあるものを単純に開いた場合、新しいウィンドウを開き中身をそのウィンドウに表示する
- ウィンドウモードでデスクトップを開いている場合はそのウィンドウの表示を更新して中身を表示
public void DoubleClick(IObject obj)
{
var folder = obj as Folder;
if(folder != null)
{
if(/*ウィンドウで開いてるフォルダーをダブルクリックした*/)
folder.Open();
if(/*デスクトップで見えているフォルダーに対してダブルクリックした*/)
LaunchExplorer(folder.AbsolutePath);
}
var file = obj as File;
if(file != null) file.LaunchApplication();
}
さて、また新しく要件が追加されました。新しく追加されたショートカットというオブジェクトは、何か別のオブジェクトに紐づいていて
ダブルクリックの動作はそのオブジェクトの挙動と同じ挙動を取ります。
public void DoubleClick(IObject obj)
{
var folder = obj as Folder;
if(folder != null)
{
if(/*ウィンドウで開いてるフォルダーをダブルクリックした*/)
folder.Open();
if(/*デスクトップで見えているフォルダーに対してダブルクリックした*/)
LaunchExplorer(folder.AbsolutePath);
}
var file = obj as File;
if(file != null) file.LaunchApplication();
var shortcut = obj as Shortcut;
if(shortcut != null) shortcut... //やべーどうすんだ!
}
こうなってくるとDoubleClickというメソッドはとてつもなく長い処理になります(いわゆる神メソッドというやつです)。
しかし、その中で行っているのはおおかた条件分岐であり、ほぼほぼ関係のない処理の割合が増えることになってます。
テストすることを考えてみましょう。
DoubleClickというメソッドをテストする際に一体テスターはどれだけの準備が必要になるだろうかと。。。
#改善してみる
今回の場合は引数を変更する場合は使えません。そういう時はそもそもそFunc内部での条件分岐はそこでする必要があったのか?を考えてみると解決するかもしれないです。
実際の挙動が確定するのはもっと前の段階で、それはインスタンスが生成されるときなのです。
ファクトリを定義する
class SampleFactory
{
private SampleConfig config;
public AbstractFactory(SampleConfig config)
{
this.config = config;
}
public ISampleInterface GetInstance()
{
switch(config.ConcreteType)//分岐は生成時の一度っきりでいい
{
case ConType.Type1: return new ConcreteSample1();
case ConType.Type2: return new ConcreteSample2();
case ConType.Type3: return new ConcreteSample3();
}
return NullSample.Instance;//何にも該当しない場合もNullを返すとややこしくなるのでNullObjectパターンを作っておくと優しい。
}
}
実装は例その1と同じようにスリムになります
public void Func(ISampleInterface sample)
{
sample.Hogehoge();
}
実際に使ってみる
var sample = factory.GetInstance();
instance.Func(sample);
こうすることでDoubleClickの例で言うと、一つ一つのテストが1クラスにまとまってやりやすくなったと思います。
#最後に
と、ここまで熱弁したものの必ず改善しなければいけないというわけではないです。
これを改善するために例えば修正が多岐にわたるようであるとかならば、課題にして後回しにするという判断も時には有効です。
ですが、この実装を続けるとプログラムはガタガタになっていきます。
モチベーションも下がる一方で、開発生産性も非常に悪くなっていきます(先の例で言うとショートカットが仕様として追加された時点で、変更をしなければいけない箇所を特定するのに時間がかかります)。
そうならないために、開発者は日々いいコードと悪いコードを探求する心が大事ですよね。