Answer to the question
History: Introduced in C++11, std::initializer_list was designed to bridge the gap between C-style aggregate initialization and modern C++ container constructors. It is implemented as a lightweight aggregate containing two pointers (or a pointer and a size) referencing a compiler-generated array of const elements. This design prioritizes zero-overhead for passing literal lists to functions like std::vector's constructor.
The problem: The underlying array is a temporary object whose lifetime is tied to the full-expression in which the std::initializer_list is created. When a class stores the std::initializer_list itself rather than copying its contents, the member merely retains pointers to deallocated stack memory. Any subsequent access creates undefined behavior, manifesting as garbage data or crashes that are difficult to reproduce.
The solution: Never store std::initializer_list as a class member; instead, eagerly copy the elements into an owning container like std::vector or std::array. If zero-copy is essential, use std::span (C++20) with externally managed storage, or accept the range via iterators. This ensures the data outlives the constructor call and remains valid for the object's lifetime.
class Bad { std::initializer_list<int> list_; public: Bad(std::initializer_list<int> list) : list_(list) {} // DANGER int sum() const { int s = 0; for (int i : list_) s += i; // UB: dangling pointers return s; } }; class Good { std::vector<int> vec_; public: Good(std::initializer_list<int> list) : vec_(list) {} // Safe: copies data int sum() const { return std::accumulate(vec_.begin(), vec_.end(), 0); } };
Situation from life
We encountered this in a high-frequency trading configuration loader where a MarketConfig class accepted default price tiers via an initializer list in its constructor to support syntax like MarketConfig cfg{{1.0, 2.0, 3.0}}. A junior developer stored the std::initializer_list<double> directly as a member to "avoid heap allocation," intending to iterate over tiers later during packet processing.
One proposed solution was to store a const std::vector<double>& passed by the caller. This would eliminate copies if the caller maintained the vector's lifetime, but it violated encapsulation and forced callers to manage persistent storage for temporary lists. Another option involved using std::array<double, N> as a template parameter, but this required knowing the tier count at compile-time, which was impossible as configurations were loaded dynamically from JSON overlays.
The chosen approach was to copy the initializer list into a std::vector<double> member immediately upon construction. While this incurred a single allocation and copy of the tier data, it guaranteed safety and immutability of the configuration state. After the change, sporadic crashes in production simulation environments disappeared, and Valgrind no longer reported "use of uninitialised value of size 8" during tier aggregation.
What candidates often miss
Why does binding an std::initializer_list to a const reference not prevent the underlying array from dangling when stored in a member?
The standard specifies that the backing array of an std::initializer_list is a temporary whose lifetime is extended only by the initializer_list object itself being bound to a reference in the current scope. When you pass an std::initializer_list by value to a constructor, the temporary array lives until the constructor returns; copying the list into a member merely duplicates the pointer pair. Consequently, the member points to reclaimed stack space once the construction expression ends, regardless of how the original argument was bound.
How does the "initializer-list constructor wins" rule interact with std::vector's constructor overload set, and why does std::vector<int>(5, 10) differ from std::vector<int>{5, 10}?
During overload resolution for direct-list-initialization (braces), C++ prioritizes constructors taking std::initializer_list over other constructors if the argument list can be implicitly converted to the list's element type. For std::vector<int>, {5, 10} selects the initializer_list<int> constructor, creating a vector of two elements (5 and 10). In contrast, parentheses (5, 10) select the size_t, const int& constructor, creating a vector of five elements initialized to 10. Candidates often miss that this priority applies even when the non-list constructor would be a better match under normal overload resolution rules.
Can constexpr functions return std::initializer_list safely, and if so, under what storage duration constraints?
While constexpr functions can return std::initializer_list, the underlying array still possesses automatic storage duration if the function is invoked at runtime. If the function is invoked in a constant expression context, the array is typically stored in static read-only memory, making it safe. However, returning an std::initializer_list from a constexpr function called with runtime arguments results in dangling pointers once the function scope exits, exactly as with non-constexpr functions. Candidates frequently conflate constexpr with "static storage" and mistakenly assume the returned list is always valid indefinitely.