Как стать автором
Обновить

Что в DI-Контейнере твоем, С++? Пробуем написать

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров8.1K

Доброго времени суток, жители Хабра.

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

И был прав, так как рефлексию в C++ ещё не завезли. Хотя и существует reflection ts и даже была статья на хабре, но пока частью стандарта это не стало. Я бы желал, чтобы ещё из этого выросла бы библиотека для создания динамической рефлексии, по умолчанию выключенной, включаемой атрибутами. Но это всё мечты.

Итак, что же было найдено?

Фреймворки:

Несколько статей и видеороликов:

Эти контейнеры имеют те или иные недостатки. Fruit, например, подразумевает использование макросов на конструкторе класса, подвергаемого инъекции. Wallaroo использует шаблоны на полях класса. Kangaru уже использует внешние классы сервисы для связывания зависимостей, чем связывает их статически. Hypodermic уже выглядит основательно, и код на гитхабе приятный и читаемый. Boost.DI... ну... я попытался посмотреть исходник...

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

А зачем он нужен?

Основная задача DI-контейнера инкапсулировать создание объектов и их зависимостей избавив от их ручного создание, но само по себе это было бы не так полезно. И как правило используется в купе с Dependency Inversion Principle.
Принцип гласит: " Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций."

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

Если перевести это в терминологию языков программирования, то нам нужно выделить интерфейс(C#, Java, и т.д.), или же класс с чисто виртуальными функциями, в C++. Выглядит это примерно вот так:

class RenderInterface
{
  public:
  virtual void Render(std::shared_ptr<Scene>) = 0;
  virtual ~RenderInterface() = default;
};

class VulkanRender : public RenderInterface
{
  public:
  void Render(std::shared_ptr<Scene>) { /* Implementation */ }
  // ...
};

class OpenGLRender : public RenderInterface
{
  public:
  void Render(std::shared_ptr<Scene>) { /* Implementation */ }
  // ...
};

class Window
{
  public:
  Window(std::shared_ptr<RenderInterface> render) { /*Implementation*/ }
  // ...
}

Здесь, Window может принять любой указатель на класс, реализующий RenderInterface. И Window уже не важно, что за графический api используется, главное, что можно было просто вызвать метод Render.

Это ведёт к важным моментам:

  1. Уменьшается зависимость между классами: им не надо знать о друг друге.

  2. Появляется возможность реализовать класс-заглушку для тестирования(mock)

  3. Нужно только в одном месте поменять создаваемую реализацию(в идеале)

Без использования этого принципа, например, OpenGLRender мог бы создаваться внутри класса Window. А если эта же зависимость, нужна другому классу? Тогда можно ее передавать через конструктор или метод. Это и называется Dependency Injection, в её ручной реализации. Это может привести к бойлерплейту, где-нибудь возле точки входа в приложение:

int main(int argc, char* args[])
{
  //...
  //... где-то выше создаются зависмости для зависимостей
  std::unique_ptr<AppDependency1> app_dependency1 = CreateAppDependency1(/*Здесь свои зависимости*/);
  std::unique_ptr<Interface2> app_dependency2;
  if (Config.GetTypeInterface2() == "FirstType")
  {
    // Создаем зависимости для FirstType
    app_dependency2 = CreateAppDependency2FirstType(/*И здесь*/);
  }
  else if (Config.GetTypeInterface2() == "SecondType")
  {
    // Создаем зависимости для SecondType
    app_dependency2 = CreateAppDependency2SecondType(/*И здесь*/);
  }
  else {/* ... */}
  
  std::unique_ptr<AppDependency3> app_dependency3 = CreateAppDependency3(/* */);
  std::unique_ptr<App> app = std::make_unique<App>(app_dependency1, app_dependency2, app_dependency3);
  return app->Run(argc, args);
}

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

А в полномасштабном приложении не один или два объекта, каждый может быть зависимостью и иметь свои. А потом ещё есть вопрос, определенный тип зависимости должен быть одним на всё приложение, или на каждый класс свой экземпляр? Может у нас несколько разных типов зависимостей с одним интерфейсом?

И здесь вступает в дело DI-контейнер. И код с main можно переписать так:

int main(int argc, char* args[])
{
  Container container;
  //...
  // Регистрация зависимостей
  //...
  Container.Register<AppDependency1>();
  Container.Register<AppDependency2, Interface2>("FirstType"));
  Container.Register<AnotherAppDependency2, Interface2>("SecondType");

  Overrides overrides {
    {GetTypeId<Interface2>, Config.GetTypeInterface2()}
  }
  Container.Register<App>(overrides);
  return Container.Resolve<App>()->Run(argc, args);
}

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

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

P.S. За раздел благодарим astypalaia

Перед началом пути

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

  • Небольшой объём и относительную простоту кода.

  • Отсутствие необходимости вмешиваться конструкцию класса, в который будут передаваться зависимости.

  • Отсутствие макросов. Не то чтобы я их не люблю. У них есть прикольные возможности, но я, как пользователь библиотеки, не хотел бы, чтобы сторонняя библиотека мусорила мне в подсказки IDE. Учитывая любовь C++ втягивать в себя возможности из других языков может когда-нибудь подтянут макросы по типу Rust'а.

  • Использовать разные умные и не очень указатели, и ссылки.

  • Постройка контейнера не во время компиляции.

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

Основа контейнера

Основой DI контейнеров в С++ является конечно же рефлексия шаблоны. Hypodermic умудряется с их помощью выводить типы аргументов для конструктора. Конечно, в мире С++ существуют инструменты для генерации метаданных, вроде moc от Qt или UnrealHeaderTool от UnrealEngine. Просто всё это сторонние инструменты, и люди не всегда хотят от них зависеть.

