コンテンツへスキップ

【C#】SOLID原則を学ぼう

  • C#

今回の記事はオブジェクト指向プログラミングにおける設計の基本、「SOLID原則」について。

ある程度プログラミングの文法を知っていれば、動作するコードを書くことは可能です。しかし、より良いコードを書きたいのであれば、文法の知識だけではなく、設計に関する知識も必要になってきます。

特にUnityでは、適当にコードを書いていくと目も当てられないようなスパゲッティーコードが容易に出来上がります。「とりあえずシングルトンにすりゃいいや!」みたいなノリで「何とかManager」クラスを作りまくった結果、「あれ?この処理どこに書いたんだっけ?」という状況になったこと、誰しも一度はありますよね…?

今回は、そんなクソk…良くないコードを書かないための設計原則である「SOLID原則」について紹介します。記事内のコードはC#で記述しますが、言語に関わらずSOLID原則は広く応用の効く考え方なので、是非とも覚えておくことをお勧めします。この原則に従っていれば、少なくとも先ほど挙げたManager地獄のような状況に陥ることはなくなるはずです。

SOLID原則とは

SOLID(ソリッド)は、ソフトウェア工学の用語であり、特にオブジェクト指向で用いられる五つの原則の頭字語である。ソフトウェア設計をより平易かつ柔軟にして保守しやすくすることを目的にしている。その特徴はインターフェースを仲介にしての機能の使用と、インターフェースによる機能の注入である。”

要するにSOLID原則とは、オブジェクト指向プログラミングにおいて「こういう風に書くと綺麗で保守性の高いコードが書けますよ」というルールのようなものを5つまとめたものです。それら5つの原則から頭文字をとって「SOLID」と呼ばれます。

基本的には上の説明にもあるとおり、オブジェクト指向言語の機能である「インターフェース」などの抽象化の機能を使って、より柔軟で保守性の高いコードを書くことを目的としています。

原則とは言っていますが、必ずしも守らなければいけないわけではないし、むしろ守らないほうが良いコードが書ける場合もあります。あくまで目的は「柔軟で保守性の高いコードを書くこと」であり、SOLID原則などの設計原則に従うことは、目的達成のための手段に過ぎません。

ただ、「これに従っていれば大体上手くいく」と言えるぐらいには有用な設計原則ですから、原則に違反してしまう明確な理由がない場合には、SOLID原則に従って書いていくべきでしょう。

少々前置きが長くなりましたが、次からは5つの原則の内容について、それぞれ詳しく解説していきます。

単一責任の原則 (Single Responsibility Principle)

“a module should be responsible to one and only one actor.”
(モジュールは単一の機能についてのみ責任を持つべきである)
Robert CMartin

「1つのクラスの役割は1つのみにせよ」という設計原則が単一責任の原則です。各クラスは単一の機能においてのみ責任を持つべきであって、同じクラスにたくさんの機能を詰め込むのは危険です。

例えば、以下のようなクラスがあったとしましょう。

皆が一度は書くであろうクラス、GameManager君です。確かに呼び出す時は楽で便利かもしれませんが、このようなクラスはアウト。良くない設計の典型例です。

なぜダメか?というと理由は単純で、「単一責任の原則」に思いっきり反したクラスだからです。

そもそも「GameManager」とは、何を担当するクラスなのでしょうか。プレイヤーのステータスを管理するのか、スコアを管理するのか、はたまたUIの更新を行うクラスなのか。このようにクラスの責務を有耶無耶にしたまま書いていくと、「1000行以上のコードで全てを管理する神クラス」みたいなものが出現してしまい、何がどこに書かれているのかが不明瞭になってしまいます。神クラス死すべし。

というわけで、こんなクソデカクラスは破壊してバラしてしまいましょう。単一責任の原則に則り、責務ごとにクラスを分割します。

というわけで、試しにクラスを分割してみました。正直Playerというのもさらに分割したいところですが、とりあえずはこんなもんでしょう。

分割することで各クラスの責務がはっきりし、どの処理がどこに書かれているかがわかりやすくなりました。「1つのクラスを変更する理由は1つでないといけない」というのが単一責任の原則の本質であるため、何らかの仕様変更があった際に他のクラスにまで影響が及ばないようにすることが重要です。

クラスの命名についても、名前から役割が想像できるように意識しておきましょう。例えば、先ほど挙げた「何とかManager」みたいな名前は曖昧になりがちなので、私は基本的にManagerという名前そのものを禁止にしています。

