C++ initializer_list Constructors: Why Braces Pick Them, and How That Breaks Overloads
Key takeaways
A constructor taking std::initializer_list lets users write {1, 2, 3}, but it also changes what every other brace initialization of your class means. Overload resolution tries initializer_list constructors first, even when another constructor is an exact match, which is why std::vector<int>{3, 7} holds two elements rather than three sevens.
What an initializer_list constructor is
A constructor whose first parameter is std::initializer_list<T> (with no other parameters, or only ones with defaults) lets users of a class write a list of values in braces:
#include <initializer_list>
#include <vector>
class IntBag {
std::vector<int> data_;
public:
IntBag(std::initializer_list<int> values) : data_(values) {}
std::size_t size() const { return data_.size(); }
};
IntBag bag = {1, 2, 3, 4, 5};
IntBag bag2{6, 7};
For {1, 2, 3} the compiler creates a hidden array const int[3] and passes the constructor a lightweight view of it: effectively a pointer and a length. That has three consequences worth keeping in mind for the rest of this article:
- The elements are
const. You can read and copy them, not move from them. - The array is temporary. It normally lives until the end of the full-expression, so the view must not be stored.
- Copying an
initializer_listcopies the view, not the elements. Two copies point at the same array.
Braces prefer initializer_list constructors
This is the rule that causes all the surprises. When an object is initialized with braces and the class has an initializer_list constructor, overload resolution happens in two phases:
- Consider only the initializer_list constructors, with the whole braced list as a single
initializer_listargument. - Only if none of them is viable, consider all constructors with the braced elements as separate arguments.
A viable initializer_list constructor wins in phase 1 even when another constructor would be a better match in phase 2:
struct Widget {
Widget(int, int) { std::cout << "(int, int)\n"; }
Widget(std::initializer_list<double>) { std::cout << "list<double>\n"; }
};
Widget a(1, 2); // (int, int)
Widget b{1, 2}; // list<double> — int to double conversion, but phase 1 wins
(int, int) is an exact match, yet list<double> is chosen, because it is viable and phase 2 never runs. The only way to fall through to phase 2 is for the elements to be impossible to convert:
struct Label {
Label(int, int) { std::cout << "(int, int)\n"; }
Label(std::initializer_list<std::string>) { std::cout << "list<string>\n"; }
};
Label c{1, 2}; // (int, int): int does not convert to std::string
One partial exception: a narrowing conversion does not make the initializer_list constructor non-viable, it makes the program ill-formed. With Widget(std::initializer_list<int>) and Widget(double, double), Widget w{1.5, 2.5} does not fall back to the double constructor; it fails with a narrowing error.
The std::vector case
std::vector<int> v1(3, 7); // size constructor: {7, 7, 7}
std::vector<int> v2{3, 7}; // initializer_list: {3, 7}
std::vector<int> v3(10); // ten zeros
std::vector<int> v4{10}; // one element, 10
Nearly everyone writes v4 meaning v3 at some point. The version of this bug I have seen do the most damage was not a typo but a refactor: a codebase adopting “always use braces” as a style rule, and someone mechanically converting std::vector<int> buffer(size) to std::vector<int> buffer{size}. It compiles, the buffer has one element instead of size, and the first out-of-range write corrupts memory far from the cause. “Uniform initialization” is uniform in syntax, not in meaning, for any class with an initializer_list constructor.
With std::vector<std::string>, the same braces behave differently because the elements do not convert: std::vector<std::string> names{3, "x"} is not a list of two strings; it falls through to phase 2 and calls the (count, value) constructor, producing three "x". The meaning of braces depends on the element type, which is exactly what makes them hard to read in generic code.
Empty braces and nested braces
Empty braces are special-cased: they mean value initialization, so the default constructor is used if the class has one.
struct M {
M() { std::cout << "default\n"; }
M(std::initializer_list<int> l) { std::cout << "list size=" << l.size() << '\n'; }
};
M x; // default
M y{}; // default: empty braces never pick the initializer_list constructor
M z({}); // list size=0: an explicitly empty list
M w{{}}; // list size=1: the inner {} value-initializes one int (0)
M w{{}} is often described as “an empty list”, but it is not. The outer braces are the initializer list and the inner {} is its single element, a value-initialized int. To pass an explicitly empty list, use M z({}) or M z(std::initializer_list<int>{}).
auto and braces
auto a{1}; // int (C++17 rule)
auto b = {1}; // std::initializer_list<int>
auto c = {1, 2}; // std::initializer_list<int>
// auto d{1, 2}; // error: direct-list-init of auto requires exactly one element
The auto a{1} rule changed in C++17 (N3922); before that, it also deduced initializer_list<int>, which is the source of older advice to never combine auto with braces. The copy-list form auto b = {1} still deduces an initializer_list, and returning such a b from a function returns a view of a dead array.
Template argument deduction does not deduce initializer_list at all: template<class T> void f(T); f({1, 2}); fails to compile, while template<class T> void f(std::initializer_list<T>); f({1, 2}); deduces T = int.
Move-only and expensive elements
Because the backing array is const, elements are always copied out of it:
std::vector<std::unique_ptr<int>> v{std::make_unique<int>(1)};
// error: static assertion failed: result type must be constructible from value type of input range
// (the real cause: unique_ptr's copy constructor is deleted)
GCC’s message points at a static assertion inside <bits/stl_uninitialized.h>, which is confusing the first time; the underlying problem is simply that the list’s elements cannot be moved. The fix is to build the container without a list:
std::vector<std::unique_ptr<int>> v;
v.reserve(2);
v.push_back(std::make_unique<int>(1));
v.push_back(std::make_unique<int>(2));
For copyable but expensive types (large strings, nested vectors), an initializer list means every element is constructed once into the temporary array and then copied again into the container. For literals in tests and configuration this is irrelevant; in a hot path that builds containers of big objects, reserve plus emplace_back avoids the extra copy.
Never store the list
class Bad {
std::initializer_list<int> values_; // a view into someone else's temporary
public:
Bad(std::initializer_list<int> v) : values_(v) {}
int first() const { return *values_.begin(); }
};
Bad b{1, 2, 3};
b.first(); // undefined behavior: the array died at the end of the previous statement
class Good {
std::vector<int> values_; // owns a copy
public:
Good(std::initializer_list<int> v) : values_(v) {}
};
This bug tends to survive testing because the dead array’s stack memory often still holds the right values until something else overwrites it. AddressSanitizer reports it as stack-use-after-scope.
Designing classes with an initializer_list constructor
Adding one is an API decision with a blast radius, because it changes the meaning of existing brace initializations of your class. Rules that have held up for me:
Only add it when the class really is “a list of values”. Containers, sets of flags, matrices, configuration maps. A class that merely can be built from a few ints (a Point, a Range) should use ordinary constructors, so Point{1, 2} keeps calling Point(int, int).
Do not overlap it with a constructor whose parameters have the list’s element type. Container(std::size_t size) next to Container(std::initializer_list<std::size_t>) guarantees that Container{5} confuses readers. If you need both, make one of them a named factory:
class Buffer {
std::vector<int> data_;
explicit Buffer(std::vector<int> d) : data_(std::move(d)) {}
public:
Buffer(std::initializer_list<int> values) : data_(values) {}
static Buffer withSize(std::size_t n) { return Buffer(std::vector<int>(n)); }
};
Buffer a{1, 2, 3}; // elements
auto b = Buffer::withSize(3); // three zeros, and it says so
Beware of variadic forwarding constructors next to it. A constructor template template<class... Args> Wrapper(Args&&...) is a better match than the copy constructor for a non-const lvalue, so copying a Wrapper calls the template with the object itself as an argument:
template <typename T>
struct Wrapper {
std::vector<T> data;
template <typename... Args>
Wrapper(Args&&... args) : data{std::forward<Args>(args)...} {}
Wrapper(std::initializer_list<T> list) : data(list) {}
};
Wrapper<int> w1{1, 2};
Wrapper<int> w2(w1); // error: no matching function for call to 'std::vector<int>::vector(<brace-enclosed initializer list>)'
Constrain the template (C++20 requires, or std::enable_if excluding Wrapper itself) or drop it.
Two classes where an initializer_list constructor fits
Configuration maps
class Config {
std::map<std::string, std::string> settings_;
public:
Config(std::initializer_list<std::pair<const std::string, std::string>> items)
: settings_(items) {}
std::string get(const std::string& key) const {
auto it = settings_.find(key);
return it != settings_.end() ? it->second : "";
}
};
Config cfg = {
{"host", "localhost"},
{"port", "8080"},
};
The element type must be std::pair<const std::string, std::string> to match std::map’s value_type exactly. With std::pair<std::string, std::string>, settings_(items) does not compile, because std::map has no constructor taking an initializer_list of a different element type; you would have to write settings_(items.begin(), items.end()), which converts every element on the way in.
Fixed-size data with a size check
template <typename T, std::size_t N>
class Vec {
std::array<T, N> v_{};
public:
constexpr Vec(std::initializer_list<T> list) {
if (list.size() != N) throw std::invalid_argument("wrong number of elements");
std::copy(list.begin(), list.end(), v_.begin());
}
};
constexpr Vec<int, 3> ok{1, 2, 3};
// constexpr Vec<int, 3> bad{1, 2}; // error: the throw is reached during constant evaluation
initializer_list::size() is constexpr but the length is not part of the type, so a non-constexpr Vec<int, 3> v{1, 2} compiles and throws at runtime. When the length must be checked at compile time, a constructor taking const T (&)[N] or a variadic constructor constrained on sizeof...(Args) == N does it in the type system instead.