C++ Brace Initialization: Narrowing Checks, Most Vexing Parse and initializer_list
Key takeaways
Brace ({}) initialization works for scalars, aggregates, classes and containers, rejects narrowing conversions and sidesteps the Most Vexing Parse, but it prefers initializer_list constructors, which is why vector<int>{10} has one element and vector<int>(10) has ten.
Why C++11 added another way to initialize
C++03 had several initialization syntaxes, each allowed in some places and not others. int x = 10; and int x(10); worked for scalars, int arr[] = {1, 2, 3}; worked for arrays and plain structs, and a std::vector couldn’t be initialized with values at all; you created it empty and called push_back. Member arrays couldn’t be initialized in a constructor’s initializer list.
C++11’s list initialization makes braces work in every context: variables, new expressions, return statements, function arguments, member initializers and default member initializers.
int x{10};
int arr[]{1, 2, 3};
std::vector<int> vec{1, 2, 3};
std::map<std::string, int> ages{{"Alice", 30}, {"Bob", 25}};
struct Point { int x, y; };
Point p{10, 20};
Point origin() { return {0, 0}; } // braces in a return statement
int* ptr = new int[3]{1, 2, 3}; // braces with new[]
“Uniform” is the marketing name. The syntax is uniform; what it does still depends on the type. For an aggregate it initializes members in order, for a class it calls a constructor, and for a class with an initializer_list constructor it usually calls that one. Those differences are where the pitfalls live.
Narrowing conversions become errors
The most concrete safety benefit is that braces reject narrowing: conversions that can lose information.
void f(double d, long long big, int large, int neg) {
int a{d}; // double -> int
int b{big}; // long long -> int
float c{large}; // int -> float: not every int is representable
unsigned u{neg}; // int -> unsigned
char ch{1000}; // constant that doesn't fit in char
int ok{3}; // fine
float fl{3}; // fine: constant 3 is exactly representable as float
}
The standard makes all five of the first lines ill-formed. What you see depends on the compiler, though, and this surprises people who read that “braces catch narrowing”. GCC 10 compiles the non-constant cases with only a warning:
warning: narrowing conversion of 'd' from 'double' to 'int' [-Wnarrowing]
error: narrowing conversion of '1000' from 'int' to 'char' [-Wnarrowing]
Only the constant that doesn’t fit is a hard error. Clang and MSVC reject all of them. If your code has to build on GCC, add -Werror=narrowing (or -pedantic-errors) so the protection you’re relying on is real; with either flag, GCC turns the warnings into error: narrowing conversion of 'd' ....
Note the rule for constants. float fl{3} and char c{65} compile because the compiler can see the value fits exactly. The check is about the value when it’s known, and about the types when it isn’t.
When you really do want the conversion, say so with a cast: int a{static_cast<int>(d)};. That turns an accidental truncation into a visible, searchable decision.
Avoiding the Most Vexing Parse
C++ inherited a grammar rule from C: anything that can be parsed as a declaration is a declaration. That turns some innocent-looking object definitions into function declarations:
struct Widget {};
Widget w(); // declares a function w() returning Widget
The error doesn’t appear here. It appears on the next line that uses w as an object, where the message talks about a function and makes no sense at first glance. Clang flags the declaration itself with -Wvexing-parse (“empty parentheses interpreted as a function declaration”); GCC gained a similar warning in version 11, so GCC 10 is silent.
The worse version involves parameters that look like parenthesized declarators:
std::ifstream file("data.txt");
std::vector<int> data(std::istream_iterator<int>(file), std::istream_iterator<int>());
// declares a function `data` taking (istream_iterator<int> file, istream_iterator<int>(*)())
Braces cannot start a function parameter list, so they end the ambiguity:
Widget w{};
std::vector<int> data{std::istream_iterator<int>(file), std::istream_iterator<int>()};
That second line works because std::istream_iterator<int> doesn’t convert to int, so the initializer_list<int> constructor isn’t viable and the iterator-range constructor is used, which leads to the main caveat.
The initializer_list preference
When you use braces and the class has a constructor taking std::initializer_list<T>, overload resolution first considers only the initializer_list constructors. Other constructors are tried only if none of those is viable. The consequences:
std::vector<int> v1(10); // ten elements, all 0
std::vector<int> v2{10}; // one element: 10
std::vector<int> v3(10, 5); // ten 5s
std::vector<int> v4{10, 5}; // two elements: 10, 5
The same rule, applied to your own classes:
struct N {
N(int) { std::cout << "int\n"; }
N(std::initializer_list<int>) { std::cout << "ilist\n"; }
};
N n1(10); // int
N n2{10}; // ilist
And the rule’s fallback makes it depend on the element type:
std::vector<std::string> vs{10}; // ten empty strings!
10 can’t become a std::string, so the initializer_list constructor isn’t viable, and the size constructor is chosen. Change std::string to int in a refactor and the meaning flips from “ten elements” to “one element” without any diagnostic.
Empty braces are special-cased: T{} value-initializes, calling the default constructor even when an initializer_list constructor exists. Getting an empty list explicitly takes different syntax, and the obvious guess is wrong:
struct M {
M() { std::cout << "default\n"; }
M(std::initializer_list<int> l){ std::cout << "ilist " << l.size() << '\n'; }
};
M a; // default
M b{}; // default
M c{{}}; // ilist 1 (the inner {} is a value-initialized int)
M d({}); // ilist 0 (an empty initializer_list)
This preference is also why the standard library’s factory functions use parentheses internally. std::make_unique<std::vector<int>>(3, 7) gives three 7s, not the elements 3 and 7. The downside is that before C++20, std::make_unique<Point>(1, 2) fails for an aggregate Point, because Point(1, 2) isn’t valid until C++20 added parenthesized aggregate initialization.
Adding an initializer_list constructor to an existing class is the change I’ve seen cause the most trouble here. Every existing Widget w{a, b} call that used to reach a two-argument constructor silently switches to the new overload if the argument types happen to convert, and nothing fails to compile. If a class already has brace-initialized call sites, audit them before adding one.
auto with braces
The deduction rules for braces have changed once and are still irregular:
auto x1{1}; // C++17: int
auto x2 = {1, 2, 3}; // std::initializer_list<int>
auto x3 = {1}; // std::initializer_list<int>
// auto x4{1, 2}; // error: direct-list-init with auto needs exactly one element
The copy form auto x = {...} always produces an initializer_list, which is almost never what you want to store: the list refers to a temporary array whose lifetime rules are easy to get wrong. When the type matters, spell it: std::vector<int> y = {1, 2, 3};.
= {} versus {}: explicit constructors
Braces come in two forms that behave differently when constructors are explicit. Widget w{1}; is direct-list-initialization and may call any constructor. Widget w = {1};, return {1}; and passing {1} to a function parameter are copy-list-initialization, which considers explicit constructors but makes the program ill-formed if one is chosen:
struct Port {
explicit Port(int n) : n_(n) {}
int n_;
};
Port a{8080}; // OK
Port b = {8080}; // error: converting to 'Port' from initializer list would use explicit constructor
Port make() { return {8080}; } // same error
This is usually the behavior you want, since explicit exists to stop silent conversions, but it surprises people who think of = {} as a cosmetic variant. The error text is GCC’s; Clang says chosen constructor is explicit in copy-initialization. Note the wording “would use”: the explicit constructor still takes part in overload resolution, so it can make a call ambiguous or fail even when a non-explicit overload exists.
Aggregates that stop being aggregates
Point p{1, 2}; initializes members directly only while Point is an aggregate. Adding a constructor, a private data member, or a virtual function turns the same braces into a constructor call, and Point p{1, 2} then fails with no matching function for call to 'Point::Point(<brace-enclosed initializer list>)'. C++20 tightened this: a class with any user-declared constructor, even Point() = default;, is no longer an aggregate. Code that compiled under C++17 with a defaulted constructor and brace-initialized members breaks when the project switches to -std=c++20, and the usual fix is simply to delete the defaulted constructor, which was not doing anything.
Where braces fit well
- Default member initializers:
int port_{8080};andstd::vector<std::string> options_{"keepalive=true"};are clear and catch narrowing in the defaults. - Aggregates and return values:
return {true, "Success", 200};for a result struct avoids repeating the type. Missing trailing members are value-initialized, soPoint p{1};setsyto 0. C++20 designated initializers make this safer with names:Point p{.x = 1, .y = 2};. - Value initialization:
T t{};zeroes scalars and aggregates and calls the default constructor for classes, with no vexing parse. See value initialization. - Anywhere you want a narrowing check, such as converting between integer types of different widths.
Parentheses remain the right choice when you’re calling a specific constructor that would compete with an initializer_list overload: container sizes (std::vector<int>(n)), std::string(10, 'x'), and similar “count and value” constructors.
A reasonable rule of thumb: braces for values, parentheses for “constructor arguments that aren’t elements”. It isn’t perfectly uniform, but it avoids every trap above.
The narrowing gap on GCC is the one I’d watch for most. A codebase adopts braces everywhere believing truncation is now impossible, builds only with GCC, and the -Wnarrowing warning scrolls past in a long build log while a double is quietly truncated. It costs one build flag to close, so I add -Werror=narrowing the same day a brace-everywhere style is adopted.