В предыдущей статье, посвященной перебору элементов кортежей, мы рассмотрели только основы. В результате нашей работы мы реализовали шаблон функции, который принимал кортеж и мог красиво вывести его в консоль. Мы также реализовали версию с оператором <<
.
Сегодня мы пойдем немного дальше и рассмотрим другие техники. Первая — с применением std::apply
из C++17, вспомогательной функции для кортежей. В этой статье также будут рассмотрены некоторые стратегии, позволяющие сделать итерацию более универсальной и обрабатывать кастомные callable-объекты, а не только выводить их в консоль.
Это вторая часть мини-серии. Уделите внимание первой статье, где мы обсуждаем основы.
Подход с std:apply
std::apply
, появившаяся в C++17, является удобной вспомогательной функцией для std::tuple
. Она принимает кортеж и callable-объект, а затем вызывает этот callable-объект с параметрами, полученными из кортежа.
Вот пример:
#include <iostream>
#include <tuple>
int sum(int a, int b, int c) {
return a + b + c;
}
void print(std::string_view a, std::string_view b) {
std::cout << "(" << a << ", " << b << ")\n";
}
int main() {
std::tuple numbers {1, 2, 3};
std::cout << std::apply(sum, numbers) << '\n';
std::tuple strs {"Hello", "World"};
std::apply(print, strs);
}
Как видите, std::apply
принимает функции sum
или print
, а затем «разворачивает» кортежи и вызывает эти функции с соответствующими аргументами.
На диаграмме, приведенной ниже, показано, как это работает:
Хорошо, но как это связано с нашей задачей?
Критически важно то, что std::apply
скрывает в себе всю генерацию индексов и вызовы std::get<>
. Вот почему мы можем заменить нашу функцию вывода в консоль на std::apply и больше не использовать index_sequence
.
Первый подход — рабочий?
Первый подход, который пришел мне в голову, был следующим: создать variadic-шаблон функции, который принимает Args...
и передать его в std::apply
:
template <typename... Args>
void printImpl(const Args&... tupleArgs) {
size_t index = 0;
auto printElem = [&index](const auto& x) {
if (index++ > 0)
std::cout << ", ";
std::cout << x;
};
(printElem(tupleArgs), ...);
}
template <typename... Args>
void printTupleApplyFn(const std::tuple<Args...>& tp) {
std::cout << "(";
std::apply(printImpl, tp);
std::cout << ")";
}
Выглядит вполне нормально... да? Проблема в том, что он не компилируется.
GCC или Clang выдают ошибку общего толка, которая сводится к следующей строке:
candidate template ignored: couldn't infer template argument '_Fn
Но как? Почему компилятор не может получить правильные шаблонные параметры для printImpl?
Проблема заключается в том, что наш printImpl
является variadic-шаблоном функции, поэтому компилятор должен создать его инстанс. Создание инстанса происходит не при вызове std::apply
, а внутри std::apply
. Компилятор не знает, как будет вызываться callable-объект при вызове std::apply
, поэтому на данном этапе он не может выполнить вывод шаблона.
Что мы можем сделать, так это немного помочь компилятору и передать сами аргументы:
#include <iostream>
#include <tuple>
template <typename... Args>
void printImpl(const Args&... tupleArgs) {
size_t index = 0;
auto printElem = [&index](const auto& x) {
if (index++ > 0)
std::cout << ", ";
std::cout << x;
};
(printElem(tupleArgs), ...);
}
template <typename... Args>
void printTupleApplyFn(const std::tuple<Args...>& tp) {
std::cout << "(";
std::apply(printImpl<Args...>, tp); // <<
std::cout << ")";
}
int main() {
std::tuple tp { 10, 20, 3.14};
printTupleApplyFn(tp);
}
В приведенном выше примере мы помогли компилятору создать запрашиваемый инстанс, поэтому он с радостью передаст его в std::apply
.
Но есть еще одна техника, которую мы можем здесь задействовать. А как насчет вспомогательного callable-типа?
struct HelperCallable {
template <typename... Args>
void operator()(const Args&... tupleArgs) {
size_t index = 0;
auto printElem = [&index](const auto& x) {
if (index++ > 0)
std::cout << ", ";
std::cout << x;
};
(printElem(tupleArgs), ...);
}
};
template <typename... Args>
void printTupleApplyFn(const std::tuple<Args...>& tp) {
std::cout << "(";
std::apply(HelperCallable(), tp);
std::cout << ")";
}
Вы ощущаете разницу?
Теперь мы передаем HelperCallable
; это конкретный тип, поэтому компилятор может передавать его без каких-либо проблем. Не происходит никакого вывода (deduction) параметров шаблона. И затем, в какой-то момент, компилятор вызовет HelperCallable(args...)
, который, в свою очередь, вызовет operator()
для этой структуры. И теперь все в порядке, и компилятор может выводить типы. Другими словами, проблему мы отложили.
Итак, мы знаем, что код отлично работает с вспомогательным callable-типом… а как насчет лямбды?
#include <iostream>
#include <tuple>
template <typename TupleT>
void printTupleApply(const TupleT& tp) {
std::cout << "(";
std::apply([](const auto&... tupleArgs) {
size_t index = 0;
auto printElem = [&index](const auto& x) {
if (index++ > 0)
std::cout << ", ";
std::cout << x;
};
(printElem(tupleArgs), ...);
}, tp
)
std::cout << ")";
}
int main() {
std::tuple tp { 10, 20, 3.14, 42, "hello"};
printTupleApply(tp);
}
Тоже работает! Я также упразднил шаблонные параметры до всего лишь template
<typename TupleT>
.
Как видите, у нас здесь лямбда внутри лямбды. Напоминает на наш пользовательский тип с operator()
. Вы также можете взглянуть на трансформацию с помощью C++ Insights, перейдя по этой ссылке.
Упрощение вывода в консоль
Поскольку наш callable-объект получает variadic-список аргументов, мы можем использовать это и немного упростить код.
Спасибо PiotrNycz, за то, что он подсказал мне это.
Код внутри внутренней лямбды использует index, чтобы проверить, нужно ли нам печатать разделитель или нет — он проверяет, первый ли аргумент мы печатаем. Мы можем сделать это во время компиляции:
#include <iostream>
#include <tuple>
template <typename TupleT>
void printTupleApply(const TupleT& tp) {
std::apply
(
[](const auto& first, const auto&... restArgs)
{
auto printElem = [](const auto& x) {
std::cout << ", " << x;
};
std::cout << "(" << first;
(printElem(restArgs), ...);
}, tp
);
std::cout << ")";
}
int main() {
std::tuple tp { 10, 20, 3.14, 42, "hello"};
printTupleApply(tp);
}
Этот код выдаст ошибку, когда в кортеже нет элементов — мы могли бы исправить это, проверяя его размер в if constexpr
, но давайте пока опустим это.
Хотите большего?
Если вы хотите посмотреть на аналогичный код, который работает с std::format
, вы можете почитать мою статью: Как форматировать пары и кортежи с помощью std::format (~1450 слов), которая доступна для патронов C++ Stories Premium на патреоне. Оценить все преимущества Premium патронажа можно здесь.
Делаем код более обобщенным
До сих пор наше внимание было сосредоточено на выводе элементов кортежа в консоль. По этому у нас была “фиксированная” функция, которая вызывалась для каждого аргумента. Чтобы развить наши идеи чуть дальше, давайте попробуем реализовать функцию, которая принимает generic (обобщенный) callable-объект. Например:
std::tuple tp { 10, 20, 30.0 };
printTuple(tp);
for_each_tuple(tp, [](auto&& x){
x*=2;
});
printTuple(tp);
Начнем с подхода с использованием index_sequence
:
template <typename TupleT, typename Fn, std::size_t... Is>
void for_each_tuple_impl(TupleT&& tp, Fn&& fn, std::index_sequence<Is...>) {
(fn(std::get<Is>(std::forward<TupleT>(tp))), ...);
}
template <typename TupleT, typename Fn,
std::size_t TupSize = std::tuple_size_v<std::remove_cvref_t<TupleT>>>
void for_each_tuple(TupleT&& tp, Fn&& fn) {
for_each_tuple_impl(std::forward<TupleT>(tp), std::forward<Fn>(fn),
std::make_index_sequence<TupSize>{});
}
Что здесь происходит?
Во-первых, код использует универсальные/universal ссылки (передаваемые/forwarding ссылки) для передачи объектов кортежа. Это необходимо для поддержки всех возможных юзкейсов, особенно если вызывающая сторона хочет изменить значения внутри кортежа. Вот почему нам нужно использовать std::forward
во всех местах.
Но почему я использовал remove_cvref_t
?
Про std::decay и remove ref
Как видите, в моем коде я использовал:
std::size_t TupSize = std::tuple_size_v<std::remove_cvref_t<TupleT>>
Это новый вспомогательный тип из трейта C++20, который гарантирует, что мы получим “реальный” тип из типа, который мы получаем по универсальной ссылке.
До C++20 часто можно было встретить std::decay
или std::remove_reference
.
Вот ссылке на Stackoverflow с хорошим резюме касательно вопроса о итерации кортежа:
Поскольку
T&&
является передаваемой ссылкой,T
будетtuple<...>&
илиtuple<...> const&
при передачеlvalue
; ноstd::tuple_size
специализировано только дляtuple<...>
, поэтому мы должны избавиться от ссылки и возможногоconst
. До добавленияstd::remove_cvref_t
в C++20 использованиеdecay_t
было простым (хотя и излишним) решением.
Обобщенная версия std::apply
Мы обсудили реализацию с index sequence
; мы также можем сделать то же самое через std::apply
. На основе чего у нас получится более простой код?
Вот моя попытка:
template <typename TupleT, typename Fn>
void for_each_tuple2(TupleT&& tp, Fn&& fn) {
std::apply
(
[&fn](auto&& ...args)
{
(fn(args), ...);
}, std::forward<TupleT>(tp)
);
}
Посмотрите внимательнее, я забыл использовать std::forward
при вызове fn
!
Мы можем решить эту проблему, используя шаблонные лямбда-выражения, доступные в C++20:
template <typename TupleT, typename Fn>
void for_each_tuple2(TupleT&& tp, Fn&& fn) {
std::apply
(
[&fn]<typename ...T>(T&& ...args)
{
(fn(std::forward<T>(args)), ...);
}, std::forward<TupleT>(tp)
);
}
Кроме того, если вы все-таки хотите придерживаться C++17, вы можете применить decltype к аргументам:
template <typename TupleT, typename Fn>
void for_each_tuple2(TupleT&& tp, Fn&& fn) {
std::apply
(
[&fn](auto&& ...args)
{
(fn(std::forward<decltype(args)>(args)), ...);
}, std::forward<TupleT>(tp)
);
}
Смотреть код в @Compiler Explorer
Возвращаемое значение
https://godbolt.org/z/1f3Ea7vsK
Заключение
Это была увлекательная история, и я надеюсь, что вы узнали из нее чуточку больше о шаблонах.
Фоновой задачей было вывести в консоль элементы кортежей и найти способ их преобразования. В ходе этого процесса мы рассмотрели variadic-шаблоны, index sequence, правила и приемы вывода аргументов шаблона, std::apply и удаление ссылок.
Я буду рад обсудить с вами изменения и улучшения, которые покажутся вам уместными. Дайте мне знать о них в комментариях под статьей.
Первую часть смотрите здесь: Шаблоны C++: как итерировать по std::tuple - основы.
Ссылки:
Effective Modern C++ by Scott Meyers
C++ Templates: The Complete Guide (2nd Edition) by David Vandevoorde, Nicolai M. Josuttis, Douglas Gregor
Всех желающих приглашаем на demo-занятие «Полиморфный аллокатор C++17». На занятии обсудим основные идеи, лежащие в основе полиморфных аллокаторов С++17, а также рассмотрим примеры работы с компонентами из нэймспейса pmr. Регистрация здесь.