Практика 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, smtplibfrom 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}Требования:
- Выделите минимум 4 класса с одной ответственностью:
UserValidator,Sha256PasswordHasher,UserRepository(абстракция +SqliteUserRepository),EmailService(абстракция +SmtpEmailService),UserLogger(абстракция +FileUserLogger). - Класс
UserService(бывшийUserManager) должен только оркестрировать регистрацию, получая зависимости через конструктор (Dependency Injection). - Реализуйте
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}")Требования:
- Примените паттерн Strategy: вынесите каждый тариф в отдельный класс с общим интерфейсом
ShippingStrategy(методcalculate(weight_kg, distance_km) -> float). - Реализуйте реестр
ShippingStrategyRegistryс методамиregister(name, strategy)иget(name);ShippingCalculatorполучает реестр через конструктор. - Проверка 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Требования:
- Продемонстрируйте нарушение тестом: функция
stretch(rect), которая ставитwidth = 10,height = 4и ожидаетarea() == 40, корректна дляRectangle, но ломается наSquare. - Предложите корректную архитектуру (на выбор): общий абстрактный
Shapeс методомarea(), гдеRectangleиSquare— независимые реализации; или неизменяемые фигуры@dataclass(frozen=True)без сеттеров. - Покажите, что функция
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("Робот не получает зарплату!")Требования:
- Разбейте
Workerминимум на 4 узких интерфейса (используйтеtyping.ProtocolилиABC):Workable(work),Biological(eat,sleep),Electrical(charge_battery),Employable(receive_salary). - Реализуйте
HumanWorker(Workable, Biological, Employable)иRobotWorker(Workable, Electrical)— без единой заглушкиNotImplementedError. - Напишите функции-клиенты, зависящие от минимума:
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}Требования:
- Определите абстракции
OrderRepository,PaymentGateway,EmailClient(ABCс нужными методами); сделайтеMySqlDatabase,StripePaymentGateway,SendgridEmailClientих реализациями. - Перепишите
OrderProcessorтак, чтобы он принимал зависимости через конструктор и работал только с абстракциями. - Реализуйте тестовые двойники
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.
Вопросы для самопроверки
- Что значит «одна причина для изменения» в SRP? Сколько причин было у исходного
UserManager? - Почему добавление нового
elifнарушает OCP? Как паттерн Strategy решает эту проблему? - Почему геометрическое «квадрат — это прямоугольник» не означает отношение подтипов в смысле LSP? Что именно ломается в
stretch()? - Какой запах кода сигнализирует о нарушении ISP? Чем
typing.Protocolудобен для узких интерфейсов? - Что именно «инвертируется» в DIP? Почему конструктор должен принимать зависимости, а не создавать их?
- Зачем писать характеристические тесты до начала рефакторинга, если поведение кода и так «кривое»?
- В каком случае введение нового интерфейса будет преждевременным (over-engineering)?