SOLID вглубь

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

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

Паттерны не появляются на пустом месте. За каждым из них стоит более фундаментальная идея: как разложить ответственность, как разрешать изменения, не ломая существующее, как зависеть от абстракций, а не от деталей. Эти идеи и формализует SOLID. Знать паттерны, но не понимать SOLID — всё равно что заучить шахматные дебюты, не понимая, зачем контролировать центр.

Зачем это на практике

Код пишется один раз, а читается и меняется десятки раз. Стоимость проекта — это в основном стоимость изменений. SOLID — это пять рычагов, которые снижают цену изменения: правка одной фичи не должна задевать пять других, добавление нового варианта не должно требовать переписывания старого. Разберём каждый принцип на паре «плохо → хорошо».

S — Single Responsibility (единая ответственность)

У класса должна быть одна причина для изменения.

«Плохо»: класс Report и считает данные, и форматирует их, и сохраняет в файл. Любое из трёх требований («поменялся формат», «пишем в БД», «другая формула») заставляет лезть в один и тот же класс.

class Report:
    def __init__(self, rows):
        self.rows = rows
    def total(self):
        return sum(self.rows)
    def to_text(self):
        return f"Итого: {self.total()}"
    def save(self, path):
        with open(path, "w") as f:
            f.write(self.to_text())  # три ответственности в одном классе

«Хорошо»: разделяем подсчёт, представление и сохранение. Теперь у каждого класса одна причина меняться.

class Report:
    def __init__(self, rows):
        self.rows = rows
    def total(self):
        return sum(self.rows)

class TextFormatter:
    def format(self, report):
        return f"Итого: {report.total()}"

r = Report([10, 20, 30])
print(TextFormatter().format(r))

Вывод:

Итого: 60

O — Open/Closed (открыт для расширения, закрыт для изменения)

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

«Плохо»: цепочка if/elif по типу. Каждая новая фигура — правка функции, которую уже протестировали.

def area(shape):
    if shape["kind"] == "circle":
        return 3.14 * shape["r"] ** 2
    elif shape["kind"] == "square":
        return shape["a"] ** 2
    # добавили треугольник — снова правим эту функцию

«Хорошо»: общий интерфейс и полиморфизм. Новая фигура — новый класс, старый код не трогаем.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class Circle(Shape):
    def __init__(self, r):
        self.r = r
    def area(self):
        return 3.14 * self.r ** 2

class Square(Shape):
    def __init__(self, a):
        self.a = a
    def area(self):
        return self.a ** 2

shapes = [Circle(2), Square(3)]
print(round(sum(s.area() for s in shapes), 2))

Вывод:

21.56

L — Liskov Substitution (подстановка Лисков)

Объект подкласса должен подставляться вместо родителя без сюрпризов.

Классический контрпример: Square наследуется от Rectangle, но переопределяет сеттеры так, что ширина и высота меняются вместе. Код, который рассчитывал на Rectangle (задал ширину 5, высоту 4, ждёт площадь 20), получит 16. Подкласс нарушил ожидания базового типа — это и есть нарушение LSP. Лечится отказом от такого наследования: квадрат и прямоугольник лучше держать раздельно или через общий интерфейс, а не «is-a» там, где поведение расходится.

I — Interface Segregation (разделение интерфейса)

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

«Плохо»: «толстый» интерфейс Machine с методами print, scan, fax. Простой принтер вынужден реализовывать scan и fax заглушками, которые бросают исключения.

«Хорошо»: дробим на мелкие роли — Printer, Scanner. Класс реализует ровно те интерфейсы, что ему нужны.

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def do_print(self, doc): ...

class Scanner(ABC):
    @abstractmethod
    def scan(self): ...

class SimplePrinter(Printer):
    def do_print(self, doc):
        return f"Печать: {doc}"

print(SimplePrinter().do_print("отчёт"))

Вывод:

Печать: отчёт

D — Dependency Inversion (инверсия зависимостей)

Зависим от абстракций, а не от конкретных реализаций.

«Плохо»: OrderService сам создаёт MySQLDatabase внутри себя — модуль высокого уровня жёстко прибит к детали. Заменить БД на тесте-заглушке невозможно.

«Хорошо»: сервис принимает абстракцию хранилища снаружи (внедрение зависимости). Теперь подставим хоть реальную БД, хоть фейк для теста.

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save(self, order): ...

class OrderService:
    def __init__(self, storage: Storage):
        self.storage = storage  # зависим от интерфейса
    def place(self, order):
        return self.storage.save(order)

class FakeStorage(Storage):
    def save(self, order):
        return f"сохранён {order}"

print(OrderService(FakeStorage()).place("заказ #1"))

Вывод:

сохранён заказ #1

Как это работает под капотом

Все пять принципов крутятся вокруг одной оси — управление зависимостями и направление изменений. SRP уменьшает число причин изменения у модуля. OCP направляет изменения в новые классы. LSP охраняет контракты, чтобы полиморфизм не врал. ISP не даёт клиентам тянуть лишние зависимости. DIP разворачивает стрелки зависимостей так, чтобы бизнес-логика не зависела от инфраструктуры. Многие GoF-паттерны — это просто способ выполнить один из этих принципов: Strategy и Template Method обслуживают OCP, Adapter — DIP/ISP, Factory — DIP.

Частые ошибки

  • SRP как «один метод на класс». Ответственность — это не размер, а причина изменения. Класс с десятью методами одной темы нормален.
  • OCP-фанатизм. Делать всё расширяемым «на будущее» — это уже YAGNI-нарушение. Открывайте для расширения те точки, где изменчивость реальна.
  • Наследование вместо композиции. Самый частый источник нарушений LSP — наследование там, где поведение отличается. «Has-a» обычно безопаснее «is-a».
  • Интерфейс на каждый чих. DIP не требует абстракции над всем; абстрагируйте границы (БД, сеть), а не каждый класс.

Итоги

  • SRP — одна причина для изменения; разделяй подсчёт, представление и хранение.
  • OCP — расширяй через новые классы, не правя проверенные.
  • LSP — подкласс не должен ломать ожидания базового типа.
  • ISP — мелкие интерфейсы-роли вместо одного «толстого».
  • DIP — высокоуровневая логика зависит от абстракций, детали внедряются снаружи.
  • Паттерны — это техники достижения SOLID, а не самоцель.
Проверьте себя
1. Что означает буква O в SOLID?
AObject-Oriented — код должен быть объектно-ориентированным
BOpen/Closed — открыт для расширения, закрыт для изменения
COverride — методы базового класса нужно переопределять
DOptional — все зависимости должны быть опциональными
2. Класс Square наследуется от Rectangle и переопределяет сеттеры так, что изменение ширины меняет и высоту. Какой принцип нарушен?
ASRP — единая ответственность
BISP — разделение интерфейса
CLSP — подстановка Лисков
DDIP — инверсия зависимостей
3. Как принцип инверсии зависимостей (DIP) предлагает связывать модуль высокого уровня и конкретную базу данных?
AМодуль создаёт объект БД внутри себя через new/конструктор
BМодуль зависит от абстракции хранилища, а конкретная реализация внедряется снаружи
CБД должна наследоваться от модуля высокого уровня
DМодуль и БД объединяют в один класс ради скорости