EthicsComputerCourses
Join training +91 90532 10052

Variadic Templates & Perfect Forwarding

C++ · Lesson 20 of 46 Intermediate 6 min read Updated

std::make_unique<Booking>("Isha", 23), vector.emplace_back(...) and std::println("{} {}", a, b) all accept any number of arguments of any type, and they pass those arguments on without making extra copies. They are built with two template techniques that every library author and many application developers use: variadic templates, which take a variable number of arguments, and perfect forwarding, which passes arguments on exactly as they came in.

This lesson builds on Templates & Concepts and Move Semantics. You will write variadic functions, use C++17 fold expressions and if constexpr, and build a small factory function that works like std::make_unique.

Parameter packs

A parameter pack is a template parameter that stands for zero or more types. You write it with ... after typename. The matching function parameter is also a pack, and sizeof... tells you how many items it holds.

C++
#include <print>

template <typename... Ts>
void count_args(const Ts&... values) {
    std::println("called with {} argument(s)", sizeof...(values));
}

int main() {
    count_args();
    count_args(42);
    count_args("Aarav", 91, 3.5, 'A');
}
Output
called with 0 argument(s)
called with 1 argument(s)
called with 4 argument(s)

For the last call the compiler deduces Ts as char[6], int, double, char and creates a matching version of the function. Everything is decided at compile time, so there is no run-time cost.

Fold expressions

To use the values in a pack you expand it with .... C++17 fold expressions combine all values with one operator. (values + ...) means v1 + (v2 + (v3 + ...)), and the comma operator lets you run a statement once for each value, from left to right.

C++
#include <concepts>
#include <cstddef>
#include <format>
#include <print>
#include <string>
#include <type_traits>

template <typename... Ts>
auto total(const Ts&... amounts) {
    return (amounts + ... + 0);                    // + 0 handles an empty pack
}

template <typename T>
std::string describe(const T& value) {
    if constexpr (std::is_integral_v<T>) {
        return std::format("integer {}", value);
    } else if constexpr (std::is_floating_point_v<T>) {
        return std::format("decimal {:.2f}", value);
    } else {
        return std::format("text \"{}\"", value);
    }
}

template <typename... Ts>
void describe_all(const Ts&... values) {
    std::size_t i = 0;
    (std::println("{}: {}", ++i, describe(values)), ...);    // one line per value
}

int main() {
    std::println("Bill: {}", total(1299, 450, 99));
    std::println("Empty bill: {}", total());
    describe_all(42, 3.14159, "Pune");
}
Output
Bill: 1848
Empty bill: 0
1: integer 42
2: decimal 3.14
3: text "Pune"

The four forms of fold are (pack op ...), (... op pack), and the same two with a starting value such as (pack op ... op 0). With an empty pack, only &&, || and the comma operator have a default result, so give + a starting value as shown.

if constexpr: choosing code while compiling

Look again at describe. if constexpr (C++17) evaluates its condition at compile time, and the branches that are not chosen are discarded: they are not compiled for that type. That matters here. For T = int, the line with {:.2f} would be an error if it were compiled, because .2f is not a valid format for integers. With a normal if, every branch must compile for every type; with if constexpr, only the chosen one must.

The conditions usually use type traits from <type_traits>, such as std::is_integral_v<T>, or concepts, as in if constexpr (std::integral<T>).

Forwarding references

In Move Semantics you saw that std::string&& binds only to rvalues (temporaries). Inside a template, T&& behaves differently when T is deduced from the argument:

  • Called with an lvalue (a named variable) of type std::string, T becomes std::string&, and the parameter is an lvalue reference.
  • Called with an rvalue, T becomes std::string, and the parameter is an rvalue reference.

Such a parameter is called a forwarding reference (you may also see the older name universal reference). It accepts anything and remembers, in T, whether the argument was an lvalue or an rvalue. auto&& works the same way.

std::forward: passing arguments on unchanged

There is a trap. Inside the function the parameter has a name, so it is an lvalue, even if the caller passed a temporary. Passing it on plainly always copies. std::forward<T>(value) (header <utility>) restores the original category: it casts to an rvalue only if an rvalue was passed in.

