четверг, 25 сентября 2014 г.

The Dependency Inversion Principle

Цикл статей о SOLID принципах

--------------------------------------------------

clip_image001

Принцип инверсии зависимости (Dependency Inversion Principle – DIP):

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

Роберт Мартин «Принципы, паттерны и методики гибкой разработки» ([Martin2006]).

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

О проблемах определения и о некорректном использовании понятия абстракции я уже писал в статье «Критический взгляд на принцип инверсии зависимостей». Здесь же я постараюсь понять, какую проблему призван решать этот принцип, а также как и когда ему следовать.

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

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

interface IDependency
{ }
class ConcreteDependency : IDependency
{ }

// ConcreteClass не знает о ConcreteDependency,
// он зависит от "абстракции" - IDependency!

class ConcreteClass
{
   
public ConcreteClass(IDependency dependency)
    {}
}

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

Давайте рассмотрим пояснение от автора этого принципа – Боба Мартина [Martin2006].

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

Вот что пишет Боб Мартин:

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

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

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

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

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

Абстракция и реализация

Давайте рассмотрим вопрос «зависимости от абстракции» более подробно.

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

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

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

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

Желание защититься от изменений может быть оправданным в таких языках как С++, где изменения в закрытой части класса, сделанные в заголовочном файле, могут привести к каскадным изменениям во всей системе. Если же рассматривать большинство современных языков программирования, включая C#, Java и другие, то в них выделение интерфейсов оправданы, но они служат для решения других задач.

ПРИМЕЧАНИЕ
Специально для борьбы с каскадными изменениями в С++ существует специальная идиома, под названием PIMPL, предложенная Гербом Саттером. Некоторые моменты в исходных статьях о SOLID принципах были навеяны именно проблемами языка С++.

Когда выделять интерфейсы?

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

Если следовать логике Роберта Мартина, то все классы из .NET Framework мы можем использовать напрямую, а все собственные классы должны использоваться через интерфейс. Таким образом, я могу создавать экземпляры классов для работы с файлами, сокетами, конфигурацией, сетью или базой данных, а вот свой собственный неизменяемый класс ValidationResult с двумя свойствами и парой методов нужно протаскивать через аргументы конструктора в виде интерфейса IValidationResult.

Очевидно, что такое разделение является слишком наивным и на практике не используется. Марк Сииман (Mark Seeman) в своей книге “Dependency Injection in .NET” разделяет зависимости на два типа: стабильные (stable) и изменчивые (volatile).

К стабильным зависимостям относятся классы, поведение которых не зависит от времени: они не работают с внешним окружением и интерфейс которых относительно стабилен. Поведение изменчивых зависимостей зависит от времени или контекста, или же их интерфейс часто изменяется.

Исходя из этого можно предложить более точный критерий выделения интерфейсов:

Когда выделять интерфейс (.NET interface) у класса:

  1. Класс является реализацией некоторой стратегии и будет использовать полиморфным образом.
  2. Реализация класса работает с внешним окружением (файлами, сокетами, конфигурацией и т.п.).
  3. Класс находится на стыке модулей.

Когда не нужно выделять интерфейс класса:

  1. Класс является неизменяемым «объектом-значением» (Value Object) или объектом-данных (Data Object).
  2. Класс обладает стабильным поведением (не работает с внешним окружением).

Есть ряд случаев, когда мы не можем использовать конкретный тип, просто потому что мы его не знаем. Класс StreamWriter может работать с любым потоком ввода/вывода; класс List<T> не знает, как именно сравнивать элементы; инфраструктура WCF не знает, как именно обрабатывать ошибки и т.п. Все это примеры использования паттерна «Стратегия», экземпляр которой обычно передается извне, а не создается им напрямую.

Использование напрямую классов, работающих с файлами, сокетами, базой данных и т.п. в бизнес-коде приложения может быть проблематичным. Инфраструктурные классы являются низкоуровневыми и должны использоваться в небольшом числе мест. Нет смысла от выделения интерфейса IFileStream и протаскивания его через конструктор десятку классов. Такой класс работает на слишком низком уровне, к тому же в .NET Framework уже есть абстрактный класс Stream, который позволит абстрагироваться от потока ввода вывода. Значительно лучше выделить более высокоуровневую стратегию (IYourDataProvider), одна из реализаций которой получает данные из файла.

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

Нет никакого смысла от выделения интерфейсов для простых классов, особенно для неизменяемых объектов-значений (т.н. Value Objects). Неизменяемые объекты-значения – это идеальный механизм борьбы со сложностью: их поведение (если оно есть) простое и абсолютно стабильное.

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

