std::initializer_list
A lightweight, read-only view over a compiler-generated const array that enables brace-initialization syntax for functions and constructors.
std::initializer_list<T>since C++11A non-owning, immutable view over a compiler-generated array of const T elements, used to give functions and constructors a uniform interface for accepting brace-enclosed element lists.
Overview
When you write {1, 2, 3} in an initializing context, the compiler synthesizes a temporary const T[N] array and wraps it in an std::initializer_list<T> object. The list is a thin proxy: it stores a pointer and a size, neither of which you can reassign, and the elements themselves are const-qualified β you cannot modify them through the list.
The header is <initializer_list>, though it is implicitly included by many standard headers.
The core interface (all members constexpr since C++14):
| Member | Return type | Notes |
|---|---|---|
begin() | const T* | Pointer to first element |
end() | const T* | One past the last element |
size() | std::size_t | Number of elements |
Because begin() and end() are provided, std::initializer_list<T> satisfies the range concept and works with range-for loops and range algorithms since C++11.
Syntax
Initializer-list constructors
An initializer-list constructor takes std::initializer_list<T> as its first parameter with no additional required parameters:
#include <initializer_list>
#include <memory>
#include <stdexcept>
class SampleBuffer {
public:
explicit SampleBuffer(std::initializer_list<float> values)
: data_(std::make_unique<float[]>(values.size()))
, size_(values.size())
{
std::copy(values.begin(), values.end(), data_.get());
}
float operator[](std::size_t i) const { return data_[i]; }
std::size_t size() const noexcept { return size_; }
private:
std::unique_ptr<float[]> data_;
std::size_t size_;
};
SampleBuffer buf{0.1f, 0.5f, 0.9f, 1.0f}; // calls initializer-list constructorFunctions accepting uniform argument lists
std::initializer_list is also useful for functions that logically accept a variable number of homogeneous arguments without resorting to variadic templates or va_args:
#include <initializer_list>
#include <numeric>
double mean(std::initializer_list<double> vals) {
if (vals.size() == 0) return 0.0;
return std::accumulate(vals.begin(), vals.end(), 0.0) / vals.size();
}
double result = mean({1.0, 2.5, 3.0, 4.5}); // 2.75Examples
Forwarding to a container member
A common pattern is an initializer-list constructor that forwards directly into an internal std::vector:
#include <initializer_list>
#include <vector>
#include <algorithm>
class IntSet {
public:
IntSet(std::initializer_list<int> il) : data_(il) {
std::sort(data_.begin(), data_.end());
data_.erase(std::unique(data_.begin(), data_.end()), data_.end());
}
bool contains(int v) const {
return std::binary_search(data_.begin(), data_.end(), v);
}
private:
std::vector<int> data_;
};
IntSet primes{7, 2, 5, 3, 2, 7}; // stored as {2, 3, 5, 7}Iterating with range-for
#include <initializer_list>
#include <cstdio>
void log_all(std::initializer_list<const char*> messages) {
for (const char* msg : messages)
std::puts(msg);
}
log_all({"connected", "authenticated", "ready"});std::min / std::max with initializer lists
The standard library overloads of std::min and std::max (and std::minmax) accept initializer_list since C++11, allowing multi-argument calls without nesting:
#include <algorithm>
int lo = std::min({a, b, c, d}); // C++11
int hi = std::max({a, b, c, d}); // C++11
auto [mn, mx] = std::minmax({a, b, c, d}); // C++11, structured bindings C++17Best Practices
Store by value, not by reference. The backing array is a temporary. Storing an std::initializer_list<T> as a member or returning it from a function leaves a dangling pointer. Copy the elements into owned storage in the constructor body.
Accept by value. The object is already a pointer+size pair (typically 16 bytes on 64-bit). Passing by const& adds no benefit and can interact oddly with lifetime extension rules.
Prefer std::initializer_list over variadic templates for homogeneous sets. When all arguments must share the same type, the initializer_list overload produces cleaner call sites and simpler implementation compared to template<typename... Args>.
Validate in the constructor. Because callers can provide any number of elements, defensive constructors should check size constraints early:
EvenPairs(std::initializer_list<int> il) {
if (il.size() % 2 != 0)
throw std::invalid_argument("requires an even number of elements");
// ...
}Common Pitfalls
Overload resolution hijacking
When a class has both a regular constructor and an initializer-list constructor, brace-initialization always prefers the initializer-list constructor β even when the narrowing conversion would otherwise make the regular constructor the better match. This is the source of the classic std::vector surprise:
std::vector<int> v1(10, 0); // regular constructor: 10 zero-filled elements
std::vector<int> v2{10, 0}; // initializer-list constructor: {10, 0}, 2 elementsThe compiler will force a narrowing conversion to reach an initializer-list constructor before it considers a non-list constructor that is a perfect match. Only if no conversion to the element type exists at all does overload resolution fall through to the regular constructors.
auto deduction rules differ by standard
C++11 and C++14 treat both copy-list-initialization and direct-list-initialization with auto the same way β everything deduces to std::initializer_list<T>:
// C++11 / C++14
auto a = {1}; // std::initializer_list<int>
auto b{1}; // std::initializer_list<int> β surprising
auto c{1, 2}; // std::initializer_list<int>C++17 tightened the rules: direct-list-initialization (auto x{...}) with a single element now deduces the element type, and with multiple elements is ill-formed:
// C++17 and later
auto a = {1}; // still std::initializer_list<int>
auto b{1}; // int β changed
auto c{1, 2}; // error: cannot deduce element typeThis means code that relied on auto x{v} yielding std::initializer_list is silently broken when compiled as C++17. Audit any such declarations when upgrading.
Lifetime of the backing array
An std::initializer_list does not own the array behind it. The array's lifetime follows different rules depending on context:
- When used to initialize a named
std::initializer_list<T>variable, the backing array lives as long as that variable. - When used as a constructor argument or function argument inline (not bound to a named list variable), the array is a temporary that is destroyed at the end of the full expression.
Saving the list itself for later use is therefore undefined behavior:
struct Bad {
std::initializer_list<int> saved;
Bad(std::initializer_list<int> il) : saved(il) {} // dangling after constructor returns
};Always extract and own the elements before the initializer-list goes out of scope.
All elements must share a common type
The compiler must be able to deduce a single T from all elements in the braced list. Mixed arithmetic types that require narrowing conversions will be rejected, and mixed unrelated types produce a hard error:
auto x = {1, 2.0}; // error: no common type between int and double
auto y = {1, 2}; // ok: std::initializer_list<int>
auto z = {1.0, 2.0}; // ok: std::initializer_list<double>Lifetime of the Backing Array in Depth
The single most dangerous property of std::initializer_list is that it is a non-owning view. The compiler synthesizes a hidden const T[N] array and passes a pointer+size pair as the initializer_list object. Whoever holds that object does not own the array; it is owned by the scope in which the braced list was written.
The standard (dcl.init.list Β§9.4.5) specifies two distinct cases:
Case 1 β bound to a named variable. When the list directly initializes a named std::initializer_list<T> local variable, the backing array's lifetime is extended to match that variable:
std::initializer_list<int> named = {10, 20, 30}; // array lives here
for (int v : named) { /* safe */ }
// array destroyed here, alongside 'named'Case 2 β passed as a function or constructor argument. When the braced list appears as an argument (not bound to a named variable), the backing array is a full-expression temporary. It is destroyed at the semicolon that ends the statement, before the called function can return:
void consume(std::initializer_list<int> il);
consume({1, 2, 3}); // array destroyed at ';' β but consume() is done by then, safeThis is safe because the called function receives the list and finishes before the temporary is torn down. The danger arises when the list β or a pointer derived from it β escapes the call.
Storing initializer_list as a member is always UB
The most common way to trip over this is storing the list inside a constructor's member initializer list:
// UNDEFINED BEHAVIOR β do not do this
struct Histogram {
std::initializer_list<double> buckets_; // stores the view, not the data
Histogram(std::initializer_list<double> il)
: buckets_(il) // copies the pointer; array is still temporary
{}
void print() const {
for (double b : buckets_) // reads freed memory β UB
printf("%f\n", b);
}
};
Histogram h{0.1, 0.5, 1.0}; // array from {0.1, 0.5, 1.0} dies at ';'
h.print(); // UB: buckets_ points to dead storageThe compiler copies two words (pointer + size) into the member. It does not extend the backing array's lifetime. By the time h.print() runs, the compiler is free to have reused that stack space.
The correct fix: copy the elements into owned storage in the constructor body.
struct Histogram {
std::vector<double> buckets_; // owns its data
Histogram(std::initializer_list<double> il)
: buckets_(il) // vector copies elements β safe
{}
};Returning initializer_list from a function
The same trap applies to return values:
// UNDEFINED BEHAVIOR
auto make_list() -> std::initializer_list<int> {
return {1, 2, 3}; // backing array dies with the return statement
}
for (int v : make_list()) { /* reads freed memory */ }Return std::vector<int> or std::array<int, N> instead.
Constructor Hijacking and the "Most Vexing Brace"
Scott Meyers coined "most vexing parse" for the T x() ambiguity; the brace-initialization analog deserves equal notoriety. The language rule is:
When overload resolution considers a set of constructors for brace-initialization, any constructor whose first parameter is
std::initializer_list<T>(with no other required parameters) is strongly preferred. The compiler will accept implicit narrowing conversions and will manufacture intermediate conversions to satisfy theinitializer_listelement type, rather than fall through to other constructors.
Only if the element type conversion is impossible (not merely ill-formed due to narrowing) does overload resolution continue to consider non-list constructors.
The std::vector canonical example
#include <vector>
std::vector<int> a(3, 5); // (count, value): {5, 5, 5} β three fives
std::vector<int> b{3, 5}; // initializer_list<int>{3, 5}: {3, 5} β two elementsa calls vector(size_type count, const int& value). b calls vector(initializer_list<int>). The difference is parentheses vs. braces β nothing else β and the results are completely different containers.
Why narrowing doesn't save you
You might expect that std::vector<double>{3, 5} would trigger a narrowing error and fall back to the (count, value) constructor. It does not:
std::vector<double> c{3, 5}; // initializer_list<double>{3.0, 5.0} β two elements
// intβdouble is widening, so no narrowing error
std::vector<double> d(3, 5); // (count, value): {5.0, 5.0, 5.0}The list constructor wins unless the conversion is truly impossible:
struct Widget {
Widget(int, bool);
Widget(int, double);
Widget(std::initializer_list<std::string>);
};
Widget w1{1, true}; // calls Widget(int, bool) β int and bool cannot convert to string
Widget w2{1, 5.0}; // calls Widget(int, double) β same reason
Widget w3{"a", "b"}; // calls Widget(initializer_list<string>)Opting out of hijacking
When you need to call a non-list constructor but the class also has an initializer_list constructor, use parentheses:
std::vector<int> sized(10); // 10 default-constructed elements
std::vector<int> listed{10}; // one element: {10}When writing templates that forward constructor arguments, this distinction becomes critical:
template<typename T, typename... Args>
T make(Args&&... args) {
return T(std::forward<Args>(args)...); // parentheses β safer in generic code
}Using braces inside such a template would silently route every call through the initializer_list constructor if one exists.
Immutability and Move-Only Types
std::initializer_list<T> provides only const T* iterators. Every element is const-qualified for the entire lifetime of the list. This has two practical consequences.
You cannot move out of an initializer_list
For copyable types this is invisible. For move-only types β std::unique_ptr, std::thread, custom RAII handles β it is a hard wall:
#include <memory>
#include <vector>
// This does NOT compile
std::vector<std::unique_ptr<int>> ptrs{
std::make_unique<int>(1),
std::make_unique<int>(2)
};
// error: use of deleted function 'unique_ptr(const unique_ptr&)'
// The initializer_list constructor calls copy, not moveThe brace expression creates temporary unique_ptr objects, places them into the const T[N] backing array as const unique_ptr<int>, and then the vector constructor attempts to copy (not move) them β which is deleted.
Alternatives for move-only element types
Option 1 β std::array with std::move:
auto arr = std::array{
std::make_unique<int>(1),
std::make_unique<int>(2)
};
// arr is std::array<unique_ptr<int>, 2>, elements are moveableOption 2 β variadic template constructor:
template<typename T>
class OwningList {
std::vector<T> data_;
public:
template<typename... Args>
OwningList(Args&&... args) {
data_.reserve(sizeof...(args));
(data_.push_back(std::forward<Args>(args)), ...); // C++17 fold
}
};
OwningList<std::unique_ptr<int>> ol{
std::make_unique<int>(1),
std::make_unique<int>(2)
};The variadic template receives forwarding references, preserving move-eligibility. The elements are moved (not copied) into the container.
Option 3 β explicit emplace_back calls: When the number of elements is known at the call site but runtime flexibility is not needed, sequential emplace_back or push_back(std::move(...)) is the most direct approach and avoids any temporary array overhead.
Narrowing Conversions and Brace-Init
Brace-initialization uniformly forbids narrowing conversions. A narrowing conversion is one where the destination type cannot represent all values of the source type: double to int, int to char, long long to float, etc.
When a braced list feeds an initializer_list, the narrowing prohibition applies to each element individually:
std::initializer_list<int> il{1, 2, 3}; // ok
std::initializer_list<int> il2{1, 2, 3.14f}; // error: 3.14f narrows to int
std::initializer_list<double> il3{1, 2, 3.14f}; // ok: intβdouble is wideningThis is a compile-time error, not a runtime trap β which is one of brace-initialization's genuine advantages over parenthesis-initialization. The same rule applies when the initializer_list element type is deduced:
auto vals = {1, 2, 3.0}; // error: no common type; 3.0 is double, others are int
auto vals = {1, 2, 3}; // ok: initializer_list<int>Note that integer constant expressions that fit in the destination type are permitted even when the source is a wider type:
std::initializer_list<char> chars{65, 66, 67}; // ok: 65 fits in char (constant expr)
int n = 300;
std::initializer_list<char> bad{n}; // error: not a constant expressionThis is a subtle but useful rule: it allows character table literals without casts while still rejecting runtime truncation.
Aggregates, Designated Initializers, and C++20 Changes
Before C++20, aggregate initialization with braces and initializer_list constructors occupied adjacent but distinct parts of the overload resolution space. C++20 changed the definition of aggregate: a class with any user-declared constructor β including an initializer_list constructor β is no longer an aggregate.
This matters when upgrading code that relied on aggregate brace-initialization for classes that gained constructors:
// C++17: aggregate (no user-declared constructors)
struct Point { int x; int y; };
Point p{3, 4}; // aggregate init: x=3, y=4
// C++20: designated initializers offer named-field init for aggregates
Point q{.x = 3, .y = 4}; // C++20 designated initializer β order must match declarationDesignated initializers are an ergonomic alternative to initializer_list for structs with named fields. They document intent at the call site and allow skipping fields (which take their default values):
struct Config {
int width = 800;
int height = 600;
bool vsync = false;
};
Config c{.width = 1920, .height = 1080}; // vsync stays falseDesignated initializers are not compatible with initializer_list β they only work for aggregates. For non-aggregate types with many optional fields, the builder pattern or named parameter structs are preferable to either technique.
Variadic Templates as a Flexible Alternative
For cases where std::initializer_list<T> is too restrictive, a variadic template constructor provides a superset of capabilities at the cost of more complex implementation.
| Feature | initializer_list<T> | Variadic template |
|---|---|---|
| Homogeneous element type | Required | Not required |
| Move-only elements | Not supported | Supported (forwarding refs) |
| Size known at compile time | No | Yes (sizeof...(Args)) |
| Narrowing conversion check | Yes | Depends on target type |
| Implementation complexity | Low | Moderate |
| Binary size impact | Low | Can increase (each instantiation) |
When to prefer variadic templates
// initializer_list: homogeneous, copyable only
class Palette {
public:
Palette(std::initializer_list<Color> colors) : colors_(colors) {}
private:
std::vector<Color> colors_;
};
// Variadic: heterogeneous, move-friendly, compile-time count
template<typename... Colors>
class TypedPalette {
static_assert((std::is_same_v<Colors, Color> && ...), "all must be Color");
public:
TypedPalette(Colors&&... c)
: colors_{std::forward<Colors>(c)...}
{}
private:
std::array<Color, sizeof...(Colors)> colors_; // stack-allocated, fixed size
};The variadic approach also enables heterogeneous tuples of factory arguments, whereas initializer_list locks every element to the same type. For the common case of a homogeneous, copyable type where the count is not a compile-time concern, initializer_list remains the simpler and idiomatic choice.
Combining both: overload set design
A well-designed container can offer both interfaces to cover all callers:
class Pipeline {
public:
// Homogeneous, runtime-count path
Pipeline(std::initializer_list<Stage> stages) : stages_(stages) {}
// Heterogeneous or move-only path
template<typename... Stages,
typename = std::enable_if_t<(std::is_constructible_v<Stage, Stages> && ...)>>
explicit Pipeline(Stages&&... stages) {
stages_.reserve(sizeof...(stages));
(stages_.emplace_back(std::forward<Stages>(stages)), ...);
}
private:
std::vector<Stage> stages_;
};One caveat: when both overloads exist and Stage is constructible from int (or another common type), brace-initialization will strongly prefer the initializer_list overload. Callers who need the variadic path must use parentheses.
See Also
std::vector,std::array, and other sequence containers β all provide initializer-list constructors since C++11- Uniform initialization and brace-initialization β the language mechanism that produces
std::initializer_listobjects std::min/std::maxβ standard overloads acceptingstd::initializer_list<T>(C++11)