Для большинства стандартных контейнеров перебор элементов можно осуществлять просто с помощью цикла 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';
}
Так как по нему итерировать?
Ну что ж, теперь, когда мы разобрались с основами, мы можем попытаться написать код, который будет выполнять перебор всех элементов кортежа.
Как видите, значения/типы устанавливаются во время компиляции. Это отличает кортеж от обычного контейнера, такого как 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);
Что здесь происходит?
Помимо типа самого кортежа наша функция 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);
Отлично!
Но… как насчет того, чтобы сделать ее более удобной для оператора <<
? На данный момент функция тесно связана с 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';
}
Добавление индексов
Поскольку у нас уже есть список индексов, почему бы нам не использовать его?
#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';
}
Теперь вместо отдельной переменной 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.
Примечания
Книги:
Effective Modern C++ by Scott Meyers.
C++ Templates: The Complete Guide (2nd Edition) by David Vandevoorde, Nicolai M. Josuttis, Douglas Gregor.
Статьи и ссылки:
Mundane std::tuple tricks: Getting started - The Old New Thing
std::index_sequence and its Improvement in C++20 - Fluent C++
Сегодня в OTUS пройдет demo-урок по основам C++, на котором обсудим:
Что есть суть «современный C++»?
Какие есть отличительные черты у этого языка?
В чем его сильные и слабые стороны?
Приглашаем всех заинтересованных начинающих, регистрация по ссылке.