Ellipsis in C++

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.

  1. va_list:
    1. Think of it as a pointer that points to the beginning of the variable arguments list.
  2. va_start: macro to initialize the argument list.
    1. When you call va_start, it initializes the va_list pointer to the beginning of the variable arguments list. It's like setting the pointer to the first element on the stack.
  3. va_arg: macro to retrieve the next argument in argument list.
    1. va_arg is used to retrieve the next argument from the variable arguments list pointed to by the va_list pointer. Each time you call va_arg, the pointer moves to the next argument on the stack.
    2. Dereferences the va_list cursor to retrieve the value of the current argument.
    3. Moves the va_list cursor to the next argument in the list.
  4. va_end: macro to cleanup argument list.
    1. Finally, when you're done processing variable arguments, you call va_end to clean up. It's like releasing the resources associated with the va_list pointer.
  • 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
}
  1. #include <stdarg.h>: This directive includes the standard C library header <stdarg.h>, which contains macros used for handling variable arguments.
  2. return_type: Specifies the data type of the value returned by the function.
  3. function_name: The name of the function being defined.
  4. fixed_arg_type arg1: The fixed arguments preceding the ellipsis (...). These arguments are accessed directly within the function body.
  5. ...: Indicates that the function accepts a variable number of arguments.
  6. va_list args: Declares a va_list variable to hold the variable arguments.
  7. va_start(args, arg1): Initializes the va_list variable to point to the first variable argument after arg1. The second argument (arg1) is the last fixed argument before the ellipsis.
  8. Processing Variable Arguments: Inside the function body, you can use the va_arg macro to retrieve each variable argument one by one.
  9. va_end(args): Cleans up the va_list variable when all arguments have been processed. It's essential to call va_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 the va_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.
    • The va_start macro uses count to determine where the variable arguments start in memory.
  • va_arg:
    • Once you have initialized the va_list, you can access the variable arguments using the va_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.
  • va_end:
    • After you've finished accessing all the variable arguments, you call va_end to clean up and release any resources associated with the va_list.
    • va_end(arg);

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;
}
variadic-func-1.jpg
variadic-func2.jpg
variadic-func3.jpg

Rules:

Variadic functions follow some rules to ensure proper usage and behavior. Here are the key rules:

  1. 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.
  2. Ellipsis must be last argument: The ellipsis (. . .) must be the last argument in function definition.
  3. 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.
  4. 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 defines va_list, va_arg, va_start, and va_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:

  1. the va_list itself, and the name of the last non-ellipsis parameter in the function, once va_start() has been called, va_list points to the first parameter in the ellipsis.
  2. To get the value of the parameter the va_list currently points to, we use va_arg(). va_arg() also takes two parameters:
    1. the va_list itself, and the type of parameter we are trying to access. Note that va_arg() also moves the va_list to the next parameter in the ellipsis (after retrieving value it advances the list pointer).

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 the va_list to point to the first variable argument.
    • va_arg: Retrieve each argument in turn.
    • va_end: Clean up the va_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) initializes args 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:

  1. Type Safety: The programmer must manually ensure the type and number of arguments are correct, leading to potential runtime errors.
  2. 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 a get function to retrieve the value at a specific index.