Skip to content

SOLID

Принципи SOLID

Що таке SOLID [🔥45/100]

Summary

П'ять принципів ООП-дизайну (SRP, OCP, LSP, ISP, DIP) для систем, що довго розширюються й підтримуються; інструмент проти зайвої складності, а не самоціль.

SOLID - акронім із перших літер п'яти принципів ООП-дизайну. Самі принципи сформулював Роберт Мартін (статті в C++ Report у 1990-х, есе "Design Principles and Design Patterns" 2000 р.), а сам акронім запропонував Майкл Фезерс близько 2004 р.

Принципи SOLID використовуються для дизайну і розробки таких програмних систем, які зможуть тривалий час розширятись, розвиватись і підтримуватись.

S: Single Responsibility Principle (Принцип єдиної відповідальності). Кожен клас повинен мати лише одну відповідальність, вирішувати тільки одне завдання. Або ж кожен клас повинен мати лише одну причину для змін.

O: Open-Closed Principle (Принцип відкритості-закритості). Код має бути відкритим до розширення (тобто до додавання нового функціоналу) та закритим до змін (все, що вже написано, не повинно змінюватися).

L: Liskov Substitution Principle (Принцип підстановки Барбари Лісков). Об'єкти в програмі можуть бути заміненими їх нащадками без зміни коду програми. Клас-нащадок повинен доповнювати, а не змінювати базовий.

I: Interface Segregation Principle (Принцип розділення інтерфейсу). Багато спеціалізованих інтерфейсів краще за один універсальний. Клієнти не повинні залежати від інтерфейсів, які вони не використовують.

D: Dependency Inversion Principle (Принцип інверсії залежностей). Об'єктом залежності повинна бути абстракція, а не щось конкретне.

  • Модулі верхніх рівнів не повинні залежати від модулів нижніх рівнів. Обидва типи модулів повинні залежати від абстракцій.
  • Абстракції не повинні залежати від деталей. Деталі повинні залежати від абстракцій.

SOLID - компас, а не самоціль. Класичні приклади прийшли зі строго типізованих мов (Java, C#), де частину рішень диктував компілятор. Python дає й легші засоби для тих самих цілей - функції першого класу, dataclass-и, typing.Protocol, - тож нижче для кожного принципу, крім класичного формулювання, показано і ці варіанти (єдиного "правильного" стилю немає). Сліпе ж слідування SOLID шкодить:

  • Over-engineering простих скриптів - крон-парсер на 200 рядків не потребує абстрактних фабрик і протоколів.
  • Ігнорування вбудованих структур - клас UserDataCollection там, де вистачає dict чи list.
  • Порушення YAGNI - абстракція "на випадок, якщо колись перейдемо з Postgres на Mongo", яку найімовірніше доведеться роками підтримувати мертвою, бо перехід так і не настане.

Links

S - Single Responsibility Principle [🔥36/100]

Summary

У модуля - одна причина для зміни: він відповідає перед одним актором (групою стейкхолдерів), а те, що змінюється з різних причин, розносять окремо.

S - Single Responsibility Principle - SRP - Принцип єдиної відповідальность Кожен клас повинен мати лише одну відповідальність, вирішувати тільки одне завдання. Це означає, що клас має бути створений для виконання лише однієї задачі, яку він повинен повністю інкапсулювати. Отже, всі сервіси цього класу мають бути підпорядковані її виконанню. Результатом слідування цій концепції є наявність лише однієї причини для зміни класу.

Уточнення Мартіна. Формулювання "одна причина для зміни" Мартін згодом визнав таким, що часто розуміють хибно - нібито "клас має робити одну дію". Точніше: модуль має бути відповідальним перед одним актором (групою/роллю стейкхолдерів, яка ініціює зміни). Тобто збирають разом те, що змінюється з одних причин, і розділяють те, що змінюється з різних. SRP - не про "один метод", а про один напрям змін.

Переваги та недоліки

  • Протидіє дублюванню коду, адже якщо функціональність розташована в неправильному місці, то доведеться копіювати її в потрібне
  • Зменшує потребу зміни вже випробуваного коду
  • Забезпечує відповідність назв класів та їх функціональності, що полегшує життя тим, хто обслуговуватиме код в майбутньому
  • Мінус - зростання кількості класів, що призводить до зростання складності системи.

Реалізація - розділити більші класи на менші. Наприклад - є клас, який створює звіт і друкує звіт. Такий клас може змінитись через 2 причини - може змінитись сам звіт або формат друку. Тому цей клас треба розділити на 2 нових.

Антипатерном до цього принципу є Божественний клас (God object) - коли один виконує дуже багато всього. І тоді з'являється ефект сніжного кому - зміна в одному місці викликає зміну в багатьох. Це також порушує GRASP-принципи High Cohesion і Low Coupling: незв'язані обов'язки зібрані в одному класі, від якого до того ж залежить багато коду.

SRP та ідея повторного використання коду тісно пов’язані з ідеєю зчеплення (cohesion) в розробці програмного забезпечення. Потрібно прагнути досягти того, щоб класи були розроблені таким чином, щоб більшість їхніх властивостей і атрибутів використовувалися їхніми методами більшу частину часу. Та повинні мати мінімальний рівень відповідальності: виконувати одну задачу, лише одну, і виконувати її добре. Коли це трапляється, ми знаємо, що це пов'язані концепції, і тому має сенс об'єднати їх під одну абстракцію. Чим менші за розміром компоненти, тим вони більш універсальні, і тим легше їх застосувати в іншому контексті без перенесення зайвої поведінки, що спричиняє зв'язування (coupling) та залежності, які роблять програмне забезпечення негнучким.

Функціями чи класами. SRP не диктує форму: розділити відповідальності можна і на функції + dataclass-и + модулі, і на класи - єдиного стандарту немає. Якщо дія не має стану, функція уникає одно-метод-класу; якщо є стан, конфігурація чи поліморфізм - доречніший клас. Приклад розділення функціями:

from dataclasses import dataclass

@dataclass
class ReportData:
    user_id: int
    total_sales: float

def fetch_user_sales(db, user_id: int) -> list[dict]:             # db.py - data layer
    return db.execute(...)

def build_report(user_id: int, sales: list[dict]) -> ReportData:  # analytics.py - logic
    return ReportData(user_id, sum(s["price"] for s in sales))

def save_report_csv(report: ReportData, path: str) -> None:       # export.py - I/O
    ...

Щоб додати експорт у JSON, пишуть нову функцію save_report_json, не чіпаючи розрахунки чи запити до БД. Тестувати теж легше - у функцію розрахунку передають мок-список, не піднімаючи тестову базу.

Links

O - Open-Closed Principle [💡20/100]

Summary

Сутність відкрита для розширення, але закрита для зміни: нову поведінку додають новою реалізацією абстракції (поліморфізм/композиція), не чіпаючи протестований код.

O - Open/Closed Principle - OCP - Принцип відкритості-закритості Програмні сутності (класи, модулі, функції) повинні бути відкритими для розширення, але закритими для змін. Розширення досягають через абстракцію та поліморфізм: код залежить від інтерфейсу, а нову поведінку додають новою реалізацією. Механізмом може бути композиція/стратегія (зазвичай гнучкіше) або успадкування - не обов'язково саме воно.

Відкритість для розширення означає, що архітектура програмної системи має бути спроєктована так, щоб додавання нових можливостей не вимагало перебудови існуючої структури. Система повинна легко "приймати" нові компоненти.

Закритість для модифікації означає, що вже написаний, протестований і впроваджений код не повинен змінюватися при додаванні нової функціональності. "Працює? Не чіпай!" Не варто ризикувати стабільністю системи, вносячи зміни до перевіреного коду.

Реалізацію можна міняти, але не можна міняти вхідні параметри та те, що повертає метод/об'єкт.

Це допомагає розв'язати проблему, як додавати новий функціонал до вже існуючої системи таким чином, щоб не порушити її стабільність і не витрачати дорогоцінні ресурси на повторне тестування.

Стратегія функцією. Типове порушення OCP - простирадло if/elif за типом (regular/student/vip), куди щоразу лізуть по нову гілку. У класичному ООП це розв'язують патерном Strategy з ієрархією класів; у Python функції є об'єктами першого класу, тож стратегію передають просто функцією-аргументом:

from typing import Callable

DiscountStrategy = Callable[[float], float]

def vip_discount(price: float) -> float:
    return price * 0.8

def calculate_total(price: float, apply: DiscountStrategy) -> float:
    return apply(price)   # core closed for modification; new behaviour = new function

Нова знижка black_friday - це нова коротка функція, ядро calculate_total не змінюється. Декоратори - ще одне втілення OCP: додати логування, кешування чи перевірку прав до функції, не чіпаючи її код.

Links

L - Liskov Substitution Principle [💡23/100]

Summary

Нащадка можна скрізь підставити замість базового класу, і код працюватиме так само - тож нащадок має поводитися як базовий, а не лише мати ті самі методи.

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 - прибирає саме такі випадки).

Специфіка Python. LSP - передусім про поведінку (контракт: інваріанти, пред- і постумови), і її не ловить жоден статичний інструмент, лише тести та рев'ю. Приклад з RubberDuck вище саме такий: сигнатура fly() збігається, тож і компілятор, і mypy його пропустять - падіння буде тільки в рантаймі. Інструменти перевіряють лише типову частину контракту: статичний компілятор ловить невідповідність типів аргументів чи результату на збірці; у Python це робить mypy (підсвітить Incompatible return value type, якщо нащадок поверне не той тип) - але теж лише типи, не поведінку. Тому в Python контракт фіксують abc.ABC плюс анотаціями і обов'язково покривають тестами - на типізацію тут покладатися не можна.

П'ять рівнів перевизначення методу. Перевизначаючи метод базового класу, нащадок може відхилятися від нього по-різному - від повністю сумісного до несумісного:

  1. Та сама зовнішня поведінка (параметри, результат, побічні ефекти), інша лише реалізація й нефункціональні характеристики (швидкодія). Класи повністю взаємозамінні.
  2. Та сама поведінка плюс додаткова робота чи зміна полів, відсутніх у базовому класі. Підстановка безпечна, а в інших місцях з'являються додаткові можливості.
  3. Змінена поведінка без порушення важливих характеристик. Допустимо розширювати область значень параметрів (контраваріантність) і звужувати область результату (коваріантність), зберігаючи контракт.
  4. Збережені формальні характеристики (сигнатура, тип результату), але сильно змінена поведінка. Зазвичай це помилка: інструменти вважають класи сумісними, хоча це не так (приклад - гумова качка вище).
  5. Несумісна зміна навіть сигнатури (довільний тип результату чи параметрів). Клас однозначно не можна підставити замість базового, і це виявляють автоматичні інструменти.

Перші три рівні сумісні з LSP, останні два його порушують. Коваріантність результату й контраваріантність параметрів - саме та свобода, що лишає простір для поліморфізму, не ламаючи взаємозамінності.

Links

I - Interface Segregation Principle [💡14/100]

Summary

Кілька вузьких інтерфейсів кращі за один товстий: клієнт не повинен залежати від методів, яких не використовує (інакше реалізації плодять заглушки NotImplementedError).

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.

typing.Protocol (структурна типізація). Імітувати інтерфейси через ABC більше не обов'язково. З Python 3.8 Protocol дає структурну типізацію: класу не треба наслідуватися - достатньо мати потрібні методи. Товстий інтерфейс дроблять на вузькі протоколи, а реалізації не змушені писати raise NotImplementedError для зайвих:

from typing import Protocol

class UserReader(Protocol):
    def get_user(self, user_id: int) -> dict: ...

class ReadOnlyCache:                                  # no inheritance from any interface
    def get_user(self, user_id: int) -> dict: ...

def send_email(user_id: int, storage: UserReader):   # depends only on read capability
    user = storage.get_user(user_id)

send_email приймає і повноцінну БД, і легкий кеш - обидва структурно підходять під UserReader, бо мають get_user. mypy це перевірить, а мертвих методів-заглушок немає.

Links

D - Dependency Inversion Principle [💡27/100]

Summary

Бізнес-логіка залежить від абстракції, а не від конкретної інфраструктури (БД, HTTP); конкретну реалізацію підставляють ззовні - обидва рівні залежать від абстракції.

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. Перехід з однієї БД на іншу зводиться до підстановки іншої реалізації; бізнес-логіка не змінюється.

Інтерфейс належить модулю вищого рівня. Найчастіша помилка - вважати, що DIP - це просто "залежати від абстракцій". Сама по собі абстракція нічого не інвертує: проєкт може бути весь на інтерфейсах і не мати жодної інверсії. Інверсія відбувається лише тоді, коли інтерфейс оголошений у модулі вищого рівня (належить його межі), а модуль нижчого рівня цей інтерфейс реалізує - тоді стрілка залежності від інфраструктури розвертається й указує на бізнес-логіку. Якщо ж інтерфейс належить модулю нижчого рівня, бізнес-логіка як залежала від нього, так і залежить - назва "інверсія" не виправдана. Звідси перевірка DIP: визначити межі модулів і впевнитися, що погранична абстракція лежить поруч зі споживачем (OrderRepository - у доменному модулі поряд із CheckoutService, а не в модулі Postgres).

Зв'язок з DI

DIP - принцип, куди має йти залежність (на абстракцію, не на конкретний клас). Dependency Injection - механізм, як конкретну реалізацію передати у клас, що залежить від абстракції (через конструктор, сетер чи параметр виклику). DIP можна виконати без DI-контейнера - достатньо передавати залежність вручну, як у прикладі вище.

typing.Protocol і DI через аргументи. Абстракцію зручно задати typing.Protocol-ом замість abc.ABC - тоді конкретним реалізаціям не треба наслідуватися:

from typing import Protocol

class OrderStorage(Protocol):
    def get_order(self, order_id: int) -> dict: ...

class OrderProcessor:
    def __init__(self, storage: OrderStorage):   # depends on protocol, not concrete DB
        self.storage = storage

У проді передають справжню БД (OrderProcessor(PostgresStorage())), у тестах - заглушку із захардкодженим словником ("DI для бідних"). Коли компонентів десятки, є dependency-injector; а FastAPI з системою Depends вбудував DI у сам фреймворк і зробив його звичним у своїй екосистемі.

Ціна DIP. Інверсія має вартість: без неї одразу видно реалізацію, а з нею її ще треба знайти за абстракцією. Якщо абстракція виділена погано - неповно описує вимоги або навпаки забруднена зайвими деталями - платять ціну DIP, не отримуючи його переваг (гнучкості, ізоляції змін, простоти тестування). Тому інверсію застосовують там, де абстракція справді осмислена, а не до кожної залежності.

Links