Variadic Functions
C and C++ have long supported variadic (variable number) argument lists on functions. It was a simple mechanism that was prone to error. Theses C-style variadic functions could be passed anything which means that the variadic function could end up with arguments it isn't prepared to handle. Also we either needed to provide a leading count value or a trailing sentinel value so that the function can figure out how many arguments were passed. Of course this could also lead to errors. Below is an example of a C-Style variadic function the passes a leading count.
cStyleVariadic.cpp
#include <iostream> #include <cstdarg> void printNumbers(int count, ...) { va_list args; va_start(args, count); for (int i = 0; i < count; ++i) { int value = va_arg(args, int); std::cout << value << ' '; } va_end(args); } int main() { printNumbers(3, 1, 2, 3); // Output: 1 2 3 }
1 2 3
Variadic Template Functions
With the introduction of Parameter Packs in C++11 we can now create variadic template functions that are much more type safe and don't require the use of count values or trailing sentinel values. Parameter Packs are used with the template mechanism in C++ to create a single object that represents the variable list of arguments. We can use the Parameter Pack in the definition of the function and use special syntax to expand the Parameter Pack in the body of the function.
We can create an abbreviated function template simply by using the auto
keyword when defining the variadic arguments. We'll see later how to use the full template
syntax with variadics.
cppStyleVariadic.cpp
#include <algorithm> #include <iostream> #include <string> using namespace std; void printStrings(auto&&... args) { for (auto s : std::initializer_list<std::string_view>{ args... }) std::cout << s << " "; std::cout << std::endl; } string strUpper(string str) { std::transform(str.begin(), str.end(), str.begin(), [](unsigned char c){ return toupper(c); }); return str; } void printUpperStrings(auto&&... args) { string upperStrs[] = { strUpper(args)... }; for (auto s : upperStrs) std::cout << s << " "; std::cout << std::endl; } int main() { const char* gamma = "gamma"; string delta = "delta"; printStrings("alpha", string("beta"), gamma, delta); printUpperStrings("alpha", string("beta"), gamma, delta); }
alpha beta gamma delta
ALPHA BETA GAMMA DELTA
The auto&&... args
syntax in the function definition causes the function to become a template function with a variadic argument list represented by the parameter pack called args. The arguments can be different types although in this function we would like to be passed only string-like arguments. The use of auto&&
as opposed to auto
or auto&
is known as a forwarding reference which can accept both lvalue and rvalue references.
In the body of the function we use the args...
syntax to expand the parameter pack into the individual arguments passed. In this example we expand the parameter pack as the argument to construct an initializer_list
that we can then iterate. Parameter packs are expanded by applying the pattern before the ellipse over and over for each argument passed. For example the expansion expression args...
is somewhat equivalent to
args[0], args[1], args[2], ...
if args
where an array which is why it works so well in initializer list contexts. But patterns can be more complex than just the name of the parameter pack. In the previous code listing we use strUpper(args)...
in the printUpperStrings()
method. Here each argument in the parameter pack is substituted as an argument to strUpper()
so we get an expansion that is somewhat equivalent to
strUpper(args[0]), strUpper(args[1]), strUpper(args[2]), ...
if args
where an array.
Instead of using the auto
keyword, we can also use the template
syntax to create a parameter pack. The ellipses is added to the typename
or class
keyword to signify a parameter pack template argument. In the example below we use typename ...Args
in the template specification to create a parameter pack called Args
. This parameter pack can represent zero or more arguments of varying types.
variadic.cpp
#include <iostream> #include <iomanip> #include <vector> using namespace std; // Function to add an arbitrary number of integers using parameter packs template<typename... Args> int add(Args... args) { return (... + args); // unary left fold expression } // Function to print an arbitrary number of integers using parameter packs template<typename... Args> void print(Args... args) { (cout << ... << args); // binary left fold expression cout << endl; } // Function to push onto a vector an arbitrary number of integers using parameter packs template<typename T, typename... Args> void insert(vector<T>& v, Args... args) { static constexpr size_t len = sizeof...(args); cout << "Pushing " << len << " integers into vector" << endl; (v.push_back(args), ...); // unary right comma fold expression return; } int main() { vector<int> v; insert(v, 1, 2, 3.14, 4, 5L); cout << "Vector: "; for (int i : v) { cout << i << " "; } cout << endl; print(1, 2, 3.14, 4, 5L); cout << add(1, 2, 3.14, 4, 5L) << endl; return 0; }
Pushing 5 integers into vector
Vector: 1 2 3 4 5
123.1445
15
In the template declaration, the typename... Args
declares a variadic type Args
that is essentially a list of types. In the template function we declare the variadic arguments with the variadic type as Args... args
. Then in the body of the template function we can expand the variadic arguments as illustrated before. With the introduction of C++20 we also have a new way to expand the variadic arguments using fold expressions.
Folds are defined in terms of cast-expressions which are any expressions that bind at least as tightly (i.e. have a precedence at least as high) as the cast operator. There are four types of fold expressions:
A unary left fold has the form (...⊕pat)
and is equivalent to ((p1⊕p2)⊕…)⊕pn
.
A unary right fold has the form (pat⊕...)
and is equivalent to p1⊕(p2⊕(…⊕pn))
.
A binary left fold has the form (e⊕...⊕pat)
and is equivalent to (((e⊕p1)⊕p2)⊕…)⊕pn
.
A binary right fold has the form (pat⊕...⊕e)
and is equivalent to p1⊕(p2⊕(…⊕(pn⊕e)))
.
In these examples, let pat
be a cast-expression containing one or more unexpanded parameter packs (i.e., a pattern). Let p1, …, pn
be the instances of pat
corresponding to each element captured by the parameter pack. Let ⊕
stand for any binary operator in C++ including the comma operator. And finally let e
be a cast-expression without any unexpanded parameter packs. Note that parentheses are always required around a fold, regardless of context.
In the add()
template function above we use an unary left fold (... + args)
which expands to something like
((((args[0] + args[1]) + args[2]) + ...) + args[n])
if args
were an array.
In the print()
template function above we use an binary left fold (cout << ... << args)
which expands to something like
((((cout << args[0]) << args[1]) << ...) << args[n])
if args
were an array.
In the insert()
template function above we use an unary right comma fold (v.push_back(args), ...)
which expands to something like
(v.push_back(args[0]), (v.push_back(args[2]), (..., v.push_back(args[n]))))
if args
were an array. Notice how the comma operator allows us to apply an operation to each element of the parameter pack in order.
In addition, there is a sizeof operator for parameter packs if you ever need to know the number of elements in the parameter pack. Simply use the sizeof...()
operator on the parameter pack.
In the previous example the template<typename... Args>
declares a parameter pack that can have any type of elements. If we wanted to restrict the arguments to a single type we can't just use the type instead of the generic typename
keyword. Instead we must use another C++20 idea called concepts. Concepts allow us to put constraints on template parameters such as integers only or matches a specific type. The C++ standard library provides several predefined concepts that you can use to constrain templates. Some examples include:
std::integral
: Requires the type to be an integral type (e.g., int, char, long).std::floating_point
: Requires the type to be a floating-point type (e.g., float, double).std::same_as<T, U>
: Requires T and U to be the same type.std::convertible_to<T, U>
: Requires that T can be converted to U.
variadicInts.cpp
#include <iostream> #include <iomanip> #include <vector> using namespace std; // Function to add an arbitrary number of integers using parameter packs template<std::same_as<int>... Ints> int addIntegers(Ints... args) { return (... + args); // unary left fold expression } // Function to print an arbitrary number of integers using parameter packs template<std::integral... Ints> void printIntegers(Ints... args) { (cout << ... << args); // binary left fold expression cout << endl; } // Function to push onto a vector an arbitrary number of integers using parameter packs template<std::convertible_to<int>... Ints> void insertIntegers(vector<int>& v, Ints... args) { static constexpr size_t len = sizeof...(args); cout << "Pushing " << len << " integers into vector" << endl; (v.push_back(args), ...); // unary right comma fold expression return; } int main() { vector<int> v; insertIntegers(v, (char)1, 2, 3.14, 4, 5L); cout << "Vector: "; for (int i : v) { cout << i << " "; } cout << endl; // Can't use 3.14 as it's not an integral type printIntegers((char)33, 2, 3, 4, 5L); // Can't use (char)1, 3.14 or 5L as they're not ints cout << addIntegers(1, 2, 3, 4, 5) << endl; return 0; }
Pushing 5 integers into vector
Vector: 1 2 3 4 5
!2345
15