Pre-C++11, every kind of "give this thing a starting value" used a slightly different syntax, and each one had its own pitfalls. Brace initialization, introduced in C++11, was the standard's attempt to make {...} work consistently across every type: built-ins, structs, classes, containers, arrays, even return values. This chapter walks through how it works, why it's stricter than the old forms in a good way, and the handful of spots where the rules surprise readers.
Before C++11, C++ inherited three ways to give a variable a starting value, and each one came from a different corner of the language. None of them worked everywhere.
Each form had restrictions. The parenthesized form int a(5) worked for built-ins and class types, but it broke down for arrays and aggregates. The = value form worked for built-ins, but it implied a temporary plus a copy for class types (the compiler usually elides the copy, but the semantics felt different). The braced {...} form worked for arrays and plain structs but not for class types with constructors.
Then there was a particularly awkward case: how to value-initialize a member of a class template. Sometimes T() worked, sometimes it didn't, depending on whether T was a built-in or a class type with no default constructor. Helper macros were written to paper over it.
C++11 introduced uniform initialization with {} to fix this. The goal was a single syntax that works for every type, including class types, aggregates, arrays, built-ins, and standard containers. std::vector<int> v{1, 2, 3}; was the headline example, which wasn't possible in C++03.
The same {...} works for a plain int, a double, a std::string (which has constructors), a plain struct, and a std::vector (which uses an std::initializer_list constructor). That uniformity is the pitch.
There's also a stylistic argument for braces that the language committee leaned on heavily. The old C++03 syntax Widget w(args) looks identical to a function declaration when args happens to be a type. The section on the most vexing parse covers this.
One historical note. The old = value form was sometimes called "copy initialization" even though the compiler almost always elided the copy and ended up generating the same instructions as the direct form. The naming was a holdover from C++98 semantics, where the standard described the form as "construct a temporary, then copy it into the named variable". Modern compilers under copy elision and guaranteed copy elision (since C++17) produce identical machine code for int b = 5 and int b(5) in nearly every case. The distinction still matters for class types with explicit constructors, as the next section shows, but the runtime cost story is a thing of the past.
T x{...} is direct-list-initialization in the standard's terminology. T x = {...} is copy-list-initialization. They produce the same result for most cases, but there's one observable difference: copy-list-initialization refuses to call explicit constructors.
The commented-out line would not compile. The error from g++ reads chosen constructor is explicit in copy-initialization. The rule mirrors what already happens with the older forms: DiscountCode b("WELCOME10") works, DiscountCode b = "WELCOME10" does not. The brace forms inherit the same distinction.
For everyday code, prefer the form without =. It's shorter, works in more contexts, and matches how constructors actually behave.
An aggregate in C++ is, roughly, a plain struct or array with no user-declared constructors, no private or protected non-static data members, no virtual functions, and no base classes (the C++17 rules relaxed this; C++20 tightened it slightly again). For an aggregate, each member can be initialized by listing values in order inside braces.
No constructor is defined on Product. The compiler walks the brace list and assigns each value to the corresponding member, in declaration order. With fewer values than there are members, the remaining members are value-initialized (more on what that means in a moment).
price and stock weren't given values, so they end up as 0.0 and 0 respectively. That's value initialization applied to the trailing members. The same rule applies to arrays: int counts[5]{}; zero-initializes every element.
C++20 added designated initializers (borrowed from C99) so members can be named in the brace list:
Designated initializers help readability, especially for structs with many fields where it's easy to swap two same-typed members by accident. They require C++20, and the members must appear in declaration order (some can be skipped, but they can't be reordered). The rest of the examples in this chapter target C++17, so designated initializers don't appear further.
The most useful safety feature brace initialization adds. The old C-style implicit conversions truncate or round when a wider type is assigned into a narrower one. With brace initialization, the compiler treats that as an error.
int rounded = tax is legal C++ and throws away the fractional part. int boom{tax} refuses to compile. The g++ error reads:
The same protection applies to literals. int x{3.14}; won't compile, but int y = 3.14; will (and produces y == 3). The narrowing rule covers any value-losing conversion the compiler can prove at compile time: double to int, long to int when the value doesn't fit, int to char when the value is out of range, signed to unsigned with a negative literal, and so on.
The catch is that narrowing is decided statically, based on what the compiler can prove. Passing a runtime double whose value happens to fit in an int still produces the error, because the compiler doesn't know the value at compile time.
That's the safety feature working as designed. To allow truncation, write it explicitly: int x{static_cast<int>(d)};. The conversion is then visible in code.
This matters most in real e-commerce code that mixes prices (double), quantities (int), and totals. A bug where a double total truncates into an int discount field can mis-charge customers. Brace initialization catches it at compile time.
No runtime cost. The narrowing check runs entirely at compile time and disappears from the generated code.
T x{}Empty braces give value initialization, the standard's term for "give me a sensibly zero-valued instance of this type". For built-in types, that means zero. For class types with a user-provided default constructor, it means calling that constructor. For aggregates, it means zero-initializing every member.
Every variable has a deterministic starting value. Compare this with the plain declaration form:
For built-in types declared at function scope, int count; leaves the variable holding whatever bits were on the stack. Reading it before assignment is undefined behavior. int count{}; removes that hazard. For class types, the two forms are usually equivalent (both default-construct), but T x{} still has the advantage of being a single rule that applies everywhere without thinking about whether T is a built-in or a class.
A common pattern in modern code: always brace-initialize member variables in class bodies, so they start in a known state even before the constructor runs.
loyaltyPoints{0} and premium{false} are default member initializers (available since C++11). If a constructor doesn't explicitly initialize one of these members, the brace value is used. This eliminates a class of bugs where a new member is added but not set in every constructor.
Value initialization of a class with no user-provided constructor zero-initializes all members, which is one memcpy-equivalent write for the whole object. For a Product struct with four members, that's typically a single instruction or two on modern CPUs. Not a performance concern.
One subtle behavior comes up in templates. If T is a built-in type, then T() and T{} are both value-initialization and produce zero. If T is a class type with a user-defined default constructor, both call that constructor. But if T is a class type without a user-defined default constructor (an aggregate), T() and T{} zero-initialize all members. This consistent behavior is the reason generic library code overwhelmingly uses T{} for "give me a default value of T". Before C++11, library writers had to special-case built-ins and aggregates, sometimes with template metaprogramming. Brace initialization erased that distinction.
The template doesn't care what T is. The brace expression T{} always produces a sensibly-zeroed instance. That's the kind of uniformity the feature was designed to deliver.
The "most vexing parse" is one of C++'s oldest pitfalls. It comes from a quirk of how the grammar handles parentheses, and brace initialization is the cleanest fix.
That code probably won't crash, but it does the opposite of what's expected. OrderProcessor p(Timer()); is parsed as a function declaration: a function named p that takes one parameter of type "pointer to a function returning Timer and taking no arguments", and itself returns OrderProcessor. The compiler does not see a constructor call. Using p as an object afterwards produces errors like error: request for member 'something' in 'p', which is of non-class type.
The grammar reason: any time C++ can parse something as either a declaration or an expression, the standard says declaration wins. Timer() could be a default-constructed Timer (an expression) or a function type (a declaration). Declaration wins. So (Timer()) is treated as a parameter declaration.
Brace initialization breaks the tie because braces cannot start a function-parameter type. Writing {...} tells the compiler this is a value, not a type.
Both Timer{} and the outer braces are direct-list-initializations. The whole thing reads as "construct an OrderProcessor from a default-constructed Timer". No ambiguity.
The vexing parse shows up most often when passing a temporary to a constructor. Templates and generic code hit it often. The fix is one of:
OrderProcessor p{Timer{}}; (brace init, the modern choice)OrderProcessor p((Timer())); (extra parens disambiguate to expression)Timer t; OrderProcessor p(t); (give the temporary a name)auto p = OrderProcessor(Timer()); (since C++11, also works)The brace form is shortest and clearest.
The diagram captures the fork. The same source token sequence routes through one of two parsings. Parens land on the declaration path, which is almost never the intent. Braces force the object-construction path because the grammar has no other meaning for {...} in that position.
std::initializer_list ConstructorsAn std::initializer_list is how containers like std::vector accept braced lists. There's one wrinkle worth knowing for this chapter: when a class has both a regular constructor and an std::initializer_list constructor, brace initialization prefers the initializer-list version even when a regular constructor would seem like the better fit. Parenthesized initialization picks the other behavior.
The canonical example is std::vector<int>.
Both lines look almost identical. The behavior is wildly different. std::vector<int> a(10, 0) calls the (size_type count, const T& value) constructor, producing a vector of ten zeros. std::vector<int> b{10, 0} calls the std::initializer_list<int> constructor, producing a vector of two elements. The compiler sees the braces and prefers the initializer-list constructor whenever the braced list's elements are convertible to the initializer-list's element type.
That preference is by design. The committee wanted std::vector<int> v{1, 2, 3, 4, 5} to do what it looks like it does. The unfortunate consequence is that {count, value} no longer means "count copies of value" for vector.
This rule isn't unique to vector. Any class with an initializer-list constructor will route {...} to it whenever possible. To call the other constructor, fall back to parentheses.
This is one of the few cases where modern C++ recommends parentheses over braces. A common rule of thumb: braces for everything except sizing constructors on standard containers. Then drop back to parens.
The same trap shows up in custom classes. A class with both a count-style constructor and an initializer-list constructor should document which one callers should use. The rule is precise: when the braced elements are convertible to the initializer list's element type, the initializer-list overload wins. When they aren't convertible at all, overload resolution falls back to the other constructors.
Quantity q2{5} is the unexpected case. There's a single int in the braces and a single-int constructor available, but the initializer-list overload still wins. The rule is unconditional: if the class has an std::initializer_list<T> constructor and the braced elements are convertible to T, that overload is preferred. To call the int constructor, use parentheses: Quantity q1(5).
No runtime cost from the rule itself. The cost is bugs. Mixing initializer-list and other constructors is a class design choice; callers need to know which form picks which.
For most uses, braces are the better default. They forbid narrowing, dodge the vexing parse, and work uniformly across types. But there are a few situations where parentheses still win.
Standard containers with sizing constructors. As shown above, std::vector<T>(count, value) is the only way to get the count-and-fill behavior without manually pushing elements. Same for std::string(count, char) and a handful of other types.
Writing std::vector<int> stockLevels{100, 0}; produces a two-element vector containing 100 and 0, not the intended hundred-element vector.
To avoid the initializer-list trap on a custom class. When a class has both an initializer-list constructor and a regular one and the regular one is needed, use parens.
When narrowing is intentional and explicit. For a deliberate narrowing conversion (rare, but it happens in low-level code), the older forms still allow it. The braced form does not. Writing static_cast<int>(...) is cleaner than dropping back to old-style init.
With `auto` when the type matters. The expression auto x{1}; was originally specified as std::initializer_list<int> in C++11, which surprised everyone. C++17 fixed this so that single-element brace lists with auto deduce the element type directly, so auto x{1}; is now int. Multi-element brace lists still deduce as std::initializer_list, which means auto v{1, 2, 3}; is an std::initializer_list<int>, not a std::vector<int> or anything else. For a vector, spell it out: std::vector<int> v{1, 2, 3};. The interaction with auto is one of the few places where mixing braces and type deduction has changed behavior across standards.
For everything else, prefer braces. Consistent style across a codebase matters more than micro-optimizing the syntax for each case. Modern C++ projects often codify "braces by default" in their style guides, with carve-outs for the two or three cases listed above. The result is fewer narrowing bugs, no most-vexing-parse traps, and one syntax to teach new contributors.
The line auto a{42} deduces int under C++17 and later. Under C++11 it would have been std::initializer_list<int> with one element. This is a rare case where upgrading the standard changes the meaning of an existing program, which is why C++17's authors fixed it: the original C++11 rule was an unwelcome surprise.
A more realistic e-commerce example that uses brace initialization across types: a struct, a class with constructors, a vector of structs, and value-initialized members.
Every initialization in that program uses braces. Product is an aggregate, so the brace list fills its members in order. OrderItem is also an aggregate, so {mouse, 2} builds one cleanly. The vector uses its std::initializer_list constructor to absorb the three OrderItem literals. The Order constructor uses braces in its member initializer list. The data members id_, customer_, and items_ have default member initializers, so they're in a known state even before the constructor body runs. The total() function value-initializes sum to 0.0 with double sum{0.0}.
That's the everyday shape of modern C++ initialization. One syntax, applied uniformly, with the compiler catching narrowing bugs along the way.
A few details from that example are worth pulling out. The vector items is built from a brace list of OrderItem aggregates, and each {mouse, 2} inside is itself an aggregate brace list. The compiler resolves the outer braces against std::vector<OrderItem>'s initializer-list constructor, then resolves each inner brace pair against OrderItem's aggregate rules. There's no constructor anywhere on OrderItem or Product; the compiler walks the brace structure and matches it to member declarations.
The Order constructor uses braces in two places: in its member initializer list (id_{id}) and in the data member defaults (id_{0}). The first is direct-list-initialization of a member from a constructor parameter. The second is a default member initializer that only fires if the constructor doesn't initialize the member explicitly. In this code, the constructor always initializes id_, so the {0} default never runs at runtime. It's there as a safety net for any future constructor that might forget to set id_.
Returning a default-constructed Order from a function is as simple as return Order{};, and the value-initialization rules zero out id_, empty out customer_, and produce an empty items_ vector. That's the same code path the example uses implicitly.
The return statement return {}; is brace-init applied to the function's return type. Every member of ShippingAddress is value-initialized to an empty string. This pattern is common for factory functions that produce a "neutral" object without manually listing every field.
10 quizzes