PythonでSOLID原則を段階的に適用する方法

  • SOLID 原則は、より読みやすく、保守しやすく、スケーラブルなオブジェクト指向の Python コードを設計するための明確な基盤を提供します。
  • 各原則 (SRP、OCP、LSP、ISP、および DIP) は、責任の分離が不十分なものから厳格な依存関係まで、特定の種類の設計問題に対処します。
  • Python でクラス、抽象化、依存性注入を使用して SOLID を適用すると、結合が減り、テスト可能性が向上し、システムの進化が促進されます。

PythonのSolid

大規模なPythonプロジェクトに取り組み始めると、まず気づくことの一つは コードの理解、テスト、拡張が難しくなります。 基本的な設計ルールをいくつか守らないと、問題が発生します。そこで、有名なSOLID原則が役立ちます。これは、チームの作業を大幅に簡素化するために設計されたベストプラクティスの集合です。

これらの原則は、 古典的なオブジェクト指向プログラミング(Java、C++、C# など)しかし、クラスやオブジェクトをある程度本格的に使用する限り、Pythonには完璧に適合します。これらが何なのか、どこから来たのか、なぜ重要なのか、そして何よりもどのように機能するのかを詳しく見ていきましょう。 Pythonでわかりやすい例を使ってSOLIDを適用する コードの保守性、拡張性、作業性が向上します。

SOLID とは何ですか? それはどこから来たのですか?

用語 SOLIDはマイケル・フェザーズによって普及された頭字語である。 ロバート・C・マーティン(通称アンクル・ボブ)が提唱した5つの設計原則をまとめたものです。アジャイル宣言の署名者の一人であるこのアメリカ人ソフトウェアエンジニアは、1990年代半ばに「OODの原則」という論文を発表し、後に「設計原則とデザインパターン」という論文を発表し、現代のオブジェクト指向設計の多くの基礎を築きました。

時が経つにつれ、他の作家たち、例えば バーバラ・リスコフとベルトラン・マイヤー 彼らは、この一連の原則に統合されるアイデアも提供してくれました。マイケル・フェザーズは、これらの原則の頭文字をSOLIDという単語に並べ替えるという(非常に鋭い)アイデアを思いつき、それが開発コミュニティで瞬く間に広まりました。

SOLID の 5 つの文字は、次のオブジェクト指向設計原則に対応しており、Python にも適用できます。

  • S – 単一責任原則 (単一責任の原則)
  • O – オープン/クローズ原則 (オープン/クローズド原則)
  • L – リスコフの置換原則 (リスコフの置換原理)
  • I – インターフェース分離の原則 (界面分離の原理)
  • D – 依存性逆転の原則 (依存逆転の原則)

基本的な考え方は、これら5つの原則を組み合わせることで、 柔軟でテストしやすく、保守しやすいソフトウェアの作成に役立ちますこれにより、展開が高速化され、原因不明のバグが減り、コードの再利用性が向上し、プロジェクトが数年間稼働した後の問題が減ります。

Python で使用される SOLID 原則とは何ですか?

PythonでSOLID原則を適用することは、単なる学問的な演習ではなく、チームの日々の業務に直接的な影響を与えます。これらの原則を遵守することで、 スパゲッティコードを削減し、コードの臭いを減らし、コードベースが「腐った臭い」になるのを防ぎます。有名な例え話で言えば、「臭いがするということは、設計が悪いということだ」。Windowsでは多くの開発者が WSL2のインストールと構成 Linux 環境を本番環境に近いものにするためです。

共同作業環境(バックエンド開発チーム、データエンジニアリング、長期サイクルの製品など)では、これらの原則が鍵となります。 複数の人が、自分の境界を越えたり、ちょっとした接触ですべてを壊したりすることなく、同じコードベースで作業することができます。さらに、Pythonは柔軟で動的であるにもかかわらず、典型的なOOP抽象化(抽象クラ​​ス、継承階層、合成、インターフェース)をシームレスに適用できます。 abc, etc.

要約すると、SOLID は次のことを達成するのに役立ちます。

  • よりクリーンで読みやすいコードそれを書いてから何年も経った後でも。
  • テスト可能性の向上責任が明確に分離されているからです。
  • 高い再利用性と拡張性 モジュール間の厳格な依存関係が少なくなるためです。
  • 付随エラーの減少1 つのモジュールで何かを変更しても、誤って他の 5 つのモジュールが壊れることはありません。

S – 単一責任原則

第一原則は、 クラスを変更する理由は 1 つだけにする必要があります。言い換えれば、明確に定義された単一の責任を担う必要があるということです。これは、メソッドが1つしかないという意味ではなく、すべてのロジックが単一の一貫した目的に向けられているという意味です。

ユーザーを表し、そのデータの保存に加えて、データベースへのアクセスとレポートの生成も処理する Python クラスを想像してください。

class User:
    def __init__(self, name: str):
        self.name = name

    def get_user_from_database(self, user_id: int) -> dict:
        # Recupera datos desde la base de datos
        # ...
        pass

    def save_user_to_database(self) -> None:
        # Persiste el usuario en la base de datos
        # ...
        pass

    def generate_user_report(self) -> str:
        # Genera un informe del usuario
        # ...
        pass

これがクラスです 3つの異なる責任を混合ユーザーの表現、永続性の管理、レポートの作成。データベース、レポート形式、またはユーザー属性を変更すると、同じクラスを変更する必要があり、横断的なバグが発生するリスクが高まります。

これらの懸念を分離すると、設計は大幅に改善されます。

class User:
    def __init__(self, name: str):
        self.name = name


class UserDB:
    @staticmethod
    def get_user(user_id: int) -> User:
        # Lógica para obtener usuarios de la base de datos
        # ...
        return User("John Doe")

    @staticmethod
    def save_user(user: User) -> None:
        # Lógica para guardar el usuario
        # ...
        pass


class UserReportGenerator:
    @staticmethod
    def generate_report(user: User) -> str:
        # Lógica para generar informes de usuario
        # ...
        return f"Report for user: {user.name}"

さて、クラス ユーザーはユーザーを実体としてのみ表しますレポートの生成方法が変更された場合は、 UserReportGeneratorデータベースを変更する場合は、 UserDB各クラスには変更の理由が 1 つだけあるため、デバッグとシステムの進化が簡単になります。

SRPをより現実的な例に適用する:アヒルとコミュニケーション

古典的なシナリオを適応させてみましょう:クラス Duck 当初は徐々に責任が加わり、最終的にはメンテナンスが困難なモンスターへと変化していきます。単純な実装を想像してみてください。

class Duck:
    def __init__(self, name: str):
        self.name = name

    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

    def greet(self, other_duck: "Duck") -> None:
        print(f"{self.name}: {self.do_sound()}, hello {other_duck.name}")

クラス それは単に「アヒル」と定義されるべきであるしかし、Duckクラスはそれらがどのように相互に通信するかも管理します。将来、会話のロジック(フレーズの追加、他の言語、異なるチャネルなど)を変更する場合、既にエンティティとして適切に機能しているDuckクラスを変更する必要があります。

SRP を尊重する解決策は、通信に特化した別のクラスからその 2 番目の責任を抽出することです。

class Duck:
    def __init__(self, name: str):
        self.name = name

    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    def communicate(self, duck1: Duck, duck2: Duck) -> None:
        sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
        sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
        conversation = 
        print(*conversation, f"(via {self.channel})", sep="\n")

この分離のおかげで、 アヒルの定義に触れることなく、コミュニケーションロジックを進化させることができるさらに、コードのテストも容易になります。 Duck そして一方で、 Communicator責任を混同することなく。

O – オープン/クローズ原則

OCP原則は、 ソフトウェア エンティティは、動作の拡張に対してはオープンである必要がありますが、直接的な変更に対してはクローズである必要があります。言い換えれば、新しい機能を追加する場合、理想的には、既に動作していて他のモジュールで使用されているクラスを書き直す必要はありません。

典型的な例としては、幾何学図形の面積の計算が挙げられます。まずは、 OCPを尊重しない:

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


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


class AreaCalculator:
    def calculate_area(self, shape) -> float:
        if isinstance(shape, Rectangle):
            return shape.width * shape.height
        elif isinstance(shape, Circle):
            return 3.14159 * shape.radius * shape.radius
        else:
            raise ValueError("Forma no soportada")

明日三角形を追加したい場合、 コードを変更する AreaCalculatorさらに追加 elifこれは、クラスが変更に対して「閉じられていない」ため、OCP に違反します。

正しいバージョンでは抽象化を導入する必要がある Shape 方法で area() 各図はそれぞれ独自の方法で実装されています。

from abc import ABC, abstractmethod


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


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

    def area(self) -> float:
        return self.width * self.height


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

    def area(self) -> float:
        return 3.14159 * self.radius * self.radius


class AreaCalculator:
    def calculate_area(self, shape: Shape) -> float:
        return shape.area()

このデザインのおかげで、 触れない三角形を追加する AreaCalculator新しいサブクラスを作成するだけです。

class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height

    def area(self) -> float:
        return 0.5 * self.base * self.height

オープン/クローズ原則は、 抽象化を通じて明確な拡張ポイントを定義する: インターフェース、抽象クラス、フックなど。Pythonでは、モジュール abc 言語が動的であっても、これを明示的に表現することができます。

通信機の例にOCPを適用

先ほどの例に戻ると コミュニケーターさらに一歩進んで、コミュニケータを毎回書き換えることなく、さまざまな種類の会話をサポートできるように設計することもできます。そのためには、会話の抽象化を定義し、コミュニケータがそれをのみ使用するようにします。

from typing import final
from abc import ABC, abstractmethod


class AbstractConversation(ABC):
    @abstractmethod
    def do_conversation(self) -> list:
        pass


class SimpleConversation(AbstractConversation):
    def __init__(self, duck1: Duck, duck2: Duck):
        self.duck1 = duck1
        self.duck2 = duck2

    def do_conversation(self) -> list:
        sentence1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
        sentence2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
        return 


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    @final
    def communicate(self, conversation: AbstractConversation) -> None:
        print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")

このバージョンでは、 新しい話し方を追加したい場合 (例えば、攻撃的な会話、ターンテイキングの会話など)別のサブクラスを作成するだけで、 AbstractConversation。 メソッド communicate() de Communicator これは変更されず、OCP に厳密に準拠します。

L – リスコフの置換原則

バーバラ・リスコフによって定式化されたリスコフの置換原理は、 サブクラスは、プログラムの予想される動作を変更することなく、基本クラスを置き換えることができる必要があります。実際には、コードが基本クラスの 1 つのインスタンスで機能する場合、サブクラスのどのインスタンスでも同様に機能するはずです。

LSP違反の典型的な例は、すべての鳥を1つの方法でモデル化することです。 fly()ダチョウを含む:

class Bird:
    def fly(self) -> None:
        pass


class Duck(Bird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    def fly(self) -> None:
        # Las avestruces no vuelan
        raise NotImplementedError("Las avestruces no pueden volar")

それを前提とするコードは 飛べる鳥は皆、ダチョウを捕まえると失敗する。 つまり Ostrich これは有効な代替品ではありません Bird、それによって LSP に違反します。

解決策は、現実をよりよく反映するように階層を調整することです。すべての鳥が飛ぶわけではないので、 一部の鳥だけがその方法を持つべきである fly():

class Bird:
    pass


class FlyingBird(Bird):
    def fly(self) -> None:
        pass


class Duck(FlyingBird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    # No vuela, así que no implementa fly()
    pass

このデザインでは、 飛んでいる鳥を必要とする関数は、飛んでいる鳥が必要であることを宣言します。 FlyingBirdダチョウを受け取ることはありません。これにより、LSPが尊重され、予期しないランタイム例外が回避されます。

LSPと鳥との会話

会話の例に戻ると、最初はアヒルのことだけを考えながらコーディングを始め、その後カラスや他の鳥を追加したくなるのはよくあることです。会話クラスが Duck, 他の種類の鳥には再利用できません コードに触れることなく:

class Crow:
    # Implementación específica del cuervo
    ...

Si SimpleConversation これはアヒル専用に型付けされているため、変更を加えずにカラスに適用することはできません。正しいアプローチは、共通の抽象化を作成することです。 Bird そして会話をその抽象化に依存させます。

from abc import ABC, abstractmethod


class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def do_sound(self) -> str:
        pass


class Crow(Bird):
    def do_sound(self) -> str:
        return "Caw"


class Duck(Bird):
    def do_sound(self) -> str:
        return "Quack"


class SimpleConversation(AbstractConversation):
    def __init__(self, bird1: Bird, bird2: Bird):
        self.bird1 = bird1
        self.bird2 = bird2

    def do_conversation(self) -> list:
        sentence1 = f"{self.bird1.name}: {self.bird1.do_sound()}, hello {self.bird2.name}"
        sentence2 = f"{self.bird2.name}: {self.bird2.do_sound()}, hello {self.bird1.name}"
        return 

このように、 Bird 契約を尊重する(do_sound()(名前など)は 有効な代替品 そして、期待される動作を壊すことはありません SimpleConversation.

I – インターフェース分離の原則

ISP原則は、 顧客は、使用しない方法に頼ることを強制されるべきではありません。これを抽象クラスまたはインターフェースに翻訳すると、1 つの巨大な汎用インターフェースよりも、複数の小さな特定のインターフェースを持つ方がよいことを意味します。

このインターフェースのデザインに注目してください Worker それを実行するすべての人に、特定の作業と食事の方法を求めます。

from abc import ABC, abstractmethod


class Worker(ABC):
    @abstractmethod
    def work(self) -> None:
        pass

    @abstractmethod
    def eat(self) -> None:
        pass


class Human(Worker):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Worker):
    def work(self) -> None:
        print("El robot está trabajando")

    def eat(self) -> None:
        # El robot no come, pero está obligado a declarar este método
        pass

クラス ロボットは方法に依存する eat() 必要のない食べ物に関連するあらゆる変化は、たとえその行動とは関係がなくても、ロボットに影響を与えます。

ISP を適用することで、インターフェースを 2 つのより小さく、より具体的なインターフェースに分割しました。

class Workable(ABC):
    @abstractmethod
    def work(self) -> None:
        pass


class Eatable(ABC):
    @abstractmethod
    def eat(self) -> None:
        pass


class Human(Workable, Eatable):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Workable):
    def work(self) -> None:
        print("El robot está trabajando")

今、 各クラスは実際に必要なメソッドのみを実装します。これにより結合が減り、設計の進化が促進され、コードの表現力が高まり、誰が何を実行できるかが非常に明確になります。

鳥類モデリングにおけるISP:飛行と遊泳

飛ぶ鳥や泳ぐ鳥をモデル化する場合にも同様のことが起こります。基本的な抽象化が Bird 両方を実装する必要がある fly() として swim()次のようなクラスになります Crow 泳ぎ方を知っているふりをしなければならない人たち:

class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def fly(self) -> None:
        pass

    @abstractmethod
    def swim(self) -> None:
        pass

    @abstractmethod
    def do_sound(self) -> str:
        pass

ISPによると解決策は インターフェースをより具体的な機能に分離する:

class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def do_sound(self) -> str:
        pass


class FlyingBird(Bird):
    @abstractmethod
    def fly(self) -> None:
        pass


class SwimmingBird(Bird):
    @abstractmethod
    def swim(self) -> None:
        pass


class Crow(FlyingBird):
    def fly(self) -> None:
        print(f"{self.name} is flying high and fast!")

    def do_sound(self) -> str:
        return "Caw"


class Duck(SwimmingBird, FlyingBird):
    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

ペンギンのモデルを作ろうと決めたら、 あなたは彼に相続させる SwimmingBird しかし、そうではありません FlyingBirdまた、空のメソッドを実装したり、人工的な例外をスローしたりする必要もありません。

D – 依存性逆転の原則

最後の原則である DIP は、次の 2 つの重要な考え方に要約できます。 高レベルモジュールは低レベルモジュールに依存すべきではなく、両方とも抽象化に依存する必要があります。そして、抽象化は詳細に依存するべきではなく、むしろ詳細が抽象化に依存するべきです。

実際には、ビジネスロジックは「MySQLを使用する」「ローカルファイルに書き込む」「このプロバイダでSMSメッセージを送信する」といった具体的な詳細に縛られるべきではないことを意味します。代わりに、 抽象インターフェース (たとえば、 Database, Channel, NotificationService) を作成し、高レベルのコードをそれらとのみ通信させます。

というデザイン DIPを破る これは、MySQL データベースを直接インスタンス化するユーザー リポジトリになります。

class MySQLDatabase:
    def connect(self) -> None:
        # Conectar a MySQL
        pass

    def query(self, sql: str) -> list:
        # Ejecutar consulta
        return []


class UserRepository:
    def __init__(self) -> None:
        self.database = MySQLDatabase()  # Dependencia directa

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

明日PostgreSQLを使うことに決めたら、 上位クラスを変更する UserRepository特定の実装の詳細に縛られています。

DIP を適用することで、まずデータベースの抽象化を定義し、次に具体的な実装をそこから継承します。

from abc import ABC, abstractmethod


class Database(ABC):
    @abstractmethod
    def connect(self) -> None:
        pass

    @abstractmethod
    def query(self, sql: str) -> list:
        pass


class MySQLDatabase(Database):
    def connect(self) -> None:
        # Conexión a MySQL
        pass

    def query(self, sql: str) -> list:
        # Consulta en MySQL
        return []


class PostgreSQLDatabase(Database):
    def connect(self) -> None:
        # Conexión a PostgreSQL
        pass

    def query(self, sql: str) -> list:
        # Consulta en PostgreSQL
        return []


class UserRepository:
    def __init__(self, database: Database) -> None:
        self.database = database  # Depende de una abstracción

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

このように、 任意の実装を注入することができます Database リポジトリを作成するときに、内部コードに触れることなく:

mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)

postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)

