Introduction
In Variadic Functions in C, I wrote:
C++ inherited variadic functions from C, warts and all. However, in C++ there are the much better alternatives of function overloading, initializer lists, and variadic templates that can be used to implement functions that accept varying numbers of arguments in a type-safe way — but that’s a story for another time.
That time is now.
Function Overloading & Default Arguments
If you want a function that can take a varying (but finite) number of arguments, possibly of different types, you can (and should) simply use either function overloading or default arguments. Most wouldn’t even consider such functions to be variadic. Nevertheless, function overloading and default arguments can eliminate some of the use-cases for variadic functions in a simple and type-safe way.
Initializer Lists
However, if you want a function that can take a varying (and unbounded) number of arguments of the same type, C++ offers initializer lists. For example, here’s a C++ implementation of the sum_n()
function given in the C article:
int sum_n( std::initializer_list<int> list ) {
int sum = 0;
for ( int i : list )
sum += i;
return sum;
}
Then you can call it like:
int r = sum_n( { 1, 2, 5 } ); // r = 8; {} are required
Note that the {}
are required to specify an initializer list literal. Without the {}
, you’d be attempting to call sum_n()
with 3 separate int
arguments rather than 1 initializer list of 3 int
elements.
If you don’t like the required use of {}
, you can hide them with a macro:
#define sum_n(...) sum_n( { __VA_ARGS__ } )
int r = sum_n( 1, 2, 5 ); // no {} now
Reminder: the C preprocessor will not expand a macro that references itself.
An initializer_list<T>
is implemented as if it were a const T[N]
where N is the number of elements in the list. While efficient, this has a few caveats:
- The
const
prevents moving. - The storage for the elements is contiguous.
- Either of those caveats can cause elements to be copied.
For trivial types like int
, none of these caveats matter; but for types that have non-trivial constructors or lack copy assignment operators, these can matter a lot. Consider:
void f( std::initializer_list<std::string> list );
void g() {
std::string s1{ "hello" }, s2{ "world" };
f( { s1, s2 } );
}
Under the hood, it’s as if this happens:
std::string const _list[2] = { s1, s2 };
f( _list );
That is, the strings are copied into a temporary array to make the storage contiguous.
If you want a function that can take a varying (and unbounded) number of arguments without the caveats of initializer_list
or of possibly different types, C++ also offers variadic templates.
Variadic Templates
Here’s a C++ implementation of the sum_n()
function given in the C article using variadic templates:
constexpr int sum_n( std::convertible_to<int> auto... ns ) {
return (0 + ... + ns);
}
Then you can call it just as before:
int r = sum_n( 1, 2, 5 ); // r = 8
This has big differences from either the C or previous C++ version:
- The implementation is much shorter.
- It’s type-safe.
- The iteration over the arguments is done at compile-time!
The use of auto
makes this an abbreviated function template. That is, it’s a shorthand for the more verbose:
template<std::convertible_to<int>... Ts>
constexpr int sum( Ts... ns ) {
return (0 + ... + ns);
}
Unlike the
initializer_list
implementation, this template implementation must be in a header file.
The std::convertible_to<int>
constrains the type that auto
(in the original code) or Ts
(in the verbose code) can be to one that either is or convertible to int
.
The ...
in the parameter declaration in the original code denotes a function template parameter pack, that is zero or more parameters of a type that are convertible to int
.
It’s no accident that the
...
token that originally was used to specify variadic parameters was reused to specify variadic templates also.
The ...
in (0 + ... + ns)
denotes a fold expression (here, specifically a binary left fold) that’s a concise way of performing the operation (here, +
) on the sequence of expanded function arguments comprising ns
. Note that the ()
are required.
Personally, I think the
()
syntax for fold expressions is too subtle since, historically, the addition of()
to any expression didn’t change its meaning, e.g.,a + b
and(a + b)
mean the same thing.
The constexpr
makes the compiler evaluate the function at compile-time if all arguments are constant expressions.
Another Example: No Sentinel Needed
Here’s a C++ implementation of the str_is_any()
function given in the C article:
bool str_is_any( std::string const &needle,
std::convertible_to<char const*> auto... haystack ) {
return ( (needle == haystack) || ... );
}
And you can call it like:
if ( str_is_any( s, "struct", "union", "class" ) )
This also has big differences from the C version:
- The implementation is much shorter.
- It’s type-safe.
- The iteration over the arguments is done at compile-time!
- The caller doesn’t pass a sentinel of
nullptr
.
The fold expression creates a disjunction (||
) of needle
==
each value of haystack
.
Iterating Variadic Arguments with Recursion
Fold expressions are both powerful and concise, but they can be used only with an operator. If you need to do something more involved with the arguments, you need to expand them yourself.
Suppose you want a function that prints all of its arguments separated by commas, e.g.:
print_list( std::cout, "hello", "world" );
One trick is to split the arguments into the first argument and the rest of the arguments:
void print_list( std::ostream &o, auto const &first,
auto const&... rest ) {
o << first;
if constexpr ( sizeof...( rest ) > 0 ) {
o << ", ";
print_list( o, rest... );
}
}
This works as follows:
The
first
argument is printed.Unlike its namesake
sizeof
operator that returns the number of bytes of its argument, thesizeof...
operator returns the number of arguments in a parameter pack.If the number of remaining arguments in
rest
> 0, print a comma and recursively callprint_list()
.In the recursive call, the first argument of
rest...
becomes the newfirst
and the remaining arguments (if any) become the newrest
. Each recursive call “peels” an argument off the front.Eventually,
sizeof...(rest)
will be 0 and the recursion will stop.
Hence, “iterating” over variadic arguments can be done via recursion. Note that the recursion is done at compile-time.
Iterating Variadic Arguments with the Comma Operator
I recently wrote:
Fold expressions are both powerful and concise, but they can be used only with an operator. If you need to do something more involved with the arguments, you need to expand them yourself.
However, there is an operator that can be used to do most anything for a sequence of expressions: the comma operator. As a reminder, in C++:
expr1, expr2;
evaluates expr1
(discards the result, if any) followed by expr2
(which is the result). It’s not commonly used except in the iteration expression of a for
loop, e.g.:
for ( int i = 0, j = 0; i < m && j < n; ++i, ++j ) {
Above, the ++i, ++j
is a typical use of the comma operator. With template parameter packs, the comma operator gains more use.
Suppose you want a function where you can call push_back()
with multiple arguments to push them all back:
template<typename Container>
using value_type = typename Container::value_type;
template<typename Container>
void push_all_back( Container *c,
std::convertible_to<value_type<Container>> auto&&... args ) {
c->reserve( sizeof...(args) );
( c->push_back( std::forward<value_type<Container>>( args ) ), ... );
}
And you can call it like:
std::vector<int> v;
push_all_back( &v, 1, 2, 5 );
This works as follows:
The
value_type
declaration is just a convenience type to lessen typing.The
&&
is a forwarding reference so arguments will work effectively with temporary objects.The
reserve( sizeof...(args) )
will ensure the container is resized at most once for sufficient space to push all of the arguments.The
std::forward<value_type<Container>>(args)
will ensure perfect forwarding of the arguments. (See C++ References.)The
push_back()
with the, ...
is a fold expression using the comma operator that will push all of the arguments one at a time.
Hence, “iterating” over variadic arguments can be done via the comma operator. As in other examples, the iteration is done at compile-time.
Conclusion
Any of function overloading, default arguments, initializer lists, or variadic templates offer a mechanism to implement variadic functions in a type-safe way. In C++, there is no reason ever to use C-style variadic functions.
Top comments (3)
It's probably obvious, but it's worth mentioning that
std::convertible_to<int>
accept anything that can be converted toint
, e.g.float
:sum_n(1, 2, 5, 0.5f)
returns8
.That's probably not expected behaviour, so
std::integral
would be a better choice in this case. That would make the code fail to compile.Yes, I know. That was my intent. The example was meant to be pedagogical, not necessarily real-world.
It depends:
If the function were ordinary (not variadic), the above is what most any C programmer would expect.
Nice and modern code, I like that :)