Skip to content

Instantly share code, notes, and snippets.

@welel
Last active February 27, 2024 23:29
Show Gist options
  • Save welel/8f3ef63e265c15763d169eff4627265d to your computer and use it in GitHub Desktop.
Save welel/8f3ef63e265c15763d169eff4627265d to your computer and use it in GitHub Desktop.
SOLID принципы с примерами на Python

SOLID с примерами на Python

Примечание: под клиентом подразумевается программные сущности, использующие другие программные сущности;

SOLID — это мнемоническая аббревиатура для набора принципов проектирования, созданных для разработки программного обеспечения при помощи объектно-ориентированных языков. Принципы SOLID направленны на содействие разработки более простого, надежного и обновляемого кода. Каждая буква в аббревиатуре SOLID соответствует одному принципу разработки.

При правильной реализации это делает ваш код более расширяемым, логичным, легко поддерживаемым и легким для чтения.

Для понимания SOLID принципов, вы должны хорошо понимать как, используются интерфейсы.

Рассмотрим каждый принцип один за другим:

1. Single Responsibility Principle
(Принцип единственной обязанности)

Принцип единственной обязанности требует того, чтобы один класс выполнял только одну работу (то же относится и к остальным программным сущностям). Т.е. необходимо производить декомпозицию программных сущностей, чтобы каждая сущность отвечала за возложенную на неё задачу. Когда класс берет на себя много обязанностей - такой антипаттерн называют God Object.

Если у класса есть более одной работы, он:

  • становится зависимым (изменение поведения одной работы класса приводит к изменению в другой),
  • ухудшается читаемость кода,
  • сложно тестировать,
  • появляются сложности в совместной разработке кода.
# Листинг [1.1]
# Пример класса с множеством обязанностей.

class  User:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        return self.name

    def save(self):
        ...

    def send(self):
        ...

    def log(self):
        ...

Мы имеем класс User, который ответственен за несколько работ — свойства пользователя, управление базой данных, отправку данных и логирование. Если в приложении будет изменен функционал одной работы, это может повлечь за изменениями в других, чтобы компенсировать новые изменения. Это как домино эффект, уроните одну кость, и она уронит все за ней следом.

Мы же просто декомпозируем класс, создадим отдельные классы, которые возьмут на себя одну ответственность.

# Листинг [1.2]
# Пример декомпозиции класса `User`.

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


class Storage:
    def save(self, user: User):
        ...


class HttpConnection:
    def send(self, user: User):
        ...


class Logger:
    def log(self, user: User):
        ...

Теперь наш код стал лучше структурирован. Размеры отдельных сущностей стали меньше, следовательно, их легче читать и с ними легче работать. Появилась возможность дать задачи нескольким разработчикам изменять разные компоненты одновременно, и никаких конфликтов возникать не должно.

2. Open-Closed Principle
(Принцип открытости/закрытости)

Программные сущности (классы, модули, функции и т.д.) должны быть открыты для расширения, но закрыты для модификации.

Изменять уже существующий код плохо, потому что он уже протестирован и работает. Если мы изменяем код, то приходится делать регрессионное тестирование. Поэтому при добавлении функционала следует не изменять уже имеющиеся сущности, а добавлять новые при помощи композиции или наследования. Даже при таком подходе, возможно придётся чуть-чуть отредактировать старый код, чтобы не допустить багов или костылей при написании нового. Но изменения старого кода необходимо максимально избегать.

Давайте представим, что у вас есть магазин, и вы даете скидку в 20% для ваших любимых покупателей используя класс Discount. Если вы решаете удвоить 20-ти процентную скидку для VIP клиентов, вы могли бы изменить класс следующим образом:

# Листинг [2.1]
# Пример модификации класса, при добавлении нового функционала.

class Discount:
  def __init__(self, customer: str, price: int):
      self.customer = customer
      self.price = price
      
  def give_discount(self):
      if self.customer == 'favourite':
          return self.price * 0.2
      if self.customer == 'vip':
          return self.price * 0.4

