|

C++ Variadic Templates: Parameter Packs & Fold Expressions Guide

What Are Variadic Templates?

Variadic templates let a single function template or class template accept any number of template arguments. Before C++11, you either wrote a fixed number of overloads or resorted to C-style va_list, which throws away all type information. Variadic templates solve both problems: they handle any arity while keeping full type safety at compile time.

The standard library relies heavily on variadic templates. std::tuple, std::variant, std::make_shared, std::format, and almost every factory function uses them. If you have been wondering how std::make_unique<Widget>(arg1, arg2, arg3) forwards an arbitrary set of constructor arguments, the answer is variadic templates combined with perfect forwarding.

Parameter Packs: The … Syntax

A parameter pack is a template parameter that captures zero or more arguments. The ellipsis (...) is the pack operator. There are two kinds:

// Template parameter pack (types)
template<typename... Ts>
void foo(Ts... args);   // Function parameter pack (values)

// Non-type parameter pack
template<int... Ns>
struct IntSeq {};

Ts is a template parameter pack — it captures a list of types. args is a function parameter pack — it captures the corresponding values. When you call foo(1, 3.14, "hello"), the compiler deduces Ts = {int, double, const char*} and args = {1, 3.14, "hello"}.

You cannot use a parameter pack directly like a regular variable. You must expand it using ... on the right side. Every pattern to the left of ... is applied to each element.

Recursive Unpacking: The Classic Approach

Before fold expressions (C++17), the standard technique was recursive template instantiation. You peel off one argument at a time and recurse on the rest:

#include <iostream>

// Base case: no arguments left
void print() {
    std::cout << "\n";
}

// Recursive case: peel off first, recurse on rest
template<typename T, typename... Rest>
void print(T first, Rest... rest) {
    std::cout << first;
    if constexpr (sizeof...(rest) > 0) {
        std::cout << ", ";
    }
    print(rest...);  // expand the remaining pack
}

int main() {
    print(1, 2.5, "hello", 'A');
    // Output: 1, 2.5, hello, A
}

Each call peels off first and passes the remaining pack rest... to the next instantiation. The compiler generates a chain of functions: print(int, double, const char*, char)print(double, const char*, char)print(const char*, char)print(char)print(). The base case stops the recursion.

This pattern works perfectly but produces many template instantiations. For large packs, compile times increase. That’s one reason C++17 introduced fold expressions.

Pack Expansion Patterns

Pack expansion is more flexible than just writing args.... You can apply a pattern to each element before expanding:

template<typename... Ts>
void wrapper(Ts... args) {
    // Pattern: &args — take address of each
    auto addresses = std::make_tuple(&args...);

    // Pattern: f(args) — call f on each
    // (void(f(args)), ...);  // C++17 fold over comma

    // Pattern: static_cast<double>(args) — cast each
    auto doubles = std::make_tuple(static_cast<double>(args)...);
}

// Expanding in a function call
template<typename... Ts>
auto make_vec(Ts... args) {
    return std::vector<std::common_type_t<Ts...>>{args...};
}

int main() {
    auto v = make_vec(1, 2, 3, 4, 5);
    // v is vector<int>{1, 2, 3, 4, 5}
}

The rule is simple: everything to the left of ... is the pattern. The compiler repeats it for each pack element, separated by commas. So static_cast<double>(args)... with args = {1, 2, 3} becomes static_cast<double>(1), static_cast<double>(2), static_cast<double>(3).

Fold Expressions (C++17)

Fold expressions collapse a parameter pack with a binary operator in a single expression, no recursion needed:

// Unary right fold: (pack op ...)
template<typename... Ts>
auto sum(Ts... args) {
    return (args + ...);  // ((a1 + a2) + a3) + ...
}

// Unary left fold: (... op pack)
template<typename... Ts>
auto sum_left(Ts... args) {
    return (... + args);  // same for + (associativity doesn't matter)
}

// Binary right fold with init: (pack op ... op init)
template<typename... Ts>
auto sum_init(Ts... args) {
    return (args + ... + 0);  // starts from 0 when pack is empty
}

// Binary left fold with init: (init op ... op pack)
template<typename... Ts>
auto sum_init2(Ts... args) {
    return (0 + ... + args);
}

int main() {
    std::cout << sum(1, 2, 3, 4, 5) << "\n";  // 15
}

There are four fold forms. The two most common are unary left fold (... op pack) and binary left fold (init op ... op pack). The binary forms are essential when the pack might be empty — a unary fold on an empty pack is ill-formed for most operators (except &&, ||, and ,).

Fold expressions work with any binary operator, including the comma operator. This lets you call a function on each element:

template<typename... Ts>
void print_all(Ts... args) {
    ((std::cout << args << " "), ...);
    std::cout << "\n";
}

// Check if all arguments are positive
template<typename... Ts>
bool all_positive(Ts... args) {
    return ((args > 0) && ...);
}

// Check if any argument equals target
template<typename T, typename... Ts>
bool any_of(T target, Ts... args) {
    return ((args == target) || ...);
}

sizeof… — Counting Pack Elements

sizeof...(pack) returns the number of elements in a parameter pack as a compile-time constant. It does not return byte sizes like regular sizeof:

template<typename... Ts>
void info(Ts... args) {
    constexpr std::size_t count = sizeof...(Ts);  // or sizeof...(args)
    std::cout << "Received " << count << " arguments\n";

    // Use in static_assert
    static_assert(sizeof...(Ts) > 0, "Need at least one argument");

    // Use in if constexpr
    if constexpr (sizeof...(Ts) == 1) {
        std::cout << "Single argument path\n";
    } else {
        std::cout << "Multi argument path\n";
    }
}

This is particularly useful for selecting different code paths at compile time based on how many arguments were passed. Combined with if constexpr, you can replace the recursive base-case pattern entirely.

