Каждый принцип SOLID отвечает на вопрос «что сломается при следующем изменении». SRP разделяет причины изменений, OCP выделяет точки расширения, LSP защищает контракт, ISP сужает зависимости, DIP разворачивает их к абстракциям. Вводите абстракцию, когда изменение действительно повторяется, а не на всякий случай.
Зачем SOLID: всё про стоимость изменений
Роберт Мартин, который популяризировал эти принципы, в 2020 году писал, что они так же актуальны, как в 90-е. Его формулировки короче учебных: собирайте вместе то, что меняется по одним причинам, и разделяйте то, что меняется по разным; расширяйте поведение, не переписывая работающий модуль; не сбивайте с толку код, который работает с интерфейсом; держите интерфейсы маленькими; направляйте зависимости в сторону абстракций.
Объединяет их одно: каждый принцип снижает цену будущего изменения. Поэтому на собеседовании сильнее звучит не «SRP — это когда класс делает одно», а «SRP нужен, чтобы правка расчёта налога не ломала отчёт». Таблица ниже — шпаргалка: какой запах в коде подсказывает, что принцип нарушен, и какой рефакторинг обычно помогает.
| Принцип | Запах в коде | Рефакторинг |
|---|---|---|
| SRP | класс правят разные отделы по разным причинам | разделить расчёт, хранение и вывод |
| OCP | каждая опция — ещё один elif в старой функции | реестр правил, стратегия, плагин |
| LSP | isinstance перед вызовом, NotImplementedError в наследнике | композиция вместо наследования |
| ISP | заглушки pass для ненужных методов | узкие Protocol под потребителя |
| DIP | бизнес-логика импортирует SMTP или ORM напрямую | абстракция и внедрение через конструктор |
S — единственная ответственность: одна причина для изменения
В 2014 году Мартин уточнил формулировку: у модуля должна быть одна причина для изменения, а причины — это люди, которые просят изменений. В примере правила расчёта счёта меняет бухгалтерия, а вид отчёта — менеджеры. Если всё это живёт в одном классе Invoice с методами total, to_text и to_csv, правка НДС требует трогать файл, который заодно рендерит отчёты, и перепроверять код обоих отделов.
После разделения invoice_total не знает о форматах, а render_text и render_csv не знают о налогах. Вывод — две строки позиций с ценами 250.00 и 180.00, «Итого: 816.00 руб.» и CSV с итогом 81600 копеек. Частая ошибка — понимать SRP как «одна функция» или «один метод в классе». Мартин говорит о причинах изменений, а не об объёме кода: render_text и render_csv спокойно живут в одном модуле, потому что их меняет один и тот же заказчик.
from dataclasses import dataclass
@dataclass
class Line:
title: str
qty: int
price: int # копейки
# Бухгалтерия меняет правила расчёта, менеджеры — вид отчёта.
# Поэтому расчёт и представление живут в разных местах.
def invoice_total(lines: list[Line], vat_percent: int) -> int:
net = sum(line.qty * line.price for line in lines)
return net + net * vat_percent // 100
def render_text(lines: list[Line], total: int) -> str:
rows = [f"{line.title:<8}{line.qty:>3} x {line.price / 100:.2f}" for line in lines]
return "\n".join(rows + [f"Итого: {total / 100:.2f} руб."])
def render_csv(lines: list[Line], total: int) -> str:
rows = [f"{line.title};{line.qty};{line.price}" for line in lines]
return "\n".join(rows + [f"total;;{total}"])
lines = [Line("Кофе", 2, 25000), Line("Чай", 1, 18000)]
total = invoice_total(lines, vat_percent=20)
print(render_text(lines, total))
print(render_csv(lines, total))O — открытость/закрытость: новое правило без правки старого кода
Принцип сформулировал Бертран Мейер в 1988 году в книге «Object-Oriented Software Construction», а Мартин пересказывает его так: поведение системы должно расширяться без изменения самой системы. Типичный запах — функция расчёта скидок, в которую каждая маркетинговая акция добавляет ещё один elif. Каждая такая правка рискует сломать уже работающие ветки и заставляет перепроверять их все.
В примере правила — отдельные функции в реестре, а final_price выбирает максимальную скидку и при новых правилах не меняется. Вывод: 3800, 7600, а после регистрации black_friday — 7200. Ошибка понимания: OCP не запрещает редактировать код. Он требует найти ось, по которой изменения действительно приходят, и сделать расширение по ней дешёвым. Если новых правил не бывает, реестр — лишняя косвенность, и обычный if читается лучше.
from typing import Callable
Order = dict
Rule = Callable[[Order], int] # возвращает скидку в рублях
RULES: list[Rule] = []
def rule(fn: Rule) -> Rule:
RULES.append(fn)
return fn
@rule
def loyal_customer(order: Order) -> int:
return order["sum"] * 5 // 100 if order["years"] >= 3 else 0
@rule
def big_cart(order: Order) -> int:
return 300 if order["sum"] >= 5000 else 0
def final_price(order: Order) -> int: # не меняется при новых правилах
return order["sum"] - max((r(order) for r in RULES), default=0)
print(final_price({"sum": 4000, "years": 4}))
print(final_price({"sum": 8000, "years": 4}))
@rule # новое правило — новый код, старый не трогаем
def black_friday(order: Order) -> int:
return order["sum"] // 10
print(final_price({"sum": 8000, "years": 4}))L — подстановка Лисков: контракт важнее сигнатуры
Барбара Лисков и Жаннетт Винг в статье 1994 года в ACM TOPLAS определили подтип через поведение, заданное спецификациями типов: любое свойство, доказанное для объектов супертипа, должно выполняться и для объектов подтипа. Практическое следствие: наследник не усиливает предусловия, не ослабляет постусловия и сохраняет инварианты базового типа. Совпадение сигнатур ничего не гарантирует: Python и статические анализаторы сверяют имена и типы, а не смысл.
Квадрат — прямоугольник в математике, но не по поведению. Контракт set_width у Rectangle: ширина меняется, высота остаётся. Square нарушает это постусловие, и функция stretch, честно написанная под Rectangle, ломается: вывод 30 для прямоугольника и «нарушен контракт: Square: высота изменилась». Исправление — не наследовать Square от изменяемого Rectangle, а дать обоим общий протокол с методом area() или сделать фигуры неизменяемыми. Полезная практика — прогонять тесты базового класса на всех наследниках.
class Rectangle:
def __init__(self, width: int, height: int):
self.width, self.height = width, height
def set_width(self, value: int) -> None: # контракт: высота не меняется
self.width = value
def area(self) -> int:
return self.width * self.height
class Square(Rectangle):
def __init__(self, side: int):
super().__init__(side, side)
def set_width(self, value: int) -> None: # «квадрат остаётся квадратом»
self.width = self.height = value
def stretch(shape: Rectangle) -> int:
old_height = shape.height
shape.set_width(10)
assert shape.height == old_height, f"{type(shape).__name__}: высота изменилась"
return shape.area()
print(stretch(Rectangle(2, 3)))
try:
stretch(Square(3))
except AssertionError as error:
print("нарушен контракт:", error)I и D — узкие интерфейсы и зависимость от абстракций
ISP: клиент не должен зависеть от методов, которые ему не нужны. По словам Мартина, в языках с динамической типизацией лишние зависимости бьют гораздо слабее, но и там не исчезают полностью. Удобный инструмент для ISP в Python — typing.Protocol из PEP 544: потребитель описывает ровно те методы, которые вызывает, а класс подходит, если они у него есть, без наследования. SignupService нужен только send, хотя SmtpMailer умеет ещё connect и bounces.
DIP разворачивает зависимость: политика верхнего уровня, регистрация, зависит от абстракции Notifier, а SMTP — деталь, которую передают снаружи. Поэтому в тесте подставляется FakeNotifier и вывод показывает [('anna@example.com', 'Добро пожаловать')], а боевой путь печатает SMTP -> oleg@example.com: Добро пожаловать. Последняя строка — True False: runtime_checkable разрешает isinstance, но проверяет только наличие методов, не сигнатуры. Основная проверка Protocol статическая — в mypy или pyright. DI-контейнер для DIP не обязателен: хватает параметра конструктора.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Notifier(Protocol): # узкий интерфейс: ровно то, что нужно
def send(self, to: str, text: str) -> None: ...
class SignupService: # политика верхнего уровня
def __init__(self, notifier: Notifier) -> None:
self.notifier = notifier # зависимость от абстракции
def register(self, email: str) -> None:
self.notifier.send(email, "Добро пожаловать")
class SmtpMailer: # деталь: умеет больше, чем нужно сервису
def connect(self) -> None: ...
def bounces(self) -> list[str]: return []
def send(self, to: str, text: str) -> None:
print(f"SMTP -> {to}: {text}")
class FakeNotifier: # для тестов, без наследования
def __init__(self) -> None:
self.sent: list[tuple[str, str]] = []
def send(self, to: str, text: str) -> None:
self.sent.append((to, text))
fake = FakeNotifier()
SignupService(fake).register("anna@example.com")
SignupService(SmtpMailer()).register("oleg@example.com")
print(fake.sent)
print(isinstance(fake, Notifier), isinstance(object(), Notifier))Когда SOLID вредит и как отвечать на собеседовании
Принципы вредят, когда абстракции появляются раньше изменений: интерфейс с единственной реализацией, фабрика для одного класса, реестр для двух правил, иерархия «на вырост». Такой код труднее читать, а угаданная заранее ось изменений часто оказывается не той. Рабочее правило: вводите абстракцию на втором-третьем реальном варианте или на границе с внешним миром — базой, сетью, сторонним API, — где она окупается хотя бы в тестах.
Ответ на собеседовании стройте по схеме: принцип одной фразой, затем проблема изменений, которую он решает, пример из своего кода и цена. Подготовьте ответы на уточнения, которые естественно вытекают из определений:
- Чем SRP отличается от правила «функция делает одно дело»?
- Почему квадрат-наследник прямоугольника нарушает LSP и как это исправить?
- Как выглядит ISP в Python, где нет ключевого слова interface?
- DIP и dependency injection — одно и то же?
- Когда вы сознательно отказались от абстракции и почему?
Проверьте себя на своём коде
Возьмите любой класс из своего проекта и задайте ему пять вопросов: сколько людей просит его менять, что придётся править при новой опции, можно ли подставить любого наследника, какие методы реально вызывают потребители и от каких деталей зависит бизнес-логика. Ответы покажут, какой принцип здесь стоит применить, а какой — нет.
Для повторения пригодятся сборник вопросов по Python и разбор Java-собеседования для middle и senior. Попробуйте объяснить каждый принцип вслух за полминуты, с одним примером из своего кода и одной ситуацией, когда он не нужен.
Коротко
Частые вопросы
Применим ли SOLID к функциональному коду и скриптам?
Идеи переносятся: разделять код по причинам изменений и зависеть от абстракций можно и с функциями — стратегия становится параметром-функцией, интерфейс — сигнатурой. Для скрипта на сотню строк, который живёт неделю, формальное следование SOLID обычно не окупается.
Чем SOLID отличается от паттернов проектирования?
SOLID — принципы, то есть критерии хорошего дизайна. Паттерны — конкретные типовые решения, и многие из них помогают соблюдать принципы: Strategy — OCP, Adapter изолирует внешний API и поддерживает DIP. Паттерн без понимания принципа превращается в заучивание схем.
Что выбрать для интерфейса в Python: ABC или Protocol?
ABC требует явного наследования или регистрации через register() и не даст создать объект наследника, пока не переопределены абстрактные методы. Protocol описывает структуру и проверяется статическим анализатором без наследования. Для узкого интерфейса на стороне потребителя удобнее Protocol, для общей базовой реализации — ABC.
Нарушает ли LSP наследник, который выбрасывает NotImplementedError?
Да, если базовый тип обещает, что метод работает: код, рассчитанный на базовый тип, получит исключение, которого не ждал. Обычно это признак того, что наследование выбрано неверно, а интерфейс слишком широк, то есть заодно нарушен ISP.
Источники
Источники и редакционная политика
Сведения проверены 7 ноября 2026 г. Ссылки рядом с разделами указывают источники фактов и технических объяснений. Выводы, учебные сценарии и рекомендации по подготовке — редакционная работа RecallDeck.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.