C++ Variadic Templates & C++26 Pack Indexing: Parameter Packs, Fold Expressions & Type Lists

C++ Variadic Templates & C++26 Pack Indexing: Parameter Packs, Fold Expressions & Type Lists
Table of Contents
- Parameter Pack Fundamentals
- Pack Expansion: Where the ... Goes
- sizeof...: Pack Size at Compile Time
- Recursive Variadic Processing (Pre-C++17)
- Fold Expressions (C++17): All Four Forms
- Practical Fold Patterns
- C++26 Pack Indexing: Direct Access by Index
- Compile-Time Type Lists with Variadic Templates
- std::tuple and std::apply: Storing Packs
- Frequently Asked Questions
- Key Takeaway
Parameter Pack Fundamentals
A parameter pack is a sequence of zero or more types (or values) bundled under a single name:
// Type parameter pack (typename...):
template<typename... Types>
struct TypeList {}; // Can hold 0 to N types
TypeList<> // 0 types
TypeList<int> // 1 type
TypeList<int, double, std::string> // 3 types
// Non-type parameter pack (auto...):
template<auto... Values>
struct ValueList {};
ValueList<1, 2, 3> // three compile-time ints
ValueList<true, 42, 'A'> // mixed types (C++17 auto)
// Function parameter pack:
template<typename... Args>
void log(Args... args) {
// args is a pack of values
}
log(1, "hello", 3.14); // Args = {int, const char*, double}
// args = {1, "hello", 3.14}Pack Expansion: Where the ... Goes
The ... after an expression expands the pack, applying the pattern to each element:
template<typename... Args>
void demonstrate(Args... args) {
// Expansion contexts:
// 1. Function call arguments:
forward_function(args...); // f(arg0, arg1, arg2)
// 2. Initializer list:
std::vector<int> v = {args...}; // {arg0, arg1, arg2}
// 3. sizeof... operator:
constexpr size_t n = sizeof...(args); // Number of arguments
// 4. Template arguments:
auto t = std::make_tuple(args...); // tuple<T0, T1, T2>(arg0, arg1, arg2)
// 5. Array initializer:
int arr[] = {static_cast<int>(args)...}; // Each arg converted to int
// 6. Base class specification:
// (in a class template context)
}
// Expanding with a pattern — applies the pattern to EACH element:
template<typename... Args>
auto make_vector_of_strings(Args&&... args) {
// For each arg: std::to_string(arg) — then collect all into vector
return std::vector<std::string>{std::to_string(args)...};
// ↑ pattern ↑ ↑ expand ↑
}
auto v = make_vector_of_strings(1, 2.5, 3L); // {"1", "2.500000", "3"}sizeof...: Pack Size at Compile Time
template<typename... Args>
constexpr size_t count_args(Args...) {
return sizeof...(Args); // Compile-time count
}
static_assert(count_args(1, 2, 3) == 3);
static_assert(count_args() == 0);
static_assert(count_args("hello") == 1);
// Use in if constexpr to stop recursion:
template<typename First, typename... Rest>
void process(First first, Rest... rest) {
handle(first);
if constexpr (sizeof...(rest) > 0) {
process(rest...); // Recurse only if more args exist
}
}Recursive Variadic Processing (Pre-C++17)
Before fold expressions, recursion was the only way to iterate a pack:
// Base case — no arguments:
void print() { std::cout << '\n'; }
// Recursive case — peel off first argument:
template<typename First, typename... Rest>
void print(First first, Rest... rest) {
std::cout << first << ' ';
print(rest...); // Recursion with one fewer arg
}
// Usage:
print(1, "hello", 3.14, true);
// Expands to:
// print(1, "hello", 3.14, true) → cout << 1; print("hello", 3.14, true)
// print("hello", 3.14, true) → cout << hello; print(3.14, true)
// print(3.14, true) → cout << 3.14; print(true)
// print(true) → cout << true; print()
// print() → cout << \n
// Downside: N overloads generated, longer compile time
// C++17 fix: fold expressions (see below)Fold Expressions (C++17): All Four Forms
Fold expressions collapse a pack with a binary operator — no recursion needed:
// Unary LEFT fold: (... op pack) = ((arg0 op arg1) op arg2) op arg3
template<typename... Args>
auto left_sum(Args... args) { return (... + args); }
// (((1 + 2) + 3) + 4) = 10
// Unary RIGHT fold: (pack op ...) = arg0 op (arg1 op (arg2 op arg3))
template<typename... Args>
auto right_sum(Args... args) { return (args + ...); }
// 1 + (2 + (3 + 4)) = 10 (same for addition, differs for subtraction!)
// Binary LEFT fold: (init op ... op pack) = ((init op arg0) op arg1) op arg2
template<typename... Args>
auto sum_from_100(Args... args) { return (100 + ... + args); }
// ((100 + 1) + 2) = 103
// Binary RIGHT fold: (pack op ... op init) = arg0 op (arg1 op (arg2 op init))
template<typename... Strings>
std::string join(Strings... strs) { return (strs + ...); }
// "a" + ("b" + ("c" + "")) — but wrong order, use left fold for stringsPractical Fold Patterns
#include <iostream>
#include <string>
#include <type_traits>
// === Print all with separator ===
template<typename Sep, typename... Args>
void print_sep(Sep sep, Args&&... args) {
bool first = true;
((std::cout << (first ? "" : sep) << args, first = false), ...);
// Comma operator: runs both expressions, left to right
std::cout << '\n';
}
print_sep(", ", 1, "hello", 3.14); // "1, hello, 3.14"
// === Call function on each arg ===
template<typename F, typename... Args>
void for_each(F func, Args&&... args) {
(func(std::forward<Args>(args)), ...); // Apply func to each
}
for_each([](auto v){ std::cout << v << '\n'; }, 1, 2.5, "hi");
// === All/Any predicates ===
template<typename... Args>
bool all_positive(Args... args) {
return (... && (args > 0)); // true only if ALL are positive
}
template<typename... Args>
bool any_positive(Args... args) {
return (... || (args > 0)); // true if ANY is positive
}
// === Collect into vector ===
template<typename T, typename... Args>
std::vector<T> make_vector(Args&&... args) {
return {static_cast<T>(std::forward<Args>(args))...};
}
auto v = make_vector<double>(1, 2.5f, 3L); // {1.0, 2.5, 3.0}
// === type checking fold ===
template<typename T, typename... Candidates>
constexpr bool is_one_of = (std::is_same_v<T, Candidates> || ...);
static_assert(is_one_of<int, char, int, double>); // true
static_assert(!is_one_of<float, char, int, double>); // falseC++26 Pack Indexing: Direct Access by Index
C++26 introduces ...[N] syntax for direct element access — no more recursive unwrapping:
// C++26 value pack indexing:
template<typename... Args>
void get_second(Args... args) {
auto second = args...[1]; // Access index 1 — second argument
std::println("Second: {}", second);
}
get_second(10, 20, 30); // "Second: 20"
// C++26 type pack indexing:
template<typename... Types>
using FirstType = Types...[0]; // The first type
using ThirdType = Types...[2]; // The third type
// Practical: swap Nth element
template<size_t I, typename... Args>
auto get_nth(Args... args) {
return args...[I]; // Compile-time index
}
auto x = get_nth<0>(10, 20, 30); // 10
auto y = get_nth<2>(10, 20, 30); // 30
// get_nth<5>(10, 20, 30); // COMPILE ERROR: index out of bounds
// Pre-C++26 equivalent (much more complex):
template<size_t I, typename... Args>
auto get_nth_old(Args&&... args) {
return std::get<I>(std::forward_as_tuple(args...));
}std::tuple and std::apply: Storing Packs
std::tuple is the standard way to store a variadic pack for later use:
#include <tuple>
#include <functional>
// Store args for later invocation:
template<typename F, typename... Args>
auto defer(F func, Args... args) {
auto bound = std::make_tuple(std::move(args)...); // Capture pack in tuple
return [func, bound = std::move(bound)]() mutable {
std::apply(func, std::move(bound)); // Unpack tuple as args to func
};
}
auto task = defer([](int a, int b){ std::println("Sum: {}", a + b); }, 3, 4);
task(); // "Sum: 7" — args unpacked from tuple when called
// std::make_from_tuple: construct from tuple
struct Point { int x, y, z; };
auto p = std::make_from_tuple<Point>(std::make_tuple(1, 2, 3));
// tuple_cat: concatenate tuples
auto t1 = std::make_tuple(1, 2);
auto t2 = std::make_tuple(3.0, "hi");
auto combined = std::tuple_cat(t1, t2); // tuple<int, int, double, const char*>
// std::apply with lambda:
std::apply([](auto... args){
(std::println("{}", args), ...);
}, combined); // Prints 1, 2, 3.0, "hi" each on a lineFrequently Asked Questions
Can a variadic template have a mix of type and non-type packs?
Not directly — a single ... introduces either a type pack (typename...) or a non-type pack (auto... or a specific type + ...). However, you can have both in one template: template<typename... Types, auto... Values> — though the two packs must be deducible separately.
Is there a compile-time performance cost to large packs?
Yes — each unique combination of template arguments generates a separate instantiation. A function called with 10 different argument count variations generates 10 specializations. Very large packs (>50 elements) can significantly slow compilation. For high-count cases, prefer std::initializer_list<T> (if all types are the same), or encode the pack in a std::tuple and pass the tuple instead.
What is the difference between expanding pack with ... before vs after?
The position of ... specifies what gets expanded: Args... expands the types, args... expands the values, and (pattern(args))... expands the pattern applied to each value. Specifically: f(args...) passes all args to one call, while (f(args), ...) calls f once for each arg (fold over comma operator).
Key Takeaway
Variadic templates are the backbone of C++'s most powerful generic utilities: printf-style formatting, std::tuple, std::variant, std::make_unique, and event systems all use them. C++17 fold expressions eliminated the need for recursive base cases for most operations. C++26 pack indexing closes the last gap — you can now randomly access pack elements by index as naturally as array access, making complex generic code readable and maintainable.
Read next: SIMD & Low-Level Optimization →
Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.
