The functions we’ve dealt with so far required a fixed number of parameters and specific types. This works well for predictable tasks, but what if you don't know how much data you’ll be dealing with beforehand? For example, imagine a scenario where you need to sum a list of numbers or check if a value exists in a set. In such cases, you do not know how many arguments you’ll receive.
Let's discuss how variadic functions help in such cases.
Introduction
Variadic functions are designed to accept an unspecified number of arguments. We use the ellipsis (...) to indicate that a function is expecting more arguments. The basic syntax is as follows:
ReturnType FunctionName(ParameterType param, ...);Here, ... indicates that the function accepts additional arguments following param. Note, you must have at least one parameter that is not ellipsis. Also, it must always be the last parameter in the function.
Variadic functions are not unique to C++; many languages include some form of this feature. In Python, the *args and **kwargs syntax serves the same purpose. Java uses ... in its method signatures, referred to as varargs. JavaScript handles it through rest parameters, also written as .... These functions are particularly useful in library development. In such cases, functions need to accommodate argument lists that are not known until the library is actually used.
Most of these implementations work by collecting the variable arguments into an array or list behind the scenes, which the function then iterates over. In C++, we have both a low-level C-style mechanism and a type-safe template-based approach, which we will cover in this topic.
Legacy C-style variadic functions
The C-style approach comes directly from the C programming language. To implement variadic functions in this way, we need some macros available from the cstdarg header:
va_list— holds the arguments needed by the other macros;va_start— used to access the arguments.va_arg— retrieves the next argument. It requires the data type of the parameter we expect.va_copy— allows you to copy arguments. Introduced inC++11.va_end— cleans up the list when we're done.
Here's a simple example that finds the smallest value from a set of double values:
#include <iostream>
#include <cstdarg>
using namespace std;
double min_doubles(int count, ...) {
va_list args;
va_start(args, count);
double smallest = va_arg(args, double);
for (int i = 1; i < count; ++i) {
double current = va_arg(args, double);
if (current < smallest) {
smallest = current;
}
}
va_end(args);
return smallest;
}
int main() {
cout << "Smallest (same type): " << min_doubles(5, 4.5, 3.1, 7.7, 1.1, 8.9) << endl;
cout << "Smallest (mixed types): " << min_doubles(3, 2, 7.8f, 1L);
return 0;
}Notice that we are manually passing count so the function knows how many arguments to expect. This is a common pattern with C-style variadic functions. The function has no way of knowing how many arguments were passed on its own, so we have to tell it explicitly somehow. This is one of the reasons this approach can be error-prone; if the count is unknown, the behavior is undefined. Another way is to use a sentinel value.
Another concern is type safety. When we pass mixed types such as 2, 7.8f, and 1L alongside a double, va_arg has no way to verify the types at compile time. It blindly interprets the raw memory as whatever type you tell it to expect. This can silently produce incorrect values or undefined behavior as seen in the output:
Smallest (same type): 0.2
Smallest (mixed types): 4.94066e-324 # undefined behaviorFunction templates
A cleaner and safer modern alternative is to use variadic templates, introduced in C++11. Instead of relying on macros, the compiler itself handles the argument unpacking in a type-safe way.
In this setup, we write two versions of the function:
A base case that handles a single argument and stops the recursion.
A recursive template that peels off one argument at a time and calls itself with the rest.
#include <iostream>
using namespace std;
template <typename T>
T min_values(T first) {
return first;
}
template <typename T, typename... Args>
T min_values(T first, Args... rest) {
T smallest_of_rest = min_values(rest...);
return first < smallest_of_rest ? first : smallest_of_rest;
}
int main() {
cout << "Smallest (same type): " << min_values(4.5, 3.1, 7.7, 1.1, 8.9, 0.2) << endl;
cout << "Smallest (mixed types): " << min_values(4.0, 2, 7.8f, 1L) << endl;
return 0;
}The template <typename T> syntax is how we tell the compiler that this function works with any type, where typename is a keyword that indicates a type placeholder. In the recursive version, typename... Args extends this idea to declare a parameter pack — a placeholder for any number of types. Notice that the ... sits to the left of the name Args, which is what tells the compiler to pack the arguments into it. The same applies to Args... rest in the function parameters.
When we later write rest... inside the function body, the ... is now to the right of the name. This tells the compiler to do the opposite — “unpack” and expand the arguments for use. This is called a pack expansion. So the same ... symbol serves two different purposes depending on where it appears relative to the name.
Each recursive call to min_values(rest...) unpacks one fewer argument, until only one remains, and the base case kicks in. Unlike the C-style approach, there is no need to manually pass a count. In this case, the compiler knows exactly how many arguments are in the pack at compile time.
When mixed types are passed, the return type T is deduced from the first argument. Each subsequent argument is converted to match the type at its level of recursion. This can lead to data loss — for example, 7.8f passing through an int level would be truncated to 7. The compiler will issue warnings about this, so it is generally best to stick to consistent types when calling the function. If a type mismatch is severe enough that no implicit conversion exists, the compiler will refuse to compile altogether. This is a contrast to the C-style approach, where the same mistake goes unnoticed until runtime.
Here is another example that does not involve math operations. This print function can accept any number of arguments of any type and print them one by one:
#include <iostream>
using namespace std;
void print() {
cout << "Base function called last to stop recursion.\n";
}
template <typename T, typename... Args>
void print(T first, Args... rest) {
cout << first << endl;
print(rest...);
}
int main() {
print(3, 2, 1.9, "I love Hyperskill.", "ABCD", 12);
return 0;
}Notice that the base case here is a plain empty function rather than a template. It is called when there are no more arguments left. Since it does not need to return or handle any type, a regular function is sufficient. Congratulations, you just built your very own cout!
Fold expressions
Not every variadic function template needs to be recursive. In C++17, fold expressions were introduced. These provide a way to expand a parameter pack in a single expression, without the need for a base case or any recursive calls.
The syntax is simple:
(pack op ...)op is a binary operator such as +, -, *, /, &&, ||, and so on. The compiler expands this as:
arg1 op arg2 op arg3 op ... op argNHere's an example that sums up any number of arguments:
#include <iostream>
using namespace std;
template<typename... Args>
auto sum(Args... args) {
return (args + ...);
}
int main() {
cout << "Sum: " << sum(1, 2, 3, 4, 5, 6, 7, 8) << endl;
cout << "Sum: " << sum(1.5, 2.5, 3.0, 1, 4.5) << endl;
return 0;
}Here, auto is used as the return type. Because we don't know in advance what types will be passed, we let the compiler figure it out. This is noticeably simpler than the recursive template approach; the compiler handles the entire expansion in one step.
Fold expressions are only practical when the operation you need maps directly onto a binary operator. Other tasks may not fit this model, so recursion remains the natural choice for those.
Conclusion
Variadic functions are a useful tool when the number of arguments is not known in advance. The legacy C-style approach using cstdarg macros gets the job done but requires manual bookkeeping and offers no type safety. Variadic templates, introduced in C++11, are the preferred modern approach. They are type-safe, require no manual argument counting, and let the compiler do the heavy lifting. When the problem can be expressed as a repeated binary operation, fold expressions, introduced in C++17, offer an even simpler solution.