Variadic Templates
As we've seen with std::void_t
, emplace()
, and make_unique
/make_shared
, there is a way to take an unknown amount of parameters of different types.
This can be done using a parameter pack. A template that uses a parameter pack is known as a variadic template.
You can almost think of a parameter pack as a list of template parameters (both type and non-type parameters). A parameter pack can have any number of parameters, including none.
template<typename ...Ts>
struct Types {};
Types<> t;
Types<1, 2, 3> t2;
Types<int, short, std::string, char> t3;
template<typename ...Ts>
void func(Ts... args) {
// ..
}
func();
func(100, "Hello", 'g');
We can define a parameter pack by putting the ellipsis in front of the pack name, and expand a parameter pack by putting the ellipsis after the pattern.
The pattern is the pack name with any adornments (such as &
) which are given to all elements of the pack. During expansion, the pattern is substituted by the elements of the pack separated by commas.
template<class ...Us>
void f(Us... pargs) {}
template<class ...Ts>
void g(Ts... args) {
return f(&args...);
// calls f by passing all elements of args by reference
// &args is the pattern
}
g(1, 0.2, "a");
We can expand parameter packs in the following contexts:
- As we've just seen, function arguments
template<typename ... Ts> void g(Ts... args) { f(args...); f(++args...); // expands to f(++a, ++b, ++c, ...) f(h(args...) + args...); // expands to //f(h(a, b, c) + a, h(a, b, c) + b, h(a, b, c) + c); }
- Initializers (both parenthesis and braces)
template<typename ... Ts> auto toVec(Ts ... args) { std::vector v = {1, args..., 2}; return v; }
- Template arguments
template<typename ... Ts> struct Test { container<int, short, Ts...> c; };
- Base class specifiers and member initialization lists
template<typename ... Mixins> struct Aggregator : public Mixins ... { Aggregator(const Mixins& ... args) : Mixins(args) ... {}; // expands to call copy constructor of all base classes }
- Using declarations
template<typename ... Mixins> struct Aggregator : public Mixins ... { Aggregator(const Mixins& ... args) : Mixins(args) ... {}; // expands to call copy constructor of all base classes void foo() {}; using Mixins::foo...; // unshadow all base class foo overloads }
Parameter packs can also be universal references and can be forwarded.
template<typename T, typename ... Args>
auto make_unique(Args&& ... args) {
return std::unique_ptr<T>( new T(std::forward<Args>(args)...) );
// expands to
// new T(std::forward<A>(a), std::forward<B>(b), std::forward<C>(c), /* ... */)
}
Basically, each use case of ...
expands a comma separated list of the pattern ...
is applied to.
The name of the pack typename (Args
in this case) is substituted which each type in the pack, and the name of the pack arguments (args
here) is substituted with each argument passed to the function.
We can "unpack" an expansion by using two function overloads (or class specializations), one which takes no arguments, this will be the base case, and one which takes a "head" argument and "tail" parameter pack. The idea here is the same as in functional programming. Here's a simple function to get the length of a parameter pack.
constexpr auto argCount() {
return 0;
}
template<typename T, typename ... Args>
constexpr auto argCount(T&&, Args&& ... args) {
return 1 + argCount(std::forward<Args>(args)...);
}
constexpr auto count = argCount(5);
static_assert(count == 1);
static_assert(argCount("Hello", 50.0, 10, 'c') == 4);
When we call argCount
with non-zero amount of arguments, the first argument gets bound to T&&
and the rest (which there may be none) gets bound to Args&&
.
If the pack has no arguments in it, the expansion won't do anything, and the no parameter overload will be called.
A better way to do this is to use the sizeof...
operator, which gets the amount of arguments in a parameter pack.
template<typneame ... Args>
constexpr auto argCount(Args&& ...) {
return sizeof...(Args);
// sizeof... takes the typename of the pack, not the argument name
}
Here's an example of getting the nth type from a parameter pack:
template<unsigned index, typename Head, typename ... List>
struct NthType {
using Type = typename NthType<index - 1, List...>::Type;
// we "pop" the Head of the pack by not passing it through
};
template<typename Head, typename ... List>
struct NthType<0, Head, List...> {
using Type = Head;
};
// index 0 specialization
using sndType = typename NthType<2, void*, char*, int, long, double&>::Type;
//sndType is int
/* sndType
NthType<index = 2, Head = void*, List = char*, int, long, double&>
NthType<index = 1, Head = char*, List = int, long, double&>
NthType<index = 0, Head = int, List = long, double&>
This is the specialization
So `Type = Head = int`
*/
Fold Expressions
A fold expression is like another type of pack expansion, except that instead of producing comma separated arguments, the pack is expanded over a binary operator. A fold expression can have an initial value as well, and must be surrounded by parentheses.
I won't explain the details of folds here, but basically left folds operate on the leftmost argument first,
right folds go from the rightmost argument to leftmost. Folds come from functional programming languages.
Fold expression have the following syntax where pack
is the pattern containing the name of the pack,
op
is the operator, init
is the initial value and ...
are the actual ellipsis.
- Unary fold right -
(pack op ...)
- Unary fold left -
(... op pack)
- Binary fold right -
(pack op ... op init)
- Binary fold left -
(init op ... op pack)
The difference between a unary and binary fold is not that one uses unary operators (that would be a normal pack expansion),
but rather the binary fold has an initial value.
The syntax for a left fold is when the ...
is on the left side of the pack name.
template<typename ... Ts>
constexpr auto sum(Ts&& ... args) {
return (... + args);
// "unary" left fold
}
template<typename ... Args>
void print(Args&& ... args) {
(std::cout << ... << args);
// binary left fold
}
template<typename ... Args>
constexpr auto allTrue(Args&& ... args) {
return (... && args);
// binary left fold
}
template<typename T, typename ...Args>
constexpr auto contains(T needle, Args ... args) {
// true if needle is contained within the pack
return (... || (args == needle));
}
template<typename ...Args>
constexpr auto selfDot(Args... args) {
// computes the dot product of the arguments with them self
return (... + (args * args));
}
Notice the need for the parenthesis to make an entire expression such as args * args
or args == needle
part of the pattern.
We define all of these function constexpr
so that the result of the function can be available at compile time (and therefore not done during runtime) if we pass arguments that are also constexpr
such as literals.
Packs and Concepts
Parameter packs can be used pretty easily with enable_if
using fold expressions.
template<typename ... Ts>
constexpr auto sum(Ts... args)
-> std::enable_if_t<(... && std::is_arithmetic_v<Ts>), decltype((... + args))>
{
return (... + args);
}
constexpr auto s = sum(10, 20.3, 100.f, 'c'); // double 229.3
Unlike before, in this situation we need to use the trailing return type because the parameter args
is used in determining the return type.
Also, notice how when we want a fold expression using the types of the pack, we use the name of the template parameter Ts
.
However, when we want a fold expression using the values of the pack, we use the name of the function argument args
.
In this case we fold over &&
(boolean AND) to ensure that all types in the pack are arithmetic.
We could fold over ||
(boolean OR) to check that at least one type upholds a certain condition.
We can use a type alias to make this a bit cleaner.
Another less graceful, (and pre C++17 friendly) way of doing this is to create an all_true
struct using SFINAE.
What we'll do is instantiate a struct with a pack of bools.
Then we'll assert that the type of the bool pack is the same when we append a true
to the front of the pack as when we push a true
to the back.
If all elements of the bool pack are the true
, then the types will be the same.
However, if any of the elements in the pack are false
, the position of this false
will differ between the two
instantiations of the template, and they won't be the same type.
template<bool...>
struct bool_pack {};
// template variables are a C++17 feature
template<bool... bools>
constexpr inline auto all_true_v = std::is_same_v<
bool_pack<bools..., true>,
bool_pack<true, bools...>>;
template<typename... Ts>
std::enable_if_t<all_true_v<std::is_arithmetic_v<Ts>...>>
foo(Ts... args) {
//...
}
Possible Exercises
- Can you create a function that takes an arbitrary number of arguments and serializes all of them into a single byte array?
The function should return an
std::vector<std::byte>
or anstd::array<std::byte, N>
. If you go the latter route, the function can beconstexpr
. You'll need an overload to handle containers likestd::vector
,std::list
, etc. You may choose the endianness of the result.- So when passed
"Hello", 5, static_cast<short>(1000)
the function should return a single byte array that would look something like:0x48 0x65 0x6C 0x6C 0x6F 0x00 0x00 0x00 0x05 0x03 0xE8 'H' 'e' 'l' 'l' 'o'| 5 | 1000 // little endian, 4 byte int, 2 byte short
- So when passed