Исходя из всего этого можно выделить несколько советов по выделению интерфейсов:

  • Выделяйте высокоуровневые стратегии (IMessageProvider, а не IMSMQProvider).
  • Старайтесь передавать результаты работы зависимостей, а не сами зависимости (передавайте Configuration (value object), а не IConfigurationProvider).
  • Используйте стандартные абстракции (Stream вместо своего собственного IFileStream, Task вместо IAsyncWorkItem и т.п.)

Какую проблему мы хотим решить?

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

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

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

  1. Дизайн является жестким (rigid), если его тяжело изменить, поскольку любое изменение влияет на слишком большое количество других частей системы.
  2. Дизайн является хрупким (fragile), если при внесении изменений неожиданно ломаются другие части системы.
  3. Дизайн является неподвижным (immobile), если код тяжело использовать повторно в другом приложении, поскольку его слишком сложно «выпутать» из текущего приложения.

ПРИМЕЧАНИЕ
Подробнее об этом смотри в статье «Критерии плохого дизайна».

Минимизация зависимостей

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

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

Сделать ключевые зависимости класса явными

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

Когда ключевых зависимостей станет слишком много (больше 4-х), то это сразу же станет сигналом к тому, что часть из них не главные, зависимости слишком низкоуровневые и должны быть объединены, или же наш класс стал слишком сложным.

Сделать класс тестируемым

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

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

К тому же, мы не можем абстрагироваться до бесконечности: как вы себе представляете тестирование класса FileSteam, который непосредственно работает с файлами? Здесь на помощь придут интеграционные тесты, ну или перехваты вызовов с помощью таких инструментов, как Shims из Microsoft Fakes.

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

Принципы управления зависимостями

  • Делайте важные зависимости явными.
  • Старайтесь минимизировать число зависимостей (если класс может о чем-то не знать, то избавьте его от этой лишней информации).
  • Выделяйте изменчивые зависимости и старайтесь сделать их высокоуровневыми.
  • Выделяйте стратегии
  • Стремитесь к неизменяемости: объекты-значения (Value Objects) и функции без побочных эффектов – это идеальный строительный материал.

Не нужно «инвертировать» зависимости: дизайн – процесс итеративный, модули верхнего уровня влияют на дизайн модулей нижнего уровня и наоборот.

Ссылки

Дополнительные ссылки

11 комментариев:

  1. SPR предназначен для борьбы со сложностью – SRP

    ОтветитьУдалить
  2. Ответы
    1. Может не будем начинать? ;) Конструктив - велкам!

      Удалить
  3. DI очень многогранная тема. А иллюстрация с револьвером - бесценна!

    ОтветитьУдалить
  4. "Класс является реализацией некоторой стратегии и будет использовать полиморфным образом. "
    Вот это вообще не понял?

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

      Удалить
  5. Статья - шикарная! Просто и понятно о сложном.
    Подскажите, откуда появилось правило "...зависимостей станет слишком много (больше 4-х)..."?

    Поправьте, пожалуйста, текст: "DIP должен бороться с признаками плохого дизайном,.." -> дизайна

    ОтветитьУдалить
    Ответы
    1. По поводу числа зависимостей: я так понимаю, корни этого числа идут от среднего объема кратковременной памяти человека. Раньше считалось, что человек может держать в рабочей памяти 7+-2 понятия. Затем же это число сократилось до 3-4. Отюсда и это правило. Поскольку в рабочую память сложно вложить больше 3-4-х вещей, то и проанализировать быстро качество дизайна класса, который принимает большее число зависимостей становится сложно.
      Это число может увеличиться, если часть зависимостей является стандартным для проекта. По сути, тогда происходит "чанкирование" (хз, как это по русски правильно) информации и часть стандартных зависимостей просто отсеиваются мозгом.

      Удалить
  6. Здравствуйте! В своей книге паттерны проектирования, в главе о DIP, вы пишете о том, что этот принцип выполняется если происходит инверсия зависимости, в смысле того что модули нижнего уровня обращаются к модулям верхнего уровня (то есть как я понимаю просто заменить реализации интерфейсом недостаточно). Вы приводите пример, когда мы передаем ISocket в класс ReportProcessor (кажется так название) и говорите что это нарушение DIP, так как ISocket лежит уровнем ниже ReportProcessor и тем самым не инвертирует зависимость. Более того, клиент ReportProcessor не должен знать об ISocket, это не относится к его уровню и вы пишете что это нарушает DIP.
    У меня возник вопрос:
    Если у класса есть volatile зависимости, которые не реализуют инверсию зависимости (не являются интерфейсами верхнего или этого же уровня), то мы не можем устанавливать их извне (по вашему определению), то как тогда мы можем устанавливать такие зависимости? В книге Симонса я пока не встретил ни одного примера где бы он не устанавливал зависимости извне

    Спасибо!

    ОтветитьУдалить
  7. "Вместо передачи зависимости всегда нужно подумать о возможности передачи ее результатов" - Прекрасно!

    ОтветитьУдалить