Возвращаемся к SOLID

Вы помните, что именно представляет собой один из краеугольных камней большой разработки программного обеспечения?

В первые годы своей работы большинство разработчиков сталкиваются с принципами SOLID, которые остаются ключевым фактором во всех хорошо написанных программах. Я часто слышу о них. И часто ловлю себя на том, что использую их в разговоре, но понимаю, к своему стыду, что не могу вспомнить, что представляет собой каждая часть в отдельности. Принцип единственной ответственности — ОК, принцип открытости/закрытости — конечно! А дальше? Может быть, пришло время пересмотреть SOLID?

Принцип единственной ответственности

Соберите вместе то, что меняется по одним и тем же причинам. Отделите то, что меняется по другим причинам.

Это распространенное заблуждение — упрощать его до “каждый модуль должен выполнять только одно действие”. Это не совсем верно, потому что дело не в “выполнении одного действия”. Сведение вашего кода к простейшим компонентам может привести к ужасно разрозненному ПО. Важна причина для изменения, а не то, что код делает. Но что определяет эту причину?

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

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

Его можно рассматривать под другим углом: например, как общий принцип замыкания, по рекомендации которого компоненты должны быть развернуты в соответствии с изменениями и любое изменение должно воздействовать на минимальный набор компонентов (в идеале на один).

Большая часть SRP может рассматриваться как ответ на проблему масштабируемости разработки. Отделите то, что изменяется по разным причинам, и вы сможете распределять инженеров, не наступая им на пятки, или масштабировать команды, не блокируя их зависимости и узкие места.

Принцип открытости/закрытости

Код должен быть открыт для расширения, но закрыт для модификации.

Расширение — это о наследовании? Конечно, в самом начале у Бертрана Мейера было именно так, но подождите! За последние 30 лет мы узнали, что наследование — довольно хитрый зверь, которого трудно приручить, поэтому мы, возможно, не захотим его использовать.

Что мы подразумеваем под этим? Если вам приходится менять пачки существующего кода каждый раз, когда вы делаете небольшое изменение, значит, с вашей архитектурой что-то не так: она не закрыта для модификации. Не меняйте нечто уже существующее. Расширьте код, построенный на надёжном фундаменте. Добавление функций — в идеале очень лёгкий процесс, имеющий мало потенциальных рисков.

Расширение само по себе имеет несколько форм. Во-первых, у вас есть традиционный способ расширения — наследование. Это, как уже упоминалось, связано уже с другим набором проблем. Кроме того, вы можете ввести дополнительное поведение в высокоуровневые компоненты. Интерфейсы — идеальный инструмент для расширения с помощью инъекций. Они защищают от изменений, представляя чётко определенный, контролирующий изменения контракт. Интерфейсы позволяют расширить код, предоставляя и подключая новые реализации контракта.

Использование интерфейсов обеспечивает надежную иерархию, в которой важные компоненты высокого уровня защищены от изменений в компонентах уровня ниже. Когда придет следующий запрос на новую функцию, вы будете готовы добавить её, не затрагивая драгоценных существующих функций.

Принцип замещения Лисков

Если для каждого объекта o1 типа S существует объект o2 типа T, такой, что для всех программ P, определенных в терминах T, поведение P остается неизменным при замене o1 на o2, то S является подтипом T.

Сложно… Давайте на время забудем о языке компьютерных наук и сосредоточимся на главном. Принцип замещения Лисков рассказывает о правильном наследовании. Он рекомендует не создавать подтипы, которые, несмотря на правильное соответствие на уровне контракта, сильно расходятся с истинной семантикой, создавая неприятные сюрпризы в поведении программы.

Обычный пример (и я не буду приводить ничего другого из-за банальной лени) — проблема квадрата и прямоугольника. Для начала предположим, что у нас есть тип “прямоугольник” с двумя методами #setX и #setY для установки длины двух измерений. Пока у нас все хорошо. Но что будет, если мы создадим подкласс для частного случая «квадрата», в котором X и Y всегда равны друг другу?

Если у нас есть программа, использующая “прямоугольник”, а затем внезапно мы подкрадываемся в подтип “квадрат”, то при вызове метода setX изменяется Y (или наоборот). Подтип должен иметь смысл «в тени» своего родителя. Это не ограничивается только наследованием, но также имеет смысл при обеспечении надежности любого интерфейса, будь то API, вызов REST или что-то ещё. Замена любого из них на другой должна быть плавной и оставаться незамеченной любым вызывающим элементом. Если клиент должен проделать обходной путь для обработки реализаций, это значит, что контракт пошёл насмарку.

Надо сказать, что соблюдение этого принципа часто весьма затруднительно. Многие интерфейсы не так уж и абстрактны, как должны быть, что приводит к утечке деталей реализации. Это особенно верно, когда речь заходит о вещах, которые вы не всегда можете учесть, например об исключениях. Чтобы избежать этой ловушки, постарайтесь сделать свои объявления интерфейсов как можно проще  —  коллеги будут вам благодарны.

Принцип сегрегации интерфейса

Ни один клиент не должен вынужденно зависеть от методов, которые он не использует.

Концептуально самый простой и тесно связанный с SRP, этот принцип заключается в поиске наиболее подходящих абстракций в коде. Причина, опять же, заключается в защите от изменений, влияющих на ортогональные пути.

Необходимость зависеть от интерфейса, имеющего неясные проблемы, приводит к большому количеству нежелательного багажа. Это раздувает код, поскольку вы должны обслуживать то, что на самом деле вам не нужно. Большие обновления происходят быстро, но для неиспользуемых функций. Кроме того, с риском, что изменения сломают то, что вы делаете.

Чрезмерно сложные интерфейсы скорее взращиваются, чем проектируются. Медленная, капельная подача дополнительных требований, странные хитрости и маленькие хаки могут с годами превратиться в крайне проблемный снежный ком.

Хороший интерфейс и дизайн API  —  это упражнение в сдержанности. Связность  —  ваш проводник в этом вопросе. Пишите интерфейсы и думайте о связности, объединяя проблемы, которые изменяются вместе, и освободите клиентов от дополнительной нагрузки.

Принцип инверсии зависимостей

Наиболее гибкими являются системы, в которых зависимости относятся только к абстракциям, а не конкретике.

Приложение почти всегда состоит из основной бизнес-логики, дающей реальные результаты с помощью набора утилит. Например, генератор отчетов с возможностью экспорта в электронную таблицу или железнодорожная транспортная система, управляющая стоп-сигналами.

Управляющий поток программы часто идёт от основной логики к специализированным утилитам, например к вводу-выводу. Однако если зависимости исходного кода идут в том же направлении, это может привести к тесной связи бизнес-логики с низкоуровневыми проблемами. Поскольку низкоуровневые реализации, скорее всего, будут часто меняться, это, в свою очередь, будет источником нестабильности приложения.

Для защиты от этой нестабильности мы хотим, чтобы любые проблемы высокого уровня зависели от абстракций, а не от конкретики, то есть от интерфейсов, а не реализаций. Интерфейсы меняются гораздо реже, чем их экземпляры. Поэтому ссылки на них защитят клиента от неопределенности.

Конечно, что-то должно быть известно и о загрязняющих деталях реализации. Именно здесь на помощь приходит инъекция зависимостей.

Вместо того, чтобы основная логика знала, что использовать напрямую, вы вводите её с помощью какой-то “реализации”. Этим гарантируется, что источник не осведомлён о проблемах низкого уровня настолько, насколько это возможно. Загрязнение может быть спрятано в ключевых местах: фабриках или фреймворках для инъекции зависимостей, например в Spring.

В заключение стоит повторить несколько вещей, сказанных Дядей Бобом о принципе инъекции зависимостей:

  • Не ссылайтесь на нестабильные конкретные классы.
  • Не наследуйтесь от них.
  • Не переопределяйте конкретные функции.
  • Никогда не упоминайте имя чего-то конкретного и нестабильного.

Заключение

Мой ключевой вывод: SOLID — это не просто низкоуровневые инструменты для написания кода, но руководящие принципы, которые распространяются на все уровни проектирования программного обеспечения, разработки и архитектуры. Надеюсь, что этот обзор помог чётко показать универсальность SOLID и также поможет вам вспомнить эти принципы, когда вы глубоко погрузитесь в дизайн. Теперь я сам уверен, что не забуду о них!

Читайте также:


Перевод статьи Matthew Lucas: Revisiting SOLID