cpp

Introduction

C++11 introduced a variadic template that can accept any number of template arguments, including none. A variadic template is a compile-time mechanism that is instantiated when used.

We recognize a variadic template by the ellipsis ... in the parameter list:

#include <iostream>

using namespace std;

template <typename... P>
void foo()
{
  cout << __PRETTY_FUNCTION__ << endl;
  cout << sizeof...(P) << endl;
}

int
main()
{
  foo<>();
  foo<int>();
  foo<bool, double>();
}

In the above example, the ellipsis was used in the definition of a parameter pack, and then in the pack expansion.

Parameter pack

We define a parameter pack with the ellipsis that is followed by the pack name, e.g., p. Pack p is made of parameters p1, p2, …, p(n-1), pn. A parameter pack can be of a template or a function. A parameter pack is used in a pack expansion or a fold expression.

Template parameter pack

A template parameter pack defines template parameters of the same kind: in the example above they are of the type kind, in the example below of the value kind.

#include <iostream>

using namespace std;

template <unsigned... N>
void foo()
{
  cout << __PRETTY_FUNCTION__ << endl;
  cout << sizeof...(N) << endl;
}

int
main()
{
  foo<>();
  foo<1>();
  foo<1, 2>();
  // foo<1, -1>();
}

Pack expansion

The name of a pack with a trailing ellipsis is called a pack expansion. A template parameter pack is expended to a comma-separated list of template parameters.

#include <iostream>
#include <string>

struct A
{
};

template <typename... P>
struct B: P...
{
  B()
  {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
  }
};

int
main()
{
  // Expands to a structure type that does not have a base class.
  B<>();
  // Expands to a structure type that has the base class A.
  B<A>();
  // Expands to a structure type derived from A and string.
  B<A, std::string>();
}

In the examples above, the template arguments were explicitly given, because the functions and the constructor didn’t have parameters defined and call arguments passed, based on which the template arguments could be deduced. Arguments for template parameters of a pack are deduced based on the definition of a function parameter pack and the call arguments.

Function parameter pack

A function parameter pack is defined in the list of function parameters, also with the ellipsis: first we put the name of a template parameter pack, the ellipsis, and then the name of the function parameter pack. Interestingly, the definition of the function parameter pack is also the expansion of the template parameter pack.

#include <iostream>

using namespace std;

template <typename... P>
void foo(P... p)
{
  cout << __PRETTY_FUNCTION__ << endl;
}

int
main()
{
  foo();
  foo(1);
  foo(1, 2.0);
}

In the example above, the function accepts arguments by value. To make the function accept its arguments by a const reference, we define the pack as const Args &... args.

Pack expansion

An expansion of a function parameter pack is written as the name of the pack followed by the ellipsis.

#include <iostream>
#include <string>
#include <vector>

template <typename T, typename... P>
auto
factory(P... p)
{
  return T{p...};
}

int
main()
{
  std::cout << factory<std::string>("Hello!") << std::endl;
  auto p = factory<std::vector<int>>(1, 2, 3);
}

A template parameter pack can be expanded in lockstep with (in tandem with) the expansion of the function parameter pack, e.g., when we initialize base objects using parameters of a constructor of the derived class:

#include <iostream>
#include <string>

struct A
{
  A() = default;
  A(int) {}
};

template <typename... P>
struct B: P...
{
  B(const P &... p): P(p)...
  {
    std::cout << __PRETTY_FUNCTION__ << std::endl;
  }
};

int
main()
{
  B<>();
  B<A>(1);
  B<A, std::string>({}, "Hello!");
}

Recursive processing

We can process function arguments recursively. The trick is to define two function parameters: the first is a regular one to be processed by the current call, the other is a parameter pack to be processed by recursive call. A pack is expended to an argument list for the recursive call. This way, the recursive-call pack is missing the first parameter of the current-call pack.

#include <iostream>
#include <string>

template <typename P1, typename... P>
void
print(P1 p1, P... p)
{
  std::cout << p1;

  if constexpr(sizeof...(P))
    print(p...);
}

int
main()
{
  print("Hello", ' ', std::string("World"), " x ", 100, '\n');
}

Fold expression

A fold expression (since C++17) describes how to generate a target (intended) expression using any binary operator op and a parameter pack. A fold expression is called that way, because it folds (like a tablecloth) the target expression (which we could write “by hand”) to a compressed format. A fold expression is expanded, instantiated for a given parameter pack, and that can replace recursive processing. We recognize the fold expression by the ellipsis and parentheses. There are four versions: two unary, and two binary, that use the same binary (that requires two operands) operator op.

Part of a fold expression is expression E that uses pack p. A fold expression is expanded by instantating expression E for the subsequent parameters of pack p. Expression E for parameter pi is denoted by Ei. We denote expression E instantiated for parameter pi by Ei.

Unary versions

The unary versions require one expression E and operator op. They are expanded something like this:

E1 op E2 op … op E(n-1) op En

The result of the above expression depends on the associativity of operator op, because the direction in which subexpressions with operator op (e.g., E1 op E2) are evaluated is given by the associativity of operator op: either from left to right for the left-to-right associativity, or from right to left for the right-to-left associativity.

