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.
The initializer_list constructor priority rule in detail
The priority rule bites hardest when a class has both a normal multi-parameter constructor and an initializer_list constructor whose element type accepts the arguments via implicit conversion. bool is an especially common victim because it converts to int.
#include <initializer_list>
#include <iostream>
struct Widget {
Widget(int i, bool b) { // (1) β C++11
std::cout << "int+bool ctor\n";
}
Widget(std::initializer_list<int> il) { // (2) β C++11
std::cout << "initializer_list<int> ctor, size=" << il.size() << "\n";
}
};
Widget w1(1, true); // calls (1) β parentheses, exact match
Widget w2{1, true}; // calls (2) β true converts to int 1; list is {1, 1}
Widget w3{1, 2}; // calls (2) β both convert to int
Widget w4{}; // calls default ctor if it exists; empty {} never
// matches a non-empty initializer_listThis is the exact mechanism behind the famous std::vector<bool> pitfall:
// These two look symmetrical but behave completely differently:
std::vector<int> vi{3, 5}; // {3, 5} β two elements // C++11
std::vector<bool> vb{3, 5}; // {true, true} β two elements
// (3βtrue, 5βtrue via bool conversion)
// To get n copies of a value, always use parentheses:
std::vector<int> vi2(3, 5); // {5, 5, 5} β three fives
std::vector<bool> vb2(3, 1); // {true, true, true}Best Practice. When designing a class that needs both an initializer_list constructor and element-count/fill constructors, keep them non-overlapping by type. Use a tag type or named factory instead if you cannot avoid ambiguity.
// Tag-based factory avoids the priority ambiguity: // C++11
struct Widget {
struct from_count_t {};
static constexpr from_count_t from_count{};
Widget(from_count_t, int n, bool fill_value); // explicit size+fill
Widget(std::initializer_list<bool> il); // braced-list path
};
Widget w = Widget::make_filled(5, true); // unambiguous
Widget w2{true, false, true}; // clearly liststd::initializer_list internals and lifetime
std::initializer_list<T> is not a container β it is a non-owning view (much like std::span) over a compiler-generated const T[N] array. The standard guarantees the array has the lifetime of the full-expression containing the braced-init-list when it is used to initialize a local variable or parameter.
The type has exactly three members:
// Conceptual implementation β actual representation is implementation-defined
template<class T>
class initializer_list {
public:
using value_type = T;
using reference = const T&;
using const_reference = const T&;
using size_type = std::size_t;
using iterator = const T*;
using const_iterator = const T*;
constexpr const T* begin() const noexcept; // pointer to first element
constexpr const T* end() const noexcept; // one past last element
constexpr std::size_t size() const noexcept; // number of elements
};The elements are always const β there is no mutable access and no way to move elements out of an initializer_list.
The backing array lifetime rule: The compiler creates the const T[N] array at the point of the braced-init-list and gives it the lifetime of the enclosing full-expression. If you store the initializer_list object beyond that point, the pointer it holds becomes dangling.
// Dangling reference bug β DO NOT do this:
std::initializer_list<int> danger() {
return {1, 2, 3}; // backing array is created here, destroyed when
} // the return statement's full-expression ends β UB!
void caller() {
auto il = danger();
for (int x : il) { // undefined behavior: il.begin() is dangling
std::cout << x;
}
}
// Storing in a member is equally wrong:
struct Bad {
std::initializer_list<int> saved_; // C++11 β NEVER do this
Bad(std::initializer_list<int> il) : saved_(il) {}
// saved_.begin() is dangling the moment the constructor returns
};Best Practice. Use std::initializer_list only as a function parameter type. Never store it in a member variable or return it from a function. If you need to persist the values, copy immediately into a container:
struct Good {
std::vector<int> saved_; // C++11
Good(std::initializer_list<int> il) : saved_(il) {} // copy on construct
};Narrowing conversion prevention in depth
The narrowing-conversion ban in brace-init is defined precisely: a conversion is narrowing if the destination type cannot represent every value representable in the source type, even if the specific value being converted would fit. The rule is value-independent for most cases.
// All of these are ill-formed (narrowing) regardless of actual value:
int a{3.14}; // Error: double β int (always lossy)
int b{3.0}; // Error: still double β int (even though 3.0 is exact)
unsigned u{-1}; // Error: negative value into unsigned
char c{300}; // Error: 300 may not fit in signed char
// These are NOT narrowing β the conversion is always value-preserving:
int x{42}; // OK: int literal β int
long y{42}; // OK: int β long (widening)
double d{42}; // OK: int β double (all ints representable in double)
float f{42}; // OK: int literal whose value fits in float
int z{'\n'}; // OK: char β int (widening on all platforms)
// Compile-time constant exception:
const int ci = 5;
char k{ci}; // OK: ci is a constant expression whose value fits
// Assignment initialization does NOT prevent narrowing β just warns:
int a2 = 3.14; // Compiles (warning); truncated to 3
int b2 = 3.0; // Compiles; b2 == 3Narrowing inside std::initializer_list follows the same rules β the element conversions are checked identically to direct brace-init:
std::initializer_list<int> il{1, 2, 3.0}; // Error: 3.0 is narrowing
std::vector<double> vd{1, 2, 3}; // OK: int β double is widening
std::vector<int> vi{1.5, 2.5}; // Error: narrowing in both elementsThe practical implication: brace-init acts as a free static assertion that your literal values fit the declared type. This is especially useful when initializing smaller integer types or function parameters.
Passing initializer_list to functions
A function that accepts std::initializer_list<T> by value receives a lightweight view. The backing array is constructed at the call site and lives for the duration of the full-expression (i.e. until the function returns, since the call is the full-expression). This is safe.
#include <initializer_list>
#include <string_view>
#include <unordered_map>
// Build an in-line lookup table from a braced list of pairs: // C++17
std::unordered_map<std::string_view, int>
make_enum_map(std::initializer_list<std::pair<std::string_view, int>> entries)
{
return {entries.begin(), entries.end()};
}
const auto directions = make_enum_map({
{"north", 0},
{"east", 1},
{"south", 2},
{"west", 3},
});
// Summing a variable-length braced list:
int sum(std::initializer_list<int> vals) { // C++11
int total = 0;
for (int v : vals) total += v;
return total;
}
int s = sum({1, 2, 3, 4, 5}); // 15Because initializer_list is a view, passing it by value is cheap (it is typically two pointers). You can also pass it by const reference, but there is rarely a reason to do so β the copy is free.
One subtlety: you cannot forward an initializer_list through a perfect-forwarding template because a braced-init-list has no type of its own. The template must either take std::initializer_list<T> explicitly or provide the type context some other way.
template<typename T>
void bad_forward(T&& val) { /* ... */ }
bad_forward({1, 2, 3}); // Error: cannot deduce T from braced-init-list
// Fix: accept initializer_list explicitly
template<typename T>
void ok_forward(std::initializer_list<T> il) { /* ... */ }
ok_forward({1, 2, 3}); // OK: T deduced as intAggregate 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 |
The four most impactful standard changes in practice:
C++11 introduced in-class member initializers for aggregates (lifted the earlier C++03 restriction). A struct with int x = 0; is still an aggregate in C++11... wait, actually this was a C++14 fix β see below.
C++14 lifted the ban on in-class member initializers. In C++11, a struct with int x = 0; was not an aggregate. C++14 made it one.
C++17 removed the ban on base classes, provided they are public and non-virtual. This enables aggregate initialization of simple inheritance chains:
// C++17: public base classes no longer disqualify
struct Base { int id; };
struct Named : Base { std::string label; };
Named n2{42, "Alice"}; // base subobject first, then derived members β C++17
// Equivalent to Named{ Base{42}, "Alice" } in explicit form
Named n3{{42}, "Alice"}; // also valid β explicit Base sub-aggregateC++20 tightened constructor exclusion from "user-provided" to "user-declared." A class with T() = default; written explicitly in its body is no longer an aggregate:
struct Problematic {
Problematic() = default; // user-declared! disqualifies in C++20 // C++20
int x;
};
Problematic p{1}; // OK in C++17, Error in C++20
// The fix: remove the explicit = default, rely on the implicit default ctor
struct Fixed {
int x; // implicit default ctor β still an aggregate in C++20
};
Fixed f{1}; // OK in all standards from C++11 onwardThis C++20 tightening was intentional: it prevents confusion where explicit constructors could coexist with aggregate init, and aligns with the principle that "user-declared" signals intent to control construction.
// Full evolution example showing what compiles at each standard:
struct S {
int x = 0; // default member initializer
int y;
};
S s1{.y = 5}; // C++11: Error (not aggregate β has member initializer)
// C++14: Error (aggregate, but designated init is C++20)
// C++20: OK β aggregate + designated initializer
struct WithDefault {
WithDefault() = default;
int x;
};
WithDefault w1{42}; // C++17: OK (aggregate, user-provided excluded only)
// C++20: Error (user-declared ctor disqualifies)Designated 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{ // C++20
.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.
Designated initializers have several constraints that differ from C99:
- Aggregates only: The target must be an aggregate (no user-declared constructors). They do not work on class types with constructors.
- Declaration order required: Designators must appear in the same order as the struct's member declarations. Skipping a member is fine; going backwards is not.
- No mixing: You cannot mix designated and non-designated (positional) elements in the same initializer.
- No nesting designators: You cannot use
{.a.b = 1}to designate nested struct members. Initialize the nested struct separately.
// Self-documenting configuration β the principal use case: // C++20
struct RenderOptions {
int width = 1920;
int height = 1080;
float gamma = 2.2f;
bool vsync = true;
bool fullscreen = false;
int msaa = 4;
};
// At the call site, every field's purpose is explicit:
auto opts = RenderOptions{
.width = 2560,
.height = 1440,
.msaa = 8,
// all other fields keep their defaults
};
// Nested struct β designated init applies per level:
struct Window {
RenderOptions render;
std::string title = "Untitled";
};
Window win{
.render = { .width = 2560, .height = 1440 },
.title = "My App",
};Best Practice. Use designated initializers for any aggregate with more than two or three fields β they eliminate "magic number" call sites and make it obvious when a field is intentionally left at its default.
C++23: from_range constructors and ranges as initializers
C++23 introduced the std::from_range_t tag and a family of range constructors for all standard containers. These supersede std::initializer_list in many use cases, especially when the source data is already in a range or when you need to avoid the const-array copy overhead.
#include <vector>
#include <ranges>
// C++23: from_range constructor β accepts any range
std::vector<int> v1(std::from_range, std::views::iota(1, 6));
// v1 == {1, 2, 3, 4, 5}
// Compose with view adaptors inline:
auto squares = std::views::iota(1, 6)
| std::views::transform([](int x){ return x * x; });
std::vector<int> v2(std::from_range, squares);
// v2 == {1, 4, 9, 16, 25}
// Move elements out of another container directly (no initializer_list copy):
std::vector<std::string> source{"hello", "world"};
std::vector<std::string> dest(std::from_range, std::move(source));
// Elements are moved, not copied β the initializer_list path cannot do thisThe from_range family solves the two biggest weaknesses of initializer_list:
- The backing array is
const, so elements can only be copied, not moved.from_rangeuses move-compatible range access. initializer_listmust be written as a literal at the call site.from_rangeaccepts any range expression, including runtime-computed ones.
// C++23: insert with range tag β replaces insert(il.begin(), il.end()):
std::vector<int> target{1, 2, 3};
std::vector<int> extra{4, 5, 6};
target.insert_range(target.end(), extra); // C++23 range member
// Also works on associative containers:
std::map<int, std::string> m;
std::vector<std::pair<int, std::string>> pairs{{1,"a"},{2,"b"}};
m.insert_range(pairs); // C++23Best Practice. In C++23 codebases, prefer from_range constructors when building containers from computed or moved-from data. Reserve initializer_list for true compile-time literal lists where the const-copy overhead is negligible (small types like int, double, or string_view).
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));
// Or in C++23, use from_range:
auto moved_strings = std::array{std::move(a), std::move(b)};
std::vector<std::string> v3(std::from_range, std::move(moved_strings)); // C++23Common 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.
bool-to-int conversion silently routes to the wrong constructor. Any class with an initializer_list<int> constructor will silently convert bool arguments when called with braces:
struct Widget {
Widget(int, bool); // the ctor you probably wanted
Widget(std::initializer_list<int>); // the ctor braces prefer
};
Widget w{1, true}; // calls initializer_list<int> with {1, 1} β NOT int+boolThe fix is to use Widget(1, true) with parentheses, or redesign the class to avoid the ambiguity.
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.
Designated initializers require declaration order. A common mistake when coming from C99 is assuming designators can appear in any order:
struct Point { int x; int y; int z; };
Point p{.z = 3, .x = 1}; // Error in C++: out of order (fine in C99)
Point q{.x = 1, .z = 3}; // OK: .y is value-initialized to 0See 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- Aggregate initialization β deeper coverage of the aggregate rules table across standards