Примечание: под клиентом подразумевается программные сущности, использующие другие программные сущности;
SOLID — это мнемоническая аббревиатура для набора принципов проектирования, созданных для разработки программного обеспечения при помощи объектно-ориентированных языков. Принципы SOLID направленны на содействие разработки более простого, надежного и обновляемого кода. Каждая буква в аббревиатуре SOLID соответствует одному принципу разработки.
При правильной реализации это делает ваш код более расширяемым, логичным, легко поддерживаемым и легким для чтения.
Для понимания SOLID принципов, вы должны хорошо понимать как, используются интерфейсы.
Рассмотрим каждый принцип один за другим:
Принцип единственной обязанности требует того, чтобы один класс выполнял только одну работу (то же относится и к остальным программным сущностям). Т.е. необходимо производить декомпозицию программных сущностей, чтобы каждая сущность отвечала за возложенную на неё задачу. Когда класс берет на себя много обязанностей - такой антипаттерн называют 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):
...
Теперь наш код стал лучше структурирован. Размеры отдельных сущностей стали меньше, следовательно, их легче читать и с ними легче работать. Появилась возможность дать задачи нескольким разработчикам изменять разные компоненты одновременно, и никаких конфликтов возникать не должно.
Программные сущности (классы, модули, функции и т.д.) должны быть открыты для расширения, но закрыты для модификации.
Изменять уже существующий код плохо, потому что он уже протестирован и работает. Если мы изменяем код, то приходится делать регрессионное тестирование. Поэтому при добавлении функционала следует не изменять уже имеющиеся сущности, а добавлять новые при помощи композиции или наследования. Даже при таком подходе, возможно придётся чуть-чуть отредактировать старый код, чтобы не допустить багов или костылей при написании нового. Но изменения старого кода необходимо максимально избегать.
Давайте представим, что у вас есть магазин, и вы даете скидку в 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 и не создавать дополнительные абстракции.
Главная идея, стоящая за 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.
Клиенты не должны зависеть от интерфейсов, которые они не используют. Нельзя заставлять клиента реализовывать интерфейс, которым он не пользуется.
Создавайте тонкие интерфейсы: много интерфейсов, предназначенных для конкретного клиента - лучше, чем один интерфейс общего назначения. Этот принцип устраняет недостатки реализации больших интерфейсов.
Чтобы проиллюстрировать это, возьмем следующий пример. Представим, что у нас стояла задача создать 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): ...
Теперь мы видим тонкие интерфейсы и избавляем программные сущности от методов, которые они не используют. Получаем более предсказуемую работу и код становится менее связанным.
Сегрегированные интерфейсы заставляют нас больше думать о нашем коде с точки зрения клиента, что приведет нас к меньшей зависимости и более легкому тестированию. Таким образом, мы не только сделали наш код лучше для клиента, но также это облегчило нам понимание, тестирование и реализацию кода для нас самих.
Зависимость должна быть от абстракций, а не от конкретики. Модули верхних уровней не должны зависеть от модулей нижних уровней. Классы и верхних, и нижних уровней должны зависеть от одних и тех же абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Наступает момент в разработке, когда наше приложение в основном состоит из модулей. Когда такое происходит, нам необходимо улучшать код используя внедрение зависимостей. Функционирование компонентов высокого уровня зависит от компонентов низкого уровня. Для создания определенного поведения вы можете использовать наследование или интерфейсы.
Сначала рассмотрим плохой пример. Представим, что у нас есть сущность 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
и ориентируются на её.
- S.O.L.I.D Principles explained in Python with examples
- YouTube: SOLID принципы простым языком (много примеров)
- Принципы SOLID
- Википедия: Принцип подстановки Барбары Лисков
- Википедия: Принцип разделения интерфейса
- Википедия: Принцип инверсии зависимостей
Больше можно прочитать:
S.O.L.I.D Principles explained in Python with examples - not working url - Error 410