Qemelly(けめる)のプログラム備忘録

Unity / AtCoderについて書きます

【Unity】SOLID原則を1から理解する - Open/Closed Principle編

SOLIDを1から勉強するコーナー、前回はSingle Responsibility(単一責任の原則)について勉強しました。
今回はOpen/Closed Principle(開放・閉鎖原則、OCP)を見ていきましょう。

開放・閉鎖原則

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

簡単に言うと、何かしらの機能を追加・修正したいとき、既存のコードに手をつけなくてもいいようにしようね、という原則です。因みにここでの「手をつけない」とは、既存のメソッドの中に何かを書き込むことを含みます。既存のメソッドには何も手をつけずに機能が追加できる設計になってはじめて、OCPを保守できていると言えます。


いいデザイン/コードとは、何らかの変更を加えるとき、あたかもその変更を予期していたかのように組まれているものである」みたいな定義を何かの本で読んでしっくり来たのを覚えていますが、OCPはこの定義を地で行くような原則になっています。

そういうわけもあって、OCPは、OOP(オブジェクト指向プログラミング)においても重要度が高い原則としてしばしば取り上げられるようです。

「SOLIDの中でも、OとDを徹底すればそれだけで一人前なコードになる!」と言われることもあるほどなので、私も特に習熟しておきたいと思っています。

例(Unityより一部改変)

例によってUnity公式から。OCPの代表例ともいえる図形の面積問題を取り上げています。
github.com

using UnityEngine;

namespace DesignPatterns.OCP
{ 
    public class AreaCalculator 
    {
        // Before: OCPを使わない実装

        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 
    {
        public float Radius { get; set; }
    }

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

先述の通りこれはOCPにおける簡単な例として有名な問題です。

Shapeの面積を計算したいとき、上記の実装例のようにして計算したい側(AreaCalculator)がShapeを判定してメソッドを切り替える処理を行うのはOCPに反しています。

例えば、アップデートで正方形、台形、三角形、平行四辺形の面積を計算できるように機能追加したくなったとしましょう。すると当然、AreaCalculatorは追加する図形分のメソッドを用意するハメになります。

実装となるとそれだけでは足りず、別のクラスでswitch文等を利用し、図形が何かを判定した後に対応するメソッドを呼ぶ、みたいな処理を書かないといけなくなると思います。こうなるとswitch文が機能追加のたびにいじられてしまい、非常に危険かつ面倒です...。


あと、周の長さも欲しくなったら地獄が始まります。GetRectanglePerimeter,GetCirclePerimeter,GetSquarePerimeter,GetTrianglePerimeter,.....。

こういう実装の場合、周の長さなんて欲しくなってはいけません。

改善例(Unityより一部改変)

こういうときはinterfaceの出番です

using UnityEngine;

namespace DesignPatterns.OCP
{
    // After: OCPを使った実装
    public class AreaCalculator
    {
        public float GetArea(IShape shape)
        {
            return shape.CalculateArea();
        }
    }

    public class Circle : IShape
    {
        public float Radius { get; set; }

        public float CalculateArea()
        {
            return Radius * Radius * Mathf.PI;
        }
    }
    public class Rectangle : IShape
    {
        public float Width { get; set; }
        public float Height { get; set; }

        public float CalculateArea()
        {
            return Width * Height;
        }
    }
    public interface IShape
    {
        public float CalculateArea();
    }
}

こうすると、以下のいいことがあります。

・先例の図形アップデートの際、図形のクラスを追加するだけでOKになる
・周の長さとかを計算したくなった時も、IShape内に定義を加えて各図形にメソッドを追加すればOK(地獄よりは幾分マシ、追加漏れがあってもエラーが出るから心配もない)


このように、interfaceやabstractクラスを追加して長方形や円を「図形」というグループで抽象化してやることによって、利用者側に「図形の面積を計算する」という本質的な作業のみをさせてあげることができました。



※(以下interfaceが分からない人向け)


interfaceはUnity初心者の登竜門的概念だと勝手に思っています。私もinterfaceの意味・意義が理解できずに何か月ほったらかし経ったことか...。interfaceが分からない人向けにはまたいつか詳しい記事を書こうと思っていますが、とりあえずここでは簡単な説明を交えながら解説します。

誤解を恐れずに言うと、interfaceはいわば「属性の証明書」です。

interfaceを継承したクラスはinterface内に書かれている内容を絶対に守らないといけません。その代わり、interfaceを継承していることが、その内容を実装していることの証明になるため、わざわざ本当は何のクラスなのかなんて気にせずに利用側のクラスが取り扱えるようにできるのです。

ここでのIShapeは「私は面積を計算できます」というメソッド(CalculateArea())を持っています。これを継承したRectangleやCircleは実際にそのメソッドを実装しなければなりません。その代わり、IShapeというinterfaceを継承していることが、面積計算ができることを証明しているため、AreaCalculator側は、わざわざ図形の形がRectangleなのかCircleなのかという非本質的な部分は考えることなく、図形の面積を求めたいという欲求のみを実装すればよくなるのです。