1Two layers: the category and the syntax
The standard names seven kinds of initialization, and the syntax you type does not map one-to-one onto them.
| Category | What it means |
|---|---|
| Default-init | T x; Runs the default constructor. For scalars at block scope it runs nothing and leaves indeterminate bytes. |
| Value-init | T x{}; Zero-fills scalars, then runs the default constructor if there is a user-provided one. |
| Zero-init | Not a syntax you write. It happens to objects with static storage duration before anything else, and as a step inside value-init. |
| Copy-init | T x = v; The = form. Cannot use explicit constructors. |
| Direct-init | T x(v); Considers every constructor, including explicit ones. |
| List-init | Anything with braces. Splits into direct-list-init T x{v} and copy-list-init T x = {v}. |
| Aggregate-init | A special case of list-init for aggregates. Members are filled positionally and no constructor is involved. |
What each syntax does depends on the type. Pick one and read down.
Two rows in the int tab are worth pausing on. int a; leaves indeterminate bytes and reading them is undefined behaviour, but static int g; is guaranteed zero. Storage duration decides that, not syntax. Never leave a local scalar default-initialized.
2The initializer_list hijack
This is the reason "always use braces" is not safe advice. If a class has any constructor taking std::initializer_list, braces prefer it even when another constructor is a far better match.
Overload resolution runs differently for the two forms. Step through it.
Braces do not just add an overload to the candidate set. They run a whole first pass in which initializer_list constructors are the only candidates, and ordinary constructors are considered only if that pass finds nothing viable. An int converts to an initializer_list<int> element without complaint, so the first pass almost always succeeds.
When you mean constructor arguments, use parentheses. In practice that means the containers, and anything else with an initializer_list constructor.
One exception that trips people up in the other direction: {} is never an empty initializer_list. It always means value-init or the default constructor. std::vector<int> v{}; calls the default constructor, not the list constructor with zero elements.
3Two things braces buy you
Narrowing becomes an error
Inside braces, a conversion that could lose information is ill-formed. This is the strongest argument for the brace habit.
The check is on the type, not the value, except for constant expressions the compiler can prove fit. int x{some_long} is an error even if the long happens to hold 3 at runtime.
The most vexing parse goes away
Anything that can be parsed as a declaration is parsed as a declaration. Parentheses are ambiguous in that grammar; braces are not.
4The = rule
Copy-initialization cannot use an explicit constructor. That is the whole purpose of the keyword: it blocks the implicit conversion, and copy-init is the form that would perform one.
The tell is the =. If there is an = in the initializer, explicit constructors are off the table, whether or not braces are involved. This is also why explicit matters on single-argument constructors: without it, any function taking your type will silently accept an int.
C++20 adds explicit(bool), so a constructor can be conditionally explicit based on a trait. That is how std::pair and std::tuple manage to be implicitly convertible only when all their element types are.
5Aggregates, and what disqualifies one
Aggregate initialization is the one that fills members positionally with no constructor involved. Its eligibility rules changed in three consecutive standards, which is why people guess wrong about them. Toggle the features.
The payoff, once a type qualifies, is that missing trailing members are value-initialized, not left as garbage:
Note the asymmetry. P p{1}; gives you a defined y of zero. P p; gives you two indeterminate members. One brace is the difference between defined and undefined behaviour.
6auto and braces
Template argument deduction and auto deduction treat braces differently, which leaves one rule to memorise.
The rule: with auto, = plus braces gives you an initializer_list. Direct braces give you the element type. This was tightened in C++17; before it, even auto a{1} deduced initializer_list<int>.
A template parameter T never deduces initializer_list from a braced argument at all. It is a hard deduction failure. That asymmetry between auto and T is deliberate, and it is why a perfect-forwarding wrapper cannot forward a braced initializer.
7Member initialization order
Members are initialized in declaration order, not the order you wrote them in the member initializer list. The compiler reorders your list silently to match.
Base classes are initialized before any member, and members in declaration order after that. Destruction is the exact reverse. -Wall includes -Wreorder, which catches the mismatch, so turn it on and believe it when it fires.
8What C++17 and C++20 changed
Guaranteed copy elision (C++17)
T x = T(args); is no longer a copy that the compiler is permitted to skip. It is direct-initialization, by definition, and no copy or move constructor needs to exist at all.
Designated initializers (C++20)
Named aggregate members, which removes most of the argument for hand-writing a builder.
Stricter than C, which allows any order, array designators, and mixing designated with positional. C++ allows none of those.
Parenthesized aggregate init (C++20)
Aggregates can now be initialized with parentheses, which fixes a long-standing wart: std::make_shared<P>(1, 2) used to fail for aggregates because the library uses parens internally.
It does not get the narrowing check that braces give, and it does not value-initialize missing members the way braces do. It exists for library compatibility, not as a new default.
9Which syntax to reach for
The two well-known positions are "almost always braces" and "parens for constructor calls, braces for data". A more useful split:
| Situation | Write | Because |
|---|---|---|
| Default value, any type | T x{}; | Never indeterminate. Works for scalars, classes and aggregates alike. |
| Ordinary construction | T x{a, b}; | Narrowing is caught, no vexing parse. |
The type has an initializer_list ctor | T x(a, b); | Braces would hijack to the list constructor. |
| Aggregate with several fields | T x{.a = 1, .b = 2}; | C++20. Named, and the compiler checks the names. |
| Simple copy of an existing value | T x = v; | Reads as assignment, and blocking explicit ctors is usually what you want here. |
| Class member, no ctor arg needed | int n_{}; | Default member initializer. Guarantees the member is never forgotten by some constructor. |
The last row, plus {} for defaults, is the one worth making a habit. Most real initialization bugs are not exotic overload resolution, they are a member that one of four constructors forgot to set.
10What happens?
11Check yourself
Answer out loud before opening each one.