SOLID и паттерны проектирования в Python

⏱ 40 мин · 🧩 заданий: 5

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

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Примените паттерн «Стратегия». Функция 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
Задание 2 Программа на Python

Реализуйте паттерн «Наблюдатель»: класс 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") == []
Задание 3 Заполнить таблицу

Какой принцип SOLID нарушен?

SOLID
Класс Report считает данные, рисует PDF и отправляет почту
Чтобы добавить новый способ оплаты, приходится дописывать elif в функцию pay()
Square(Rectangle) ломает код, который меняет ширину и высоту независимо
Интерфейс Worker требует реализовать work() и eat(), а у робота нет eat()
OrderService внутри себя создаёт PostgresRepository()
Отметьте ячейку — «+», пустая — «−».
Задание 4 Один вариант ответа

Почему многие классические паттерны (Стратегия, Команда, Фабрика) в Python выглядят проще, чем в Java?

Задание 5 Один вариант ответа

Что такое внедрение зависимостей (dependency injection)?

Войдите, чтобы отмечать пройденные уроки.