Initializer Lists and Aggregate Initialization
std::initializer_list, brace initialization, aggregate init, designated initializers (C++20), narrowing rules, and constructor overload pitfalls.
Initializer Listsince C++11std::initializer_list<T> is a lightweight compiler-synthesized view over a temporary const array, produced when a braced-init-list appears where the type is deduced or expected β most commonly in constructor and function calls.
Overview
"Initializer list" is an overloaded term in C++. It refers to two distinct things:
- Brace-init syntax (
{...}) β the uniform initialization syntax introduced in C++11 that works for variables, aggregates, and constructor calls alike. std::initializer_list<T>β the library type (<initializer_list>) that receives a braced list when a constructor or function declares it as a parameter.
Both interact with constructor overload resolution in ways that regularly surprise experienced C++ engineers. Aggregate initialization β initializing arrays and POD-like structs positionally β predates C++11 but was significantly extended in C++11 (member initializers), C++17 (base class subobjects), and C++20 (designated initializers, tightened aggregate rules).
Syntax
// Value initialization β zero-initializes built-ins // C++11
int n{}; // 0
double d{}; // 0.0
std::string s{};// ""
// Direct-list-initialization // C++11
int x{42};
std::vector<int> v{1, 2, 3}; // calls initializer_list<int> ctor
// Copy-list-initialization β semantically identical for most purposes // C++11
std::vector<int> v2 = {1, 2, 3};
// Aggregate initialization β positional, predates C++11
struct Vec3 { float x, y, z; };
Vec3 p{1.0f, 2.0f, 3.0f};
// Designated initializers β named fields, undesignated members value-init // C++20
Vec3 q{.x = 1.0f, .z = 3.0f}; // .y == 0.0fNarrowing conversions are ill-formed in brace-init β the principal safety advantage over = and ():
int a = 3.14; // OK: silently truncated to 3
int b{3.14}; // Error: narrowing double β int
unsigned c{-1}; // Error: narrowing signed β unsigned
char d{256}; // Error: value may not fit in charExamples
std::initializer_list in constructors
#include <initializer_list>
#include <vector>
class Histogram {
std::vector<double> bins_;
public:
Histogram(std::initializer_list<double> weights) : bins_(weights) {}
Histogram(std::size_t n, double fill) : bins_(n, fill) {}
};
Histogram h1{0.1, 0.4, 0.3, 0.2}; // initializer_list ctor β 4 elements
Histogram h2(10, 0.0); // size+fill ctor β 10 bins at 0.0
Histogram h3{10, 0.0}; // initializer_list ctor! β {10.0, 0.0}h3 is the critical case: when a braced-init-list can match an initializer_list<T> constructor via implicit conversion, that constructor wins unconditionally over all others β even if other constructors are a nominally better type match. Parentheses are the only escape.
Constructor overload resolution rules
When braces are used, the compiler applies a two-stage rule:
- If any constructor takes
std::initializer_list<T>and the braced elements are implicitly convertible toT, that constructor wins β even over constructors with identical parameter types. - Only if no
initializer_listconstructor can be made to work does the compiler consider other constructors.
struct Widget {
Widget(int i, double d); // (1)
Widget(std::initializer_list<long double> il); // (2) β C++11
};
Widget w1(1, 2.0); // calls (1) β parentheses bypass (2)
Widget w2{1, 2.0}; // calls (2) β int and double convert to long double
Widget w3{}; // calls (1) with default args if available, or default ctor
// empty braces only match initializer_list if T is default-constructible
// and the ctor is Widget(initializer_list<T>) with no other matchAdding an initializer_list constructor to an existing class is a potentially silent breaking change for all callers using brace initialization.
Aggregate initialization across standards
An aggregate qualifies for positional brace initialization without invoking a constructor. The definition has shifted:
| Standard | Disqualifies an aggregate |
|---|---|
| C++11/14 | User-provided ctors; private/protected non-static members; virtual functions; base classes |
| C++17 | Same, but public base classes are now permitted |
| C++20 | User-declared ctors (even = default); private/protected members; virtual functions |
// C++17: public base classes no longer disqualify
struct Base { int id; };
struct Named : Base { std::string label; };
Named n{"Alice", 42}; // C++17 aggregate: Base{} first? Noβ
Named n2{42, "Alice"}; // base subobject first, then derived members β C++17
// C++20: = default is user-declared β no longer an aggregate
struct Problematic {
Problematic() = default; // user-declared! disqualified in C++20
int x;
};
Problematic p{1}; // OK in C++17, Error in C++20Designated initializers (C++20)
Designated initializers name fields explicitly. Unlike C99, C++20 requires designators appear in declaration order. Undesignated members are initialized from their default member initializer, or value-initialized if none exists.
struct ServerConfig {
std::string host = "localhost";
uint16_t port = 8080;
bool tls = false;
int timeout_ms = 30'000;
};
ServerConfig prod{
.host = "api.example.com",
.port = 443,
.tls = true,
// .timeout_ms not designated β uses default (30'000)
};
// Ordering and mixing rules:
struct Layout { int x; int y; int z; };
Layout a{.z = 3, .x = 1}; // Error: out of declaration order
Layout b{1, .y = 2}; // Error: cannot mix designated and positional
Layout c{.x = 1, .z = 3}; // OK: .y value-initialized to 0Designated initializers are the idiomatic C++20 pattern for "named parameters" and large configuration structs. They make call sites self-documenting and resilient to field reordering.
Best Practices
Prefer {} for all zero-initialization. int n; is indeterminate; int n{}; is always zero. For any type, T x{} guarantees value-initialization β zero for scalars, default construction for class types.
Use () to call specific non-list constructors. Any time you need std::vector<int>(n, val) semantics (n copies of val), use parentheses. Braces will silently invoke the two-element initializer_list constructor instead.
Treat auto and braces as version-sensitive. The deduction rules changed in C++17 (via N3922):
auto a = {1}; // std::initializer_list<int> β all standards
auto b = {1, 2}; // std::initializer_list<int> β all standards
auto c{1}; // std::initializer_list<int> in C++11/14
// int in C++17+
auto d{1, 2}; // std::initializer_list<int> in C++11/14
// ill-formed in C++17+When you actually want an initializer_list, use auto x = {expr} explicitly. When you want the element type, use auto x{single_expr} in C++17+ mode.
Avoid std::initializer_list for non-trivial types. The backing array is const T[], so elements cannot be moved out β only copied. This creates invisible double-copy overhead:
std::string a = expensive_query("a");
std::string b = expensive_query("b");
// std::move(a) and std::move(b) move-construct into the const array,
// but the vector copies from that const array β moves are not propagated.
std::vector<std::string> v{std::move(a), std::move(b)}; // two hidden copies
// Better for non-trivial types:
std::vector<std::string> v2;
v2.push_back(std::move(a));
v2.push_back(std::move(b));Common Pitfalls
The vector{n, val} / vector(n, val) trap is the most common:
std::vector<int> a{5, 0}; // {5, 0} β two elements
std::vector<int> b(5, 0); // {0,0,0,0,0} β five zerosStoring initializer_list produces dangling pointers. The backing array is a temporary whose lifetime ends with the full expression. Returning or storing an initializer_list is undefined behavior:
std::initializer_list<int> dangle() {
return {1, 2, 3}; // backing array is local β UB when caller reads it
}
// Safe: use it immediately or copy into a container
auto copy_it(std::initializer_list<int> il) {
return std::vector<int>(il); // OK: copied before il goes out of scope
}Use std::initializer_list only as a function parameter type, never as a return type or stored member.
Adding an initializer_list ctor hijacks existing brace call sites. Any class that gains a new initializer_list<T> constructor will silently reroute all {} calls that can convert to T β including calls to unrelated constructors that previously worked fine with braces.
C++20 tightened aggregate rules break C++17 code. A struct with an explicitly-defaulted constructor is an aggregate in C++17 but not in C++20. Audit structs with = default constructors before raising the standard version β previously valid brace-initialization may become an error.
See Also
autotype deduction β C++17 change to single-element brace-init deduction- Constructors and overload resolution β full overload resolution priority rules
- Move semantics β why
constbacking arrays prevent moves on extraction std::span(C++20) β non-owning view that can replaceinitializer_listin some interfaces with zero-copy overhead