Why must C++ function parameter packs be placeholders or pack expansions?

Viewed 198

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:

  1. 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)

  2. 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.

3 Answers

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. [...]

Why does the standard impose this restriction?

Most likely because this would confuse(or create complications) users with the old C varargs function that have almost same syntax(with unnamed parameter) as shown below:

//this is a C varargs function
void good3(int...) 
           ^^^^^^
{
    
}

Now if the fourth function bad was allowed:

 
template<int = 0> void bad(int...i)
                           ^^^^^^^ --->this is very likely to confuse users as some may consider it as a C varargs function instead of a function parameter pack 
{
}

IMO the above seems atleast a little ambiguous due to the similarity in the syntax of C varargs and a function parameter pack.

From dcl.fct#22:

There is a syntactic ambiguity when an ellipsis occurs at the end of a parameter-declaration-clause without a preceding comma. In this case, the ellipsis is parsed as part of the abstract-declarator if the type of the parameter either names a template parameter pack that has not been expanded or contains auto; otherwise, it is parsed as part of the parameter-declaration-clause.


I'd like a function that works when you say good(1, 2U, '\003')

You can use std::common_type for this.

template <typename... T, typename l= std::common_type_t<T...>>
void func(T&&... args) {
}

Am I missing some trick that would allow one to write a function that takes a variable number of arguments of a specific type?

The good3 given in your example is very readable and should be used from C++20. Though if one is using C++17 then one way to do this is using the SFINAE principle with a combination of std::conjunction and std::is_same as shown below.

Method 1

Here we simply check if all the arguments passed are of the same type.

template <typename T, typename ... Args>
std::enable_if_t<std::conjunction_v<std::is_same<T, Args>...>, bool>
func(const T & first, const Args & ... args)
{
    std::cout<<"func called"<<std::endl;
    return true;
}
int main()
{
    func(4,5,8);    //works
    //func(4,7.7); //wont work as types are different
    std::string s1 = "a", s2 = "b"; 
    func(s1, s2);  //works
    //func(s1, 2);   //won't work as types are different
    
}

Working demo


Looking at your comment it seems you want to add one more restriction that the program should work only when

a) all the arguments are of the same type

b) all of those matches a specific type say int, or std::string.

This can be done by adding a static_assert inside the function template:

static_assert(std::is_same_v<T, int>, "Even though all parameters are of the same type, they are not int");

Method 2

Here use the static_assert shown above to check if the arguments passes are of a specific type like int.

template <typename T, typename ... Args>
std::enable_if_t<std::conjunction_v<std::is_same<T, Args>...>, bool>
func(const T & first, const Args & ... args)
{
    static_assert(std::is_same_v<T, int>, "Even though all parameters are of the same type, they are not int");
    std::cout<<"func called"<<std::endl;
    return true;
}

int main()
{
    func(3,3);    //works
    //func(4,7.7); //wont work as types are different
    std::string s1 = "a", s2 = "b"; 
    //func(s1, s2);  //won't work as even though they are of the same type but not int type
    
}

Working demo


Am I missing some trick that would allow one to write a function that takes a variable number of arguments of a specific type?

If I understand the question correctly, you can work around it by adding a proxy function that casts and forwards arguments to actual implementation:

unsigned wrap_sum(auto&&...args) {
  return []<std::size_t...I>(std::index_sequence<I...>,
                             std::conditional_t<true, unsigned, decltype(I)> ... args) {

    return (0u + ... + args);

  }(std::make_index_sequence<sizeof...(args)>{},
    std::forward<decltype(args)>(args)...);
}
Related