Why Is the Std::Initializer_List Constructor Preferred When Using a Braced Initializer List

Why is the std::initializer_list constructor preferred when using a braced initializer list?

§13.3.1.7 [over.match.list]/p1:

When objects of non-aggregate class type T are list-initialized
(8.5.4), overload resolution selects the constructor in two phases:

  • Initially, the candidate functions are the initializer-list constructors (8.5.4) of the class T and the argument list consists of
    the initializer list as a single argument.
  • If no viable initializer-list constructor is found, overload resolution is performed again, where the candidate functions are all
    the constructors of the class T and the argument list consists of
    the elements of the initializer list.

If the initializer list has no elements and T has a default
constructor, the first phase is omitted. In copy-list-initialization,
if an explicit constructor is chosen, the initialization is
ill-formed.

As long as there is a viable initializer-list constructor, it will trump all non-initializer-list constructors when list-initialization is used and the initializer list has at least one element.

Why does std::initializer_list in ctor not behave as expected?

Meyers is mostly correct (the exception is that T{} is value-initialization if a default constructor exists), but his statement is about overload resolution. That takes place after CTAD, which chooses the class (and hence the set of constructors) to use.

CTAD doesn’t “prefer” initializer-list constructors in that it prefers copying to wrapping for nestable templates like std::vector or std::optional. (It’s possible to override this with deduction guides, but the standard library uses the default, as one might expect.) This makes some sense in that it prevents creating strange types like std::optional<std::optional<int>>, but it makes generic code harder to write because it gives

template<class T> void f(T x) {
std::vector v{x};
// …
}

a meaning that depends on the type of its argument in an irregular and non-injective fashion. In particular, v might be std::vector<int> with T=int or with T=std::vector<int>, despite being std::vector<std::deque<int>> if T=std::deque<int>. It’s unfortunate that a tool for computing one type based on some others is not usable in a generic context.

Constructors taking initializer lists

It sounds like you're asking for motivation, as opposed to where in the standard it says this must be done. For that you can look at the original proposal N1919 for intializer lists by Bjarne Stroustrup, the C++ language creator.

He lists four ways to intialise an object:

X t1 = v; // “copy initialization” possibly copy construction
X t2(v); // direct initialization
X t3 = { v }; // initialize using initializer list
X t4 = X(v); // make an X from v and copy it to t4

Note that he's not talking about C++11, or a proposed version where initializer lists were introduced. This is back in C++98. That brace initialiser syntax already worked, but only for C-style structs i.e. with no user-defined constructor. This is a holdover from C, which has always allowed initialising structs (and arrays) this way, and it will always do the same thing: initialise element-by-element.

The whole point of the proposal is to allow initialisation for proper C++ objects like std::vector<int> in the same way as those C-style structs and arrays: C++ is meant to allow user-defined classes to look like built-in types (hence e.g. operator overloading) and here's one place it didn't. To turn your question around, the thing that is odd is not that std::vector<int>{3} calls the initialiser list constructor, the thing that is odd is that std::vector<std::string>{3} calls the non-initialiser list constructor. Why does it ever call a non-initialiser list constructor? That's not really what brace initialisation was originally for. The answer to that is to allow fix-length containers with hand-written constructors, like this:

class Vector3D {
public:
Vector3D(double x, double y, double z) { /*...*/ }
// ...
};
Vector3D v = {1, 2, 3}; // Ought to call non-initialiser list constructor

That is why constructors taking std::initializer_list are preferred (if available) when using brace initialisation. For those knowing the background, the use of brace initialisers for everything, as has become the fashion, seems really perverse: Foo f{7} looks like f will directly contain the number 7 and nothing else after construction completes, not that it does something arbitrary like construct something 7 elements long.

List initialization (aka uniform initialization) and initializer_list?

List-initialization prefers constructors with a std::initializer_list argument. From cppreference:

The effects of list-initialization of an object of type T are:

[...cases that do not apply here ...]

  • Otherwise, the constructors of T are considered, in two phases:
    • All constructors that take std::initializer_list as the only argument, or as the first argument if the remaining arguments have
      default values, are examined, and matched by overload resolution
      against a single argument of type std::initializer_list

    • If the previous stage does not produce a match, all constructors of T participate in overload resolution against the set of arguments
      that consists of the elements of the braced-init-list, with the
      restriction that only non-narrowing conversions are allowed. If this
      stage produces an explicit constructor as the best match for a
      copy-list-initialization, compilation fails (note, in simple
      copy-initialization, explicit constructors are not considered at all).

While direct initialization does not prefer the initializer_list constructor. It calls the constructor that takes the size as argument.

And from https://en.cppreference.com/w/cpp/container/vector/vector:

Note that the presence of list-initializing constructor (10) means
list initialization and direct initialization do different things:

