Критический взгляд на принцип инверсии зависимостей

в 19:27, , рубрики: DIP, solid, ооп, Проектирование и рефакторинг, метки: ,

DISCLAIMER: У автора этой статьи нет цели подорвать авторитет или каким-то образом обидеть столь уважаемого камрада, как «дядюшка» Боб Мартин. Речь здесь идет скорее о более тщательном обдумывании принципа инверсии зависимостей и анализ примеров, использованных при его описании.

Принцип инверсии зависимостей (Dependency Inversion Principle, DIP) был впервые описан Бобом Мартином в одноименной статье, опубликованной в журнале C++ Report в 1996 году. Затем, практически в неизменном виде он был опубликован в книгах Боба Мартина «Принципы, паттерны и методики гибкой разработки» [Mattin2006].

По ходу статьи я буду приводить все необходимые цитаты и примеры из вышеупомянутых источников. Но чтобы не было «спойлеров» и ваше мнение оставалось объективным, я бы рекомендовал потратить 10-15 минут и ознакомиться с оригинальным описанием этого принципа в статье [Martin96] или книге [Martin96].

Принцип инверсии зависимостей звучит так [Martin2006 p.190]:

А. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те и другие должны зависеть от абстракций.
В. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Давайте начнем с первого пункта.

Разбиение на слои

У лука есть слои, у торта есть слои, у людоедов есть слои и у программных систем – тоже есть слои! – Шрек (с)
Любая сложная система является иерархичной: каждый слой строится на базе проверенного и хорошо работающего слоя более низкого уровня. Это позволяет сосредоточиться в каждый момент времени на ограниченном наборе концепций, не задумываясь о том, как реализованы слои нижнего уровня.
В результате мы получаем примерно следующую диаграмму:

Критический взгляд на принцип инверсии зависимостей
Рисунок 1 – «Наивная» схема разбиения на слои

С точки зрения Боба Мартина такая схема разбиения системы на слои является наивной. Недостатком такого дизайна является «коварная особенность: слой Policy зависит от изменений во всех слоях на пути к Utility. Эта зависимость транзитивна.» [Martin2006 p.192].

Хм… Весьма необычное утверждение. Если говорить о платформе .NET, то зависимость будет транзитивной только в том случае, если текущий модуль будет «выставлять» модули нижних уровней в своем открытом интерфейсе. Другими словами, если в MechanismLayer есть открытый класс, принимающий в качестве аргумента экземпляр StringUtil (из UtilityLayer), то все клиенты уровня MechanismLayer становятся зависимыми на UtilityLayer. В противном случае, транзитивность изменений отсутствует: все изменения нижнего уровня ограничены текущем уровнем и не распространяются выше.

Чтобы понять мысль Боба Мартина нужно вспомнить, что впервые принцип инверсии зависимостей был описан в далеком 1996-м году [Martin96], и в качестве примеров использовался язык С++. В исходной статье сам автор пишет о том, что проблема транзитивности есть лишь в языках без четкого разделения интерфейса класса от реализации. В С++ и правда проблема транзитивных зависимостей актуальна: если файл PolicyLayer.h включает посредством директивы «include» MechanismLayer.h, который, в свою очередь включает UtilityLayer.h, то при любом изменении в заголовочном файле UtilityLayer.h (даже в «закрытой» секции классов, объявленных в этом файле) нам придется перекомпилировать и развернуть заново всех клиентов. Однако в С++ эта проблема решается путем использования идиомы PIml, предложенной Гербом Саттером и сейчас тоже не столь актуальна.

Решение этой проблемы с точки зрения Боба Мартина заключается в следующем:

«Слой более высокого уровня объявляет абстрактный интерфейс служб, в которых он нуждается. Затем слои нижних уровней реализуются так, чтобы удовлетворять этим интерфейсам. Любой класс, расположенный на верхнем уровне, обращается к слою соседнего снизу уровня через абстрактный интерфейс. Таким образом, верхние слои не зависят от нижних. Наоборот, нижние слои зависят от абстрактного интерфейса служб, объявленного на более высоком уровне… Таким образом, обратив зависимости, мы создали структуру, одновременно более гибкую, прочную и подвижную.» [Martin2006 p.192]

Критический взгляд на принцип инверсии зависимостей
Рисунок 2 – Инвертированные слои

