Шаблоны C++: как итерировать по std::tuple — std::apply и еще пара приемов

от автора

В предыдущей статье, посвященной перебору элементов кортежей, мы рассмотрели только основы. В результате нашей работы мы реализовали шаблон функции, который принимал кортеж и мог красиво вывести его в консоль. Мы также реализовали версию с оператором <<.

Сегодня мы пойдем немного дальше и рассмотрим другие техники. Первая — с применением 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); }

Смотреть в @Compiler Explorer

Как видите, 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); }

Смотреть в @Compiler Explorer

В приведенном выше примере мы помогли компилятору создать запрашиваемый инстанс, поэтому он с радостью передаст его в 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); }

Смотреть в @Compiler Explorer

Тоже работает! Я также упразднил шаблонные параметры до всего лишь 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); }

Смотреть в @Compiler Explorer

Этот код выдаст ошибку, когда в кортеже нет элементов — мы могли бы исправить это, проверяя его размер в 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)     ); }

Смотреть в @Compiler Explorer

Кроме того, если вы все-таки хотите придерживаться 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 — основы.

Ссылки:


Всех желающих приглашаем на demo-занятие «Полиморфный аллокатор C++17». На занятии обсудим основные идеи, лежащие в основе полиморфных аллокаторов С++17, а также рассмотрим примеры работы с компонентами из нэймспейса pmr. Регистрация здесь.


ссылка на оригинал статьи https://habr.com/ru/articles/656363/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *