According to the C++ standard (specifically [over.ics.list]), when list-initialization occurs, the compiler attempts to match the braced-init-list against constructors accepting std::initializer_list<T>. This binding constitutes an identity conversion (exact match), which outranks the user-defined conversions required to match individual elements to non-initializer_list constructors. Consequently, a constructor like Container(size_t count, T value) loses to Container(std::initializer_list<T>) when called with {10, 20}, because the latter requires no conversion for the braced-init-list argument itself, regardless of element-wise narrowing.
We were designing a Matrix class for a graphics engine that provided both a fill constructor Matrix(size_t rows, size_t cols, double val) and an aggregate-style constructor Matrix(std::initializer_list<std::initializer_list<double>>) for literal table initialization. A junior developer wrote Matrix m{1080, 1920, 0.0} expecting a 1080x1920 zero-initialized matrix, but instead the program created a 1x3 matrix containing the three scalar values, causing a subtle runtime rendering crash that was difficult to trace during debugging sessions.
We initially considered mandating parentheses syntax Matrix(1080, 1920, 0.0) for the fill constructor to bypass the std::initializer_list overload. However, this violated our coding standard's preference for C++11 uniform initialization and created an inconsistent API where some constructors required parentheses while others used braces.
Next, we explored tag dispatching by adding a fill_tag_t parameter to the fill constructor, effectively forcing users to write Matrix{fill_tag, 1080, 1920, 0.0}. While this disambiguated the call, it cluttered the public interface and confused developers who expected intuitive constructor signatures without artificial tag types.
Third, we attempted to restrict the std::initializer_list constructor to only activate for nested braces via SFINAE on the template parameter. This approach broke legitimate use cases like Matrix{{1.0, 2.0}, {3.0, 4.0}} and introduced brittle template metaprogramming that increased compile times and error message complexity.
Ultimately, we chose to introduce a static factory function Matrix::filled(rows, cols, val) and made the three-parameter fill constructor private, directing users to explicit syntax for dimension-based construction while keeping the std::initializer_list constructor public for aggregate syntax. This preserved the intuitive brace initialization for literal tables without risking accidental misinterpretation of dimension arguments.
The refactored API prevented the original bug by making Matrix{1080, 1920, 0.0} a compile-time error with no matching public constructor. Developers were now forced to use either Matrix::filled(1080, 1920, 0.0) for fill operations or Matrix{{...}} for initializer lists, which significantly improved code clarity and safety.
How does the compiler rank the conversion sequence from a braced-init-list to a non-initializer_list constructor compared to the identity match of an initializer_list constructor?
According to the C++ standard's overload resolution rules for list-initialization, binding a braced-init-list to a std::initializer_list<T> parameter constitutes an identity conversion (exact match) with the highest rank. In contrast, matching the same braced-init-list to another constructor requires the compiler to treat the list as a parenthesized expression list and perform user-defined or standard conversions on each element. Because identity conversions outrank all other conversion sequences, the initializer_list constructor wins even if its element types are a worse logical match than those required by an alternative constructor.
Why does auto x = {1, 2, 3}; deduce std::initializer_list<int> in C++11 and C++14, while auto x{1, 2, 3} becomes ill-formed in C++17 and later?
Prior to C++17, copy-list-initialization using the = token with auto always deduced std::initializer_list for braced-init-lists. However, C++17 introduced new rules for direct-list-initialization with auto (without =) that perform standard template argument deduction: if the braced-init-list contains multiple elements, deduction fails because auto cannot represent an std::initializer_list in this context, making the program ill-formed. This change eliminates the "secret std::initializer_list" trap for direct initialization, yet candidates often overlook that the copy syntax (auto x = {...}) still deduces std::initializer_list even in modern C++, creating a subtle inconsistency between initialization styles.
In what scenario can a class with both an initializer_list constructor and a variadic template constructor resolve ambiguously, and how can std::in_place_t disambiguate them?
When a class provides both Container(std::initializer_list<T>) and template<typename... Args> Container(Args&&... args), the variadic pack can match the same arguments as the initializer_list constructor via template argument deduction. For Container c{1, 2, 3}, both constructors are viable: the first via identity conversion of the braced-init-list, and the second via deducing Args as int, int, int. Although the non-template initializer_list constructor usually wins the tie-breaker, adding a tag type like std::in_place_t to the variadic constructor (e.g., Container(std::in_place_t, Args&&... args)) forces users to write Container{std::in_place, 1, 2, 3}, ensuring the variadic version is only invoked explicitly while the initializer_list constructor handles homogeneous braced lists by default.