C++ Initialization from Past to Present
If you keep your good ideas to yourself, they are useless; you could just as well have been doing crossword puzzles. Only by articulating your ideas and making them accessible through writing and talks do they become a contribution.
- Bjarne Stroustrup
Introduction
In C++, initialization is deceptively complex.
Many programmers treat it as “just setting a value,” but the reality is that different forms of initialization carry different semantics. These nuances affect when memory is set, which constructors are invoked, how default members are applied, and how aggregate types behave differently from class types.
In his CppCon 2023 talk, Ben Saks walks through the various initialization forms, the pitfalls, and how to think clearly about writing robust initialization code.
🎥 Watch the talk on YouTube
Given my desire to expand my C++ knowledge, I found this topic both practically important and conceptually rich. Here’s a breakdown of key takeaways from the talk, along with my own commentary and code snippets.
Why this matters
- Improper initialization can lead to undefined behaviour, wasted CPU cycles, or subtle runtime bugs.
- In safety-critical domains, deterministic and explicit initialization can prevent catastrophic failures.
- Understanding initialization helps you design cleaner types, prevent surprises, and write maintainable, predictable code.
Historical Forms of initialization
0. What we took from C
C brought us two main types:
- Scalars: Simple values like
int,char, or pointers. - Aggregates: Objects made of smaller pieces, like
structs and arrays.
// Scalars
int x = 1; // copy-initialization
int* px = &x; // copy-initialization
// Aggregates
struct Point { int x; int y; };
Point p1 = {1, 2}; // aggregate-initialization
int values[3] = {1, 2, 3}; // aggregate-initialization
Point p2 = p1; // copy-initialization
A key concept from C is zero-initialization. Objects declared at namespace scope (globally) or as static or thread_local are automatically zero-initialized if no other initializer is provided.
namespace home {
int ha[3]; // zero-initialized
}
static int i; // zero-initialized
int main() {
thread_local float f; // zero-initialized
}
Zero-initialization also occurs for any aggregate elements that are not explicitly initialized in a list.
struct Point { int x; int y; };
Point p1 = {1}; // p1 = {1, 0};
int values[3] = {1}; // values[3] = {1, 0, 0};
1. Aggregate-initialization
When C++ introduced classes, it needed to protect class invariants (the rules that keep an object valid). Allowing users to freely initialize all members (like in C) could break those rules.
This is why classes with any user-defined constructors are not considered aggregates and don’t allow aggregate-initialization.
class MyClass {
public:
int x;
MyClass() {/* User-defined constructor */};
};
MyClass obj = {1}; // compiler error, MyClass is not an aggregate
struct Point { int x; int y; };
Point p1 = {1, 2}; // aggregate-initialization okay
int values[3] = {1, 2, 3}; // aggregate-initialization okay
(Note: The rules for what constitutes an aggregate have been relaxed in C++17 and C++20, but the core idea—that constructors take precedence—remains.)
2. Direct-initialization
This is what most people think of as “calling a constructor.” It occurs when you provide arguments in parentheses ().
class MyClass {
public:
int x;
MyClass() : MyClass(255) // direct-initialization
{};
MyClass(int x);
MyClass(MyClass const &other);
MyClass &operator=(MyClass const &other);
};
MyClass obj1(2); // direct-initialization
MyClass obj2(obj1); // direct-initialization (invoking a copy constructor)
int x(2); // direct-initialization
3. Copy-initialization (vs assignment)
This form uses the equals sign =. It’s crucial to distinguish this from assignment.
MyClass obj3 = obj1; // copy-initialization (invokes copy constructor)
obj1 = obj3; // assignment (invokes operator=)
- Initialization (like
obj3) creates a new object and calls a constructor. - Assignment (like
obj1 = ...) overwrites an existing object and callsoperator=.
Copy-initialization also happens implicitly when you pass or return objects by value.
When you brace-initialize an aggregate, the aggregate itself is aggregate-initialized, but each individual member is copy-initialized.
struct Point { int x; int y; };
Point p1 = {1, 2}; // p1 is aggregate-initialized. x and y are copy-initialized.
4. explicit
What if you have a constructor you don’t want the compiler to call implicitly?
class MyClass {
public:
MyClass(int x);
// ...
};
void f(MyClass obj); // pass-by-value
f(1); // OK: Compiler implicitly calls MyClass(1)
In the code above, the compiler performs an implicit conversion from 1 to MyClass using copy-initialization. This can hide bugs. To prevent this, we mark the constructor explicit.
class MyClass {
public:
explicit MyClass(int x); // Prevent implicit conversions
// ...
};
void f(MyClass obj); // pass-by-value
f(1); // error!
5. Default-initialization
This is what happens when you declare an object with no initializer at all.
MyClass obj1; // default-initialization. Calls default constructor.
int x; // default-initialization. Indeterminate value!
For a class type (MyClass), this calls the default constructor. But for a built-in type (int), the value is indeterminate (i.e., garbage). This is a classic source of bugs.
And watch out for the “most vexing parse”:
MyClass obj1(); // Not default-intialization. Function declaration!
This doesn’t create an object. It declares a function named obj1 that returns a MyClass.
6. Value-initialization
Value-initialization uses empty parentheses (), most often seen with new.
MyClass::MyClass():
x() // value-initialize x
{}
MyClass *obj = new MyClass(); // value-initialization
What T() does depends on the type:
- Class with user-provided default ctor: Calls that constructor.
- Class without user-provided default ctor: Zero-initializes all fields, then calls the implicit default ctor.
- Array: Value-initializes every element.
- Scalar (like
int): Zero-initializes it.
This int() form is a key takeaway: it’s how you historically guaranteed a scalar is zeroed.
Modern Initialization
As you can see, that’s a lot of different rules. We had to consider scalars, aggregates, and classes, and no single syntax worked for all three.
This was an especially big problem for templates.
template <typename T>
void f(T const &t) {
int x = t; // narrowing conversion?
// ...
T obj1; // uninitialized 'int' or default-constructed 'class'?
// ...
T obj2 = T(); // is T copy constructible?
// ...
T obj3 = {1, 2, 3}; // Is T an aggregate?
// ...
}
7. Uniform-Initialization Syntax
C++11 introduced brace-initialization ({}) as a “uniform” syntax.
int a{5}; // scalar
int values[3]{1, 2, 3}; // aggregate
MyClass obj{1}; // class with constructor
This new syntax had two huge benefits:
- Safety: It disallows “narrowing conversions.”
int x{2.5};is a compile-time error, preventing data loss. - Consistency:
int c{};now reliably zero-initializesc. This solves the “uninitializedint” problem and the “most vexing parse” (int b();).
int a; // uninitialized
int b(); // function declaration
int c{}; // zero-initialized
For aggregates, any members not explicitly listed in the braces are also zero-initialized.
struct S { int x; int y = 10; };
S s1{3}; // x = 3, y = 10
S s2{}; // x = 0, y = 10 (Wait, what? See pitfalls)
int values[3]{1}; // {1, 0, 0}
This is especially useful for templates. T{} is the safe, modern way to get a “default” object.
template <typename T>
void f(T const &t) {
T obj {}; // Guaranteed to be initialized
// ...
}
8. Initializer Lists
Brace-initialization also introduced std::initializer_list, which allows constructors to accept a list of values.
class IntVector {
public:
IntVector();
IntVector(int const *data, std::size_t length);
IntVector(std::initializer_list<int> init);
// ...
};
IntVector v1 {1, 2, 3, 4}; // direct-list-initialization
IntVector v2 = {1, 2, 3, 4}; // copy-list-initialization
But this adds a new, very tricky rule:
The Gotcha: If a class has a std::initializer_list constructor, brace-initialization will strongly prefer it over other constructors.
This preference leads to one of the most famous “gotchas” in C++:
std::vector<int> v1 (10, 0); // Vector constructor: 10 elements, all set to 0
std::vector<int> v2 {10, 0}; // Initializer-list ctor: 2 elements, {10, 0}
Even empty braces are affected!
IntVector v3 {};
If v3{} is used and there’s no default constructor, the compiler will call the initializer_list constructor with an empty list!
Adding or removing an initializer_list constructor can silently change the behavior of existing code.
Common pitfalls
- Narrowing conversions:
int x = 2.5;is a silent data loss.int x{2.5};is a (good) compile error. -
Mixing defaults and braces:
struct X { int x = 1; int y = 2; }; X x1{}; // x=1, y=2 (default-member initializers are used) X x2{3}; // x=3, y=2 (x is overridden, y uses its default)(Note: This behavior with
s2{}in section 7 is a bit different.Xis not an aggregate because it has default initializers.Sis an aggregate, sos2{}does aggregate initialization, which zero-initializesxandybefore default initializers are considered. Confusing, right? This is why Ben’s talk exists!) - Uninitialized built-ins:
int x;in a function is garbage. Useint x{};. - Template traps:
T{}(value-init) andT()(value-init) are safer thanT t;(default-init) in templates. T{} is the most “uniform” choice. - The
std::vectortrap: Remembervector{10, 0}is not the same asvector(10, 0).
Reflections
- Determinism matters: In real-time or embedded systems, uninitialized memory can lead to non-reproducible behaviour.
- Consistency helps: Always knowing what
T{}does for your types simplifies reasoning about code. - Template safety: In generic libraries, prefer
T{}— it provides safer, more uniform behaviour across types. - Explicit design: Think about your type’s initialization policy when you write it. Don’t let it “just happen.”
Quick reference
// Built-in
int x = 0;
int y{}; // zero-initialized
int z; // indeterminate — AVOID
// Class with default member initializers
struct Config {
double sampleRate = 1000.0;
int bufferSize = 256;
bool enabled = true;
};
Config cfg1{}; // all defaults apply
Config cfg2{2000.0,512}; // overrides first two, enabled=true
// Aggregate vs non-aggregate
struct Point { int x; int y; };
Point p{10,20};
Point q{}; // x=0, y=0
class Widget {
int w, h;
public:
Widget(int w_, int h_) : w(w_), h(h_) {}
};
Widget w1(5,6); // OK
// Widget w2{}; // error: no default ctor
// Templated example
template<typename T>
T makeDefault() {
return T{}; // Safe, value- or brace-initializes T
}
Best practices
- Prefer brace initialization (
{}) for its safety and consistency. - Never leave built-in types uninitialized.
- Always use member initializer lists for class members.
- Give your classes/structs default member initializers if a “zeroed state” is meaningful.
- Be intentional — know what
T t{}vsT t()vsT t;does. - In templates, assume
Tcould be anything — choose the safest form (T{}). - Be careful with
std::initializer_listconstructors. They can be confusing for your users.
Conclusion
Initialization in C++ isn’t just boilerplate — it’s where correctness begins.
Ben Saks’ talk reminded me that good initialization is quiet, predictable, and intentional. If you write systems that need to be both fast and safe, understanding these subtle rules is one of the best investments you can make as a C++ developer.