Why you should prefer the uniform initialization in modern C++

Photo by Ilya Pavlov on Unsplash

Why you should prefer the uniform initialization in modern C++

C++ offers various syntax choices for object initialization. Initialization values may be specified with parenthesis, equal signs or braces. In many cases, it is also possible to use an equals sign and braces together which usually gets treated as same as the braces-only version. For eg., an int can be initialized in the following ways. (stackoverflow.com/questions/48026922/is-an-..)

int i1(0);                  // initializer in paranthesis
int i2 = 0;                 // initializer follows "="
int i3{0} or int i3 = {0};  // initializer in braces.

There are several reasons to prefer braced initialization or “Uniform Initialization” over other initializing expressions. Here is a look at some of them.

1. Initialize aggregate and POD types

Both standard and dynamically allocated containers as well as POD types can be initialized with default values which was formerly not possible.

std::vector<int> v1{ 1, 2, 3 };
int *a1 = new int[3] { 5, 6, 7 };

struct Bar
{
   int m_Value1;
   int m_Value2;
};
Bar bar { 1, 7.6 };

2. Assign default values to non-static data members of a class

This capability is shared with the “=” initializer syntax but not with parenthesis.

class Foo
{private:
   int x{ 0 }; // fine, x's default value is 0
   int y = 0;  // also fine
   int z(0);   // error!
};

3. Prohibits implicit narrowing conversions

Braced initialization prohibits implicit narrowing conversions among built-in types. An error is thrown for nonexpressible values inside the braced initialization.

int i1 = 5.1;  // warning: possible loss of data
int i2(5.2);   // warning: possible loss of data
int i3{ 5.3 }; // error: requires a narrowing conversion

4. Avoid Most Vexing Parse

The Most Vexing Parse is when C++ cannot distinguish between the creation of an object and the declaration of a function. This is because anything that can be parsed as a declaration must be interpreted as one.

class Foo
{
public:
   Foo()      { /* do something */ }
   Foo(int i) { /* do something */ }
};

int main()
{
   Foo foo1(15); // call Foo ctor with arg 15
   Foo foo2();   // declares a function named foo2 that returns a Foo object

   return EXIT_SUCCESS;
}

Braced initialization prevents this since functions cannot be declared using braces.

Foo foo2{}; // calls ctor with no args

Using braced initialization with the std::initializer_list

So are there no drawbacks to braced initialization? Is it the preferred style for all cases? The answer is an unfortunate ‘No’. One drawback to braced initialization is sometimes the surprising behavior that results from a tangled relationship among braced initializers, std::initializer_list and constructor overloading resolution. Consider the following class

class Foo
{
public:
   Foo(int f_value1, double f_value2) : m_Value1{ f_value1 },
                                        m_Value2{ f_value2 } {}

   Foo(std::initializer_list<int> f_list) : m_Value1{std::accumulate(
                                                        f_list.begin(),
                                                        f_list.end(),
                                                        0) },
                                            m_Value2{ 0.0 } {}
private:
   int    m_Value1;
   double m_Value2;
};

During the constructor call, the constructor with the std::initializer_list takes precedence over the other constructor overloads.

Foo foo1{ 15, 32, 27 }; // calls ctor with initalizer list
Foo foo2{ 85, 45 };     // also calls ctor with initalizer list

In the case of foo2, we would expect the constructor Foo(int f_value1, double f_value2) to be called, but the constructor with the initializer list takes precedence during constructor overload resolution. Thus adding a constructor with an initializer list might break legacy code that uses braced initialization for constructor calls.

The fix? Introduce an empty struct/class as an argument in the constructor with the initializer list to force the constructor overloading resolution to choose other versions of the constructor.

struct list_picker { };

class Foo
{
public:
   Foo(int f_value1, int f_value2) : m_Value1{ f_value1 },
                                     m_Value2{ f_value2 } {}

   Foo(list_picker,
       std::initializer_list<int> f_list) : m_Value1{ std::accumulate(
                                                        f_list.begin(), 
                                                        f_list.end(),
                                                        0) },
                                            m_Value2{ 0.0 } {}

private:
   int m_Value1;
   int m_Value2;
};

Now with the above-modified constructor, the constructor overloading resolution happens as follows

Foo foo1{ 15, 32, 27 }; // calls ctor with initalizer_list
Foo foo2{ 85, 45 };     // also calls ctor with int and double as args

If you need to call the constructor with the initializer_list, you can do the following

Foo foo3{ {}, { 12, 45} };