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

587d82e293efcb7678fa29ac3c6dbf33

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

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

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

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

Фреймворки:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

struct ArgumentResolverInvoker
{

  template 
  operator T()
  {
      return ArgumentResolver< typename std::decay< T >::type >::template resolve(m_registration, m_resolutionContext);
  }
}

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

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

template struct Factory;

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

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

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

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

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


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

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

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

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

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

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

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

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

template
size_t GetTypeId()
{
  return reintepret_cast(&GetTypeId);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

template
using Reference = T&;

template
using SharedPtr = std::shared_ptr;

template
struct WrapperInfo { };

template
struct WrapperInfo
{
    using Type = T;

    template
    using Wrapper = Reference

; }; template class TWrapper, class ... TArgs> struct WrapperInfo> { using Type = T; template using Wrapper = TWrapper

; };

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

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

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

Завершение

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

© Habrahabr.ru