C++ Metaprogramming: Variadic Templates and Parameter Packs

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:

unary left fold has the form (...⊕pat) and is equivalent to ((p1p2)⊕…)⊕pn.

unary right fold has the form (pat⊕...) and is equivalent to p1⊕(p2⊕(…⊕pn)).

binary left fold has the form (e⊕...⊕pat) and is equivalent to (((e⊕p1)⊕p2)⊕…)⊕pn.

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

References

C++20 idioms for parameter packs