В некотором роде такое разбиение разумно. Так, например, при использовании паттерна наблюдатель, именно наблюдаемый объект (observable) определяет интерфейс взаимодействия с внешним миром, поэтому никакие внешние изменения не могут на него повлиять.

Но с другой стороны, когда речь заходит именно о слоях, которые представляются обычно сборками (или пакетами в терминах UML), то предложенный подход вряд ли можно назвать жизнеспособным. По своему определению, вспомогательные классы нижнего уровня используются в десятке разных модулях более высокого уровня. Utility Layer будет использоваться не только в Mechanism Layer, но еще и в Data Access Layer, Transport Layer, Some Other Layer. Должен ли он в таком случае реализовывать интерфейсы, определенные во всех модулях более высокого уровня?

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

Понятие абстракции

Многие термины настолько «въедаются» в наш мозг, что мы перестаем обращать на них внимание. Для большинства «объектно-ориентированных» программистов это означает, что мы перестаем задумываться над многими заезженными терминами, как «абстракция», «полиморфизм», «инкапсуляция». Чего над ними думать, ведь все и так понятно? ;)

Однако для того, чтобы точно понять смысл принципа инверсии зависимостей и второй части определения, нам нужно вернуться к одному из этих фундаментальных понятий. Давайте посмотрим на определение термина «абстракция» из книги Гради Буча [Booch2007]:

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

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

Давайте вернемся к определению: Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Какой пример возникает в голове теперь, после того, как мы вспомнили, что же такое абстракция? Когда абстракция начинает зависеть от деталей? Примером нарушения этого принципа может служить абстрактный класс GZipStream, который принимает MemoryStream, а не абстрактный класс Stream:

abstract class GZipStream
{
    // Абстракция GZipStream принимает конкретный поток
    protected GZipStream(MemoryStream memoryStream) {}
}

Другим примером нарушения этого принципа может быть абстрактный класс репозитория из слоя доступа к данным, принимающий в конструкторе PostgreSqlConnection или строку подключения для SQL Server, что делает любую реализацию такой абстракции завязанной на конкретную реализацию. Но это ли имеет ввиду Боб Мартин? Если судить по примерам, приведенных в статье или в книге, то под понятием «абстракции» Боб Мартин понимает нечто совсем иное.

Принцип DIP по Мартину

Для объяснения своего определения Боб Мартин дает следующее пояснение.

Чуть упрощенная, но все еще весьма действенная интерпретация принципа DIP выражается простым эвристическим правилом: «Зависеть надо от абстракций». Оно гласит, что не должно быть зависимостей от конкретных классов; все связи в программе должны вести на абстрактный класс или интерфейс.

  • Не должно быть переменных, в которых хранятся ссылки на конкретные классы.
  • Не должно быть классов, производных от конкретных классов.
  • Не должно быть методов, переопределяющих метод, реализованный в одном из базовых классов.

В качестве же иллюстрации нарушения принципа DIP вообще, и первого «проясняющего» пункта, в частности, приводится следующий пример:

public class Button
{
    private Lamp lamp;
    public void Poll()
    {
        if (/* какое-то условие */)
            lamp.TurnOn();
    }
}

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

У кода есть одна интересная особенность. За редким исключением, код сам по себе не может быть корректным или не корректным; баг это или фича зависит от того, что от него ожидается. Даже если нет формальной спецификации (что является нормой), код некорректен лишь в том случае, когда он делает не то, что от него требуется или предполагается. Именно этот принцип лежит в основе контрактного программирования [Meyer2000], в котором спецификация (намерения) выражаются непосредственно в коде в форме предусловий, постусловий и инвариантов.

Глядя на класс Button я не могу сказать ошибочен дизайн или нет. Я могу точно сказать, что имя класса не соответствует его реализации. Класс нужно переименовать в LampButton или убрать из класса Button поле Lamp.

Боб Мартин настаивает на том, что данный дизайн некорректен, поскольку «высокоуровневая стратегия приложения не отделена от низкоуровневой реализации. Абстракции не отделены от деталей. В отсутствие такого разделения стратегия верхнего уровня автоматически зависит от модулей нижнего уровня, а абстракция автоматически зависит от деталей» [Martin2006].

Во-первых, я не вижу в данном примере «стратегий верхнего уровня» и «модулей нижнего уровня»: с моей точки зрения, классы Button и Lamp находятся на одном уровне абстракции (во всяком случае, я не вижу аргументов, доказывающих обратное). Тот факт, что класс Button может кем-то управлять не делает его более высокоуровневым. Во-вторых, здесь нет «абстракции, зависящей от деталей», здесь есть «реализация абстракции, зависящая от деталей», что совсем не одно и тоже.

