Dyuichi Blog

オブジェクト指向シリーズⅢ: SOLID原則

はじめに

本記事は「オブジェクト指向シリーズ」の第3回目の記事である.

「オブジェクト指向シリーズ」では,私がオブジェクト指向について勉強する中で特に重要であると思ったトピックについてまとめている.

誰かの勉強の際の手助けになれば幸いであるが,誤りがあれば以下に記載されているいずれかのSNSにご連絡いただけるとありがたい.

SOLID原則とは

SOLIDとは,以下の5つの原則の頭文字をとった略語である.

  • Single Responsibility Principle (SRP)
  • Open Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Separation Principle (ISP)
  • Dependency Inversion Principle (DIP)

このSOLID原則は,決してオブジェクト指向に特化したものではなく,ソフトウェア開発全般に言えることである.

SOLID原則①: 単一責任原則(SRP)

クラスと責務は1対1対応するべきという原則である.

以下は単一責任原則が守られていないプログラムである.

pythonclass Report:
    pass

class ReportOperation:
    def __init__(self, report: Report):
        self.report = report

    def generate_report(self):
        pass

    def save_report(self):
        pass

このプログラムでは,ReportOperationクラスで,レポートの内容を生成するという責任と,レポートを保存するという2つの責任を持っている.この状態では,レポート内容の生成方法が変わったときと,レポートの保存方法が変わったとき(例:ファイル保存 ⇒ DB保存)にこのReportOperationクラスを修正する必要が生じる.

では,単一責任原則を守ったプログラムを以下に示す.

pythonclass Report:
    pass

class ReportGenerateOperation:
    def __init__(self, report: Report):
        self.report = report

    def generate_report(self):
        pass

class ReportSaveOperation:
    def __init__(self, report: Report):
        self.report = report

    def save_report(self):
        pass

これで,クラスと責務を1対1に対応することができた.

ただし,なんでもかんでも別々にすればいいわけではない.例えばデータベースドライバーによるDBへの書き込みと読み込みを考える.書き込みと読み込みにに変更が加えられるときは,おそらくDBの仕様が変更された時ではなかろうか?どちらかのみを変更したいケースはあまりないと思われる.このような時は,書き込みと読み込みを同じクラスで管理するべきである.

SOLID原則②: 開放閉鎖原則(OCP)

クラスの設計は,拡張に対してオープンな姿勢を取り,変更に対してクローズドな姿勢であるべきという原則である.

以下は開放閉鎖原則が守られていないプログラムである.

pythonfrom abc import ABC

class Shape(ABC):
    def calculate_area(self):
        if isinstance(self, Rectangle):
            return self.width * self.height
        elif isinstance(self, Circle):
            return 3.14 * self.radius * self.radius

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

このプログラムでは,calculate_area() メソッドで図形の面積を計算しているが,新しい図形をサポートしたい場合は,このメソッドを変更する必要が生じる.

では,開放閉鎖原則を守ったプログラムを以下に示す.

pythonfrom abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

    def calculate_area(self):
        return self.area()

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius * self.radius

このようなプログラムにすることで,新しい図形が登場しても,Shapeクラスを継承した新しいクラスを追加するだけでよく,calculate_areaメソッドを変更する必要が無い.

これが,「拡張に対してオープンな姿勢を取り,変更に対してクローズドな姿勢を取る」ということである.

SOLID原則③: リスコフの置換原則(LSP)

派生クラスの振る舞いは,基底クラスの振る舞いを完全にカバーしなければならないという原則である.言い換えると,基底クラスのインスタンスをその派生クラスのインスタンスで置き換えることができ,それによって正しく動作しなくなるような状況が生じてはならない,ということである.

以下はリスコフの置換原則が守られていないプログラムである.

pythonclass TaskDisplay:
    def __init__(self, total, remains):
        self.total = total
        self.remains = remains

    def display(self):
        print(f"{self.total} 件中 {self.remains} 件完了しました。")

class PercentileTaskDisplay(TaskDisplay):
    def display(self):
        percent = self.remains / self.total * 100
        print(f"{percent:.1f}% 完了しました。")

このプログラムでは,タスクの消化具合を表示するためのクラスが実装されている.PercentileTaskDisplayでは,パーセント表示で消化具合を確認することができる.では,totalが0の時はどうなるだろうか.この時,ゼロ除算の実行時エラーが生じる.

この実行時エラーを回避するために,TaskDisplayクラス内でtotalに0をセットできないような制約をかける方法がある.しかし,これでは他のプログラムでTaskDisplayを使用していた場合,他のプログラムもすべて書き換える必要が生じる.

そのため,PercentileTaskDisplay側で対応するのが正しい解決策である.

では,リスコフの置換原則を守ったプログラムを以下に示す.

pythonclass TaskDisplay:
    def __init__(self, total, remains):
        self.total = total
        self.remains = remains

    def display(self):
        print(f"{self.total} 件中 {self.remains} 件完了しました。")

class PercentileTaskDisplay(TaskDisplay):
    def display(self):
        if self.total != 0:
            percent = self.remains / self.total * 100
        else:
            percent = 100
        print(f"{percent:.1f}% 完了しました。")

これにより,クライアントコードでは派生クラスを基底クラスのつもりで問題なく使うことができる.

SOLID原則④: インターフェース分離原則(ISP)

インターフェース版の単一責任原則である.この原則では,大きな一つのインターフェースよりも,特定のクライアントに特化した複数のインターフェースを持つ方がいいという考えに基づいている.

以下はインターフェース分離原則が守られていないプログラムである.

pythonclass DatabaseDriver:
    def write(self):
        print("write")
        pass

    def read(self):
        print("read")
        pass

class CommandExecuter:
    def __init__(self, database_driver: DatabaseDriver):
        self.database_driver = database_driver

    def execute(self):
        self.database_driver.write()

class QueryService:
    def __init__(self, database_driver: DatabaseDriver):
        self.database_driver = database_driver

    def query(self):
        self.database_driver.read()

このプログラムでは,データベースドライバーを用いた読み込みと書き込みを実装している.例えばCommandExecuter では,DatabaseDriverwrite メソッドしか使わないため,readメソッドを意識せずに実装できるような状態がよい(後々の問題分析のしやすさや,読むコード量の削減につながる).

では,インターフェースの分離原則を守ったプログラムを以下に示す.

pythonclass DataInputInterface():  # 入力用インターフェース
    def write(self):
        raise NotImplementedError

class DataOutputInterface():  # 出力用インターフェース
    def read(self):
        raise NotImplementedError

class DatabaseDriver(DataInputInterface, DataOutputInterface):
    def write(self):
        print("write")
        pass

    def read(self):
        print("read")
        pass

class CommandExecuter():
    def __init__(self, input: DataInputInterface):
        self.input = input

    def execute(self):
        self.input.write()

class QueryService():
    def __init__(self, output: DataOutputInterface):
        self.output = output

    def query(self):
        self.output.read()

このプログラムでは,入力用と出力用に新しくインターフェースを実装している.これにより,CommandExecuterからは,DataInputInterfacewriteメソッドのみを意識すればよいことになり,不要な負担が軽減される.

SOLID原則⑤: 依存性逆転原則(DIP)

依存性逆転原則は,以下2つのガイドラインに基づいている.

  1. 高レベルのモジュールは,低レベルのモジュールに依存すべきではない.どちらも抽象に依存するべきである.
    1. 抽象は詳細に依存すべきではない.詳細が抽象に依存すべきである.

    以下は依存性逆転原則が守られていないプログラムである.

    pythonclass LightBulb:
        def turn_on(self):
            pass
        def turn_off(self):
            pass
    
    class Switch:
        def __init__(self, bulb: LightBulb):
            self.bulb = bulb
    
        def operate(self):
            pass

    このプログラムでは,Switchクラス(高レベル)が具体的なLightBulbクラス(低レベル)に依存してしまっている.

    では,依存性逆転原則を守ったプログラムを以下に示す.

    pythonfrom abc import ABC, abstractmethod
    
    class SwitchableInterface(ABC):  # Interface
        @abstractmethod
        def turn_on(self):
            pass
    
        @abstractmethod
        def turn_off(self):
            pass
    
    class LightBulb(SwitchableInterface):
        def turn_on(self):
            pass
    
        def turn_off(self):
            pass
    
    class Switch():
        def __init__(self, device: SwitchableInterface):
            self.device = device
    
        def operate(self):
            pass

    依存性逆転原則にのっとることで,新しいデバイスを追加する場合も,SwitchableInterfaceを継承するだけで,Switchクラスと互換性を保つことが可能となる.

    まとめ

    SOLID原則は,ソフトウェア開発における拡張性や保守性を高めることができる.このことについて,記事内のサンプルコードからも読み取れるのではなかろうか.それぞれの原則は似ているようだが,目的が同じわけではない.実際に実装する中で違いを掴んでいくことが重要である.

    参考文献

    本記事では,オブジェクト指向におけるSOLID原則について,特に自分が勉強中気になった点を中心にざっくりと書いてみた.

    次回はデザインパターンについて解説する.