How to pass a variadic number of arguments of the same type with C++20
Some years ago Jonathan Boccara wrote a series of articles on his excellent blog Fluent{C++} about how to pass a variadic number of arguments of the same type to a function; at that time, I participated in finding one of the ways to achieve that goal (and by the way, you can find the articles here: https://www.fluentcpp.com/2019/01/25/variadic-number-function-parameters-type/).
Many of the solutions found at the time involved SFINAE and/or other techniques and even though I was already aware of concepts, I didn’t know enough yet so I initially thought that using them to solve the problem was a kind of misuse… Furthermore, I knew there was a proposal to add such a capability directly in the language (see, https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1219r2.html).
And by the way, if you’re asking why not use an initializer list… it’s because it would be inefficient since its elements would be pretty much always copied due to the language rules.
Years have passed and today I believe I was wrong and that concepts are probably the best tool we have so far to achieve what we want.
Ready for some real examples?
Example #1: passing a variadic number of arguments of exactly the same type
Let’s say we have a function that accepts a vector of strings and a variadic number of strings that will be added to the vector. If we want to check at compile-time that the arguments are all of std::string type, we can do the following:
#include <concepts>
#include <vector>
#include <string>
void add(std::vector<std::string>& v, std::same_as<std::string> auto const&... args) {
(v.push_back(args), ...);
}
int main() {
std::vector<std::string> v;
std::string s1{"hello"};
std::string s2{"concepts"};
add(v, s1, s2);
// ...
}
We are using the new C++20 simplified syntax for templates and using the standard concept std::same_as to enforce that we can accept only std::string arguments. If we pass something that is not a string, the compiler would emit an error.
Notice that we are taking variadic arguments by const& but we can also pass rvalue strings to the function because rvalues can bind to a const& qualified argument of std::string type:
std::vector<std::string> v;
std::string s1{"hello"};
add(v, s1, std::string{"concepts"});
Anyway, as you probably noticed, in this way we would end up copying the second string into the vector instead of efficiently moving it into it. To avoid the copy we have to enable perfect forwarding of the arguments for our function. Let’s see how to do it in the following example.
Example #2: perfect-forwarding a variadic number of arguments of the same type
To have perfect forwarding, we have to change the definition of our “add” function. Unfortunately, things get a little bit tricky…
At first sight, you could think that doing this would work:
void add(std::vector<std::string>& v, std::same_as<std::string> auto&&... args) {
(v.push_back(std::forward<decltype(args)>(args)), ...);
}
If we use our newly defined function passing only rvalue strings, it seems to work fine:
add(v, std::string{"hello"}, std::string{"concepts"});
Unfortunately, if we call “add” using lvalues or mixed lvalues/rvalues arguments, the compiler is not happy and emits an error. This is true even if we define our function in these other ways:
// attempt to support constrained perfect forwarding: DOES NOT WORK
template <std::same_as<std::string>... Args>
void add(std::vector<std::string>& v, Args&&... args) {
(v.push_back(std::forward<Args>(args)), ...);
}
// alternate syntax: DOES NOT WORK EITHER
template <typename... Args>
requires (std::same_as<std::string, Args> && ...)
void add(std::vector<std::string>& v, Args&&... args) {
(v.push_back(std::forward<Args>(args)), ...);
}
Both approaches do not work since the compiler still insists that we can pass only rvalue strings to the function (I’m using MSVC 17.1.3 and GCC 12.1.0).
To be able to pass lvalues AND rvalues and have them perfectly forwarded, we have to resort to std::remove_cvref_t within our requires clause, doing the following:
// Perfect forwarding arguments with concepts: a working solution
template <typename... Args>
requires (std::same_as<std::string, std::remove_cvref_t<Args>> && ...)
void add(std::vector<std::string>& v, Args&&... args) {
(v.push_back(std::forward<Args>(args)), ...);
}
In this way, the compiler is happy and we can pass std::strings with any qualifier in any combination:
std::vector<std::string> v;
std::string s1{"hello"};
std::string s2{"concepts"};
add(v, s1, s2); // all l-values
add(v, std::string{"hello"}, std::string{"concepts"}); // all r-values
add(v, s1, std::move(s2), std::string{"!!!"}); // mixed l-values/r-values
Example #3: passing a variadic number of arguments convertible to a certain type
Sometimes, we would like not to restrict our arguments to a single type, but to several types as long as they are convertible to the one we are interested in. For example, in our case it makes sense to allow passing C-style strings to the function without having to explicitly instantiate a std::string, like this:
add(v, "hello", "concepts", "!!!");
At the same time, we would like to maintain the capability to perfect forward real std::strings and use our “add” function like this:
add(v, s1, std::move(s2), "!!!"); // mixed l-values/r-values and C-style strings
Fortunately, to be able to call our function under these requirements, it’s easy again:
void add(std::vector<std::string>& v, std::convertible_to<std::string> auto&&... args) {
(v.push_back(std::forward<decltype(args)>(args)), ...);
}
All we have to do is to use the standard concept std::convertible_to and to have perfect forwarding we need to retrieve the types of args. In this case, we do that by using decltype(args). Alternatively, you could also explicitly give a name to the variadic pack types:
template <std::convertible_to<std::string>... Args>
void add(std::vector<std::string>& v, Args&&... args) {
(v.push_back(std::forward<Args>(args)), ...);
}
// OR (it's really the same)
template <typename... Args>
requires (std::convertible_to<Args, std::string> && ...)
void add(std::vector<std::string>& v, Args&&... args) {
(v.push_back(std::forward<Args>(args)), ...);
}
Conclusions
While it would be good to have homogeneous variadic parameters directly supported by the language, by using Concepts we have a working solution relatively clean and readable.
What I like about using concepts is the fact that it’s clear from the function interface whether only a specific type is allowed as a parameter (by using std:same_as) or whether conversions are permitted (by using std::convertible_to). What I don’t like is when it gets tricky by trying to perfect forward arguments of a specific type…
I hope this article will be useful for you and of course, feedback and suggestions are welcome 😉.