Skip to content
C++
Language
since C++11
Intermediate

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++11

std::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:

  1. Brace-init syntax ({...}) β€” the uniform initialization syntax introduced in C++11 that works for variables, aggregates, and constructor calls alike.
  2. 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

cpp
// 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.0f

Narrowing conversions are ill-formed in brace-init β€” the principal safety advantage over = and ():

cpp
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 char

Examples

std::initializer_list in constructors

cpp
#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:

  1. If any constructor takes std::initializer_list<T> and the braced elements are implicitly convertible to T, that constructor wins β€” even over constructors with identical parameter types.
  2. Only if no initializer_list constructor can be made to work does the compiler consider other constructors.
cpp
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 match

Adding 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:

StandardDisqualifies an aggregate
C++11/14User-provided ctors; private/protected non-static members; virtual functions; base classes
C++17Same, but public base classes are now permitted
C++20User-declared ctors (even = default); private/protected members; virtual functions
cpp
// 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++20

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.

cpp
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 0

Designated 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):

cpp
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:

cpp
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:

cpp
std::vector<int> a{5, 0};   // {5, 0}      β€” two elements
std::vector<int> b(5, 0);   // {0,0,0,0,0} β€” five zeros

Storing 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:

cpp
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