Uniform initialization syntax is one of my favourite features of Modern C++. I think it’s important, in good quality code, to clearly distinguish between initialization and assignment.
When it comes to user-defined types – structures and classes – brace initialization can throw up a few unexpected issues, and some counter-intuitive results (and errors!).
In this article, I want to have a look at some of the issues with brace initialization of user-defined types – specifically, brace elision and initializer_lists.
Read on for more…
Contents
User-defined types
We all know the hoary old interview question: “What’s the difference between structs and classes in C++”. And we all regurgitate the same answer:
- The default access specifier on a struct is public; on a class it’s private
- The default inheritance is public for structs; private for classes.
For Modern C++ there is also a third difference: the way brace initialization is handled.
For a structure, brace initialization performs a direct initialization of its members
struct POD { int i; int j; }; int main() { POD pod1 { 1, 2 } // pod1.i => 1 // pod1.j => 2 }
For class types, brace initialization results in a call to a constructor.
class ADT { public: ADT(int x, int y); void op(); private: int a; int b; }; int main() { ADT adt1 { 1, 2 }; // ADT::ADT(1, 2); }
There’s nothing to stop you adding a constructor to a struct. If you do, the braced initialization list becomes a call to a constructor.
struct POD { int i; int j; POD(int x, int y); }; int main() { POD pod1 { 1, 2 }; // POD::POD(1, 2); }
There are some odd corner cases, that might throw you:
class ADT { public: // NOTE - public attributes (Yeah, I know...) // int a; int b; // Explicitly deleted constructors // ADT(int, int) = delete; ADT() = default; void op() { }; private: }; int main() { ADT adt1 { }; // OK!? ADT adt2 { 1, 2 }; // OK!? What gives?... }
Even though we’ve explicitly deleted the two constructors, this code compiles just fine. The reason this works (maybe against your expectations) is that both objects are being direct initialized – that is, the constructors are not being called. The compiler can do this as the attributes are declared as public.
I can ‘break’ this code by actually defining one (or both) of the constructors explicitly
class ADT { public: int a; int b; ADT(int, int) = delete; ADT() { /* ctor definition... */ }; void op() { }; private: }; int main() { ADT adt1 { }; // OK ADT adt2 { 1, 2 }; // ERROR - ctor is deleted (as expected) }
The compiler treats user-defined types with only public attributes as an aggregate type. It will attempt to direct initialize the members unless an appropriate constructor is defined.
By extension, if I declare any private attributes aggregate initialization can no longer be applied (you can’t access the private member to initialize it); therefore, the braced initialization list must be used to call a constructor.
class ADT { public: int a; int b; ADT(int, int) = delete; ADT() = delete; void op() { }; private: int c; // <= Private data member }; int main() { ADT adt1 { }; // ERROR - Default ctor is deleted ADT adt2 { 1, 2 }; // ERROR - Non-default ctor is deleted }
Brace elision
Brace elision is a syntactic mechanism to simplify (in some cases) nested structures.
If an aggregate type has a sub-aggregate (that is, another structure) element then the braces around the initializer of the sub-aggregate type may be omitted (elided). The compiler will take as many initializers from the list as required to initialize the element; the remaining iniitializers are then used to initialize any remaining members.
struct Inner { int arr[2]; }; int main() { Inner inner1; // Uninitialized Inner inner2 { }; // arr[0] & arr[1] => 0 Inner inner3 { { 1, 2 } }; // Direct initialisation Inner inner4 { 1, 2 }; // Brace elision of arr }
The first two initializations are pretty straightforward. But let’s explore the second two initializations in a bit more detail. The initialization of inner3 is explicit and complete. We could view it like this
int main() { Inner inner3 { // Init. list for Inner { // Init. list for Inner::arr 1, // Inner::arr[0] => 1 2 // Inner::arr[1] => 2 } }; }
The initialization for inner4 uses brace elision. Instead of finding an opening brace for the initialization of Inner::arr, the compiler finds an expression. It assumes that this is meant to be the initialization list for the first (declared) member of Inner. It will then keep consuming initialization-expressions until the member is completely initialized, or it reaches a closing brace.
int main() { Inner inner4 { // Init. list for Inner // Initializer braces for arr missing (elided) 1, // Set Inner::arr[0] => 1 2 // Set Inner::arr[1] => 2 }; }
If we supply too many values, we get an error
int main() { Inner inner4 { // Init. list for Inner // Initializer braces for arr missing (elided) 1, // Set Inner::arr[0] => 1 2, // Set Inner::arr[1] => 2 3 // ERROR - nothing left to initialize }; }
In this example, if we supply too few initializers, the remainder of Inner::arr will be value initialized
int main() { Inner inner4 { // Init. list for Inner // Initializer braces for arr missing (elided) 1 // Set Inner::arr[0] => 1 // Set Inner::arr[1] => 0 (value initialized) }; }
The question that typically comes up is: why do we have this mechanism? The best answer I can give is std::array.
The STL array class is a thin wrapper around a C-style array. Basically, it’s a struct with a nested C-style array inside. A (highly simplified) implementation could look something like this:
template <typename T, size_t sz> struct array { T _elems[sz]; // Member functions... };
To initialize a std::array with values you would have to provide two sets of braces – one set for the std::array, one set for the (nested) C-style array.
int main() { std::array<int, 4> vals { { 1, 2, 3, 4 } }; }
This looks awkward; and doesn’t fit in with the initialization syntax of anything else in the language. Brace elision makes our code look more ‘normal’
int main() { std::array<int, 4> vals { 1, 2, 3, 4 }; // Brace-elided // initialization }
(If you’re using Clang it will emit a diagnostic about brace-elided initialization for std::arrays)
Let’s now look at some corner cases where brace-elision may lead to code “not meeting developer expectations”.
Now we have a structure-within-a-structure.
struct Inner { int arr[2]; }; struct Outer { Inner inner; int other_data; }; int main() { Outer outer1; Outer outer2 { }; Outer outer3 { { 1, 2 }, 3 }; Outer outer4 { Inner { { 1, 2 } }, 3 }; Outer outer5 { Inner { 1, 2 }, 3 }; Outer outer6 { 1, 2, 3 }; }
The first three initializations should be pretty straightforward, based on what we’ve just talked about.
I’ll expand out the initialization of outer4, as in the previous examples
Outer outer4 { // Init. list for Outer Inner { // Init. list for temporary Inner object { // Init. list for Inner::arr 1, // arr[0] => 1 2 // arr[1] => 2 } // End of init. list for Inner::arr }, // End of init. list for Inner 3 // Initializer for Outer::other_data };
(Note: The temporary Inner object will be constructed directly into the outer4 member; there will be no copying.)
The initialization for the object outer5 is similar, but this time we are eliding the braces around the temporary Inner object’s arr member.
Using copy initialization seems unnecessary at first glance but is actually a very useful convenience, particularly with class types as we’ll explore later.
For outer6 we have elided all the braces on internal members.
Outer outer6 { // Init. list for Outer // Braces elided for Inner // Braces elided for Inner::arr 1, // Set Inner::arr[0] => 1 2, // Set Inner::arr[1] => 2 // End of Inner initialization 3 // Initializer for Outer::other_data };
Let’s go all-in on this. This time we have a structure that consists of an array of other structures (replace Outer with std::array to see where this is going…)
struct Inner { int arr[2]; }; struct Outer { Inner data[2]; }; int main() { Outer outer1 { 1, 2, 3, 4 }; // OK Outer outer2 { { 1, 2 }, { 3, 4 } }; // ERROR! }
The horrible thing about this example is the code that looks it should work, doesn’t; and the code that looks like it shouldn’t work, does!
This is brace elision messing with us. Let’s expand these two examples to see why they work (or fail!) the way they do.
int main() { Outer outer1 { // Init. list for Outer // Brace elision of Outer::data // Brace elision of Inner::arr 1, // Outer::data[0]::arr[0] => 1 2, // Outer::data[0]::arr[1] => 2 // End of init for Outer::data[0] 3, // Outer::data[1]::arr[0] => 3 4 // Outer::data[1]::arr[1] => 4 // End of init for Outer::data[1] // End of init for Outer data }; }
In the second example, by aligning the initializer braces it is easier to see where the problem lies. Brace elision of a member means that the compiler takes initializer values from the supplied braced initializer list. Here, we can see only two values are supplied for Inner::arr; therefore, the second pair of values are value-initialized.
int main() { Outer outer2 { // Init. list for Outer { // Init. list for Outer::data // Brace elision of Inner::arr 1, // Outer::data[0]::arr[0] => 1 2 // Outer::data[0]::arr[1] => 2 // Outer::data[1]::arr[0] => 0 // Outer::data[1]::arr[1] => 0 }, // End of init list for Outer::data { // ERROR - Nothing left to initialize! 3, 4 } }; }
To avoid this confusion you have to make sure you specify all the braces (that is, no brace elision)
int main() { Outer outer3 { { { 1, 2 }, { 3, 4 } } }; // No brace elision }
If our nested type is a class then we are no longer direct-initializing the object(s) – we are calling constructors.
Given the following code
class ADT { public: ADT() = default; ADT(int a, int b); // Defined ctor void do_stuff() { }; private: int i { 1 }; int j { 10 }; }; struct Outer { ADT elems[2]; // <= Array of classes };
Our first two definitions give no problems (providing our ADT class supports default construction)
int main() { Outer outer1; // OK - ADT::ADT() for each element Outer outer2 { }; // OK - ADT::ADT() }
The following declarations give us similar issues to our previous examples.
int main() { Outer outer3 { { { 1, 2 }, { 3, 4 } } }; // OK - Explicit Outer outer4 { { 1, 2 }, { 3, 4 } }; // ERROR - too many // initializers }
As before, let’s expand these out and look for the brace elision (or not)
int main() { Outer outer3 { // Init. list for Outer { // Init. list for Outer::elems { // Constructor args for Outer::elems[0] 1, 2 }, { // Constructor args for Outer::elems[1] 3, 4 } } // End of init. list for Outer::elems }; Outer outer4 { // Init. list for Outer { // Init. list for Outer::elems // Brace elision for Outer::elems[0] 1, // Call ADT::ADT(1, 2) 2 // No more initialisers. // Outer::elems[1] is default constructed }, // End of init. list for Outer::elems { // ERROR - Nothing left to initialize! 3, 4 } }; }
Using copy initialization makes the code a lot more comprehensible.
int main() { Outer outer5 { ADT { 1, 2 }, // Copy initialize Outer::elems[0] ADT { 3, 4 } // " " " " Outer::elems[1] }; }
Technically, there is brace elision going on here; and we should really write
int main() { Outer outer5 { { ADT { 1, 2 }, // Copy initialize Outer::elems[0] ADT { 3, 4 } // " " " " Outer::elems[1] } }; }
However, hopefully you will agree this actually detracts from the readability of the code.
List initialization of class types
Since one of the design goals of C++ was to emulate the behaviour of built-in types it seems reasonable that you should be able to initialise user-defined aggregate types (containers, etc.) in the same way.
class Position { public: Position() = default; Position(double azi, double elev); void set(double azi, double elev); double azimuth() const; double elevation() const; private: double rho { 90.0 }; double theta { 90.0 }; }; // Container-like class // class Track { public: void add(const Position& in_val); Position& operator[](int index); private: std::array<Position, 16> elements; std::array<Position, 16>::iterator next { begin(elements) }; };
Since the Track class has private data members, using brace initialization results in an attempted call to a constructor. In this case there is no constructor that takes four Position objects.
int main() { Track track { // ERROR - No appropriate ctor! Position { 45.0, 45.0 }, Position { 60.0, 60.0 }, Position { 120.0, 120.0 }, Position { 180.0, 180.0 } }; ... }
A std::initializer_list allows a class to be initialized with a list of arguments (although they must be of the same type).
#include <array> #include <initializer_list> #include "Position.h" class Track { public: Track(std::initializer_list<Position> init); void add(const Position& in_val) Position& operator[](int index) private: std::array<Position, 16> elements; std::array<Position, 16>::iterator next { begin(elements) }; }; int main() { Track track { // Creates std::initializer_list<Position> Position { 45.0, 45.0 }, Position { 60.0, 60.0 }, Position { 120.0, 120.0 }, Position { 180.0, 180.0 } }; ... }
When the compiler creates an initializer list the elements of the list are constructed on the stack (or in static memory, depending on the scope of the initializer list) as const objects.
The compiler then creates the initializer_list object that holds the address of the first element and one-past-the-end of the last element. Note that the initializer_list object is very small (two pointers) so can be passed by copy; although you could pass by reference-to-const and save one pointer (at the cost of double-indirect access to the initialiser objects)
A brief look at the implementation of std::initializer_list can be enlightening as to its implementation. Here is a (simplified, for clarity) implementation.
template <typename T> class initializer_list { public: constexpr initializer_list(const T* fst, const T* lst) : first { fst }, last { lst } { } constexpr const T* begin() const { return first; } constexpr const T* end() const { return last; } constexpr std::size_t size() const { return static_cast<std::size_t>(last – first); } private: const T* first; // First element const T* last; // One-past-the-end };
The initializer_list object is an instance of a template class that consists of two pointers – one to the first element; and one pointing to one-past-the-end. (Alternatively, an initializer_list can be implemented as a pointer plus a length; the results are the same)
A simple interface allows client code to access the elements using the Iterator pattern (as with other container-like entities).
Track::Track(std::initializer_list<Position> init) { auto iter = begin(init); auto num_elems = (init.size() <= 16) ? init.size() : 16; for (unsigned i { 0 }; i < num_elems; ++i) { elements[i] = *iter; ++iter; } }
The initializer list (and its list of initializing objects) is an r-value expression. Therefore, you must copy from the initializer list into your internal container.
Beware of potential confusion when overloading constructors with std::initializer_list. Given the following class
class ADT { public: // Constructor overloads // ADT(); ADT(int init_val); ADT(std::initializer_list<int> init_vals); ... }
Different initialization declarations will yield different (and maybe unexpected!) results
int main() { ADT adt1 { 1, 2, 3, 4 }; // ADT::ADT(std::initializer_list<int>) ADT adt2 { 1 }; // ADT::ADT(std::initializer_list<int>) ADT adt3 (1); // ADT::ADT(int) ADT adt4 { }; // ADT::ADT() }
The rules are as follows:
- If there is a constructor overload for an initializer list the compiler will strongly prefer this overload to any other.
- To invoke a non-initializer_list overload you must use the parenthesis constructor notation
- The compiler will always prefer a default constructor (empty braces) to an empty initializer_list constructor call.
Summary
I’ll leave you with some of our guidelines regarding initialization in C++
- Favour uniform initialization syntax. That is, clearly distinguish between initialization and assignment.
- By extension to the above guideline: always prefer brace initialization.
- Use structs as “Plain Old Data” types only. Avoid constructors and behaviour on structs. In other words, use structs like a C programmer would.
- Use classes for behavioural types.
- Member variables of a class should represent an object’s operational state; and should only be exposed grudgingly.
- Use Non-Static Data Member Initializers wherever possible.
- Use constructors to define an interface to allow clients to override default operational state.
- For initialization of nested objects in aggregate types, prefer copy initialization syntax of the nested object. That way you can exploit brace elision safely; and, perhaps more importantly, to make your code more readable and easier to reason about.
- If you must avoid copy initialization, make sure you avoid brace elision; declare all the braces explicitly.
- Only add initializer_list constructors onto classes that should behave like aggregate types.
- Never overload for both a single object and an initializer list of that type.
- Practice makes perfect, part 3 – Idiomatic kata - February 27, 2020
- Practice makes perfect, part 2– foundation kata - February 13, 2020
- Practice makes perfect, part 1 – Code kata - January 30, 2020
Glennan is an embedded systems and software engineer with over 20 years experience, mostly in high-integrity systems for the defence and aerospace industry.
He specialises in C++, UML, software modelling, Systems Engineering and process development.
Thanks for the thorough article!