Решение по Мартину такое:

Критический взгляд на принцип инверсии зависимостей
Рисунок 3 – «Инвертирование зависимостей»

Лучше ли данное решение? Давайте посмотрим…

Главным плюсом инвертирования зависимостей «по Мартину» является инвертирование владения. В исходном дизайне, при изменении класса Lamp пришлось бы изменяться классу Button. Теперь класс Button «владеет» интерфейсом ButtonServer, а он не может измениться из-за изменения «нижних уровней», таких как Lamp. Все как раз наоборот: изменение класса ButtonServer возможно только под воздействием изменений класса Button, что приведет к изменению всех наследников класса ButonServer!

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

Как по мне, в данном случае можно поступить проще. Зачем что-то выдумывать, когда в нашем арсенале уже давно есть инструмент для решения задачи инвертирования владения: паттерн наблюдатель. В нашем случае, интерфейс ButtonServer является классическим наблюдателем, поэтому я бы в .NET использовал события, а для другой платформы, переименовал бы ButtonServer в ButtonObserver. Интерфейс наблюдателя (грубо говоря, набор событий) всегда контролируется тем, кто эти события предоставляет, поэтому интерфейс ButtonObserver будет контролируется именно классом Button, а не наблюдателями, которые этот интерфейс реализуют.

В таком случае, никто не удивится, что при изменении класса Button, например, при появлении третьего состояния у кнопки, в интерфейсе ButtonObserver появится еще один метод! Но я бы при этом не делал класс Lamp наблюдателем класса Button; вместо этого я бы переложил связь кнопки и лампы на более высокий уровень:

Критический взгляд на принцип инверсии зависимостей
Рисунок 4 – Использование «посредника»

По сути, это LampController – это посредник (медиатор), который реализует логику более высокого уровня: наблюдает за состоянием кнопки и управляет лампой.

В данном случае мы упрощаем развитие каждой части нашей системы, поскольку медиатор является барьером, который гасит изменения в одной стороне системы, не давая им распространиться в другую часть системы! Любые изменения класса Button приведут к изменению LampController, но не приведут к изменению класса Lamp и наоборот.

При таком подходе мы не вводим лишних связей, не используем наследования (LampController наследует ButtonObserver, но мы могли бы использовать события вместо этого). Классы Button и Lamp максимально автономны и могут изменяться относительно свободно, не влияя друг на друга.

Мы можем пойти дальше и выделить интерфейсы IButton и ILamp, и отвязать медиатор от конкретных ламп и кнопок. Теперь, как минимум, становится ясно, что класс LampController является стратегией более высокого уровня, которую мы хотим использовать повторно. (Хотя я предпочту такое усложнение делать лишь тогда, когда в этом будет необходимость!)

Следуя принципу инверсии зависимостей

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

Если следовать этому принципу буквально, то мы можем создать класс, реализация которого должна зависеть от абстракции, реализация которой должна зависеть от абстракции, реализация которой … ну, вы поняли.

Критический взгляд на принцип инверсии зависимостей

Рисунок 5 – «Горшочек – не вари» (с)

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

«Довольно часто нарушение принципа DIP практически безвредно. Чем выше вероятность того, что конкретный класс будет изменяться, тем вероятнее, что зависимость от него приведет к неприятностям. Но если конкретный класс не склонен к изменениям, то ничего страшного в зависимости от него нет.»

После чего дается такое уточнение:

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

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

Да и изменчивость интерфейса наших собственных классов будет вызывать сложности, только если этот интерфейс находится на стыке модулей, которые разрабатываются разными людьми или организациями; если же над двумя модулями работает один человек (или люди из одной локальной команды), то нестабильность интерфейсов легко решается современными IDE.

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

Заключение

Поймите меня правильно: я руками и ногами за хороший дизайн. Я за дизайн, который является «гибким, надежным и повторноиспользуемым», но как этого добиться? Приведет ли к хорошему дизайну буквальное следование принципу инверсии зависимостей? Как нам понять, что он означает, если в его определении такие термины как абстракция трактуются по-своему, а проблемы транзитивности зависимостей взяты из С++?

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

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

Ссылки по теме

Автор: SergeyT

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js