Real-World Examples

Let’s build practical utilities using variadic templates:

#include <iostream>
#include <string>
#include <sstream>

// Type-safe printf replacement
template<typename... Args>
std::string format(const std::string& fmt, Args... args) {
    std::ostringstream oss;
    auto expand = [&oss](const auto& arg) {
        oss << arg;
    };
    
    std::size_t pos = 0;
    std::size_t arg_idx = 0;
    auto values = std::make_tuple(args...);
    
    // Simple {} replacement
    std::string result = fmt;
    ((result.find("{}") != std::string::npos ? 
        (result.replace(result.find("{}"), 2, 
            (std::ostringstream{} << args).str()), 0) : 0), ...);
    return result;
}

// Constrained minimum of N values
template<typename T>
T minimum(T val) { return val; }

template<typename T, typename... Rest>
T minimum(T first, Rest... rest) {
    T rest_min = minimum(rest...);
    return first < rest_min ? first : rest_min;
}

// Check if a value is in a set (like Python's "in")
template<typename T, typename... Options>
bool is_one_of(T value, Options... options) {
    return ((value == options) || ...);
}

int main() {
    std::cout << minimum(5, 3, 8, 1, 4) << "\n";  // 1

    if (is_one_of(3, 1, 2, 3, 4, 5)) {
        std::cout << "Found!\n";
    }

    int status = 404;
    if (is_one_of(status, 200, 201, 204)) {
        std::cout << "Success\n";
    } else {
        std::cout << "Not a success code\n";
    }
}

Variadic Class Templates

Variadic templates work with class templates too. This is how std::tuple is implemented:

// Simplified tuple implementation
template<typename... Ts>
struct Tuple {};

// Specialization: peel off first type
template<typename Head, typename... Tail>
struct Tuple<Head, Tail...> {
    Head value;
    Tuple<Tail...> rest;

    Tuple(Head h, Tail... t) : value(h), rest(t...) {}
};

// Get element by index
template<std::size_t I, typename T>
struct TupleElement;

template<typename Head, typename... Tail>
struct TupleElement<0, Tuple<Head, Tail...>> {
    using type = Head;
    static Head& get(Tuple<Head, Tail...>& t) { return t.value; }
};

template<std::size_t I, typename Head, typename... Tail>
struct TupleElement<I, Tuple<Head, Tail...>> {
    using type = typename TupleElement<I-1, Tuple<Tail...>>::type;
    static auto& get(Tuple<Head, Tail...>& t) {
        return TupleElement<I-1, Tuple<Tail...>>::get(t.rest);
    }
};

template<std::size_t I, typename... Ts>
auto& get(Tuple<Ts...>& t) {
    return TupleElement<I, Tuple<Ts...>>::get(t);
}

int main() {
    Tuple<int, double, std::string> t(42, 3.14, "hello");
    std::cout << get<0>(t) << "\n";  // 42
    std::cout << get<1>(t) << "\n";  // 3.14
    std::cout << get<2>(t) << "\n";  // hello
}

The pattern is identical to function recursion: specialize for one element plus the rest. The base case is the empty pack specialization Tuple<>.

Perfect Forwarding with Packs

The most important real-world use of variadic templates is perfect forwarding — passing arguments through a wrapper without losing their value category (lvalue vs rvalue). This is how std::make_unique and emplace_back work:

#include <memory>
#include <utility>

// Our own make_unique
template<typename T, typename... Args>
std::unique_ptr<T> my_make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

// Factory pattern with perfect forwarding
template<typename T, typename... Args>
T create(Args&&... args) {
    std::cout << "Creating with " << sizeof...(Args) << " args\n";
    return T(std::forward<Args>(args)...);
}

struct Widget {
    std::string name;
    int value;
    Widget(std::string n, int v) : name(std::move(n)), value(v) {
        std::cout << "Widget(" << name << ", " << value << ")\n";
    }
};

int main() {
    auto w = create<Widget>("test", 42);
    auto p = my_make_unique<Widget>("heap", 99);
}

The pattern Args&&... captures forwarding references, and std::forward<Args>(args)... expands to forward each argument individually. This preserves move semantics — if the caller passes an rvalue, the inner constructor receives an rvalue. No unnecessary copies.

Common Mistakes

Forgetting the base case. Without a base case for recursive unpacking, you get an infinite template instantiation that eventually hits the compiler’s recursion limit. Always provide a zero-argument or single-argument overload.

// WRONG: no base case
template<typename T, typename... Rest>
void process(T first, Rest... rest) {
    handle(first);
    process(rest...);  // when rest is empty, no matching overload!
}

// FIX: add base case
void process() {}  // base case

template<typename T, typename... Rest>
void process(T first, Rest... rest) {
    handle(first);
    process(rest...);
}

Expanding in the wrong context. Pack expansion only works in specific contexts: function arguments, template arguments, initializer lists, base class lists, and a few others. You cannot expand a pack in an arbitrary statement.

Mixing up sizeof... and sizeof. sizeof...(pack) counts elements. sizeof(pack) would try to get the byte size of the pack itself, which is not what you want.

Empty pack with unary fold. A unary fold over an empty pack is ill-formed for most operators. Use a binary fold with an init value when the pack might be empty: (0 + ... + args) instead of (... + args).

Summary

Variadic templates are C++’s mechanism for writing functions and classes that accept any number of type-safe arguments. Parameter packs capture arguments with ..., pack expansion applies patterns to each element, fold expressions (C++17) collapse packs with operators, and sizeof... counts pack elements at compile time. The most critical real-world application is perfect forwarding, which powers factory functions throughout the standard library. In the next lesson, you’ll learn about type traits and SFINAE — the compile-time type inspection tools that pair naturally with variadic templates.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *