Skip to content

Modern C++ • Uniform Initialization

Correct data initialization is a fundamental concern in computer programming, especially when the environment does not provide a consistent behavior, which can lead to unexpected results. Prior to C++11, initialization could be performed in a variety of ways and programmers had to be very careful in order not to leave the objects in an undefined, potentially dangerous state. Since C++11 things have improved significantly and developers have now more consistent and intuitive initialization constructs that simplify writing better code and address some issues that were long overdue.

Before diving into practical examples, let’s revise some language terminology that will help us put things in the right perspective. There are a variety of ways in which data objects can be initialized, depending on their type and the context they’re in, and in the course of its existence new methods have been added to support new features and functionality. At the time of this writing, C++ supports the following initialization types:

  • value initialization: refers to the initialization of an object when it is constructed by invoking an initializer expression without any arguments (i.e. T x())
  • direct initialization: refers to the initialization of an object when it is constructed by invoking an initializer expression with an explicit set of arguments (i.e. T x(a,...))
  • copy initialization: refers to the initialization of an object when it is constructed by copying the value from another object such as in assignments, pass-by-value and return-by-value (e.g. T x = y)
  • default initialization: refers to the initialization of an object constructed without invoking an explicit initializer expression (i.e. T x)
  • zero initialization: refers to the initialization of an object with a “zero” value (indirectly as a result of some other type of initialization, such as value initialization)
  • list initialization (C++11): (aka brace-initialization) refers to the initialization of an object using an initializer list (i.e. T x{...}
  • aggregate initialization (C++11): refers to the initialization of aggregate objects using an initializer list, which is a special case of list initialization (e.g. T x[]{a,...})

The initialization process may involve several of the above mentioned methods and each one has a range of effects on the data being initialized depending on the context and the C++ version. These specifications have changed in the course of the C++ modernization process to accommodate the implementation of new features, so you are invited to read the appropriate C++ Standard drafts for all the esoteric details about specific versions.

When it comes to C++ initialization we are generally speaking of the following data types

  • Primitive (integer, floating point, boolean, character, etc.)
  • Aggregates (broadly speaking, a set of publicly accessible data values, such as arrays and PODs)
  • Members (data types defined in a class)
  • Containers (data structures such as vectors, lists, maps, etc.)

These objects can be initialized in different ways in legacy C++, and for each type there could be several initialization methods, as illustrated in the following code

// -------------------------
// Primitives initialization
// -------------------------
 
int x = 5;           // copy-initialization
int z(3);            // direct-initialization
int t();             // ERROR: ambiguous (function declaration ?)
int w = int(1);      // direct-initialization + copy-initialization
double d;            // default-initialization (undefined value)
int seq[3];          // default-initialization (undefined values)
int n = int();       // zero-initialization (by value-initialization)
 
// -------------------------
// Aggregates initialization
// -------------------------
 
int age[3] = {28, 35, 43};            // copy-list-initialization
char hello[] = "ciao";
char greet[] = {'c','i','a','o','\0'};
 
int* years = new int[3];              // can't be initialized
     years[0] = 1980;                 // individual elements must be
     years[1] = 1975;                 // assigned after construction
     years[2] = 1985;
 
struct M {
   int x; int y;
};
 
M m1 = {1,2};                         // copy-list-initialization
M m2;                                 // uninitialized
M *m3 = new M;                        // uninitialized
 
struct C { 
   C() : x(0), y(0) {}
   C(int a, int b) : x(a), y(b) {}
   int x; int y;
}
C c1;                                 // default-initialization
C c2(1,2);                            // direct initialization
C c3{4,5};                            // direct-list-initialization
C c4 = C();                           // copy-value-initialization
 
// ----------------------
// Members initialization
// ----------------------
 
class A
{
    int x = 1;   // ERROR: not allowed for non-static non-const members
    int ages[3];
    M m;
public:
    A(int x) : 
      x(x), 
      ages(???),    // can't initialize arrays here
      m(???)        // can't initialize PODs here
    {
        ages[0] = 25;
        ages[1] = 35;
        ages[2] = 45;
 
        m.x = -1;
        m.y = 10;
    }
};

// -------------------------
// Containers initialization
// -------------------------
 
std::vector<int> ages;         // can't be initialized
 
ages.push_back(28);            // individual elements must be
ages.push_back(35);            // added after construction
ages.push_back(43);
 
std::map<std::string, int> people;  // same as above ...
 
people["Alex"] = 28;
people["Justin"] = 35;
people["Mark"] = 43;
 
class B
{
    std::vector<int> ages;
    std::map<std::string, int> people;
 
    B() : 
       ages(???), people(???)  // can't initialize containers here
    {
         // Containers must be initialized here
         // by adding individual elements
    }
};

As can be seen from the examples above, legacy C++ had some very annoying limitations. Specifically, 1) dynamically allocated arrays and PODs could not be initialized; 2) arrays and PODs could not be initialized in member initialization lists; 3) containers could not be initialized; 4) non-static non-const class members could not be initialized at the class level. Furthermore, the initialization methods were not consistent as there were many ways to perform objects initialization with possibly different effects.

The C++11 standard introduced some new features:

1. An extended initializer list by introduction of initializer_list objects;
2. The ability to initialize any class member at the class level; and
3. A unified syntax for a more intuitive and consistent initialization;

Initializer Lists

The braced initializer list {...} has been extended to containers as well so that they can be easily initialized with a sequence of elements at construction time, like automatically allocated arrays and PODs

std::vector<int> years = { 1975, 1980, 2018 };   // (copy) list-initialization
 
std::vector<std::string> names {                 // (direct) list-initialization
    "Alberto", "John", "Mark"
};
 
std::list<std::string> places {
    "Rome", "Melbourne", "Tokyo"
};
 
std::map<std::string, int> people {
    {"Albert", 614092837 },
    {"John", 390398273},
    {"Mark", 619283330}
};
 
class A 
{
    std::vector<int> ages;
    std::list<std::string> places;
    std::map<std::string, int> people;
public:
    A() :  // containers can now be initialized in member init lists
        ages{ 25, 35, 45 },
        places{ "Rome", "Melbourne", "Tokyo" },
        people{ 
                { "Albert", 614092837 },
                { "John", 390398273 },
                { "Mark", 619283330 } }
    {
    }
};

This is possible thanks to an auxiliary object of type initializer_list<T> that’s automatically constructed by the compiler from initializer lists. What happens under the hood is this: 1) the elements in the initializer list are assigned to a temporary array of type const T[]; 2) an initializer_list<T> object is constructed to reference the elements of the array (multiple copies of initializer_list<T> are possible and they will all reference the same array); 3) the initializer_list<T> object is passed to the container into a specific constructor called the initializer-list constructor (or sequence constructor); 4) the container uses the initializer list object to access the elements in the initializer list and initialize its own elements. The initializer_list object is basically a const iterator with a size() method that allows read-only access to the elements of the initializer list that it wraps.

But the initializer_list object is not only limited for use by the compiler with container classes. Being a first-class citizen with a type of its own, it can be used as any other object and passed as a parameter or returned as a value, as shown in the following code

class MyCollection
{
    std::list<int> collection;
public:
    // Initializer-list constructor
    MyCollection(std::initializer_list<int> items)
    {
        collection.insert(collection.end(), items);
    }
 
    // A method taking an initializer_list as an argument
    void add(std::initializer_list<int> items)
    {
        collection.insert(collection.end(), items);
    }
 
    void add(int item)
    {
        collection.push_back(item);
    }
};
 
MyCollection collection { 1,2,3 }; // calls the initializer-list constructor
collection.add({ 4,5,6 });
collection.add(7);

With this mechanism it is possible to conveniently pass list of elements into custom classes or functions allowing for a more seamless coding experience.

Note, however, that there are situations where uniform initialization can create ambiguity. Consider for example the following code

std::vector<int> v{3};

Since one of std::vector‘s constructor overloads can take a single integer argument to indicate the number of elements in the new vector, this may generate ambiguity regarding the intent of the above statement: did the programmer want a vector with 3 zero-initialized elements or with one element equal to 3?

The answer is that if a class has no defined sequence constructor, then the compiler will resolve the call by matching the elements of the list to the available constructors. If a sequence constructor is defined then list-initialization will be performed first. In our example the STL vector supports list initialization, so a vector with one element equal to 3 will be created. To use the constructor taking the number of elements as an argument, direct-initialization must be used (i.e. vector v(3)).

Class Member Initialization

Along with the standard member initializer list, C++11 provides another mechanism to initialize class members to default values directly at the class level, that is at the point where they’re declared, as illustrated in the following code

struct P {
    int x; int y;
};
 
class A 
{
    double pi    = 3.14;
    char letter  = 'A';
    int months   = 12;
    int *ptr     = nullptr;
    int years[3] = { 1975, 1980, 1985 };  // or in the member initializer list
    P point      = { 1,-3 };
 
