Привет! Меня зовут Николай, я C++-разработчик в SimbirSoft. В этой статье хочу поделиться практическими приёмами работы с вариативными шаблонами в C++ и разобрать идиомы, которые помогают решать задачи с пакетами параметров — от их перебора и преобразования до использования в более сложных конструкциях.
Мы рассмотрим рекурсию по аргументам и параметрам шаблона, свёртки, работу с лямбда-функциями и requires, мультилямбды, рекурсивные типы через наследование и другие приёмы. На примерах разберём, как современные возможности C++ позволяют сокращать объём шаблонного кода и решать задачи как на этапе компиляции, так и во время выполнения.
Статья будет полезна C++-разработчикам, которые уже знакомы с шаблонами и хотят глубже разобраться в variadic templates и возможностях пакетов параметров. Для комфортного чтения потребуется уверенное владение базовым синтаксисом C++, понимание шаблонов, лямбда-выражений и основных возможностей стандартной библиотеки. Материал ориентирован прежде всего на разработчиков среднего уровня, но будет полезен и тем, кто хочет расширить свой набор практических приёмов современного C++.
Контексты
С появлением вариативных шаблонов появилась возможность функциям принимать переменное количество аргументов. Также они позволяют использовать шаблонные типы, содержащие переменное количество элементов (например, std::tuple). Основным языковым механизмом, позволяющим использовать вариативные шаблоны, являются пакеты параметров, которые содержат произвольное количество значений или типов.
С пачками параметров легко выполнять некоторые действия, например передавать содержащиеся в них значения в функцию. Другие задачи выполнить немного сложнее, например перебрать пакет параметров или извлечь определённые элементы. Однако в целом это можно решить с помощью различных идиом, некоторые из которых требуют большего объёма кода, чем другие. Прежде чем рассмотреть идиомы, необходимо напомнить контексты, в которых могут происходить расширения пачки параметров.
1. В аргументах функции.
template<typename... Args> void foo(Args ... args){ g(args...); }
Расширение пакета может происходить при вызове функции g(...).
2. При инициализации в круглых скобках.
template<typename... Args> void foo(Args ... args){ Object object(args...); }
Расширение пакета происходить при инициализации объекта Object.
3. При инициализации в фигурных скобках.
template<typename... Args> void foo(Args ... args){ int res[5] = {args...}; }
4. В аргументах шаблона.
template<typename... Args> void foo(Args ... args){ Tuble<Args...> t; }
Расширения пакета происходить в списке аргументов шаблона объекта Tuble.
5. В параметрах функции.
template<typename... Ts, int... N> void foo(Ts (&...arr)[N]) {}
6. В параметрах шаблона.
template<typename... Args> struct Object{ template<Args... Values> struct apply {}; };
7. В наследовании и списках инициализации базовых классов.
template<typename... Base> struct Object : public Base...{ public: Object(const Base&... base) : Base(base)... {} };
Расширениями пакетов являются список базовых классов и вызовы их конструкторов. Валидными являются также конструкции, когда создаётся объект Object без шаблонных параметров.
8. В лямбда-функциях при захвате параметра.
template<typename... Args> void f(Args... args){ auto lm = [args...] { return g(args...); }; lm(); }
9. Оператор sizeof....
template<typename... Args> struct Object{ const std::size_t value = sizeof...(Args); };
10. В динамической спецификации исключения (работает до С++17).
template<typename... Args> void func(int arg) throw(Args...){}
11. В выравнивание.
template<typename... Args> struct alignas(Args...) Align{};
Из пакета параметров берется наибольшее значение.
12. В атрибутах.
template<int... Args> [[vendor::attr(Args)...]] void f();
Если предусмотрено спецификацией самого атрибута.
13. В объявлении (начиная с C++17).
template<typename... Args> struct Object : Args...{ using Args::g...; };
14. При индексировании пачки параметров (начиная с C++26).
template<typename... Args> auto sum(Args ... args){ return args...[0] + args...[1]; }
15. При объявлении друзей (начиная с C++26).
template<class... Args> class Object{ friend Args...; };
Идиомы
Теперь непосредственно перейдём к рассмотрению самих идиом.
1. Рекурсия по спискам аргументов.
Базовая идиома с шаблонами с переменным числом аргументов заключается в рекурсивной итерации по списку аргументов с использованием перегрузки функций для различных случаев.
void print(){} // Функция для завершения рекурсии void print(const auto &first, const auto &...args){ std::cout << first; print(rest...); }
Начиная с C++17, многие случаи использования рекурсии решаются с помощью свертки:
void print(const auto &...args){ (std::cout << ... << args); }
2. Рекурсия по параметрам шаблона.
Другой способ — рекурсия по параметрам шаблона. Этот способ применяется, когда необходимо использовать элементы пакета параметров или создавать на их основе новые элементы (например, если передаётся список типов).
В следующем примере с помощью рекурсии вычисляем сумму кодов символов:
template<char ... Args> struct number{}; //Общий шаблонный класс template<char T, char ... Args> struct number<T, Args...>{ static constexpr int sum = T + number<Args...>::sum; }; template<> struct number<>{ static constexpr int sum = 0; //Класс для завершения рекурсии }; int main(){ std::cout << number<'a', 'b', '9'>::sum << std::endl; }
3. Свертки с помощью оператора запятой.
Если возникает необходимость выполнить одну и ту же операцию над каждым элементом пакета параметров, можно использовать свёртку с помощью оператора запятой, который просто последовательно выполняет одно выражение за другим.
template<typename T, typename ...Args> void insert_all(T &t, Args&&...args){ (t.push_back(std::forward<Args>(args)), ...); // (void(t.push_back(std::forward<Args>(args))), ...); – Возможен такой вариант если перегружен оператор запятая(operator,()) } int main(){ std::vector<int> number; insert_all(number, 12, 34, 5, 12); std::ranges::copy(number, std::ostream_iterator<int>(std::cout, " ")); }
Это код можно было написать с помощью рекурсии, но он является громоздким по сравнению со сверткой.
4. Вычисление по короткому замыканию && и ||.
Иногда нужно перебирать набор параметров до тех пор, пока не будет выполнено какое-то условие. В данном случае можно использовать логические операторы && и ||, которые прерывают вычисления и прекращают любые действия, когда становится ясно, что условие истинно или ложно. Следующий пример демонстрирует нахождение заданного типа в списке и возвращает true, если он присутствует.
template<typename T, typename ...Args> struct any_off{ static constexpr bool val = (std::is_same_v<T, Args> || ...); }; int main(){ std::cout << std::format("{}", any_off<void, int, void>::val); }
5. Использование в лямбда-функциях.
Иногда для извлечения пакета параметров из шаблонного типа (например, tuple) требуется деконструкция. Это можно сделать, определив вспомогательный тип или функцию, но это приводит к увеличению объёма кода. Один из способов сделать код более читабельным — использовать лямбда-выражение. Лямбда-выражения особенно полезны при работе с кортежами. Сочетание лямбда-выражения с std::apply позволяет получить набор параметров, соответствующий содержимому кортежа.
В следующем примере функция на вход принимает кортеж, состоящий из разных типов, и модифицирует каждый элемент. Лямбда-функция mdf проверяет тип каждого элемента: если он является числом, то умножает элемент на 2, если нет, то не изменяет:
//Вариант 1 auto modify(auto tuple){ return std::apply([]<typename ...T>(T... t) { auto mdf = []<typename U>(U u){ if constexpr(std::is_integral_v<U> || std::is_floating_point_v<U>){ return 2 * u; } else{ return u; } }; return std::tuple(mdf(t)...); }, tuple); }
Во втором варианте демонстрируется аналогичная функция из использование целочисленной последовательности std::integer_sequence и без std::apply.
//Вариант 2 auto modify(auto tuple){ return [&tuple]<std::size_t ...I>(std::index_sequence<I...>) { auto mdf = []<typename U>(U u){ if constexpr(std::is_integral_v<U> || std::is_floating_point_v<U>){ return 2 * u; } else{ return u; } }; return std::tuple(mdf(get<I>(tuple))...); }(std::make_index_sequence<std::tuple_size_v<decltype(tuple)>>{}); } int main(){ std::tuple tuple(1, 'a', 3.0); auto t = modify(tuple); std::cout << std::format("{} {} {}", get<0>(t), get<1>(t), get<2>(t)); }
6. Использование в лямбда-функциях c захватом параметров в requires.
Аналогично предыдущему пункту, лямбда-функции с захватом параметров можно использовать в выражениях requires. Для демонстрации возьмём предыдущий пример и создадим концепт, который проверяет, являются ли элементы кортежа числами (перенесём проверку из лямбды mdf в концепт tuple_number).
template<typename ...Args> concept tuple_number = requires(Args...){ requires (true && [](auto ... t){ return ((std::is_integral_v<decltype(t)> || std::is_floating_point_v<decltype(t)>) && ...); }(Args{}...)); }; template<tuple_number ... Args> constexpr auto modify(Args ... args){ std::tuple<Args...> tuple = {args...}; return std::apply([]<typename ...T>(T... t) { return std::tuple(T(2 * t)...); }, tuple); } int main(){ auto t = modify(1, 6, 3.0); std::cout << std::format("{} {} {}", get<0>(t), get<1>(t), get<2>(t)); }
7. В decltype с использованием лямбда-функции.
Если нужно изменить типы в пакете параметров, к примеру получить указатели или ссылки на типы кортежа, то начиная с С++20 можно использовать лямбда-функции в decltype.
В примере демонстрируется преобразования std::tuple<int, std::string, double> в std::tuple<*int, std::string, double>.
template<typename Tuple> using tuple_ptrs = decltype(std::apply([](auto ...t) { return std::tuple(&t...); }, std::declval<Tuple>())); int main(){ std::tuple<int, std::string, double> tuple; tuple_ptrs<decltype(tuple)> ptr; }
До С++20 пришлось бы использовать дополнительные классы с частичной специализацией, и результат бы получался громоздким.
8. Мультилямбда.
Для написания разных лямбда-функций с разными типами, можно реализовать новый шаблонный тип с переменным числом аргументов, multilambda, который создает функциональный объект, состоящий из нескольких лямбда-выражений с разными сигнатурами типов.
Пример:
template<typename ...Lambdas> struct multilambda : Lambdas... { using Lambdas::operator()...; constexpr multilambda(Lambdas ...lambda) : Lambdas(std::move(lambda))... {} }; //Для предотвращения лишний работы по перемещению лямбда-функции в структуру template<typename ...Lambdas> multilambda(Lambdas...) -> multilambda<Lambdas...>; int main(){ std::tuple t (1, true, std::string("hello"), 3.0); constexpr multilambda action { [](int i) { std::cout << i << std::endl; }, [](double d) { std::cout << d << std::endl; }, [](bool b) { std::cout << (b ? "yes\n" : "no\n"); }, [](std::string s) { std::cout << s.size() << " bytes\n"; }, }; std::apply([action](auto ...v) { (action(v), ...); }, t); }
multilambda принимает набор лямбда-функцию и делает их базовыми классами. Затем, расширяя шаблон using L::operator()...;, добавляя в область видимости все операторы вызова функций, так что любой из них может быть вызван.
Аналогично предыдущему пункту multilambda может быть использована в decltype.
9. Рекурсивные типы через наследования.
Для определения типа, который содержит переменное количество аргументов в зависимости от пакета параметров (аналог std::tuple), желательно использовать наследование, при котором производный класс содержит один элемент, а базовый класс — остальные элементы.
template<typename ...T> struct Tuple; template<> struct Tuple<> { static constexpr std::size_t len = 0; }; template<typename T, typename ...Args> struct Tuple<T, Args...> : Tuple<Args...> { using head_type = T; using tail_type = Tuple<Args...>; static constexpr std::size_t len = 1 + sizeof...(Args); [[no_unique_address]] head_type value_{}; //Оптимизация пустой базы constexpr Tuple() = default; template<typename U, typename ...List> constexpr Tuple(U &&u, List &&...list) : tail_type(std::forward<List>(list)...), value_(std::forward<U>(u)) {} tail_type &tail(){ return *this; } const tail_type &tail() const{ return *this; } }; template<typename ...T> Tuple(T...) -> Tuple<T...>; int main(){ Tuple<int, char, double> tuple(1, 'a', 10.0); std::cout << std::format("{} {} {}", tuple.value_, tuple.tail().value_, tuple.tail().tail().value_); }
10. Однородные пакеты параметров функции.
В некоторых задачах возникает необходимость передавать в функции однотипные параметры, количество которых заранее известно. Например:
template<char ...T> void foo(T&&...t){...}
Так работает только со встроенными типами. Для пользовательских типов необходимо написать дополнительную функцию. Которая будет либо создавать объект, либо передавать ссылку на этот объект. Для этого случая создадим функцию:
template<typename Want, typename Have> inline auto local_copy(Have &in) -> std::conditional_t<std::is_same_v<Want, Have>, Want &&, Want>{ return static_cast<Have&&>(in); }
Функция создаёт новый объект типа Want из аргумента типа Have, если только он не является временным объектом типа Want, иначе возвращает ссылку на существующий временный объект. Для определения какой тип возвратить используется функция std::conditional_t.
Пример:
struct Obj { Obj(int value) : value_(value){} void use() { std::cout << value_ << std::endl; } private: int value_; }; template<std::convertible_to<Obj> ...T> void use(T&&...t){ (local_copy<Obj, T>(t).use(), ...); } int main(){ Obj a1(1), a2(10), a3(100); use(a1, a2, a3); }
Иногда можно воспользоваться списком инициализации(initializer_list):
void use(std::initializer_list<Obj> args){ for (const Obj &i : args){ i.use(); } }
Но у такого способа есть два недостатка:
— Нужно писать дополнительные скобки
use({a1, a2, a3});
— В функции можно использовать только константные методы.
11. Массивы указателей на функцию.
Рассмотрим следующий фрагмент кода, где по значению index вызывается определенная функция:
int f1(){ return 0; } int f2(){ return 1; } int f3(){ return 2; } int main(){ int index; std::cin >> index; switch (index) { case 0: std::cout << f1(); break; case 1: std::cout << f2(); break; case 2: std::cout << f3(); break; default: break; } }
Данный фрагмент имеет ряд недостатков. Он громоздкий, а при добавлении новых функций нужно привязывать их к индексу в switch.
Решением такой проблемы может быть массив указателей на функции:
std::array<int(*)(), 3> array{&f1, &f2, &f3}; std::cout << std::format("{}", array[index]());
Но тут есть следующие проблемы: нужно указывать тип массива и его размер. Если, к примеру, index будет вне диапазона, то программа завершится аварийно. При этом исключение не будет выброшено. Хотелось бы писать код наподобие этого:
std::tuple func(&f1, &f2, &f3); std::cout << std::format("{}", set_index(func, index));
set_index будет вызывать из кортежа функцию по индексу, а если индекс некорректен — генерировать исключение.
Для такого фрагмента необходимо решить проблему преобразования константы времени выполнения в константу времени компиляции без оператора switch. Следующий пример показывает, как можно решить эту проблему:
//Получает результат применения F к константу значению I template<std::size_t I, typename F> inline constexpr auto integral_constant(F f) { return static_cast<F>(f)(std::integral_constant<std::size_t, I>{}); } //Выбирает нужную функцию на этапе выполнения template<std::size_t N, typename F> inline constexpr auto invoke(int n, F &&f){ constexpr auto invoke_array = []<std::size_t...I>(std::index_sequence<I...>) { return std::array{ integral_constant<I, F&&>... }; }(std::make_index_sequence<N>{}); return invoke_array.at(n)(std::forward<F>(f)); } template<typename T> requires requires { { std::tuple_size_v<T>+0 } -> std::same_as<std::size_t>; } auto set_index(T &t, std::size_t n){ return invoke<std::tuple_size_v<T>>(n, [&t](auto i) { return std::get<i>(t)(); }); }
В итоге данный код будет работать и вызывать нужную функцию во время выполнения:
int main(){ int index = 0; std::cin >> index; std::tuple func(&f1, &f2, &f3); std::cout << std::format("{}", set_index(func, index)); }
Вывод
Вариативные шаблоны в C++ предоставляют гибкие механизмы для работы с произвольным количеством аргументов, а рассмотренные идиомы — от рекурсии и свёрток до мультилямбд и массивов указателей — позволяют эффективно обрабатывать пакеты параметров как на этапе компиляции, так и во время выполнения. Выбор конкретного подхода зависит от задачи, однако современные стандарты C++ (особенно начиная с C++17) значительно упрощают многие из этих приёмов с помощью свёрток, лямбда-выражений и новых контекстов расширения. В итоге владение этими идиомами позволяет писать более компактный, безопасный и производительный код.
Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.

