Для большинства стандартных контейнеров перебор элементов можно осуществлять просто с помощью цикла for с диапазоном прямо во время выполнения. Но что насчет кортежей (std::tuple)? В этом случае мы не можем использовать обычный цикл, так как он не “понимает” список аргументов кортежа во время компиляции. В этой статье я покажу вам несколько приемов, которые вы можете использовать для итерации по элементам кортежа.

В качестве примера будем использовать следующий кортеж:

std::tuple tp { 10, 20, 3.14, 42, "hello"};
printTuple(tp);

Мы бы хотели, чтобы в консоль выводилось следующее:

(10, 20, 3.14, 42, hello)

Как же нам реализовать ​​функцию printTuple?

Давайте начнем!

Это первая часть, где мы обсуждаем только основы. Во второй части мы поговорим об обобщениях, std::apply и многом другом.

Основы

std::tuple (кортеж) — это коллекция фиксированного размера, содержащая разнородные значения.

Для сравнения, его младший брат — std::pair (пара) — принимает два шаблонных параметра, <T, U>.

std::pair<int, double> intDouble { 10, 42.42 };
// или with CTAD, C++17:
std::pair deducedIntDouble { 10, 42.42 }; // выведение типов!

std::tuple принимает переменное количество аргументов. Так что это обобщение std::pair , поскольку оно может принимать любое количество аргументов/значений.

std::tuple<int, double, const char*> tup {42, 10.5, "hello"};
// или with CTAD, C++17:
std::tuple deducedTup {42, 10.5, "hello"}; // выведение типов

Если вам нужно получить доступ к элементу пары, вы можете просто запросить .first или .second элемент:

std::pair intDouble { 10, 42.42 }; 
intDouble.first *= 10;

Однако поскольку у кортежа размер может быть разный, у него нет ни .first, ни .second, ни .third... вы можете получить доступ к его элементам только через std::get:

#include <tuple>
#include <iostream>

int main() {
    std::tuple tp {42, 10.5, "hello"};
  
    // по индексу:
    std::get<0>(tp) *= 100;
    std::cout << std::get<0>(tp) << '\n';
    std::cout << std::get<2>(tp) << '\n';
    
    // по типу:
    std::cout << std::get<double>(tp) << '\n';
}

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

Так как по нему итерировать? 

Ну что ж, теперь, когда мы разобрались с основами, мы можем попытаться написать код, который будет выполнять перебор всех элементов кортежа.

Как видите, значения/типы устанавливаются во время компиляции. Это отличает кортеж от обычного контейнера, такого как std::vector, куда мы обычно помещаем значения во время выполнения.

Чтобы итерировать по кортежу, нам нужно преобразовать этот “желанный” код:

// как бы мы хотели:
std::tuple tp {42, 10.5, "hello"};
for (auto& elem : tp)
    std::cout << elem << ", ";

Во что-то вроде:

std::tuple tp {42, 10.5, "hello"};
std::cout << std::get<0>(tp) << ", ";
std::cout << std::get<1>(tp) << ", ";
std::cout << std::get<2>(tp) << ", ";

Другими словами, нам нужно разложить кортеж в ряд вызовов std::get<id> для доступа к элементу по его позиции (id). Позже мы можем передать полученные таким образом элементы в std::cout или любой другой вызываемый объект (для их обработки).

К сожалению, язык не поддерживает такие циклы времени компиляции… по крайней мере пока (дополнительную информацию по этому вопросу вы можете посмотреть внизу).

Чтобы добиться подобного эффекта, нам нужно прибегнуть к шаблонной магии.

Базовая структура 

Сначала мы можем попробовать задействовать шаблон функции, который принимает список индексов, которые мы хотим вывести:

template <typename T>
void printElem(const T& x) {
    std::cout << x << ',';
};

template <typename TupleT, std::size_t... Is>
void printTupleManual(const TupleT& tp) {
    (printElem(std::get<Is>(tp)), ...);
}

И тогда наша искомая функция примет следующую форму:

std::tuple tp { 10, 20, "hello"};
printTupleManual<decltype(tp), 0, 1, 2>(tp);

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

Что здесь происходит?

Помимо типа самого кортежа наша функция printTupleManual принимает нетиповые шаблонные аргументы — variadic список из size_t значений.

Для определения типа tp этом вызове я использовал decltype. В качестве альтернативы мы могли бы вызвать ее следующим образом:

std::tuple tp { 10, 20, "hello"};
printTupleManual<std::tuple<int, int, const char*>, 0, 1, 2>(tp);

Как видите, decltype экономит нам много времени. Подробнее об этом смотрите на Cppreference - decltype.

Внутри самой функции мы используем свертку параметров шаблона (доступную начиная с C++17), чтобы расширить этот variadic набор параметров с помощью оператора запятой.

Другими словами, наша функция тождественна следующей форме:

void printTupleManual<std::tuple<int, int, const char *>, 0, 1, 2>
    (const std::tuple<int, int, const char *> & tp)
{
  printElem(get<0>(tp)), (printElem(get<1>(tp)), printElem(get<2>(tp)));
}

Мы можем проиллюстрировать это “расширение” с помощью CppInsights — смотрите демо по этой ссылке.

Благодаря сверткам (fold expressions) мы стали на шаг ближе к искомой нами форме цикла времени компиляции!

Поприветствуйте index_sequence 

В предыдущем примере нам нужно было передавать список индексов вручную. Создание такого списка аргументов не вяжется с масштабируемостью и подвержено ошибкам. Можем ли мы автоматически вывести этот список на основе размера кортежа?

Все, что нам сейчас нужно, так это сгенерировать следующие индексы:

// для кортежа размера N сгенерировать список
0, 1, ..., N-1

Эта проблема очень распространена в программировании с шаблонами, и, начиная с C++14, мы можем использовать index_sequence. Это вспомогательный шаблонный класс, который содержит индексы в виде нетиповых параметров шаблона:

template< class T, T... Ints > 
class integer_sequence;

template<std::size_t... Ints>
using index_sequence = std::integer_sequence<std::size_t, Ints...>;

Стандартная библиотека C++ определяет std::integer_sequence<T, T... Ints>, а std::index_sequence — это просто integer_sequence по size_t. Смотрите @cppreference.com.

Таким образом мы можем преобразовать наш код в такую форму:

template <typename T>
void printElem(const T& x) {
    std::cout << x << ',';
};

template <typename TupleT, std::size_t... Is>
void printTupleManual(const TupleT& tp, std::index_sequence<Is...>) {
    (printElem(std::get<Is>(tp)), ...);
}

И вызывать его следующим образом:

std::tuple tp { 10, 20, "hello"};
printTupleManual(tp, std::index_sequence<0, 1, 2>{});

Мы также можем воспользоваться вспомогательной функцией make_index_sequence:

printTupleManual(tp, std::make_index_sequence<3>{});

И заключительная часть — получаем размер кортежа:

printTupleManual(tp, std::make_index_sequence<std::tuple_size<decltype(tp)>::value>{});

Также у нас есть вспомогательной шаблон переменной — tuple_size_v, который может сделать нашу строку немного короче:

printTupleManual(tp, std::make_index_sequence<std::tuple_size_v<decltype(tp)>>{});

Объединим все это в нашей функции:

template <typename T>
void printElem(const T& x) {
    std::cout << x << ',';
};

template <typename TupleT, std::size_t... Is>
void printTupleManual(const TupleT& tp, std::index_sequence<Is...>) {
    (printElem(std::get<Is>(tp)), ...);
}

template <typename TupleT, std::size_t TupSize = std::tuple_size_v<TupleT>>
void printTupleGetSize(const TupleT& tp) {
    printTupleManual(tp, std::make_index_sequence<TupSize>{});
}

Теперь ее вызов выглядит очень лаконично:

std::tuple tp { 10, 20, "hello"};
printTupleGetSize(tp);

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

Вы также можете посмотреть полностью “развернутый” код в C++ Insights, перейдя по этой ссылке.

Например, наш вызов разворачивается в:

void printTupleGetSize<std::tuple<int, int, const char *>, 3>
     (const std::tuple<int, int, const char *> & tp)
{
  printTupleManual(tp, std::integer_sequence<unsigned long, 0, 1, 2>{});
}

Как видите, make_index_sequence был элегентно развернут в std::integer_sequence<unsigned long, 0, 1, 2>{}.

Вывод std::tuple  

Теперь, когда нас есть все необходимые функции для итерации по кортежу, мы можем попробовать заключить их в окончательную функцию вывода в консоль.

template <typename TupleT, std::size_t... Is>
void printTupleImp(const TupleT& tp, std::index_sequence<Is...>) {
    size_t index = 0;
    auto printElem = [&index](const auto& x) {
        if (index++ > 0) 
            std::cout << ", ";
        std::cout << x;
    };

    std::cout << "(";
    (printElem(std::get<Is>(tp)), ...);
    std::cout << ")";
}

template <typename TupleT, std::size_t TupSize = std::tuple_size_v<TupleT>>
void printTuple(const TupleT& tp) {
    printTupleImp(tp, std::make_index_sequence<TupSize>{});
}

Как видите, внутри printTupleImp я преобразовал printElem в лямбду. Этот шаг является вспомогательным, так как он позволяет мне легко перенести некоторые дополнительные состояния в функцию вывода в консоль. Мне нужно проверить, должен ли я ставить разделитель или нет.

