std::initializer_list
, aggregate and designated initializationAt this point, we’re likely very familiar with initializing objects by specifying the type of object we want to construct, and a list of values to pass to a constructor defined as part of that type.
Typically, this looks something like this:
#include <iostream>
struct Vec3 {
// Constructor using a member initializer list
Vec3(int x, int y, int z)
: x{x}, y{y}, z{z}
{}
int x{0};
int y{0};
int z{0};
void Log(){
std::cout
<< "x=" << x
<< ", y=" << y
<< ", z=" << z << '\n';
}
};
int main(){
// Calling the constructor to create an object
Vec3 Vector{1, 2, 3};
Vector.Log();
}
x=1, y=2, z=3
This is referred to as list initialization, and the previous example is only one way to use it.
In this lesson, we’ll cover some of the other useful scenarios. We’ll also introduce other forms of list initialization, including aggregate initialization, and designated initialization.
When we want to construct a new object to assign to a variable, we don’t need to specify the type of the new object. After all, it will inherently have the same type as the variable we’re storing it in.
This is clearly true when the type can be initialized from a single value:
int Number { 1 };
// We can include the type during assignment...
Number = int{2};
// ...but we don't need to
Number = 3;
The same applies to any type. If we need multiple constructor arguments to create the new object we’re assigning to our variable, we just need to provide them as a list:
#include <iostream>
struct Vec3 {/*...*/};
int main(){
Vec3 Vector;
Vector = {1, 2, 3};
Vector.Log();
}
x=1, y=2, z=3
Similar shortcuts are available when constructing an object to return immediately. In scenarios like this, we need to provide a list of constructor arguments.
The compiler knows what type we’re constructing - it will be the return type of the function:
#include <iostream>
struct Vec3 {/*...*/};
Vec3 GetVector(){
return {1, 2, 3};
}
int main(){
GetVector().Log();
}
x=1, y=2, z=3
The same logic applies when constructing an object to pass to a function. We only need to pass the constructor arguments - the type can be inferred from the function’s parameter list:
#include <iostream>
struct Vec3 {/*...*/};
void HandleVector(Vec3 V){
V.Log();
}
int main(){
HandleVector({1, 2, 3});
}
x=1, y=2, z=3
As a final example, we can also provide a simple list of arguments to construct an object within the member initializer list of another constructor.
Below, our Character
constructor is constructing a Vec3
using some of its parameters. The constructor doesn’t need to specify the type it’s constructing - just the variable that will be used.
The compiler knows the variable - Position
, in this case, is a Vec3
- so it understands how to use the list:
#include <iostream>
struct Vec3 {/*...*/};
struct Character {
Character(
std::string Name, int x, int y, int z) :
Name{Name},
Position{x, y, z}
{}
std::string Name;
Vec3 Position;
};
int main(){
Character Player("Anna", 1, 2, 3);
std::cout << Player.Name << " Position: ";
Player.Position.Log();
}
Anna Position: x=1, y=2, z=3
We covered member initializer lists in more detail in the introductory course:
std::initializer_list
Sometimes, we need to store initializer lists as standalone objects. The std::initializer_list
template class is designed for this. It accepts a single template parameter, representing the type of values we’re storing in the list.
#include <iostream>
void Log(std::initializer_list<int> Numbers){
for (auto x : Numbers) {
std::cout << x << ", ";
}
}
int main(){
Log({1, 2, 3, 4, 5});
}
1, 2, 3, 4, 5,
Often, std::initializer_lists
are used in scenarios where we need to forward arguments from one constructor to another.
Below, we’ve laid the basic foundations of a custom container type. This container accepts a list of initial values, which allows it to be initialized using an API that will be familiar:
CustomContainer Numbers{1, 2, 3, 4, 5};
Below, our CustomContainer
type captures initialization values as a std::initializer_list
within a constructor. This is so the initial collection can be forwarded to a private std::vector
, which manages our type's underlying storage:
#include <iostream>
#include <vector>
template <typename T>
class CustomContainer {
public:
CustomContainer(
std::initializer_list<T> Contents) :
Container{Contents}{}
auto begin() const{
return Container.begin();
}
auto end() const{ return Container.end(); }
private:
std::vector<T> Container;
};
int main(){
CustomContainer Numbers{1, 2, 3, 4, 5};
for (int x : Numbers) {
std::cout << x << ", ";
}
}
1, 2, 3, 4, 5,
A std::initializer_list
is a lightweight, read-only wrapper. As such, it is fast to copy, so generally passed by value rather than by reference.
std::initializer_list
is also a homogenous container. That is, every value it contains must be the same type.
A container that can store a variety of value types is referred to as heterogenous. Standard library support for these types of containers is currently limited. The closest heterogeneous equivalent to a std::initializer_list
is std::tuple
, which we covered in an earlier lesson:
Previously, we’ve seen when we’re working with a simple type, we can initialize objects simply by providing values for the data members. Our Vec3
struct can be initialized from a list of 3 int
objects, even though it has no such constructor defined:
#include <iostream>
struct Vec3 {/*...*/};
int main(){
Vec3 V{1, 2, 3};
V.Log();
}
In this example, our Vec3
struct had declared public data members x
, y
, and z
- in that order, and we created an object using a list of 1
, 2
, and 3
- in that order.
Thus, our object is initialized with those values, and our program output is:
x=1, y=2, z=3
This is referred to as aggregate initialization. It only works with aggregates.
Aggregates are simple structs, classes, and unions that do not use some of the more advanced features. Specifically, as of C++23, for our type to be an aggregate, it can not have:
If our type meets this criterion, it is an aggregate, and its objects can be initialized using aggregate initialization.
As of C++20, we can initialize aggregates using designated initializers. Alongside each value, we provide the name of the field we’re initializing, prefixed by a period. Using designated initializers, our previous example would look like this:
#include <iostream>
struct Vec3 {/*...*/};
int main(){
Vec3 V{.x = 1, .y = 2, .z = 3};
V.Log();
}
x=1, y=2, z=3
Within a designated initializer, the fields do not need to be in the same order as defined within our type. The main advantage is they allow us to omit some of the fields from the list.
Below, we use a designated initializer to define values for x
and z
, omitting y
. Therefore, y
takes on the default value defined in the struct declaration:
#include <iostream>
struct Vec3 {/*...*/};
int main(){
Vec3 V{.x = 1, .z = 3};
V.Log();
}
x=1, y=0, z=3
The concept of designated initialization also existed in the C language, using a similar syntax. It was only added to C++ in the recent C++20Â spec.
Older resources that document designated initializers are likely to reference how they were used in C. The C++ implementation we've introduced here works slightly differently.
In this lesson, we explored various initialization techniques, including list, aggregate, and designated initialization.
Main Points Learned:
std::initializer_list
for capturing and forwarding lists of homogenous values.A quick guide to creating objects using lists, including std::initializer_list
, aggregate and designated initialization
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.