Yakk's answer is very good. Here I just want to give my own opinion on it, and tweak its details for C++23/C++26. First, there's the "non-options" (listed but incompletely analyzed by Yakk):
template<class... Ts>
void foo_g(const Ts&...);
This one is great if you want to call the function as foo_g(1,2,3)
. It is the only working heterogeneous option (that is, the arguments can be of different types). But of course heterogeneity is not one of your acceptance criteria. What is, is to be callable like foo_g({1,2,3})
. This one is not callable like that.
Many other signatures are non-options because of how template type deduction works:
template<class T, size_t N>
void foo_a(const std::array<T, N>&);
template<class T>
void foo_b(std::span<const T>);
template<class T>
void foo_e(const std::vector<T>&);
template<class... Ts>
void foo_f(const std::tuple<Ts...>&);
template<class T, size_t N>
void foo_b2(std::span<const T, N>);
A call like foo_e({1,2,3})
fails deduction, because the compiler cannot deduce what T
to plug into const vector<T>&
to get a type that is constructible from [or bindable-to] a temporary [of some still-undetermined type] constructed from {1,2,3}
. Trying to deduce the cv-unqualified T
in foo_b2
's span<const T, N>
parameter is even harder — which is to say, also impossible for the compiler. Godbolt:
#include <vector>
template<class T> void foo(std::vector<T> v);
int main() {
foo({1,2,3});
}
error: no matching function for call to 'foo'
6 | foo({1,2,3});
| ^~~
note: candidate template ignored: couldn't infer template argument 'T'
3 | template<class T> void foo(std::vector<T> v);
| ^
So all of those signatures taking tuple
, array
, vector
, span
... are just plain out. (Unless you have a concrete type T
in mind, so that you can write a simple non-template function! In that case, see below.)
That leaves your two original candidates:
template<class T, int N>
void foo_c(const T (&a)[N]);
template<class T>
void foo_d(std::initializer_list<T> il);
There are three differences between these two, as far as I can see:
- Ergonomics:
N
(of a type you get to choose!) versus il.size()
(always size_t
). a
versus il.begin()
. (For C++26, P3016 proposes to support il.data()
. It was forwarded to LWG in Tokyo last month.)
- Performance:
foo_c({1,2,3})
always creates a temporary of array type and binds a reference to it. That temporary, like all temporaries in C++, is created on the stack. This means you just used 12 bytes of stack space. If your array is a million elements long (perhaps because you used C++26 #embed
to create it), then you just used 4MB of stack space! That's horrible. Now, foo_d({1,2,3})
is also permitted to create a temporary backing array on the stack and bind to it; but quality implementations will instead create the backing array in static .rodata
, so that no stack space is used at all. (GCC is the only quality implementation as of October 2023, but I predict that all compilers will be doing this by October 2025. This optimization was permitted by Defect Report only as of mid-2023; see P2752R3.)
- Template bloat:
foo_d
is templated only on the type T
, so foo_d({1,2,3})
and foo_d({1,2})
will instantiate the same overload. foo_c
is templated on both T
and N
, so foo_c({1,2,3})
and foo_c({1,2})
will instantiate two different overloads. If you need to use N
as a constant-expression inside foo
, then obviously you must prefer foo_c
. If you don't need to use N
as a constant-expression, then you should prefer the alternative with less template bloat.
So, foo_c
wins (slightly) on ergonomics; foo_d
wins overwhelmingly on performance and template bloat. And, back in the ergonomics category, foo_d
's function signature wins in a landslide over foo_c
's mess of brackets and ampersands. Prefer foo_d
.
For the sake of argument, let's consider what happens if you pin T
as int
, but still want to accept braced-initializer-lists of differing lengths. Then we have the following still-non-options:
template<size_t N>
void foo_a(const std::array<int, N>&); // can't deduce N
template<class... Ts> requires (std::same_as<Ts, int> && ...)
void foo_f(const std::tuple<Ts...>&);
template<size_t N>
void foo_b2(std::span<const int, N>);
But we have two new contenders added to our two previous options:
void foo_b(std::span<const int>);
void foo_e(const std::vector<int>&);
template<int N> void foo_c(const int (&a)[N]);
void foo_d(std::initializer_list<int> il);
foo_b
is not yet an option, but it will be permitted in C++26-and-later (because C++26's working draft includes P2447). In C++23, if you wanted to use foo_b
, you'd have to write foo_b({{1,2,3}})
, which would create a temporary array on the stack just like foo_c
. After P2447, you can write foo_b({1,2,3})
directly, and quality implementations will not create a temporary array. So, in C++26, foo_b
is a very viable option.
foo_e({1,2,3})
creates an initializer_list
(with a backing array that quality implementations will put in .rodata
), then copies the array's elements into a heap-allocated array managed by a temporary vector
. This is terrible. Don't do this.
So foo_d
beat foo_c
, and foo_b
certainly beats foo_e
. Our real choice here is between foo_b(span)
and foo_d(initializer_list)
.
- Generality:
foo_b
will happily accept any contiguous range, e.g. foo_b(vector)
, foo_b(arr)
, foo_b(il)
, etc. foo_d
will accept only literal braced-initializer-lists and expressions of type initializer_list
such as il
. Almost certainly, in practice, your intent will be closer to foo_b
than to foo_d
. (Unless foo
is a constructor, in which case obviously you should use initializer_list
so that foo
will be an initializer-list constructor, full stop.)
- Portability:
foo_b
is accepted by libc++ 18, but not yet by libstdc++ or MS STL. Once vendors implement P2447, they'll still likely support foo_b
only in C++26 and later, not in C++20 or C++23. This means you won't be able to use foo_b
in new code for a long time yet.
Conclusion: Accepting a braced-initializer-list? Use initializer_list
!
std::array<T, t.size()> x;
in foo2 (and call it)