Но нет, это нарушает OCP, OCP запрещает это. Например, если мы хотим дать новую скидку для другого типа покупателей, то это требует добавления новой логики. Чтобы следовать OCP принципу, мы добавим новый класс, который будет расширять Discount. И в этом новом классе реализуем эту логику:

# Листинг [2.2]
# Пример добавления функционала путем наследования.

class Discount:
    def __init__(self, customer, price):
      self.customer = customer
      self.price = price
      
    def get_discount(self):
      return self.price * 0.2

class VIPDiscount(Discount):
    def get_discount(self):
      return super().get_discount() * 2

Если вы решите дать скидку супер VIP пользователям, то это будет выглядеть так:

# Листинг [2.3]
# Пример добавления функционала путем наследования (2).

class SuperVIPDiscount(VIPDiscount):
    def get_discount(self):
      return super().get_discount() * 2

Таким образом, мы не затрагиваем уже существующий код (закрыт для модификации), а добавляем новый (открыт для расширения).

Когда вы продумываете схему своих сущностей, стоит на самом раннем этапе выявить сущности системы, которые могут меняться или расширятся в будущем и написать для них правильные абстракции.

Рассмотрим ещё один пример: у нас есть класс Weapon (оружие) и Character (персонаж). В этой программе персонаж владеет оружием и может наносить удары оружием.

# Листинг [3.1]
# Пример программы, плохо поддающейся расширению.

class Weapon:
    def __init__(self, name: str, damage: int):
        self.name = name
        self.damage = damage

    def attack(self):
        print(f"{self.name} наносит удар: -{self.damage} hp")


class Character:
    def __init__(self, name: str, weapon: Weapon):
        self.name = name
        self.weapon = weapon

    def change_weapon(self, weapon: Weapon):
        self.weapon = weapon

    def attack(self):
        self.weapon.attack()


sword = Weapon("Needle", 24, 3)
aria = Character("Aria", sword)
aria.attack() # Output: Needle наносит удар: -24 hp

Теперь мы решили добавить новое оружие - лук, и нам приходится менять метод Weapon.attack и добавлять дополнительное поле type, чтобы расширить логику вывода (добавить "стреляет", вместо "наносит" для лука).

# Листинг [3.2]
# Пример добавления нового функционала с нарушением OCP.

class Weapon:
    def __init__(self, _type: str, name: str, damage: int):
        self.type = _type
        self.name = name
        self.damage = damage

    def attack(self):
        if self.type == "striking":
            print(f"{self.name} наносит удар: -{self.damage} hp")
        elif self.type == "shooting":
            print(f"{self.name} стреляет: -{self.damage} hp")


sword = Weapon("striking", "Needle", 24, 3)
aria = Character("Aria", sword)
aria.attack() # Output: Needle наносит удар: -24 hp

bow = Weapon("shooting", "Twig", 30, 100)
aria.change_weapon(bow)
aria.attack() # Output: Twig стреляет: -30 hp

Как мы рассмотрели выше, такой подход нарушает OCP. При написании класса Weapon заранее не было предусмотрено его расширение для стрелковых орудий. С самого начала стоило создать более абстрактный код.

# Листинг [3.3]
# Пример программы, хорошо поддающейся расширению.

class Attacker:
    """Интерфейс для атакующих классов."""
    def attack(): raise NotImplementedError


class Weapon(Attacker):
    """Задает общую структуру орудий."""
    def __init__(self, name: str, damage: int):
        self.name = name
        self.damage = damage


class Sword(Weapon):
    """
    Наследует структуру орудия и реализует интерфейс для атаки.
    """
    def attack(self):
        print(f"{self.name} наносит удар: -{self.damage} hp")


class Bow(Weapon):
    def attack(self):
        print(f"{self.name} стреляет: -{self.damage} hp")


sword = Sword("Needle", 24, 3)
bow = Bow("Twig", 30, 100)

