CodeGym /Courses /C++ SELF /Initializer list: correctness, not style

Initializer list: correctness, not style

C++ SELF
Level 48, Lesson 3
Available

1. Introduction

If you are just starting out, it’s easy to think: “Well, in the constructor you can just write field_ = value; — and that’s it.” Sometimes you can. But that’s a dangerous simplification. The constructor runs when the object is still being born, and C++ has a strict order: members are created first, and only then the constructor body is executed. The initializer list (: field_(value)) is a way to create members already correct, rather than creating “whatever” and then moving values around with assignment.

Imagine you ordered a pizza. Initialization is when the pizza arrives already with cheese and toppings. Assignment is when you get an empty dough and say: “Oh, I’ll put everything on top myself now.” Sometimes that works, but why suffer and why are you paying for delivery at all?

Mini-diagram “what happens when an object is created”

flowchart TD
    A[You allocate storage for the object] --> B["Members (and base parts) are initialized"]
    B --> C[Constructor body is executed]
    C --> D[Object is ready to use]

The key point: members are initialized before the constructor body. Therefore the initializer list is not “syntactic sugar” but a mechanism that runs at the correct moment.

2. Syntax of the initializer list

When you first see a constructor with an initializer list, it looks like someone accidentally put a colon and commas. In fact it’s straightforward: after the constructor parameters comes a colon, followed by a list of how each member should be initialized. Each part field_(...) means: “create the member field_ right away with this value”. This is exactly creation of the member, not “assignment afterwards”.

Let’s start building our mini project: a simple model for a CLI todo tracker (something like the “smallest Todo” that does not promise you a new life on Mondays).

Example 1: correct Task constructor with an initializer list

#include <string>

class Task {
    int id_;
    std::string title_;
    bool done_;
public:
    Task(int id, const std::string& title)
        : id_(id)
        , title_(title)
        , done_(false)
    {}
};

It’s important to capture the idea: id_, title_, done_ are not “given values”, they are created already with the right values.

Later we will add methods and print the task, but first let’s reinforce: this is “correct style” not because it looks nicer, but because it is semantically correct.

3. Initialization vs assignment in the constructor

For simple types (int, bool) it often seems there is no difference. Indeed, in very simple cases the optimizer may “fold” the extra work. But as soon as members become more “alive” (for example, std::string, std::vector), the difference becomes practical: either you create the member already correct, or you create it “empty” first and then force it to rebuild.

Important point: if you write inside the constructor body title_ = title;, by that moment title_ is already somehow created. Usually as an empty string. So you do a second operation: assignment. That is two steps instead of one.

Example 2: “works, but not what we want”

#include <string>

class Task {
    int id_;
    std::string title_;
    bool done_;
public:
    Task(int id, const std::string& title) {
        id_ = id;           // assignment
        title_ = title;     // assignment (the string was already created)
        done_ = false;      // assignment
    }
};

This code compiles and will work. But you are essentially telling the compiler: “Create the object somehow, and then I will rebuild it from the inside.” For simple members it may not be critical. But later we will see cases where this is absolutely prohibited — and that’s where it becomes truly “correctness, not style”.

4. When the initializer list is mandatory: const and references

Here comes a moment when C++ will firmly say: “No.” And that is good, because the language protects you from the dangerous “assign later” model. Some members cannot be created “empty” and then assigned. Classic examples are const members and references.

The reason is simple: const means “this value cannot be changed after creation”. And a reference cannot be rebound to another object: it must refer to something from birth.

Example 3: const member — initializer list only

#include <string>

class Task {
    const int id_;
    std::string title_;
public:
    Task(int id, const std::string& title)
        : id_(id)        // must be here
        , title_(title)
    {}
};

If you try to write id_ = id; in the constructor body, the compiler will rightly complain: id_ cannot be assigned because it is const.

In the standard there is specific terminology about “member initializers” and that they apply to direct non-static members. Even if you are not going to read the standard like a novel before bed, it helps to know this is not a “suggestion” but a strict language rule.

Example 4: reference member

#include <string>

class TaskView {
    const std::string& title_ref_;
public:
    TaskView(const std::string& title)
        : title_ref_(title)
    {}
};

A quick warning: storing references as members is almost always “thin ice” because you must ensure the lifetime of the referred object. But as a teaching example it perfectly demonstrates the rule: a reference cannot be created empty and then assigned.

5. Constructor and invariants: a valid state from the start

So far we discussed mostly mechanics. Now — the meaning. When you design a class, its constructor is the place where you must ensure minimal correctness: the object should not be creatable in a “broken” state. The initializer list helps because it sets initial values exactly where they belong.

Imagine a rule in our application: a task cannot have a negative id. We can “normalize” input — for example, if a negative value arrives, make it 0. This is not ideal business logic, but it serves as an example of an invariant.

Example 5: normalization in the initializer list

#include <string>

class Task {
    int id_;
    std::string title_;
    bool done_;
public:
    Task(int id, const std::string& title)
        : id_(id < 0 ? 0 : id)
        , title_(title)
        , done_(false)
    {}
};

Here we explicitly show: the object cannot be born with id_ = -50. We don’t leave this to “someone else to check later”. This is the mature model: the constructor is the entry point to validity.

6. Default member initializer and the initializer list

While building the Task class you may have noticed that many members have obvious default values. For example, it makes sense for done_ to start as false. In C++ you can specify default values right in the member declaration. That makes constructors simpler and the class clearer: you literally read the members and immediately see the “normal state” of the object.

It’s important to understand priority: if a member is not listed in the initializer list, then the member’s default initializer (if present) is used. This avoids duplicating the same default in every constructor.

Example 6: default member initializers

#include <string>

class Task {
    int id_ = 0;
    std::string title_ = "untitled";
    bool done_ = false;
public:
    Task(int id, const std::string& title)
        : id_(id)
        , title_(title)
    {}
};

In this variant done_ is omitted from the initializer list because it already has a sensible default. Such code is easier to maintain: fewer repetitions and less chance to forget to initialize a member.

7. Small usage in main

So far we wrote classes “in a vacuum”. But a beginner’s brain needs to see that an object is really created, really lives, and methods actually work. Let’s add a small bit of functionality: read members via const methods and print the task. This continues the previous theme: encapsulation and a minimal interface.

The initializer list remains the star here: it makes Task always appear in a predictable state.

Example 7: Task with methods and printing

#include <iostream>
#include <string>

class Task {
    int id_;
    std::string title_;
    bool done_;
public:
    Task(int id, const std::string& title)
        : id_(id)
        , title_(title)
        , done_(false)
    {}

    int id() const { return id_; }
    bool done() const { return done_; }

    void mark_done() { done_ = true; }

    void print() const {
        std::cout << "#" << id_ << " " << title_
                  << (done_ ? " [done]" : " [todo]") << '\n';
        // e.g.: #1 Buy milk [todo]
    }
};

int main() {
    Task t{1, "Buy milk"};
    t.print();        // #1 Buy milk [todo]
    t.mark_done();
    t.print();        // #1 Buy milk [done]
}

Notice how calm the logic in main becomes: we create an object and it is immediately ready to use. This is a “thin main” in miniature: the more initialization logic is hidden in the constructor, the less clutter around it.

8. Useful nuances

Mem-initializer is a distinct part of the constructor

Beginners sometimes treat the initializer list as “part of the body” or “a place where you can also write regular code”. Think differently: a constructor has two parts — the “initialization” part and the “execution” part. The initializer list is the part that specifies how to create members. The body is the part that specifies what to do after creation.

In standard terminology you may see phrases about mem-initializer and identifiers of initialized members. We’re not studying the standard line by line, but it’s useful to know that the syntax is not accidental: it’s a separate language mechanism, not a “fancy formatting trick”.

Practical takeaway is simple: if you want to set starting values for members — do it in the initializer list (or using default member initializers). If you want to perform actions that must run after initialization (e.g., print a message, compute something, etc.) — that belongs in the constructor body.

Cheat sheet: when to use what

To avoid keeping everything in your head, it helps to fix the difference once in a small table. It doesn’t replace understanding, but it helps when you open the code a week later and wonder: “So… why is there a colon, and why can’t I just assign?”

What you do Where to write Why
Set initial values for members Initializer list : field_(...) or default member initializers That is the moment the member is actually created
Want to “change state later” In the constructor body (or via methods) using assignment This is working with an already-existing object
Member is const or a reference Only initializer list (or default member initializer) They cannot be initialized by later assignment
Need to guarantee an invariant at creation Initializer list (sometimes + checks in the body) The object must be born valid

Note: “default member initializers” and “initializer list” are not competitors. They usually work together: defaults express the common norm, and the constructor refines when the user provides parameters.

9. Common mistakes

Mistake #1: confusing initialization and assignment and writing everything in the constructor body.
At first this seems logical: “the constructor runs — so I can assign.” But members are already created by the time the constructor body runs. For int this often goes unnoticed, but for std::string and other complex types you get extra work. For const members and references you will get a compilation error.

Mistake #2: treating the initializer list as a “style” you can ignore.
This is one of the most harmful attitudes. The initializer list is not about code aesthetics or “programmer snobbery”. It exists because C++ has a strict object creation order, and some members can only be correctly set at creation time.

Mistake #3: trying to ensure object validity “from outside” instead of inside the constructor.
If a class allows creation of “broken” objects, you will inevitably get bugs: someone forgot to call a setter, order of calls was mixed up, an object was thrown into a container and left uninitialized. Constructor + initializer list let you ensure an object is sensible right after Task t{...};

Mistake #4: making members const/references and then being surprised they “won’t assign”.
This is not compiler caprice but language logic: const members and references must receive their value at initialization. Sometimes this forces you to design your data model more carefully — and that’s a benefit: the language gently hints that an object’s “life” begins in the constructor, not afterwards.

1
Task
C++ SELF, level 48, lesson 3
Locked
Cursor Coordinates
Cursor Coordinates
1
Task
C++ SELF, level 48, lesson 3
Locked
User Profile
User Profile
1
Task
C++ SELF, level 48, lesson 3
Locked
Normal range
Normal range
1
Task
C++ SELF, level 48, lesson 3
Locked
String preview
String preview
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION