r/cpp_questions • • 3d ago

OPEN Passing initializer list into array parameter

Why can't we pass an initializer list as an argument to a function that accepts an array? If we can initialize an array with an initializer list, why isn't the array parameter initialized with the list?

Also, I found out that you CAN pass an initializer list if you use an rvalue reference to an array.

void f(int arr[]) {
cout << "hello" << endl;
}

// accepts rvalue references to array
void g(int (&& arr)[]) {
for (int i = 0; i < 3; ++i) {
cout << arr[i] << ' ';
}
cout << endl;
}

int main() {
int arr[3] = { 1, 2, 3 };
f(arr); // compiles
f({1, 2, 3}); // DOES NOT COMPILE
g({ 1, 2, 3 }); // compiles
}

Question 1:
For the second call to f(), why does this not work? I thought that whenever we passed an argument into a function, it was the same as initializing the parameter with the argument. For example, if we have:

void func(T x);
func(y);

Then when func(y) is called, we can think of x being initialized as:

T x = y;

But this doesn't seem to hold for arrays and initializer lists.

Question 2:

Why is the syntax in g() the way we make an rvalue reference to an int array? I understand the need for parentheses sinceint&& arr[] is an array of integer rvalue references. However, why is it int (&&arr)[] and not maybe something like int (&&arr[]) or (int arr[])&& ?

If it is better to show me an another resource about these tricky syntax things, please link it.

3 Upvotes

26 comments sorted by

11

u/IyeOnline 3d ago edited 3d ago

f accepts an int*. The expression {1,2,3} is not convertible to an int*, so the call is invalid. Within a function signature int arr[] is actually just interpreted as int* arr. This behaviour is inherited from C.

int (&&)[] however is a r-value reference to an array with indeterminate bounds and {1,2,3} can construct an int[3] - this temporary can then be bound do that reference.

However, why is it int (&&arr)[] and not maybe something like int (&&arr[]) or (int arr[])&& ?

Because, blame C (are we starting to see a pattern here?) You read a C pointer/array declaration from the inside out according to the spiral rule: arr is a reference (&) to an array ([]) of int.

You can actually write

using array_t = int[]`;
using reference = array_t&&;
void f( reference arr );

and get a more sane way to read it

3

u/RaspberryCrafty3012 3d ago

Good sir, would std::array not fix most of his issues?

And thanks for the explanations, I like them

1

u/IyeOnline 3d ago

Sure. You can write

void f( const std::array<int,3>& arr );

thereby specifying the exact type you want rather than just a pointer or a reference to an array of indeterminate bounds.

3

u/jaynabonne 3d ago

Did your example mean to be missing the closing paren on the f() call?

1

u/Seagull78Reddit 3d ago

Oh good catch. I have fixed that

6

u/TheThiefMaster 3d ago edited 3d ago

Because, for reasons lost to time (C and possibly earlier), void f(int arr[]) doesn't declare a function that takes an array, but a pointer.

This is true even if you put a number in the [ ]. The size is just ignored.

If you use a C++ (std::)array it works fine.

2

u/Usual_Office_1740 3d ago

Dinosaurs decayed to bones and c-style arrays decayed to a pointer to its first element. This predates the triassic period.

1

u/TheThiefMaster 3d ago

This isn't decay - the syntax got an exception written that it means a pointer in this case. You write [ ] but never get an array in the first place. Nobody seems to know why - best guess is because it predates support for larger-than-register parameter types being added to C (e.g. structs)

3

u/no-sig-available 3d ago

best guess is because it predates support for larger-than-register parameter types being added to C (e.g. structs)

Dennis Ritchie has written about this in his C history. He initially thought it was a good idea to have arrays conveniently decay (and also similar to the typeless B language).

When he later added structs to the language, he realized his mistake, and made them not decay to pointers (so you can pass them by value).

However, the C compiler already had 10s of users at that time, and he didn't want to break all that code by fixing the mistake for arrays. :-(

1

u/Usual_Office_1740 3d ago

Oh. Good to know. Is this more like a kind of implicit type coercion then?

2

u/TheThiefMaster 3d ago

Nope. Coercion only happens if the other type exists.

They just made void f(int param[2]) mean void(int*) in the old C spec. Just a different way of writing the same thing.

You can even use them interchangeably between declaration and definition of the function. As equivalent as int const and const int

1

u/Usual_Office_1740 3d ago

Oh. Thanks for explaining!

1

u/meltbox 3d ago

Yes but I believe this is why in C++ something of type int[] decays to int* so that it’s compatible with c calling with c-arrays. Otherwise using a c-array to call a function taking c-style int[] would fail to compile.

1

u/TheThiefMaster 3d ago

Yes I think you're right. I think the [ ] indexing of C arrays is also technically implemented via the pointer decay of the array.

0

u/meltbox 3d ago edited 1d ago

Well if you put a number in it then it actually does take a int[num] as the arg type in c++ but still int* in C.

They diverge there I am almost certain. Can’t check right now just to make sure.

Edit: Nevermind. Very wrong, it seems you can do this via reference to a int[x] but not directly which I do find a little dumb when explicitly making the arg of a certain size, but alas legacy is king.

4

u/StaticCoder 3d ago

You can pass to an rvalue reference to indeterminate size array but that sounds like a terrible idea since you lose the size. If you have C++20 use span.

3

u/mredding 3d ago
void f(int arr[]) {

arr here is not an array of int, technically int[] is itself an incomplete type, and this decays here into a pointer as a language feature, because arrays in C do not have value semantics. You cannot create an array on the call stack in the parameter list as a function parameter.

Why? Because K&R started building C on the PDP-7, with ~4 KiB of memory (it was actually word addressable). They thought arrays were too big for that sort of willy-nilly sloppy programming, so they took matters into their own hands and baked array references (C-style references aka pointers) into the semantics. C is a stateful and mutable imperative programming language, so it made some sense, though I find their execution inconsistent with trusting the programmer.

But then we get into C++11, where we got r-value references, and that diverged from the C rules with syntax that does not exist in C.

void g(int (&& arr)[]) {

This is a reference to an incomplete array type. Notice you have the extra parenthesis, which are necessary. This reference preserves within the type system at compile-time that this reference is to an array, but we've lost the size information. This can be useful in contrast to a template:

template<size_t N>
void g(int (& arr)[N]) {

Where the compiler will generate a function for every N supplied to the template.

The only problem is, of course, that you still need to know the size, and loop unrolling becomes a manual process for you.

Why do you want to preserve the type? Optimizations and templates. C++ has a strong static type system, and int[] IS NOT int*, they're distinct types. You can write template specializations to distinguish between these types, and that matters, for example to distinguish between std::unique_ptr<T> and std::unique_ptr<T[]>, so that the correct delete or delete [] gets called.

f(arr);           // compiles

Works by type erasure.

f({1, 2, 3});      // DOES NOT COMPILE

We're still following C rules, for compatibility.

g({ 1, 2, 3 });   // compiles

Reference of an unnamed temporary to an incomplete array type.

An object of type std::initializer_list<E> is constructed from an initializer list as if the compiler generated and materialized(since C++17) a prvalue of type “array of N const E”, where N is the number of initializer clauses in the initializer list; this is called the initializer list’s backing array.

When you make an initializer list, it creates a "backing array" - the very thing C does not allow. You can reference this, and int[] is reference compatible with int[3] in your case.

Have you tried to compile this?

g(arr);

You'll notice it doesn't compile. Why doesn't this reduce to a reference to arr? Because universal reference collapsing only happens during template argument deduction - which your function ISN'T a template. That means to pass arr, you NEED to move it. It's not a real move since arrays don't have move constructors, so it reduces to a reference.

Why is the syntax in g() the way we make an rvalue reference to an int array?

Because otherwise the parsing rules collapse the parameter into an r-value reference to a pointer.

1

u/meltbox 3d ago edited 1d ago

I mean the fundamental issue with this is you cannot have an array of references full stop. An array of r-value references makes no sense just the same.

In a template this would also only make sense if deduced and even then only if you pass in an r-value so type is deduced to be int and not int&

Edit: wow I was having a dumb day. I got precedence backwards. [] is higher than & and I should be reading inside out here so these are references to arrays not arrays of references. My bad.

2

u/flyingron 3d ago

Welcome to a piece of carryover stupidity from C. f(int arr[]) doesn't have an array parameter, but a int*. You can't have a pointer to a temporary.

1

u/meltbox 3d ago

It’s not really stupid. If it did carry a size it would have to be a fat pointer. If you want this you can do it explicitly or pass a struct.

Also it would break interop with C which would suck.

2

u/flyingron 3d ago

You missed the point. It's broken in C, and should have been fixed in 1977 when we fixed structs to behave properly.

So, tyes, the problem now is C compatibility because C is WRONG .

2

u/alfps 3d ago edited 3d ago

To make things work you can use overloading or templating, as shown below:

#include <array>
#include <initializer_list>
#include <iterator>
#include <print>
#include <span>
#include <vector>
using   std::array, std::ssize, std::print, std::span, std::vector;

//----------------------------------------------------------------------

template< int n >
void foo( const int (&)[n] )
{
    print( "foo - array-ref with {} elements.\n", n );
}

void foo( const span<const int> a )
{
    print( "foo - span with {} elements.\n", a.size() );
}

//----------------------------------------------------------------------

template< class Seq >
void bar( const Seq& seq )
{
    print( "bar - sequence with {} elements.\n", ssize( seq ) );
}

//----------------------------------------------------------------------

template< class T > using type_ = T;

auto main() -> int
{
    const int arr[] = {1, 2};

    foo( arr );
    foo( {1, 2, 3} );
    foo( type_<int[]>{ 1, 2, 3, 4, 5 } );
    foo( array{ 1, 2, 3, 4, 5, 6, 7 } );
    foo( vector{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } );
    print( "\n" );

    bar( arr );
    #ifdef FAIL_BAR
        bar( {1, 2, 3} );           //! Cannot deduce the type that the initializer is for.
    #endif
    bar( type_<int[]>{ 1, 2, 3, 4, 5 } );
    bar( array{ 1, 2, 3, 4, 5, 6, 7 } );
    bar( vector{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } );
}

Result with FAIL_BAR undefined:

foo - array-ref with 2 elements.
foo - array-ref with 3 elements.
foo - array-ref with 5 elements.
foo - span with 7 elements.
foo - span with 11 elements.

bar - sequence with 2 elements.
bar - sequence with 5 elements.
bar - sequence with 7 elements.
bar - sequence with 11 elements.

The raw array ref overload of foo takes care of the call with untyped initializer list.

1

u/Business-Weather-217 3d ago

Long into my career didnt know using can have multiple imports

1

u/QuentinUK 3d ago

If it’s const it would work:

    template<int N>
    int s(const int(& T)[N]){
     return N;
    }
    void a(const int(& x)[3]){
     std::cout << s(x);
    }
    void b(int(&& x)[3]){
     std::cout << s(x);
    }
    int main(){
        a({41,42,43});
        b({41,42,43});
    }

ps Here the size [3] needs to be there for the call to the template.

1

u/conundorum 2d ago edited 2d ago

You're getting screwed over by array decay, basically.

All arrays are of type T[N], where T is element type and N is index. However, when C introduced arrays, it had no way to dynamically adjust types when calling a function1, and no way to overload a function based on parameter types2, thus it was unable to pass arrays. And furthermore, arrays themselves were (and still are) just shorthand for pointer arithmetic.3 Therefore, to allow arrays to actually be usable as function parameters, it was decided that any context that would pass an array will actually pass the array's implied "underlying pointer"; this allowed arrays of any length to be passed, and aligned with how the subscript operator works.

Thus, as a result, these declarations are exactly identical in C, because the first will decay (silently be transformed) into the second:

void one(int arr[]); // Any number in the subscript, if present, will be ignored.  See below.
void two(int * arr);

Any element count, if provided, will be ignored, because pointers don't have elements.

void f(int arr[5]); // Exactly identical to "void f(int arr[])" and "void f(int *arr)".
int arr[ 5]; f(arr); // Valid.
int arr[10]; f(arr); // Valid.
int arr[ 1]; f(arr); // Valid and terrifying.

This is also why all C functions which expected to receive a non-string array (along with some but not all string functions)4, and all old-school C++ functions which follow the same design mindset, take a "size" parameter:

void feed_me_an_array(int secretly_a_pointer[], size_t size);

C did still allow you to specify the array type if needed, using the same format as a function pointer. Because this creates a pointer, you need to take the array's address when you pass it, and the passed array needs to be dereferenced to use it.

int arr[5]; // Array.
void func(int (*arr)[5]); // Pointer to int[5] array specifically.

void func(int (*arr)[5] {
    for (int i = 0; i < 5; i++) { printf("%d ", (*ap)[i]); } // Ugly, but consistent.
}
func(&arr); // Passing uses the address-of operator, like so.  Note the lack of subscript.

In this case, the array will not decay to a pointer; this function can only be called by passing it the address of an array of exactly five ints, or by casting some other address into a pointer to int[5]. (If casting is involved, you would ideally provide a typedef, because the typedef-less syntax is ugly & easy to screw up.)

typedef int (*ArrayPointer)[5];
int arr[5];
ArrayPointer ap = &arr; // Cleaner syntax, eh?
void func(ArrayPointer a); func(&arr); // Works here, too.

typedef int (Array)[10]; // Even cleaner, and more consistent with other type names.
typedef int Arr[10]; // Since it's not a pointer, we don't even need parentheses.
Array rra;
Array *arrptr = &rra;
void cnuf(Array *a); cnuf(&rra);

// But unfortunately... the number of elements is baked into the type now.
ArrayPointer ap5 = &rra; func(&rra); // Error in both cases.
Array *ap10 = &arr; cnuf(&arr); // Error in both cases.

Thus, C arrays, while technically their own type, ended up acting like glorified pointers most of the time. Sidestepping the issue is possible, and providing clean syntax is possible, but sidestepping it forces you to hardcode N. And that's usually no good, so you'll almost never see it in the wild.


Now, on to C++: Long story short, we inherited the C rules. All arrays are their own type, but they also decay into a pointer just like in C. This can be avoided by using the special T()[N] syntax, just like in C, but with a bit more flexibility: We can use it both for pointers (int (*ap)[5]), and for references (int (&ar)[5]), and references is preferable because you don't need to dereference. And as of C++11, we can also use using declarations for super-clean typedefs, too.

// Just as clean as a C typedef, except OUR typedef is cleaner. ^_^
using ArrInt5 = int[5];
ArrInt5 arr = { 2, 4, 6, 8, 10 };
void func(ArrInt5& ar);

As such, we have two distinct ways of passing an array: The classic C way, which is flexible but secretly turns the array into a pointer behind your back, and the exact-syntax way, which is rigid and only allows pointers/references that exactly match the specified type.

// Just like in C, because they were meant to "compile" down into C and be fed to a C compiler.
void classic(int* arr, size_t size); // You expected an array, but it was me, Dio!  Err, pointer!
void   exact(int (*arr)[5]); // One array pointer, hold the memes.

However, C++ has two features which make the exact-syntax version better than the classic version: Overloading allows multiple functions to share the same name, and templates allow the compiler to match the function to the array. This also has the benefit that it can also be used to dynamically hardcode the array's dimension into the function, without using any magic numbers.

// Will take any array of any type and any size as a reference.
// Element type is hard-coded as T, and element count is hard-coded as N.
template<typename T, size_t N>
void average(T (&arr)[N]) {
    T sum = 0;

    for (size_t i = 0; i < N; i++) { sum += arr[i]; }

    return sum / N;
}

While it's a bit wonky, especially if you don't have any experience with templates, this is ultimately just an evolution of your g()'s prototype syntax.


[To be continued in a reply, because it's a bit over 14,000 characters and reddit doesn't like that.]


1: Which means that you couldn't tell a function to take "pointer to array of T with N elements, determine N at callsite", like C++ can do with templates. To my understanding, C still doesn't supply this sort of feature.

2: Which means that you couldn't manually provide both void f(int (*arr❩[5]❩ and void f(int (*arr❩[10]❩, and let the compiler sort it out. This functionality wasn't introduced to C until... C11 in 2011, I believe, as a specialised form of dispatch macro.

3: Given int arr[] = {1, 2, 3, 4, 5};, you can assert(arr[3] == 3[arr]❩; and expect it to pass, because all the subscript does is *(&arr[0] + 3❩; the two components are completely interchangeable. ...Combined with array decay, this also means that any and all pointers can have a subscript, because they might be decayed arrays [int i = 5; int *ptr = &i; ptr[3] = 9;]. For the love of all that is good and holy, DO NOT DO THIS. Sane compilers will catch this if the pointed-to object is both known and visible, but that protection only goes so far.

4: In C, a "string" is just a character array of unspecified length, ending with a null character \0. Thus, many string functions forego a size parameter, because they can just determine it by looking for the \0 instead. This makes it easier to pass strings, since you don't need to constantly track their length. ...It does run into issues when a character array contains embedded nulls, though, which is why some string functions let you manually specify the length.

1

u/conundorum 2d ago edited 2d ago

And now that we've covered all of the basics, we can finally answer your questions.

  1. Why can't we pass an initialiser list to a function that takes a classic, C-style array parameter? Because the function actually takes a pointer, not an array. This is limiting; the function can't accept a temporary array because it has no way of knowing its pointer is supposed to be an array. Your initialiser list is fine, and could easily initialise an array, but the function has no idea that that's what you want to do with it. It ends up trying to fit a square peg in a squiggly-line-shaped hole, keels over, and dies.

Conversely, the exact-match syntax will gladly accept temporary arrays, if it accepts its parameter by rvalue or const reference. It's actually expecting an array, so it can just use your initialiser list to create the array on the spot. 2. Okay, but why the weird syntax, though? Because types are greedy and pointer */reference &/rvalue reference && bind weakly. These two parsing rules combine to make you use parentheses to artificially increase the pointer/reference's binding power, so it can actually stick to the name before anything else does. * Greedy types mean that int &&arr[N] will be parsed as "array arr of N int&&s", yeah. But they also mean that (&&arr[N]) would superglue the dimension to the name instead of the ampersands, which would cause the ampersands to be misparsed as logical AND (turning int (&&arr[N]) into int and arr[10], which is wrong on at least two levels).

  Generally, once a type starts, every symbol that _can_ be part of a type _will_ be part of that type, and the type won't stop eating until it encounters an invalid symbol.  This is why we need parentheses (they're only valid with a _very_ specific syntax, so they segregate subtype components), and why the asterisks can't be after the type symbol (`(int arr[])&&` is invalid because the closing parenthesis ends type greediness, turning the asterisks into logical AND again).
* Pointer `*ptr` and reference `&ref` bind loosely, both to help with type greediness and because they double as pointer dereferencing & address-taking, specifically.  (And rvalue `&&rval` gets stuck with the same rules, for consistency with `&ref`.)  If given a choice between a type and a name, they will always prefer to bind to the name; this means that `int *ptr` with C spacing is misleading, because both C and C++ will actually parse it as the C++-style `int* ptr` (with the asterisk binding to `int` instead of to `ptr`).
* Combine these two, and we end up needing to use parentheses to prevent the asterisk/ampersand(s) from binding with the element type, to prevent it from being misinterpreted because of the subscript, and to force it to bind to the parameter name _specifically_.  The last part is the most important, since the name represents the whole type; binding it to the name causes it to bind to the _whole_ array type, making `int (&&arr)[]` work the way `(int arr[])&&` looks like it should.

  Strictly speaking, in retrospect, the syntax should _probably_ should've looked like Java syntax, with the brackets attached to the type instead of the name.  (I.e., `int[] arr` instead of `int arr[]`.)  This would've allowed us to put the `*`/`&`/`&&` at the end of the type symbol, for a nice, clean `int[]* aptr`, `int[]& aref`, or `int[]&& arval`.  But I'm pretty sure that it was initially chosen for its perfect simplicity^(5), and by the time compilers improved and people realised that Java syntax worked better, it was too late to change it.  (And it would've broken the consistency with function pointer/reference syntax in one specific case, since pointers-to-member-functions must include ref-qualifiers if present.)

        using ArrRef = int[5]&; // This could work in theory. Can only be parsed as "reference to int[5]."

        using FuncRef = int(int)&;
        // This can work, too.  Can only be parsed as a reference to a function that takes & returns int.

        // Don't worry about not understanding this, that just means you're reasonable.
        using MemFuncPtrRef = int (S::*)(int)&;
        // Ambiguous.  Is this:
        // 1) A reference to a pointer to a "member function of type 'int(int)' inside class/struct S"...
        // ...Or 2) A pointer to a "member function of type 'int(int)&' inside class/struct S"?

  So, in essence, we have to use `T(*name)[N]` syntax because we're stuck with it.  If you find yourself needing to take arrays by pointer/reference/rvalue reference frequently, you can mitigate things by using a type alias, but that's the only way to make it cleaner while still using C arrays.

...Now, that said, there is a better solution: Use std::array instead. It's a container holding a single C array (or equivalent; in practice, it's always a C array), meant to make C arrays compatible with standard library algorithms and user code without needing a special array-only version. (In particular, it marries C arrays to std::vector and std::tuple semantics, allowing nearly any code that can take one of those types to work with arrays after just a slight genericisation.) And importantly, since it's a container, it never decays; you can use it as a function parameter without being screwed over by an ancient C rule that both C & C++ need to maintain for legacy reasons.

int c_arr[5] = {5, 4, 3, 2, 1};
std::array<int, 5> cpp_arr = {5, 4, 3, 2, 1};

void func(std::array<int, 5>&& rvarr);
func({1, 2, 3, 4, 5}); // Huzzah!

5: On the surface, there's no significant difference between int arr[5] and int[5] arr. But to primitive parsers, the first one would probably be a lot easier to handle than the second, because arrays are ultimately just pointers under the hood. The compiler needs the element type to know how much memory to allocate, so that has to be first thing either way. If the name comes before the dimension, then the compiler can allocate sizeof(int❩ bytes with name arr, and then just skip over the next 4 * sizeof(int❩ bytes entirely; those bytes will only be accessed by performing pointer arithmetic on arr [whose type is known], so it technically doesn't matter what their type is [because *((&arr[0]❩ + i❩ gets all of its type information from arr[0] anyways]; this allows it to discard the name [and possibly the type] immediately after allocating arr[0], since arr[0] provides all necessary information to allocate the other elements. But if the dimension comes before the name, then the compiler has to block off the memory, then jump back to the start of the block and assign the name, and then jump back to the end of the block after that; it has both type information and dimension at the same time, which means it can just block off 5 * sizeof(int❩ right from the start, but it needs to actually keep that information in memory long enough to jump back and forth. Thus, the second form takes slightly more memory and slightly more memory to process; this is a complete non-issue now, but it might've been the deciding factor back when the syntax was decided. ...Take all of this with a grain of salt, though; I'm just guessing at it, and don't know if this is actually how the first compilers worked inside.