C++
#include <print>
#include <string>
#include <utility>

void store(const std::string& s) { std::println("copy {}", s); }
void store(std::string&& s)      { std::println("move {}", s); }

template <typename T>
void pass_plain(T&& value) {
    store(value);                         // value has a name: always an lvalue
}

template <typename T>
void pass_forward(T&& value) {
    store(std::forward<T>(value));        // keeps lvalue or rvalue as the caller gave it
}

int main() {
    std::string name = "Meera";
    pass_plain(name);
    pass_plain(std::string("Farhan"));
    pass_forward(name);
    pass_forward(std::string("Farhan"));
}
Output
copy Meera
copy Farhan
copy Meera
move Farhan

pass_plain copies the temporary "Farhan" even though nobody else could use it. pass_forward copies the named variable (the caller still needs it) and moves the temporary. This is perfect forwarding. Use std::forward only on forwarding references, and std::move on things you own and are finished with.

Putting it together: a factory function

Variadic templates and forwarding combine into the pattern behind std::make_unique and emplace_back: accept any arguments and forward them all to a constructor.

C++
#include <memory>
#include <print>
#include <string>
#include <utility>

struct Booking {
    std::string passenger;
    int seat;
    Booking(std::string name, int seat_no) : passenger(std::move(name)), seat(seat_no) {
        std::println("Booking for {}, seat {}", passenger, seat);
    }
};

template <typename T, typename... Args>
std::unique_ptr<T> make_logged(Args&&... args) {
    std::println("Creating an object from {} argument(s)", sizeof...(Args));
    return std::make_unique<T>(std::forward<Args>(args)...);
}

int main() {
    auto b = make_logged<Booking>("Isha", 23);
    std::println("Stored: {} in seat {}", b->passenger, b->seat);
}
Output
Creating an object from 2 argument(s)
Booking for Isha, seat 23
Stored: Isha in seat 23

std::forward<Args>(args)... expands to one std::forward call per argument, each with its own type. You can also constrain packs with concepts, for example template <std::integral... Ts> accepts only integers.

Common mistakes

  • Forgetting ... after a pack. Writing std::forward<Args>(args) without ... is a compile error: an unexpanded pack.
  • Using std::move on a forwarding reference. It moves from the caller's named variables too, leaving them empty. Use std::forward.
  • Thinking every T&& is a forwarding reference. Only a deduced T&& (or auto&&) is. std::vector<T>&& and std::string&& are plain rvalue references.
  • Using a value after forwarding it. After std::forward passed an rvalue on, the object may have been moved from.
  • Using a normal if with type-specific code. Every branch must compile for every type; use if constexpr.

Key points

  • typename... Ts declares a parameter pack; sizeof... counts it and ... expands it.
  • C++17 fold expressions such as (values + ... + 0) and (f(values), ...) combine or loop over a pack.
  • if constexpr compiles only the chosen branch, so each branch can use type-specific code.
  • A deduced T&& is a forwarding reference that accepts lvalues and rvalues.
  • std::forward<T>(x) passes an argument on with its original category; it is the heart of make_unique and emplace_back.

Exercise

Write a variadic function bool all_passed(const Ts&... marks) that returns true only if every mark is at least 40, using a fold expression over &&. Test it with (78, 92, 65), with (78, 35, 65) and with no arguments, and explain the result for the empty call.

Show solution
C++
#include <print>

template <typename... Ts>
bool all_passed(const Ts&... marks) {
    return ((marks >= 40) && ...);
}

int main() {
    std::println("{}", all_passed(78, 92, 65));
    std::println("{}", all_passed(78, 35, 65));
    std::println("{}", all_passed());
}
Output
true
false
true

The fold expands to (78 >= 40) && (92 >= 40) && (65 >= 40). For an empty pack, a fold over && is defined to be true, just as "every student in an empty class passed" is true.

Quick Quiz

1. What does sizeof...(args) return?

2. Why does pass_plain(std::string("Farhan")) copy instead of move?

3. Which parameter is a forwarding reference?

4. What is special about if constexpr?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call