aria = Character("Aria", sword)
aria.attack() # Output: Needle наносит удар: -24 hp

aria.change_weapon(bow)
aria.attack() # Output: Twig стреляет: -30 hp

Такой код легче расширяется, выглядит чище и профессиональнее. Следует заметить, что если вы точно уверены, что у вас не будет расширения функционала в будущем, тогда лучше придерживаться принципа KISS и не создавать дополнительные абстракции.

3. Liskov Substitution Principle
(Принцип подстановки Лисков)

Главная идея, стоящая за Liskov Substitution Principle в том, что для любого класса клиент должен иметь возможность использовать любой подкласс базового класса, не замечая разницы между ними, и следовательно, без каких-либо изменений поведения программы при выполнении. Это означает, что наследуемый класс должен дополнять, а не замещать поведение родителя и, что клиент полностью изолирован и не подозревает об изменениях в иерархии классов.

Формальное определение: Пусть q(x) является свойством, верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.

Проще говоря, если вы в дочернем классе нарушаете логику родительского, то вы нарушаете принцип LSP.

# Листинг [4]
# Пример программы, нарушающей принцип LSP.

class Develpoer:
    def write_code(self): ...


class Backend(Developer):
    def configure_server(self): ...


class DevOps(Developer):
    """Представим, что наш DevOps не умеет писать код."""
    def monitor_resources(self): ...
	
    def write_code(self):
        """Изменяем реализацию, тем самым нарушая LSP."""
        raise UnableToDo("DevOps не может писать код.")

В листинге выше DevOps специалист нарушил логику своего родителя, тем самым нарушив принцип LSP. Потому что в соответствии с принципом, клиент, который использует Developer, должен иметь возможность заменить его на любой дочерний класс и не сломать программу. В случае в дочерним классом DevOps программа станет выдавать ошибку.

Следующий пример демонстрирует возможность клиента использовать класс и его потомков без нарушения логики программы.

# Листинг [5]
# Пример программы, соблюдающей принцип LSP.

from dataclasses import dataclass


@dataclass
class Position:
    x: int = 0
    y: int = 0

    def __str__(self):
        return f"({self.x}, {self.y})"


class Character:
    """Суперкласс персонажей."""
    def __init__(self, name: str):
        self.name = name
        self.position = Position()

    def move(self, destination: Position):
        print("{name} двигается с {start} на {end}".format(
            name=self.name, start=self.position, end=destination
        ))
        self.position = destination


class Human(Character):
    """Дочерний класс, соблюдающий логику родителя."""
    def move(self, destination: Position):
        print("{name} идёт с {start} на {end}".format(
            name=self.name, start=self.position, end=destination
        ))
        self.position = destination

    def buy(self):
        """Добавляет свою логику."""
        print("Купить предмет.")


class Dragon(Character):
    """Дочерний класс, соблюдающий логику родителя."""
    def move(self, destination: Position):
        print("{name} летит с {start} на {end}".format(
	    name=self.name, start=self.position, end=destination
        ))
        self.position = destination

    def attack(self):
        """Добавляет свою логику."""
        print("Извернуть пламя на противника.")


def move(character: Character, destination: Position):
    """
    Клиент, который использует `Character` и его потомков,
    не замечая разницы.
    """
    character.move(destination)


spirit = Character("Spirit")
john = Human("John")
drogon = Dragon("Drogon")

meeting_point = Position(x=300, y=250)

move(spirit, meeting_point)
move(john, meeting_point)
move(drogon, meeting_point)

# Output:
# Spirit двигается с (0, 0) на (300, 250)
# John идёт с (0, 0) на (300, 250)
# Drogon летит с (0, 0) на (300, 250)

Как мы видим функция move может работать как с Character, так и с его потомками без ошибок.

LSP это основа хорошего объектно-ориентированного проектирования программного обеспечения, потому что он следует одному из базовых принципов ООП — полиморфизму. Речь о том, чтобы создавать правильные иерархии такие, что классы, производные от базового являлись полиморфными для их родителя по отношению к методам его интерфейсов. Ещё интересно отметить, как этот принцип относится к примеру предыдущего принципа. Если мы пытаемся расширить класс новым несовместимым классом, то все сломается. Взаимодействие с клиентом будет нарушено, и как результат, такое расширение будет невозможно (или, для того чтобы сделать это возможным, нам пришлось бы нарушить другой принцип и модифицировать код клиента, который должен быть закрыт для модификации, такое крайне нежелательно и неприемлемо).

Тщательное обдумывание новых классов в соответствии с LSP помогает нам расширять иерархию классов правильно. Также, LSP способствует OCP.

4. Interface Segregation Principle
(Принцип разделения интерфейсов)

Клиенты не должны зависеть от интерфейсов, которые они не используют. Нельзя заставлять клиента реализовывать интерфейс, которым он не пользуется.

Создавайте тонкие интерфейсы: много интерфейсов, предназначенных для конкретного клиента - лучше, чем один интерфейс общего назначения. Этот принцип устраняет недостатки реализации больших интерфейсов.

Чтобы проиллюстрировать это, возьмем следующий пример. Представим, что у нас стояла задача создать Smartphone. Мы создали для него и будущих устройств интерфейс Device. Потом у нас появилась задача дописать Laptop, который не умеет звонить. Тут мы должны понять, что наш интерфейс Device противоречит ISP, и нам следует его разделить. Но если бы мы не знали о ISP, то могли бы написать Laptop как в листинге [6.1]. И когда появилась бы задача дописать Phone, тоже бы нарушили принцип. Получился бы следующий код:

# Листинг [6.1]
# Пример программы, нарушающей ISP.

# В листингах 6.* под ... подразумеваем пропущенную
# реализацию метода.

class Device:
    def call(self): raise NotImplementedError
    def send_file(self): raise NotImplementedError
    def browse_internet(self): raise NotImplementedError


class Smartphone(Device):
    def call(self): ...
    def send_file(self): ...
    def browse_internet(self): ...


class Laptop(Device):
    def call(self):
        raise BadOperation("Ноутбук не может звонить.")

    def send_file(self): ...
    def browse_internet(self): ...


class Phone(Device):
    def call(self): ...

    def send_file(self):
        raise BadOperation("Телефон не может отправлять файлы.")

    def browse_internet(self):
        raise BadOperation("Телефон не может выходить в интернет.")

Это чёткая иллюстрация зависимости клиентов Laptop и Phone от интерфейса Device, который они реализуют лишь частично.

Приятный трюк заключается в том, что в нашей бизнес-логике отдельный класс может реализовать несколько интерфейсов, если необходимо. Таким образом, мы можем предоставить единую реализацию для всех общих методов между интерфейсами. В Python это легко решается множественным наследованием:

# Листинг [6.2]
# Пример программы, соблюдающей ISP.

class CallDevice:
    def call(self): raise NotImplementedError


class FileTransferDevice:
    def send_file(self): raise NotImplementedError


class InternetDevice:
    def send_file(self): raise NotImplementedError


class Smartphone(CallDevice, FileTransferDevice, InternetDevice):
    def call(self): ...
    def send_file(self): ...
    def browse_internet(self): ...


class Laptop(FileTransferDevice, InternetDevice):
    def send_file(self): ...
    def browse_internet(self): ...


class Phone(CallDevice):
    def call(self): ...

Теперь мы видим тонкие интерфейсы и избавляем программные сущности от методов, которые они не используют. Получаем более предсказуемую работу и код становится менее связанным.

Сегрегированные интерфейсы заставляют нас больше думать о нашем коде с точки зрения клиента, что приведет нас к меньшей зависимости и более легкому тестированию. Таким образом, мы не только сделали наш код лучше для клиента, но также это облегчило нам понимание, тестирование и реализацию кода для нас самих.