    std::vector<int> ages = { 25, 35, 45 };
    std::list<std::string> places = { "Rome", "Melbourne", "Tokyo" };
    std::map<std::string, int> people = { { "Albert", 614092837 },
                                          { "John", 390398273 },
                                          { "Mark", 619283330 } };
};

Class member initialization can also be done through the member initializer list, but doing it at the class level provides more flexibility as it allows you to specify a class-level default value that can then be overridden in the member initializer list (which takes precedence) if needed. Also, it removes clutter as you don’t have to repeat it in every constructor if there are many of them, thus making the code more readable.

Uniform Initialization

C++11 extends the concept of initializer list even further to unify the way data types are initialized and provide a consistent syntax. In fact, aside from aggregates and classes, initialization lists can also be used with primitive types

int a{ 5 };       // = 5
int b = { 3 };    // = 3
int c{};          // = 0
char d{ 'A' };    // = 'A'
char e{};         // = ''
double f{ 0.5 };  // = 0.5
double g{ f };    // = 0.5

and with dynamically allocated objects

int *a = new int[5] { 1,2,3 };                         // [1,2,3,0,0]
char *dev = new char[7] { 'A','L','B','E','R','T' };   // ['A','L','B','E','R','T',\0]
double *debts= new double[3]{};                        // [0.0, 0.0, 0.0]
const char **fruits = new const char*[3] {             // ["Apple","Kiwi","Mango"]
    "Apple", "Kiwi", "Mango"
};
 
struct P { int x; int y; };
 
P *point = new P{-30, 28};

The idea is that of using a single notation for all kinds on initialization that will make the code more readable, maintainable, concise and intuitive, as shown in the below example

// Old C++
 
int a = 5;
int b(5);
int c();  // ERROR: ambiguous
int d[5] = {1,2,3,4,5};
int *e = new int[3];
     e[0] = 1; e[1] = 2; e[2] = 3;
 
class C
{
     int a[3];
     double rate;
     std::vector<int> ages;
public:
     C() : rate(2.5)
     {
          a[0] = 1;
          a[1] = 2;
          a[2] = 3;
 
          ages.push_back(25);
          ages.push_back(30);
          ages.push_back(35);
     }
};
 
// Modern C++
 
int a = {5};
int b {5};
int c {};
int d[5] {1,2,3,4,5};
int *e = new int[3] {1,2,3};
 
class C
{
     int a[3]              {1,2,3};
     double rate           {2.5};
     std::vector<int> ages {25,30,35};
};

One benefit of using initializer lists is that they do not allow narrowing. Narrowing is the (side) effect that arises when a value is assigned to another value with a smaller (narrower) type. For example, assigning a floating point value to an integer. Historically, narrowing has been quite a controversial topic and, while it is a potential source of bugs, it has become an established practice for doing implicit conversions. Initializer lists forbid this kind of operation, as illustrated in the following code

int i1 = 543;
float f1 = 1.5f;
double d1 = 2.1;
 
// Narrowed values
char c1 = i1;            // c1 = 31
int i2 = f1;             // i2 = 1
float f2 = d1;           // f2 = 2.09999
     
char c2 { i1 };          // ERROR: requires a narrowing conversion int->char
int i3  { f1 };          // ERROR: requires a narrowing conversion float->int
float f3 { d1 };         // ERROR: requires a narrowing conversion double->float
char c3 { 260 };         // ERROR: requires a narrowing conversion int->char
char c4 { 10 };          // OK: 10 is an int but fits into a char
double d2 { 10 };        // OK: int fits into a double
int i4 { 5.6 };          // ERROR: requires a narrowing conversion double->int
unsigned int i5 { -1 };  // ERROR: requires a narrowing conversion int->unsigned int
vector<int> v {1,2.5,3}  // ERROR: 2.5 requires a narrowing conversion double->int

Conclusion

All in all, it appears that, apart from edge cases, there are many reasons to prefer the new uniform initialization over the old method of initializing objects. First of all is safety. Doing implicit conversions is quick and simple but it may cause unwanted side effects and hide subtle bugs. The second reason is consistency. The use of the braced-init-list notation {...} eliminates some ambiguities such as the “most vexing parse” and provides for a more consistent programming experience. The third reason is extended initialization capabilities as it allows to initialize objects (e.g. containers) in ways that were not possible before.

There are however some downsides as well. Despite its name, it’s not really “uniform” in a sense that it’s not universally applicable. It may also cause unwanted effects due to the overload resolution rules when constructing an object that provides support for sequence initialization alongside others (as in the std::vector case). The most important fact to keep in mind is that “uniform” initialization and “classic” initialization are not interchangeable, so the best practice in this case would be to pick one and consistently stick to it.

Published inModern C++