Перейти к содержимому

Практика 3. Практическая работа 3. SOLID на практике

Цель

Научиться распознавать «запахи кода» (code smells) и нарушения принципов SOLID в готовом legacy-коде и устранять их безопасным рефакторингом. По итогам работы вы должны уметь:

  • разбивать «божественный» класс на классы с единственной ответственностью (SRP);
  • заменять разрастающиеся if/elif полиморфизмом и стратегиями (OCP);
  • находить и исправлять нарушения подстановки в иерархиях наследования (LSP);
  • дробить «толстые» интерфейсы на узкие роли (ISP);
  • инвертировать зависимости через абстракции и внедрение зависимостей (DIP);
  • писать характеристические тесты перед рефакторингом, чтобы не сломать поведение.

Краткая теория

SOLID — пять принципов объектно-ориентированного проектирования (Роберт Мартин), борющихся с жёсткостью, хрупкостью и непереиспользуемостью кода:

  • S — Single Responsibility. У класса должна быть только одна причина для изменения. Лекарство от God Class.
  • O — Open/Closed. Сущности открыты для расширения, но закрыты для модификации. Лекарство от разрастающихся switch statements.
  • L — Liskov Substitution. Объекты подкласса должны без проблем подставляться вместо объектов базового класса, сохраняя его контракт и инварианты.
  • I — Interface Segregation. Клиенты не должны зависеть от методов, которые не используют. Лекарство от заглушек NotImplementedError.
  • D — Dependency Inversion. Модули верхнего уровня и детали зависят от абстракций, а не друг от друга. Практический приём — Dependency Injection через конструктор.

Распространённые code smells: God Class/Method, Long Parameter List, Switch Statements, Feature Envy, Shotgun Surgery, Primitive Obsession, Data Clumps.

Главное правило рефакторинга: сначала тесты, потом изменения. Напишите характеристические тесты на текущее (даже кривое) поведение, затем меняйте внутренности маленькими шагами — тесты не должны сломаться.

Подробности — в Лекции 4 «Принципы SOLID».


Задания

Во всех заданиях даётся «плохой» код, который нужно отрефакторить. Готовые решения не прикладывайте к работе самостоятельно — присылайте свой рефакторинг и тесты. Используйте аннотации типов и осмысленные имена.

Задание 1. SRP — разбиение God-класса

UserManager делает слишком много: валидирует, хеширует пароль, пишет в БД, шлёт письмо и логирует — пять причин для изменения в одном классе.

import re, hashlib, sqlite3, smtplib
from datetime import datetime
class UserManager:
def __init__(self, db_path: str, smtp_host: str):
self.db_path = db_path
self.smtp_host = smtp_host
def register(self, name: str, email: str, password: str) -> dict:
if len(name) < 2:
raise ValueError("Имя слишком короткое")
if not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
raise ValueError("Некорректный email")
if len(password) < 8:
raise ValueError("Пароль слишком короткий")
password_hash = hashlib.sha256(password.encode()).hexdigest()
conn = sqlite3.connect(self.db_path)
cur = conn.cursor()
cur.execute(
"INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)",
(name, email, password_hash),
)
user_id = cur.lastrowid
conn.commit()
conn.close()
with smtplib.SMTP(self.smtp_host) as smtp:
smtp.sendmail("noreply@x.com", email, f"Привет, {name}!")
with open("users.log", "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat()}] Зарегистрирован: {email}\n")
return {"id": user_id, "name": name, "email": email}

Требования:

  1. Выделите минимум 4 класса с одной ответственностью: UserValidator, Sha256PasswordHasher, UserRepository (абстракция + SqliteUserRepository), EmailService (абстракция + SmtpEmailService), UserLogger (абстракция + FileUserLogger).
  2. Класс UserService (бывший UserManager) должен только оркестрировать регистрацию, получая зависимости через конструктор (Dependency Injection).
  3. Реализуйте InMemoryUserRepository для тестов и напишите характеристические тесты на register() (успех + три случая ошибок валидации).

Задание 2. OCP — замена if/elif стратегиями

Калькулятор доставки нарушает OCP: новый тариф требует правки calculate.

class ShippingCalculator:
def calculate(self, method: str, weight_kg: float, distance_km: float) -> float:
if method == "standard":
return 100 + weight_kg * 10 + distance_km * 0.5
elif method == "express":
return 300 + weight_kg * 15 + distance_km * 1.0
elif method == "pickup":
return 0
elif method == "international":
return (500 + weight_kg * 50 + distance_km * 2.0) * 1.2
else:
raise ValueError(f"Неизвестный метод: {method}")

Требования:

  1. Примените паттерн Strategy: вынесите каждый тариф в отдельный класс с общим интерфейсом ShippingStrategy (метод calculate(weight_kg, distance_km) -> float).
  2. Реализуйте реестр ShippingStrategyRegistry с методами register(name, strategy) и get(name); ShippingCalculator получает реестр через конструктор.
  3. Проверка OCP: добавьте тариф "drone" (200 + weight*20 + distance*3.0), не меняя существующие классы — только регистрируя новую стратегию.