For an associative operation (e.g, addition), the direction doesn’t matter as the result is the same. If the operation is not associative, the direction matters. Check this out: 3 - 2 - 1 is evaluated from left to right: (3 - 2) - 1 = 0, and not from right to left: 3 - (2 - 1) = 2. Conclusion: operator - must have the left-to-right associativity (even though it’s not associative).

There is no fold expression that would be expanded as show above, so that a compiler evaluates the subexpressions in the direction given by the associativity of operator op. There are, however, two versions (of a unary fold expression) that impose the direction:

Therefore:

For an associative operation, both versions yield the same result. However, if an operation is not associative, then we have to choose the proper version, depending on the associativity of operator op. In the following example, subtraction is not associative and has the left-to-right associativity, so we have to use the left version of the unary fold expression.

template <int... P>
constexpr int left()
{
  return (... - P);
}

template <int... P>
constexpr int right()
{
  return (P - ...);
}

int main()
{
  // (3 - 2) - 1 = 0
  static_assert(left<3, 2, 1>() == 0);
  // 3 - (2 - 1) = 2
  static_assert(right<3, 2, 1>() == 2);
}

In the example below we have to use the right version:

#include <cassert>

template <typename... T>
void left(T &... p)
{
  (... += p);
}

template <typename... T>
void right(T &... p)
{
  (p += ...);
}

int main()
{
  int x = 1, y = 2, z = 3;

  // We would like to inrement y by z, and then x by y:
  //
  // x += y += z;
  //
  // The above compiles and works as expected, because += has the
  // right associativity, and so we need the right fold expression.

  // left(x, y, z);
  right(x, y, z);

  assert(x == 6);
  assert(y == 5);
  assert(z == 3);
}

Binary version

The binary versions require an initializing expression A that is the second argument. A compiler differentiates arguments A and E by the parameter pack. There are two versions:

Input-output streams are commonly the initializing expression of a binary fold expression with the stream insertion (<<) or extraction (>>) operators, for which we have to use the left version because:

Here’s an example:

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

using namespace std;

template <typename... P>
void
read(P &... p)
{
  (cin >> ... >> p);
  // (p >> ... >> cin);
}

template <typename... P>
void
write(P &&... p)
{
  (cout << ... << p);
  // (p << ... << cout);
}

int
main()
{
  write("Hello", ' ', std::string("World"), " x ", 100, "!\n");

  string txt;
  int x;
  bool b;

  read(txt, x, b);
  write(txt, ' ', x, ' ', b, '\n');
}

Here’s an example with the right version:

#include <cassert>

template <typename R, typename... T>
void left(R &r, T &... p)
{
  (r += ... += p);
}

template <typename R, typename... T>
void right(R &r, T &... p)
{
  (p += ... += r);
}

int main()
{
  int x = 1, y = 2, z = 3;

  // We would like to inrement y by z, and then x by y:
  //
  // x += y += z;
  //
  // The above compiles and works as expected, because += has the
  // right associativity, and so we need the right fold expression.

  // left(x, y, z);
  right(x, y, z);

  assert(x == 6);
  assert(y == 5);
  assert(z == 3);
}

From binary to unary

A binary fold expression is handy and expressive, but we could do without it: we can add expression A to the parameter pack and use the unary fold expression. We add either at the front of the pack to use the left version or at the back to use the right version. The example below uses the stream operators, so we have to use the left version.

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

using namespace std;

template <typename... P>
void
read(P &&... p)
{
  (... >> p);
}

template <typename... P>
void
write(P &&... p)
{
  (... << p);
}

int
main()
{
  write(cout, "Hello", ' ', std::string("World"), " x ", 100, "!\n");

  istringstream in("Hi! 100 0");

  string txt;
  bool b;

  // We don't care about the 100, so we read it into a temporary.
  read(in, txt, int(), b);
  write(cout, txt, ' ', 200, ' ', b, '\n');
}

A short but difficult example: a comma-separated list

The binary comma operator is unusual because it joins two independently-evaluated subexpressions. The comma has the left-to-right associativity, so the left subexpression is evaluated first, unaffected by the parentheses in the right subexpression. Interestingly, this operator has the lowest priority, yet it establishes the order in which the subexpressions of higher-priority operators are evaluated. In the example below, the comma operators (and not parentheses) establish the order in which the subexpressions of operator << are evaluated.

#include <iostream>

using namespace std;

int main()
{
  (cout << '1', cout << '2'), cout << '3';
  cout << '1', (cout << '2', cout << '3');
}

The example below uses the left version, where (std::cout << ", " << p) is E, and comma is op. If pack p is empty, then the fold expression is empty. If p has one parameter only, then a compiler adds an extra empty parameter, if such a one exists (for the comma operator, it’s void()), because op is binary. To cater for the correct printing of a comma, we process the first parameter separately, outside the pack (as in the recursive processing), and the pack parameters we process with a fold expression.

#include <iostream>
#include <string>

template <typename T, typename... P>
void
print(const T &t, const P &... p)
{
  std::cout << t;
  (... , (std::cout << ", " << p));
}

int
main()
{
  print(5, "10", std::string("15"));
  std::cout << std::endl;

  // What's this?
  1, void();
  // Well, it's needed here:
  print(1);
  std::cout << std::endl;  
}

Conclusion

Quiz