The declarator for a C++20 function parameter pack must either be a placeholder or a pack expansion. For example:
// OK, template parameter pack only, no function parameter pack
template<unsigned ...I> void good1() {}
// OK function parameter pack is pack expansion of decltype(I)
template<unsigned ...I> void good2(decltype(I)...i) {}
// OK contains placeholder auto
void good3(std::same_as<unsigned> auto...i) {}
// OK contains placeholder auto
void good4(std::convertible_to<unsigned> auto...i) {}
// Error, no pack expansion or placeholder
template<unsigned = 0> void bad(unsigned...i) {}
This seems to make it impossible to declare a function that takes a variable number of parameters of a specific type. Of course, good2
above will do it, but you have to specify some number of dummy template arguments, as in good2<0,0,0>(1,2,3)
. good3
sort of does it, except if you call good3(1,2,3)
it will fail and you have to write good3(1U,2U,3U)
. I'd like a function that works when you say good(1, 2U, '\003')
--basically as if you had an infinite number of overloaded functions good()
, good(unsigned)
, good(unsigned, unsigned)
, etc.
good4
will work, except now the arguments aren't actually of type unsigned
, which could be a problem depending on context. Specifically, it could lead to extra std::string
copies in a function like this:
void do_strings(std::convertible_to<std::string_view> auto...s) {}
My questions are:
Am I missing some trick that would allow one to write a function that takes a variable number of arguments of a specific type? (I guess the one exception is C strings, because you can make the length a parameter pack as in template<std::size_t...N> void do_cstrings(const char(&...s)[N]) {/*...*/}
, but I want to do this for a type like std::size_t)
Why does the standard impose this restriction?
update
康桓瑋 asked why not use good4
in conjunction with forwarding references to avoid extra copies. I agree that good4
is the closest to what I want to do, but there are some annoyances with the fact that the parameters are different types, and some places where references do not work, either. For example, say you write code like this:
void
good4(std::convertible_to<unsigned> auto&&...i)
{
for (auto n : {i...})
std::cout << n << " ";
std::cout << std::endl;
}
You test it with good(1, 2, 3)
and it seems to work. Then later someone uses your code and writes good(1, 2, sizeof(X))
and it fails with a confusing compiler error message. Of course, the answer was to write for (auto n : {unsigned(i)...})
, which in this case is fine, but there might be other cases where you use the pack multiple times and the conversion operator is non-trivial and you only want to invoke it once.
Another annoying problem arises if your type has a constexpr conversion function that doesn't touch this
, because in that case the function won't work on a forwarding reference. Admittedly this is highly contrived, but imagine the following program that prints "11":
template<std::size_t N> std::integral_constant<std::size_t, N> cnst = {};
constexpr std::tuple tpl ('0', '1', '2', '3', '4', '5', '6', '7', '8', '9');
inline const char *
stringify(std::convertible_to<decltype(cnst<1>)> auto...i)
{
static constexpr const char str[] = { get<i>(tpl)..., '\0' };
return str;
}
int
main()
{
std::cout << stringify(cnst<1>, cnst<1>) << std::endl;
}
If you change the argument to stringify
to a forwarding reference stringify(std::convertible_to<decltype(cnst<1>)> auto&&...i)
, it will fail to compile because of this.
update2
Here's a more comprehensive example showing why good4
isn't quite good enough if you want to avoid extra moves/copies:
#include <concepts>
#include <iostream>
#include <initializer_list>
#include <concepts>
struct Tracer {
Tracer() { std::cout << "default constructed" << std::endl; }
Tracer(int) { std::cout << "int constructed" << std::endl; }
Tracer(const Tracer &) { std::cout << "copy constructed" << std::endl; }
Tracer(Tracer &&) { std::cout << "move constructed" << std::endl; }
void do_something() const {}
};
void
f1(Tracer t1, Tracer t2, Tracer t3)
{
t1.do_something();
t2.do_something();
t3.do_something();
}
void
f2(std::convertible_to<Tracer> auto ...ts)
{
(Tracer{ts}.do_something(), ...); // binary fold over comma
}
void
f3(std::convertible_to<Tracer> auto&& ...ts)
{
(Tracer{std::forward<decltype(ts)>(ts)}.do_something(), ...);
}
void
f4(std::initializer_list<Tracer> tl)
{
for (const auto &t : tl)
t.do_something();
}
void
f5(std::convertible_to<Tracer> auto&& ...ts)
{
std::initializer_list<Tracer> tl { std::forward<decltype(ts)>(ts)... };
for (const auto &t : tl)
t.do_something();
}
int
main()
{
Tracer t;
std::cout << "=== f1(t, 0, {}) ===" << std::endl;
f1(t, 0, {});
std::cout << "=== f2(t, 0, Tracer{}) ===" << std::endl;
f2(t, 0, Tracer{});
std::cout << "=== f3(t, 0, Tracer{}) ===" << std::endl;
f3(t, 0, Tracer{});
std::cout << "=== f4({t, 0, {}}) ===" << std::endl;
f4({t, 0, {}});
std::cout << "=== f5(t, 0, Tracer{}) ===" << std::endl;
f5(t, 0, Tracer{});
std::cout << "=== done ===" << std::endl;
}
The output of the program is:
default constructed
=== f1(t, 0, {}) ===
default constructed
int constructed
copy constructed
=== f2(t, 0, Tracer{}) ===
default constructed
copy constructed
copy constructed
int constructed
copy constructed
=== f3(t, 0, Tracer{}) ===
default constructed
copy constructed
int constructed
move constructed
=== f4({t, 0, {}}) ===
copy constructed
int constructed
default constructed
=== f5(t, 0, Tracer{}) ===
default constructed
copy constructed
int constructed
move constructed
=== done ===
We are trying to replicate an inifinite sequence of overloaded functions that behave like f1
, which is what the rejected P1219R2 would have given us. Unfortunately, the only approach that doesn't require an extra copy is to take a std::initializer_list<Tracer>
, which requires an extra set of braces on function invocation.
Why does the standard impose this restriction?
I'll focus on the "why's", as other answer already visits various workarounds.
P1219R2 (Homogeneous variadic function parameters) went as far as EWG
# EWG incubator: in favor SF F N A SA 5 2 3 0 0
But was eventually rejected for C++23 by EWG
SF F N A SA 2 8 8 9 2
I think the rationale was that whilst the proposal was very well-written the actual language facility was not an essentially useful one, and particularly not enough to hold its weight given that it's a breaking change due to the C varargs comma mess:
The varargs ellipsis was originally introduced in C++ along with function prototypes. At that time, the feature did not permit a comma prior to the ellipsis. When C later adopted these features, the syntax was altered to require the intervening comma, emphasizing the distinction between the last formal parameter and the varargs parameters. To retain compatibility with C, the C++ syntax was modified to permit the user to add the intervening comma. Users therefore can choose to provide the comma or leave it out.
When paired with function parameter packs, this creates a syntactic ambiguity that is currently resolved via a disambiguation rule: When an ellipsis that appears in a function parameter list might be part of an abstract (nameless) declarator, it is treated as a pack declaration if the parameter's type names an unexpanded parameter pack or contains auto; otherwise, it is a varargs ellipsis. At present, this rule effectively disambiguates in favor of a parameter pack whenever doing so produces a well-formed result.
Example (status quo):
template <class... T> void f(T...); // declares a variadic function template with a function parameter pack template <class T> void f(T...); // same as void f(T, ...)
With homogeneous function parameter packs, this disambiguation rule needs to be revisited. It would be very natural to interpret the second declaration above as a function template with a homogeneous function parameter pack, and that is the resolution proposed here. By requiring a comma between a parameter list and a varargs ellipsis, the disambiguation rule can be dropped entirely, simplifying the language without losing any functionality or degrading compatibility with C.
This is a breaking change, but likely not a very impactful one. [...]