Задание 3. LSP — исправление иерархии фигур

Классический пример нарушения подстановки: Square как наследник Rectangle.

class Rectangle:
def __init__(self, width: float, height: float):
self._width, self._height = width, height
@property
def width(self) -> float: return self._width
@width.setter
def width(self, value: float) -> None: self._width = value
@property
def height(self) -> float: return self._height
@height.setter
def height(self, value: float) -> None: self._height = value
def area(self) -> float: return self._width * self._height
class Square(Rectangle):
def __init__(self, side: float):
super().__init__(side, side)
@Rectangle.width.setter
def width(self, value: float) -> None: self._width = self._height = value
@Rectangle.height.setter
def height(self, value: float) -> None: self._width = self._height = value

Требования:

  1. Продемонстрируйте нарушение тестом: функция stretch(rect), которая ставит width = 10, height = 4 и ожидает area() == 40, корректна для Rectangle, но ломается на Square.
  2. Предложите корректную архитектуру (на выбор): общий абстрактный Shape с методом area(), где Rectangle и Square — независимые реализации; или неизменяемые фигуры @dataclass(frozen=True) без сеттеров.
  3. Покажите, что функция total_area(shapes: list[Shape]) -> float корректно работает с любым набором фигур.

Задание 4. ISP — разделение «толстого» интерфейса

Интерфейс Worker объединил несвязанные роли, поэтому реализации городят заглушки.

from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self) -> str: ...
@abstractmethod
def eat(self) -> str: ...
@abstractmethod
def charge_battery(self) -> str: ...
@abstractmethod
def receive_salary(self, amount: float) -> str: ...
class RobotWorker(Worker):
def work(self) -> str: return "Работаю"
def eat(self) -> str: raise NotImplementedError("Робот не ест!")
def charge_battery(self) -> str: return "Заряжаюсь"
def receive_salary(self, amount: float) -> str:
raise NotImplementedError("Робот не получает зарплату!")

Требования:

  1. Разбейте Worker минимум на 4 узких интерфейса (используйте typing.Protocol или ABC): Workable (work), Biological (eat, sleep), Electrical (charge_battery), Employable (receive_salary).
  2. Реализуйте HumanWorker(Workable, Biological, Employable) и RobotWorker(Workable, Electrical)без единой заглушки NotImplementedError.
  3. Напишите функции-клиенты, зависящие от минимума: run_shift(workers: list[Workable]) и pay_salaries(employees: list[Employable], amount).

Задание 5. DIP — инверсия зависимостей

OrderProcessor сам создаёт конкретные зависимости, поэтому его нельзя протестировать без реальной БД, платёжного шлюза и почты.

class OrderProcessor:
def __init__(self):
self.db = MySqlDatabase()
self.payment = StripePaymentGateway()
self.email = SendgridEmailClient()
def process(self, order: dict, card_token: str, email: str) -> dict:
charge_id = self.payment.charge(order["amount"], card_token)
order["charge_id"] = charge_id
order_id = self.db.save_order(order)
self.email.send(email, "Заказ оформлен", f"Ваш заказ #{order_id}")
return {"order_id": order_id, "charge_id": charge_id}

Требования:

  1. Определите абстракции OrderRepository, PaymentGateway, EmailClient (ABC с нужными методами); сделайте MySqlDatabase, StripePaymentGateway, SendgridEmailClient их реализациями.
  2. Перепишите OrderProcessor так, чтобы он принимал зависимости через конструктор и работал только с абстракциями.
  3. Реализуйте тестовые двойники InMemoryOrderRepository, FakePaymentGateway, FakeEmailClient и напишите юнит-тесты process() без сети и БД (проверьте, что заказ сохранён, платёж списан, письмо отправлено).

Критерии оценки

КритерийВес
Задание 1 (SRP): минимум 4 класса, DI, характеристические тесты25%
Задание 2 (OCP): Strategy + реестр, новый тариф без правок15%
Задание 3 (LSP): демонстрация нарушения + корректная иерархия15%
Задание 4 (ISP): минимум 4 интерфейса, нет заглушек, узкие клиенты15%
Задание 5 (DIP): абстракции, работа с fakes, юнит-тесты20%
Качество кода: типизация, имена, читаемость, осмысленные тесты10%

Работа засчитывается при наборе не менее 60%. Все задания должны иметь рабочие тесты на pytest.


Вопросы для самопроверки

  1. Что значит «одна причина для изменения» в SRP? Сколько причин было у исходного UserManager?
  2. Почему добавление нового elif нарушает OCP? Как паттерн Strategy решает эту проблему?
  3. Почему геометрическое «квадрат — это прямоугольник» не означает отношение подтипов в смысле LSP? Что именно ломается в stretch()?
  4. Какой запах кода сигнализирует о нарушении ISP? Чем typing.Protocol удобен для узких интерфейсов?
  5. Что именно «инвертируется» в DIP? Почему конструктор должен принимать зависимости, а не создавать их?
  6. Зачем писать характеристические тесты до начала рефакторинга, если поведение кода и так «кривое»?
  7. В каком случае введение нового интерфейса будет преждевременным (over-engineering)?