Для того, чтобы создать объект, необходимо определить типы аргументов для конструктора. А в чём проблема, если исключить отсутствие рефлексии в плюсах? Конструктор не является обычной функцией. Если для функции и есть довольной простой способ получить её аргументы, то с конструктором такой способ не подходит.

Hypodermic это делает с помощью нескольких вспомогательных шаблонных классов. Код ниже из самого Hypodermic

struct ArgumentResolverInvoker
{

  template <class T, class = /*scary code*/>
  operator T()
  {
      return ArgumentResolver< typename std::decay< T >::type >::template resolve(m_registration, m_resolutionContext);
  }
}

Компилятор пытается вывести тип оператора приведения для ArgumentResolverInvoker и тем самым сообщает тип аргумента конструктора, и там позади ещё несколько классов которые в этом помогают.

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

template<class FunctionType> struct Factory;

template<class Type, class ... Args>
struct Factory<Type(Args...)>
{
  static void* Create(Container* container)
  {
    return TypeTraits<Type>::Create(container->resolve<Args>()...);
  }
}

Этот метод использует основан на том, что компилятор старается использовать, как можно более узкоспециализированную версию шаблона, и когда мы напишем
Factory<decltype(TypeTraits<Foo>::Create)>
Компилятор отправится ко второй версии и выведет для нас все необходимые типы.

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

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

template<class T, class... Args>
struct Constructor
{
  T* Create(Args... args)
  {
    return new T(std::forward<Args>(args)...);
  }
};


template<>
struct TypeTraits<Foo> : Constructor<Foo, Dependency1...>
{
  // TypeTraits не только для Create существует
  constexpr LifeTimeScope LifeTime = LifeTimeScope::Singleton;
  // ...
};

Отдельный метод Create в TypeTraits и сам TypeTraits позволяет нам много полезных вещей:

  1. Классы из сторонних библиотек можно поместить в контейнер, так как нет необходимости изменять их, описав TypeTraits.

  2. Использовать собственные аллокаторы в Create

  3. Вызвать дополнительные методы пост-инициализации.

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

Находим Id для типа

Хорошо, у нас есть класс фабрики. Дальше нам нужно этот класс зарегистрировать в контейнере. Для этого всего лишь нужно взять адрес Factory::Create и положить его в map. Затем, когда мы захотим получить объект нужного нам типа, мы достаем метод из мапы и создаём объект. А как мы его создаём? Ведь аргумент шаблона метода Resolve это объект времени компиляции, а map это объект времени выполнения: одно в другое просто так не переходит. Для этого мы воспользуемся ещё одним вспомогательным шаблонный методом.

template<class T>
size_t GetTypeId()
{
  return reintepret_cast<size_t>(&GetTypeId);
}

Да, мы воспользуемся адресом шаблонной функции как ключом в map. Благодаря тому, что каждая функция будет иметь своё место памяти, будет достигнута уникальность id. Мне сначала пришла идея использовать адрес метода Create для этой цели, но если мы захотим сопоставить тип интерфейса и тип реализации этого интерфейса, метода Create для интерфейса у нас может и не быть.

Управление временем жизни и хранение указателей

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

  1. Синглтон - живет всё время, пока жив контейнер и клиенты.

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

  3. Никак - клиент сам решает, что с этим делать.

Первые 2 отличаются тем, какой умный указатель хранить в контейнере: shared_ptr или weak_ptr, и что можно передавать клиенту. Передавать ссылку, имея weak_ptr в контейнере, как можно догадаться, приведёт к проблемам.

И как же указатели хранить в контейнере? Для это решил использовать std::variant. Благодаря ему можно свести все указатели в один. К тому же я использую shared_ptr<void> и т.п. для хранения указателей. Несмотря на немного дурной тон это позволило мне написать обобщенный не шаблонный метод Resolve, и оставить в шаблоне, только то, что действительно в этом нуждается.

Приведение к типу, требуемому клиентом

Когда мы вызываем метод Resolve в фабрике мы получаем тип аргумента

static void* Create(Container* container)
{
  return TypeTraits<Type>::Create(container->resolve<Args>()...);
}

И, вероятно, что типом будет какой-нибудь shared_ptr<Dependency> или Dependency&, но в контейнере зарегистрирован Dependency, а не перечисленный из этих двух.

Поэтому мы говорим: "Больше шаблонов богу шаблонов" и идём дальше.

template<class T, class ... Args>
using Reference = T&;

template<class T, class ... Args>
using SharedPtr = std::shared_ptr<T>;

template<class T>
struct WrapperInfo { };

template<class T>
struct WrapperInfo<T&>
{
    using Type = T;

    template<class P, class ... PArgs>
    using Wrapper = Reference<P>;
};

template<class T, template <class P, class ... PArgs> class TWrapper, class ... TArgs>
struct WrapperInfo<TWrapper<T, TArgs...>>
{
    using Type = T;

    template<class P, class ... PArgs>
    using Wrapper = TWrapper<P>;
};

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

static void* Create(Container* container)
{
  return TypeTraits<Type>::Create(
    container->resolve<typename WrapperInfo<Args>::Type, WrapperInfo<Args>::template Wrapper>()...
  );
}

Теперь метод Resolve знает, что и в каком виде ему надо вернуть.

Завершение

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

Теги:
Хабы:
Всего голосов 13: ↑10 и ↓3+13
Комментарии4

Публикации

Истории

Работа

Программист C++
119 вакансий
QT разработчик
4 вакансии

Ближайшие события

28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань