SOLID
Принципи SOLID⚑
Що таке SOLID⚑
SOLID - це абревіатура складена з перших літер п'яти базових принципів ООП і дизайну, запропонована Робертом Мартіном.
Принципи SOLID використовуються для дизайну і розробки таких програмних систем, які зможуть тривалий час розширятись, розвиватись і підтримуватись.
S: Single Responsibility Principle (Принцип єдиної відповідальності). Кожен клас повинен мати лише одну відповідальність, вирішувати тільки одне завдання. Або ж кожен клас повинен мати лише одну причину для змін.
O: Open-Closed Principle (Принцип відкритості-закритості). Код має бути відкритим до розширення (тобто до додавання нового функціоналу) та закритим до змін (все, що вже написано, не повинно змінюватися).
L: Liskov Substitution Principle (Принцип підстановки Барбари Лісков). Об'єкти в програмі можуть бути заміненими їх нащадками без зміни коду програми. Клас-нащадок повинен доповнювати, а не змінювати базовий.
I: Interface Segregation Principle (Принцип розділення інтерфейсу). Багато спеціалізованих інтерфейсів краще за один універсальний. Клієнти не повинні залежати від інтерфейсів, які вони не використовують.
D: Dependency Inversion Principle (Принцип інверсії залежностей). Об'єктом залежності повинна бути абстракція, а не щось конкретне.
- Модулі верхніх рівнів не повинні залежати від модулів нижніх рівнів. Обидва типи модулів повинні залежати від абстракцій.
- Абстракції не повинні залежати від деталей. Деталі повинні залежати від абстракцій.
S - Single Responsibility Principle⚑
S - Single Responsibility Principle - SRP - Принцип єдиної відповідальность Кожен клас повинен мати лише одну відповідальність, вирішувати тільки одне завдання. Це означає, що клас має бути створений для виконання лише однієї задачі, яку він повинен повністю інкапсулювати. Отже, всі сервіси цього класу мають бути підпорядковані її виконанню. Результатом слідування цій концепції є наявність лише однієї причини для зміни класу, що робить його значно здоровішим.
Переваги та недоліки
- Протидіє дублюванню коду, адже якщо функціональність розташована в неправильному місці, то доведеться копіювати її в потрібне
- Зменшує потребу зміни вже випробуваного коду
- Забезпечує відповідність назв класів та їх функціональності, що полегшує життя тим, хто обслуговуватиме код в майбутньому
- Мінус - зростання кількості класів, що призводить до зростання складності системи.
Реалізація - розділити більші класи на менші. Наприклад - є клас, який створює звіт і друкує звіт. Такий клас може змінитись через 2 причини - може змінитись сам звіт або формат друку. Тому цей клас треба розділити на 2 нових.
Антипатерном до цього принципу є Божественний клас (God object) - коли один виконує дуже багато всього. І тоді з'являється ефект сніжного кому - зміна в одному місці викликає зміну в багатьох. Це також порушує патерн Information Expert з GRASP.
SRP та ідея повторного використання коду тісно пов’язані з ідеєю зчеплення (cohesion) в розробці програмного забезпечення. Потрібно прагнути досягти того, щоб класи були розроблені таким чином, щоб більшість їхніх властивостей і атрибутів використовувалися їхніми методами більшу частину часу. Та повинні мати мінімальний рівень відповідальності: виконувати одну задачу, лише одну, і виконувати її добре. Коли це трапляється, ми знаємо, що це пов'язані концепції, і тому має сенс об'єднати їх під одну абстракцію. Чим менші за розміром компоненти, тим вони більш універсальні, і тим легше їх застосувати в іншому контексті без перенесення зайвої поведінки, що спричиняє зв'язування (coupling) та залежності, які роблять програмне забезпечення негнучким.
Links
- https://www.pythontutorial.net/python-oop/python-single-responsibility-principle/
- Все, що ви хотіли знати про принципи SOLID. Частина перша: SRP - dou.ua
O - Open-Closed Principle⚑
O - Open/Closed Principle - OCP - Принцип відкритості-закритості Програмні сутності (класи, модулі, функції) повинні бути відкритими для розширення, але закритими для змін. Розширення певного класу може здійснюватись через його успадкування.
Відкритість для розширення означає, що архітектура програмної системи має бути спроєктована так, щоб додавання нових можливостей не вимагало перебудови існуючої структури. Система повинна легко "приймати" нові компоненти.
Закритість для модифікації означає, що вже написаний, протестований і впроваджений код не повинен змінюватися при додаванні нової функціональності. "Працює? Не чіпай!" Не варто ризикувати стабільністю системи, вносячи зміни до перевіреного коду.
Реалізацію можна міняти, але не можна міняти вхідні параметри та те, що повертає метод/об'єкт.
Це допомагає розв'язати проблему, як додавати новий функціонал до вже існуючої системи таким чином, щоб не порушити її стабільність і не витрачати дорогоцінні ресурси на повторне тестування.
Links
L - Liskov Substitution Principle⚑
L - Liskov Substitution Principle - LSP - Принцип підстановки Лісков Функції, які використовують базовий тип, повинні мати можливість використовувати підтипи базового типу, не знаючи про це. Тобто поведінка в похідних класах не повинна суперечити поведінці, заданій базовим класом.
Об'єкти в програмі можуть бути заміненими їх нащадками без зміни коду програми. Клас-нащадок повинен доповнювати, а не змінювати базовий. Це вимагає суворого дотримання контрактів та інтерфейсів, визначених у базовому класі, у всіх його похідних класах.
- Принцип про правильне наслідування.
- Проектування конктракту - контракт інформує авторів клієнтського коду про бажану поведінку класу. Контракт визначає вхідні і вихідні параметри.
Канонічний контрприклад
Класична ілюстрація порушення LSP - "гумова качка":
class Duck:
def fly(self):
return "flying"
class RubberDuck(Duck): # rubber duck "is-a" duck by inheritance
def fly(self):
raise NotImplementedError("rubber ducks cannot fly")
Тип-наслідник формально "є" Duck, але звужує контракт батьківського методу. Будь-який код, що приймає Duck і викликає .fly(), ламається при підстановці RubberDuck:
def migrate_south(ducks: list[Duck]):
for d in ducks:
d.fly() # crashes for RubberDuck
migrate_south([Duck(), RubberDuck()]) # NotImplementedError
Сам факт спадкування не доводить підстановочності - LSP вимагає зберігати контракт батьківського методу, а не лише сигнатуру. Якщо тип не може його виконати, його не треба успадковувати - правильніше винести спільну частину у вужчий інтерфейс (див. I - Interface Segregation Principle - прибирає саме такі випадки).
Links
I - Interface Segregation Principle⚑
I - Interface Segregation Principle - ISP - Принцип розділення інтерфейсу. Багато спеціалізованих інтерфейсів краще за один універсальний. Інтерфейс може бути поділений на спеціалізовані ще на стадіях проектування, заради майбутньої гнучкості програмних компонентів.
- Клієнти не повинні залежати від методів, які вони не використовують.
- Занадто товсті інтерфейси необхідно розділяти на менші та специфічні, щоб їх клієнти знали лише про ті методи, що необхідні для них у роботі. Як результат, при зміні певного функціоналу, незмінними мають лишатися ті класи, як не використовують його. Тобто виконання цього принципу допомагає системі залишатись гнучкою при внесенні до неї змін.
# wrong
# In this design the `Car` class must implement the `fly()` method from the `Vehicle` class that the `Car` class doesn’t use. Therefore, this design violates the interface segregation principle.
class Vehicle(ABC):
@abstractmethod
def go(self): pass
@abstractmethod
def fly(self): pass
class Aircraft(Vehicle):
def go(self): print("Taxiing")
def fly(self): print("Flying")
class Car(Vehicle):
def go(self): print("Going")
def fly(self): raise Exception('The car cannot fly')
# To fix this, you need to split the `Vehicle` class into small ones and inherits from these classes from the `Aircraft` and `Car` classes:
class Movable(ABC):
@abstractmethod
def go(self): pass
class Flyable(Movable):
@abstractmethod
def fly(self): pass
class Aircraft(Flyable):
def go(self): print("Taxiing")
def fly(self): print("Flying")
class Car(Movable):
def go(self): print("Going")
ISP допомагає дотримуватися OCP, дозволяючи легко розширювати функціональність без модифікації існуючого коду.
Прикладом розділення інтерфейсу можуть слугувати mixins.CreateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin в DRF.
Links
D - Dependency Inversion Principle⚑
D - Dependency Inversion Principle - DIP - Принцип інверсії залежностей Модулі вищих рівнів не мають залежати від модулів нижчих рівнів. Обидва типи модулів повинні залежати від абстракцій. Це досягається через використання інтерфейсів та ін'єкції залежностей (dependency injection).
Типова плутанина
DIP часто плутають із простим наслідуванням ("клас Dog залежить від Animal"). Це не та інверсія: тут і так модуль нижчого рівня залежить від модуля вищого рівня. DIP - про те, що бізнес-логіка (модуль вищого рівня) не повинна знати про конкретну реалізацію інфраструктури (БД, HTTP-клієнт, файлова система); вона залежить лише від абстрактного інтерфейсу, а конкретну реалізацію підставляють ззовні. Так само й сама інфраструктура реалізує цей інтерфейс - тобто обидва залежать від абстракції, а не одне від одного.
Реалізація
from abc import ABC, abstractmethod
class OrderRepository(ABC): # abstraction (interface)
@abstractmethod
def save(self, order): ...
class PostgresOrderRepository(OrderRepository):
def save(self, order):
# SQL INSERT ...
...
class MongoOrderRepository(OrderRepository):
def save(self, order):
# collection.insert_one(...)
...
class CheckoutService: # high-level module
def __init__(self, orders: OrderRepository):
self._orders = orders # depends on abstraction, not concrete DB
def checkout(self, order):
# business logic ...
self._orders.save(order)
CheckoutService нічого не знає про Postgres чи Mongo - він приймає будь-який OrderRepository. Перехід з однієї БД на іншу зводиться до підстановки іншої реалізації; бізнес-логіка не змінюється.
Зв'язок з DI
DIP - принцип, куди має йти залежність (на абстракцію, не на конкретний клас). Dependency Injection - механізм, як конкретну реалізацію передати у клас, що залежить від абстракції (через конструктор, сетер чи параметр виклику). DIP можна виконати без DI-контейнера - достатньо передавати залежність вручну, як у прикладі вище.
Links