SOLID原則

SOLID原則について自分なりに記事を書くことで少しでも自分に定着できるようにしたいなと思い✍ なんとなくわかっているが人に説明するときに毎回考えてしまう ※個人的な解釈です

SOLID原則

オブジェクト指向における5つの原則

原則: f:id:toshizabeth:20210125094216p:plain

なので実装上特別な場合は守る必要はないが、適用すると利益となる場合がある。

そもそもオブジェクト指向とは変更に柔軟に対応できるようにするため。

この記事がとてもわかり易い! qiita.com



SOLID原則に則ったオブジェクト設計をすることで変更に柔軟で堅牢性が高いクラスが出来上がるわけですね

原則の種類

  • 単一責任原則
    • Single Responsibility Principle
  • オープン・クローズド原則
    • Open Closed Pronciple
  • リスコフの置換原則
    • Liskov Substitution Principle
  • インターフェイス分離の原則
    • Interface Segregation Principle
  • 依存性逆転の原則

単一責任原則

  • クラスを変更する理由は一つであるべき

割と単純な言い方をすると、あるクラスが負うべき責任(役割)は一つがいいよね。そのほうが後々 予期せぬバグを踏まなくなったり、クラスの肥大化が防げる。

経験ですが、一つのクラスに複数のことをさせようとするとそのクラスの複雑性が増し(多重責任、密結合)、クラスが肥大化することで見落としによるバグが出る可能性が高い。

役割はできるだけ少なくすることは良いことが多く、一つのクラスに多重の責任を押し付けるより役割分クラスを作成するほうが良。

プログラムを始めたときは一つのクラスに押し込めがちだったが、経験を重ねるうちに 「役割毎にクラス作成するためにはどうすればいいだろうか。どう機能を切り離したらいいかな..」 と考えるようになった

これは自然と「多重責任」が「バグ」につながることが多く、危機管理能力が高まったからだと思う

オープン・クローズド原則

  • (主にクラス)は拡張のために開いていて、修正のために閉じているべき

新しく機能を追加するときを想定して、

既存のコードに手を加えず、新しい機能が追加できるようになっているべき

オブジェクト指向で重要なinterfaceを使用したプログラミングをすると自然に達成している。

// アイテムを使用する
public void UseItem(string name, int id)
{
    switch (name)
    {
        case "たべもの":
            // 情報を取得して、HP回復する
            var foodData = FoodRepository.Find(id);
            AddHP(foodData.Value);
            return;

        case "ポーション":
            // 情報を取得して、MP回復する
            var portionData = PortionRepository.Find(id); 
            AddMP(portionData.Value);
            return;
    }
}

アイテムを使用するメソッドを書くとき、アイテムの種類分switchで分岐して書くことで新しいアイテムタイプが追加されたときに記述漏れが発生する可能性が高く、そして縦にずらずらっと並べられると人間不具合が発生する箇所を見落としがちになります

public void UseItem(IUsableItem item)
{
    // アイテム効果を適用させる
    item.Apply();
}

例としては単純すぎますが、言いたいこととして

なにか機能を追加したいときに既存のクラス、メソッドを追加する形にはせずに

インターフェースやベースクラスを使用してアイテムの種類を増やす箇所と、使用する箇所を依存しない形にするべき。

機能を使用する箇所は使用する処理を書くだけ!

使用者は対象が実際に何かは知らなくて良く、機能だけ使いたい部分はインターフェースをうまく使用できている所ですね

「インターフェースってなんのために必要なんだ」と学生の頃は思ってましたが、当時でもオープン・クローズド原則を学んでいれば「なるほど〜」とすぐに理解できたと思います

処理が並ばずに冗長さがなくなるのも精神衛生的にいいですね

リスコフの置換原則

  • 基底クラスとそれを継承したクラスが存在するとき、プログラム内で基底クラスが使われるところはすべて継承クラスに置き換えることもできる(置換)べき

これを達成するために事前条件と事後条件のルールが有るわけですね。

  • 事前条件 : 処理の前に決められている条件。

事前条件が基底クラスより継承クラスのほうが強い条件となってはならない。

例えば

// 継承元クラス
public class Base
{
    public virtual void Hoge(int value)
    {
        // 0 より大きい値だったらOK
        assert(value > 0);
...
    }
}

// 継承先クラス
public class Test : Base
{
    public override void Hoge(int value)
    {
        // 0より大きい数で100より小さい数じゃなきゃだめ
        assert(value > 0 && value < 100);
...
    }
}

Hoge に対してBaseだと0以上の数値であればどれでも大丈夫だったが、Testでは100未満という制限が入ってしまった(条件が強くなった)

これだと Base を Test に置き換えると挙動が変わってしまう(assertに引っかかる)恐れがあるので原則としては成り立たないということになります。

  • 事後条件 : 処理の後に決められている条件。

事後条件が基底クラスより継承クラスのほうが弱い条件となってはならない。

例えば

// 継承元クラス
public class Base
{
    public virtual void Hoge(int value)
    {
        int result;
...

        // 結果、0より大きい数で100より小さい数じゃなきゃだめ
        assert(result > 0 && result < 100);
    }
}

// 継承先クラス
public class Test : Base
{
    public override void Hoge(int value)
    {
        int result;
...
        // 結果、0 より大きい値だったらOK
        assert(result > 0);
    }
}

今度は処理が終わった後に、基底クラスの方は 1 ~ 99 までの値しか許可されていなかったのに対し、継承クラスは 1 ~ の数値なら何でもOKになっています。

この場合、継承元が守らなければならなかった事後条件が破綻していることになるので、原則に則っていない。ということになる。

結果何を言いたいかというと、

正しい継承関係になっているかどうか。継承して拡張すべきクラスとして正しいのか?という指標の一つとして考えられるものなのかなと思います。

上の例は明らかに継承としてはおかしい。

もう一つ、Baseを継承させたTest2というクラスがあり、こちらの事前条件は Base と同じ。

// 継承先クラス
public class Test2 : Base
{
    public override void Hoge(int value)
    {
        // 0 より大きい値だったらOK
        assert(value > 0);
...
    }
}

このTest2と上のほうにかいた事前条件を強めたTestクラスがあるとき

Base baseA, baseB;

baseA = new Test();
baseB = new Test2();

baseA.Hoge(1000); // assertにひっかかる!
baseB.Hoge(1000); // OK

このように baseA と baseB それぞれ渡した数によってエラーが起きる。

型によって特殊な処理をしなければならなくなると、

if (baseA is Test)
{
    // Testクラスの場合...
...
}

といった条件分岐が出る可能性が高い。

「継承元クラスをそのまま継承先クラスで置き換えてもバグが起きないことを保証してね」ということも事前条件、事後条件というのを考えてみるとなるほど納得。

後、派生型が出すことができる例外は基底型が出す例外、もしくはその例外を継承した例外であるべき。のようです。

依存性逆転の原則

  • 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。どちらのモジュールも抽象に依存すべきである。

上位レイヤー → 下位レイヤーを扱う人

下位レイヤー → 上位レイヤーの人に使われる人

抽象は、実装の詳細に依存すべきではない。

実装の詳細が、抽象に依存すべきである。

ということらしいです。

例えば下位レイヤーがファイル読み込み/書き込みの操作をするクラスとして、上位レイヤーはそのファイル読み書きを利用する人。

// 上位レイヤー
public class Hoge
{
    private FileReader _fileReader;

    public void Fuge()
    {
        _fileReader.Read();
...
    }
}

// 下位レイヤー
public class FileReader
{
    public void Read() { ... }
}

適当にこのようなクラスがあるとき、HogeはFileReaderに依存している。 FileReaderはHogeはしらない。と言える。

この状態では例えばファイルではなくメモリから直接データを読み取ることになったとき ( MemoryReader )に実装の改修が必要になる。

private FileReader _fileReader;
private MemoryReader _memoryReader;

public void Fuga()
{
    if (/* FileReader を使用するとき*/ )
    {
        _fileReader.Read();
    }
    else
    {
        _memoryReader.Read();
    }
}

とするのはおかしい。

処理を拡張するたびに変更が入るのでオープン・クローズドの原則にも違反する。

原則にある 抽象に依存すべきである。 と言う通り、抽象(interface)を利用してやる

//
// Hoge側
//

// IReader は Hogeクラス側にかかれているもの
public interface IReader
{
    void Read();
}

public class Hoge
{
    private IReader _reader;

    public void Fuge()
    {
        _reader.Read();
...
    }
}

.....

//
// Reader側
//

public class FileReader : IReader
{
    public void Read() { ... }
}

public class MemoryReader : IReader
{
    public void Read() { ... }
}

Reader側がHogeのIReaderに依存する

使用者(Hoge)のIReaderに File&Memory(使用される側)が依存する。

Hoge → File&Memory で依存していたのが Hoge.IReader ← File&Memory と依存が逆転している。

使用者が使用される側に依存する考えから、使用される側が使用者に依存する(インターフェイスを使う)という考えにする。

あくまで使用者(上位レイヤー)が決めた実装に対し、使用される側(下位レイヤー)が従うこと。

そのために上位レイヤーのインターフェイスを継承する(依存性の逆転)

インターフェイス分離の原則

インターフェイスを利用することで仕様側は実装の詳細を知らなくても良くなる。

結果、クラス間の依存性を弱め、疎結合になる。

インターフェイスに定義されているメソッドは継承先のクラスはすべて実装しなければいけなくなりますが、そこで 不必要なものはいれないようにしよう ということ

三者インターフェイスを見たときに インターフェイスの役割 と 必要な実装 がわかることが正しいインターフェイスの形

一つのインターフェイスを肥大化させるのではなく、機能毎にインターフェイスを分割すること。そしてそのインターフェイスが正しい名前がついていることが良い。

利用する側としてもVisualStudioのインテリセンスで必要なメソッドだけ推測して出してくれたほうが使いやすい。

先程のリソースを読み込むReader関連も

  • ファイルを開く/閉じる : FileOpen/Close
  • 開いたファイルを読み込む : Read

とinterfaceを分けてるほうがそれぞれの「責務」がはっきりしており使用者としてもミスが少なくなりますね。


まとめ

頭の片隅においておいて実装するときに気をつけておけば、一段レベルアップした設計ができるようになる。

ゲームプログラミングにおいても適用できるし、もちろんUnity使用時にも同様。

しかし、原則はあくまで原則。

守るべきところは守るとよいが縛られすぎて「時間がかかりすぎる」「複雑になりすぎる」ようであれば則らないというのも問題ないところ