5. Dependency Inversion Principle
(Принцип инверсии зависимостей)

Зависимость должна быть от абстракций, а не от конкретики. Модули верхних уровней не должны зависеть от модулей нижних уровней. Классы и верхних, и нижних уровней должны зависеть от одних и тех же абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Наступает момент в разработке, когда наше приложение в основном состоит из модулей. Когда такое происходит, нам необходимо улучшать код используя внедрение зависимостей. Функционирование компонентов высокого уровня зависит от компонентов низкого уровня. Для создания определенного поведения вы можете использовать наследование или интерфейсы.

Сначала рассмотрим плохой пример. Представим, что у нас есть сущность Post, и мы дали задание трём программистам реализовать различные хранилища для постов. Они не договорились об именовании и создали хранилища с разными названиями методов. Это очень плохо, потому что сущность, которая будет использовать хранилища для сохранения постов сильно зависит от конкретной реализации хранилища и должна будет каждый раз подстраиваться под каждое хранилище при их смене.

# Листинг [7.1]
# Пример неорганизованного кода.

class Post:
    title: str
    content: str


class PostLocalStorage:
    def fetch_all(self): ...
    def get_one(self): ...
    def save(self): ...


class PostCacheDict:
    def get_all(self): ...
    def get(self): ...
    def set(self): ...


class PostDBStorage:
    def select_all(self): ...
    def select_one(self): ...
    def insert(self): ...

Первый шаг к структуризации кода и избавлении от зависимостей - это создание общего интерфейса для хранилищ.

# Листинг [7.2]
# Пример введения общего интерфеса для классов хранилищ.

class Storage:
    def get_all(self): raise NotImplementedError
    def get(self): raise NotImplementedError
    def save(self): raise NotImplementedError


class PostLocalStorage(Storage):
    def get_all(self): ...
    def get(self): ...
    def save(self): ...

# Другие хранилища тоже наследуются от `Storage`.
...

Теперь все хранилища используют одинаковые названия методов, что позволяет клиенту использовать хранилище без знания его типа. Но ещё лучше - это ввести абстракцию и работать напрямую с ней. Эта абстракция будет получать объект хранилища и делегировать вызов конкретных методов хранилищу.

# Листинг [7.3]
# Пример введения общей абстракции для хранилищ.

class StorageClient(Storage):
    def __init__(self, storage: Storage):
        self.storage = storage

    def get_all(self):
        return self.storage.get_all()

    def get(self, *args):
        return self.storage.get(*args)

    def save(self, *args):
        return self.storage.save(*args)

При таком подходе:

  • клиент всегда работает с абстракцией хранилища StorageClient;
  • прозрачный интерфейс;
  • клиент не зависит от конкретной реализации хранилищ.

Примечание: клиент хранилища != StorageClient (клиент хранилища - тот кто сохраняет посты с помощью StorageClient).

Если отразить итоговую программу на определение, то клиент теперь зависит от абстракции StorageClient, а не от PostLocalStorage и т.д. Модуль верхнего уровня (клиент хранилища) не зависит от модулей нижних уровней (реализаций хранилищ). Класс верхнего (клиент хранилища) и нижних (реализации хранилищ) зависят от одной и той же абстракции - StorageClient. StorageClient не зависит от деталей реализации хранилищ, она просто делегирует выполнение методов общего интерфейса. Детали реализации зависят от StorageClient и ориентируются на её.

Источники:

  1. S.O.L.I.D Principles explained in Python with examples
  2. YouTube: SOLID принципы простым языком (много примеров)
  3. Принципы SOLID
  4. Википедия: Принцип подстановки Барбары Лисков
  5. Википедия: Принцип разделения интерфейса
  6. Википедия: Принцип инверсии зависимостей

Больше можно прочитать:

@imelnyk1347
Copy link

S.O.L.I.D Principles explained in Python with examples - not working url - Error 410

@welel
Copy link
Author

welel commented Feb 28, 2021

