Skip to content
C++
Library
since C++11
Intermediate

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++11

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

MemberReturn typeNotes
begin()const T*Pointer to first element
end()const T*One past the last element
size()std::size_tNumber 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:

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

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

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

Examples

Forwarding to a container member

A common pattern is an initializer-list constructor that forwards directly into an internal std::vector:

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

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

cpp
#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++17

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

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

cpp
std::vector<int> v1(10, 0);   // regular constructor: 10 zero-filled elements
std::vector<int> v2{10, 0};   // initializer-list constructor: {10, 0}, 2 elements

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

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

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

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

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

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

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

cpp
void consume(std::initializer_list<int> il);
consume({1, 2, 3}); // array destroyed at ';' β€” but consume() is done by then, safe

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

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

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

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

cpp
// 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 the initializer_list element 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

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

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

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

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

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

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

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

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

cpp
auto arr = std::array{
    std::make_unique<int>(1),
    std::make_unique<int>(2)
};
// arr is std::array<unique_ptr<int>, 2>, elements are moveable

Option 2 β€” variadic template constructor:

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

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

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

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

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

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

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

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

cpp
struct Config {
    int width  = 800;
    int height = 600;
    bool vsync = false;
};

Config c{.width = 1920, .height = 1080}; // vsync stays false

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

Featureinitializer_list<T>Variadic template
Homogeneous element typeRequiredNot required
Move-only elementsNot supportedSupported (forwarding refs)
Size known at compile timeNoYes (sizeof...(Args))
Narrowing conversion checkYesDepends on target type
Implementation complexityLowModerate
Binary size impactLowCan increase (each instantiation)

When to prefer variadic templates

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

cpp
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_list objects
  • std::min / std::max β€” standard overloads accepting std::initializer_list<T> (C++11)