Jonathan Boccara's blog

How to Define A Variadic Number of Arguments of the Same Type – Part 3

Published February 5, 2019 - 0 Comments

Daily C++We’re going further yet into the topic of how to make a variadic pack of template arguments of the same type.

Indeed, since C++11 we can declare an arbitrary number of template parameters of any type:

template<typename... Ts>
void f(Ts&&... ts)
{
   // ...

But we can’t declare an arbitrary number of template parameters of same type:

void f(std::string&&... ts) // imaginary C++!
{
   // ...

Our motivating example was to create a function that would concatenate the strings passed as its various arguments, and maybe display it on the screen for example.

We’ve been exploring a few options to work around this limitations last week, with various trade-offs. This was Part 1 and Part 2 of the series on template parameters of the same type.

To be honest, I initially planned it to be just be that: Part 1 and Part 2. But you guys, readers of Fluent C++, reacted to Part 1 and Part 2 by suggesting several other solutions to this problem. Your inputs (reproduced with the authors’ permissions) constitute this unplanned Part 3. You guys rock, and this is your post. Thank you.

std::initializer_list

One way to create a list of objects of the same type is to use std::initializer_list. It is Stefano Bellotti who suggested this idea:

std::string f(std::initializer_list<std::string_view> strings)
{
    std::string str;
    
    return std::accumulate(strings.begin(), strings.end(), str, 
          [](std::string val, std::string_view s) { return val + " " + s.data(); });
}

One nice advantage of this technique is that it is simple and relies only on standard components.

In the particular case of std::string we can use std::string_view as in the above snippet, in order to avoid copying the arguments into the std::initializer_list. In the general case we’d have to make a copy though (at least I can’t see how to avoid the copy, if you know do leave a comment below).

The call site looks like this:

f( { "So", "long", ", and thanks for all the fish" } )

And if we add a parameter that is not (convertible to) a string, the code stop compiling:

f( { "So", "long", ", and thanks for all the fish", 42 } )

Indeed this new list can no longer form a valid std::initializer_list.

Note that the trade-off of this solution involves passing arguments between braces {}.

Rely on the compiler to prevent illegal operations

Why do we need to force the inputs to be std::strings (or whatever else) in the interface? We could also rely a little on the implementation of the function for this. This is what JFT suggested:

template <typename... Ts>
std::string f(Ts&&... s) {
    return (... + s);
}

This solution relying on C++17 fold expressions creates a condition on the function for it to be compilable: its arguments must be addable with operator+, and since the function returns a std::string, the result of operator+ must be convertible to a std::string for the code to compile.

That doesn’t leave a lot of possibilities for the types of the arguments.

The call site looks like this:

using namespace std::string_literals;
auto str = f("So"s, "long"s, ", and thanks for all the fish"s);

Note that we have to pass std::strings, and not const char* even if they are convertible to std::string. Indeed, the template deduction would then identify the Ts... as const char*, and const char* cannot be summed with operator+. This is why the above code uses the C++14 string literal operator (“s“).

The code would no longer compile if we pass a parameter of another type:

auto str = f("So"s, "long"s, ", and thanks for all the fish"s, 42); // doesn't compile

Indeed, we can’t add a std::string and an int together.

Like the previous one, this solution only relies on standard components.

Comma operator and SFINAE

In Part 1 we explored how to use SFINAE to force all parameters to be convertible to std::string, by relying on std::conjunction.

Reader flashmozzg shows us a shortcut to perform SFINAE on several parameters: relying on the comma operator:

template<typename... Ts>
auto f(Ts &&... ts) -> decltype((((void)std::string(ts)), ...))
{
    //...
}

The above code attempts to work out the type that would result from converting the parameters to std::string. The comma operator allows to perform this operation on each element of the template parameters pack.

If this expression is created successfully, it means that all the parameters are convertible to std::string.

We can also encapsulate this expression into a template parameter (as a way to make SFINAE pretty):

template<typename... Ts>
using AllStrings = decltype((((void)std::string(std::declval<Ts>())), ...));

template<typename... Ts, typename = AllStrings<Ts...>>
void f(Ts &&... ts)
{
    //...
}

This involves more code than the previous solutions and relies on two advanced features of C++ (comma operator and SFINAE). In return the call site looks natural and allows conversions from const char* to std::string:

f("So", "long", ", and thanks for all the fish");

As expected, adding parameters of other types doesn’t compile:

f("So", "long", ", and thanks for all the fish", 42); // doesn't compile

SFINAE on individual parameters

All the SFINAE techniques we’ve seen so far operate on the parameter pack as a whole. Björn Fahller, inspired by Stephen Dewhurst, shows how to make SFINAE on individual parameters:

template <typename T, typename = std::enable_if_t<std::is_constructible_v<const std::string&, const T&>>>
using String = T;

template <typename ... Ts>
void f(const String<Ts>& ... s)

Note that this avoids adding an extra default parameter in the template parameters, like we did in all our previous examples of SFINAE. A trade-off of this solution is that, since we no longer use the template parameters directly, we can also no longer use universal references (Ts&&).

The call site looks natural:

f("So", "long", ", and thanks for all the fish");

As as expected it stops compiling with extra parameters of other types:

f("So", "long", ", and thanks for all the fish", 42); // doesn't compile

Introducing a type deduction

This last solution, suggested by Henrik Sjöström, consists in creating a template pack of parameters that resolve into std::strings:

template<typename To, typename From>
using Convert = To;

template<typename... Args>
void DoSoemthing(Convert<std::string,Args>... args)
{
    // ...
}

Since the compiler cannot resolve convert from a parameter, we need to specify the types of Args at call site. To encapsulate this, Henrik suggests to add a level of indirection with an extra function:

template<typename... Args>
decltype(auto) Wrapper(Args&&... args){
    return DoSoemthing<Args...>(std::forward<Args>(args)...);
}

To make a parallel with the above examples, we could rename Wrapper to f and DoSomething to fImpl.

The call site becomes the usual one:

f("So", "long", ", and thanks for all the fish");

And adding the extra parameter makes the compilation fail as desired:

f("So", "long", ", and thanks for all the fish", 42); // doesn't compile

A big thanks to all Fluent C++ readers that took the time to give their suggestions to solve the problem of multiple parameters of the same type, and made this unplanned Part 3 happen! You guys rock.

Before you leave

Have you checked out my new book on how to stay efficient and happy when working with legacy code? It’s The Legacy Code Programmer’s Toolbox. It’s like THE big event on Fluent C++ at the moment. If you have to work with legacy code, that book is made for you.

It’s been out for only a few days, and received very positive feedback from its first readers. Check it out!

Don't want to miss out ? Follow:   twitterlinkedinrss
Share this post!Facebooktwitterlinkedin