Copy Construction in Initializer Lists
Asked Answered
P

2

12

I was exploring the ugly world of std::intializer_list.

As far as I've understood from the standard:

§ 11.6.4:

  1. An object of type std::initializer_list is constructed from an initializer list as if the implementation generated and materialized (7.4) a prvalue of type “array of N const E”, where N is the number of elements in the initializer list. Each element of that array is copy-initialized with the corresponding element of the initializer list, and the std::initializer_list object is constructed to refer to that array. [ Note: A constructor or conversion function selected for the copy shall be accessible (Clause 14) in the context of the initializer list. — end note ] [...]

So, in case the type E is a class, I expect the copy constructor to be called.


The following class does not allow copy construction:

struct NonCopyable {
  NonCopyable() = default;   
  NonCopyable(const NonCopyable&) = delete;
};

I am going to try to instantiate a std::initializer_list with this class.

#include <vector>

void foo() {
  std::vector<NonCopyable>{NonCopyable{}, NonCopyable{}};
}

With g++-8.2 -std=c++14 I get what I expect, compiler error:

error: use of deleted function 'NonCopyable::NonCopyable(const NonCopyable&)'.

Perfect!


However, the behaviour changes with the new standard.

Indeed, g++-8.2 -std=c++17 compiles.

Compiler Explorer Test


I thought it was because of the new requirement about copy elision provided by the new standard, at first.

However, changing the standard library implementation (keeping c++17) the error comes back:

clang-7 -std=c++17 -stdlib=libc++ fails:

'NonCopyable' has been explicitly marked deleted here NonCopyable(const NonCopyable&) = delete;

Compiler Explorer Test


So what am I missing?

1) Does C++17 require copy-elision in the copy construction of elements of initializer_list?

2) Why libc++ implementation does not compile here?


Edit Please note that, in the example g++ -std=c++17 (which compiles), if I change the default constructor as "user defined":

struct NonCopyable {
  NonCopyable();
  NonCopyable(const NonCopyable&) = delete;
};

the program does not compile anymore (not because of link error).

Compiler Explorer Example

Paregmenon answered 2/2, 2019 at 23:41 Comment(1)
"changing the standard library implementation (keeping c++17)" You're also changing compilers, so it's a lot less trivial than you're making it sound.Auricular
P
6

The issue is that this type:

struct NonCopyable {
  NonCopyable() = default;   
  NonCopyable(const NonCopyable&) = delete;
};

is trivially copyable. So as an optimization, since std::initializer_list is just backed by an array, what libstdc++ is doing is simply memcpying the the whole contents into the vector as an optimization. Note that this type is trivially copyable even though it has a deleted copy constructor!

This is why when you make the default constructor user-provided (by just writing ; instead of = default;), is suddenly doesn't compile anymore. That makes the type no longer trivially copyable, and hence the memcpy path goes away.

As to whether or not this behavior is correct, I am not sure (I doubt there's a requirement that this code must not compile? I submitted 89164 just in case). You certainly want libstdc++ to take that path in the case of trivially copyable - but maybe it needs to exclude this case? In any case, you can accomplish the same by additionally deleting the copy assignment operator (which you probably want to do anyway) - that would also end up with the type not being trivially copyable.

This didn't compile in C++14 because you could not construct the std::initializer_list - copy-initialization there required the copy constructor. But in C++17 with guaranteed copy elision, the construction of std::initializer_list is fine. But the problem of actually constructing the vector is totally separate from std::initializer_list (indeed, this is a total red herring). Consider:

void foo(NonCopyable const* f, NonCopyable const* l) {
  std::vector<NonCopyable>(f, l);
}

That compiles in C++11 just fine... at least since gcc 4.9.

Prefigure answered 3/2, 2019 at 0:5 Comment(6)
1) Default ctor's user-providedness doesn't affect trivially copyable. However, libstdc++ is dispatching on trivial, not trivially copyable (which is gcc.gnu.org/bugzilla/show_bug.cgi?id=68350). 2) Deleting the copy assignment operator doesn't make the type non-trivially-copyable either, but libstdc++'s optimization is engaged via std::copy, and so it does not apply the optimization if the type is not copy-assignable.Ichthyolite
Finally, this is technically library-level UB. Anything is conforming.Ichthyolite
"Note that this type is trivially copyable even though it has a deleted copy constructor!" I'm pretty sure that this behavior is not legal in accord with the standard. Even if the copy constructor is never called (pursuant to the "as if" rule), initializer_list constructors for containers require that the value_type is CopyConstructible, which NonCopyable is not. Specifically, it requires EmplaceConstructible from the value type of the range, which NonCopyable is not.Auricular
@NicolBolas Yes, and the penalty for breaking a Requires is undefined behavior, see [res.on.required]. So it's perfectly legal for the implementation to ignore it and do anything they want instead.Ichthyolite
Ah, and I'll correct my 2): additionally deleting copy assignment does make it non-trivially-copyable in the current WP as a result of DR1734 (the move members are suppressed), but that DR doesn't appear to have been implemented in the current implementations.Ichthyolite
@Ichthyolite Yeah I need to rewrite most of this... at least it's mostly in the right direction.Prefigure
A
3

Does C++17 require copy-elision in the copy construction of elements of initializer_list?

Initializing the elements of an initializer_list never guaranteed the use of "copy construction". It merely performs copy initialization. And whether copy initialization invokes a copy constructor or not depends entirely on what is going on in the initialization.

If you have a type that is convertible from int, and you do Type i = 5;, that is copy initialization. But it will not invoke the copy constructor; it will instead invoke the Type(int) constructor.

And yes, the construction of the elements of the array the initializer_list references are susceptible to copy elision. Including C++17's rules for guaranteed elision.

That being said, what isn't susceptible to those rules is the initialization of the vector itself. vector must copy the objects from an initializer_list, so they must have an accessible copy constructor. How a compiler/library implementation manages to get around this is not known, but it is definitely off-spec behavior.

Auricular answered 3/2, 2019 at 0:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.