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 calls operator=.

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:

  1. Safety: It disallows “narrowing conversions.” int x{2.5}; is a compile-time error, preventing data loss.
  2. Consistency: int c{}; now reliably zero-initializes c. This solves the “uninitialized int” 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. X is not an aggregate because it has default initializers. S is an aggregate, so s2{} does aggregate initialization, which zero-initializes x and y before default initializers are considered. Confusing, right? This is why Ben’s talk exists!)

  • Uninitialized built-ins: int x; in a function is garbage. Use int x{};.
  • Template traps: T{} (value-init) and T() (value-init) are safer than T t; (default-init) in templates. T{} is the most “uniform” choice.
  • The std::vector trap: Remember vector{10, 0} is not the same as vector(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{} vs T t() vs T t; does.
  • In templates, assume T could be anything — choose the safest form (T{}).
  • Be careful with std::initializer_list constructors. 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.


📚 Further Reading