Ellipsis (and Why to Avoid Them)

In all the functions we've examined so far, the number of parameters has been fixed at compile time, even when using default parameters. However, C++ provides a mechanism called ellipsis (represented as ...) that allows functions to accept a variable number of arguments.

Warning
Ellipsis are rarely used in modern C++, potentially dangerous, and we strongly recommend avoiding them. This section is provided for completeness and to help you understand legacy code.

Ellipsis Syntax

Functions using ellipsis follow this pattern:

return_type function_name(parameter_list, ...)

Key requirements:

  • At least one regular (non-ellipsis) parameter is required
  • Regular parameters must come first
  • The ellipsis must always be the last parameter

The ellipsis captures any additional arguments beyond those specified in parameter_list. While not technically accurate, you can think of the ellipsis conceptually as an array holding extra parameters.

Basic Ellipsis Example

Let's write a function that calculates the mean of several doubles:

#include <iostream>
#include <cstdarg> // required for ellipsis support

// count specifies how many additional numbers follow
double calculateMean(int count, ...)
{
    double sum{0.0};

    // Declare a va_list to access ellipsis arguments
    std::va_list arguments;

    // Initialize va_list. First parameter: the va_list
    // Second parameter: the last non-ellipsis parameter
    va_start(arguments, count);

    // Extract each argument from the ellipsis
    for (int i{0}; i < count; ++i)
    {
        // va_arg extracts values from ellipsis
        // First parameter: the va_list
        // Second parameter: the type to extract
        sum += va_arg(arguments, double);
    }

    // Clean up the va_list
    va_end(arguments);

    return sum / count;
}

int main()
{
    std::cout << calculateMean(4, 10.0, 20.0, 30.0, 40.0) << '\n';
    std::cout << calculateMean(6, 5.5, 10.5, 15.5, 20.5, 25.5, 30.5) << '\n';

    return 0;
}

Output:

25
18

Let's break down the components:

  1. #include : Provides va_list, va_start, va_arg, and va_end macros
  2. std::va_list: A type that represents the ellipsis arguments
  3. va_start(): Initializes the va_list to point to the first ellipsis argument
  4. va_arg(): Retrieves the current argument and advances to the next one
  5. va_end(): Cleans up the va_list when finished

The ellipsis parameter has no name - you access its values through the va_list object.

Danger 1: No Type Checking

With regular function parameters, the compiler verifies that argument types match parameter types. This type safety helps catch errors.

Ellipsis completely suspend type checking. You can pass any type to an ellipsis parameter, and the compiler won't warn you if it's wrong.

Consider this dangerous example:

std::cout << calculateMean(5, 12.5, 20.0, 30.0, 40.0, 50.0) << '\n';

This looks correct, but what if we accidentally use an integer instead of a double?

std::cout << calculateMean(5, 100, 20.0, 30.0, 40.0, 50.0) << '\n';

Output:

2.38658e+07

That's clearly wrong! Here's what happened:

Computers store all data as sequences of bits. A variable's type tells the computer how to interpret those bits. Ellipsis throw away type information, so when you call va_arg(arguments, double), you're telling the system "interpret the next chunk of data as a double."

An int typically uses 4 bytes of storage, while a double uses 8 bytes. When we passed 100 (an int) as the first ellipsis argument, va_arg(arguments, double) read 8 bytes: the 4 bytes of the integer plus 4 bytes from the next argument. This produces garbage. The next va_arg call then reads the remaining 4 bytes of the second argument plus 4 bytes of the third, producing more garbage.

Even worse - this compiles without warnings:

double weight{75.5};
std::cout << calculateMean(5, 100, "invalid", 'X', &weight, &calculateMean) << '\n';

This mixes integers, strings, characters, pointers - complete nonsense. Yet it compiles and runs, producing garbage output. This exemplifies "garbage in, garbage out."

Danger 2: No Argument Count Tracking

Ellipsis don't track how many arguments were passed. You must devise your own tracking mechanism, typically using one of three methods:

Method 1: Pass a Length Parameter

Pass the count as a regular parameter (as in our calculateMean example above).

However, this relies on the caller providing the correct count:

std::cout << calculateMean(5, 10.0, 20.0, 30.0, 40.0) << '\n';

Output:

-1.18584e+08

We said 5 arguments but only provided 4. The function reads past the valid arguments into garbage memory values.

Opposite problem:

std::cout << calculateMean(4, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0) << '\n';

