Variadic Templates in C++: Parameter Packs, Pack Expansion, sizeof... and Fold Expressions

Key takeaways

Variadic templates accept any number of types and arguments with full type checking. Learn the pack syntax, the contexts where args... may be expanded, recursive processing versus C++17 folds and if constexpr, empty-pack rules, and forwarding packs.

What problem variadic templates solve

Before C++11, “a function that takes any number of arguments” meant C varargs:

#include <cstdarg>
void print(int count, ...) {
    va_list args;
    va_start(args, count);
    // The callee must *guess* each argument's type: va_arg(args, int), va_arg(args, double)...
    va_end(args);
}

Nothing tells the callee what was passed. Get the type wrong and you read garbage; pass a std::string and the behaviour is undefined (conditionally supported at best). The alternative was writing the same template overloaded for one, two, three… parameters, which is what pre-C++11 libraries such as Boost did with preprocessor macros.

Variadic templates let a template take a pack of zero or more template arguments, with every type preserved and checked at compile time:

template<typename... Args>
void print(const Args&... args) {
    ((std::cout << args << ' '), ...);   // C++17 fold over the comma operator
    std::cout << '\n';
}
print(1, "two", 3.5);   // 1 two 3.5

The standard library is built on them: std::make_unique, std::make_shared, emplace_back, std::tuple, std::variant, std::thread’s constructor and std::function’s signature handling all use packs.

The three pieces of syntax

template<typename... Args>     // template parameter pack: zero or more types
void func(Args... args) {      // function parameter pack: one parameter per type
    process(args...);          // pack expansion: args1, args2, ..., argsN
}

The ... goes to the left of a name when declaring a pack, and to the right of a pattern when expanding it. The pattern can be any expression containing the pack, and it is repeated once per element:

process(args...);                   // process(a, b, c)
process(std::forward<Args>(args)...); // process(std::forward<A>(a), std::forward<B>(b), ...)
process(f(args)...);                 // process(f(a), f(b), f(c))
process(f(args...));                 // process(f(a, b, c)): different!

The last two lines show why the position of ... matters: it expands the smallest enclosing pattern containing the pack.

Expansion is only allowed in specific contexts: function call arguments, template argument lists, braced initializer lists, base class lists, using declarations (C++17), lambda captures, and fold expressions. You can’t write args...; as a statement on its own, which is why pre-C++17 code used the “initializer-list trick” int dummy[] = {0, (f(args), 0)...}; to get a loop. Fold expressions replaced that.

sizeof...(args) (or sizeof...(Args)) is the number of elements, as a compile-time constant. It doesn’t evaluate anything.

Processing a pack recursively

The C++11 way to do something with each element is to peel off the first one and recurse on the rest:

void print() { std::cout << '\n'; }      // base case: must be declared first

template<typename T, typename... Args>
void print(const T& first, const Args&... rest) {
    std::cout << first << ' ';
    print(rest...);                       // recurse with one fewer argument
}
print(1, 2, 3);   // 1 2 3

Each call instantiates a new function, so print(1, 2, 3) creates print<int, int, int>, print<int, int> and print<int>, and the chain ends at the non-template print().

The base case trips people up in a way that looks like a compiler bug. If void print() is declared after the template, the recursive call print(rest...) with an empty pack still fails:

error: no matching function for call to 'print()'

Name lookup for a dependent call happens partly at the template’s definition and partly at instantiation, but the instantiation-time part is argument-dependent lookup, and a call with no arguments has no arguments to look through. So only what was visible at the definition counts. Declare the base case first, or avoid needing one with if constexpr:

template<typename T, typename... Args>
T minOf(T first, Args... rest) {
    if constexpr (sizeof...(rest) == 0) {
        return first;
    } else {
        T m = minOf(rest...);
        return first < m ? first : m;
    }
}
minOf(5, 2, 8, 1, 9);   // 1

if constexpr discards the recursive branch when the pack is empty, so minOf() with zero arguments is never instantiated. For this particular case std::min({5, 2, 8, 1, 9}) with an initializer list already exists, but it needs all arguments to have the same type.

Fold expressions (C++17)

When the per-element work is a single binary operator, a fold expression expresses it directly, with no recursion and no extra instantiations:

template<typename... Args> auto sum(Args... args)          { return (0 + ... + args); }
template<typename... Args> bool allPositive(Args... args)  { return ((args > 0) && ...); }
template<typename... Args> std::vector<int> makeVector(Args... args) {
    std::vector<int> r;
    r.reserve(sizeof...(args));
    (r.push_back(args), ...);   // comma fold: runs in order, left to right
    return r;
}
sum();                 // 0
sum(1, 2, 3, 4, 5);    // 15
allPositive();         // true
allPositive(1, -2);    // false

The four forms are (pack op ...), (... op pack), (pack op ... op init) and (init op ... op pack). Left folds group from the left (((a + b) + c)), right folds from the right. For + on integers it makes no difference; for -, for /, or for << on a stream it does, and (std::cout << ... << args) must be a left fold with the stream as the initial value.

Empty packs are where folds differ from recursion. A unary fold over an empty pack is an error for most operators:

error: fold of empty expansion over operator+

Only && (gives true), || (gives false) and the comma operator (gives void()) have defined results for an empty pack. Use a binary fold with an identity value, (0 + ... + args), when zero arguments should work. The fold expressions post goes through each operator in more detail.

