Objects, Lifetime & RAII
A reservation that survives a return
A rehearsal studio has three rooms. A program reserves Room 2 for a recording session, checks the session's length, and either records or gives up early. In both cases the room must become free again. Here is the first version. It uses only what you already know: functions, a global counter and std::string.
#include <iostream>
#include <string>
int rooms_in_use = 0;
void reserve(const std::string& room) {
rooms_in_use = rooms_in_use + 1;
std::cout << "reserve " << room << "\n";
}
void release(const std::string& room) {
rooms_in_use = rooms_in_use - 1;
std::cout << "release " << room << "\n";
}
bool record(int minutes) {
reserve("Room 2");
if (minutes <= 0) {
return false; // Room 2 is never released
}
std::cout << "record " << minutes << " minutes\n";
release("Room 2");
return true;
}
int main() {
record(45);
std::cout << "in use: " << rooms_in_use << "\n";
record(0);
std::cout << "in use: " << rooms_in_use << "\n";
return rooms_in_use == 1 ? 0 : 1;
}
After the 45-minute session the program prints in use: 0. After the session of 0 minutes it prints in use: 1, although nobody is in the room. Nothing crashed. The next band simply waits for a room that is empty.
A note on the last line of main, because every program in this lesson ends the same way. A program that returns 0 from main reports success. Each program here returns 0 only if the numbers the lesson quotes are the numbers it computed, and the lesson's build runs every one of them. If a number in the text were wrong, the build would fail.
A resource is anything that comes with a duty attached: a file must be closed, a lock unlocked, a room released. The question of this module is who holds that duty, and how C++ lets you write it down once.
The cleanup that every branch must remember
The obvious repair is one more line: call release("Room 2") before return false. That fixes today's program. It also shows what is wrong with the design.
The duty to release now lives in every path out of record. With two ways out there are two release calls. Add a check for missing microphones and there are three. Each new return is a new chance to forget, and the person adding it may never have read the line that reserves the room. Releasing twice is no better than not releasing: by the time of the second release, another band might already hold the room.
The cost of this approach is one release call per exit, and all of them must agree. Being careful does not scale. What we want is to state the duty once, in a place that every exit passes through. C++ has such a place.
Construction begins a responsibility
So far your own types have been structs: a few values under one name. A class is the same thing with two additions. It can keep some members private, so that only its own functions can touch them, and it can have two special functions that C++ calls for you.
The constructor has the same name as the class and no return type. It runs when an object is created. The destructor is the class name with a ~ in front. It takes no arguments, and it runs when the object's lifetime ends. For a local variable, that moment is the closing } of the block it was declared in.
The next program has a class whose only job is to report those two moments.
#include <iostream>
#include <string>
#include <vector>
std::vector<std::string> events;
class Reservation {
public:
Reservation(const std::string& what) : what_(what) {
events.push_back("begin " + what_);
}
~Reservation() {
events.push_back("end " + what_);
}
private:
std::string what_;
};
void session() {
Reservation rehearsal("rehearsal");
{
Reservation mic("mic check");
}
Reservation upload("upload");
}
int main() {
session();
for (const std::string& line : events) {
std::cout << line << "\n";
}
const std::vector<std::string> expected = {
"begin rehearsal", "begin mic check",
"end mic check", "begin upload",
"end upload", "end rehearsal"};
return events == expected ? 0 : 1;
}
Three pieces of syntax are new. public: and private: mark which members the rest of the program may use. The part : what_(what) after the constructor's parameter list is the member initializer list: it gives each member its first value before the constructor's body runs. And nobody calls ~Reservation(). There is no such call anywhere in the program, yet three "end" lines appear.
Trace the scopes before running
Read session again and predict the six lines before looking at expected.
rehearsal is created first. The inner block creates mic, and its closing brace ends mic at once, before upload exists. At the final brace of session two objects are still alive, and they end in the reverse of the order they were created: upload, then rehearsal.
Reverse order is not an arbitrary rule. A later object may use an earlier one: an upload may need the rehearsal it uploads. Ending the newest first means nothing ever outlives something it depends on.
Predict: does an early return skip a local object's destructor?
No. Leaving a function with return ends the lifetime of every local object that has been created so far, newest first, exactly as the closing brace would. A return changes where the function stops. It does not change what has to end.
A trace like events shows what the program's own constructors and destructors reported. That makes it honest, and it also means it is not an independent witness: a class that forgets to report an event leaves a gap in the trace. So the programs here check a counter as well as a trace. They answer different questions.
The ownership invariant
Now put the two halves together. If the constructor reserves the room and the destructor releases it, the release is written once, and it runs at the one moment every exit shares: the end of the object's lifetime.
#include <iostream>
#include <string>
int rooms_in_use = 0;
class Reservation {
public:
Reservation(const std::string& room) : room_(room) {
rooms_in_use = rooms_in_use + 1;
std::cout << "reserve " << room_ << "\n";
}
~Reservation() {
rooms_in_use = rooms_in_use - 1;
std::cout << "release " << room_ << "\n";
}
private:
std::string room_;
};
bool record(int minutes) {
Reservation room("Room 2");
if (minutes <= 0) {
return false; // room is released here too
}
std::cout << "record " << minutes << " minutes\n";
return true;
}
int main() {
record(45);
record(0);
std::cout << "in use: " << rooms_in_use << "\n";
return rooms_in_use == 0 ? 0 : 1;
}
record no longer mentions release. It prints in use: 0 after both sessions. A third return added next month will be correct without its author knowing there was anything to get right.
This technique has an awkward traditional name, RAII, for "resource acquisition is initialization". Read it as: acquiring the resource is creating the object, so giving it back is the end of that object. The object is called the resource's owner.
One responsibility, one owner
Every reserved room has exactly one object responsible for releasing it. That object releases it once, when its lifetime ends. An object that holds no room releases nothing.
Why does the early return keep the invariant? Follow the three stages. The constructor establishes it: one reservation, one new owner. Nothing in the body of record releases the room or creates a second owner, so the body preserves it. And every normal way out of record ends the lifetime of room, which runs the destructor once; so at the end the room is free and has no owner. The argument never looked at which return was taken. That is the point.
Aside: what the guarantee does not cover
The argument is about normal ways out: reaching the closing brace, or return. A program stopped abruptly, for example by std::abort or by a browser killing a frozen tab, runs no destructors. C++ also has exceptions, a second way of leaving a function, which this course's browser compiler switches off. An exception that is caught runs the destructors of the local objects it passes, which is why C++ has no finally.
A copy is an object, not another name
Module 3 separated two things. A reference is another name for an existing object. A copy is a new object with its own lifetime, so it has its own destructor call.
For a struct of numbers that is harmless. For an owner it breaks the invariant. C++ copies a class member by member unless told otherwise, so a copied Reservation holds the same room name, and now two objects believe they must release Room 2.
#include <iostream>
#include <string>
int rooms_in_use = 0;
class Reservation {
public:
Reservation(const std::string& room) : room_(room) {
rooms_in_use = rooms_in_use + 1;
}
~Reservation() {
rooms_in_use = rooms_in_use - 1;
}
private:
std::string room_;
};
int main() {
{
Reservation first("Room 2");
Reservation second = first; // a copy
}
std::cout << "in use: " << rooms_in_use << "\n";
return rooms_in_use == -1 ? 0 : 1;
}
One room was reserved and two were released: the program prints in use: -1. Notice which function did not run for second: our constructor. A copy is made by a different constructor, the copy constructor, which C++ wrote for us, and that one reserves nothing.
There are three honest policies for copying an owner, and a class should pick one on purpose:
- copying is forbidden;
- a copy reserves a second room of its own;
- the type is designed for several owners to share one resource, and releases when the last one ends.
For a room reservation the first is right. C++ lets a class refuse an operation by declaring it and writing = delete. There are two ways to copy, so there are two lines:
Reservation(const Reservation&) = delete;refusesReservation b = a;- the same with
operator=refusesb = a;between existing objects.
After that, a copy is a compile error rather than a wrong count at run time. Passing a Reservation to a function by value is a copy as well, so it is refused too; passing it by const Reservation& still works, because a reference is not a second owner.
Moving transfers the obligation
Forbidding copies is too strict taken alone. A reservation made at the front desk must be handed to the session that will use it, and a std::vector of sessions has to relocate its elements when it grows. What these cases need is a move: afterwards the new object holds the duty and the old object holds none. The count of owners stays at one.
A class allows moving with a move constructor. Its parameter is written Reservation&& other. Read && here as "an object whose owner has agreed to give its contents away". You give that agreement by writing std::move(a). The name misleads: std::move moves nothing. It only marks a as available to be moved from, so that the move constructor is chosen.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
int rooms_in_use = 0;
int releases = 0;
class Reservation {
public:
Reservation(const std::string& room)
: room_(room), held_(true) {
rooms_in_use = rooms_in_use + 1;
}
~Reservation() {
if (held_) {
rooms_in_use = rooms_in_use - 1;
releases = releases + 1;
}
}
Reservation(const Reservation&) = delete;
Reservation& operator=(const Reservation&) = delete;
Reservation(Reservation&& other) noexcept
: room_(other.room_), held_(other.held_) {
other.held_ = false;
}
bool held() const { return held_; }
private:
std::string room_;
bool held_;
};
struct Session {
std::string band;
Reservation room;
};
int main() {
{
Reservation desk("Room 1");
Reservation stage = std::move(desk);
if (desk.held() || !stage.held()) return 1;
std::vector<Session> tonight;
tonight.push_back(Session{"Tide", Reservation("Room 2")});
tonight.push_back(Session{"Moss", Reservation("Room 3")});
if (rooms_in_use != 3) return 1;
}
std::cout << "in use: " << rooms_in_use
<< ", releases: " << releases << "\n";
return rooms_in_use == 0 && releases == 3 ? 0 : 1;
}
The move constructor does two things, and the second is the one people forget. It takes over what other held, and it marks other as holding nothing. The destructor must then respect that mark, which is why it now begins with if (held_). desk still exists after the move and its destructor still runs at the closing brace; it finds held_ false and releases nothing. This is the third sentence of the invariant at work.
noexcept is a promise that this function cannot fail. It matters for one reason in this module: when a vector grows it relocates its elements, and it moves them only if their move constructor makes that promise or if they cannot be copied at all. Otherwise it copies, to be safe.
Aside: what a moved-from object is
After a move the source must be safe to destroy. What else it promises is decided by its type. Our Reservation promises held() is false. Standard types promise less than you might guess: a moved-from std::string is valid, but its contents are not something to rely on.
Let members do the work
Look at Session in the program above. It has no constructor, no destructor, no = delete and no move constructor, and yet the program releases exactly three rooms for three reservations, including the two that travelled into the vector.
It works because C++ builds those functions for a struct out of its members' versions. Destroying a Session destroys its room, which releases. Moving a Session moves its band and its room. Copying a Session would need to copy its room, which is forbidden, so Session cannot be copied either. The policy written once in Reservation became the policy of everything that contains one.
This is the Rule of Zero: give ownership to small types whose only job is to own, and let every larger type declare none of the five functions this lesson has met (destructor, copy constructor, copy assignment, move constructor, move assignment). std::string and std::vector, which you have used since Module 4, are owners of exactly this kind. Each owns memory and releases it in its destructor, which is why you have never had to.
Predict: you add an empty destructor, ~Session() {}, to the struct. What changes?
The program stops compiling. Declaring a destructor, even an empty one, tells C++ that the type manages something by hand, and C++ then no longer writes the move constructor for it. A Session cannot be copied, and now cannot be moved, so push_back has no way to put one into the vector. A function that looks harmless switched off a function you were relying on.
Aside: when you do write one, write the set
A class that needs a hand-written destructor almost always needs a decision about all five functions. Move assignment (a = std::move(b) between existing objects) is the hardest: a may already own something, which must be released first. That is a good reason to keep hand-written owners few and small.
Count obligations, not milliseconds
How long a destructor takes depends on the machine. What can be counted exactly is duties. In this module's model the events are acquire, transfer and release, and the claim is: if a program acquires resources and all their owners end normally, it performs exactly releases. A transfer changes who owns; it never changes .
The last program checks that claim with . It acquires three rooms. It then moves reservations several times: from desk to stage, into the vector at each push_back, and again whenever the vector grows and relocates what it holds. It finishes with releases == 3. Every one of those moves left behind an object whose destructor ran and released nothing.
| Design | Release calls written | Releases at run time |
|---|---|---|
| by hand, exits | , kept in step by hand | only if no exit was missed |
| owner object | 1, in the destructor |
Moving a Reservation costs a constant number of member writes, however long the session it stands for. The release itself costs whatever the resource costs. Destroying a vector of a thousand sessions runs a thousand destructors. An owner makes cleanup certain; it does not make it free.
A problem that looks different
A print shop takes jobs at a front desk and passes each one to one of two printers. Every accepted job must end in exactly one way: printed or cancelled. While a job waits it sits in the desk's queue; once a printer takes it, the desk must no longer be able to cancel it. At closing time some jobs are still in the queue and one is half printed.
Nothing here is called a room or a reservation. Which object is responsible for a job at each moment? What should the desk's variable hold after the hand-over? Should a job be copyable, and what would a copy mean to the customer? What should happen to the queue's jobs when the queue itself ends? This lesson leaves the answers to you.
Practise
The lab has five exercises, with other objects than rooms. You trace constructors and destructors on a timeline and watch each lifetime as a bar; tell a copy from a reference in the memory panel; repair a cleanup that an early return skips; and build an owner that can be moved but not copied, then place it in a larger type that needs no code. The final challenge hands you a program with a fault and does not say which idea applies. You plan in writing before you code.
Recap
You can now: predict when each local object's lifetime ends, give a resource a single owner, and choose whether that owner may be copied or moved.
Invariant: every acquired resource has exactly one owner, which releases it once when its lifetime ends; an owner that was moved from releases nothing.
Complexity achieved: acquisitions, exactly releases, with one release call in the source instead of one per exit; a move is a constant number of member writes.
Failure mode: cleanup written by hand misses an early return; a copied owner releases twice; an unnecessary destructor silently removes a type's move.
In real software: the standard library's std::fstream closes its file in its destructor, and std::lock_guard unlocks its mutex in its destructor. A struct built from such members needs no cleanup code of its own.
Retrieval: from Module 3, why does a const T& parameter see the caller's object while a by-value parameter gets an object of its own? Which of the two would compile for a Reservation that forbids copying?
Check yourself
- In
session, a fourth reservation is added inside the inner block, aftermic. Write the eight trace lines in order. - A class reserves in its constructor and releases in its destructor, and its author is sure the destructor is correct. Give a three-line
mainthat still releases twice. - The move constructor in the last program loses its line
other.held_ = false;. What goes wrong at the closing brace, and which sentence of the invariant is broken?
Original SciMigo course material. C++ examples target C++17.