Skip to content

Module 3 — Concepts and Explanations

Read this with a compiler open. The subject is invisible by nature — construction and destruction produce no output unless you make them — so every section here ends with code that prints what is happening.


1. What is a constructor for?

A constructor is a member function with the class's name and no return type. It runs automatically when an object is created. It is not there for convenience. It is there for one job:

There is no way to create one of these that is not valid.

That is a design guarantee. Consider a Book that requires an identifier and a title:

class Book {
public:
    Book(int id, const std::string& title) : id_(id), title_(title) {
        if (id_ <= 0) {
            throw std::invalid_argument("an item needs a positive identifier");
        }
    }
private:
    int id_;
    std::string title_;
};

There is no Book with a zero identifier, because the only way to make a Book refuses to. Contrast this with what a beginner's version does:

class BookWorse {
public:
    int id_;
    std::string title_;      // both public, and both unvalidated
};

BookWorse book{}; produces an object with id_ == 0 and an empty title. Every function that later reads book.id_ has to ask whether it was ever set — and the answer is "look at the constructor", which is the thing that should have guaranteed it.

The construction sequence, always in this order:

# What happens
1 The base class is constructed (Module 6 — not yet relevant)
2 Data members are constructed, in declaration order
3 The constructor body runs

Steps 1 and 2 are the member initialiser list; step 3 is the body. Keeping them separate in your head is the whole of §4.


2. The default constructor, and how it disappears

A default constructor is any constructor you can call with no arguments. That includes one whose parameters all have defaults.

The rule that surprises everybody:

If you declare any constructor, the compiler generates no default constructor.

class Loan {
public:
    Loan(int daysAllowed) : daysAllowed_(daysAllowed) {}
private:
    int daysAllowed_;
};

Loan loan;      // error: no matching function for call to 'Loan::Loan()'

The compiler is being consistent: once you have taken over how this type comes into existence, it stops assuming. The old Tracked-style class with no constructor worked because the compiler generated everything; write one constructor and that stops.

Three ways forward, and they say different things:

Option Written What it means
Write a real default Loan() : daysAllowed_(14) {} You decide what an argument-less loan is
Ask for the compiler's Loan() = default; "Use the generated version" — which is only useful if the members themselves have defaults: int daysAllowed_ = 14;
Delegate Loan() : Loan(14) {} The default is the real constructor with a standard argument (see §6)

Pick exactly one — they are alternatives, and declaring two default constructors is a redefinition error. Which you pick is a design statement. = default is meaningful here only because of that int daysAllowed_ = 14; member initialiser; without one, the generated constructor would leave the member indeterminate, which is worse than having no default constructor at all.

= default is worth meeting properly. It says "I want the compiler-generated version here", and it is different from writing {} in two ways that matter: it keeps the constructor trivial where possible (which affects performance and whether the type can be a constexpr or a POD-like aggregate), and it keeps the type an aggregate if nothing else disqualifies it — so brace initialisation keeps working.

And the same keyword has an opposite:

class Session {
public:
    Session(const Session&) = delete;             // no copying, on purpose
};

= delete is how C++ says "this operation exists, and calling it is an error." It is better than the old trick of declaring the function private and not defining it, because the error message says what you meant:

error: use of deleted function 'Session::Session(const Session&)'

Two rules worth memorising now, because they cost lives later:

  • Loan() = default; is still not generated if a member has no default constructor. A defaulted constructor can be deleted implicitly — its first error message often names the member, not the constructor.
  • A member initialiser (int daysAllowed_ = 14;) makes the compiler's default constructor do something useful. This is why most classes in this course use them: the member is never uninitialised, whatever constructor ends up running.

3. Overloaded constructors, and explicit

Several constructors are fine, as long as each is callable in a distinct way:

class Loan {
public:
    Loan() : Loan("(unassigned)", 14) {}                       // delegates — §6
    explicit Loan(const std::string& member) : Loan(member, 14) {}
    explicit Loan(int daysAllowed) : Loan("(unassigned)", daysAllowed) {}
    Loan(const std::string& member, int daysAllowed) : member_(member), days_(daysAllowed) {}
private:
    std::string member_;
    int days_;
};

explicit matters more than it looks. Without it on a single-argument constructor, the compiler will use that constructor to convert:

Loan loan = "alice";        // ✗ without explicit: a Loan, built from a string
void take(Loan l);
take("alice");              // ✗ and now this silently compiles too

Those conversions are rarely what anybody intended, and they are how a function taking a Loan gets called with something that is not a loan. The rule for this course: mark single-argument constructors explicit unless you specifically want the conversion.

Redeclaring a constructor as explicit also stops copy-initialisation:

Loan one(14);      // ✓ direct initialisation
Loan two = 14;     // ✗ copy-initialisation, refused by `explicit`

A real trap, from this module's own programs. Deleting an overload to prevent a silly conversion can disable a good overload, because the compiler prefers a standard conversion (like const char* → bool) over a user-defined one (like const char* → std::string):

Loan(bool) = delete;              // looks sensible...
Loan loan("alice");               // ✗ error: use of deleted function 'Loan::Loan(bool)'

Loan(void*) = delete;             // ...but this is the version that works
Loan loan(nullptr);               // ✗ refused, which is what we wanted
Loan loan("alice");               // ✓ now reaches the std::string overload

examples/module-03/04-constructor-overloads/main.cpp contains both versions, with the error message from each.


4. The member initialiser list: not a style preference

class ByAssignment {
public:
    ByAssignment(int amount, int quantity) : amount_(0), quantity_(0) {
        amount_ = amount;              // assignment: the member was already built
        quantity_ = quantity;          // so this is the SECOND operation on it
    }
private:
    Slot amount_;
    Slot quantity_;
};

The initialiser list constructs the member with that value. The body assigns to a member that has already been default-constructed. Both produce a working object; they do not cost the same:

Initialiser list Assignment in the body
Operations on the member one construction a default construction plus an assignment
const members works impossible — cannot assign to a const member
Reference members works impossible — a reference cannot be reseated
Members with no default constructor works impossible — nothing to default-construct first
What the compiler says when you get it wrong — uninitialized const member, uninitialized reference member, no matching function for call to 'Slot::Slot()'

Those three messages are real, and all three are in Misconceptions and mistakes. The mechanism is worth testing rather than believing: examples/module-03/02-initialiser-lists/main.cpp prints both versions side by side, with a member type that announces every operation performed on it.

The habit: use the initialiser list for everything. A body that assigns to members is a body that has already done the work twice.

Initialisation order is declaration order — always

class Rectangle {
public:
    Rectangle(int width, int height)
        : width_(width), height_(height), area_(width_ * height_) {}
private:
    int width_;
    int height_;
    int area_;              // declared last, so initialised last — and so it may use
};                          // width_ and height_, which are already initialised

Members are initialised in the order they are declared, whatever order the list uses. A list written in a different order still compiles, and the compiler warns:

warning: 'Rectangle::height_' will be initialized after [-Wreorder]
warning:   'int Rectangle::width_' [-Wreorder]
warning:   when initialized here [-Wreorder]

The course treats that warning as an error, because the alternative is a member initialised from something that does not exist yet — which produces a garbage value, a 0, or undefined behaviour, depending on the day.

Why the language does this. Members are destroyed in reverse order of construction, and construction follows declaration order because it is the only order that puts every member's storage in a fixed, deterministic sequence — base first, then members, then the body, then the reverse for destruction. A list cannot override that, so it must not pretend to.


5. A destructor, and when you need one

class Tracked {
public:
    ~Tracked() {
        --live_;
        std::cout << "- " << name_ << '\n';
    }
private:
    std::string name_;
    static int live_;
};

A destructor is ~ClassName(). It takes no arguments and has no return type. It runs automatically when the object's lifetime ends — which for an ordinary local object means the closing brace of its scope.

The order is exactly the reverse of construction: the body's locals die in reverse order of creation, members die in reverse declaration order, and the object as a whole dies before the code that uses it can continue.

When does a class need a destructor? The honest rule, at this point in the course:

A class needs a destructor when it owns something that must be released — a resource whose lifetime is not already managed by one of its members.

Almost every class you write in this module needs no destructor at all. Book and Member hold ints and std::strings, and std::string already releases its own memory when it dies. Writing ~Book() {} adds nothing.

Where a destructor becomes essential is when a class holds a raw resource: a pointer from new, an open file, a lock, a socket. That is Modules 10 and 11, where the destructor stops being a curiosity and becomes the mechanism behind RAII — and it is the reason this module exists before them.

Two rules about destructors, both from later modules, both worth knowing now:

  • A destructor should not throw. If it does and the stack is already unwinding because of another exception, the program terminates. Module 13.
  • Unless you write one, the compiler generates it, and the generated one destroys each member. For members that manage themselves, that is correct — which is why most classes need nothing.

6. Delegating constructors

When several constructors share initialisation, one can call another:

class Ticket {
public:
    explicit Ticket(std::string code) : code_(std::move(code)) {}
    Ticket() : Ticket("UNASSIGNED") {}        // delegates: the default is a real default
private:
    const std::string code_;
};

Two details that surprise people, and this module's fourth program prints both:

  1. The target constructor runs first, and finishes. Then the delegating constructor's body runs. So the log reads "constructed …", then "…and the delegating body ran".
  2. A delegating constructor's initialiser list contains only that one delegation. It cannot also initialise members — they were already initialised by the constructor it delegated to. Roughly, : Ticket("UNASSIGNED"), x_(1) is an error.

And the one invariant delegating constructors must respect: if A delegates to B, B must not delegate back to A. The compiler catches the direct cycle.

Use delegation when the "default" is genuinely the same object with a standard argument — Ticket() is Ticket("UNASSIGNED"). Do not use it to hide a constructor that has four parameters while the delegating one has a completely different job; that is two types wearing one name.


7. Object lifetime: three kinds, and one of them is your problem

Storage Created Destroyed Seen so far
Automatic (locals, "on the stack") when its declaration is reached at the end of its scope, in reverse order yes — everything in this module
Static (static locals, globals) before main for globals; at the first execution for static locals at program exit, in reverse order of construction briefly, for static counters
Dynamic (new / delete, and containers) when you ask for it when you release it — or never Modules 10 and 11

Automatic objects are the default and the best choice. They cost nothing extra, they cannot leak, and their destruction is guaranteed by the language — including when a function returns early, and (from Module 13) when an exception unwinds the stack. When you can put an object on the stack, do.

Static objects are worth one caution now: a static member or local is constructed once and destroyed at program exit, so its destructor can run after objects its constructor depended on have already been destroyed. That is the static destruction order fiasco, and it has no fix other than not relying on it. Module 16 revisits it.

Dynamic objects are the ones you can get wrong. Everything in Modules 10 and 11 is about making their lifetime automatic anyway — by putting the pointer inside an object whose destructor releases it.

A local scope is a lifetime boundary, and this is the single most useful tool in the module:

{
    Tracked inner("inner");        // constructed here
    std::cout << inner.name();
}                                  // destroyed here — before the next statement runs

Braces are free. Using them to bound a lifetime — a lock, a file, a temporary calculation — is the habit that Modules 11 and 13 build on.


8. Temporary objects

A temporary is an unnamed object created during the evaluation of an expression:

Loan(3);                           // a temporary Loan
printSummary(Loan(3));             // created, used, destroyed — all in that one statement

How long does it live? Until the end of the full expression that created it — which is the whole statement, semicolon included. So in printSummary(Loan(3)), the temporary is alive for the entire call, and dead before the next line.

One extension, and it is genuinely useful:

const Loan& ref = Loan(3);         // the temporary's life is extended to `ref`'s
std::cout << ref.days();

Binding a temporary to a const T& (or an rvalue reference, T&&) extends its lifetime to that of the reference. This is legal, and it is the mechanism behind several patterns you will meet later. It is not an invitation to write such references casually — if the reference outlives its scope, the extension stops applying and you have a dangling reference.

Temporaries you did not write are still there. These all create one:

void consume(Tracked item);        // the by-value parameter: a copy, not a temporary
std::string s = "a" + "b";         // the concatenation result is a temporary
return Tracked("name");            // the return value — see below

The C++17 guarantee worth knowing. When a function returns a prvalue — an unnamed value like Tracked("name") — the object is constructed directly in the caller, and there is no temporary at all:

Tracked makeItem(const std::string& name) {
    return Tracked(name);          // no copy, no temporary, in C++17
}

const Tracked made = makeItem("book-three");   // ONE construction

That is why this course fixes -std=c++17 in every build command. Under C++14 the same program is allowed to create and destroy a temporary, and a lifecycle trace would show an extra pair of lines — which would make predictions depend on the compiler rather than on the language.


9. Stack objects, and when not to use them

"On the stack" means automatic storage duration: the object lives in the current function's frame, and its lifetime is determined by the scope it was declared in.

void f() {
    Book primer(1001, "C++ Primer");     // on the stack: dies at the end of f
}

Choose this by default, for three reasons:

  • Destruction is guaranteed. No path out of the function — early return, exception, loop break — skips it.
  • It is free. No allocation, no bookkeeping, no pointer.
  • It is local. A reader sees where it begins and where it ends without searching.

When you cannot use the stack:

Need Why the stack fails
Many objects, or a count known only at run time The number of declarations must be known when writing the code
An object that outlives the function that creates it Its storage is released when the function returns
An object whose type is decided at run time Module 7's polymorphic variables are handled through pointers or references
An object too large for the stack A few megabytes is a real limit; exceeding it is a crash, not an error message

All four are Module 10's and 11's subject. The point here is that "use the stack" is a default, not a law — and that each exception comes with a specific cost you should be able to name.


10. Initialisation is not assignment

Two operations look alike in one-line examples and behave completely differently:

Tracked a("first");
Tracked b = a;         // INITIALISATION — calls the copy constructor. b did not exist.
Tracked c(a);          // exactly the same thing, spelled differently

Tracked d("second");
d = a;                 // ASSIGNMENT — calls operator=. d already existed and is changed
Initialisation Assignment
When an object comes into existence an object already exists
Which function a constructor operator=
Can be refused yes — a constructor may not exist yes — operator= may be deleted
Example that fails Session s = "bob"; with explicit alice = other; when operator= is deleted

The distinction is not pedantry. Tracked b = a; does not create a Tracked and then copy into it — it creates b by copying a. The member initialiser list (§4) is the same idea one level down: amount_(amount) initialises; amount_ = amount; in the body assigns. Module 10 takes both apart when the member is a pointer, at which point the difference stops being academic and starts being a double free.


Checkpoint

Answer these before the worked examples. If an answer is not immediate, re-read the section beside it.

  1. In what order does an object get built? Name the three steps, and say which one the initialiser list is. (§1.)
  2. Predict: which of these compile, and which do not?
    class Loan {
    public:
        Loan(int days) : days_(days) {}
    private:
        int days_;
    };
    Loan a;          // (1)
    Loan b(14);      // (2)
    Loan c = 14;     // (3)
    
    (§2 and §3. One of the three is refused for a reason that has nothing to do with the default constructor — say which, and why.)
  3. Why must a const member be initialised in the initialiser list, and what does the compiler say if you assign it in the constructor body instead? (§4.)
  4. A class declares its members int width_; int height_; int area_; and its constructor writes : area_(width_ * height_), height_(height), width_(width). What is area_? Why? (§4 — and the compiler warns, which is the only reason this is not a silent bug.)
  5. A function returns Tracked("name") and the caller writes const Tracked made = makeItem("name");. How many constructions and how many destructions does that produce under C++17, and why is the answer different under C++14? (§8.)
  6. A local object is declared inside { } in the middle of a function, and the function later returns early. When is it destroyed? (§7.)
  7. Predict: for examples/module-03/01-lifecycle-trace/main.cpp, what does consumeByValue(second) add to the trace before the function returns? (§8 and §10 — then run it.)

Next: Worked examples — four programs, each with a prediction prompt. · All modules