четверг, 28 марта 2019 г.

[prog.c++] Как работать с STL-ными контейнерами без include-ов описаний этих контейнеров

Давеча мы обновили свою тоненькую обертку над RapidJSON. Очередным добавлением в json_dto стала поддержка STL-ных контейнеров (std::deque, std::list, std::forward_list, std::set, std::multiset, std::unordered_set, std::unordered_multiset, std::map, std::multimap, std::unordered_map, std::unordered_multimap). И добавляя эту поддержку нам нужно было решить, как это сделать с минимальными накладными расходами.

Идти по простому пути, т.е. делать в json_dto какой-нибудь #include <set>, а потом перегружать часть внутренних функций json_dto для std::set и std::multiset, очень не хотелось. Во-первых, это сильно увеличивает объем нашей собственной работы. Во-вторых, это увеличивает время компиляции проектов, в которых json_dto используется.

Поэтому мы пошли по пути современного C++: т.е. шаблонная магия и SFINAE во все поля :) Под катом несколько слов об этом для тех, кому тема современного C++ интересна.

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

Итак, решение состоит в том, чтобы написать всего лишь небольшой набор шаблонных функций, заточенных под разные категории контейнеров. Этих категорий всего три: последовательные контейнеры (std::deque, std::list, std::forward_list), ассоциативные контейнеры вроде std::set (std::set, std::multiset, std::unordered_set, std::unordered_multiset), и ассоциативные контейнеры вроде std::map (std::map, std::multimap, std::unordered_map, std::unordered_multimap). Но компилятор должен понимать, какая шаблонная функция должна вызываться в каждом конкретном случае. Для этого используется SFINAE. Например:

templatetypename C >
std::enable_if_t<
      details::meta::is_stl_map_like_associative_container<C>::value,
      void >
read_json_value(
   C & cnt,
   const rapidjson::Value & object )
{...}

Или:

templatetypename C >
std::enable_if_t<
      details::meta::is_stl_like_sequence_container<C>::value,
      void >
read_json_value(
   C & cnt,
   const rapidjson::Value & object )
{...}

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

templatetypename T >
struct is_stl_like_sequence_container
   {
      static constexpr bool value = 
            has_value_type<T>::value &&
            !has_key_type<T>::value &&
            has_iterator_type<T>::value &&
            has_const_iterator_type<T>::value &&
            has_begin<T>::value &&
            has_end<T>::value &&
            ( has_emplace_back<T>::value ||
               (has_before_begin<T>::value && has_emplace_after<T>::value) )
            ;
   };

Где, в свою очередь, используются мета-функции (типа) попроще. Вот, скажем:

templatetypenametypename = void_t<> >
struct has_value_type : public std::false_type {};

templatetypename T >
struct has_value_type< T, void_t<typename T::value_type> > : public std::true_type {};

Или вот такое:

templatetypenametypename = void_t<> >
struct has_emplace_after : public std::false_type {};

templatetypename T >
struct has_emplace_after<
      T,
      void_t<
         decltype(
               std::declval<T &>().emplace_after(
                     std::declval<typename T::const_iterator>(),
                     std::declval<typename T::value_type>() )
         ) >
      > : public std::true_type {};

Вот, собственно, и все. Посредством мета-функций определяется, есть ли в контейнере типы и методы, которые мы ждем от STL-ного контейнера. Если есть, то работаем с ним как с STL-ным контейнером. Но при этом внутри json_dto есть include только для std::vector (поскольку нам нужно особым образом обрабатывать vector<bool>). Определения всех остальных контейнеров пользователь должен в своем .cpp-файле загружать сам. При этом даже если он сделает include для deque, скажем, после подключения json_dto, то ничего страшного не произойдет. Все будет работать как задумано.

Отдельно стоит еще упомнятуть то, что заполнение std::deque/list принципиально отличается от заполнения std::forward_list. Поэтому для нивелирования этих различий пришлось сделать специальный вспомогательный шаблон и его специализацию. Но, опять же, сделано это без include для forward_list.


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

С одной стороны, написание этих самых мета-функций -- это что-то из категории "понять это невозможно, можно только запомнить". Тут явно используются побочные эффекты от добавления шаблонов в C++. Хотелось бы иметь что-то более удобное и более наглядное. Но фокус в том, что когда пытаюсь задуматься о том, а как должно выглядеть то, что меня бы устроило... То хорошего ответа не нахожу. Поэтому хорошо хоть, что есть такой способ написания мета-функций. Может быть кто-то когда-то придумает что-то получше. У меня не получается.

С другой стороны, мне нравится такая гибкость C++. Кто-то когда-то написал std::deque. Затем кто-то когда-то написал std::forward_list. Мы написали свой json_dto вообще без оглядки на std::deque и std::forward_list. Но когда потребовалось, мы смогли посредством duck typing-а поддержать и std::deque, и std::forward_list. Причем это правильный duck typing, разрешаемый во время компиляции.

И, мне кажется, такой подход в долгосрочной перспективе масштабируется и адаптируется под новые условия гораздо лучше, чем какая-нибудь Java с ее интерфейсами. Или какой-нибудь Rust с трайтами. Хотя в краткосрочной перспективе, особенно когда все создается с нуля (как в случае Rust-а), строгость Java-овских интерфейсов или Rust-овых трейтов выглядит намного лучше.

4 комментария:

ssko комментирует...

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

eao197 комментирует...

@ssko:
> хотя и многословненько как-то со SFINAE. :)

Возможно, с концептами в C++20 будет компактнее и читаемее.

Анонимный комментирует...

Так концепты - это по сути сахарок над enable_if со SFINAE. В смысле делают то же самое, только запись короче (и понятнее).

Анонимный комментирует...

А контракты - это переодетый assert.