Перейти к содержанию
Языки

Принципы SOLID простыми словами: примеры на Python

SOLID — пять принципов объектно-ориентированного проектирования: единственная ответственность, открытость/закрытость, подстановка Лисков, разделение интерфейсов и инверсия зависимостей. Определения легко выучить, но собеседование быстро переходит к вопросу «зачем»: какую проблему решает принцип и когда он вредит. Здесь каждый принцип разобран как ответ на конкретную боль при изменении кода — с коротким примером на Python 3.12, который мы запустили, и типичной ошибкой понимания. В конце — вопросы, которые задают после определения.

Автор: Опубликовано

8 мин чтенияРедакционный разборОбновлено
  • SOLID
  • Python
  • ООП
  • архитектура
  • собеседование
Главная мысль

Каждый принцип SOLID отвечает на вопрос «что сломается при следующем изменении». SRP разделяет причины изменений, OCP выделяет точки расширения, LSP защищает контракт, ISP сужает зависимости, DIP разворачивает их к абстракциям. Вводите абстракцию, когда изменение действительно повторяется, а не на всякий случай.

Зачем SOLID: всё про стоимость изменений

Роберт Мартин, который популяризировал эти принципы, в 2020 году писал, что они так же актуальны, как в 90-е. Его формулировки короче учебных: собирайте вместе то, что меняется по одним причинам, и разделяйте то, что меняется по разным; расширяйте поведение, не переписывая работающий модуль; не сбивайте с толку код, который работает с интерфейсом; держите интерфейсы маленькими; направляйте зависимости в сторону абстракций.

Объединяет их одно: каждый принцип снижает цену будущего изменения. Поэтому на собеседовании сильнее звучит не «SRP — это когда класс делает одно», а «SRP нужен, чтобы правка расчёта налога не ломала отчёт». Таблица ниже — шпаргалка: какой запах в коде подсказывает, что принцип нарушен, и какой рефакторинг обычно помогает.

Принцип, запах в коде и типичный рефакторинг
ПринципЗапах в кодеРефакторинг
SRPкласс правят разные отделы по разным причинамразделить расчёт, хранение и вывод
OCPкаждая опция — ещё один elif в старой функцииреестр правил, стратегия, плагин
LSPisinstance перед вызовом, 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, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку