It has been three days. The room is cold and dark, but your screens are blinding. You feel disoriented as you come in and out of dissociative episodes. Now and again, you laugh, to no accompaniment. Why are you here? Was this your fault?

Your first mistake was to engage—this much is clear.

∗  ∗  ∗

Back when I took a first course in C++ a few years ago, I was taught that, under certain circumstances, the compiler would provide some kind of defaulted constructors in case we don’t provide our own. Curious to know more, my primary concern was with cases like this:

struct T { /* ... */ };

T t;
T s{};
T r{arg1, arg2, ...};

I became interested in the particulars of what this meant. Most of my focus fell on the first two—for the third, I felt satisfied with a hand-wavy explanation of “if T is simple enough, it’ll do component-wise initialization.” The first two are where the danger lies, after all: what if some objects are left uninitialized? The search looked something like what follows.

Primarily, there are two kinds of initialization of concern: default-initialization and value-initialization. The rules given in the standard look roughly like this:

  • For any type T, T t; performs default-initialization on t as follows:
    • If T is a class type and there is a default constructor, run it.
    • If T is an array type, default-initialize each element.
    • Otherwise, do nothing.
  • For any type T, T t{}; performs value-initialization on t as follows:
    • If T is a class type…
      • If there is no default constructor (i.e., if the user has declared any non-default constructors) or if there is a user-provided or deleted default constructor, default-initialize.
      • Otherwise, zero-initialize and then default-initialize.
    • Otherwise, if T is an array type, value-initialize each element.
    • Otherwise, zero-initialize.

You can see each of these in action here:

struct Pair {
    int x, y;
    Pair() : x{0}, y{1} {}
};
struct SimplePair {
    int x, y;
};

int x{}; // value-initialized => zero-initialized
int y; // default-initialized (to garbage)
Pair p; // default-initialized => default-constructed
Pair q{}; // value-initialized => default-initialized => default-constructed
SimplePair r; // default-initialized => default-constructed to garbage (more on this later)

This leaves for discussion the default constructor, i.e., the zero-argument constructor overload. What does the compiler provide us and when? It’s generally common knowledge that, so long as you don’t declare any of your own constructors, the compiler will declare and (possibly) provide its own. But, the devil’s in the details after all—and C++ has a terrifying quantity of details, which bears some implication on the exorcist’s nightmare contained therein.

The Default Constructor

When you don’t declare any constructors, the compiler will declare a default constructor for you: this one is called implicitly-declared. There’s also the almost-identical “defaulted on first declaration” constructor—almost-identical in that they’re mostly interchangeable in the standard, it looks like this:

struct T {
    T() = default;
};

These constructors don’t necessarily do anything by virtue of being implicitly-declared or defaulted on first declaration—after all, they’re only declared so far—but there are knock-on effects which will affect what they do. In particular, if a default constructor is implicitly declared or explicitly defaulted (and not defined as deleted), an implicitly-defined default constructor will be provided by the compiler. In terms of implementation, it’s guaranteed to be equivalent to a constructor with an empty body and empty member initializer list (i.e., T() {}).

So, if we did something like this:

struct T {
    int x;
    T() = default;
};

T t{};
std::cout << t.x << std::endl;

The printed result would be 0. This is because we value-initialize t and, since T has a non-user-provided default constructor, the object is zero-initialized (hence t.x is zero-initialized) then default-initialized (calling the implicitly-defined default constructor, which does nothing).

Naturally, we can also get an implicitly-defined default constructor outside the class as follows:

struct T {
    T();
};
T::T() = default;

Actually, let me augment this to look a little more like the last example:

struct T {
    int x;
    T();
};
T::T() = default;

T t{};
std::cout << t.x << std::endl;

You’d expect the printed result to be 0, right? You poor thing. Alas—it will be garbage. Some things can never be perfect, it seems. Here’s a relevant excerpt from our description of value-initialization:

If there is no default constructor (i.e., if the user has declared any non-default constructors) or if there is a user-provided or deleted default constructor, default-initialize.

A-ha. You see this line?

T::T() = default;

That’s user-provided. By defining the constructor outside the class like this, we are not first-declaring it as defaulted; we are defining it as defaulted. Providing it, if you will. So, the compiler will rightfully opt to simply default-initialize t, hence running the explicitly-defaulted default constructor which does precisely nothing. Great.

Of course, in some situations, it’s impossible for the compiler to provide a sane default constructor—in such cases, it defines the implicitly-declared default constructor as deleted. Here are some of the situations that lead to this:

  • T has a non-static reference member (what would you reasonably default-initialize this to?);
  • T has non-static members or non-abstract base classes which aren’t reasonably default-constructable or destructable (I’m omitting some details here, but you get the idea); or
  • T has any const, non-static, non-const-default-constructible members without default member initializers (if the member is const and won’t get default-initialized to anything useful, our final object would necessarily permanently contain garbage).
    • Aside: for a class type, “const-default-constructible” means default-initialization will invoke a non-inherited user-provided constructor—the idea being that a const object of the type can be sanely initialized by default-initialization.

Remember that that’s all assuming you don’t provide any constructors yourself. If you do, the compiler won’t try to implicitly define a default constructor—not even as deleted. There would be no default constructor at all here.

I’m omitting some details here and eliding discussion of unions altogether, but these are the broad strokes. Basically, if your class has anything that can’t be default-initialized in an at-least-sort-of-potentially-useful way, the compiler will give up and define the implicitly-declared default constructor as deleted. This follows the (often unfortunate) guiding C++ philosophy of being very permissive.

Here’s an example that’s as simple as it gets: the presence of a const int member without a default member initializer (e.g., = 0) defines the implicitly-declared default constructor as deleted, so it won’t compile:

struct A {
    const int x;
};

A a{};

Well, that’s what you’d think if you hadn’t read carefully enough, anyway. As it turns out, this gnarly code is perfectly well-formed and will compile—indeed, a.x will be initialized 0. Why? Because A is an aggregate. And, actually, that’s not value-initialization at all.

Initialization Done Right

Alright, let’s get down to brass tacks. I lied to you. Well, only kind of—all the other examples I gave so far were carefully cooked so that the explanations I gave were still technically right—there was just one incorrect definition, and some very deliberate dancing around a very large elephant in the room.

In fact, when we write something like T t{};, what’s actually being performed first-up is something called list-initialization. Indeed, anything that looks like T t{...}, T t = {...}, or most any other curly-brace-decorated form of initialization, is probably list-initialization. The first two forms here are called direct-list-initialization and copy-list-initialization respectively. Copy-initialization, as a standalone thing, is about initializing an object from another object, usually involving an = in some way; direct-initialization, on the other hand, is about initializing an object from a set of constructor arguments. The practical difference is minimal beyond syntax, so we’ll mostly restrict our discussion to direct-list-initialization.

List-initialization is a bit of a complicated thing but, as it turns out, there’s actually yet another kind of initialization standing between it and the funny-looking const example that got us here. First, a definition:

Definition

An aggregate is either (1) an array or (2) a class which has

  • no user-declared or inherited constructors;
  • no private or protected direct non-static data members;
  • no private or protected direct base classes; and
  • no virtual functions or virtual base classes.

Basically, an aggregate is one kind of “simple” type that we can craft, sitting close behind trivial and standard-layout types. There’s a special kind of list-initialization which exists for aggregates called aggregate initialization. The particulars surrounding this get a little hairy (read: uninteresting) in the standard, but it suffices to say that it’s a souped-up way of copy-initializing each element of the class (or array) with each element of the initializer list, in order. If the number of elements given in the list is less than the number of elements in the aggregate, each remaining element of the aggregate will be initialized with its default member initializer (if it has one) or, assuming it’s not a reference, copy-initialized with an empty initializer list (as in, = {}; this will recursively lead to another round of list-initialization).

So, here’s the glue between list-initialization and aggregate initialization: if list-initialization is performed on an aggregate, aggregate initialization is performed unless the list has only one argument, of type T or of type derived from T, in which case it performs direct-initialization (or copy-initialization). Hence, with an example like this:

struct S {
    int a;
    float b;
    char c;
};

S s{3, 4.0f, 'S'};

…there are no constructor calls to speak of.

Of course, that covers list-initialization for aggregates, but there are a few other cases left. Namely…

  • If T is a non-aggregate class type…
    • If the initializer is empty and T has a default constructor, then value-initialization is performed.
    • Otherwise, consider other constructors according to the usual overload resolution procedure—note that std::initializer_list constructor overloads always get priority here.
  • When the initializer list contains exactly one element, non-class types and references are initialized more or less how you’d expect, so we won’t dwell on them.
  • Finally, otherwise, if the initializer list is empty, value-initialization is performed.

With that, I will correct the subtly wrong definition of value-initialization I gave earlier: for non-aggregate types T, T t{}; performs value-initialization (via list-initialization) on t as follows:

  • If T is a class type…
    • If there is no default constructor or if there is a user-provided or deleted default constructor, default-initialize.
    • Otherwise, zero-initialize and then default-initialize.
  • Otherwise, if T is an array type, value-initialize each element.
  • Otherwise, zero-initialize.

The only addition is the word “non-aggregate.” There are a few wrinkles I’m leaving out (like unions), but they all behave more or less how you’d expect given all of this. Anyway, here’s that gnarly example from earlier again:

