Принципы SOLID в объектно-ориентированном проектировании
Принципы SOLID в объектно-ориентированном проектировании
Что такое SOLID?
SOLID — это набор из пяти фундаментальных принципов объектно-ориентированного проектирования и программирования. Эти принципы были сформулированы Робертом Мартином (также известным как "Дядя Боб") в начале 2000-х годов и призваны помочь разработчикам создавать гибкие, поддерживаемые и расширяемые программные системы. Каждая буква в аббревиатуре SOLID обозначает отдельный принцип:
- S — Принцип единственной ответственности (Single Responsibility Principle)
- O — Принцип открытости/закрытости (Open/Closed Principle)
- L — Принцип подстановки Барбары Лисков (Liskov Substitution Principle)
- I — Принцип разделения интерфейса (Interface Segregation Principle)
- D — Принцип инверсии зависимостей (Dependency Inversion Principle)
Эти принципы не являются строгими правилами, обязательными к исполнению в любой ситуации. Они представляют собой руководящие идеи, которые помогают принимать архитектурные и дизайнерские решения. Их применение способствует уменьшению связанности компонентов, повышению читаемости кода, упрощению тестирования и облегчению внесения изменений без риска нарушить уже работающую функциональность.
Ниже каждый из принципов рассматривается подробно, с пояснением его сути, примерами на разных языках программирования и демонстрацией того, как соблюдение принципа влияет на качество кода.
SOLID работает как набор инженерных эвристик. На маленьком скрипте часть принципов может быть избыточной, а в большой кодовой базе их применение снижает стоимость изменений и ускоряет ревью.
Play ITЗагрузка интерактивного демо…
S — Принцип единственной ответственности
Принцип единственной ответственности утверждает, что каждый класс, модуль или функция должны иметь одну, и только одну, причину для изменения. Другими словами, сущность должна отвечать за одну конкретную задачу или область поведения. Если у класса появляется более одной причины для изменения, это сигнал о том, что он берёт на себя слишком много обязанностей.
Разделение обязанностей позволяет локализовать изменения — когда требования к одной части системы меняются, затрагиваются только те компоненты, которые непосредственно связаны с этой частью. Это снижает риск случайного нарушения других функций при внесении правок.
Пример нарушения
Представим класс, который одновременно сохраняет данные пользователя в файл и отправляет уведомление по электронной почте:
КЛАСС МенеджерПользователей // две причины для изменения
метод Сохранить(данные)
записать_в_файл(данные) // меняется при смене хранилища
конец
метод ОтправитьПисьмо(адрес, текст)
отправить_smtp(адрес, текст) // меняется при смене почтового API
конец
КОНЕЦ
Справочно на Python (нарушение SRP)
Код ITЗагрузка примера кода…
Этот класс отвечает и за работу с файловой системой, и за коммуникацию по сети. При изменении формата хранения данных или механизма отправки писем потребуется править один и тот же класс. Это усложняет поддержку и увеличивает вероятность ошибок.
Корректная реализация
Разделим обязанности на два отдельных класса:
КЛАСС ХранилищеПользователей
метод Сохранить(данные) …
КОНЕЦ
КЛАСС ПочтовыйСервис
метод Отправить(адрес, текст) …
КОНЕЦ
КЛАСС РегистрацияПользователя
поля: хранилище, почта
метод Зарегистрировать(данные, email)
хранилище.Сохранить(данные)
почта.Отправить(email, "Добро пожаловать")
конец
КОНЕЦ
Справочно на Python
Код ITЗагрузка примера кода…
Теперь каждый класс имеет чёткую зону ответственности. Изменение одного аспекта не затрагивает другие.
Аналог на Java
Код ITЗагрузка примера кода…
Код ITЗагрузка примера кода…
Аналог на C#
Код ITЗагрузка примера кода…
Соблюдение принципа единственной ответственности делает код более модульным, тестируемым и понятным. Каждый компонент можно заменить, расширить или повторно использовать независимо от других.
O — Принцип открытости/закрытости
Принцип открытости/закрытости гласит, что программные сущности (классы, модули, функции) должны быть открыты для расширения, но закрыты для модификации. Это означает, что поведение системы можно изменять, добавляя новый код, а не изменяя существующий.
Цель этого принципа — обеспечить стабильность уже протестированного и работающего кода. Вместо того чтобы вносить правки в старые классы, разработчик создаёт новые, которые расширяют или реализуют нужное поведение через наследование или композицию.
Пример нарушения
Рассмотрим функцию, которая рассчитывает общую стоимость заказа с учётом типа скидки:
АЛГОРИТМ ИтогСоСкидкой(товары, тип_скидки)
сумма := сумма_цен(товары)
если тип_скидки = "сезон" то вернуть сумма * 0.9
иначе если тип_скидки = "vip" то вернуть сумма * 0.8
иначе если тип_скидки = "промо" то вернуть сумма * 0.85
иначе вернуть сумма
конец
КОНЕЦ
Новый тип скидки заставляет менять этот алгоритм — нарушение "закрыт для изменений".
Справочно на Python
Код ITЗагрузка примера кода…
Каждый раз, когда появляется новый тип скидки, приходится изменять эту функцию. Это нарушает принцип: код не закрыт для модификации.
Корректная реализация через полиморфизм
Создадим абстракцию для скидок:
ИНТЕРФЕЙС Скидка
метод Применить(сумма) → число
КОНЕЦ
КЛАСС СкидкаСезон РЕАЛИЗУЕТ Скидка
метод Применить(сумма) вернуть сумма * 0.9
КОНЕЦ
АЛГОРИТМ ИтогСоСкидкой(товары, скидка: Скидка)
сумма := сумма_цен(товары)
вернуть скидка.Применить(сумма)
КОНЕЦ
Новая скидка = новый класс, а не правка ИтогСоСкидкой.
Справочно на Python
Код ITЗагрузка примера кода…
Теперь для добавления новой скидки достаточно создать новый класс, реализующий интерфейс Discount. Функция calculate_total не требует изменений.
Аналог на Java
Код ITЗагрузка примера кода…
Аналог на C#
Код ITЗагрузка примера кода…
Принцип открытости/закрытости особенно важен в крупных системах, где изменения в ядре могут вызвать каскадные ошибки. Он поощряет использование абстракций и делегирование поведения, что упрощает эволюцию системы без переписывания рабочего кода.
L — Принцип подстановки Барбары Лисков
Принцип подстановки Барбары Лисков утверждает, что объекты в программе должны быть заменяемы на экземпляры их подтипов без изменения корректности работы программы. Другими словами, если класс B является подклассом класса A, то любой объект типа A можно заменить объектом типа B, и программа продолжит работать так, как ожидалось.
Этот принцип лежит в основе полиморфизма и обеспечивает предсказуемость поведения наследуемых классов. Нарушение принципа приводит к неожиданным ошибкам, когда код, написанный для базового типа, начинает вести себя некорректно при работе с производным типом.
Классический пример нарушения
Рассмотрим геометрические фигуры. Интуитивно кажется, что квадрат — это частный случай прямоугольника, поэтому можно создать иерархию:
КЛАСС Прямоугольник
метод УстановитьШирину(w) ширина := w
метод УстановитьВысоту(h) высота := h
КОНЕЦ
КЛАСС Квадрат НАСЛЕДУЕТ Прямоугольник
метод УстановитьШирину(w) ширина := w; высота := w
метод УстановитьВысоту(h) ширина := h; высота := h
КОНЕЦ
АЛГОРИТМ УдвоитьШирину(фигура: Прямоугольник)
фигура.УстановитьШирину(фигура.ширина * 2)
// для квадрата меняется и высота — сюрприз для вызывающего кода
КОНЕЦ
Справочно на Python
Код ITЗагрузка примера кода…
Теперь представим функцию, которая ожидает прямоугольник:
def double_width(rect: Rectangle):
rect.set_width(rect.width * 2)
return rect.area()
Если передать в неё Rectangle(3, 4), результат будет 24 (ширина стала 6, высота осталась 4).
Если передать Square(3), ширина станет 6, но высота тоже станет 6, и площадь будет 36. Это нарушает ожидаемое поведение — функция предполагает, что только ширина изменится, а высота останется прежней.
Таким образом, Square не может корректно заменить Rectangle, несмотря на кажущуюся логичность наследования.
Корректный подход
Вместо наследования лучше использовать композицию или общий интерфейс:
Код ITЗагрузка примера кода…
Теперь оба класса реализуют один интерфейс Shape, но не связаны наследованием. Функции, работающие с Shape, могут принимать любой из них, не делая предположений о внутренней структуре.
Аналог на Java
Код ITЗагрузка примера кода…
Здесь нет методов изменения размеров, что устраняет проблему. Если изменяемость необходима, следует проектировать интерфейсы так, чтобы подтипы не нарушали контракты.
Аналог на C#
Код ITЗагрузка примера кода…
Принцип подстановки Лисков защищает от логических противоречий в иерархиях наследования. Он требует, чтобы подклассы не только расширяли, но и полностью соблюдали контракты своих родителей — по сигнатурам, по условиям до и после выполнения методов, и по инвариантам состояния.
I — Принцип разделения интерфейса
Принцип разделения интерфейса гласит, что клиенты не должны зависеть от методов, которые они не используют. Вместо одного большого интерфейса с множеством методов лучше создавать несколько специализированных интерфейсов, ориентированных на конкретные задачи.
Этот принцип направлен на уменьшение связанности и повышение гибкости. Когда класс реализует интерфейс, содержащий ненужные ему методы, он вынужден либо писать пустые заглушки, либо выбрасывать исключения, что нарушает целостность системы.
Пример нарушения
Представим универсальный интерфейс для устройства:
ИНТЕРФЕЙС МФУ
Печать(), Сканировать(), Факс(), Копировать()
КОНЕЦ
КЛАСС ПростойПринтер РЕАЛИЗУЕТ МФУ
Печать() …
Сканировать() ошибка "не поддерживается" // лишний метод для клиента
КОНЕЦ
Справочно на Python
Код ITЗагрузка примера кода…
Теперь реализуем простой принтер:
Код ITЗагрузка примера кода…
Каждый вызов неподдерживаемого метода приведёт к ошибке. Это нарушает принцип: класс зависит от методов, которые не использует.
Корректная реализация
Разделим интерфейс на части:
Код ITЗагрузка примера кода…
Теперь каждый класс реализует только те интерфейсы, которые ему действительно нужны. Клиенты могут запрашивать только необходимые возможности.
Аналог на Java
Код ITЗагрузка примера кода…
Аналог на C#
Код ITЗагрузка примера кода…
Принцип разделения интерфейса способствует созданию более чистых, сфокусированных абстракций. Он позволяет клиентам точно определять свои зависимости и избегать "загрязнения" нерелевантными методами.
D — Принцип инверсии зависимостей
Принцип инверсии зависимостей утверждает, что модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба типа модулей должны зависеть от абстракций. Кроме того, абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Этот принцип лежит в основе многих современных архитектурных подходов, таких как архитектура с чистыми слоями (Clean Architecture) или гексагональная архитектура. Его цель — разорвать жёсткую связанность между компонентами системы, чтобы изменения в одной части не вызывали каскадных правок в других.
В традиционном подходе высокоуровневый компонент (например, бизнес-логика) напрямую использует низкоуровневый (например, базу данных или внешний API). Это создаёт зависимость от конкретной реализации. При замене базы данных на другую приходится переписывать бизнес-логику. Принцип инверсии зависимостей предлагает ввести абстракцию (интерфейс), через которую высокоуровневый компонент взаимодействует с низкоуровневым. Сама же реализация этой абстракции внедряется извне, часто с помощью механизма внедрения зависимостей (Dependency Injection).
Пример нарушения
Рассмотрим сервис уведомлений, который напрямую зависит от конкретного провайдера:
КЛАСС СервисЗаказов
поле уведомитель := новый SmtpУведомления() // жёсткая связь
метод Оформить(заказ)
…
уведомитель.Отправить("Заказ оформлен")
конец
КОНЕЦ
Справочно на Python
Код ITЗагрузка примера кода…
Если потребуется добавить поддержку SMS или мессенджеров, придётся изменять OrderService. Бизнес-логика привязана к конкретной технологии доставки уведомлений.
Корректная реализация
Введём абстракцию и передадим реализацию извне:
ИНТЕРФЕЙС Уведомления
метод Отправить(текст)
КОНЕЦ
КЛАСС SmtpУведомления РЕАЛИЗУЕТ Уведомления …
КЛАСС SmsУведомления РЕАЛИЗУЕТ Уведомления …
КЛАСС СервисЗаказов
поле уведомитель: Уведомления
конструктор(уведомитель)
метод Оформить(заказ)
уведомитель.Отправить("Заказ оформлен")
конец
КОНЕЦ
// Сборка снаружи:
сервис := новый СервисЗаказов(новый SmsУведомления())
Справочно на Python
Код ITЗагрузка примера кода…
Теперь OrderService не знает, какой именно способ уведомления используется. Можно легко подменить реализацию без изменения самого сервиса заказов.
Аналог на Java
Код ITЗагрузка примера кода…
В реальных приложениях объект OrderService создаётся с помощью контейнера внедрения зависимостей (например, Spring), который автоматически подставляет нужную реализацию.
Аналог на C#
Код ITЗагрузка примера кода…
В .NET такие зависимости обычно регистрируются в DI-контейнере при старте приложения:
// В методе ConfigureServices или аналогичном
services.AddScoped<INotificationService, EmailNotificationService>();
// или
services.AddScoped<INotificationService, SmsNotificationService>();
Принцип инверсии зависимостей позволяет строить системы, где основная бизнес-логика изолирована от деталей инфраструктуры. Это упрощает тестирование (можно подставить моки), повышает гибкость и делает код более устойчивым к изменениям в окружении.
См. также — DRY, KISS и смежные принципы
SOLID описывает структуру классов и зависимостей в ООП. Рядом в практике идут принципы DRY (Don’t Repeat Yourself — одно представление каждой единицы знания), KISS (Keep It Simple, Stupid — минимально достаточная сложность) и YAGNI (не реализовывать "на будущее" без запроса).
Развёрнутые разделы с примерами нарушений, чек-листами и связью с SRP — в статье Принципы проектирования (разделы DRY и KISS). Дублирование как запах кода — в Горизонтальное дублирование.
SOLID в повседневной работе
В реальном проекте принципы удобно применять по шагам:
- На этапе проектирования фиксируйте ответственности модулей и границы зависимостей.
- При код-ревью проверяйте, добавляет ли правка новую причину изменения существующего класса.
- В рефакторинге выносите вариативное поведение за интерфейсы и стратегии.
- В тестах изолируйте бизнес-логику от инфраструктуры через DI и моки.
Быстрый чек-лист для ревью
- SRP: у класса одна предметная роль и одна причина изменения.
- OCP: расширение поведения выполняется добавлением нового кода без редактирования стабильного ядра.
- LSP: подтип сохраняет контракт базового типа и не ломает ожидания клиента.
- ISP: интерфейсы узкие и ориентированы на сценарии клиентов.
- DIP: высокоуровневый модуль зависит от абстракций, детали подключаются снаружи.
Антипаттерны при внедрении SOLID
- Формальное дробление классов без практической границы ответственности.
- Абстракции "на вырост" без реального второго варианта реализации.
- Интерфейсы с десятками методов, которые затрудняют подстановку и тестирование.
- Наследование ради переиспользования кода, когда композиция даёт более предсказуемую модель.
- DI-контейнер как "магический глобальный объект" без явных границ зависимостей.
Продакшн-кейс
Сервис уведомлений интернет-банка поддерживает email, push и SMS.
- SRP разделяет шаблоны сообщений, маршрутизацию каналов и отправку через провайдеров.
- OCP позволяет подключать новый канал уведомлений отдельной реализацией.
- LSP обеспечивает взаимозаменяемость провайдеров в runtime-конфигурации.
- ISP делит интерфейсы на узкие контракты доставки, трекинга и ретраев.
- DIP изолирует бизнес-правила уведомлений от SDK конкретного вендора.
Результат: команда ускоряет выпуск новых каналов связи и снижает риск регрессий при замене инфраструктурного поставщика.
Расширенный чек-лист ревью
- У каждого класса зафиксирована одна предметная обязанность.
- Новое поведение добавлено расширением, ядро модуля осталось стабильным.
- Подтипы соблюдают контракт по результату, ошибкам и инвариантам.
- Интерфейсы отражают сценарии клиента и не включают лишние методы.
- Высокоуровневые правила зависят от абстракций, а детали подключаются при сборке.
Мини-упражнения
- Выберите "большой" сервис и разделите его на отдельные ответственности.
- Уберите условные ветки типа провайдера через стратегию и полиморфизм.
- Проверьте иерархию наследования на LSP-контракты в unit-тестах.
- Перепроектируйте один "толстый" интерфейс в 2–3 специализированных.
- Вынесите инфраструктуру из бизнес-логики через DIP и dependency injection.
Контрольные вопросы
- Какая часть модуля меняется чаще всего и почему?
- Какие абстракции дают реальную расширяемость, а какие создают шум?
- Какие тесты подтверждают соблюдение LSP для подтипов?
- Какой слой отвечает за выбор конкретной реализации зависимостей?