Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 6 of 8

std::format("{} is {}", name, age) takes three arguments. std::format("{}", x) takes two. One template handles both, and any other count.

That is a variadic template, and the mechanism is a parameter pack.

The syntax

template <typename... Args>
void print_all(const Args&... args);

Three uses of ..., each meaning something different:

  • typename... Args — declares Args as a pack of types, zero or more.
  • const Args&... args — declares args as a pack of parameters, one per type.
  • args... (at a call site) — expands the pack back into a comma-separated list.

The first two pack; the third unpacks.

Folding over the pack

#include <iostream>
#include <string>

// Args is a parameter pack: zero or more types.
template <typename... Args>
void print_all(const Args&... args) {
    // Fold expression (C++17): expands to arg1 << " " << arg2 << " " << ...
    ((std::cout << args << " "), ...);
    std::cout << "\n";
}

// sizeof...(pack) gives the number of elements, at compile time.
template <typename... Args>
void report(const Args&... args) {
    std::cout << "got " << sizeof...(args) << " arguments: ";
    print_all(args...);
}

int main() {
    print_all(1, 2, 3);
    print_all("mixed", 42, 3.5, 'x');
    print_all();

    report(1, 2, 3);
    report(std::string("one"));
    return 0;
}
1 2 3 
mixed 42 3.5 x 

got 3 arguments: 1 2 3 
got 1 arguments: one

Note the blank line: print_all() with an empty pack is valid, and prints only the newline.

sizeof...(args) is the pack's length, known at compile time — not to be confused with sizeof(x), which is a size in bytes.

Fold expressions

((std::cout << args << " "), ...) is a fold expression, added in C++17. It applies an operator across the whole pack.

There are four forms, and the difference between them matters:

#include <iostream>
#include <string>

// The recursive form, which is how this was done before C++17.
// Base case: no arguments left.
void print_recursive() {
    std::cout << "\n";
}

// Peel one argument off the front, recurse on the rest.
template <typename First, typename... Rest>
void print_recursive(const First& first, const Rest&... rest) {
    std::cout << first;
    if constexpr (sizeof...(rest) > 0) {
        std::cout << ", ";
    }
    print_recursive(rest...);
}

// The four fold forms.
template <typename... Args>
auto sum_right(Args... args) { return (args + ...); }        // a + (b + (c + d))

template <typename... Args>
auto sum_left(Args... args) { return (... + args); }         // ((a + b) + c) + d

template <typename... Args>
auto sum_with_zero(Args... args) { return (args + ... + 0); } // works for an empty pack

template <typename... Args>
bool all_true(Args... args) { return (args && ...); }

int main() {
    print_recursive(1, 2, 3);
    print_recursive("a", 'b', 3.5);

    std::cout << sum_right(1, 2, 3, 4) << "\n";
    std::cout << sum_left(1, 2, 3, 4) << "\n";
    std::cout << sum_with_zero() << "\n";
    std::cout << std::boolalpha << all_true(true, true, false) << "\n";

    // Left and right differ for non-associative operations.
    std::cout << "right: " << (1.0 / (2.0 / 4.0)) << "\n";
    return 0;
}
1, 2, 3
a, b, 3.5
10
10
0
false
right: 2

| Form | Name | Expands to | |---|---|---| | (args + ...) | unary right fold | a + (b + (c + d)) | | (... + args) | unary left fold | ((a + b) + c) + d | | (args + ... + init) | binary right fold | a + (b + (c + init)) | | (init + ... + args) | binary left fold | ((init + a) + b) + c |

sum_right and sum_left both print 10 because addition is associative. For subtraction or division they differ, which the last line illustrates: 1/(2/4) is 2, while ((1/2)/4) is 0.125. Prefer the left fold, since it matches how the operator normally groups.

The binary forms exist for the empty pack. sum_right() with no arguments would not compile — there is nothing to add — while sum_with_zero() returns the initial value, which is why it prints 0.

Recursion, the older way

print_recursive above shows how this was done before fold expressions, and you will still meet it because folds cannot express everything.

The pattern: peel the first argument off, handle it, recurse on the rest. Termination is a non-template overload taking no arguments.

if constexpr (sizeof...(rest) > 0) is a compile-time if (C++17): the branch not taken is not compiled at all. That is what stops a comma being printed after the last element. An ordinary if would work here too, but if constexpr is essential when the untaken branch would not compile for some instantiation — which is common in variadic code.

Perfect forwarding, and what it is for

The most important use of parameter packs is passing arguments through to a constructor without losing anything:

#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

struct Widget {
    std::string name;
    int size;

