Variadic Functions

An introduction to variadic functions, which allow us to pass a variable quantity of arguments
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
3D Character Concept Art
Ryan McCombe
Ryan McCombe
Updated

Often, we’ll find ourselves wanting to implement a function that can accept any number of arguments. Our reasons for needing to do this broadly fall into two categories:

1. API Design / Optimising Developer Experience

Sometimes, the most intuitive API we can provide for a function is a highly flexible parameter list. For example, if we’re creating a logging library, the friendliest way to let ourselves or other developers use it is probably to just let them pass everything they want to log as arguments:

Log(A, B, C);

But we don’t know in advance how many things a consumer is going to want to log in a single function call. We could just pick an arbitrary limit like 10 and throw an error if someone goes over that. But, as we’ll soon learn, such a restriction is not necessary.

2. Forwarding Arguments to an Unknown Function

Often, we’ll need to write a function that accepts arguments that ultimately need to be passed on to another function. And, at the time we’re writing our code, we don’t know what that function will be, or how many arguments it will accept.

We’ve seen examples of this in the standard library, such as the std::make_unique() and std::make_shared() functions to create smart pointers and the emplace() methods that exist on various containers.

These functions accept a list of arguments that will be passed along to a constructor. But they don’t know which constructor they’re forwarding to, or even the type they’re constructing. That depends on a template parameter. As such, these types of functions need to have a highly flexible parameter list.

Parameter Packs

The key mechanism that lets modern C++ functions accept a variable number of parameters is a parameter pack.

A parameter pack is a collection of zero or more parameters. There are two types:

  • Template parameter packs are collections of template parameters
  • Function parameter packs are collections of function parameters

We define a parameter pack using ... syntax, often called an ellipsis.

The following example shows a function template, using both a template parameter pack and a function parameter pack.

In this case, we’ve called our template parameter pack Types and our function parameter pack Arguments, but we’re free to use any name, subject to the usual naming rules.

template <typename... Types>
void MyFunction(Types... Arguments){
  // ...
}

A function that uses a parameter pack is called a variadic function, and can be called with a variable number of arguments:

template <typename... Types>
void MyFunction(Types... Arguments){
  std::cout << "Hello!\n";
}

int main(){
  MyFunction();
  MyFunction(3);
  MyFunction(42, false, "Hello");
}
Hello!
Hello!
Hello!

References within Parameter Packs

It’s also possible to capture arguments by reference, using the & operator in the usual way:

template <typename... Types>
void Function(Types&... Arguments){
  // ...
}

And by const references:

template <typename... Types>
void Function(const Types&... Arguments){
  // ...
}

We can also use &&, but it doesn’t necessarily denote an rvalue reference. When suffixed to a deduced type (such as a template or auto) the && syntax denotes a forwarding reference, which is sometimes also called a universal reference.

We cover this in detail later in this chapter. For now, we can note that using && with our template type allows our function to capture both lvalue and rvalue references:

template <typename... Types>
void Function(Types&&... Arguments){
  // ...
}

int main(){
  int A{1};
  Function(A, 2);
}

We covered lvalues and rvalues in our earlier lesson on move semantics, and we have a dedicated lesson later in the course that covers forwarding, and forwarding references.

The sizeof… operator

A variation of the sizeof operator is available to return the number of parameters in a parameter pack. It is the sizeof... operator:

template <typename... Types>
void Function(Types... Arguments){
  std::cout
    << "Received "
    << sizeof...(Arguments)
    << " Arguments";
}

int main(){ Function(42, "Hello", true); }
Received 3 Arguments

Unpacking Parameter Packs

Once we have a parameter pack, we will often need to "expand" or "unpack" it to its constituent parameters. Typically, this is done to get a series of values to forward to some other function, as individual arguments.

To do this, we also rely on the ellipsis syntax. In the following example, we expand our parameter pack to provide arguments to a std::tuple constructor:

template <typename... Types>
void MyFunction(Types... Arguments){
  std::tuple Tuple{Arguments...};
}

We covered tuples in more detail in an earlier lesson:

std::tuple is a template class. In the above example, we were using Class Template Argument Deduction (CTAD) to let the compiler figure out the template parameters based on our initialization values.

But we can unpack template parameters in the same way, should we ever need or want to:

template <typename... Types>
void MyFunction(Types... Arguments) {
  std::tuple<Types...> Tuple { Arguments... };
}

While expanding our parameter pack, we can use any expression that includes the name of our pack. Above, we use the most basic expression - Arguments - but we can use more elaborate expressions if needed.