Output:

25

We provided 6 values but said 4. The function only processes the first 4 and ignores the rest. These errors are extremely difficult to detect.

Method 2: Use a Sentinel Value

A sentinel is a special value that marks the end of data. For example, C-style strings use '\0' as a sentinel.

Here's calculateMean rewritten with a sentinel value of -1.0:

#include <iostream>
#include <cstdarg>

double calculateMean(double first, ...)
{
    // Handle first value specially (it's not in the ellipsis)
    double sum{first};
    int count{1};

    std::va_list arguments;
    va_start(arguments, first);

    while (true)
    {
        double value{va_arg(arguments, double)};

        // Check for sentinel value
        if (value == -1.0)
            break;

        sum += value;
        ++count;
    }

    va_end(arguments);

    return sum / count;
}

int main()
{
    std::cout << calculateMean(10.0, 20.0, 30.0, 40.0, -1.0) << '\n';
    std::cout << calculateMean(5.5, 10.5, 15.5, 20.5, -1.0) << '\n';

    return 0;
}

Challenges with sentinels:

  • C++ requires at least one non-ellipsis parameter, so we must handle first specially
  • If the user forgets the sentinel, the function loops through garbage until it finds a matching value (or crashes)
  • Sentinels only work when a value exists outside the valid range (what if we need negative numbers?)

Method 3: Use a Decoder String

A decoder string specifies the type of each argument:

#include <iostream>
#include <string_view>
#include <cstdarg>

double calculateMean(std::string_view types, ...)
{
    double sum{0.0};
    int count{0};

    std::va_list arguments;
    va_start(arguments, types);

    for (char type : types)
    {
        if (type == 'd') // double
        {
            sum += va_arg(arguments, double);
            ++count;
        }
        else if (type == 'i') // int
        {
            sum += va_arg(arguments, int);
            ++count;
        }
    }

    va_end(arguments);

    return count > 0 ? sum / count : 0.0;
}

int main()
{
    std::cout << calculateMean("dddd", 10.0, 20.0, 30.0, 40.0) << '\n';
    std::cout << calculateMean("ddiddi", 5.5, 10.5, 100, 15.5, 20.5, 200) << '\n';

    return 0;
}

This method can handle mixed types, but the decoder string is cryptic and must match the arguments exactly, or undefined behavior occurs.

Recommendations

First choice: Don't use ellipsis at all! Better alternatives usually exist. For our calculateMean example, we could pass a std::vector<double> or an array, providing type safety and automatic size tracking.

If you must use ellipsis:

  1. Use consistent types: All ellipsis arguments should be the same type to minimize type confusion
  2. Prefer count or decoder parameters over sentinels: This forces explicit specification of argument count, reducing sentinel-related errors
  3. Document extensively: Clearly document expected types and argument count requirements

Advanced note: C++11 introduced parameter packs and variadic templates, which provide ellipsis-like functionality with full type safety. C++17 added fold expressions, making parameter packs much more practical. These modern features should be used instead of traditional ellipsis in new code.

Summary

Ellipsis (...): A mechanism that allows functions to accept a variable number of arguments, though with significant limitations and dangers.

Key components: The <cstdarg> header provides std::va_list for representing ellipsis arguments, va_start() for initialization, va_arg() for retrieval, and va_end() for cleanup.

Major danger 1 - No type checking: Ellipsis completely suspend type checking, allowing any type to be passed. Using va_arg() with the wrong type leads to undefined behavior as the function misinterprets the bit patterns of the arguments.

Major danger 2 - No argument count tracking: Ellipsis don't track how many arguments were passed. You must implement your own tracking using: a length parameter (error-prone if count is wrong), a sentinel value (fails if sentinel is forgotten or is a valid value), or a decoder string (cryptic and error-prone).

Partial extraction problem: When extracting multiple values, errors can leave objects in inconsistent states with some values updated and others not, which is particularly dangerous.

Best practices for ellipsis: Use consistent types for all ellipsis arguments, prefer count/decoder parameters over sentinels, and document extensively. However, the overarching recommendation is simple: avoid ellipsis entirely in modern C++.

Modern C++ provides far better alternatives: use containers like std::vector for type-safe variable-length argument lists, or use parameter packs and variadic templates (C++11) with fold expressions (C++17) for compile-time type-safe variadic functions. These modern features eliminate all the dangers of traditional ellipsis while providing cleaner, safer code.