2️⃣

『SOLID原則 in Unity』 2: Open Closed Principle編

2024/01/25に公開

はじめに

ゲーム開発において、設計のことを調べるとまず最初に出てくるSOLID原則。DRY(Don't Repeat Yourself)原則やKISS(Keep It Simple, Stupid)原則に並んで非常に大事な原則です。

SOLID原則を整理しながら、オブジェクト指向やUnityの勘所について見ていきましょう。

今回はOpen Closed Principle(OCP)について。

Open Closed Principle : オープン・クローズドの原則

結構守るのが難しい原則だと思っています。

端的には、「クラスは拡張に対して開かれており、修正や変更に対しては閉じていなければならない」という原則とされます。

簡単には、「機能追加したいときに関数の中身いじくるような設計にしない方がいいよ~」的な原則です。

これだけだとよく分からないので、違反例を見ていきましょう。

OCP違反例その1

OCPの説明にて非常に有名な図形の面積問題について見ていきます。その名の通り、図形の面積を求める機能を実装する問題です。

以下のコードは、各図形とその面積を求めるためのコードです。一見問題ないように見えますね。

namespace OCP
{ 
    public class AreaCalculator : MonoBehaviour
    {
        public float GetRectangleArea(Rectangle rectangle)
        {
            return rectangle.Width * rectangle.Height;
        }

        public float GetCircleArea(Circle circle)
        {
            return circle.Radius * circle.Radius * Mathf.PI;
        }
    }

    public class Circle : MonoBehaviour
    {
        public float Radius { get; private set; }
        
        public void Initialize(float radius)
        {
            Radius = radius;
        }
    }

    public class Rectangle : MonoBehaviour
    {
        public float Width { get; private set; }
        public float Height { get; private set; }
        
        public void Initialize(float width, float height)
        {
            Width = width;
            Height = height;
        }
    }
}  

実際、例えばこのように使うことが出来ます。

namespace OCP
{ 
    public class Example : MonoBehaviour
    {
        [SerializeField] private AreaCalculator _areaCalculator;
        [SerializeField] private Circle _circle;
        [SerializeField] private Rectangle _rectangle;
        
        private void Awake()
        {
            _circle.Initialize(1);
            _rectangle.Initialize(1, 2);
            
            Debug.Log(_areaCalculator.GetCircleArea(_circle)); // 3.141593
            Debug.Log(_areaCalculator.GetRectangleArea(_rectangle)); // 2
	    }
    }
}  

では、同様にして正方形、ひし形、平行四辺形等の図形を追加したくなった場合、このコードを書きかえる部分は以下のようになります。

namespace OCP
{ 
    public class AreaCalculator : MonoBehaviour
    {
        public float GetRectangleArea(Rectangle rectangle)
        {
            return rectangle.Width * rectangle.Height;
        }

        public float GetCircleArea(Circle circle)
        {
            return circle.Radius * circle.Radius * Mathf.PI;
        }

+        public float GetSquareArea(Square square)
+        {
+            return square.Width * square.Height;
+        }

+        public float GetRhombusArea(Rhombus rhombus)
+        {
+            return rhombus.Width * rhombus.Height;
+        }

+        public float GetParallelogramArea(Parallelogram parallelogram)
+        {
+            return parallelogram.Width * parallelogram.Height;
+        }
    }

    public class Circle : MonoBehaviour
    {
        public float Radius { get; private set; }
        
        public void Initialize(float radius)
        {
            Radius = radius;
        }
    }

    public class Rectangle : MonoBehaviour
    {
        public float Width { get; private set; }
        public float Height { get; private set; }
        
        public void Initialize(float width, float height)
        {
            Width = width;
            Height = height;
        }
    }

+    public class Square : MonoBehaviour
+    {
+        public float Width { get; private set; }
+        public float Height { get; private set; }
+        
+        public void Initialize(float width, float height)
+        {
+            Width = width;
+            Height = height;
+        }
+    }

+    public class Rhombus : MonoBehaviour
+    {
+        public float Width { get; private set; }
+        public float Height { get; private set; }
+        
+        public void Initialize(float width, float height)
+        {
+            Width = width;
+            Height = height;
+        }
+    }

+    public class Parallelogram : MonoBehaviour
+    {
+        public float Width { get; private set; }
+        public float Height { get; private set; }
+        
+        public void Initialize(float width, float height)
+        {
+            Width = width;
+            Height = height;
+        }
+    }
}  

このように、図形を追加するたびにAreaCalculatorクラスを書き換える必要があります。これはだるいので、型パターンで処理を分岐させるように書き換えてみましょう。

switch文を使うと以下のようになります。

namespace OCP
{ 
    public class AreaCalculator : MonoBehaviour
    {
-        public float GetRectangleArea(Rectangle rectangle)
-        {
-            return rectangle.Width * rectangle.Height;
-        }
-
-        public float GetCircleArea(Circle circle)
-        {
-            return circle.Radius * circle.Radius * Mathf.PI;
-        }
+        public float GetArea(object shape)
+        {
+            switch (shape)
+            {
+                case Rectangle rectangle:
+                    return rectangle.Width * rectangle.Height;
+                case Circle circle:
+                    return circle.Radius * circle.Radius * Mathf.PI;
+                case Square square:
+                    return square.Width * square.Height;
+                case Rhombus rhombus:
+                    return rhombus.Width * rhombus.Height;
+                case Parallelogram parallelogram:
+                    return parallelogram.Width * parallelogram.Height;
+                default:
+                    throw new ArgumentOutOfRangeException(nameof(shape), shape, null);
+            }
+        }
    }

    public class Circle : MonoBehaviour
    {
        public float Radius { get; private set; }
        
        public void Initialize(float radius)
        {
            Radius = radius;
        }
    }

    public class Rectangle : MonoBehaviour
    {
        public float Width { get; private set; }
        public float Height { get; private set; }
        
        public void Initialize(float width, float height)
        {
            Width = width;
            Height = height;
        }
    }

    public class Square : MonoBehaviour
    {
        public float Width { get; private set; }
        public float Height { get; private set; }
        
        public void Initialize(float width, float height)
        {
            Width = width;
            Height = height;
        }
    }

    public class Rhombus : MonoBehaviour
    {
        public float Width { get; private set; }
        public float Height { get; private set; }
        
        public void Initialize(float width, float height)
        {
            Width = width;
            Height = height;
        }
    }

    public class Parallelogram : MonoBehaviour
    {
        public float Width { get; private set; }
        public float Height { get; private set; }
        
        public void Initialize(float width, float height)
        {
            Width = width;
            Height = height;
        }
    }
}  

これで、図形を追加するたびにAreaCalculatorクラスを書き換える必要はなくなりました。しかしこの状態だと機能追加時にGetArea()関数の中身を書き換える必要があります。これはOCP違反です。

機能を追加したいだけなのに修正が入ってしまうとOCP違反です。 この場合、手間がかかりますし既存の機能が壊れてしまう可能性があります。

だからといって先述のようにいちいちGetRectangleArea()GetCircleArea()...と書いていくのもおかしな話です。これだと、書くのがだるいだけでなく、使用するクラス側が図形の種類を知っていなければならなくなってしまいます。

本質的には、何の図形かなんてわからなくても面積が出てくれた方がいいはずです。

今回の問題のよくある解決法は、そもそも図形が自分で面積を計算するようにすることです。

そのために、interfaceを使うこともよくあります。

解決法

コードが冗長になってしまうので、CircleクラスとRectangleクラスのみを抜粋して説明します。

namespace OCP
{ 
    public interface IShape
    {
        float GetArea();
    }

    public class Circle : MonoBehaviour, IShape
    {
        public float Radius { get; private set; }
        
        public void Initialize(float radius)
        {
            Radius = radius;
        }

        public float GetArea()
        {
            return Radius * Radius * Mathf.PI;
        }
    }

    public class Rectangle : MonoBehaviour, IShape
    {
        public float Width { get; private set; }
        public float Height { get; private set; }
        
        public void Initialize(float width, float height)
        {
            Width = width;
            Height = height;
        }

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

これで、図形を追加するたびにAreaCalculatorクラスを書き換える必要はなくなりました。また、機能追加時にGetArea関数の中身を書き換える必要もなくなりました。これでOCPを守ることが出来ました。

実際に使うには、以下のようになります。

namespace OCP
{ 
    public class Example : MonoBehaviour
    {
        [SerializeField] private Circle _circle;
        [SerializeField] private Rectangle _rectangle;
        
        private void Awake()
        {
            _circle.Initialize(1);
            _rectangle.Initialize(1, 2);
            
            Debug.Log(_circle.GetArea()); // 3.141593
            Debug.Log(_rectangle.GetArea()); // 2
        }
    }
}  

コードが非常に本質的になったと思います。この設計の場合、図形が追加されてもIShapeを実装するだけで済みます。

OCP違反例その2

よりUnityらしい例として、以下のような機能を実装してみましょう。

  • Player, Enemy, Bulletの3種類のオブジェクトがあり、それぞれにダメージを与える機能を実装する
  • PlayerはHpが3あり、1回Bulletに衝突すると1ダメージを受ける
  • EnemyはHpが1あり、1回Bulletに衝突すると1ダメージを受ける
  • Bulletは1回衝突すると消滅する
  • 0以下のHpを持つオブジェクトは消滅する

以下のように実装することが出来ます。

namespace OCP
{ 
    public class Player : MonoBehaviour
    {
        [SerializeField] private int _hp = 3;

        public void Damage(int damage)
        {
            _hp -= damage;
            if (_hp <= 0)
            {
                Destroy(gameObject);
            }
        }
    }

    public class Enemy : MonoBehaviour
    {
        [SerializeField] private int _hp = 1;

        public void Damage(int damage)
        {
            _hp -= damage;
            if (_hp <= 0)
            {
                Destroy(gameObject);
            }
        }
    }

    public class Bullet : MonoBehaviour
    {
        private void OnCollisionEnter(Collision other)
        {
            switch (other.gameObject.tag)
            {
                case "Player":
                    if (other.gameObject.TryGetComponent(out Player player))
                    {
                        player.Damage(1);
                    }
                    break;
                case "Enemy":
                    if (other.gameObject.TryGetComponent(out Enemy enemy))
                    {
                        enemy.Damage(1);
                    }
                    break;
            }
            Destroy(gameObject);
        }
    }
}  

Collision等が正しく設定されていれば、これで正しく動作します。ですが、これはOCP違反です。

例えば、Bulletに衝突したときにダメージを与えるオブジェクトを追加したいとします。壊れる壁とか。この場合、Bulletクラスを書き換える必要があります。

namespace OCP
{ 
    public class Bullet : MonoBehaviour
    {
        private void OnCollisionEnter(Collision other)
        {
            switch (other.gameObject.tag)
            {
                case "Player":
                    if (other.gameObject.TryGetComponent(out Player player))
                    {
                        player.Damage(1);
                    }
                    break;
                case "Enemy":
                    if (other.gameObject.TryGetComponent(out Enemy enemy))
                    {
                        enemy.Damage(1);
                    }
                    break;
+                case "Wall":
+                    if (other.gameObject.TryGetComponent(out Wall wall))
+                    {
+                        wall.Damage(1);
+                    }
+                    break;
            }
            Destroy(gameObject);
        }
    }
}  

このようにして関数の中身を書き換えることになってしまっているので、OCP違反です。

こういったコードをあっちにも書いて、こっちにも書いて…とやっていると、いつか修正漏れをしてしまう可能性があります。

解決法

またinterfaceの出番です。今回はIDamageableというinterfaceを作って、それを実装することで解決します。

namespace OCP
{ 
    public interface IDamageable
    {
        void Damage(int damage);
    }

    public class Player : MonoBehaviour, IDamageable
    {
        [SerializeField] private int _hp = 3;

        public void Damage(int damage)
        {
            _hp -= damage;
            if (_hp <= 0)
            {
                Destroy(gameObject);
            }
        }
    }

    public class Enemy : MonoBehaviour, IDamageable
    {
        [SerializeField] private int _hp = 1;

        public void Damage(int damage)
        {
            _hp -= damage;
            if (_hp <= 0)
            {
                Destroy(gameObject);
            }
        }
    }

    public class Bullet : MonoBehaviour
    {
        private void OnCollisionEnter(Collision other)
        {
            // ここでIDamageableを実装しているかどうかを判定する
            if (other.gameObject.TryGetComponent(out IDamageable damageable))
            {
                damageable.Damage(1);
            }
            Destroy(gameObject);
        }
    }
}  

ありがちなアンチパターン(違反例)

  • switch文を使って型パターンで処理を分岐させる
    • interfaceで本質を抜き出すと解決することが多い
  • なんちゃらProcessorとかなんちゃらCalculatorとかいうクラス
    • データを持ってるクラス側で処理ができないか検討してみるとよいかも…?

Discussion