Part V — Learning by Doing¶
Work each exercise cold: compiler, debugger, sanitizer, and offline docs as your only feedback loops, opening a chapter's reference solution only after your own attempt. The repository's exercises/ directory carries a task card for every exercise (plus the vendor code for Chapters 17 and 18), so you can attempt each one without the solution on the next screen.
Chapter 14 — Exercise: The Lifetime Tracer¶
One small class makes every lifetime rule in this book visible: a Tracer that prints from every special member function it has — destructor, copy and move constructors, copy and move assignment — plus the ordinary constructor that names it, each stamped with an instance ID and its own address. Build it once, keep it forever — it is a diagnostic instrument, not a toy. When container or call behavior is mysterious, drop a Tracer in and the output replaces guesswork. It needs no tools a locked-down work machine lacks.
The complete instrument¶
// Tracer v2 - makes object identity, lifetime, and moves maximally visible.
// Build: clang++ -std=c++17 -Wall -Wextra tracer.cpp -o tracer
// or: cl /std:c++17 /W4 /EHsc tracer.cpp
#include <cstdio> // std::snprintf
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Tracer {
public:
explicit Tracer(std::string n)
: name_(std::move(n)), id_(++counter_)
{
++alive_;
Log("constructed");
}
~Tracer() {
--alive_;
Log("destroyed");
}
// ---- copy: duplicates, source untouched -------------------------------
Tracer(const Tracer& other)
: name_(other.name_), id_(++counter_)
{
++alive_;
Log("copy-CONSTRUCTED from " + other.Label());
}
Tracer& operator=(const Tracer& other) {
// std::string::operator= releases our old buffer internally.
// With a raw resource, WE would have to release it here. (Finding 4)
name_ = other.name_;
Log("copy-ASSIGNED from " + other.Label());
return *this;
}
// ---- move: steals, source becomes a husk ------------------------------
Tracer(Tracer&& other) noexcept
: name_(std::move(other.name_)), id_(++counter_) // steal FIRST (Finding 1)
{
++alive_;
Log("move-CONSTRUCTED, gutting " + other.MarkHusk());
}
Tracer& operator=(Tracer&& other) noexcept {
if (this != &other) { // self-move guard (Finding 5)
name_ = std::move(other.name_); // steal
Log("move-ASSIGNED, gutting " + other.MarkHusk());
}
return *this;
}
// ---- bookkeeping ------------------------------------------------------
static void Report() {
std::cout << "---- " << alive_ << " object(s) still alive, "
<< counter_ << " ever created ----\n";
}
private:
// "name#id @address" - id distinguishes objects sharing a name;
// address proves identity (RVO: 'temp' and 'd' print the SAME address).
std::string Label() const {
return name_ + "#" + std::to_string(id_) + " @" + Addr();
}
std::string Addr() const {
char buf[32];
std::snprintf(buf, sizeof buf, "%p", static_cast<const void*>(this));
return buf;
}
std::string MarkHusk() {
std::string was = Label(); // capture identity BEFORE overwriting
name_ = "husk"; // tracing sugar only - real moved-from
return was; // strings are simply left empty
}
void Log(const std::string& what) const {
std::cout << Label() << " " << what << '\n';
}
std::string name_;
int id_;
inline static int counter_ = 0; // total ever created
inline static int alive_ = 0; // live right now (leak detector)
};
// ---------------------------------------------------------------------------
Tracer MakeTracer() {
Tracer t("temp");
return t; // watch: NO copy, NO move (RVO)
}
void ByValue(Tracer t) { (void)t; }
void ByRef(const Tracer& t) { (void)t; }
int main() {
std::cout << "--- singles ---\n";
Tracer a("a");
Tracer b = a; // copy: b is a NEW object (new id)
Tracer c = std::move(a); // move: a becomes a husk
std::cout << "--- calls ---\n";
ByValue(b); // copy in, destroyed at return
ByRef(b); // SILENCE - binds directly, no object
ByValue(std::move(b)); // move in - b gutted
std::cout << "--- RVO ---\n";
Tracer d = MakeTracer(); // one construction; compare the
// address with d's destructor line!
std::cout << "--- vector ---\n";
std::vector<Tracer> v;
v.push_back(Tracer("v1")); // temp constructed, moved in, temp dies
v.push_back(Tracer("v2")); // + REALLOCATION: v1 moves to new block
// (delete noexcept above -> it COPIES)
Tracer::Report(); // everything from main still alive
std::cout << "--- teardown (reverse order per scope) ---\n";
return 0;
} // vector first (its elements), then d, c, b, a
// After main, statics persist; a final Report() via atexit would show 0 alive.
Design choices worth stealing¶
- Instance IDs separate identity from name. Copies share a name; the
#idmakes each object unambiguous:a#2 copy-CONSTRUCTED from a#1. - Addresses turn claims into proofs. Same address across "constructed" and "destroyed" lines = same object. Stack addresses vs heap addresses are visibly different ranges.
- Moves steal in the initializer list —
name_(std::move(other.name_))— then log. Note the move constructor prints its ownname_, because by the time the body runs the theft has already happened. - Husk marking (
t.name_ = "husk") is tracing sugar: it makes the teardown roll-call show which objects ended life gutted. Real moved-from strings are simply left empty. - The sink-parameter constructor —
explicit Tracer(std::string n) : name_(std::move(n))— takes by value and moves in: one overload optimally handles both copies and moves (Chapter 6). inline staticcounters (C++17) — static members defined in-class, no separate .cpp definition needed; the modern fix for the Chapter 4 annoyance.- The alive counter is a leak detector: every construction increments, every destruction decrements; a nonzero count at exit means RAII bookkeeping is broken somewhere.
Annotated output of a real run¶
--- singles ---
a#1 @0x7fff...d90 constructed
a#2 @0x7fff...dc0 copy-CONSTRUCTED from a#1 @0x7fff...d90
a#3 @0x7fff...df0 move-CONSTRUCTED, gutting #1 @0x7fff...d90
b = a created a new object (#2) at a new address. std::move(a) let #3 steal from #1 — note the gutted label prints an empty name: honestly stolen before printing. a (#1) still exists as a husk; it gets a normal destructor at the very end.
--- calls ---
a#4 ... copy-CONSTRUCTED from a#2 ...
a#4 ... destroyed
a#5 ... move-CONSTRUCTED, gutting #2 ...
a#5 ... destroyed
ByValue(b): a copy lives exactly for the call. ByRef(b) printed nothing at all — const T& binds directly, no object created; that silence is why it is the default parameter idiom. ByValue(std::move(b)): same function, but permission was granted, so the parameter moved — and b is now a husk.
That is the entire output of Tracer d = MakeTracer() on clang and GCC. One construction; no copy, no move. The compiler built "temp" directly in d's memory — the local, the return value, and d are one object (compare this address with d's destructor line at teardown: identical).
This is copy elision, and it is worth knowing which flavour, because not every build above produces it. MakeTracer returns a named local, which makes this NRVO — the optional kind. What C++17 made mandatory is narrower: elision is guaranteed only when the returned operand is a temporary outright, return Tracer("temp");. clang and GCC perform NRVO anyway, even at -O0. MSVC does not unless asked: /Zc:nrvo is off by default and switched on by /O2, /permissive-, or /std:c++20 — none of which the cl /std:c++17 /W4 /EHsc line above passes. Build it that way and an extra move-construction appears here, and every #id after it shifts by one. (To see that output on any compiler, add -fno-elide-constructors.) Either way the cost is one move at worst, which is why returning objects by value is the right default in modern C++ — but "free" is a property of your compiler and flags, not a promise of the language.
--- vector ---
v1#7 @0x7fff...e50 constructed <- temporary, on the STACK
v1#8 @0x5622...2c0 move-CONSTRUCTED... <- moved into the vector: HEAP address!
husk#7 ... destroyed <- temporary husk dies
v2#9 ... constructed
v2#10 @0x5622...398 move-CONSTRUCTED...
v1#11 @0x5622...370 move-CONSTRUCTED, gutting #8 @0x5622...2c0 <- REALLOCATION
husk#8 ... destroyed
husk#9 ... destroyed
The address ranges expose stack vs heap directly (Chapter 3). And the second push_back triggered reallocation: v1 was picked up and move-constructed again at a new heap address — you can watch the vector carry its contents to a bigger block. Delete noexcept from the move constructor and this line becomes copy-CONSTRUCTED: the vector falls back to copying when the move might throw (Chapter 6, Finding 3 of Chapter 25).
---- 6 object(s) still alive, 11 ever created ----
--- teardown (reverse order per scope) ---
v1#11 destroyed v2#10 destroyed <- vector's elements first
temp#6 destroyed <- d (which IS temp: RVO)
a#3 destroyed
husk#2 destroyed husk#1 destroyed <- the gutted b and a, normal destruction
Eleven constructions, eleven destructions — balanced books, no leaks. Destruction runs in reverse construction order within each scope — a guarantee for the named locals; the order of the two element lines inside the vector is the standard library's business (libstdc++ destroys front-to-back as shown here, libc++ on a Mac back-to-front, and the standard blesses both). The husks in the roll-call are visual proof of which objects were genuinely emptied.
Four experiments to run¶
- Delete
noexceptfrom the move constructor. The reallocation line flips from move to copy — vector's exception-safety rule, observed live. - Add
v.reserve(4);before the push_backs. The entire reallocation block vanishes — no growth, no transfer. - Add
Tracer x("x"); x = x;— self-copy-assignment. The copy assignment here has no self-check and survives only becausestd::string::operator=tolerates it. Ask yourself what happens when the member is a raw pointer: that question is the doorway to the Buffer worked example in Chapter 15. - Make
aconst —const Tracer a("a");— and watch the third line of the singles section.Tracer c = std::move(a);now printscopy-CONSTRUCTED, andanever becomes a husk: the cast asked for a steal, the const forbade it, and the copy constructor answered without a word from the compiler. Chapter 6's value-category table is the reason, and the trap it names second.