Template Programming

C++
Author

Quasar

Published

November 10, 2024

C++11 introduced variadic templates which permit functions to accept a variable number of arguments. They also permit template types such as std::tuple that can hold a variable number of elements. The main language mechanism enabling variadic templates is parameter packs, which hold an arbitrary number of values or types. Some things are easier to do with parameter packs - for instance passing the values they comprise to a function. Other tasks are a bit trickier to accomplish, such as iterating over a parameter pack or extracting specific elements. However, these things can generally be accomplished through various idioms, some more unwieldy then others.

Between C++11 and C++20, the language gained several improvements to variadic templates. Improvements to other features, such as concepts and lambdas, have also created new options for manipulating parameter packs in C++20. Ideally, cataloging these tricks make it easier for people to do what they need with variadic templates.

An overview of variadic templates

A template parameter pack is a template parameter that accepts zero or more template arguments. A function parameter pack is a function parameter that accepts zero or more function arguments. A variadic template is template that captures a parameter pack in its template arguments or function arguments. A parameter pack is captured by introducing an identifier prefixed by an ellipsis, as in ...X. Once captured, a parameter pack can later be used in a pattern expanded by an ellipsis (...), generally to the right of the pattern, as in X.... Pack expansion is conceptually equivalent to having one copy of the pattern for each element of the parameter pack.

#include <iostream>

template <typename T>
T sum(T x){
    return x;
}

template <typename T, typename... Args>
T sum(T x, Args... args){
    return x + sum<Args...>(args...);
}

int main()
{   
    double result = sum(1.0, 2.0, 3.0, 4.0, 5.0);
    std::cout << "result = " <<  result;
    return 0;
}

Compiler Explorer

The sum() function takes one or more arguments. The first argument is always captured by the parameter x and the rest of the arguments are captured by the pack ...args on line 9.

Expanding parameter packs

When using a variadic template, we often use a recursive logic with two overloads : one for the general case and one for ending the recursion.

The below code snip is a minimalistic example of tuple. The first class is the primary template. The primary template tuple has two member variables : first of type Type and rest of type Types... . This means that a template of N elements will contain the first element, and another tuple; this second tuple in turn contains the second element and yet another tuple; so on and so forth.

A captured parameter pack must be used in a pattern that is expanded with an ellipsis (...). A pattern is a set of tokens containing the identifiers of one or more parameter packs. On line 11, we capture a parameter pack rest consisting of a sequence of values rest[i] each of type Types[i] for the i-th position in parameter pack Types. On line 13, we expand the pattern rest.

// Variadic class templates and parameter pack expansion
#include <functional>
#include <utility>
#include <iostream>

template <typename Type, typename... Types>
struct tuple{
    Type first_;
    tuple<Types...> rest_;

    tuple(Type first, Types... rest) 
        : first_(first)
        , rest_(rest...)
        {}
};

template <typename T>
struct tuple<T>{
    T first_;

    tuple(T first) : first_(first) {}
};

int main()
{   
    tuple<double, double, double> x1(3.0, 4.0, 5.0);
    return 0;
}

Compiler Explorer

When a pattern contains more than one parameter pack, all packs must have the same length. This length determines the number of times the pattern is conceptually replicated in the expansion, once for each position in the expanded pack(s). Consider the following code snippet:

// An example with two parameter packs
#include <iostream>
#include <type_traits>
#include <tuple>

template <std::same_as<char>... C>
void expand(C... c)
{
    std::tuple<C...> tpl(c...);

    const char msg[] = { C(std::toupper(c))..., '\0' };
    //Do something
}
int main()
{   
    expand('t','e','m','p','l','a','t','e','s');
    return 0;
}

On line 7, tuple<C...> expands the pack C in the template-argument list, while tpl(c...) expands c in an initializer list (which, not to be confused with std::initializer_list is the C++ grammar for comma-separated lists of expressions passed as arguments to function calls and constructors).

On line 9, we expand the pattern C(std::toupper(c)) in another initializer list. This is an example of a pattern with two packs, C and c, both of which have the same length and are expanded in lockstep. (std::toupper() returns an int rather than a char so requires a cast).

In most cases, an expanded pattern is conceptually equivalent to the number of copies of the pattern equal to the size of the parameter pack. Unless otherwise noted, a pattern is expanded by appending an ellipsis (...). Here is a list of contexts in which a pattern can be expanded:

  • In initializer-lists (as shown above), including pack expansion in the arguments to a function call. Conceptually, such a pack expansion is equivalent to a comma-separated list of instances of the pattern.
  • In base specifier lists, to specify one base class for each member of a type parameter pack e.g.: