xx - variadic arguments

Sometimes there is a desire to provide varying amount of arguments to a function. Arrays (objects composed of multiple subobjects of the same type) could be used for this purpose but they are limited to one type.

Variadic arguments (often referred to as va-args) allow to pass an arbitrary amount of objects of a limited set of types to a function through a magic ... argument. By far, the most well-known and the most utilized functions with variadic arguments is the printf family of functions from the C standard library (also available in C++):

1
2
3
4
5
6
7
8
// print to standard output
int printf(const char* format, ...);
// print to specified stream - printf(args...) is equivalent to fprintf(stdout, args...)
int fprintf(std::FILE* stream, const char* format, ...);
// print to a buffer
int sprintf(char* buffer, const char* format, ...);
// print to a buffer with specified maximum size
int snprintf(char* buffer, std::size_t buf_size, const char* format, ...);

All functions take a string which specifies formatting and a variadic set of arguments. All functions return the number of printed characters.

1
2
3
4
5
6
7
8
9
#include <cstdio>

int main()
{
	int x = 0xdeadbeef;
	long l = 6 * 142857;
	float f = 1.f / 7;
	std::printf("x = %x\nl = %ld\nf = %f\n", x, l, f);
}

How it works

A function which accepts va-args is not aware how many arguments have been passed. Additionally, for implementation reasons (different size and alignment of different types):

  • various built-in types undergo through a specific set of convertions:

    • float to double

    • bool, char, short and unscoped enumerations to int

    • few others for arrays, pointers and other kinds of types

  • user-defined types are not supported or supported with implementation-defined semantics

  • the behavior is undefined if the last parameter before ... has reference type or is not supported in the set of convertions

In other words, arguments passed through magic ... undergo various transformations so that the implementation can assume certain things about them. This greatly limits the set of allowed types, practically to only simple built-in types.

The function from the inside has to use a set of magic macros which can be roughly represented as such functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// magic type representing a set of variadic arguments
typedef /* unspecified */ va_list;

// read variadic arguments into ap, parm_n must be the name of the parameter before ...
void va_start(va_list ap, parm_n);

// obtain the next parameter from ap, assuming it has type T
T va_arg(va_list ap, T);

// end traversal of the variadic argument list
void va_end(va_list ap);

// copy variadic arguments from src to dest
void va_copy(va_list dest, va_list src);

Again, for implementation reasons the macros have weird forms and expect the caller to know certain things about the parameters. This is why printf-family of functions requires to provide format strings like %ld so that it knows how to extract actual data.

Even a simple function which adds numbers (assuming all are of type int) is fairly complicated:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <cstdarg>

int add_nums(int count, ...)
{
	int result = 0;
	std::va_list args;
	va_start(args, count);

	for (int i = 0; i < count; ++i)
		result += va_arg(args, int);

	va_end(args);
	return result;
}

int main()
{
	std::cout << add_nums(4, 25, 25, 50, 50) << "\n";
}

Why it's bad

Variadic arguments, due to their design, cause numerous problems:

  • They require to use macros, and in C++ macros should be used only as a last resort.

  • These macros are "magic", they require compiler support to work and have unusual syntax.

  • These macros are not even UPPERCASE, thus they violate a very strong convention.

  • Everything related to their usage is extremely bug-prone: basically any mistake ends in undefined behavior.

  • Even if a function with variadic arguments is perfectly written, it's still possible to call it with invalid parameters to invoke undefined behavior.

  • Using functions with variadic arguments is not trivial, just see printf documentation.

  • The set of supported types is limited.

  • Supported types undergo through specific convertions.

  • Even passing variadic arguments from one to another function is not trivial.

  • There are no performance benefits.

In short, it's a very type unsafe feature with lots of opportunities to write dysfunctional code. The only benefit is the ability to pass an arbitrary amount of objects of different types, though many "terms and conditions" apply.

Variadic arguments are so bug-prone that compilers implemented special warnings, just for the printf-family of functions. They scan format strings and compare them with available compile time information about other arguments to detect potential mistakes. It's a huge failure that statically typed languages such as C and C++ require the programmer to manually provide type information.

Variadic arguments are simply against core C and C++ goals.

Alternatives

Due to complexity, risk and uintuitiveness of variadic arguments, everything else is better:

  • function overloading - supports different types

  • arrays - supports arbitrary amount of arguments

  • std::initializer_list - an alternative to arrays

  • (variadic) templates - they also use ... but work totally differently

Libraries with formatting:

  • Boost.Format (header-only): has printf-like interface but is implemented through templates and has full type safety

  • fmtlib (header-only): the proper design and implementation of a printf-like function according to C++ goals, since C++20 part of fmt is in the standard library header <format>