🚀 ニフティ’s Notion

SOLID原則

ソフトウェア構築時に従うべき5つのガイドライン。ソフトウェアの拡張性や保守性を高めるためのもの。

オブジェクト指向プログラミングを使っても、「分かりやすいプログラム」や「メンテナンスしやすいプログラム」になるというわけではない。そこで、5つのガイドラインによって、開発者にとって読みやすくメンテナンスが可能なプログラムを作成するための指標が生み出された。

S:SRP [ Single Responsibility Principle] 【単一責任の原則】
クラスに任せる仕事は1つだけにするべきである。

プログラム内の各クラスの役割を1つに絞るということ。したがって同一クラスに異なる理由で変更を加えるメソッドを入れてはならない。すべてのクラスはソフトウェアが提供する機能の1部分だけの役割を果たすべきであり、コード変更時の影響範囲を最小限に抑える。

  • やるとどうなるのか?
    • システムの凝集性が高まる
    • オブジェクトが役割がはっきりして、整理される
  • やらないとどうなるのか?
    • 変更を加えると他のメソッドにも影響が伝播する可能性がある
      • リファクタが難しくなる
    • ゴッドクラス(肥大化したクラス)になり、全体の把握が難しくなる
開発中に仲間外れのメソッドを見つけた場合は、SRP違反の可能性があるので、異なるクラスに分けることも検討しましょう。
(ただし、やりすぎるとバラバラになりどこに何が書いてあるかわからなくなってしまうため、適度にやっていきましょう)
×
  • 本クラスに、本の内容を伝えてくれるメソッドと、内容を保存してくれるメソッドがある
  • ファイルではなく、データベースを保存する場合は、Bookクラスを書き換える必要が出てくる。
  • 本の内容をとる、保存するという二つの役割があるので、分離したほうが良い。
image block
class Book:
    def __init__(self, name: str, author_name: str):
        self.__name = name
        self.__author_name = author_name

    def get_name(self) -> str:
        return self.__name

    def get_author_name(self) -> str:
        return self.__author_name

    def save(self) -> None:
        with open("book.txt") as f:
            f.write(self.get_name() + " " + self.get_author_name())


if __name__ == "__main__":
    book = Book("A Great Book", "Naoki Uehara")
    book.save()
  • Bookクラスは本の内容を持つ
  • BookRepositoryクラスは本の内容を保存してくれる。
  • Bookの役割を分離でき、スマートになった。
image block
class Book:
    def __init__(self, name: str, author_name: str):
        self.__name = name
        self.__author_name = author_name

    def get_name(self) -> str:
        return self.__name

    def get_author_name(self) -> str:
        return self.__author_name


class BookRepository:
    def save(self, book: Book) -> None:
        with open("book.txt", "w") as f:
            f.write(book.get_name() + "," + book.get_author_name())


if __name__ == "__main__":
    book_repository = BookRepository()
    book = Book("A Great Book", "Naoki Uehara")
    book_repository.save(book)
O:OCP [Open-Closed Principle]【オープン・クローズドの原則】
ソフトウェアのエンティティ(クラス、モジュール、関数)は拡張に対して開き、修正に対して閉じていなければならない。

クラスを修正することなく、クラスの振る舞いを拡張できなければならないというもの。すなわち、なにか機能を追加するとき、新しいクラスを追加することは自由であるが、既存クラスの変更は最小限に留めなければならない。

次のコードの中に、CalculateAreasというクラスがある。ここでは、Rectangleオブジェクトを受け取り、オブジェクトの計算をしてその結果を返却している。

×

もし、円の面積の計算を加えたいのであれば、CalculateAreasクラスを変更しなければならない。これは、既存クラスの変更を最小限に留めなければならないというオープン/クローズの原則に反している。

image block
class Rectangle:
    def __init__(self, length: float, width: float):
        self.__length = length
        self.__width = width

    def get_length(self):
        return self.__length

    def get_width(self):
        return self.__width


class CalculateAreas:
    def calcArea(self, r: Rectangle):
        return r.get_width() * r.get_length()


if __name__ == "__main__":
    r = Rectangle(1, 2)
    ca = CalculateAreas()
    print(ca.calcArea(r))

上記の実装では、新しい図形のクラスを加えてもCalculateAreasクラスを変更する必要が無い。

このようにすることで、安全にコードを拡張することができる。オープン/クローズの原則を一言で言えば、「サブクラスを通して拡張することで元のクラスを変更する必要がなくなる」ということである。