Each argument of our parameter pack will be passed into that expression in turn, and we then forward the value that the expression returns.

In the following example, we calculate what each argument will be if we multiply it by 2, and we forward those values instead of our original parameter pack values.

Typically, tasks like this will require the introduction of additional brackets to control the order of operations:

#include <iostream>
#include <tuple>

template <typename... Types>
void Function(Types... Arguments){
  std::tuple Tuple{(Arguments * 2)...};

  std::cout << "First Object in Tuple: "
    << std::get<0>(Tuple);
}

int main(){ Function(2, 5, 10); }
First Object in Tuple: 4

In the following example, we pass each argument to a function and forward the return values of those function calls to our tuple constructor. This unlocks a lot of possibilities, but below, our function is simply logging the value and then returning it:

#include <iostream>
#include <tuple>

template <typename T>
T Log(T Object){
  std::cout << Object << '\n';
  return Object;
}

template <typename... Types>
void Function(Types... Arguments){
  std::tuple Tuple{Log(Arguments)...};

  std::cout << "First Object in Tuple: "
    << std::get<0>(Tuple);
}

int main(){ Function(2, 5, 10); }
2
5
10
First Object in Tuple: 2

Compile Time Conditionals and SFINAE

When writing variadic functions, quite often the code we write will be invalid if the size of our argument list is 0. This is true of our previous tuple examples - if Function were ever called with 0 arguments, our code would fail to compile.

A simple if statement does not solve this, as if statements are evaluated at run time. The compiler doesn’t know what will happen at run time, so it requires all branches of our code to be syntactically valid.

As such, we’ll often be using if constexpr statement in our variadic functions. These blocks remove code entirely from our application at compile time if a condition is not met.

In the following example, we only construct the tuple if our function was called with 1 or more arguments:

#include <iostream>
#include <tuple>

template <typename... Types>
void Function(Types... Arguments){
  if constexpr (sizeof...(Arguments) > 0) {
    std::tuple Tuple{Arguments...};

    std::cout << "First Argument: "
      << std::get<0>(Tuple);
  } else {
    std::cout << "\nParameter pack is empty!";
  }
}

int main(){
  Function(42, false, "Hello");
  Function();
}
First Argument: 42
Parameter pack is empty!

In our earlier lessons on templates, we covered the substitution failure is not an error (SFINAE) concept. That applies here too - just because a template instantiation resulted in invalid code, that does not mean our entire project has an error.

It just means that that specific template can’t handle the argument list we used. Rather than immediately throwing an error, the compiler will check if other templates can generate a valid function that can handle our invocation. It only becomes an error if no templates can handle the request.

We could make another template to handle the scenario of our function being called without arguments. Or, as we’ve done above, use compile-time logic to make our existing template compatible even if our parameter pack is empty.

Ensuring Variadic Function Calls Have Arguments

Parameter packs can have any number of arguments, and as we’ve seen, that number can include 0

In most scenarios where we’re creating variadic functions, the idea that they will be called with zero arguments doesn’t make sense for our use case.

We can ensure our template function is only callable with 1 or more arguments by pulling the first parameter out of the pack, and using it like we would a regular parameter.

In the following example, we’ve separated our first parameter, both in the template parameter list (where we’ve called the type T1) and in the function parameter list (where we’ve called the argument Arg1):

#include <iostream>
#include <tuple>

template <typename T1, typename... Types>
void Function(T1 Arg1, Types... Arguments){
  std::tuple Tuple{Arg1, Arguments...};
}

int main(){
  Function(42, false, "Hello");
  Function();
}

Now, our function can no longer be called without at least one argument. The previous example generates a compilation error:

'Function': no matching overloaded function found

Implementing Variadic Logic

Sometimes, our variadic functions have requirements that are a little more complex than simply forwarding their parameters to some other function.

Rather, our function needs to directly implement logic that works with any number of function parameters. Typically, the best way of accomplishing this involves using the recursive techniques we covered earlier in this chapter.

Most commonly, this means handling the first parameter in the pack, and then recursively re-calling the function with the remaining parameters.

This has the effect where every subsequent recursive call has one fewer argument than the previous. Eventually, we have a call where the sizeof... our parameter pack is 0, at which case we can stop the recursion:

#include <iostream>

template <typename T1, typename... Types>
void Log(T1 First, Types... Others) {
  std::cout << "Logging: " << First << '\n';
  if constexpr (sizeof...(Others) == 0) {  
    // Base Case - Stop Recursion
    std::cout << "Done!";
  } else {
    // Recursive Case
    std::cout << sizeof...(Others)
      << " Parameter(s) Remaining\n\n";
    Log(Others...);
  }
}