Теперь мы можем запустить ее:

std::tuple tp { 10, 20, "hello"};
printTuple(tp);

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

Отлично!

Но… как насчет того, чтобы сделать ее более удобной для оператора << ? На данный момент функция тесно связана с std::cout, поэтому нам сложно вывести кортеж каким-либо другим образом.

Оператор <<     

Все, что нам нужно сделать, это задействовать нашу вспомогательную функцию и передать дополнительный объект ostream :

#include <iostream>
#include <ostream>
#include <tuple>

template <typename TupleT, std::size_t... Is>
std::ostream& printTupleImp(std::ostream& os, const TupleT& tp, std::index_sequence<Is...>) {
    size_t index = 0;
    auto printElem = [&index, &os](const auto& x) {
        if (index++ > 0) 
            os << ", ";
        os << x;
    };

    os << "(";
    (printElem(std::get<Is>(tp)), ...);
    os << ")";
    return os;
}

template <typename TupleT, std::size_t TupSize = std::tuple_size<TupleT>::value>
std::ostream& operator <<(std::ostream& os, const TupleT& tp) {
    return printTupleImp(os, tp, std::make_index_sequence<TupSize>{}); 
}

int main() {
    std::tuple tp { 10, 20, "hello"};
    std::cout << tp << '\n';
}

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

Добавление индексов    

Поскольку у нас уже есть список индексов, почему бы нам не использовать его?

#include <iostream>
#include <ostream>
#include <tuple>

template <typename TupleT, std::size_t... Is>
std::ostream& printTupleImp(std::ostream& os, const TupleT& tp, std::index_sequence<Is...>) {
    auto printElem = [&os](const auto& x, size_t id) {
        if (id > 0) 
            os << ", ";
        os << id << ": " << x;
    };

    os << "(";
    (printElem(std::get<Is>(tp), Is), ...);
    os << ")";
    return os;
}

template <typename TupleT, std::size_t TupSize = std::tuple_size<TupleT>::value>
std::ostream& operator <<(std::ostream& os, const TupleT& tp) {
    return printTupleImp(os, tp, std::make_index_sequence<TupSize>{}); 
}

int main() {
    std::tuple tp { 10, 20, "hello"};
    std::cout << tp << '\n';
}

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

Теперь вместо отдельной переменной index я просто передаю текущий индекс элемента из сверточного выражения. Мы также можем использовать его в выводе в качестве префикса для каждого элемента.

Тогда мы получим:

(0: 10, 1: 20, 2: hello)

Хотите больше?

Если вы хотите посмотреть на аналогичный код, который работает с std::format, вы можете почитать мою статью: Как форматировать пары и кортежи с помощью std::format (~1450 слов), которая доступна для патронов C++ Stories Premium на патреоне. Оценить все преимущества Premium патронажа можно здесь.

Заключение и следующая часть  

Это был интересный эксперимент!

За несколько шагов этого руководства мы прошли путь от основ кортежей к итерации по списку индексов, а затем и с std::index_sequence. Благодаря сверточным выражениям, доступным с C++17, мы можем расширить наш список аргументов времени компиляции и реализовать поверх него функцию.

Мы сосредоточились на функции вывода, так как она относительно проста для понимания и более-менее полезна. В следующий раз я постараюсь сделать нашу функцию для итерации более общей, чтобы можно было выполнять преобразования значений. Мы также рассмотрим удобную функцию std::apply, которая даст нам дополнительные возможности.

Обязательно читайте вторую часть: Шаблоны C++: как итерировать по std::tuple - std::apply и многое другое — C++ Stories.

Возвращаясь к вам

Мне также было бы очень интересно узнать о ваших вариантах использования кортежей и способах итерации по ним.

Обязательно делитесь вашими мыслями в комментариях под статьей.

for времени компиляции

Я упомянул, что C++ не предлагает нам цикла времени компиляции, но есть одно предложение P1306 — “Expansion statements”. В настоящее время оно находится в рассмотрении, но вряд ли появится в C++23.

Это позволит нам писать что-то вроде:

auto tup = std::make_tuple(0, ‘a’, 3.14);
for... (auto elem : tup)
    std::cout << elem << std::endl;

Посмотреть статус этого предложения можно на @Github/cpp/papers.

Примечания

Книги:

Статьи и ссылки:


Сегодня в OTUS пройдет demo-урок по основам C++, на котором обсудим:

  • Что есть суть «современный C++»?

  • Какие есть отличительные черты у этого языка?

  • В чем его сильные и слабые стороны?

Приглашаем всех заинтересованных начинающих, регистрация по ссылке.