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, а не самоцель.