struct A {
    const int x;
};

A a{};

A sharp moment of clarity and understanding; the sensation that, if there is a god, it must have spoken to you. You are grateful either way. Here, A is clearly an aggregate, and so list-initialization leads to aggregate initialization which leads to copy-list-initialization of a.x with an empty list, hence value initialization, hence zero initialization. No fuss, no muss. Constructors were never in question to begin with. While we’re feeling so high and mighty, we might even try this:

A b{4};

This performs list-initialization, hence aggregate initialization, hence copy-initialization of b.a with 4. Yes! We might even be so bold as to try this:

A c{4.0f};
// error: narrowing conversion of '4.0e+0f' from 'float' to 'int' [-Wnarrowing]

Fuck. OK, maybe we pushed our luck. I forgot to mention that list-initializing with one element doesn’t allow for narrowing conversions. It’s fine, though. It’s all fine. Here:

A c(4.0f); // N.B. parentheses, not braces

Under C++20, this compiles.

Cry.

Lists But Rounder

You might recall from a first course in C++ that you can kind of mostly use parentheses in the place of braces for the purposes of initialization. Not everywhere, but, like, mostwhere. You were probably told to avoid it for reasons like the most vexing parse: while T t(a, b, c); syntactically represents an invocation of some constructor (not quite; we’ll discuss this later), T t(); syntactically represents a declaration of a function t which takes no parameters and has return type T. Here, you really need to either settle on T t{}; or T t; instead. However, this parenthesized expression-list initializer is often functionally different from braced-init-list initializers. Parenthesized initializers invoke direct-non-list-initialization, which has rules that are similar to but different from direct-list-initialization.

Parenthesized initializers let us perform aggregate initialization on aggregates (both classes and arrays) in basically the same way as with list-initialization, but narrowing conversions are allowed, remaining elements are directly value-initialized (rather than empty-list-initialized), and temporaries bound to references do not have their lifetime extended. Did you catch that last one? Here’s the action replay:

struct T {
    const int& r;
};

T t(42);

That’s right: t.r is a dangling reference. Reading from it is undefined behaviour. What a world. I really can’t imagine why this would be desired behaviour for any programmer ever, but that’s just how things are sometimes.

In any case, parentheses are generally less “regulatory”; they’re often more permissive. Along with those allowances I just mentioned, it also lets us invoke the copy constructor for T in case there’s also a std::initializer_list<T> constructor overload—recall that such overloads take priority in overload resolution, and a braced-init-list of Ts can be interpreted as std::initializer_list<T>.

struct T {
    T(std::initializer_list<T>) {
        std::cout << "list" << std::endl;
    }
    T(const T&) {
        std::cout << "copy" << std::endl;
    }
};

T t{}; // list
T s{t} // list
T r(t); // copy
T q(T{}); // list (no copy!)

That last one might come as a surprise—according to what we’ve seen so far, we would expect the list constructor to be used for T{}, followed by the copy constructor for q(T{}). You may recall something called copy elision—this is essentially that. In particular, direct- and copy-initialization have provisions where, if the initializer is a prvalue of type T, the object gets initialized directly by the expression rather than the temporary materialized by it. This is never explicitly called “elision” by the standard—one might argue that it’s not even an appropriate name for it since C++17—but it’s probably what you know it as anyway.

An aside: from what I can tell, for some reason, this technically only works if non-list-initialization is used, i.e, T q(T{}); but NOT T q{T{}};. Like, there’s just nothing described in the standard about list-initialization that would allow for elision to take place. Both GCC and Clang ignore this and elide anyway in most cases, except if a std::initializer_list<T> constructor overload is defined (like above), in which case GCC uses it instead of eliding—I understand this to be a single corner case where GCC does the right thing. I expect this will be cleared up eventually—see CWG issue 2311.

Anyway, for a parenthesized initializer, if this elision provision doesn’t apply, constructors are considered as you would expect; if there are none and T is an aggregate class, it’ll do per-element copy-initialization as discussed earlier.

One last nugget: elements of parenthesized initialization lists have no guaranteed evaluation order, whereas braced initialization lists evaluate elements strictly from left to right.

∗  ∗  ∗

That should be most of it. I mean, there are special initialization rules for static variables (constant initialization included), but, like, do you really care? In my humble opinion, here’s the key takeaway: just write your own fucking constructors! You see all that nonsense? Almost completely avoidable if you had just written your own fucking constructors. Don’t let the compiler figure it out for you. You’re the one in control here. Or is it that you think you’re being cute? You just added six instances of undefined behaviour to your company’s codebase, and now twenty Russian hackers are fighting to pwn your app first. Are you stupid? What’s the matter with you? What were you thinking? God.