禁止しろとまでは言いませんが、そのような名前をつける必要がある場合には、そもそもクラスの責務が分離できていないことが多いです。各クラスには単一の機能のみを持たせ、その機能が一目でわかるようなクラス名をつけるようにしましょう。

オープン・クローズド原則 (Open-Closed Principle)

“software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
(ソフトウェア要素(クラス、モジュール、関数など)は、拡張に対しては開いており、修正に対しては閉じているべきである。)
Bertrand Meyer

続いては「オープン・クローズド原則」について。機能の拡張と変更に関する設計原則で、「開放閉鎖の原則」みたいな呼ばれ方をすることもあります。少々説明が抽象的でわかりづらいので、一つずつ解説していきましょう。

「拡張に対して開いている」とは「機能の追加は簡単にできるようにしておかなければならない」ということを意味します。いちいち機能を追加するたびにコードを書き換えていては手間なので、あらかじめ機能の追加が簡単にできるようにしておきましょう、ということです。

対して「修正に対して閉じている」とは「機能の追加時に修正が発生してはいけない」ということを意味します。機能を追加する際には、既存のコードに手を加えることなく追加できるようにしておく必要があります。

まあ要するに、あらかじめ抽象化しておきましょうという話です。virtualやabstractなどの仮想メソッドやインターフェースを用いて、処理を「抽象に依存」させます。

例えば、接触判定時に相手に応じて処理を分けたい場合。

void OnCollisionEnter(Collision other)
{
    switch (other.gameObject.tag)
    {
        case "Player":
            other.GetComponent<Player>().OnDamage();
            break;
        case "Enemy":
            other.GetComponent<Enemy>().ApplyDamage();
            break;
    }
}

このコードだと、タグが追加されるたびにcaseを追加してswitch文を修正する必要があります。このような状況は好ましくありません。

そこで、ダメージの処理を抽象に依存させます。共通のインターフェース「IDamageable」を定義しましょう。

public interface IDamageable
{
    void ApplyDamage();
}

そして、このインターフェースをPlayerやEnemy等のクラスで実装します。

あとは接触判定の処理の中でIDamageableを取得し、それに対してApplyDamageを呼ぶだけです。

void OnCollisionEnter(Collision other)
{
    other.GetComponent<IDamageable>().ApplyDamage();
}

こうしておけば、他に敵やNPC等のクラスを追加しても、追加先のクラスでIDamageableを実装するだけで接触判定が機能するようになります。このように、機能を追加する必要がある場合には、あらかじめ基底クラスやインターフェースを定義しておき、そちらに依存する形で実装しておきましょう。

リスコフの置換原則 (Liskov Substitution Principle)

“Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.”
φ(x) を型 T のオブジェクト x に関して証明可能な性質とする。このとき、φ(y) は型 T のサブタイプ S のオブジェクト y について真でなければならない。)
Barbara Liskov

※コメントでご指摘を受けた通り、Square/Rectangle問題においてRectangleが不変である場合にはリスコフの置換原則が適用可能であるということを踏まえ、一部内容を修正しました。

マジで何言ってるかわからない引用を冒頭に持ってきましたが、3つ目の原則「リスコフの置換原則」です。

「派生型は基底型に置き換え可能でなければならない」というのがこの原則の内容になります。これも教科書的な定義で伝わりづらいので例を示しましょう。

まず、長方形を表す「Rectangle」クラスを用意します。

public class Rectangle
{
    public virtual float Height { get; set; }
    public virtual float Width { get; set; } 

    //面積を返すメソッド
    public float GetArea()
    {
        return Width * Height;
    }
}

そして、このクラスを継承して正方形「Square」クラスを作ります。

public class Square : Rectangle
{
    private float _height;
    private float _width;
    public override float Height
    {
        get
        {
            return _height;
        }
        set
        {
            _height = value;
            _width = value;
        }
    }
    public override float Width
    {
        get
        {
            return _width;
        }
        set
        {
            _width = value;
            _height = value;
        }
    }
}

一見この2つのクラスは「Square is a Rectangle」の関係が成立し、正しいように見えます。(実際、数学的な定義で言えば正方形は長方形の一部ですのでSquare is a Rectangleは成立します。)

しかし、以下のようなコードはどうでしょうか。

void Test(Rectangle r)
{
    r.Width = 10f;
    r.Height = 2f;
    Assert.IsTrue(r.GetArea() == 20f);
}

Test(new Rectangle());
Test(new Square()); // Squareを突っ込んだだけでエラーが出る

このようなコードを書くと、TestメソッドにSquareを入れただけでAssertに引っかかってエラーができます。「Squareが基底クラスのRectangleに置き換え不可能」な状況が発生してしまっているわけです。

void Test(Rectangle r)
{
    if (r is Square s) //なぜかSquareだけ特別扱い
    {
        r.Width = 5f;
        Assert.IsTrue(r.GetArea() == 25f);
    }
    else
    {
        r.Width = 10f;
        r.Height = 2f;
        Assert.IsTrue(r.GetArea() == 20f);
    }
}

さらにこのまま書き進めていくと、上の状況を避けるために型ごとのif文が生まれたりします。これでは継承の意味がありません。

このように、派生クラスで基底クラスのルールを変更してしまうと派生クラスを基底クラスに置き換え不可能になる状況が発生する可能性があります。

この問題に対しては、2つの解決策が考えられます。

解決策1: 共有のインターフェースを定義する

1つ目の解決策は、Rectangleの代わりにIShapeというインターフェースを定義し、RectangleとSquareでこれを実装することです。

public interface IShape
{
    float GetArea();
}

public class Rectangle : IShape
{
    public float Width { get; set; }
    public float Height { get; set; }

    public float GetArea()
    {
        return Width * Height;
    }
}

public class Square : IShape
{
    public float SideLength { get; set; }

    public float GetArea()
    {
        return SideLength * SideLength;
    }
}

これでRectangleとSquareをIShapeに置き換え可能になり、上のような問題は発生しなくなります。

解決策2: Rectangleを不変にする

もう一つの解決策は、Rectangleが変更されないように不変(Immutable)にしてしまうことです。

元々Rectangleクラスは、暗黙の了解として「WidthとHeightを独立して変更可能である」という事前条件の上に作られていました。しかし、継承先のSquareではWidthとHeightのSetterで自動的にもう片方が変更されてしまうため、独立して変更可能であるという基底クラスのルールを破ってしまうことになっています。

言い換えると、先ほどの2つのクラスは「WidthとHeightを独立して変更可能である」という条件があったために「Square is a Rectangle」が成立していませんでした。

つまり、Rectangleを変更できないようにしておき、WidthとHeightを独立して変更可能であるというルール自体をなくしてしまえば、SquareをRectangleで置き換え可能になります。具体的には、以下のようにコードを変更します。

public class Rectangle
{
    // heightとwidthを変更不可能にする
    public readonly float width;
    public readonly float height;
    
    // コンストラクタで縦横の長さを設定
    public Rectangle(float w, float h)
    {
        width = w;
        height = h;
    }

    //面積を返すメソッド
    public float GetArea()
    {
        return width * height;
    }
}

public class Square : Rectangle
{
    // コンストラクタで一辺の長さを設定する
    public Square(float sideLength) : base(sideLength, sideLength) { }
}

このような形にしておけば「Square is a Rectangle」が正しく成立し、リスコフの置換原則に反することはなくなります。ただし、そもそもRectangleを継承してSquareを実装するメリットがあまりないため、基本的には解決策1の方法を用いることになると思います。

「派生クラスでは基底クラスでのルールを守る」ということを最優先し、アクセスレベルを書き換えたり、条件判定を強化したりしないようにしてください。if (A is B)みたいな分岐が頻繁に出てくるようなコードになってきた場合には、各クラスの実装を見直しましょう。

インターフェース分離の原則 (Interface Separation Principle)

Clients should not be forced to depend upon interfaces that they do not use.”
(クライアントは、使用しないインターフェイスの実装を強制されてはならない)
Robert CMartin

4つ目は「インターフェース分離の原則」です。この原則の言いたいことは「使わないメソッドの実装を強制するな(1つのインターフェースに余計なメソッドを置くな)」ということです。

例えば、複数のキャラクターのクラスを作成する際に、共通のインターフェースであるICharacterを定義して実装するとします。

public interface ICharacter
{
    void Walk();
    void Fly();
    void Attack();
}

そして、このインターフェースを実装して具体的なキャラクターを作っていきます。

public class Villager : ICharacter
{
    public void Walk()
    {
       //何らかの処理
    }
   
    //攻撃をしたり空を飛んだりはできないが、実装の関係上メソッドを置かなければならない
    public void Fly() { }
    public void Attack() { }
}

public class Bird : ICharacter
{
    //歩くことはできないが、実装の関係上メソッドを置かなければならない
    public void Walk() { }

    public void Fly()
    {
       //何らかの処理
    }
   
    public void Attack()
    {
        //何らかの処理
    }
}

public class Dragon : ICharacter
{
    public void Walk()
    {
       //何らかの処理
    }

    public void Fly()
    {
       //何らかの処理
    }
   
    public void Attack()
    {
        //何らかの処理
    }
}

すると、実際には不可能なアクションを空のメソッドで書いておかなければならない事態が発生してしまいます。村人に攻撃能力や飛行能力はありませんし、鳥に歩く能力はありませんが、ICharacterインターフェースを継承しているために不要なメソッドの実装を強制されています。そもそも、呼び出されることのないメソッドが置かれていること自体が良くありません。

この事態を引き起こす要因は、ICharacterが大きすぎることにあります。汎用的な1つのインターフェースを作るのではなく、必要な機能に応じてインターフェースを分割しましょう。

ということで、ICharacterを3つのインターフェースに分割してみます。

public interface IWalkable
{
    void Walk();
}

public interface IFlayable
{
    void Fly();
}

public interface IAttackable
{
    void Attack();
}

そして、これらを実装してキャラクターを作っていきます。

public class Villager : IWalkable
{
    public void Walk()
    {
       //何らかの処理
    }
}

public class Bird : IFlyable, IAttackable
{
    public void Fly()
    {
       //何らかの処理
    }
   
    public void Attack()
    {
        //何らかの処理
    }
}

public class Dragon : IWalkable, IFlyable, IAttackable
{
    public void Walk()
    {
       //何らかの処理
    }

    public void Fly()
    {
       //何らかの処理
    }
   
    public void Attack()
    {
        //何らかの処理
    }
}

これで余計なメソッドを実装する必要がなくなり、コードがスッキリしました。

このようにインターフェースを定義する際には、余計なメソッドを置かずに最小限の構成で作り、機能ごとに複数のインターフェースに分離しましょう、というのがこの「インターフェース分離の原則」です。

依存性逆転の原則 (Dependency Inversion Principle)

“High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).”
(上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである)
“Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.”
(抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである)

最後の原則は「依存性逆転の原則」です。少々難しい原則かもしれませんが、「設計を行う上で最も重要な原則」と言っても過言ではないほど大事なので、是非とも覚えていきましょう。

まず例として、以下のようなクラスの関係を考えます。

PlayerクラスはSwordクラスを保持し、Attack()時にSwordのOnAttack()を呼び出します。要するに、PlayerがSwordに依存している状態です。

これらのクラスにおける問題は「Playerが詳細の実装(Swordクラス)に依存している」ため、全く拡張が効かないことです。例えば、PlayerにSword以外の武器「Gun」を使えるようにするとしましょう。この状態でGunクラスを追加すると、以下のようになります。

PlayerクラスにAttackメソッドのオーバーロードが増えました。武器が2つだけならまだこれでも何とかなるかもしれませんが、3つ4つと増えていったら、その度にPlayerクラスにコードを書き足さなければなりません。これはオープン・クローズド原則にも反しています。

また、これらのクラスをモジュールという単位で考えると、

「上位モジュール」であるPlayerモジュールが「下位モジュール」であるWeaponモジュールに依存しています。この状態は依存性逆転の原則に反しており、好ましくありません。

そこで共通のインターフェース「IWeapon」を定義し、Playerモジュールに配置してみましょう。

先ほどの図と比べると、モジュール間の矢印の向きが下向きから上向きに「逆転」しているのがわかるかと思います。これが「依存性逆転」と呼ばれる理由です。

依存性逆転を行う最大の利点は、上位モジュールに一切手を加えることなく機能の追加・差し替えが可能になることです。実際にこの部分をコードにしてみると…

// ここを差し替えるだけで動く
var weapon = new Sword();

// コンストラクタでインスタンスを渡す
var player = new Player(weapon);
player.Attack();

こんな形になり、Playerに渡すインスタンスを変えるだけで処理を差し替えることができるようになります。便利。

このように、「上位モジュールで仕様(インターフェース)」を決めておき、それに従って下位モジュールが「詳細を実装する」というのが、設計を行う上での基本になります。重要なテクニックなので、しっかり使えるようにしておきましょう。

終わりに

いかがだったでしょうか。SOLID原則は非常に有用な設計原則であり、これを意識して書けるようになるとコードの保守性が飛躍的に上昇します。特に「オープン・クローズドの原則」「依存性逆転の原則」辺りは設計をする上で重要な原則なので、コードを書く際には常に意識しておくと良いでしょう。

是非ともSOLID原則を覚えて、より良い設計ができるようになっていきましょう!

「【C#】SOLID原則を学ぼう」への8件のフィードバック

  1. 依存性逆転の原則で自分の中で消化できない部分があります。
    この記事では
    ・Playerモジュールは上位モジュール
    ・Weaponモジュールは下位モジュール
    ・上位モジュール(Player)が下位モジュール(Weapon)に依存しているのは良くない
    という問題があって、

    ・IWeaponインターフェイスを作る
    ・IWeaponインターフェイスはPlayerモジュールに含むとする ※1
    ・PlayerクラスがIWeaponクラスに依存しているのは、どっちもPlayerモジュールだから問題なし
    ・WeaponモジュールのSwordクラス・GunクラスがIWeaponインターフェイスに依存しているが、
    それは下位モジュールから上位モジュールだから問題なし。
    という解決案を提示されていると思います。

    これの、※1 のIWeaponインターフェイスがWeaponmモジュールではなくてPlayerモジュール扱いである事に違和感を覚えます。
    それが許されるなら、Sword・GunクラスもPlayerモジュールにすれば全部解決じゃないかと思ってしまいます。
    (もちろんそれをやると全部のクラスがPlayerモジュールに入ってPlayerモジュールの意味がなくなりますが)

    自分で依存性逆転の原則に従ってコードをリファクタリングしているのですが
    この悩みがあってなかなかキレイなクラス構造にする事に行き詰まっています。
    この考え方を治すアドバイスを頂けませんでしょうか。

    1. 今回の例の場合、Weaponモジュールは「武器に関する実装を置くモジュール」というよりも「Playerが利用する武器の詳細を定義するモジュール」と捉えるとわかりやすいかもしれません。

      この2つのモジュールを実装する場合、順序としてはPlayerモジュール→Weaponモジュールという順で実装していくことになると思いますが、Playerモジュールを実装する時点ではPlayerが何らかの武器を利用できることは決まっていても、実際にどんな武器が存在するかまでは決まっていません。
      そのため「Playerが利用する何らかの武器」という概念を「IWeapon」インターフェースとしてあらかじめPlayerモジュールに配置しておき、具体的にどのような武器(例えば剣や銃など)があるかに関してをWeaponモジュールで定義します。このような形をとっておけば、いくら新しい武器を追加してもPlayerモジュールに変更が走ることはありません。

      重要なのは、下位モジュールの具体的な実装に上位モジュールが振り回されないようにすることです。Playerモジュールの責務は「Playerに関する処理の実装」ですから、それ以外の理由で変更されることがないようにするために、具体的な武器の実装はWeaponモジュールとして切り離しています。
      ※IWeaponに変更があった時にPlayerモジュールが変更されるのは良いのかと思われるかもしれませんが、今回の場合はそもそも処理の流れ的にPlayerの処理が変更された時にしかIWeaponは変更されないので問題ありません。

      1. ありがとうございます。理解が深まりました。

        前々から依存性逆転の原則がイマイチよくわかっていなくて、
        でも具体的に何が分からないのかが分からなかったので質問もロクに出来なくて一人で悩んでいたのですが
        この記事は具体的なコードがあって、自分がどこを理解していないのかを言語化する事が出来て、質問を書くことが出来ました。
        そして素晴らしい回答をいただくことが出来て本当にありがとうございました。

  2. 記事の趣旨とは関係ないのですが、
    「Square is a Rectangle が成立しない」のは Square が mutable になっているからで、
    不変オブジェクトにしてコンストラクタのみで制約を作ると正しく成立します。
    new Square(length) と new Rectangle(width, height) 、あとは同じということですね。
    リスコフの置換原則は良く誤解されていて、 immutable にすれば割と簡単にクリアできます。
    例としてはむしろ読んで分かりにくいと思う人もいるでしょうからこれが正しいとは言いません。

    1. 確かに、Rectangleをimmutableにすれば置き換え可能になりますね…!(Square/Rectangleの例を色々誤解してました…)
      ご指摘いただいた内容を踏まえ、リスコフの置換原則の項目を修正しました。
      まだ正しく理解できているかちょっと不安なので、確認していただけると嬉しいです…

  3. 記事の内容には直接関係ないかも知れませんが、クラス図が綺麗で気になりました。何のツールを使って作成したのか教えて頂けないでしょうか?

    1. この記事のクラス図は「Figma」のプラグイン「Entity Modeler」を利用して作成しています。
      クラス図だけでなく色々な関係図が簡単に描けるので便利です。

  4. ピンバック: 【C#】Dependency Injection(依存性の注入)とは - Annulus Games

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です