C++ - std::initializer_list is a view of a temporary const array (Okade & Bhattacharyya, ACCU on Sea 2026)

7 minute read

Demystifying C++ initializer_list - Design, Behavior, and Best Practices (slides)

std::initializer_list<T> is not a container. It is a pointer and a length pointing at a compiler-generated array of const T that dies at the end of the full-expression. Almost every surprise it produces — the extra copies, the dangling reads, the constructor you did not ask for — falls out of that one sentence.

What the compiler actually builds

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <cstdio>
#include <initializer_list>
#include <type_traits>

struct A {
  int v;
  A(int v) : v(v) { printf("A(%d)\n", v); }
  ~A()            { printf("~A(%d)\n", v); }
};

void take(std::initializer_list<A> l) {
  static_assert(std::is_same_v<decltype(l.begin()), const A*>);  // <-- array of const A
  printf("size=%zu  sizeof(list)=%zu\n", l.size(), sizeof(l));
}

int main() {
  take({1, 2, 3});
  puts("back in main");
}
1
2
3
4
5
6
7
8
A(1)
A(2)
A(3)
size=3  sizeof(list)=16
~A(3)
~A(2)
~A(1)
back in main

The standard spells the transformation out as roughly const A __a[3] = {A{1}, A{2}, A{3}}; followed by a list built from __a. You can read all of it off the output: three constructions before the call, sixteen bytes of list (two pointers), and three destructions at the closing ;. Because the list is this cheap, pass it by value — that is what the standard library does, and std::move on one only copies the two pointers anyway.

const elements mean copy, never move

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <cstdio>
#include <memory>
#include <vector>

struct A {
  A(int)          { puts("A(int)"); }
  A(const A&)     { puts("A(const A&)"); }
  A(A&&) noexcept { puts("A(A&&)"); }
  ~A()            { puts("~A()"); }
};

