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

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

Комментарии (0)