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

Внедрение зависимостей — это метод разделения компонентов, при котором клиентский код не использует компонент напрямую.

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

Мы рассмотрим следующие методы внедрения зависимостей:

  • Наследование интерфейса
  • Параметр шаблона
  • Концепции
  • Прокси

Обратите внимание, что примеры надуманы и написаны не для корректности, а для того, чтобы концепция была понятна. В примерах иногда отсутствует код, который не является необходимым для понимания концепции (например, большинство исходных файлов).

Исходный код примеров можно найти в разделе Ресурсы в конце статьи.

Без внедрения зависимостей

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

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

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

Наследование интерфейса

При наследовании интерфейса клиентский код зависит только от интерфейса. Конкретная реализация внедряется в клиентский код с помощью конструктора или сеттера. Это форма динамического полиморфизма.

Автомобиль зависит только от интерфейса IEngine, поэтому можно использовать любую реализацию движка, если он наследует и реализует IEngine.

В коде, который инициализирует и использует Car, мы можем передать конкретный класс, такой как V8Engine или Inline4Engine или даже MockEngine.

// passing a V8Engine
Car car{make_shared<V8Engine>()};
// passing an Inline4Engine
Car car{make_shared<Inline4Engine>()};
// passing a MockEngine for testing
Car car{make_shared<MockEngine>()};

Применение

Этот метод следует использовать почти всегда. Если проблем с производительностью нет, то этот метод является наиболее подходящим, поскольку его узнают многие разработчики.

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

Если бы V8Engine принадлежал стороннему классу, он не наследовался бы от IEngine. Тогда внедрение зависимостей было бы невозможно с помощью этого метода.

Параметр шаблона

Внедрение зависимостей с использованием параметра шаблона — еще один метод, который можно использовать до C++20. Это форма статического полиморфизма.

В этом случае конкретная реализация не ограничена наследованием и даже не знает, что ее можно внедрить в Car.

Car — это шаблон класса с единственным параметром Engine, который является произвольным типом движка. Этот метод позволяет нам специализировать Car с любой реализацией движка.

// Car is specialized with V8Engine
Car<V8Engine> car{make_shared<V8Engine>()};
// compiler can deduce type
Car car{make_shared<MockEngine>()};

Если класс Car знает, как инициализировать Engine, передача указателя через конструктор не требуется. Это делает инициализацию автомобиля Car<V8Engine> car;

Плюсы

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

Минусы

  • Он использует шаблоны, что означает, что класс с зависимостями должен быть полностью определен в заголовке.
  • Реализация движка не может быть изменена во время выполнения на другой тип.
// car is specialized with V8Engine
Car car{make_shared<V8Engine>()};
// ...
// error: cannot change car specialization
car.setEngine{make_shared<Inline4Engine>()};

Применение

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

Концепции

Концепции — это новая языковая функция в C++20, которая позволяет определять именованные требования к параметрам шаблона. Эти требования проверяются во время компиляции и выдают ошибку компилятора, если какие-либо требования не выполняются. Это почти то же самое, что и параметр шаблона, за исключением того, что на параметр шаблона накладываются явные ограничения.

Концепты оцениваются как логические значения времени компиляции, что делает их пригодными для использования с static_assert(). Таким образом, мы можем гарантировать, что реализация соответствует требованиям концепции. Тест на соответствие находится прямо под реализацией V8Engine. Нахождение рядом с объявлением класса делает его выразительным и явным.

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

Прокси

Proxy — это библиотека динамического полиморфизма C++20, которая не требует, чтобы компоненты наследуются от интерфейса. Он используется с 3 шаблонами основных классов: dispatch, facade и proxy.

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

// default V8Engine initialization
Car car{pro::make_proxy<FEngine, V8Engine>()};
// passing only a reference of engine to Car
V8Engine engine;
Car car{pro::make_proxy<FEngine>(engine)};
// passing a pointer which is implicitly cast to proxy
V8Engine engine;
Car car{&engine};

Этот метод использует динамический полиморфизм, аналогичный наследованию. Однако у него есть то преимущество, что он не зависит от ограничений наследования компонентов. Единственная проблема в том, что он работает только на C++20.

Применение

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

Ресурсы

Весь код в этой статье находится в проекте на GitHub.

Еще несколько методов внедрения зависимостей в устаревший код можно найти в статье Майкла К. Фезерса Working Effectively with Legacy Code.

Любые комментарии или отзывы приветствуются.

Рекомендации