image block
from abc import ABC

# インターフェース
class Shape(ABC):
		@abstractmethod
    def get_area(self) -> float:
        pass


class Rectangle(Shape):
    def __init__(self, length: float, width: float):
        self.__length = length
        self.__width = width

    def get_area(self) -> float:
        return self.__length * self.__width


class Circle(Shape):
    def __init__(self, r: float):
        self.__radius = r

    def get_area(self) -> float:
        return self.__radius * self.__radius * 3.14


class CalculateAreas:
    def calcArea(self, shape: Shape):
        return shape.get_area()


if __name__ == "__main__":
    ca = CalculateAreas()
    r = Rectangle(1, 2)
    print(ca.calcArea(r))

    c = Circle(4)
    print(ca.calcArea(c))
L:LSP [Liskov Substitution Principle]【リスコフの置換原則】
親クラス は、その子クラスで代用可能でなければならない。(継承の条件)

親クラスのインスタンスは、その子クラスのインスタンスによって置き換えることができるように設計しなければならないというもの。親クラスが何かを行うことができるのであれば、子クラスも同じことができなくてはならない。つまり一貫性を持たせるということ。

×
  • Rectangle(長方形クラス)とSquare(正方形クラス)がある。正方形クラスは縦の長さ=横の長さという制約がある。リスコフ原則によれば、Rectangleクラスを正方形クラスに置換しても正しく動作することが必要だが、先の制約からうまく動作しない。
image block
from abc import ABC


# 図形インターフェース
class Shape(ABC):
    def calc_area(self) -> float:
        pass


# 長方形クラス
class Rectangle(Shape):
    def __init__(self, length: float, width: float):
        self.__length = length
        self.__width = width

    def calc_area(self) -> float:
        return self.__length * self.__width

    def set_length(self, length: float):
        self.__length = length

    def set_width(self, width: float):
        self.__width = width


# 正方形クラス
class Square(Rectangle):
    def __init__(self, length):
        super().set_length(length)
        super().set_width(length)

    def set_length(self, length: float):
        super().set_length(length)
        super().set_width(length)

    def set_width(self, length: float):
        super().set_length(length)
        super().set_width(length)


if __name__ == "__main__":
    r = Rectangle(1, 2)
    print(r.calc_area())

    s = Square(4)
    print(s.calc_area())
  • 例えば、継承が合わないのなら委譲を使う。
    image block
from abc import ABC


# 図形インターフェース
class Shape(ABC):
    def calc_area(self) -> float:
        pass


# 長方形クラス
class Rectangle(Shape):
    def __init__(self, length: float, width: float):
        self.__length = length
        self.__width = width

    def calc_area(self) -> float:
        return self.__length * self.__width

    def set_length(self, length: float):
        self.__length = length

    def set_width(self, width: float):
        self.__width = width


# 正方形クラス
class Square(Shape):
    def __init__(self, length):
        self.__rectangle = Rectangle(length, length)

    def set_length(self, length: float):
        self.__rectangle.set_length(length)
        self.__rectangle.set_width(length)
    def calc_area(self):
				self.__rectangle.calc_area()


if __name__ == "__main__":
    r = Rectangle(1, 2)
    print(r.calc_area())

    s = Square(4)
    print(s.calc_area())

I:ISP [Interface Segregation Principle]【インターフェイス分離の原則】
クライアントは自分たちが使わないインターフェースに依存することを強いられるべきではない

一つのインターフェースに全てまとめるのではなく、小さなインターフェースに分けた方がいいというものである。

  • やらないとどうなるのか?
    • 呼び出し元はそのインターフェースしか見えないので、呼び出しちゃいけないメソッドを呼び出してしまう。
    • インターフェースが大きすぎると具象クラスは単一責任の原則に反してしまう。
×

動物インターフェースを定義する。動物インターフェースは走る、食べる、ミルクを出すメソッドを必要とする。

image block
from abc import ABC,abstractmethod

# 走る&食べる&ミルクを出すことができれば動物インターフェースを実装しているとみなす
class animal(ABC):
    @abstractmethod
    def run(self):
        pass
    @abstractmethod
    def eat(self):
        pass
    @abstractmethod
    def milk(self):
        pass

犬クラスは動物インターフェースを実装できる。


# 犬は走る&食べる&ミルクを出すことができる
class dog(animal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")
    def milk(self):
        print("milk")

トカゲクラスを実装しようとすると、走る、食べることはできるが、爬虫類のためミルクを出すことはできない。このように大きなインターフェースを作ると、具象クラスは使用しないメソッドを実装することを強いられる。


# トカゲはミルクを出すことができないので、milkは実装できない。
class lizard(animal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")
    def milk(self):
        # このメソッドを呼ぶことは禁止する!!!!
        pass
全体のソースコード
from abc import ABC,abstractmethod

# 走る&食べる&ミルクを出すことができれば動物インターフェースを実装しているとみなす
class animal(ABC):
    @abstractmethod
    def run(self):
        pass
    @abstractmethod
    def eat(self):
        pass
    @abstractmethod
    def milk(self):
        pass

# 犬は走る&食べる&ミルクを出すことができる
class dog(animal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")
    def milk(self):
        print("cry")

# トカゲはミルクを出すことができないので、milkは実装できない。
class lizard(animal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")
    def milk(self):
        # このメソッドを呼ぶことは禁止する!!!!
        pass

def func(a: animal):
    a.milk()

func(dog())
func(lizard())

animalインターフェースが大きすぎたので分割する。ミルクを出すメソッドをmammalインターフェースに分けることにした。

image block
from abc import ABC,abstractmethod

# 走る&食べることができれば動物インターフェースを実装しているとみなす
class animal(ABC):
    @abstractmethod
    def run(self):
        pass
    @abstractmethod
    def eat(self):
        pass

# ミルクを出すことができれば哺乳類インターフェースを実装しているとみなす
class mammal(ABC):
    @abstractmethod
    def milk(self):
        pass

犬クラスは、動物インターフェースと哺乳類インターフェースを実装することにした。

# 犬は動物かつ哺乳類である
class dog(animal, mammal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")
    def milk(self):
        print("cry")

トカゲクラスは動物インターフェースを実装すれば良い。


# トカゲは動物である
class lizard(animal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")
全体のソースコード
from abc import ABC,abstractmethod

# 走る&食べることができれば動物インターフェースを実装しているとみなす
class animal(ABC):
    @abstractmethod
    def run(self):
        pass
    @abstractmethod
    def eat(self):
        pass

# ミルクを出すことができれば哺乳類インターフェースを実装しているとみなす
class mammal(ABC):
    @abstractmethod
    def milk(self):
        pass

# 犬は動物かつ哺乳類である
class dog(animal, mammal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")
    def milk(self):
        print("cry")

# トカゲは動物である
class lizard(animal):
    def run(self):
        print("run")
    def eat(self):
        print("eat")

def func(a: animal):
    a.eat()

func(dog())
func(lizard())

D:DIP [Dependency Inversion Principle]【依存関係逆転の原則】
具象は抽象に依存すべき コードは抽象に依存しなければならないというもの。

普通にコードを書いてしまうと、処理の方向と依存の方向が同じになってしまい、抽象が具象に依存してしまう。インターフェースを用いて依存の向きを逆転してやる。

  • やるとどうなるのか?
    • 変更頻度の低いインターフェースに依存させることで、呼び出し元へ変更の影響が伝播しにくくなる
      • 基本的には変更頻度が低いオブジェクトに依存させたほうが、書き換えの範囲が少なくて済む
    • 具象クラスを変更すれば振る舞いを変更できる(ポリモーフィズム)
      • ファイルに保存する・DBに保存する・メモリの保存するクラスなど別々にクラスを作っておき、使いたいクラスをインターフェースに代入すれば振る舞いを切り替えられる。
    • 振る舞いを変更できると言うことはテストが書ける
      • わざとエラーを発生させることで、エラーが発生したときのテストが書ける
    • モックが刺せる
      • 一時的に固定値を返すクラスを作って、インターフェースに代入することで、DB等を用意しなくとも開発を進められる
  • やらないとどうなるのか?
    • 技術的な理由で振る舞いを変更するため具象クラスを変更したときに抽象も変更しないといけなくなる。
×

二つのクラスの依存関係は以下のような形になっている。ここで問題なのは、UserRegisterUsecaseという上位オブジェクトがUserRepositoryに依存しており、抽象が具象に依存してしまっている。

image block
UserRegisterUsecase

UserRegisterUsecaseはユーザー登録の責務を負うクラス。UserRepositoryを使ってユーザー情報を保存する。

class UserRegisterUsecase:
    def __init__(self):
        self.__user_repository = FileUserRepository()

    def handle(self, user_id : int, user_name:str) -> None:
        self.__user_repository.create(str(user_id), user_name)
FileUserRepository

FileUserRepositoryはcreateを公開しており、ユーザーをファイルに保存できる。

class FileUserRepository:
    def __init__(self):
        self.__file_name = "user.txt"

    def create(self, user_id : int, user_name:str) -> None:
        with open(self.__file_name, "a", encoding='utf-8', newline='\n') as f:
            f.write(str(user_id) + " " + user_name + "\n")

利用者側はhandleメソッドを使ってユーザー登録できる。

if __name__ == "__main__":
    user_register_usecase = UserRegisterUsecase()
    user_register_usecase.handle(0, "uehara")
    user_register_usecase.handle(1, "nakahara")
    user_register_usecase.handle(2, "simohara")
全体のコード
class UserRegisterUsecase:
    def __init__(self):
        self.__user_repository = FileUserRepository()

    def handle(self, user_id : int, user_name:str) -> None:
        self.__user_repository.create(str(user_id), user_name)

class FileUserRepository:
    def __init__(self):
        self.__file_name = "user.txt"

    def create(self, user_id : int, user_name:str) -> None:
        with open(self.__file_name, "a", encoding='utf-8', newline='\n') as f:
            f.write(str(user_id) + " " + user_name + "\n")

    def find_by_id(self, user_id:int) -> str:
        with open(self.__file_name, "r") as f:
            for i in f.readlines():
                if len(i) >= 2 and i[0] == user_id:
                    return i[1]
        return ""

if __name__ == "__main__":
    user_register_usecase = UserRegisterUsecase()
    user_register_usecase.handle(0, "uehara")
    user_register_usecase.handle(1, "nakahara")
    user_register_usecase.handle(2, "simohara")

具象から抽象に依存の向きを変える必要がある。インターフェースを使えば依存の向きを自由に変えることができる。ユーザー情報を保存するインターフェースを作ると、FileUserRepository(具象)がUserRepositoryInterface(抽象)に依存する形になった。

image block
UserRepositoryInterface

createができればUserRepositoryInterfaceとして扱える。

class UserRepositoryInterface(ABC):
    def create(self, user_id :int, user_name : str) -> None:
        pass
FileUserRepository

UserRepositoryInterfaceを実装する(継承)。振る舞いとしてはユーザーIDとユーザー名を受け取り、ファイルに保存する。

class FileUserRepository(UserRepositoryInterface):
    def __init__(self):
        self.__file_name = "user.txt"

    def create(self, user_id : int, user_name : str) -> None:
        with open(self.__file_name, "a", encoding='utf-8', newline='\n') as f:
            f.write(str(user_id) + " " + user_name + "\n")
UserRegisterUsecase

コンストラクタでUserRepositoryInterfaceを実装したクラスを受け取るようにする。こうすることで、どのように保存するか、その形式をどうするか?などの具体的な振る舞いを気にしなくても良くなる。

class UserRegisterUsecase:
    def __init__(self, user_repository : UserRepositoryInterface):
        self.__user_repository = user_repository

    def handle(self, user_id : int, user_name : str) -> None:
        self.__user_repository.create(user_id, user_name)
main

どのUserRepositoryを使うか決めて、ユースケースに渡す。外からインターフェース(抽象)に具象クラスをあてがうので、 依存性の注入(DI) と呼ばれている。

if __name__ == "__main__":
		# DI
		user_repository = FileUserRepository()
    user_register_usecase = UserRegisterUsecase(user_repository)

		# ユースケースの実行
    user_register_usecase.handle(0, "uehara")
    user_register_usecase.handle(1, "nakahara")
    user_register_usecase.handle(2, "simohara")

まとめ

オブジェクト指向には保守しやすくするための5つの原則がある。
単一役割の原則:それぞれのクラスに役割をあてることで、変更の影響を最小限にする
オープン・クローズの原則:既存の成果物を変更せずに拡張できるようにする
リスコフの置換原則:機能拡張として変更が想定される場所は置換可能にして変更しやすくする
インターフェース分離の原則:変更の影響を最小限にするために、関心のないものに無理矢理依存させない
依存性逆転の原則:変更されやすい具象には依存させず、安定した抽象に依存させる