int main() {
  Log(42, 9.8, "Hello");
}
Logging: 42
2 Parameter(s) Remaining

Logging: 9.8
1 Parameter(s) Remaining

Logging: Hello
Done!

The combination of parameter packs and recursion can be quite difficult to understand, so don’t worry if the flow of the previous program is difficult to follow. If it doesn’t make sense, it may be helpful to step through it in a debugger. Alternatively, the following program implements the same idea, but in a slightly different way:

#include <iostream>

template <typename T1, typename... Types>
void Log(T1 First, Types... Others){
  std::cout << "Logging: " << First << '\n';
  if constexpr (sizeof...(Others) > 0) {
    std::cout << sizeof...(Others)
      << " Parameter(s) Remaining\n\n";
    Log(Others...);
  }
}

int main(){
  Log(42, 9.8, "Hello");
}
Logging: 42
2 Parameter(s) Remaining

Logging: 9.8
1 Parameter(s) Remaining

Logging: Hello

Another variation of this recursive behavior is shown below. In this example, once the size of our argument list reaches 1, our call to Log is handled by a different, non-recursive function:

#include <iostream>

template <typename T>
void Log(T Object){
  std::cout << "Logging: " << Object;
}

template <typename T1, typename... Types>
void Log(T1 First, Types... Others){
  std::cout << "Logging: " << First << '\n';
  Log(Others...);
}

int main(){
  Log(42, 9.8, "Hello");
}
Logging: 42
Logging: 9.8
Logging: Hello

Is this really recursion?

Whilst these are recursive algorithms from our perspective as programmers, it’s worth reflecting a little on what is going on behind the scenes.

We’re not recursively calling a function, rather we’re recursively calling a template function.

Each recursive call uses a different argument list than the previous - specifically, an argument list that is progressively getting shorter. As such, the compiler is using our template to generate a series of different functions. So, behind the scenes, every layer of our recursion is calling a different function.

In our Log(42, 9.8, true) example, we’re calling:

  • Log<int, double, bool>, then
  • Log<double, bool> and finally
  • Log<bool>

Using these template-based recursion techniques can result in the compiler generating a lot of functions. This is particularly true if we’re using our variadic templates heavily throughout our project, with a wide variety of argument types.

The raw volume of code involved in a project can have performance implications in many environments. If we can solve our specific problem without using variadic functions at all, it’s often worth avoiding them.

When we do need to use variadic functions, C++17 introduced fold expressions, which can help us mitigate some of the problems. These provide us with more succinct options to implement simple recursive behaviors that cover most use cases, without the overhead of full-fledged recursion.

We can get very close to our previous behavior with a one-line fold expression:

#include <iostream>

template <typename... Types>
void Fold(Types... Args){
  (std::cout << ... << Args);
}

int main(){
  Fold("Hello ", "World", '!');
}
Hello World!

We explain how this works, and cover fold expressions in detail, in our next lesson.

Summary

In this lesson, we covered variadic functions, learning how they enable functions to accept any number of arguments through parameter packs. The key topics we learned include:

  • Variadic functions allow for a variable number of arguments, which is useful for API design and argument forwarding.
  • Parameter packs can be divided into template parameter packs and function parameter packs, both defined using the ellipsis syntax.
  • Arguments within parameter packs can be captured by value, by reference, by const reference, and as forwarding references to support both lvalue and rvalue references.
  • The sizeof... operator helps determine the number of arguments in a parameter pack, useful for conditional logic and compile-time evaluations.
  • Unpacking parameter packs enables forwarding arguments to other functions or constructors, such as when initializing a std::tuple.
  • Compile-time conditionals with if constexpr and techniques like SFINAE ensure code validity even when argument lists vary in size, including zero arguments.
  • Ensuring variadic function calls have at least one argument can be achieved by separating the first parameter from the rest of the pack.
  • Implementing variadic logic often involves recursive template function calls, where each call handles a subset of the parameter pack until all arguments are processed.
  • Fold expressions introduced in C++17 offer a concise and efficient way to apply an operation to each element in a parameter pack

Was this lesson useful?

Next Lesson

Fold Expression

An introduction to C++17 fold expressions, which allow us to work more efficiently with parameter packs
3D Character Concept Art
Ryan McCombe
Ryan McCombe
Updated
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access

This course includes:

  • 124 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Fold Expression

An introduction to C++17 fold expressions, which allow us to work more efficiently with parameter packs
3D Character Concept Art
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved