Variadic Templates & Perfect Forwarding
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.
#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');
}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.
#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");
}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,Tbecomesstd::string&, and the parameter is an lvalue reference. - Called with an rvalue,
Tbecomesstd::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.
#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"));
}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.
#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);
}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. Writingstd::forward<Args>(args)without...is a compile error: an unexpanded pack. - Using
std::moveon a forwarding reference. It moves from the caller's named variables too, leaving them empty. Usestd::forward. - Thinking every
T&&is a forwarding reference. Only a deducedT&&(orauto&&) is.std::vector<T>&&andstd::string&&are plain rvalue references. - Using a value after forwarding it. After
std::forwardpassed an rvalue on, the object may have been moved from. - Using a normal
ifwith type-specific code. Every branch must compile for every type; useif constexpr.
Key points
typename... Tsdeclares 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 constexprcompiles 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 ofmake_uniqueandemplace_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
#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());
}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?
sizeof... counts the items in a pack at compile time.
2. Why does pass_plain(std::string("Farhan")) copy instead of move?
A named parameter is an lvalue; std::forward is needed to restore the rvalue.
3. Which parameter is a forwarding reference?
Only a deduced T&& (or auto&&) is a forwarding reference.
4. What is special about if constexpr?
The condition is evaluated while compiling and only the chosen branch is instantiated.
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
