Лекция 4. Принципы SOLID
Введение: зачем нужны принципы проектирования
Объектно-ориентированное программирование даёт нам инструменты — классы, наследование, полиморфизм. Но сами по себе инструменты не гарантируют хорошую архитектуру: молотком можно как забить гвоздь, так и разбить себе палец. Принципы SOLID — это набор инженерных рекомендаций, которые помогают строить системы, легко поддающиеся изменению, расширению и тестированию.
Аббревиатуру SOLID предложил Роберт Мартин (Robert C. Martin, «Uncle Bob») в начале 2000-х, обобщив пять принципов объектно-ориентированного дизайна:
- S — Single Responsibility Principle (единственная ответственность).
- O — Open/Closed Principle (открытость/закрытость).
- L — Liskov Substitution Principle (подстановка Барбары Лисков).
- I — Interface Segregation Principle (разделение интерфейсов).
- D — Dependency Inversion Principle (инверсия зависимостей).
Важно понимать: SOLID — это не закон и не самоцель. Это эвристики, которые борются с конкретными «запахами кода» (code smells) — симптомами плохого дизайна: жёсткостью (трудно менять), хрупкостью (правка в одном месте ломает другое), неподвижностью (нельзя переиспользовать) и вязкостью (проще сделать «костыль», чем правильно). Python — динамический язык и не навязывает эти принципы, но грамотное их применение заметно повышает качество кода.
S — Single Responsibility Principle (Принцип единственной ответственности)
Определение. У класса (модуля, функции) должна быть только одна причина для изменения. Иначе говоря, класс должен отвечать за одну, чётко очерченную область ответственности. «Причина для изменения» обычно связана с конкретной группой заинтересованных лиц или с одной осью изменчивости (бизнес-логика, формат хранения, способ доставки и т. д.).
Запах кода, который устраняется: God Class / God Method — класс, который «делает всё»: и валидирует, и пишет в БД, и шлёт письма, и логирует. Любое изменение в одной из этих областей затрагивает один и тот же файл, провоцируя конфликты и регрессии.
Плохо: один класс делает всё
import re, hashlib, sqlite3, smtplib
class UserManager: def register(self, name: str, email: str, password: str) -> dict: if len(name) < 2: # 1. валидация raise ValueError("Имя слишком короткое") if not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): raise ValueError("Некорректный email") pwd_hash = hashlib.sha256(password.encode()).hexdigest() # 2. хеш conn = sqlite3.connect("app.db") # 3. запись в БД conn.execute("INSERT INTO users(name,email,hash) VALUES(?,?,?)", (name, email, pwd_hash)) conn.commit() with smtplib.SMTP("smtp.example.com") as smtp: # 4. письмо smtp.sendmail("noreply@x.com", email, f"Привет, {name}!") with open("users.log", "a") as f: # 5. лог f.write(f"Зарегистрирован: {email}\n") return {"name": name, "email": email}У этого класса как минимум пять причин для изменения: правила валидации, алгоритм хеширования, схема БД, способ рассылки, формат логов.
Хорошо: одна ответственность — один класс
class UserValidator: def validate(self, name: str, email: str) -> None: if len(name) < 2: raise ValueError("Имя слишком короткое") if not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): raise ValueError("Некорректный email")
class Sha256Hasher: def hash(self, password: str) -> str: return hashlib.sha256(password.encode()).hexdigest()
class SqliteUserRepository: def save(self, name, email, pwd_hash) -> int: ... # только работа с БД
class SmtpEmailService: def send_welcome(self, email, name) -> None: ... # только отправка письма
class UserService: """Только оркестрация: связывает шаги, но сам ничего не делает.""" def __init__(self, validator, hasher, repo, email): self._validator, self._hasher = validator, hasher self._repo, self._email = repo, email
def register(self, name: str, email: str, password: str) -> dict: self._validator.validate(name, email) pwd_hash = self._hasher.hash(password) user_id = self._repo.save(name, email, pwd_hash) self._email.send_welcome(email, name) return {"id": user_id, "name": name, "email": email}Теперь каждый класс можно изменять и тестировать независимо. Поменялась схема БД — правим только SqliteUserRepository. Это резко снижает риск регрессий.
O — Open/Closed Principle (Принцип открытости/закрытости)
Определение. Программные сущности должны быть открыты для расширения, но закрыты для модификации. Добавление новой функциональности должно происходить через написание нового кода, а не через правку уже работающего и оттестированного.
Запах кода, который устраняется: Switch Statements — разрастающиеся if/elif/else или match по типу или строке. Каждый новый вариант требует править центральный метод, повышая риск сломать существующие ветки.
Плохо: добавление способа доставки правит метод
class ShippingCalculator: def calculate(self, method: str, weight: float, distance: float) -> float: if method == "standard": return 100 + weight * 10 + distance * 0.5 elif method == "express": return 300 + weight * 15 + distance * 1.0 elif method == "pickup": return 0 else: raise ValueError(f"Неизвестный метод: {method}")Каждый новый тариф — это правка calculate, повторное тестирование и риск задеть соседние ветки.
Хорошо: паттерн Strategy + реестр
from abc import ABC, abstractmethod
class ShippingStrategy(ABC): @abstractmethod def calculate(self, weight: float, distance: float) -> float: ...
class StandardShipping(ShippingStrategy): def calculate(self, weight, distance): return 100 + weight * 10 + distance * 0.5
class ExpressShipping(ShippingStrategy): def calculate(self, weight, distance): return 300 + weight * 15 + distance * 1.0
class ShippingCalculator: def __init__(self): self._registry: dict[str, ShippingStrategy] = {}
def register(self, name: str, strategy: ShippingStrategy) -> None: self._registry[name] = strategy
def calculate(self, method: str, weight: float, distance: float) -> float: if method not in self._registry: raise ValueError(f"Неизвестный метод: {method}") return self._registry[method].calculate(weight, distance)Новый способ доставки — это новый класс, который мы регистрируем извне, не трогая ShippingCalculator:
class DroneShipping(ShippingStrategy): def calculate(self, weight, distance): return 200 + weight * 20 + distance * 3.0
calc = ShippingCalculator()calc.register("drone", DroneShipping()) # расширение без модификацииМеханизм закрытости обычно достигается через абстракцию (базовый класс, протокол) и полиморфизм.
L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
Определение. Объекты подкласса должны быть взаимозаменяемы с объектами базового класса без нарушения корректности программы. Если код работает с базовым типом, подстановка любого наследника не должна менять ожидаемое поведение. Подкласс не должен усиливать предусловия и ослаблять постусловия, а также обязан сохранять инварианты базового класса.
Запах кода, который устраняется: наследник, который «ломает» контракт родителя — выбрасывает неожиданные исключения, требует больше входных условий или меняет смысл операции. Часто это следствие наследования «по созвучию» вместо настоящего отношения «является».
Плохо: квадрат как наследник прямоугольника
class Rectangle: def __init__(self, width, height): self._width, self._height = width, height
@property def width(self): return self._width @width.setter def width(self, v): self._width = v
@property def height(self): return self._height @height.setter def height(self, v): self._height = v
def area(self): return self._width * self._height
class Square(Rectangle): def __init__(self, side): super().__init__(side, side) @Rectangle.width.setter def width(self, v): self._width = self._height = v # связал стороны @Rectangle.height.setter def height(self, v): self._width = self._height = vКлиентский код, корректный для Rectangle, ломается на Square:
def stretch(rect: Rectangle) -> None: rect.width = 10 rect.height = 4 assert rect.area() == 40 # верно для Rectangle, но для Square area == 16!Геометрически квадрат — частный случай прямоугольника, но в терминах изменяемого поведения он не является его подтипом.
Хорошо: общий интерфейс без ложного наследования
from abc import ABC, abstractmethod
class Shape(ABC): @abstractmethod def area(self) -> float: ...
class Rectangle(Shape): def __init__(self, width, height): self.width, self.height = width, height def area(self): return self.width * self.height
class Square(Shape): def __init__(self, side): self.side = side def area(self): return self.side ** 2
def total_area(shapes: list[Shape]) -> float: return sum(s.area() for s in shapes) # работает с любой фигуройRectangle и Square — независимые реализации общего контракта Shape. Подстановка любой из них в total_area безопасна. Альтернатива — сделать фигуры неизменяемыми (@dataclass(frozen=True)): без сеттеров проблема изменения сторон исчезает в принципе.
I — Interface Segregation Principle (Принцип разделения интерфейсов)
Определение. Клиенты не должны зависеть от методов, которые они не используют. Лучше много маленьких специализированных интерфейсов, чем один «толстый». Каждый интерфейс должен описывать одну сплочённую роль.
Запах кода, который устраняется: «жирный» интерфейс, заставляющий реализации городить заглушки raise NotImplementedError. Это прямой признак того, что абстракция объединила несвязанные роли.
Плохо: один интерфейс на всех
from abc import ABC, abstractmethod
class Worker(ABC): @abstractmethod def work(self): ... @abstractmethod def eat(self): ... @abstractmethod def charge_battery(self): ...
class RobotWorker(Worker): def work(self): return "Работаю" def eat(self): raise NotImplementedError("Робот не ест!") # заглушка def charge_battery(self): return "Заряжаюсь"RobotWorker вынужден реализовать eat(), хотя он ему не нужен. Клиент, который вызовет eat() для робота, получит ошибку в рантайме.
Хорошо: узкие протоколы по ролям
from typing import Protocol
class Workable(Protocol): def work(self) -> str: ...
class Biological(Protocol): def eat(self) -> str: ...
class Electrical(Protocol): def charge_battery(self) -> str: ...
class HumanWorker: def work(self): return "Работаю" def eat(self): return "Обедаю"
class RobotWorker: def work(self): return "Работаю" def charge_battery(self): return "Заряжаюсь"
def run_shift(workers: list[Workable]) -> list[str]: return [w.work() for w in workers] # требует только то, что используетКаждый класс реализует ровно те роли, которые ему нужны. Функция run_shift зависит только от Workable — минимально необходимого контракта. В Python для этого удобны typing.Protocol (структурная типизация, «утиная» проверка) и множественное наследование от абстрактных базовых классов.
D — Dependency Inversion Principle (Принцип инверсии зависимостей)
Определение. Модули верхнего уровня не должны зависеть от модулей нижнего уровня — оба должны зависеть от абстракций. Детали (конкретные реализации) зависят от абстракций, а не наоборот. Это «инвертирует» привычное направление зависимостей: высокоуровневая бизнес-логика перестаёт быть прибитой к конкретной БД или платёжному шлюзу.
Запах кода, который устраняется: жёсткая связанность (tight coupling), когда класс сам создаёт свои зависимости через конструктор конкретного типа. Такой код невозможно протестировать без реальной БД, сети и почтового сервера.
Плохо: бизнес-логика создаёт конкретные зависимости
class OrderProcessor: def __init__(self): self.db = MySqlDatabase() # жёсткая привязка self.payment = StripePaymentGateway() # к конкретным self.email = SendgridEmailClient() # реализациям
def process(self, order, card, email): charge_id = self.payment.charge(order["amount"], card) order_id = self.db.save_order(order) self.email.send(email, "Заказ оформлен", f"Заказ #{order_id}") return {"order_id": order_id, "charge_id": charge_id}Заменить MySQL на PostgreSQL или подставить фейк в тест нельзя, не переписав класс.
Хорошо: зависимости через абстракции (Dependency Injection)
from abc import ABC, abstractmethod
class OrderRepository(ABC): @abstractmethod def save_order(self, order: dict) -> int: ...
class PaymentGateway(ABC): @abstractmethod def charge(self, amount: float, card: str) -> str: ...
class EmailClient(ABC): @abstractmethod def send(self, to: str, subject: str, body: str) -> None: ...
class OrderProcessor: def __init__(self, repo: OrderRepository, payment: PaymentGateway, email: EmailClient): self._repo = repo self._payment = payment self._email = email
def process(self, order, card, email): charge_id = self._payment.charge(order["amount"], card) order_id = self._repo.save_order(order) self._email.send(email, "Заказ оформлен", f"Заказ #{order_id}") return {"order_id": order_id, "charge_id": charge_id}Конкретные MySqlDatabase, StripePaymentGateway реализуют абстракции для продакшна, а в тестах подставляются лёгкие двойники:
class InMemoryOrderRepository(OrderRepository): def __init__(self): self.saved = [] def save_order(self, order): self.saved.append(order); return len(self.saved)
class FakePaymentGateway(PaymentGateway): def charge(self, amount, card): return "ch_fake"
# Тест без сети и БДprocessor = OrderProcessor(InMemoryOrderRepository(), FakePaymentGateway(), FakeEmailClient())Передача зависимостей через конструктор называется внедрением зависимостей (Dependency Injection) — это практический приём, реализующий DIP. Ключевая идея: конструктор принимает зависимости, а не создаёт их.
Как принципы связаны между собой
SOLID — это не пять изолированных правил, а взаимоусиливающая система:
- SRP — фундамент. Когда у класса одна ответственность, его проще закрыть для модификации (OCP), легче выделить узкий интерфейс (ISP) и проще подменить зависимость (DIP).
- OCP опирается на LSP и DIP. Расширение без модификации возможно только через абстракции (DIP) и корректную замену реализаций (LSP). Если наследник нарушает контракт, полиморфная подстановка из OCP сломается.
- ISP помогает DIP. Узкие интерфейсы — это и есть «правильные» абстракции, от которых стоит зависеть. Чем мельче и сплочённее интерфейс, тем меньше лишних связей.
- LSP — гарант полиморфизма. Без подстановочной корректности рушится сама идея «один интерфейс — много реализаций», на которой держатся OCP, ISP и DIP.
Можно сказать так: SRP даёт кому зависеть, ISP — от чего зависеть, DIP — как зависеть (через абстракцию), LSP — гарантирует, что зависимость безопасно заменяема, а OCP — итоговое свойство системы, которое всё это обеспечивает.
Запахи кода и принципы, которые их лечат
| Запах кода | Симптом | Принцип |
|---|---|---|
| God Class / God Method | класс делает слишком много | SRP |
| Switch Statements | разрастающиеся if/elif по типу | OCP |
| Нарушенный контракт наследника | подкласс кидает сюрпризы | LSP |
| Fat Interface | заглушки NotImplementedError | ISP |
| Tight Coupling | класс создаёт зависимости сам | DIP |
| Shotgun Surgery | одно изменение — правки во многих местах | SRP + OCP |
Предостережение: не переусердствуйте
SOLID борется со сложностью, но слепое следование принципам само порождает сложность. Не вводите абстракцию «впрок»: интерфейс оправдан, когда у него есть хотя бы две реализации (или вторая — фейк для теста — появится в обозримом будущем). Преждевременное дробление на классы и плодение интерфейсов даёт обратный эффект — over-engineering. Принципы — это компас, а не рельсы: применяйте их там, где код реально меняется и болит.
Краткие итоги
- SOLID — пять принципов ООП-проектирования, борющихся с жёсткостью, хрупкостью и непереиспользуемостью кода.
- S (SRP): одна причина для изменения на класс; лекарство от God Class.
- O (OCP): открыт для расширения, закрыт для модификации; реализуется через абстракции и полиморфизм (например, Strategy).
- L (LSP): наследник должен безопасно подставляться вместо базового типа, сохраняя контракт и инварианты.
- I (ISP): много узких интерфейсов лучше одного «толстого»; клиент зависит только от того, что использует.
- D (DIP): зависим от абстракций, а не от конкретных классов; практический приём — Dependency Injection через конструктор.
- Принципы взаимосвязаны: SRP — основа, LSP — гарант полиморфизма, DIP и ISP задают правильные абстракции, OCP — итоговое свойство гибкой системы.
- SOLID — эвристики, а не догма: избегайте преждевременных абстракций.
Вопросы для самопроверки
- Что понимается под «одной причиной для изменения» в SRP? Приведите пример класса с несколькими причинами.
- Почему добавление нового
elifв большой условный оператор является нарушением OCP? Как это исправить? - Чем геометрическое отношение «квадрат — это прямоугольник» отличается от отношения подтипов в смысле LSP?
- Какой запах кода сигнализирует о нарушении ISP? Как
typing.Protocolпомогает следовать этому принципу? - Что именно «инвертируется» в принципе инверсии зависимостей? Как Dependency Injection связан с DIP?
- Почему LSP можно считать необходимым условием для работы OCP?
- В каких случаях введение новой абстракции (интерфейса) будет преждевременным и вредным?
- Сопоставьте каждый из пяти принципов с устраняемым им запахом кода.