このパターンは 依存性注入 これは DIP を適用する最も一般的な方法です。クラスは独自の依存関係を作成せず、常に抽象化を型として使用して、外部から依存関係を受け取ります (コンストラクターまたは特定のメソッドを通じて)。

チャネルとコミュニケータに適用されるDIP

鳥の会話の例では、DIPを適用することでチャネル管理を改善することもできます。チャネルとコミュニケータにそれぞれ別の抽象化を定義するとします。

class AbstractChannel(ABC):
    @abstractmethod
    def get_channel_message(self) -> str:
        pass


class AbstractCommunicator(ABC):
    @abstractmethod
    def get_channel(self) -> AbstractChannel:
        pass

    @final
    def communicate(self, conversation: AbstractConversation) -> None:
        print(*conversation.do_conversation(),
              self.get_channel().get_channel_message(),
              sep="\n")

最初の単純な実装は次のようになります。

class SMSChannel(AbstractChannel):
    def get_channel_message(self) -> str:
        return "(via SMS)"


class SMSCommunicator(AbstractCommunicator):
    def __init__(self) -> None:
        self._channel = SMSChannel()  # Depende de detalle concreto

    def get_channel(self) -> AbstractChannel:
        return self._channel

正しいように思えますが、 この通信機は、 SMSChannelコミュニケータが外部からチャネルを受信するようにすることで(依存性注入)、設計を改善し、抽象化のみに依存するようになりました。

class SimpleCommunicator(AbstractCommunicator):
    def __init__(self, channel: AbstractChannel) -> None:
        self._channel = channel

    def get_channel(self) -> AbstractChannel:
        return self._channel

このアプローチでは、新しいチャネル(メール、プッシュ通知など)は AbstractChannel y 通信コードを変更せずに使用できます。繰り返しますが、高レベルのクラスは詳細ではなく抽象化に依存します。

SOLID を無視するとどうなるでしょうか?

これらの原則を考慮しないと、コードは次のような問題に悩まされる傾向があります。 コードの臭い、コードの腐敗、そして解きほぐすことのできない結合つまり、何千もの責任を持つ巨大なクラス、契約を破るサブクラス、循環的な依存関係、そして、あまりにも多くのことを実行するため 1 日おきに変更されるメソッドです。

結果は明らかであり、どのチームにとっても非常に痛いものとなります。 脆弱性やバグが増え、リファクタリングが頻繁に行われ、最悪の場合、実質的に使用できないコードになってしまいます。これは一般に「スパゲッティ コード」と呼ばれるもので、理解するのが難しく、パッチが多く、重要な部分を壊さずに拡張することがほぼ不可能です。

SOLID原則は不変のものではなく、特にラピッドプロトタイピングや非常に小規模なプロジェクトにおいては、必ずしもすべてを厳密に適用する価値があるわけではありません。それでも、 これらを念頭に置いて、Python でのオブジェクト指向設計のほとんどに適用してください。 これは、時間の経過とともに拡大するプロジェクトと、少し大きくなるとすぐに崩壊するプロジェクトの違いを生みます。

Windows 11プログラミングに最適なIDE
関連記事
Windows 11でプログラミングするのに最適なIDE

Googleで優先ソースとして追加する