std::vector<int> b{3}; // creates a 1-element vector holding {3}
std::vector<int> a(3); // creates a 3-element vector holding {0, 0, 0}
std::vector<int> d{1, 2}; // creates a 2-element vector holding {1, 2}
std::vector<int> c(1, 2); // creates a 1-element vector holding {2}


And how can I call std::vector::vector(size_t) with list-initialization?

As explained above, the presence of the std::initializer_list constructor prevents you from calling the other constructor via list-initialization.

How does nested list-initialization forward its arguments?

Usually, expressions are analyzed inside-out: The inner expressions have types and these types then decide which meaning the outer operators have and which functions are to be called.

But initializer lists are not expressions, and have no type. Therefore, inside-out does not work. Special overload resolution rules are needed to account for initializer lists.

The first rule is: If there are constructors with a single parameter that is some initializer_list<T>, then in a first round of overload resolution only such constructors are considered (over.match.list).

The second rule is: For each initializer_list<T> candidate (there could be more than one of them per class, with different T each), it is checked that each initializer can be converted to T, and only those candidates remain where this works out (over.ics.list).

This second rule is basically, where the initializer-lists-have-no-type hurdle is taken and inside-out analysis is resumed.

Once overload resolution has decided that a particular initializer_list<T> constructor should be used, copy-initialization is used to initialize the elements of type T of the initializer list.

Why does C++ allow std::initializer_list to be coerced to primitive types, and be used to initialise them?

That {} syntax is a braced-init-list, and since it is used as an argument in a function call, it copy-list-initializes a corresponding parameter.

§ 8.5 [dcl.init]/p17:

(17.1) — If the initializer is a (non-parenthesized) braced-init-list, the object or reference is list-initialized (8.5.4).

§ 8.5.4 [dcl.init.list]/p1:

List-initialization is initialization of an object or reference from a braced-init-list. Such an initializer is
called an initializer list, and the comma-separated initializer-clauses of the list are called the elements of the
initializer list. An initializer list may be empty. List-initialization can occur in direct-initialization or copy-initialization contexts; [...]

For a class-type parameter, with list-initialization, overload resolution looks up for a viable constructor in two phases:

§ 13.3.1.7 [over.match.list]/p1:

When objects of non-aggregate class type T are list-initialized (8.5.4), overload resolution selects the constructor
in two phases:

— Initially, the candidate functions are the initializer-list constructors (8.5.4) of the class T and the argument list consists of the initializer list as a single argument.

— If no viable initializer-list constructor is found, overload resolution is performed again, where the candidate functions are all the constructors of the class T and the argument list consists of the elements of the initializer list.

but:

If the initializer list has no elements and T has a default constructor, the first phase is omitted.

Since std::deque<T> defines a non-explicit default constructor, one is added to a set of viable functions for overload resolution. Initialization through a constructor is classified as a user-defined conversion (§ 13.3.3.1.5 [over.ics.list]/p4):

Otherwise, if the parameter is a non-aggregate class X and overload resolution per 13.3.1.7 chooses a single
best constructor of X to perform the initialization of an object of type X from the argument initializer list,
the implicit conversion sequence is a user-defined conversion sequence with the second standard conversion
sequence an identity conversion.

Going further, an empty braced-init-list can value-initialize its corresponding parameter (§ 8.5.4 [dcl.init.list]/p3), which for literal types stands for zero-initialization:

(3.7) — Otherwise, if the initializer list has no elements, the object is value-initialized.

This, for literal types like bool, doesn't require any conversion and is classified as a standard conversion (§ 13.3.3.1.5 [over.ics.list]/p7):

Otherwise, if the parameter type is not a class:

(7.2) — if the initializer list has no elements, the implicit conversion sequence is the identity conversion.

[ Example:

void f(int);
f( { } );
// OK: identity conversion

end example ]

Overload resolution checks in first place if there exists an argument for which a conversion sequence to a corresponding parameter is better than in another overload (§ 13.3.3 [over.match.best]/p1):

[...] Given these definitions, a viable function F1 is defined to be a better function than another viable function
F2 if for all arguments i, ICSi(F1) is not a worse conversion sequence than ICSi(F2), and then:

(1.3) — for some argument j, ICSj(F1) is a better conversion sequence than ICSj(F2), or, if not that, [...]

Conversion sequences are ranked as per § 13.3.3.2 [over.ics.rank]/p2:

When comparing the basic forms of implicit conversion sequences (as defined in 13.3.3.1)

(2.1) — a standard conversion sequence (13.3.3.1.1) is a better conversion sequence than a user-defined conversion sequence or an ellipsis conversion sequence, and [...]

As such, the first overload with bool initialized with {} is considered as a better match.



Related Topics



Leave a reply



Submit