int main() {
  puts("-- vector<A> v{1, 2, 3}");
  { std::vector<A> v{1, 2, 3}; }

  puts("-- reserve + emplace_back");
  { std::vector<A> v; v.reserve(3);
    v.emplace_back(1); v.emplace_back(2); v.emplace_back(3); }

  // std::vector<std::unique_ptr<int>> u{std::make_unique<int>(1)};
  //   error: call to implicitly-deleted copy constructor of 'std::unique_ptr<int>'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
-- vector<A> v{1, 2, 3}
A(int)
A(int)
A(int)
A(const A&)
A(const A&)
A(const A&)
~A() x6
-- reserve + emplace_back
A(int)
A(int)
A(int)
~A() x3

begin() returns const A*, so the container has no way to move out of the backing array. Construction through an initializer_list is never in place: build the array, then copy it element by element. For int or string_view that is free; for std::string it doubles the allocations, and for a move-only type it does not compile at all. The commented-out line is the unique_ptr case — the diagnostic points at construct_at with _Args = <const std::unique_ptr<int>&>.

The initializer_list constructor is considered first, alone

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <cstdio>
#include <initializer_list>
#include <string>
#include <vector>

struct Bag {
  Bag(int, double)                 { puts("Bag(int, double)"); }
  Bag(std::initializer_list<bool>) { puts("Bag(initializer_list<bool>)"); }
};

struct Bag2 {
  Bag2(int, double)                        { puts("Bag2(int, double)"); }
  Bag2(std::initializer_list<std::string>) { puts("Bag2(initializer_list<string>)"); }
};

int main() {
  const std::vector<int> a(2, 3);  // <-- two elements, both 3
  const std::vector<int> b{2, 3};  // <-- two elements, 2 and 3
  printf("a = %d %d\nb = %d %d\n", a[0], a[1], b[0], b[1]);

  const std::string s1(5, 'c');    // <-- "ccccc"
  const std::string s2{5, 'c'};    // <-- two chars: '\5' and 'c'
  printf("s1 = %s (%zu)\ns2 = %s (%zu)\n", s1.c_str(), s1.size(), s2.c_str(), s2.size());

  const Bag p(1, 1.0);             // parens: ordinary overload resolution
  // const Bag q{1, 1.0};          // error: type 'double' cannot be narrowed to 'bool'

  const Bag2 r{1, 1.0};            // <-- int/double do not convert to string, so phase 1 finds nothing
  (void)p; (void)r;
}
1
2
3
4
5
6
a = 3 3
b = 2 3
s1 = ccccc (5)
s2 = ^Ec (2)
Bag(int, double)
Bag2(int, double)

[over.match.list] runs overload resolution in two phases. Phase 1 considers only the initializer-list constructors; the rest of the constructors are looked at in phase 2, and only if phase 1 found nothing viable. So Bag q{1, 1.0} never reaches the exact-match Bag(int, double): int and double both convert to bool, initializer_list<bool> is viable, and then narrowing makes it an error. Clang says type 'double' cannot be narrowed to 'bool' in initializer list, GCC 15.1 says narrowing conversion of '1.0e+0' from 'double' to 'bool'. Bag2 differs only in the element type: nothing converts to std::string, phase 1 comes up empty, and Bag2(int, double) wins. Adding an initializer_list constructor to an existing class silently rewrites every braced call site.

The backing array dies before you finish reading it

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <cstdio>
#include <initializer_list>

struct Holder {
  Holder(std::initializer_list<int> l) : list_(l) {}  // <-- stores a view of a dying array
  void print() const {
    for (const int i : list_) printf("%d ", i);
    puts("");
  }
  std::initializer_list<int> list_;
};

int main() {
  Holder h{1, 2, 3};
  h.print();  // <-- backing array died at the end of the previous statement
}

At -O2 this prints 1 2 3 and exits 0; the stack slot simply has not been reused yet. Built with -fsanitize=address it reports stack-use-after-scope in Holder::print(). The same trap in return position is at least diagnosed: std::initializer_list<int> GetList() { return {1, 2, 3}; } gets warning: returning address of local temporary object [-Wreturn-stack-address]. Never store an initializer_list as a member and never return one — treat it strictly as a parameter type.

Where it earns its keep: compile-time tables

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <algorithm>
#include <initializer_list>
#include <string_view>

struct Recipe {
  std::string_view name;
  std::initializer_list<std::initializer_list<std::string_view>> steps;  // <-- no allocation
};

constexpr Recipe kRecipes[]{
    {"Pancakes", {{"Mix flour", "sugar", "baking powder"},
                  {"Whisk eggs", "milk"},
                  {"Cook", "flip", "serve"}}},
    {"Tea",      {{"Boil water"},
                  {"Add tea leaves", "steep"},
                  {"Add milk", "sugar", "serve"}}},
};

constexpr bool HasIngredient(std::string_view recipe, std::string_view ingredient) {
  const auto* r = std::ranges::find(kRecipes, recipe, &Recipe::name);
  return r != std::end(kRecipes) &&
         std::ranges::any_of(r->steps, [&](const auto& step) {
           return std::ranges::contains(step, ingredient);
         });
}

int main() {
  static_assert(HasIngredient("Tea", "sugar"));
  static_assert(!HasIngredient("Tea", "flour"));
  static_assert(HasIngredient("Pancakes", "flip"));
}

A std::vector<std::vector<std::string>> of constant data costs a heap allocation per row at startup and can never be constexpr. Nested initializer_list gives ragged rows with no allocation and no runtime work — the static_asserts prove the whole lookup folds at compile time. The lifetime rule still applies — this is safe only because the arrays back a constexpr object with static storage duration — so the talk keeps the pattern to types local to one translation unit.

C++26: the backing array may live in static storage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <cstdio>
#include <initializer_list>

void f(std::initializer_list<int> a, std::initializer_list<int> b) {
  printf("shared backing array: %s\n", a.begin() == b.begin() ? "yes" : "no");
}

void h() {
  int local = 0;
  std::initializer_list<int> il = {1, 2, 3};
  const long d = (const char*)il.begin() - (const char*)&local;  // <-- stack, or far away?
  printf("distance from stack local: %ld\n", d < 0 ? -d : d);
}

int main() { f({1, 2, 3}, {1, 2, 3}); h(); }
Compiler shared array distance from the stack
Apple clang 21, -std=c++23 and -std=c++2c no 4 bytes — on the stack
GCC 15.1, -std=c++23 and -std=c++26 no ~1014 bytes — static storage

P2752R3 permits (does not require) the compiler to put a constant backing array in static storage and to let separate lists share one array. GCC already places it statically; Clang still builds it on the stack, which is what makes std::vector<char> v = { #embed "2mb-image.png" }; a 2 MB stack allocation today. Two other C++26 changes from the talk: il.empty() and il.data() become members, and the std::begin/std::end overloads for initializer_list are removed (P3016R6) as redundant. Neither the members nor list sharing is implemented yet in Apple clang 21, Homebrew clang 23.1, or GCC 15.1. The std::span(initializer_list) constructor was added by P2447R6 and then removed again before C++26 shipped, because it changed the meaning of std::span<void* const>{a, 0} from size 0 to size 2; P4190R0 proposes restoring it in C++29.

Summary

Use Cost Verdict
f(std::initializer_list<string_view>) parameter one stack array, pointer copies the intended use
constexpr nested lists as a lookup table none, folds at compile time best case
vector<A> v{a, b, c} for an allocating A array + one copy per element use reserve + emplace_back
vector<unique_ptr<T>> v{...} does not compile, elements are const
member variable or return value dangling pointer undefined behaviour, never do it
  • The list is two pointers into a temporary const array. Pass it by value; std::move on it moves nothing.
  • Elements are const, so containers copy them. initializer_list construction is never in place — prefer reserve + emplace_back, set::emplace, map::emplace.
  • Braces change overload resolution: initializer-list constructors are matched in a phase of their own, before every other constructor is even considered.
  • The backing array dies at the end of the full-expression. Parameter only — not a member, not a return type.
  • For constant data it is the cheapest ragged container C++ has, and C++26 lets the compiler hoist its array into static storage.