In all of the function we built so far, the number of parameters a function will take must be known in advance (even if they have default values). However, there are certain cases where it can be useful to be able to pass a variable number of parameters to a function. C++ provides a special specifier known as ellipsis (…
) that allow us to do this.
What is Variadic Function?
A variadic function is a function that is designed to accept a variable number of arguments. Variadic functions are particularly useful when the number of arguments is not known at compile time or when the function needs to accommodate a varying number of parameters.
In C and C++, variadic functions are typically implemented using variadic templates (in C++) or with the help of macros provided by the <stdarg.h>
header (in C). These macros include va_list
, va_start
, va_arg
, and va_end
, which allow the function to access and process the variable arguments.
variadic function definition:
int sum(int n, …)
- Here
…
signals that there can be variable number of arguments.
There are different ways to achieve variable argument passing in C++:
Different Ways to Pass Variable Argument
1 Traditional C-Style (Older Approach):
In older codebase or when interfacing with C libraries, C-style variadic function can still be used. However, they lack type safety and are generally less flexible.
Syntax
return_type function_name(argument_list, ...)
The argument_list
is one or more normal function parameters. Note that functions that use ellipsis must have at least one non-ellpsis parameter. Any arguments passed to the function must match the argument_list parameters first.
- The ellipsis (which is represented as three periods in a row) must always be the last parameter in the function.
- It is conceptually useful to think of the ellipsis as an array that holds any additional parameters beyond those in the
argument_list
.
Traditional Approach used four macros defined in <cstdarg.h>
for C++ and <stdarg.h>
for C.
va_list
:- Think of it as a pointer that points to the beginning of the variable arguments list.
va_start
: macro to initialize the argument list.- When you call
va_start
, it initializes theva_list
pointer to the beginning of the variable arguments list. It's like setting the pointer to the first element on the stack.
- When you call
va_arg
: macro to retrieve the next argument in argument list.va_arg
is used to retrieve the next argument from the variable arguments list pointed to by theva_list
pointer. Each time you callva_arg
, the pointer moves to the next argument on the stack.- Dereferences the
va_list
cursor to retrieve the value of the current argument. - Moves the
va_list
cursor to the next argument in the list.
va_end
: macro to cleanup argument list.- Finally, when you're done processing variable arguments, you call
va_end
to clean up. It's like releasing the resources associated with theva_list
pointer.
- Finally, when you're done processing variable arguments, you call
- The header file we’re going to use is
<cstdarg>
in C++ and<stdarg.h>
in C.
Syntax:
#include <stdarg.h>
return_type function_name(fixed_arg_type arg1, ...) {
va_list args;
va_start(args, arg1); // Initialize va_list to point to the first variable argument
// Process variable arguments using va_arg
va_end(args); // Cleanup va_list
}
#include <stdarg.h>
: This directive includes the standard C library header<stdarg.h>
, which contains macros used for handling variable arguments.return_type
: Specifies the data type of the value returned by the function.function_name
: The name of the function being defined.fixed_arg_type arg1
: The fixed arguments preceding the ellipsis (...
). These arguments are accessed directly within the function body....
: Indicates that the function accepts a variable number of arguments.va_list args
: Declares ava_list
variable to hold the variable arguments.va_start(args, arg1)
: Initializes theva_list
variable to point to the first variable argument afterarg1
. The second argument (arg1
) is the last fixed argument before the ellipsis.- Processing Variable Arguments: Inside the function body, you can use the
va_arg
macro to retrieve each variable argument one by one. va_end(args)
: Cleans up theva_list
variable when all arguments have been processed. It's essential to callva_end
to avoid resource leaks.
Visualization:
va_list args; // Declare a variable to hold the pointer to the variable argument list
va_start(args, arg1); // Initialize the pointer to the first variable argument after 'arg1'
// Access variable arguments using va_arg
int next_arg = va_arg(args, int); // Retrieve the next argument from the variable argument list, interpreting it as an int
// Process variable arguments...
va_end(args); // Clean up the pointer after processing all variable arguments
va_list
:- Special pointer type used for traversing the arguments.
- To use variadic function you declare
va_list
variable. va_list arg;
va_start
:- This macro initializes the
va_list
variable to prepare for accessing the variable arguments. - It's like saying “start recording variable arguments from this point onwards”.
va_start(arg, count);
- Here,
count
is the last fixed argument before the ellipsis (…
). It tells the compiler where the variable arguments begin. args
: This is theva_list
variable that acts as a pointer to the list of variable arguments.count
: This is the last fixed argument before the variable arguments. It serves as a reference point for where the variable arguments begin.
- Here,
- The
va_start
macro usescount
to determine where the variable arguments start in memory.
- This macro initializes the
va_arg
:- Once you have initialized the
va_list
, you can access the variable arguments using theva_arg
macro. - This macro retrieves the next argument of a specified type from the
va_list
. int num = va_arg(arg, int);
- Here,
int
specifies the type of the next argument. You should provide the expected type to ensure proper retrieval. - It is used in loop to fetch next argument, from 1 to count.
- Here,
- Once you have initialized the
va_end
:- After you've finished accessing all the variable arguments, you call
va_end
to clean up and release any resources associated with theva_list
. va_end(arg);
- After you've finished accessing all the variable arguments, you call
Example:
Here's a simple example of a variadic function that calculates the sum of a variable number of integers:
#include <stdarg.h>
int sum(int count, ...) {
va_list args;
va_start(args, count); // Initialize va_list to first variable argument
int total = 0;
for (int i = 0; i < count; ++i) {
total += va_arg(args, int); // Retrieve each argument as an int
}
va_end(args); // Cleanup va_list
return total;
}
Rules:
Variadic functions follow some rules to ensure proper usage and behavior. Here are the key rules:
- At least one fixed argument: A variadic function must have at least one fixed argument before the ellipsis (
...
). This fixed argument provides context for the variable arguments. - Ellipsis must be last argument: The ellipsis (
. . .
) must be the last argument in function definition. - Fixed argument count: The fixed argument(s) should provide information about the number or types of variable arguments. Variadic functions often accept an additional argument specifying the number of variable arguments.
- No overloading: Variadic functions cannot be overloaded in C. However, you can define multiple variadic functions with different names or use function pointers to achieve similar behavior.
Another Example:
#include <iostream>
#include <cstdarg> // needed to use ellipsis
// The ellipsis must be the last parameter
// count is how many additional arguments we're passing
double findAverage(int count, ...)
{
int sum{ 0 };
// We access the ellipsis through a va_list, so let's declare one
std::va_list list;
// We initialize the va_list using va_start. The first argument is
// the list to initialize. The second argument is the last non-ellipsis
// parameter.
va_start(list, count);
// Loop through all the ellipsis values
for (int arg{ 0 }; arg < count; ++arg)
{
// We use va_arg to get values out of our ellipsis
// The first argument is the va_list we're using
// The second argument is the type of the value
sum += va_arg(list, int);
}
// Cleanup the va_list when we're done.
va_end(list);
return static_cast<double>(sum) / count;
}
int main()
{
std::cout << findAverage(5, 1, 2, 3, 4, 5) << '\n';
std::cout << findAverage(6, 1, 2, 3, 4, 5, 6) << '\n';
return 0;
}
// Output
3
3.5
As you can see, this function takes a variable number of parameters.
Now, let's take a look at the components that make up this example.
- First, we have to include
cstdarg
header. This header definesva_list
,va_arg
,va_start
, andva_end
, which are macros that we need to use to access the parameters that are part of the ellipsis. - We then declare our function that used the ellipsis. Remember that the argument list must be one or more fixed parameters. In this case, we are passing in a single integer that tells us how many numbers to average.
- The ellipsis always comes last.
Note that the ellipsis parameter has no name. Instead, we access the values in the ellipsis through a special type known as va_list
. It is conceptually useful to think va_list
is a pointer that points to the ellipsis array. First, we declare a va_list
, which we have called list
for simplicity.
The next thing we need to do is make list point to our ellipsis parameters. We do this by calling va_start()
. va_start()
takes two parameters:
- the
va_list
itself, and the name of the last non-ellipsis parameter in the function, onceva_start()
has been called,va_list
points to the first parameter in the ellipsis. - To get the value of the parameter the
va_list
currently points to, we useva_arg()
.va_arg()
also takes two parameters:- the
va_list
itself, and the type of parameter we are trying to access. Note thatva_arg()
also moves theva_list
to the next parameter in the ellipsis (after retrieving value it advances the list pointer).
- the
Finally, to clean up when we are done, we call va_end()
, with va_list
as the parameter.
Note that va_start()
can be called again any time we want to reset the va_list
to point to the first parameter in the ellipsis again.
Diagram Explanation:
Imagine the stack layout when a variadic function is called. The stack grows downwards, and arguments are pushed onto the stack.
- Arguments are pushed onto the stack from right to left.
Complete Example:
#include <stdio.h>
#include <stdarg.h>
void exampleFunction(int fixedParam, ...) {
va_list args;
va_start(args, fixedParam);
char c = va_arg(args, char);
double d = va_arg(args, double);
char* str = va_arg(args, char*);
printf("Fixed parameter: %d\n", fixedParam);
printf("First argument (char): %c\n", c);
printf("Second argument (double): %f\n", d);
printf("Third argument (string): %s\n", str);
va_end(args);
}
int main() {
exampleFunction(1, 'A', 2.5, "Hello");
return 0;
}
// Output
Fixed parameter: 1
First argument (char): A
Second argument (double): 2.500000
Third argument (string): Hello
Let's consider a function exampleFunction(int fixedParam, ...)
being called with exampleFunction(1, 'A', 2.5, "Hello")
.
- Stack Layout:
|------------------------|
| "Hello" | <- Top of the stack (latest argument)
|------------------------|
| 2.5 |
|------------------------|
| 'A' |
|------------------------|
| fixedParam = 1 | <- Bottom of the stack (fixed parameter)
|------------------------|
- Steps with Macros:
va_list
: Declare a variable to handle the variable arguments.va_start
: Initialize theva_list
to point to the first variable argument.va_arg
: Retrieve each argument in turn.va_end
: Clean up theva_list
.
Detailed Steps and Diagrams:
Step 1: Declaring va_list
:
#include <stdarg.h>
void exampleFunction(int fixedParam, ...) {
va_list args;
// More code...
}
va_list args
: This variable will hold information about the current position in the variable argument list.
Step 2: Initializing with va_start
:
va_start(args, fixedParam);
va_list args points to:
|------------------------|
| "Hello" |
|------------------------|
| 2.5 |
|------------------------|
| 'A' | <- args starts here (initial position)
|------------------------|
| fixedParam = 1 |
|------------------------|
- Explanation:
va_start(args, fixedParam)
initializesargs
to point to the first variable argument ('A'
).
Step 3: Retrieving Arguments with va_arg
char c = va_arg(args, char);
double d = va_arg(args, double);
char* str = va_arg(args, char*);
- Diagram (after each
va_arg
call):
After char c = va_arg(args, char)
:
c = A
args points to:
|------------------------|
| "Hello" |
|------------------------|
| 2.5 | <- args now here
|------------------------|
| 'A' |
|------------------------|
| fixedParam = 1 |
|------------------------|
After double d = va_arg(args, double)
:
d = 2.5
args points to:
|------------------------|
| "Hello" | <- args now here
|------------------------|
| 2.5 |
|------------------------|
| 'A' |
|------------------------|
| fixedParam = 1 |
|------------------------|
After char* str = va_arg(args, char*)
:
str = "Hello"
args points to: beyond the last argument.
|------------------------|
| "Hello" |
|------------------------|
| 2.5 |
|------------------------|
| 'A' |
|------------------------|
| fixedParam = 1 |
|------------------------|
Each call to va_arg
retrieves the current argument and advances the args
pointer to the next argument in the list.
Step 4: Cleaning Up with va_end
va_end(args);
va_list args is no longer valid:
|------------------------|
| "Hello" |
|------------------------|
| 2.5 |
|------------------------|
| 'A' |
|------------------------|
| fixedParam = 1 |
|------------------------|
va_end(args)
performs any necessary cleanup. After this call,args
is no longer valid.
Drawbacks of C-style Variadic Function:
- Type Safety: The programmer must manually ensure the type and number of arguments are correct, leading to potential runtime errors.
- Flexibility: Limited to functions, not usable with templates and classes directly.
Why ellipsis are dangerous:
1 Type checking is suspended
Ellipsis offer the programmer a lot of flexibility to implement function that can take a variable number of parameters. However, this flexibility comes with some downsides.
With regular function parameters, the compiler uses type checking to ensure the types of the function arguments match the type of the function parameters (or can be implicitly converted so they match.) This helps ensure you don't pass a function an integer when it was expecting a string, or vice versa. However, note that ellipsis parameters have no type declarations. This means it is possible to send arguments of any type to the ellipsis. However, the downside is that the compiler will no longer be able to warn you if you call the function with ellipsis arguments that do not make sense. When using the ellipsis, it is completely up to the caller to ensure the function is called with ellipsis arguments that the function can handle. Obviously that leaves quite a bit of room for error (especially if the caller was not the one who wrote the function).
For Example:
std::cout << findAverage(6, 1.0, 2, 3, 4, 5, 6) << '\n';
Although this may look harmless enough at first glance, note that the second argument (the first ellipsis argument) is a double instead of an integer. This compiles fine, and produces a somewhat surprising result:
1.78782e+008
but it prints
3.5
2 Ellipsis don't know how many parameters were passed
Not only do the ellipsis throw away the type of the parameters, it also throws away the number of parameters in the ellipsis. This means we have to devise our own solution for keeping track of the number of parameters passed into the ellipsis. Typically, this is done in one of three ways.
Method 1: Pass a length parameter:
To pass one of the fixed parameter represent the number of optional parameters passed. This is the solution we use in the findAverage() example above.
However, even here we run into trouble. For example, consider the following call:
std::cout << findAverage(6, 1, 2, 3, 4, 5) << '\n';
// Output 2.66667
Here, we told findAverage()
we are going to provide 6
additional values, but we only gave it 5
. Consequently, the first five values that va_arg()
returns were the ones we have passed in. The 6th
value it returns was a garbage value somewhere in the stack. Consequently, we got a garbage answer.
The below will produce the perfect result:
std::cout << findAverage(6, 1, 2, 3, 4, 5, 6, 7) << '\n';
// Output
3.5
Method 2: Use a sentinel value
To use a sentinel value. A sentinel is a special value that is used to terminate a loop when it encountered. For example, with strings, the null terminator is used as a sentinel value to denote the end of the string. With ellipsis, the sentinel is typically passed in as the last parameter. Here's an example:
#include <iostream>
#include <cstdarg> // needed to use ellipsis
// The ellipsis must be the last parameter
double findAverage(int first, ...)
{
// We have to deal with the first number specially
int sum{ first };
// We access the ellipsis through a va_list, so let's declare one
std::va_list list;
// We initialize the va_list using va_start. The first argument is
// the list to initialize. The second argument is the last non-ellipsis
// parameter.
va_start(list, first);
int count{ 1 };
// Loop indefinitely
while (true)
{
// We use va_arg to get values out of our ellipsis
// The first argument is the va_list we're using
// The second argument is the type of the value
int arg{ va_arg(list, int) };
// If this parameter is our sentinel value, stop looping
if (arg == -1)
break;
sum += arg;
++count;
}
// Cleanup the va_list when we're done.
va_end(list);
return static_cast<double>(sum) / count;
}
int main()
{
std::cout << findAverage(1, 2, 3, 4, 5, -1) << '\n';
std::cout << findAverage(1, 2, 3, 4, 5, 6, -1) << '\n';
return 0;
}
Note that we no longer need to pass an explicit length as the first parameter. Instead, we pass a sentinel value as the last parameter.
However, there are couple of challenges here,
- First, C++ requires that we pass at least one fixed parameter. In the previous example, this was our count variable. In this example, the first value is actually part of the number to be averaged. So instead of treating the first value to be averaged as part of the ellipses parameter, we explicitly declare it as a normal parameter. We then need special handling for it inside the function.
- Second, this requires the user to pass in the sentinel as the last value. If the user forgets to pass in the value (or passes in the wrong value), the function will loop continuously until it runs into garbage that matches the sentinel (or crashes).
- Finally, note that we have chosen
-1
as our sentinel. That's fine if we only wanted to find the average of positive numbers, but what if we wanted to include negative numbers? Sentinel values only work well if there is a value that falls outside the valid set of values for the problem you are trying to solve.
Method 3: Use a decoder string
It involves passing a “decoder string” that tells the program how to interpret the parameters.
#include <iostream>
#include <string_view>
#include <cstdarg> // needed to use ellipsis
// The ellipsis must be the last parameter
double findAverage(std::string_view decoder, ...)
{
double sum{ 0 };
// We access the ellipsis through a va_list, so let's declare one
std::va_list list;
// We initialize the va_list using va_start. The first argument is
// the list to initialize. The second argument is the last non-ellipsis
// parameter.
va_start(list, decoder);
for (auto codetype: decoder)
{
switch (codetype)
{
case 'i':
sum += va_arg(list, int);
break;
case 'd':
sum += va_arg(list, double);
break;
}
}
// Cleanup the va_list when we're done.
va_end(list);
return sum / std::size(decoder);
}
int main()
{
std::cout << findAverage("iiiii", 1, 2, 3, 4, 5) << '\n';
std::cout << findAverage("iiiiii", 1, 2, 3, 4, 5, 6) << '\n';
std::cout << findAverage("iiddi", 1, 2, 3.5, 4.5, 5) << '\n';
return 0;
}
In this example, we pass a string that encodes both the number of optional variables and their types. The cool thing is that this lets us deal with parameters of different types. However, this method has downsides as well:
- The decoder string can be a bit cryptic, and if the number or types of the optional parameters don't match the decoder string precisely, bad things can happen.
2 Variadic Template
It is a feature introduced in C++11 that allow you to write functions and classes that operate on an arbitrary number of arguments.
They provide a way to define templates that can take a variable number of template parameters, offering a type-safe alternative to C-style variadic functions.
Variadic template allow functions and classes to accept a variable number of arguments.
2.1 Basics of Variadic Templates:
Template Parameter Packs: A template parameter pack allows you to define a template that can take an arbitrary number of template parameters.
template<typename... Args>
class MyClass {
// ...
};
Function Parameter Packs: A function parameter pack allows a function to accept a variable number of arguments.
template<typename... Args>
void myFunction(Args... args) {
// ...
}
2.2 Expanding Parameter Packs:
Expanding parameter packs is a critical aspect of variadic templates. It involves unpacking the parameters and processing them.
2.3 Recursive Variadic Functions:
2.3.1 Old Recursion Variadic Function:
Before fold expression, variadic templates often used recursion to handle each argument.
#include <iostream>
// Base case for recursion
void print() {
std::cout << "The will be called lastly."<< std::endl;
}
// Recursive variadic template function
template<typename T, typename... Args>
void print(T first, Args... args) {
std::cout << first << " ";
print(args...); // Recursive call print with remaining arguments
}
int main() {
print(1, 2.5, "hello", 'a');
return 0;
}
// Output
1 2.5 hello a The will be called lastly.
Explanation:
- Template Parameter Pack:
typename... Args
allows the template to accept an arbitrary number of types. - Function Parameter Pack:
Args... args
allows the function to accept an arbitrary number of arguments. - Recursive Call: The function processes one argument and recursively calls itself with the remaining arguments.
- Base Case: When no arguments are left, the base case function prints a newline.
2.3.2 Fold Expressions (C++17)
C++17 introduced expressions, simplifying operations on parameter packs by eliminating the need for recursive templates.
#include <iostream>
template<typename... Args>
auto sum(Args... args) {
return (args + ...); // Right fold expression to sum all arguments
}
int main() {
std::cout << "Sum: " << sum(1, 2, 3, 4, 5) << std::endl; // Outputs: Sum: 15
return 0;
}
// Output
Sum: 15
- Fold Expression:
(args + ...)
expands the parameter pack and applies the+
operator between each argument, effectively summing all arguments.
Another Example:
template<typename... Args>
void print(Args... args) {
(std::cout << ... << args) << std::endl; // C++17 fold expression
}
This fold expression prints each argument in the parameter pack.
2.4 Variadic Class Templates
Variadic templates are also useful for creating classes that can handle a variable number of types.
This feature is particularly useful for designing data structures like tuples, variant types, and containers that need to be generic over an arbitrary number of types.
Template Parameter Pack
A template parameter pack is used to accept a variable number of template arguments. It is declared using typename...
(or class...
), followed by the parameter pack name.
template<typename... Types>
class MyClass {
// Implementation
};
Example: Simple Variadic Class Template
#include <iostream>
// Base case for the variadic class template
template<typename... Types>
class MyTuple;
// Recursive case for the variadic class template
template<typename T, typename... Types>
class MyTuple<T, Types...> : public MyTuple<Types...> {
T value;
public:
MyTuple(T v, Types... vs) : MyTuple<Types...>(vs...), value(v) {}
T get_value() const { return value; }
template<size_t idx>
auto get() const {
if constexpr (idx == 0) {
return value;
} else {
return MyTuple<Types...>::template get<idx - 1>();
}
}
};
// Specialization for the empty tuple
template<>
class MyTuple<> {};
int main() {
MyTuple<int, double, std::string> t(42, 3.14, "Hello");
std::cout << t.get<0>() << " " << t.get<1>() << " " << t.get<2>() << std::endl; // Outputs: 42 3.14 Hello
return 0;
}
Explanation:
1 Base Case:
template<typename... Types>
class MyTuple {};
- This is the base case for the recursive definition. It handles the case when there are no more types left.
2 Recursive Case:
template<typename T, typename... Types>
class MyTuple<T, Types...> : public MyTuple<Types...> {
T value;
public:
MyTuple(T v, Types... vs) : MyTuple<Types...>(vs...), value(v) {}
template<size_t idx>
auto get() const {
if constexpr (idx == 0) {
return value;
} else {
return MyTuple<Types...>::template get<idx - 1>();
}
}
};
- The recursive case inherits from the tuple of the remaining types, stores a value of type
T
, and provides aget
function to retrieve the value at a specific index.