本記事は「オブジェクト指向シリーズ」の第3回目の記事である.
「オブジェクト指向シリーズ」では,私がオブジェクト指向について勉強する中で特に重要であると思ったトピックについてまとめている.
誰かの勉強の際の手助けになれば幸いであるが,誤りがあれば以下に記載されているいずれかのSNSにご連絡いただけるとありがたい.
SOLIDとは,以下の5つの原則の頭文字をとった略語である.
このSOLID原則は,決してオブジェクト指向に特化したものではなく,ソフトウェア開発全般に言えることである.
クラスと責務は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の仕様が変更された時ではなかろうか?どちらかのみを変更したいケースはあまりないと思われる.このような時は,書き込みと読み込みを同じクラスで管理するべきである.
クラスの設計は,拡張に対してオープンな姿勢を取り,変更に対してクローズドな姿勢であるべきという原則である.
以下は開放閉鎖原則が守られていないプログラムである.
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
メソッドを変更する必要が無い.
これが,「拡張に対してオープンな姿勢を取り,変更に対してクローズドな姿勢を取る」ということである.
派生クラスの振る舞いは,基底クラスの振る舞いを完全にカバーしなければならないという原則である.言い換えると,基底クラスのインスタンスをその派生クラスのインスタンスで置き換えることができ,それによって正しく動作しなくなるような状況が生じてはならない,ということである.
以下はリスコフの置換原則が守られていないプログラムである.
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}% 完了しました。")
これにより,クライアントコードでは派生クラスを基底クラスのつもりで問題なく使うことができる.
インターフェース版の単一責任原則である.この原則では,大きな一つのインターフェースよりも,特定のクライアントに特化した複数のインターフェースを持つ方がいいという考えに基づいている.
以下はインターフェース分離原則が守られていないプログラムである.
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
では,DatabaseDriver
のwrite
メソッドしか使わないため,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
からは,DataInputInterface
のwrite
メソッドのみを意識すればよいことになり,不要な負担が軽減される.
依存性逆転原則は,以下2つのガイドラインに基づいている.
以下は依存性逆転原則が守られていないプログラムである.
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原則について,特に自分が勉強中気になった点を中心にざっくりと書いてみた.
次回はデザインパターンについて解説する.