A type-safe format function

Recursion is still the right tool when each step needs to inspect the input in between arguments. A minimal %-placeholder formatter:

void format_impl(std::ostringstream& oss, const char* fmt) { oss << fmt; }

template<typename T, typename... Args>
void format_impl(std::ostringstream& oss, const char* fmt,
                 const T& value, const Args&... args) {
    while (*fmt) {
        if (*fmt == '%') {
            if (fmt[1] == '%') { oss << '%'; fmt += 2; continue; }  // "%%" -> "%"
            oss << value;
            format_impl(oss, fmt + 1, args...);
            return;
        }
        oss << *fmt++;
    }
}

template<typename... Args>
std::string format_string(const char* fmt, const Args&... args) {
    std::ostringstream oss;
    format_impl(oss, fmt, args...);
    return oss.str();
}

format_string("Hello % from %", "World", "C++");  // Hello World from C++
format_string("% + % = %", 1, 2, 3);              // 1 + 2 = 3
format_string("100%% of %", "tests");            // 100% of tests
format_string("% and %", 1);                      // 1 and %   (too few arguments)

Every argument is printed with its own operator<<, so types can’t mismatch the way printf("%d", 3.5) can. The last line shows what this simple version doesn’t check: the count. Too few arguments leaves a placeholder; too many are silently ignored. C++20’s std::format checks the format string against the arguments at compile time, which is the production answer; this code is the idea behind it.

Two details worth copying: the pack is taken by const Args&... so strings aren’t copied at every level, and the function isn’t called sprintf, because an unqualified call to a name that also exists in <cstdio> risks overload surprises.

Variadic class templates

Packs work for class templates too, and recursive inheritance is the classic way to build a tuple:

template<typename... Types> class Tuple;
template<> class Tuple<> {};

template<typename Head, typename... Tail>
class Tuple<Head, Tail...> : private Tuple<Tail...> {
    Head value;
public:
    Tuple(Head h, Tail... t) : Tuple<Tail...>(t...), value(h) {}
    Head& head() { return value; }
    Tuple<Tail...>& tail() { return *this; }
};

template<std::size_t I, typename T> struct TupleElement;
template<typename Head, typename... Tail>
struct TupleElement<0, Tuple<Head, Tail...>> {
    static Head& get(Tuple<Head, Tail...>& t) { return t.head(); }
};
template<std::size_t I, typename Head, typename... Tail>
struct TupleElement<I, Tuple<Head, Tail...>> {
    static auto& get(Tuple<Head, Tail...>& t) {
        return TupleElement<I - 1, Tuple<Tail...>>::get(t.tail());
    }
};
template<std::size_t I, typename... Types>
auto& get(Tuple<Types...>& t) { return TupleElement<I, Tuple<Types...>>::get(t); }

Tuple<int, double, std::string> t(42, 3.14, "Hello");
get<0>(t); get<1>(t); get<2>(t);   // 42, 3.14, Hello

Tuple<int, double, std::string> derives from Tuple<double, std::string>, which derives from Tuple<std::string>, which derives from Tuple<>. The 0 specialization is chosen over the general one for index 0 because it’s more specialized. Real implementations avoid deep inheritance chains with index sequences, but this shows the mechanics. In practice, use std::tuple.

A pack can also be stored as the parameter list of another template, which is often all you need:

template<typename... Args>
class Event {
    std::vector<std::function<void(Args...)>> handlers_;
public:
    void subscribe(std::function<void(Args...)> h) { handlers_.push_back(std::move(h)); }
    template<typename... Ts>
    void emit(Ts&&... args) { for (auto& h : handlers_) h(args...); }
};

Event<int, std::string> userEvent;
userEvent.subscribe([](int id, const std::string& name) { std::cout << "User " << id << ": " << name << '\n'; });
userEvent.emit(123, "Alice");   // User 123: Alice

Note emit passes args... without forwarding. Forwarding with std::forward<Ts>(args)... inside the loop would let the first handler move from an argument and leave the rest with a moved-from object. That’s the “forward exactly once” rule from perfect forwarding.

Pitfalls and costs

Forwarding packs. A pack taken by value (Args... args) copies every argument. Factories and wrappers should take Args&&... args and expand std::forward<Args>(args)..., which is exactly how std::make_unique is written.

Compile time and error messages. Every distinct argument list is a new instantiation, and recursive processing adds one per element. Deep recursion can hit the compiler’s instantiation depth limit (GCC’s default -ftemplate-depth is 900), and errors inside a pack expansion are reported with the full chain of instantiations. Folds and if constexpr keep both under control.

Runtime cost. There’s none from the pack mechanism itself: after instantiation the calls are ordinary functions that the optimizer inlines like any other. Code size grows with the number of distinct instantiations, which matters for heavily used logging macros with many call-site signatures.

Pack-expansion errors. Using a pack without expanding it gives error: parameter packs not expanded with '...'. It usually means the ... is in the wrong place or missing, as in the f(args)... versus f(args...) example above.

The failure mode I run into most with hand-written variadic logging helpers is order-of-evaluation confusion from the pre-C++17 trick. Arguments of a function call are evaluated in an unspecified order, so log(f(args)...) doesn’t guarantee f runs left to right, while a comma fold and a braced initializer list do. When output appears scrambled, check whether the expansion is inside a function call’s argument list.