Skip to content
C++
Language
since C++11
Advanced

Master Variadic Templates to Write Truly Generic Code

Expand parameter packs, recurse over type lists, and build generic utilities like type-safe loggers and forwarding wrappers using C++11 variadic templates.

By the end of this page, you will understand what a parameter pack is and why it exists, how to expand packs recursively and with C++17 fold expressions, and how to apply these tools to real-world patterns: perfect forwarding, aggregation, and per-element transformation.

What and Why

Before C++11, writing a function that accepted an arbitrary number of arguments of different types required either C-style variadic arguments (va_args) β€” type-unsafe and fragile β€” or a combinatorial explosion of overloads: func(T1), func(T1, T2), func(T1, T2, T3), and so on.

Variadic templates eliminate both problems. A parameter pack is a compile-time placeholder for zero or more template arguments. Pack expansion lets you apply an operation to every element of that pack, all resolved at compile time with full type safety and zero runtime overhead.

The mental model that helps most: a parameter pack is not a container. It is a syntactic list of types or values that you explode into concrete code at marked expansion points. The compiler generates a distinct version of your template for each unique set of argument types β€” the pack is a shorthand for that process.

Step by Step

Declaring and Inspecting a Pack

The ... to the left of a name in a declaration means "this is a pack." The ... to the right of a name means "expand this pack here."

cpp
#include <cstddef>
#include <iostream>

template <typename... Ts>   // Ts is a type parameter pack
void show(Ts... args) {     // args is a function parameter pack matching Ts
    std::cout << sizeof...(args) << " argument(s)\n";
}

int main() {
    show();                    // 0 argument(s)
    show(1, 2.0, "three");    // 3 argument(s)
}

sizeof...(pack) is a compile-time constant expression. It works on both type packs and value packs.

Recursive Expansion β€” The C++11 Idiom

The original way to process each element is recursive template instantiation: peel off the first argument, do something with it, then recurse on the rest.

cpp
#include <iostream>

// Base case: nothing left to print
void print() {}

// Recursive case: handle one element, then recurse
template <typename T, typename... Rest>
void print(T first, Rest... rest) {
    std::cout << first << '\n';
    print(rest...);   // expand the remaining pack
}

int main() {
    print(42, 3.14, "hello");
    // prints: 42  /  3.14  /  hello
}

The call chain is entirely determined at compile time. When the pack empties, the zero-argument base case is selected. No runtime recursion, no overhead.

Fold Expressions β€” The C++17 Upgrade

C++17 introduced fold expressions, which collapse the recursive pattern into a single expression. There are four forms; the most common two:

FormEvaluation
(... op pack)Unary left: ((p0 op p1) op p2) ...
(pack op ...)Unary right: (p0 op (p1 op p2)) ...
cpp
#include <iostream>

template <typename... Ts>
auto sum(Ts... args) {
    return (... + args);  // unary left fold over +
}

template <typename... Ts>
void print_all(Ts... args) {
    // Binary left fold: feed each arg into cout
    ((std::cout << args << ' '), ...);
    std::cout << '\n';
}

int main() {
    std::cout << sum(1, 2, 3, 4) << '\n';  // 10
    print_all(42, 3.14, "hello");           // 42 3.14 hello
}

The comma-fold (expr, ...) guarantees left-to-right evaluation order in C++17 and later.

Common Patterns

Perfect Forwarding a Pack

This is the single most important variadic pattern in production code. It lets you relay any number of arguments to another function while preserving their value categories:

cpp
#include <memory>

template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

struct Point { int x, y; };

int main() {
    auto p = make<Point>(3, 4);
}

std::forward<Args>(args)... expands both packs β€” Args and args β€” in lockstep. This is the internal mechanism behind std::make_unique, std::make_shared, and every emplace method in the standard library.

Aggregating Over a Pack

Fold expressions make "reduce" operations trivial:

cpp
#include <iostream>

template <typename... Ts>
bool all_positive(Ts... args) {
    return (... && (args > 0));
}

template <typename... Ts>
auto product(Ts... args) {
    return (... * args);
}

int main() {
    std::cout << all_positive(1, 2, 3)    << '\n';  // 1
    std::cout << all_positive(1, -2, 3)   << '\n';  // 0
    std::cout << product(2, 3, 4)         << '\n';  // 24
}

Applying a Callable to Each Element

The comma-fold pattern is the idiomatic "for-each over a pack":

cpp
#include <iostream>

template <typename F, typename... Ts>
void for_each_arg(F&& f, Ts&&... args) {
    (f(std::forward<Ts>(args)), ...);
}

int main() {
    for_each_arg(
        [](auto x) { std::cout << x << '\n'; },
        10, 3.14, "hello"
    );
}

What Can Go Wrong

Missing the Base Case in Recursive Templates

cpp
// WRONG: when rest is empty, print(rest...) matches no overload
template <typename T, typename... Rest>
void bad_print(T first, Rest... rest) {
    std::cout << first;
    bad_print(rest...);  // compile error when rest is empty
}

The fix in C++11 is a zero-argument overload. In C++17, if constexpr is cleaner and eliminates the separate overload entirely:

cpp
template <typename T, typename... Rest>
void good_print(T first, Rest... rest) {
    std::cout << first << '\n';
    if constexpr (sizeof...(rest) > 0) {
        good_print(rest...);  // only instantiated when rest is non-empty
    }
}

Forgetting std::forward on a Forwarding Pack

cpp
template <typename... Args>
void wrong(Args&&... args) {
    target(args...);                          // always passes lvalues
}

template <typename... Args>
void right(Args&&... args) {
    target(std::forward<Args>(args)...);      // preserves value category
}

Without std::forward, every argument is passed as an lvalue reference regardless of what the caller provided. Move-only types will fail to compile; moveable types will be copied unnecessarily.

Expanding Outside an Expansion Context

A pack can only be expanded in specific syntactic positions: function argument lists, initializer lists, base class specifiers, and a handful of others. Assigning a pack directly to a scalar variable is a compile error. When you need to "do something" to each element for its side effects in C++11, the initializer-list trick works:

cpp
template <typename... Ts>
void process(Ts&&... args) {
    // C++11: force expansion via brace-init
    int unused[] = { 0, (do_something(std::forward<Ts>(args)), 0)... };
    (void)unused;
}

In C++17, replace this with a comma fold.

Quick Reference

SyntaxMeaning
typename... TsDeclare a type parameter pack
Ts... argsDeclare a function parameter pack
sizeof...(Ts)Count of types in pack (compile-time size_t)
f(args...)Expand pack as function arguments
(... op pack)Unary left fold (C++17)
(pack op ...)Unary right fold (C++17)
(init op ... op pack)Binary left fold (C++17)
std::forward<Ts>(args)...Expand both packs in lockstep
if constexpr (sizeof...(rest) > 0)Guard recursive call against empty pack (C++17)

Standard requirements: parameter packs and recursive expansion β€” C++11. Fold expressions and if constexpr β€” C++17.

What's Next

  • Advanced Templates β€” SFINAE, explicit specialization, and argument deduction rules that interact directly with parameter packs.
  • Concepts (Advanced) β€” Constrain variadic packs with requires clauses to catch misuse at the call site.
  • constexpr if (Advanced) β€” Eliminate recursive base-case overloads with compile-time branching.
  • Lambda (Advanced) β€” Capture parameter packs in lambdas for deferred or parallel expansion.
  • Expression Templates β€” A canonical idiom that pushes template metaprogramming β€” and variadic techniques β€” to their limits.