S.O.L.I.D Principles explained in Python with examples - not working url - Error 410

Thank you. The link is updated.

Update: The author deleted the story.

@maksymefimov
Copy link

Thank you.This is good topic for beginners.

@PenthagonHacker
Copy link

Большое спасибо!!

@async-python
Copy link

Спасибо, с принципами подстановки Лисков еще бы примеров, не совсем улавливается сам принцип, т.к. речь идет о взимозаменяемости класса и его наследника (если я правильно понял).

@defenitionofreal
Copy link

@vardeath На сколько я это понял (Лисков) то, это как бы есть базовый класс со всеми нужными атрибутами для дочерних классов.

@redbuzzz
Copy link

Спасибо за статью, возник такой вопрос, чем полиморфизм отличается от 3 принципа SOLID ?(хотя бы в Python)

@welel
Copy link
Author

welel commented Feb 25, 2023

@async-python

Спасибо, с принципами подстановки Лисков еще бы примеров, не совсем улавливается сам принцип, т.к. речь идет о взимозаменяемости класса и его наследника (если я правильно понял).

Тут мы говорим о реализации иерархии наследования классов таким образом, что на место какого-то взятого класса, мы могли подставить его потомка и ничего не должно сломаться. Т.е. потомок должен правильно реализовать интерфейсы своего предка.

class SpeakingCreature:
    """Интерфейс для говорящих созданий."""
    name: str = ...

    def speak(self):
        raise NotImplementedError


class Animal(SpeakingCreature):
    """Животное, реализующее интерфейс говорящих созданий.""" 
    def __init__(self, name: str):
        self.name = name

    def speak(self):
        return "Mmm"


class Dog(Animal):
    def speak(self):
        return "Woof!"


class Cat(Animal):
    def speak(self):
        return "Meow!"


def animal_speak(animal: Animal) -> str:
    """Заставляет говорить объекты класса `Animal`."""
    return animal.speak()


if __name__ == '__main__':
    bear = Animal("Bear")
    dog = Dog("Fido")
    cat = Cat("Whiskers")
    
    # Заставляем говорить животное
    print(animal_speak(bear)) # Output: "Mmm"

    # Заставляем говорить потомков `Animal`,
    # и всё успешно работает, т.к. они реализуют
    # одинаковые интерфейсы в едином стиле.
    print(animal_speak(dog)) # Output: "Woof!"
    print(animal_speak(cat)) # Output: "Meow!"

@welel
Copy link
Author

welel commented Feb 25, 2023

@redbuzzz

Спасибо за статью, возник такой вопрос, чем полиморфизм отличается от 3 принципа SOLID ?(хотя бы в Python)

Принцип подстановки Лисков (LSP) является частным случаем полиморфизма. Если в LSP мы говорим о полиморфизме базового класса и его потомков, то в общем случае полиморфизма, мы говорим о любых классах, даже тех, которые не пересекаются в иерархии наследования.

Пример с LSP: Пример выше

Пример обычного полиморфизма:

class Fund:
    def __init__(self, budjet: int):
        self.budjet = budjet

    def __add__(self, fund: "Fund") -> "Fund":
        return Fund(self.budjet + fund.budjet)


def plus(operand_1, operand_2):
    """Складывает объекты, которые реализуют интерфейс `__add__`."""
    return operand_1 + operand_2


if __name__ == "__main__":
    # Интерфейс `__add__` реализуют оба класса - `int` и `Fund`,
    # поэтому функция `plus` успешно работет с любым из этих классов,
    # при этом они не наследуются от одного суперкласса.

    print(plus(1, 2)) # Output: 3

    small_fund = Fund(1000)
    big_fund = Fund(1_000_000)
    merged_fund = plus(small_fund, big_fund)
    print(merged_fund.budjet) # Output: 1001000

    # Если бы `Fund` не реализовал `__add__`, то была бы ошибка:
    # TypeError: unsupported operand type(s) for +: 'Fund' and 'Fund'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment