SOLID и паттерны проектирования в Python
SOLID
Пять принципов объектного проектирования. На собеседованиях их спрашивают постоянно — важно не только назвать, но и привести пример.
| Буква | Принцип | Коротко |
|---|---|---|
| S | Single Responsibility | у класса одна причина для изменения |
| O | Open/Closed | открыт для расширения, закрыт для изменения |
| L | Liskov Substitution | наследник заменяет родителя без поломок |
| I | Interface Segregation | много маленьких интерфейсов лучше одного толстого |
| D | Dependency Inversion | зависеть от абстракций, а не от конкретных классов |
S — единственная ответственность
# ✗ Плохо: класс делает три разные вещи
class Report:
def calculate(self): ...
def to_pdf(self): ...
def send_email(self, to): ...
# ✓ Хорошо: каждая ответственность отдельно
class ReportCalculator: ...
class PdfRenderer: ...
class EmailSender: ...
Изменение формата PDF не должно задевать расчёты, а смена почтового сервиса — PDF.
O — открытость/закрытость
# ✗ Каждый новый формат — правка функции
def export_bad(data, fmt):
if fmt == "json":
...
elif fmt == "csv":
...
# ✓ Новый формат — новая запись в реестре, существующий код не меняется
import json
EXPORTERS = {}
def exporter(fmt):
def register(func):
EXPORTERS[fmt] = func
return func
return register
@exporter("json")
def to_json(rows):
return json.dumps(rows, ensure_ascii=False)
@exporter("csv")
def to_csv(rows):
return "\n".join(",".join(map(str, r.values())) for r in rows)
def export(rows, fmt):
return EXPORTERS[fmt](rows)
rows = [{"id": 1, "name": "Анна"}]
print(export(rows, "json"))
print(export(rows, "csv"))
L — подстановка Барбары Лисков
Код, работающий с родителем, должен работать и с наследником. Классический контрпример:
class Rectangle:
def __init__(self, w, h):
self.w, self.h = w, h
def set_width(self, w):
self.w = w
def area(self):
return self.w * self.h
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
def set_width(self, w): # квадрат обязан менять обе стороны
self.w = self.h = w
def stretch(rect: Rectangle):
rect.set_width(10)
return rect.area() # ожидаем 10 * исходная высота
print(stretch(Rectangle(2, 5))) # 50 — как ожидалось
print(stretch(Square(5))) # 100 — сюрприз: Square не заменяет Rectangle
Вывод: «квадрат — это прямоугольник» в математике, но не в смысле поведения. Признаки нарушения LSP: наследник бросает NotImplementedError в методе родителя, усиливает требования к аргументам или ослабляет гарантии результата.
I — разделение интерфейсов
from typing import Protocol
# ✗ Толстый интерфейс
class Worker(Protocol):
def work(self) -> None: ...
def eat(self) -> None: ... # роботу это не нужно
# ✓ Маленькие протоколы
class Workable(Protocol):
def work(self) -> None: ...
class Eatable(Protocol):
def eat(self) -> None: ...
D — инверсия зависимостей и внедрение зависимостей
from typing import Protocol
class UserRepository(Protocol):
def get(self, user_id: int) -> dict: ...
class SqlUserRepository: # «настоящая» реализация (в проде)
def get(self, user_id):
raise RuntimeError("нет базы данных в этом примере")
class InMemoryUserRepository: # для тестов
def __init__(self, users):
self.users = users
def get(self, user_id):
return self.users[user_id]
class UserService:
def __init__(self, repo: UserRepository): # зависимость передаётся снаружи
self.repo = repo
def greeting(self, user_id):
return f"Здравствуйте, {self.repo.get(user_id)['name']}!"
service = UserService(InMemoryUserRepository({1: {"name": "Анна"}}))
print(service.greeting(1))
UserService зависит от абстракции UserRepository, а конкретную реализацию ему внедряют — в тестах подставляется хранилище в памяти. Так устроены Depends в FastAPI и фикстуры pytest.
Паттерны по-питоновски
| Паттерн | Классическая реализация | В Python |
|---|---|---|
| Стратегия | интерфейс + классы-стратегии | передать функцию |
| Команда | класс с execute() |
функция, functools.partial, лямбда |
| Фабрика | класс-фабрика | функция или словарь {"csv": CsvReader} (классы — объекты) |
| Синглтон | приватный конструктор | модуль; functools.cache на функции-фабрике |
| Итератор | класс-итератор | генератор yield |
| Декоратор | класс-обёртка | @decorator |
| Шаблонный метод | абстрактный класс | ABC или передача функций-хуков |
Фабрика и реестр
class CsvReader:
def read(self, path): return f"читаю CSV {path}"
class JsonReader:
def read(self, path): return f"читаю JSON {path}"
READERS = {".csv": CsvReader, ".json": JsonReader} # классы — обычные значения словаря
def reader_for(path):
ext = path[path.rfind("."):]
try:
return READERS[ext]()
except KeyError:
raise ValueError(f"неизвестный формат {ext}") from None
for p in ["data.csv", "conf.json"]:
print(reader_for(p).read(p))
Адаптер
Приводит чужой интерфейс к ожидаемому:
class OldPaymentApi: # сторонняя библиотека, менять нельзя
def make_payment(self, kopecks, currency_code):
return f"оплачено {kopecks / 100:.2f} {currency_code}"
class PaymentAdapter: # наш код ожидает pay(rub)
def __init__(self, api):
self.api = api
def pay(self, rub):
return self.api.make_payment(int(rub * 100), "RUB")
print(PaymentAdapter(OldPaymentApi()).pay(199.9))
Наблюдатель
class Order:
def __init__(self):
self._listeners = []
def on_paid(self, callback):
self._listeners.append(callback)
return callback # можно использовать как декоратор
def pay(self):
print("Заказ оплачен")
for cb in self._listeners:
cb(self)
order = Order()
@order.on_paid
def send_email(o):
print(" → письмо клиенту")
order.on_paid(lambda o: print(" → уведомление складу"))
order.pay()
Так работают сигналы Django (post_save), события в GUI и брокеры сообщений.
Репозиторий
Прячет детали хранения за коллекционным интерфейсом (add, get, list). Бизнес-логика не знает, где лежат данные — в Postgres, Redis или в памяти (пример выше с UserRepository).
DRY, KISS, YAGNI
- DRY (Don't Repeat Yourself) — одно знание в одном месте. Но не объединяйте код, который лишь случайно похож.
- KISS (Keep It Simple, Stupid) — простое решение лучше «гибкого».
- YAGNI (You Aren't Gonna Need It) — не пишите то, что «может понадобиться». Паттерн ради паттерна — антипаттерн.
Вопрос на собеседовании
«Расскажите про SOLID с примерами». Хороший ответ: для каждой буквы одно предложение + пример из своего опыта. Например, D: «сервис принимает репозиторий в конструкторе, поэтому в тестах я подставляю фейковую реализацию без базы».
Итог
- SOLID: одна ответственность, расширение без изменения, честное наследование, узкие интерфейсы, зависимость от абстракций.
- Внедрение зависимостей делает код тестируемым.
- В Python многие паттерны сводятся к функциям, словарям и модулям — функции и классы являются объектами.
- Не усложняйте: KISS и YAGNI важнее паттернов.
Практика решено 0 из 5
Примените паттерн «Стратегия». Функция checkout(total, discount) принимает сумму
и функцию-стратегию скидки и возвращает итог.
Реализуйте стратегии:
- no_discount(total) — без скидки;
- percent(p) — фабрика, возвращающая стратегию «скидка p процентов»;
- fixed_over(threshold, amount) — фабрика: если сумма ≥ threshold, вычесть amount.
Какие тесты проверяют код (3)
assert checkout(1000, no_discount) == 1000 assert checkout(1000, percent(10)) == 900 assert checkout(5000, fixed_over(3000, 500)) == 4500 and checkout(2000, fixed_over(3000, 500)) == 2000
def checkout(total, discount):
return discount(total)
def no_discount(total):
return total
def percent(p):
def strategy(total):
return total * (100 - p) / 100
return strategy
def fixed_over(threshold, amount):
def strategy(total):
return total - amount if total >= threshold else total
return strategy
Реализуйте паттерн «Наблюдатель»: класс EventBus с методами
- subscribe(event, handler) — подписать функцию на событие;
- publish(event, *args) — вызвать все обработчики события в порядке подписки
и вернуть список их результатов.
Если на событие никто не подписан, publish возвращает [].
Какие тесты проверяют код (2)
bus = EventBus()
bus.subscribe("order", lambda n: f"письмо {n}")
bus.subscribe("order", lambda n: f"смс {n}")
assert bus.publish("order", 7) == ["письмо 7", "смс 7"]
assert EventBus().publish("nothing") == []
from collections import defaultdict
class EventBus:
def __init__(self):
self._handlers = defaultdict(list)
def subscribe(self, event, handler):
self._handlers[event].append(handler)
def publish(self, event, *args):
return [handler(*args) for handler in self._handlers[event]]
Какой принцип SOLID нарушен?
| S | O | L | I | D | |
|---|---|---|---|---|---|
Класс Report считает данные, рисует PDF и отправляет почту |
|||||
Чтобы добавить новый способ оплаты, приходится дописывать elif в функцию pay() |
|||||
Square(Rectangle) ломает код, который меняет ширину и высоту независимо |
|||||
Интерфейс Worker требует реализовать work() и eat(), а у робота нет eat() |
|||||
OrderService внутри себя создаёт PostgresRepository() |
Почему многие классические паттерны (Стратегия, Команда, Фабрика) в Python выглядят проще, чем в Java?
Что такое внедрение зависимостей (dependency injection)?
Войдите, чтобы отмечать пройденные уроки.