DEV Community

Paul J. Lucas
Paul J. Lucas

Posted on

12 1

Variadic Functions in C++

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;
}
Enter fullscreen mode Exit fullscreen mode

Then you can call it like:

int r = sum_n( { 1, 2, 5 } );  // r = 8; {} are required
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. The const prevents moving.
  2. The storage for the elements is contiguous.
  3. 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 } );
}
Enter fullscreen mode Exit fullscreen mode

Under the hood, it’s as if this happens:

  std::string const _list[2] = { s1, s2 };
  f( _list );
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Then you can call it just as before:

int r = sum_n( 1, 2, 5 );  // r = 8
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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) || ... );
}
Enter fullscreen mode Exit fullscreen mode

And you can call it like:

if ( str_is_any( s, "struct", "union", "class" ) )
Enter fullscreen mode Exit fullscreen mode

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" );
Enter fullscreen mode Exit fullscreen mode

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... );
  }
}
Enter fullscreen mode Exit fullscreen mode

This works as follows:

  • The first argument is printed.

  • Unlike its namesake sizeof operator that returns the number of bytes of its argument, the sizeof... operator returns the number of arguments in a parameter pack.

  • If the number of remaining arguments in rest > 0, print a comma and recursively call print_list().

  • In the recursive call, the first argument of rest... becomes the new first and the remaining arguments (if any) become the new rest. 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;
Enter fullscreen mode Exit fullscreen mode

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 ) {
Enter fullscreen mode Exit fullscreen mode

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 ) ), ... );
}
Enter fullscreen mode Exit fullscreen mode

And you can call it like:

std::vector<int> v;
push_all_back( &v, 1, 2, 5 );
Enter fullscreen mode Exit fullscreen mode

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.

Reinvent your career. Join DEV.

It takes one minute and is worth it for your career.

Get started

Top comments (3)

Collapse
 
serpent7776 profile image
Serpent7776

It's probably obvious, but it's worth mentioning that std::convertible_to<int> accept anything that can be converted to int, e.g. float:
sum_n(1, 2, 5, 0.5f) returns 8.
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.

Collapse
 
pauljlucas profile image
Paul J. Lucas

It's probably obvious, but it's worth mentioning that std::convertible_to<int> accept anything that can be converted to int, e.g. float.

Yes, I know. That was my intent. The example was meant to be pedagogical, not necessarily real-world.

That's probably not expected behaviour.

It depends:

int sum_3( int a, int b, int c );

int r = sum_3( 1, 5, 0.5 );  // compiles: r = 6
Enter fullscreen mode Exit fullscreen mode

If the function were ordinary (not variadic), the above is what most any C programmer would expect.

Collapse
 
pgradot profile image
Pierre Gradot

Nice and modern code, I like that :)