Skip to content

std::initializer_list: The Lightweight Sequence Behind Braces

What initializer_list is: A Read-only View Generated by the Compiler for {...}

std::initializer_list is the standard library type introduced in C++11 to support "braced list initialization". When you write vector<int>{1, 2, 3} or f({1, 2, 3}), the compiler constructs a std::initializer_list<int> behind the scenes to represent the sequence {1, 2, 3}. It is an extremely lightweight object—essentially just a pointer plus a length—making it a "view over data that it does not own," similar to span.

cpp
std::initializer_list<int> il = {1, 2, 3};   // 编译器构造,指向底层 const int[3]
il.size();        // 3
il.begin();       // 指向首元素
il.end();         // 尾后

There are three key properties: it does not own the elements (which reside in a underlying const array generated by the compiler), the elements are const (read-only), and copying it is a shallow copy (it only copies the pointer and length, not the elements). These three properties determine its entire behavior, and they also hide its most notorious pitfall.

How Lightweight It Is: Shallow Copy, Read-Only Elements

Copying an initializer_list is shallow—copying an initializer_list means copying its internal pointer (and length), leaving the underlying const array untouched. Therefore, passing an initializer_list by value is almost zero-cost, similar to passing a raw pointer.

cpp
void f(std::initializer_list<int> il);   // 按值传,其实是浅拷贝(指针 + size)
f({1, 2, 3, 4, 5});   // 不拷贝 5 个 int,只传一个视图

However, we must remember that "elements are const": the elements inside an initializer_list are const T, so you cannot get non-const access. This seems harmless, but it creates a major pitfall when combined with move semantics—we will cover this in the next section.

The Move Trap: Elements inside {...} Can Only Be Copied into Containers

This is the classic pitfall of initializer_list. You want to put several objects into a vector, so you conveniently write vector<T>{a, b, c}, thinking that modern C++ will efficiently move them—only to find that they are copied in.

The root cause lies in "elements are const": the elements of an initializer_list are const T, while move constructors require T&& (non-const). When a vector is constructed from an initializer_list, it must copy each const element into its own storage. You cannot move from a const object, you can only copy. Even if you write std::move inside the braces, it only allows the object to "move into the initializer_list" (because the constructor accepts an rvalue at that step), but once inside the initializer_list, it becomes const. When moving it into the vector afterwards, only copying is possible.

Let's measure this to see exactly how many copies occur. We will use a type that can count the number of copies and moves:

Expand (49 lines)Collapse
cpp
#include <iostream>
#include <vector>
#include <string>
#include <utility>

struct Counted {
    std::string s;
    inline static int copies = 0;
    inline static int moves = 0;
    Counted(std::string x) : s(std::move(x)) {}
    Counted(const Counted& o) : s(o.s) { ++copies; }
    Counted(Counted&& o) noexcept : s(std::move(o.s)) { ++moves; }
};

int main()
{
    // 场景 1:左值构造 initializer_list → vector
    {
        Counted a{"a"}, b{"b"}, c{"c"};
        Counted::copies = 0;
        Counted::moves = 0;
        std::vector<Counted> v{a, b, c};
        std::cout << "vector{a,b,c}        : copies=" << Counted::copies
                  << " moves=" << Counted::moves << "\n";
    }
    // 场景 2:move 进 initializer_list → vector(陷阱:进 vector 那步还是拷贝)
    {
        Counted a{"a"}, b{"b"}, c{"c"};
        Counted::copies = 0;
        Counted::moves = 0;
        std::vector<Counted> v{std::move(a), std::move(b), std::move(c)};
        std::cout << "vector{move(a),...}  : copies=" << Counted::copies
                  << " moves=" << Counted::moves << "\n";
    }
    // 场景 3:不用 initializer_list,push_back(move) → 全移动
    {
        Counted a{"a"}, b{"b"}, c{"c"};
        Counted::copies = 0;
        Counted::moves = 0;
        std::vector<Counted> v;
        v.reserve(3);
        v.push_back(std::move(a));
        v.push_back(std::move(b));
        v.push_back(std::move(c));
        std::cout << "push_back(move)      : copies=" << Counted::copies
                  << " moves=" << Counted::moves << "\n";
    }
    return 0;
}
bash
g++ -std=c++17 -O2 -o /tmp/init_list_test /tmp/init_list_test.cpp && /tmp/init_list_test
text
vector{a,b,c}        : copies=6 moves=0
vector{move(a),...}  : copies=3 moves=3
push_back(move)      : copies=0 moves=3

Let's compare these three scenarios. The first case, vector{a, b, c} (lvalues), results in six copies and zero moves—three copies to construct the initializer_list elements, followed by three more copies into the vector. The second case, vector{std::move(a), ...}, results in three copies and three moves—std::move allows the objects to be moved into the initializer_list (saving three copies), but the step into the vector still requires three copies because const elements cannot be moved. The third case, push_back(std::move(...)), results in zero copies and three moves—it bypasses the initializer_list entirely and moves directly into the vector, achieving zero-copy.

So, keep this performance pitfall in mind: when inserting multiple objects into a container, vector{move(a), ...} still copies them into the vector; only push_back(move) achieves zero-copy. When T is a heavy type (like a large string or vector), this difference represents tangible copying overhead.

Brace Preference: Why {...} Always Loves Matching initializer_list Constructors

initializer_list has a specific "overload resolution preference": as long as a class has a constructor accepting initializer_list, brace initialization will prioritize it, even if other constructors seem like a "better match." The classic failure case involves vector<int>:

cpp
cpp
std::vector<int> v1(10, 0);    // 圆括号:10 个 0(count + value 构造)
std::vector<int> v2{10, 0};    // 花括号:两个元素 10 和 0(initializer_list 构造!)

v1 is ten zeros, while v2 contains two elements, {10, 0}. The same intent, yet parentheses and braces yield completely different results simply because braces prioritize matching the initializer_list<int> constructor. This isn't a bug; it's the rule: brace initialization prefers initializer_list constructors when available. Therefore, when constructing containers, don't mix up (count, value) and {a, b}; use different brackets for different intents.

Wrapping Up

std::initializer_list is the lightweight view behind brace list initialization: it owns nothing, its elements are const, and copies are shallow. It allows syntax like {1, 2, 3} to be passed elegantly to functions and containers, but "const elements" introduce two caveats to remember. First, the move trap (a vector{...} into a container forces a copy, so for heavy types, use push_back(move)). Second, brace priority (when an initializer_list constructor exists, {} will eagerly match it). In the next article, we move away from initialization to examine the memory layout of types themselves: object size and trivial types.

Want to run it and see the results yourself? Check out the online example below (you can run it and view the assembly):

Compiler Explorer

std::initializer_list: Read-Only Views and the Move Trap

Read-only views generated by braces, the copy trap where const elements cannot be moved, and brace overload priority

code/examples/vol3/11_initializer_lists.cpp

References

v0.7.0-9-g940ec1b · 940ec1b · 2026-07-05