    Widget(std::string n, int s) : name(std::move(n)), size(s) {
        std::cout << "Widget(" << name << ", " << size << ") constructed\n";
    }
};

// Perfect forwarding: take anything, pass it on unchanged.
template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

int main() {
    auto w = make<Widget>("gear", 5);
    std::cout << w->name << " " << w->size << "\n";

    // emplace_back is the same idea: it forwards to the element's constructor,
    // so the object is built in place rather than built and then moved.
    std::vector<Widget> widgets;
    widgets.reserve(2);
    widgets.emplace_back("bolt", 1);
    widgets.emplace_back("nut", 2);
    std::cout << widgets.size() << " widgets\n";
    return 0;
}
Widget(gear, 5) constructed
gear 5
Widget(bolt, 1) constructed
Widget(nut, 2) constructed
2 widgets

That make function is std::make_unique, near enough. emplace_back is the same mechanism applied to a container.

Two pieces do the work:

Args&&... args is a forwarding reference, not an rvalue reference. In a deduced context T&& binds to anything — lvalue or rvalue — and remembers which it was in T. The identical syntax means an rvalue reference only when the type is not deduced, which is a real source of confusion (lesson 9.9).

std::forward<Args>(args)... restores what the caller passed. Without it, everything inside the function is a named variable and therefore an lvalue, so an rvalue argument would be copied rather than moved.

Why forwarding is worth the syntax

Measured with a type that reports its own copies and moves:

#include <iostream>
#include <string>
#include <utility>

struct Tracker {
    std::string label;

    explicit Tracker(std::string l) : label(std::move(l)) {}
    Tracker(const Tracker& other) : label(other.label) {
        std::cout << "  copy of " << label << "\n";
    }
    Tracker(Tracker&& other) noexcept : label(std::move(other.label)) {
        std::cout << "  move of " << label << "\n";
    }
};

void consume(Tracker t) { std::cout << "  consumed " << t.label << "\n"; }

// Without forwarding: everything arrives as an lvalue inside, so it copies.
template <typename T>
void relay_by_value(T arg) { consume(arg); }

// With forwarding: T&& plus std::forward preserves lvalue-ness or rvalue-ness.
template <typename T>
void relay_forward(T&& arg) { consume(std::forward<T>(arg)); }

int main() {
    Tracker a("named");

    std::cout << "relay_by_value(a):\n";
    relay_by_value(a);

    std::cout << "relay_forward(a):\n";
    relay_forward(a);

    std::cout << "relay_forward(temporary):\n";
    relay_forward(Tracker("temp"));
    return 0;
}
relay_by_value(a):
  copy of named
  copy of named
  consumed named
relay_forward(a):
  copy of named
  consumed named
relay_forward(temporary):
  move of temp
  consumed temp

Count them. Passing by value costs two copies — one into the relay, one into consume. Forwarding an lvalue costs one, the unavoidable one. Forwarding a temporary costs zero copies: it moves.

For a Tracker holding one string that is a small saving. For a std::vector of a million elements it is the difference between a memcpy and a pointer swap.

This is exactly why emplace_back(args...) can beat push_back(T(args...)): the latter builds a temporary and moves it, the former builds the element in place.

Variadic class templates

Packs work on classes too. std::tuple is the canonical example:

// template <typename... Types>
// class tuple;
//
// std::tuple<int, std::string, double> row{1, "name", 2.5};
//
// So is std::variant (lesson 13.10):
//
// template <typename... Types>
// class variant;
//
// std::variant<int, std::string> value = 42;

Writing one is harder than writing a variadic function, because storing a pack requires recursive inheritance or a recursive member. In practice you use std::tuple rather than writing your own.

Try it

Write a variadic log(level, args...) that prints the level then every argument separated by spaces. Add count_matching<T>(args...) returning how many arguments are of type T. Then write sum_all with a fold and make it work correctly with no arguments.

Checkpoint

  • typename... Args declares a parameter pack; args... expands one.
  • sizeof...(args) is the pack's length at compile time.
  • Fold expressions (C++17) apply an operator across a pack in one line.
  • Prefer the left fold (... + args); use the binary form to handle an empty pack.
  • The older recursive form still applies when a fold cannot express the logic.
  • if constexpr discards the untaken branch entirely, which variadic code often needs.
  • Args&&... in a deduced context is a forwarding reference, not an rvalue reference.
  • std::forward<Args>(args)... preserves lvalue/rvalue-ness — without it everything copies.
  • Perfect forwarding is what makes std::make_unique and emplace_back avoid copies.
  • std::tuple and std::variant are variadic class templates.

Questions about this lesson

Log in to ask a question.