Доступ к элементам std::tuple во время исполнения программы
При тестировании разрабатываемой библиотеки математических алгоритмов для автономного вождения нашей команде приходилось достаточно много манипулировать с кортежами (std::tuple
): итерироваться по каждому элементу кортежа или в произвольном порядке, выбирать элементы, чьи индексы и/или значения удовлетворяют определенному условию, и т.д. Написание для каждого случая отдельной рекурсивной функции прохода по кортежу, во-первых, требует знания основ метапрограммирования и шаблонной магии, а во-вторых, отнимает существенное количество времени разработчика.
Мне в голову пришла идея:, а что если получать доступ к элементам по индексу, не известному на этапе компиляции?
Теоретические предпосылки
Как известно, кортеж представляет из себя гетерогенный список, и в общем случае возвращаемое значение вышеупомянутой функции std::get
может быть разным и зависит от переданного ей шаблонного параметра — индекса элемента в кортеже. Так, для типа std::tuple
std::get<0>
вернет int
(на самом деле ссылку на int
), а std::get<1>
— double
. Напишем объявление функции, которую мы хотим реализовать:
template
ReturnT get(TupleT && tuple, std::size_t index);
Шаблонный параметр TupleT
— это тип передаваемого в функцию кортежа, а ReturnT
— возвращаемое значение, которое нам необходимо определить. Данная функция возвращает элемент кортежа под номером index
, значение которого мы не знаем во время компиляции, а следовательно, и не знаем тип элемента кортежа. Значит, нам нужен какой-то тип, который удовлетворяет всем типам, содержащимся в интересующем нас кортеже.
Возвращаемый тип
В 17 стандарте есть класс std::any
, который может в себе хранить объект любого типа, однако хотелось бы избежать динамического выделения памяти для простого доступа элемента. Второй возможный тип — это шаблонный класс std::variant
, параметры которого мы сейчас обсудим.
Но вначале надо определиться, что делать в случае, если нам передали невалидное значение индекса. Первый вариант, который приходит в голову, — это бросать исключение, например, объект типа std::out_of_range
. Второй вариант — это возвращать какой-нибудь специальный тип, сигнализирующий невалидность переданного типа.
Первый вариант показался мне более предпочтительным, потому что возникновение исключительной ситуации в данном случае — это явно вина программиста, и ее, по идее, возникать не должно.
Теперь перейдем к возвращаемому значению. Первая мысль, которая пришла мне в голову — это возвращать std::variant
с такими же шаблонными параметрами, как и подаваемый на вход кортеж. Напишем метафункцию на подобие type_traits стандартной библиотеки для этого:
template
struct ToVariant;
template
using ToVariantT = typename ToVariant::type;
template
struct ToVariant>
{
using type = std::variant;
};
Эта метафункция определена только для типа std::tuple
. Отлично, теперь у нас есть возвращаемый тип. Однако при каждом вызове нашей функции get
происходит копирование имеющегося объекта, что в общем случае может быть достаточно дорогостоящей операцией.
Для решения этой проблемы мне показалось оптимальным использовать класс std::reference_wrapper
из стандартной библиотеки, чтобы в нашем «варианте» хранить только ссылки на существующие объекты. Метафункция с учетом константности кортежа запишется так:
template
struct ToVariantRef;
template
using ToVariantRefT = typename ToVariantRef::type;
template
struct ToVariantRef>
{
using type = std::variant>...>;
};
template
struct ToVariantRef>
{
using type = std::variant...>;
};
Реализация функции get
Теперь я хочу привести реализации функции get в том порядке, в котором они приходили мне в голову. Первой идеей было использовать std::map
, где ключам соответствовал бы индекс элемента, а значениям — непосредственно значения элементов кортежа. Реализация приведена ниже:
namespace detail
{
template >>
std::map buildVariantMap(TupleTRef&& tuple,
std::index_sequence)
{
std::map variantMap;
(variantMap.emplace(Idx, ReturnT{
std::in_place_index_t{},
std::ref( std::get( std::forward( tuple ) ) ) }), ...);
return variantMap;
}
} // namespace detail
template
auto variantMapGet(TupleTRef && tuple, std::size_t index)
-> ToVariantRefT>
{
using TupleT = std::remove_reference_t;
auto variantMap = detail::buildVariantMap(
std::forward(tuple),
std::make_index_sequence>{});
return variantMap.at(index);
}
Мы используем std::make_index_sequence
, чтобы из размера кортежа N получить последовательность 0…N-1, а в функции buildVariantMap
мы используем fold expressions, чтобы заполнить нашу «мапу» значениями. Минусы такого подхода очевидны — из-за динамического выделения памяти такой подход будет работать очень медленно.
Еще одной идеей для улучшения было использование std::array
вместо std::map
. Я решил не приводить реализацию, т.к. она схожа с реализацией, использующей std::map
. В таком случае все наши данные будут лежать на стеке и данный подход намного быстрее использования std::map
. Однако создавать массив на N элементов для доступа всего к одному из них все равно достаточно расточительно.
Следующей моей мыслью было использовать линейный поиск индекса элемента, реализация:
namespace detail
{
template
auto linearGetImpl(TupleTRef&& tuple, std::size_t index)
-> ToVariantRefT>
{
using TupleT = std::remove_reference_t;
using ReturnT = ToVariantRefT>;
if constexpr (Idx < std::tuple_size_v)
{
if (Idx == index)
{
return ReturnT{ std::in_place_index_t{},
std::ref(std::get(std::forward(tuple))) };
}
return linearGetImpl(std::forward(tuple), index);
}
else
{
throw std::out_of_range("Error! Tuple index is out of range!\n");
}
}
} // namespace detail
template
auto linearGet(TupleTRef&& tuple, std::size_t index)
-> ToVariantRefT>
{
using TupleT = std::remove_reference_t;
if (index >= std::tuple_size_v)
{
throw std::out_of_range("Error! Tuple index is out of range!\n");
}
return detail::linearGetImpl<0>(std::forward(tuple), index);
}
Мы просто проверяем, равен ли index
текущему значению шаблонного параметра Idx
, и если нет, то вызываем функцию detail::linearGetImpl
с параметром Idx + 1
. Такой подход оказывается существенно быстрее использования std::array
, но в таком случае у нас доступ к элементу имеет сложность O (N), что все равно недостаточно хорошо.
Затем я решил улучшить эту реализацию, используя бинарный поиск:
namespace detail
{
template
auto binaryGetImpl(TupleTRef && tuple, std::size_t index)
-> ToVariantRefT>
{
using TupleT = std::remove_reference_t;
using ReturnT = ToVariantRefT>;
constexpr auto MiddleIdx = (FirstIdx + LastIdx) / 2;
if constexpr (FirstIdx == LastIdx)
{
return ReturnT{ std::in_place_index_t{},
std::ref(std::get(std::forward(tuple))) };
}
else
{
if constexpr (MiddleIdx > 0)
{
if (MiddleIdx > index)
{
return binaryGetImpl(tuple, index);
}
}
if (MiddleIdx < index)
{
return binaryGetImpl(tuple, index);
}
return ReturnT{ std::in_place_index_t{},
std::ref(std::get(std::forward(tuple))) };
}
}
} // namespace detail
template
auto binaryGet(TupleTRef && tuple, std::size_t index)
-> ToVariantRefT>
{
using TupleT = std::remove_reference_t;
if (index >= std::tuple_size_v)
{
throw std::out_of_range("Error! Tuple index is out of range!\n");
}
return detail::binaryGetImpl<0, std::tuple_size_v - 1>(
std::forward(tuple), index);
}
Суть бинарного поиска заключается в том, что мы делим исходный отрезок 0…N-1 каждый раз пополам и идем в ту часть, где находится искомый индекс index
. Из-за использования двух шаблонных параметров std::size_t
такая реализация, однако, может увеличить объем сгенерированного кода и, как следствие, бинарника.
Наконец, можно получить O (1) реализацию при условии, что сложность оператора switch
не зависит от N. Реализация выглядит криво, но имеет место быть:
namespace detail
{
template
auto switchCaseGetImpl(TupleTRef&& tuple, std::size_t index)
-> ToVariantRefT>
{
using TupleT = std::remove_reference_t;
using ReturnT = ToVariantRefT>;
static_assert(std::tuple_size_v < 100);
#define CASE_N(I) \
case (I): \
if constexpr ((I) < std::tuple_size_v) \
{ \
return ReturnT{ std::in_place_index_t{}, \
std::ref(std::get(std::forward(tuple))) }; \
} \
else \
{ \
break; \
}
switch (index)
{
CASE_N(0);
CASE_N(1);
...
CASE_N(98);
CASE_N(99);
}
#undef CASE_N
}
} // namespace detail
template
auto switchCaseGet(TupleTRef&& tuple, std::size_t index)
-> ToVariantRefT>
{
using TupleT = std::remove_reference_t;
if (index >= std::tuple_size_v)
{
throw std::out_of_range("Error! Tuple index is out of range!\n");
}
return detail::switchCaseGetImpl(std::forward(tuple), index);
}
Данная функция работает только с кортежами размера меньше 100 (выглядит как разумное допущение). А вместо … в функции detail::switchCaseGetImpl
идет набор CASE_N
от 2 до 97. Для получения более красивого кода можно воспользоваться макросом из буста: BOOST_PP_REPEAT.
Бенчмарки
Для бенчмарков использовалась библиотека Google. Мы взяли std::tuple
длиной 40, каждый тип которого — это int
. Сложность каждой операции была порядка сложности нахождения целочисленного остатка от деления. Был выбран компилятор MSVC с флагом оптимизации O2. Результаты:
Benchmark | Time | CPU | Iterations |
BmSwitchCaseGet | 195 ns | 195 ns | 3733333 |
BmBinaryGet | 258 ns | 257 ns | 2488889 |
BmLinearGet | 492 ns | 500 ns | 1000000 |
BmVariantMapGet | 169057 ns | 168795 ns | 4073 |
BmVariantArrayGet | 1571 ns | 1573 ns | 407273 |
BmCompileTimeGet | 124 ns | 126 ns | 6400000 |
Из приведенных результатов видно, что switch-case реализация самая быстрая из вышеприведенных, а разница между std::get
и нашей реализацией составляет приблизительно 50%. Такая разница, по-видимому, обусловлена лучшей способностью компилятора оптимизировать код в случае доступа к элементам во время компиляции, а также созданием объекта std::ref
на каждый вызов нашей функции get
. Однако, если выполнять какие-то нетривиальные действия с элементами кортежа, то разница, скорее всего, окажется незначительной. Разница в 70 ns — это разница для прохождения по всем элементам кортежа, то есть разница для доступа к одному элементу составляет порядка 2 ns.
Заключение
В итоге мы разработали функциональность, которая позволяет итерироваться по элементам кортежа в произвольном порядке и не писать отдельную функцию для каждого случая. Это существенно экономит время разработчика — надеюсь, сэкономит время и вам. В заключении хочу привести код того, как можно пользоваться разработанной функцией.
Допустим, мы хотим вывести все элементы кортежа в поток std::cout
. Код будет выглядит следующим образом:
using TupleT = std::tuple;
int main()
{
const TupleT tuple{ 5, 3.0, "str"};
for (std::size_t idx{}; idx < std::tuple_size_v; ++idx)
{
std::visit([](auto && tupleValue) { std::cout << tupleValue.get() << " "; },
rttg::get(tuple, idx));
}
}
Для того, чтобы в лямбде не писать каждый раз .get()
, мы будем использовать обертку над функцией std::visit
:
template
auto visit(CallableT && callable, VariantTs && ... variants)
{
return std::visit(
[&callable](auto && value)
{
return std::invoke(std::forward(callable), value.get());
},
std::forward(variants)...);
}
Таким образом, финальный вариант можно записать без лишних .get()
:
using TupleT = std::tuple;
int main()
{
const TupleT tuple{ 5, 3.0, "str"};
for (std::size_t idx{}; idx < std::tuple_size_v; ++idx)
{
rttg::visit([](auto && tupleValue) { std::cout << tupleValue << " "; },
rttg::get(tuple, idx));
}
}
Для перегрузки работы с различными типами можно использовать overload trick:
/**
* Agregator of callables with operator()
*/
template
struct Overloader : CallableTs...
{
using CallableTs::operator()...;
};
/**
* Class template argument deduction guide
*/
template Overloader(CallableTs ...) -> Overloader;
В таком случае пример можно переписать в виде:
using TupleT = std::tuple;
int main()
{
const TupleT tuple{ 5, 3.0, "str"};
for (std::size_t idx{}; idx < std::tuple_size_v; ++idx)
{
rttg::visit(
rttg::Overloader
{
[](auto arg) { std::cout << arg << ' '; },
[](double arg) { std::cout << std::fixed << arg << ' '; },
[](const std::string & arg) { std::cout << std::quoted(arg) << ' '; }
},
rttg::get(tuple, idx));
}
}
Для такого простого примера, конечно, можно было воспользоваться функцией на подобие for_each
. Однако наша функция позволяет итерироваться в произвольном порядке, а не обязательно в возрастающем/убывающем:
using TupleT = std::tuple;
std::vector getIterableIndices()
{
// some non-trivial logic here
return std::vector{ 2U, 3U, 0U, 1U };
}
int main()
{
const TupleT tuple{ "str1", 1, "str2", 2, "str3", 3 };
for (const auto idx : getIterableIndices())
{
rttg::visit([](auto && tupleValue) { std::cout << tupleValue << " "; },
rttg::get(tuple, idx));
}
}
→ Ссылка на репозиторий
P.S. В итоге было решено оставить бинарную версию функции get из-за относительно небольшой разницы в производительности и более удобоваримой реализации по сравнению со switch-case.
P.S. S. Для кортежей размера < 7 линейный поиск работает быстрее бинарного и приблизительно наравне с реализацией switch-case.