13

I can write a function which take a temporary array(such as {1, 2, 3}) as an argument in two ways:

// using array
template<typename T, int N>
auto foo1(const T(&t)[N]) -> void;

// using std::initializer_list
template<typename T>
auto foo2(std::initializer_list<T> t) -> void;

Is there any guideline which tells which one is better?

5
  • 1
    See here, for example.
    – Amit G.
    Commented Jul 16, 2018 at 2:20
  • 2
    In the first one you can use the length as a compile-time constant, in the second one you can't. Conversely, the first one cannot accept a list whose length is not known at that point.
    – M.M
    Commented Jul 16, 2018 at 3:25
  • @MM Not sure I understand that comment.
    – catnip
    Commented Jul 16, 2018 at 3:31
  • 2
    @PaulSanders try std::array<T, t.size()> x; in foo2 (and call it)
    – M.M
    Commented Jul 16, 2018 at 3:35
  • @MM OK, thanks, I understand it now. And so, by poking our heads out from behind the parapet from time to time, we learn.
    – catnip
    Commented Jul 16, 2018 at 4:02

2 Answers 2

13

They are both very different things. There is also 2 or 3 other choices that are reasonable.

template<class T, std::size_t N>
void foo_a( std::array<T, N> const& );

template<class T>
void foo_b( gsl::span<const T> );

template<class T, std::size_t N >
void foo_c( T const(&)[N] );

template<class T>
void foo_d( std::initializer_list<T> );

template<class T, class A=std::allocator<T> >
void foo_e( std::vector<T, A> const& );

template<class...Ts>
void foo_f( std::tuple<Ts...> const& );

template<class...Ts>
void foo_g( Ts const& ... );

here are 7 different ways to take a bunch of Ts.

They all have advantages and disadvantages over each other.

The closest to strictly better is foo_a over foo_c; foo_c is only good in that it is more compatible with C-style arrays.

foo_b lets you consume any of the others except foo_f. So that is nice.

a, c and f all have compile-time determined lengths within foo. This could make a difference, depending on what you are doing. You could in theory write a foo_b type view that handles fixed length, but nobody bothers.

e is the only one that supports dynamic length at the call-site.

f supports non-identical types, but makes iteration a bit less clean.

All of them can be modified slightly to permit move-out (even the initializer list one with a bit more boilerplate).

d gives the easiest {}, but g is just as clean (omit the {} entirely).

Usually I use my home-rolled gsl::span variant. It has an initializer_list constructor. And I very very rarely want to deduce T.

2
  • what is gsl:: ?
    – kyb
    Commented Oct 9, 2022 at 17:30
  • @kyb guideline support library Commented Oct 9, 2022 at 20:48
4

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!

Not the answer you're looking for? Browse other questions tagged or ask your own question.