Understanding uniform initialization
Brace-initialization is a uniform method for initializing data in C++11. For this reason, it is also called uniform initialization. It is arguably one of the most important features of C++11 that developers should understand and use. It removes previous distinctions between initializing fundamental types, aggregate and non-aggregate types, and arrays and standard containers.
Getting ready
To continue with this recipe, you need to be familiar with direct initialization, which initializes an object from an explicit set of constructor arguments, and copy initialization, which initializes an object from another object. The following is a simple example of both types of initializations:
std::string s1("test"); // direct initialization
std::string s2 = "test"; // copy initialization
With these in mind, let’s explore how to perform uniform initialization.
How to do it...
To uniformly initialize objects regardless of their type, use the brace-initialization form {}
, which can be used for both direct initialization and copy initialization. When used with brace-initialization, these are called direct-list and copy-list-initialization:
T object {other}; // direct-list-initialization
T object = {other}; // copy-list-initialization
Examples of uniform initialization are as follows:
- Standard containers:
std::vector<int> v { 1, 2, 3 }; std::map<int, std::string> m { {1, "one"}, { 2, "two" }};
- Dynamically allocated arrays:
int* arr2 = new int[3]{ 1, 2, 3 };
- Arrays:
int arr1[3] { 1, 2, 3 };
- Built-in types:
int i { 42 }; double d { 1.2 };
- User-defined types:
class foo { int a_; double b_; public: foo():a_(0), b_(0) {} foo(int a, double b = 0.0):a_(a), b_(b) {} }; foo f1{}; foo f2{ 42, 1.2 }; foo f3{ 42 };
- User-defined Plain Old Data (POD) types:
struct bar { int a_; double b_;}; bar b{ 42, 1.2 };
How it works...
Before C++11, objects required different types of initialization based on their type:
- Fundamental types could be initialized using assignment:
int a = 42; double b = 1.2;
- Class objects could also be initialized using an assignment from a single value if they had a conversion constructor (prior to C++11, a constructor with a single parameter was called a conversion constructor):
class foo { int a_; public: foo(int a):a_(a) {} }; foo f1 = 42;
- Non-aggregate classes could be initialized with parentheses (the functional form) when arguments were provided and only without any parentheses when default initialization was performed (a call to the default constructor). In the next example,
foo
is the structure defined in the How to do it... section:foo f1; // default initialization foo f2(42, 1.2); foo f3(42); foo f4(); // function declaration
- Aggregate and POD types could be initialized with brace-initialization. In the following example,
bar
is the structure defined in the How to do it... section:bar b = {42, 1.2}; int a[] = {1, 2, 3, 4, 5};
A Plain Old Data (POD) type is a type that is both trivial (has special members that are compiler-provided or explicitly defaulted and occupy a contiguous memory area) and has a standard layout (a class that does not contain language features, such as virtual functions, which are incompatible with the C language, and all members have the same access control). The concept of POD types has been deprecated in C++20 in favor of trivial and standard layout types.
Apart from the different methods of initializing the data, there are also some limitations. For instance, the only way to initialize a standard container (apart from copy constructing) is to first declare an object and then insert elements into it; std::vector
was an exception because it is possible to assign values from an array that can be initialized prior using aggregate initialization. On the other hand, however, dynamically allocated aggregates could not be initialized directly.
All the examples in the How to do it... section use direct initialization, but copy initialization is also possible with brace-initialization. These two forms, direct and copy initialization, may be equivalent in most cases, but copy initialization is less permissive because it does not consider explicit constructors in its implicit conversion sequence, which must produce an object directly from the initializer, whereas direct initialization expects an implicit conversion from the initializer to an argument of the constructor. Dynamically allocated arrays can only be initialized using direct initialization.
Of the classes shown in the preceding examples, foo
is the one class that has both a default constructor and a constructor with parameters. To use the default constructor to perform default initialization, we need to use empty braces—that is, {}
. To use the constructor with parameters, we need to provide the values for all the arguments in braces {}
. Unlike non-aggregate types, where default initialization means invoking the default constructor, for aggregate types, default initialization means initializing with zeros.
Initialization of standard containers, such as the vector and the map, also shown previously, is possible because all standard containers have an additional constructor in C++11 that takes an argument of the type std::initializer_list<T>
. This is basically a lightweight proxy over an array of elements of the type T const
. These constructors then initialize the internal data from the values in the initializer list.
The way initialization using std::initializer_list
works is as follows:
- The compiler resolves the types of the elements in the initialization list (all the elements must have the same type).
- The compiler creates an array with the elements in the initializer list.
- The compiler creates an
std::initializer_list<T>
object to wrap the previously created array. - The
std::initializer_list<T>
object is passed as an argument to the constructor.
An initializer list always takes precedence over other constructors where brace-initialization is used. If such a constructor exists for a class, it will be called when brace-initialization is performed:
class foo
{
int a_;
int b_;
public:
foo() :a_(0), b_(0) {}
foo(int a, int b = 0) :a_(a), b_(b) {}
foo(std::initializer_list<int> l) {}
};
foo f{ 1, 2 }; // calls constructor with initializer_list<int>
The precedence rule applies to any function, not just constructors. In the following example, two overloads of the same function exist. Calling the function with an initializer list resolves a call to the overload with an std::initializer_list
:
void func(int const a, int const b, int const c)
{
std::cout << a << b << c << '\n';
}
void func(std::initializer_list<int> const list)
{
for (auto const & e : list)
std::cout << e << '\n';
}
func({ 1,2,3 }); // calls second overload
However, this has the potential to lead to bugs. Let’s take, for example, the std::vector
type. Among the constructors of the vector, there is one that has a single argument, representing the initial number of elements to be allocated, and another one that has an std::initializer_list
as an argument. If the intention is to create a vector with a preallocated size, using brace-initialization will not work, as the constructor with the std::initializer_list
will be the best overload to be called:
std::vector<int> v {5};
The preceding code does not create a vector with five elements but, instead, a vector with one element with a value of 5
. To be able to actually create a vector with five elements, initialization with the parentheses form must be used:
std::vector<int> v (5);
Another thing to note is that brace-initialization does not allow narrowing conversion. According to the C++ standard (refer to paragraph 9.4.5 of the standard, document version N4917), a narrowing conversion is an implicit conversion:
— From a floating-point type to an integer type.
— From long double to double or float, or from double to float, except where the source is a constant expression and the actual value after conversion is within the range of values that can be represented (even if it cannot be represented exactly).
— From an integer type or unscoped enumeration type to a floating-point type, except where the source is a constant expression and the actual value after conversion will fit into the target type and will produce the original value when converted to its original type.
— From an integer type or unscoped enumeration type to an integer type that cannot represent all the values of the original type, except where the source is a constant expression and the actual value after conversion will fit into the target type and will produce the original value when converted to its original type.
The following declarations trigger compiler errors because they require a narrowing conversion:
int i{ 1.2 }; // error
double d = 47 / 13;
float f1{ d }; // error, only warning in gcc
To fix this error, an explicit conversion must be done:
int i{ static_cast<int>(1.2) };
double d = 47 / 13;
float f1{ static_cast<float>(d) };
A brace-initialization list is not an expression and does not have a type. Therefore, decltype
cannot be used on a brace-init-list, and template type deductions cannot deduce the type that matches a brace-init-list.
Let’s consider one more example:
float f2{47/13}; // OK, f2=3
The preceding declaration is, despite the above, correct because an implicit conversion from int
to float
exists. The expression 47/13
is first evaluated to integer value 3
, which is then assigned to the variable f2
of the type float
.
There’s more...
The following example shows several examples of direct-list-initialization and copy-list-initialization. In C++11, the deduced type of all these expressions is std::initializer_list<int>
:
auto a = {42}; // std::initializer_list<int>
auto b {42}; // std::initializer_list<int>
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2}; // std::initializer_list<int>
C++17 has changed the rules for list initialization, differentiating between direct- and copy-list-initialization. The new rules for type deduction are as follows:
- For copy-list-initialization, auto deduction will deduce an
std::initializer_list<T>
if all the elements in the list have the same type, or be ill-formed. - For direct-list-initialization, auto deduction will deduce a
T
if the list has a single element, or be ill-formed if there is more than one element.
Based on these new rules, the previous examples would change as follows (the deduced type is mentioned in the comments):
auto a = {42}; // std::initializer_list<int>
auto b {42}; // int
auto c = {4, 2}; // std::initializer_list<int>
auto d {4, 2}; // error, too many
In this case, a
and c
are deduced as std::initializer_list<int>
, b
is deduced as an int
, and d
, which uses direct initialization and has more than one value in the brace-init-list, triggers a compiler error.
See also
- Using auto whenever possible, to understand how automatic type deduction works in C++
- Understanding the various forms of non-static member initialization, to learn how to best perform initialization of class members