Skip to content

Instantly share code, notes, and snippets.

@ckwastra
Last active June 9, 2024 04:29
Show Gist options
  • Save ckwastra/77b0e67ed157fc1038b167c0a9b546db to your computer and use it in GitHub Desktop.
Save ckwastra/77b0e67ed157fc1038b167c0a9b546db to your computer and use it in GitHub Desktop.
Initialization in C++: A Corner Case

Initialization in C++: A Corner Case

Recently, I've been trying to find out the difference between copy-initialization (e.g. in return statements) and direct-initialization (e.g. in static_cast expressions). Besides the explicit keyword, the code posted by Johannes really caught my attention because recent versions of GCC and Clang exhibit different behaviors for the mentioned code. Upon further investigation, it may be surprising that these behaviors are somewhat intentional. In the following, I will try to explain this corner case of initialization in C++. This post may be a bit arcane. Anyway, have fun reading!

Introduction

Given the following code:

// -std=c++23
#include <cstdio>
struct B;
struct A {
  A() = default;
  A(const B &) { puts("A::A(const B &)"); }
};
struct B {
  operator A() { // intentionally non-const
    puts("B::operator A()");
    return A();
  }
};
int main() {
  B b;
  A a0(b);                  // direct-init
  A a1 = b;                 // copy-init
  A a2 = static_cast<A>(b); // static_cast
  A a3 = (A)b;              // C-style cast
  A a4 = A(b);              // function-style cast
  A a5{b};                  // direct-list-init #1
  A a6 = A{b};              // direct-list-init #2
  A a7 = {b};               // copy-list-init
}

Which function will be called for each initialization of A? The constructor A::A(const B &) or the operator B::operator A()? It turns out that different compilers produce different answers. The following table summarizes the behaviors of Clang 18.1.0, GCC 14.1, and GCC 13.2. Try it yourself on Godbolt.

Initialization GCC 14.1 GCC 13.2 Clang 18.1.0
direct-init A::A(const B &) B::operator A() B::operator A()
copy-init B::operator A() B::operator A() B::operator A()
static_cast A::A(const B &) B::operator A() B::operator A()
C-style cast A::A(const B &) B::operator A() B::operator A()
function-style cast A::A(const B &) B::operator A() B::operator A()
direct-list-init #1 A::A(const B &) B::operator A() B::operator A()
direct-list-init #2 A::A(const B &) B::operator A() B::operator A()
copy-list-init A::A(const B &) A::A(const B &) B::operator A()

Note that only GCC 14.1 fully conforms to the C++23 standard. Next, let's find out why this is the case and how it works.

Direct-initialization

A a(b) basically works as follows: first, we choose a set of candidate functions, and then we perform overload resolution on them ([dcl.init.general]/16.6.2). During overload resolution, we select a set of viable functions from the candidate functions that are suitable for this call (e.g. the number of arguments matches). After that, the best viable function is chosen (or none due to ambiguity) and used for initialization. This may be an oversimplification, but it is sufficient for our case study. The candidate functions for direct-initialization A a(b) are the constructors of A, which are:

A::A(const B &);
A::A(A &&);
A::A(const A &);

All of them are viable functions, and we can work out the following implicit conversion sequences (ICSs) for each of them:

A::A(const B &): B & -> const B &      (1)
     A::A(A &&): B & -> A -> A &&      (2)
A::A(const A &): B & -> A -> const A & (3)

It turns out that ICS (1) is the best conversion sequence, and A(const B &) is the best viable function, thus it is used for direct-initialization.


UPDATE: As mentioned by argothiel, the above formulation of ICSs is not correct and could be misleading. Strictly speaking, both B & and const B & directly bind to b ([over.ics.ref]/1), and there is no such conversion like B & -> const B &. Instead, there are rules that prefer B & over const B & ([over.ics.rank]/3.2.6).

Copy-initialization

A a = b is similar to direct-initialization, except that the candidate functions now include ([dcl.init.general]/16.6.3):

  • Converting constructors of A, i.e. those non-explicit construtors that can be called with one argument since we really can't make copy-initialization work with multiple arguments. A a = b, c just declare another variable c of type A, and A a = (b, c) is no different from A a = c.
  • Non-explicit conversion functions of B, i.e. B::operator A() in our case.

Following the same process of overload resolution, we end up with the following viable functions and their ICSs:

A::A(const B &): B & -> const B &      (1)
     A::A(A &&): B & -> A -> A &&      (2)
A::A(const A &): B & -> A -> const A & (3)
B::operator A(): B & (no conversion)   (4)

Clearly, (4) wins, and B::operator A() is used for copy-initialization. Note that If it were declared like B::operator A() const, (1) and (4) will share the same ICS, and there would be ambiguity.

static_cast

In our case, A a = static_cast<A>(b) simply reduces to direct-initialization A a(b) ([expr.static.cast]/4).

C-style cast

Similarly, A a = (A)b basically reduces to A a = static_cast<A>(b) ([expr.cast]/4.2), which then further reduces to A a(b).

Function-style cast

When there is only a single expression, function-style cast reduces to C-style cast ([expr.type.conv]/2). That is, A(b) reduces to (A)b. Therefore, A a = A(b) reduces to A a = (A)b, which then further reduces to A a(b).

