Давеча мы обновили свою тоненькую обертку над 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. Например:
template< typename 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 ) {...} |
Или:
template< typename 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-выражениях. Например:
template< typename 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) ) ; }; |
Где, в свою очередь, используются мета-функции (типа) попроще. Вот, скажем:
template< typename, typename = void_t<> > struct has_value_type : public std::false_type {}; template< typename T > struct has_value_type< T, void_t<typename T::value_type> > : public std::true_type {}; |
Или вот такое:
template< typename, typename = void_t<> > struct has_emplace_after : public std::false_type {}; template< typename 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 комментария:
Утиная типизация всегда выигрывает в выразительности, у математической строгостью другие сильные стороны.
На мой вкус более чем уместное применение mt: ограниченный скоп, поэтому все понятно и работает, хотя и многословненько как-то со SFINAE. :)
@ssko:
> хотя и многословненько как-то со SFINAE. :)
Возможно, с концептами в C++20 будет компактнее и читаемее.
Так концепты - это по сути сахарок над enable_if со SFINAE. В смысле делают то же самое, только запись короче (и понятнее).
А контракты - это переодетый assert.
Отправить комментарий