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.

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.

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

This is the exact mechanism behind the famous std::vector<bool> pitfall:

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

cpp
// 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 list

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

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

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

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

cpp
// 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 == 3

Narrowing inside std::initializer_list follows the same rules β€” the element conversions are checked identically to direct brace-init:

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

The 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.

cpp
#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});   // 15

Because 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.

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

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

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:

cpp
// 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-aggregate

C++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:

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

This 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.

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

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

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

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

The from_range family solves the two biggest weaknesses of initializer_list:

  1. The backing array is const, so elements can only be copied, not moved. from_range uses move-compatible range access.
  2. initializer_list must be written as a literal at the call site. from_range accepts any range expression, including runtime-computed ones.
cpp
// 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++23

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

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));

// 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++23

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.

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:

cpp
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+bool

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

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

See Also