Direct-list-initialization #1

A a{b} involves two-stage overload resolution, one for initializer-list constructors (none in our case), and the other for all constructors ([dcl.init.list]/3.7). The resolution process is largely the same as that of the above direct-initialization, and we end up using A::A(const B &). One interesting concequence of this two-stage resolution is that all of the following declarations of std::vector<int> have the same semantics:

std::vector<int> v0{1, 2, 3};
std::vector<int> v1{{1, 2, 3}};
std::vector<int> v2({1, 2, 3});

UPDATE: Note that std::vector<int> v({{1, 2, 3}}) differs from the above initializations in that it will match the move constructor instead of the initializer-list constructor. This results in a redundant move operation since there is no guaranteed copy elision in this case, although various compilers have already implemented it. See also Fedor and CWG2311.

Direct-list-initialization #2

A a = A{b} reduces to the above #1 case ([expr.type.conv]/2).

Copy-list-initialization

A a = {b} is the same as A a{b}, except that if a explicit constructor is chosen, ths initialization is ill-formed, whereas in copy-initialization, non-explicit constructors are not listed as candidate functions in the first place ([dcl.init.list]/3.7). In other words, copy-initialization simply disregards explicit constructors, even if one of them would be the best viable function if it were not declared with explicit. In my opinion, the behavior of copy-list-initialization is more intuitive and robust. Another difference is that copy-list-initialization doesn't consider conversion functions as its candidate functions, so we end up chosing A::A(const B &). As you may have noticed, copy-list-initialization also has the advantage that it works nicely with multiple arguments.


UPDATE: It turns out that there is one corner case for copy-list-initialization as well. As I mentioned above, copy-list-initialization only checks explicit after the final constructor has been chosen. Now consider the following code:

#include <cstdio>
#include <utility>
struct A {
  A() = default;
  explicit A(A &&) { puts("A::A(A &&)"); }
  A(const A &) { puts("A::A(const A &)"); }
};
struct B {
  explicit B(A &&) { puts("B::B(A &&)"); }
  B(const A &) { puts("B::B(const A &)"); }
};
int main() {
  A a = {std::move(A())}; // to prevent copy elision
  B b = {A()};
}

Both initializations will choose the corresponding A && overload since it is a better fit and then become ill-formed because of the presence of explicit. However, in this case, GCC 14.1, Clang 18.1, and MSVC v19.38 all accept the first initialization while rejecting the second one (Godbolt). This is because A a = {std::move(A())} simply reduces to A a = std::move(A()) (i.e. copy initialization) before the adoption of CWG2137, and implementations seem to haven't fully fixed this issue yet (dfrib).

Summary

If we drop the duplicate items, we get:

Initialization GCC 14.1 GCC 13.2 Clang 18.1.0
direct-init A::A(const B &) B::operator A() B::operator A()
copy-init B::operator A() B::operator A() B::operator A()
direct-list-init #1 A::A(const B &) B::operator A() B::operator A()
copy-list-init A::A(const B &) A::A(const B &) B::operator A()

The difference between copy-initialization and other initialization methods is caused by the fact that only copy-initialization will consider B::operator A() as the candidate function and thus chooses it as the best best viable function. In other initializations, only constructers are considered as candidate functions, and A::A(const B &) is better than B::operator A(), followed by the copy or move construtor. It may seems surprising that both GCC 13.2 and Clang 18.1.0 got this wrong. However, it turns out this is caused by compilers attempting an optimization missed by the C++23 standard, which results in breaking the above rules.

A missing optimization

C++17 redefines the semantics of prvalue such that a prvalue is no longer a object, and passing a prvalue around doesn't invoke copy or move constructor (this is what we called guaranteed copy elision). A prvalue is not materialized into a object until needed (e.g. binding to a reference). Unfortunately, until at least C++23, direct-initialization prevents copy elision when a convertion function is chosen (CWG2327). Recall that in our example, we have the following ICSs for the move or copy constructor:

     A::A(A &&): B & -> A -> A &&      (2)
A::A(const A &): B & -> A -> const A & (3)

Suppose that one of these functions is chosen, the presence of references (A && or const A &) in the ICSs causes the prvalue A() to be materialized into a object and we end up with an extra copy or move operation. Basically, the rule of direct-initialization doesn't allow this kind of optimization, but it's surely a desriable optimization so compilers have implemented things to allow this optimization, though it may break other rules.

P2828 is the paper trying to resolve this issue in the standard. It also has conducted a great survey of different compilers' approachs to achieve this copy elision. For example, Clang simply considers conversion functions as candidates for direct-initialization as well, while GCC gives ICS B & -> A -> <reference> higher priority in overload resolution and then replaces the constructor by the conversion function. P2828 perfers the most conservative approch, i.e. only do copy elision when a copy or move constructor and a conversion function are chosen, to not change any overload resolution rule for now.

Conclusion

Note that this post is just a case study, and what presented here is far from the complete picture of initialization in C++. For example, we didn't touch on aggregate initialization at all. Returning to my original question, what's the difference between copy-initialization and direct-initialization besides explicit? As we have seen above, one answer is that these two methods can potentially call different functions to initialize the object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment