C++ Module 23 – Design, idioms and maintainable C++
C++ idioms: scope guards and pimpl
Write a C++ scope guard that rolls back half-finished work, move private members behind a pointer with pimpl, and avoid the incomplete-type trap.
What you will learn
- Write a scope guard that rolls back finished steps when a later one fails
- Choose between a guard that always runs and one that runs only on failure
- Separate a class's private members from its header with the pimpl idiom
- Avoid the incomplete-type error of a std::unique_ptr pimpl
- Measure what pimpl saves in preprocessed lines and changes in object size
Before you start
What you’ll learn · Before you start
What you will learn
- Write a scope guard that rolls back finished steps when a later one fails
- Choose between a guard that always runs and one that runs only on failure
- Separate a class's private members from its header with the pimpl idiom
- Avoid the incomplete-type error of a std::unique_ptr pimpl
- Measure what pimpl saves in preprocessed lines and changes in object size
Before you start
On this page
Booking a seat takes three steps here: hold seat B1-23, charge 845 rupees, record the ticket. When the third step
fails, the first two must be undone, newest first, on every path out of the function. A
scope guard: An object that runs a callable, usually a lambda, from its destructor, so that cleanup or an undo step runs on every way out of a scope: the closing brace, a return or stack unwinding. A release() call switches it off once the work has succeeded. is a small object that runs a piece of code when it goes out of scope:
at the closing brace, on a return, or while an exception passes through. Each undo is then written once, next to
the step it undoes.
pimpl idiom: Pointer to implementation: a class keeps its private data in a separate type that its header only declares and reaches it through a pointer, usually a std::unique_ptr, so the data can change without changing the header. More in “std::unique_ptr: exclusive ownership”, short for “pointer to implementation”, fixes a problem of the same family:
a class’s private members leak into every file that includes its header. It moves them into a struct that only the
class’s .cpp file defines, which here cut a header from 44,273 preprocessed lines to 19,360 and made each object
one pointer. Both idioms rest on a destructor that runs on every way out of a scope, and every number below was
measured with Apple clang 21 and GCC 16.
A scope guard runs cleanup on every way out
ScopeExit stores a callable and calls it from its destructor, unless release() was called first. In book(),
each step that succeeds is followed by a guard that would undo it, and the last lines switch every guard off:
C++ · booking.cpp
// booking.cpp: book a seat in three steps; scope guards undo the finished steps when a later one fails.
#include "scope_exit.h"
#include <print>
#include <set>
#include <stdexcept>
#include <string>
#include <vector>
struct Train {
std::set<std::string> free_seats{"B1-21", "B1-22", "B1-23"};
void hold(const std::string& seat) {
if (!free_seats.erase(seat)) throw std::runtime_error("seat " + seat + " is taken");
std::println(" held {}", seat);
}
void unhold(const std::string& seat) {
free_seats.insert(seat);
std::println(" undo: freed {}", seat);
}
};
struct Wallet {
int balance = 0;
void charge(int rupees) {
if (rupees > balance) throw std::runtime_error("balance too low");
balance -= rupees;
std::println(" charged {}, balance {}", rupees, balance);
}
void refund(int rupees) {
balance += rupees;
std::println(" undo: refunded {}, balance {}", rupees, balance);
}
};
struct Ledger {
std::vector<std::string> tickets;
bool full = false;
void record(const std::string& ticket) {
if (full) throw std::runtime_error("ledger is full");
tickets.push_back(ticket);
std::println(" recorded {}", ticket);
}
};
void book(Train& train, Wallet& wallet, Ledger& ledger, const std::string& seat, int fare) {
train.hold(seat);
ScopeExit undo_hold{[&] { train.unhold(seat); }};
wallet.charge(fare);
ScopeExit undo_charge{[&] { wallet.refund(fare); }};
ledger.record(seat + " for " + std::to_string(fare));
// Every step succeeded: commit by switching the undo steps off.
undo_charge.release();
undo_hold.release();
}
void attempt(const char* label, Train& train, Wallet& wallet, Ledger& ledger, const std::string& seat, int fare) {
std::println("{}:", label);
try {
book(train, wallet, ledger, seat, fare);
std::println(" booked");
} catch (const std::exception& e) {
std::println(" failed: {}", e.what());
}
std::println(" free seats {}, balance {}, tickets {}", train.free_seats.size(), wallet.balance, ledger.tickets.size());
}
int main() {
Train train;
Wallet wallet{1200};
Ledger ledger;
attempt("1. enough money", train, wallet, ledger, "B1-21", 845);
attempt("2. too little money", train, wallet, ledger, "B1-22", 845);
ledger.full = true;
wallet.balance = 2000;
attempt("3. the last step fails", train, wallet, ledger, "B1-23", 845);
} C header · scope_exit.h
// scope_exit.h: a small scope guard. It runs a callable when it goes out of scope, unless release() was called.
#pragma once
#include <type_traits>
#include <utility>
template <class F>
class ScopeExit {
public:
// [[nodiscard]] on the constructor: both compilers then warn about a guard that is never named.
[[nodiscard]] explicit ScopeExit(F f) noexcept(std::is_nothrow_move_constructible_v<F>)
: f_(std::move(f)) {}
ScopeExit(const ScopeExit&) = delete; // one guard, one cleanup
ScopeExit& operator=(const ScopeExit&) = delete;
~ScopeExit() { // noexcept, like every destructor
if (active_) f_();
}
void release() noexcept { active_ = false; } // the commit point: the cleanup will not run
private:
F f_;
bool active_ = true;
}; from session import run
# Three bookings, with both compilers: one succeeds, one fails at the second step, one at the last.
run("""
clang++ -std=c++23 -Wall -Wextra booking.cpp -o booking && ./booking
g++-16 -std=c++23 -Wall -Wextra booking.cpp -o booking-gcc && ./booking-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra booking.cpp -o booking && ./booking 1. enough money: held B1-21 charged 845, balance 355 recorded B1-21 for 845 booked free seats 2, balance 355, tickets 1 2. too little money: held B1-22 undo: freed B1-22 failed: balance too low free seats 2, balance 355, tickets 1 3. the last step fails: held B1-23 charged 845, balance 1155 undo: refunded 845, balance 2000 undo: freed B1-23 failed: ledger is full free seats 2, balance 2000, tickets 1 $ g++-16 -std=c++23 -Wall -Wextra booking.cpp -o booking-gcc && ./booking-gcc 1. enough money: held B1-21 charged 845, balance 355 recorded B1-21 for 845 booked free seats 2, balance 355, tickets 1 2. too little money: held B1-22 undo: freed B1-22 failed: balance too low free seats 2, balance 355, tickets 1 3. the last step fails: held B1-23 charged 845, balance 1155 undo: refunded 845, balance 2000 undo: freed B1-23 failed: ledger is full free seats 2, balance 2000, tickets 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 booking_session.py
In the first booking every step succeeds, release() switches both guards off, and nothing is undone. In the
second, charge throws because the fare of 845 rupees is more than the balance of 355. At that moment only
undo_hold exists, so leaving book frees seat B1-22 and touches nothing else. In the third, the ledger throws
after the seat was held and the fare charged. The stack unwinding: What happens while a thrown exception travels to its catch block: every local object constructed since the try block began, and not yet destroyed, is destroyed in reverse order before the handler runs. More in “Destructors and RAII in C++”, the two guards
are destroyed in the reverse order of their creation, and the refund runs before the seat is freed. Both compilers
print the same lines. Step through the third booking and watch the order in which the guards run:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Before the call. Seats B1-22 and B1-23 are free, the wallet holds 2000 and the ledger holds 1 ticket. The ledger has been set to full, so the third step of this booking will throw.
- Step 1, then its guard. hold() takes seat B1-23. Only now, once the step has succeeded, the next line creates undo_hold, whose lambda would free the seat again.
- Step 2, then its guard. charge(845) takes the fare: the balance falls from 2000 to 1155. The next line creates undo_charge, whose lambda would refund the 845.
- Step 3 throws. record() throws std::runtime_error("ledger is full"). The two release() calls at the end of book() never run, so both guards are still active as the exception leaves the function.
- The newest guard runs first. Stack unwinding destroys the locals of book() in the reverse order of their creation. undo_charge was created last, so it runs first: it refunds 845 and the balance is 2000 again.
- Then the older one. undo_hold is destroyed next and frees seat B1-23. The undo steps ran in the reverse order of the steps, refund before release, which is the order that undoes them cleanly.
- The caller catches it. attempt() catches the exception and prints "failed: ledger is full", then "free seats 2, balance 2000, tickets 1": exactly the state before the call, on both compilers.
The undo steps ran in the reverse order of the steps. Three details make the guard safe to use. The undo code sits
right after the step it undoes, so the two cannot drift apart as the function changes. The lambdas capture by
reference, which is safe here because each guard is destroyed before the parameters and earlier locals that its
lambda refers to. And the cleanup runs inside a destructor, so it must not throw: an exception leaving refund while
another one unwinds the stack would end the program through std::terminate, as the
destructors lesson shows. The C++ Core Guidelines name this tool finally and
recommend it as a last resort, for cleanup that has no proper resource class (rule E.19).
Key idea
Create each guard right after the step it undoes, and call release() once every step has succeeded: the guards
then undo exactly the finished steps, the newest first.
Checkpoint
Check: A scope guard and its release()
A guard can tell failure from success by counting exceptions
A guard that waits for release() reacts to every way out, including a return false that reports an error. When a
function reports its failures with exceptions, a guard can tell success from failure on its own. The function
std::uncaught_exceptions() (C++17) returns how many exceptions are propagating in the current thread. ScopeFail
saves that number when it is created and runs its callable only if the number is higher when it is destroyed, which
means a new exception is leaving its scope. ScopeSuccess does the opposite:
// scope_fail.cpp: guards that run only when their scope is left by an exception, or only when it is not.
#include <exception>
#include <print>
#include <stdexcept>
#include <utility>
#if __has_include(<experimental/scope>)
#include <experimental/scope>
#endif
// Counting exceptions, not asking "is one in flight?", lets these guards work inside destructors during unwinding.
template <class F>
class ScopeFail {
public:
[[nodiscard]] explicit ScopeFail(F f) : f_(std::move(f)) {}
ScopeFail(const ScopeFail&) = delete;
ScopeFail& operator=(const ScopeFail&) = delete;
~ScopeFail() {
if (std::uncaught_exceptions() > in_flight_) f_(); // a new exception is leaving this scope
}
private:
F f_;
int in_flight_ = std::uncaught_exceptions();
};
template <class F>
class ScopeSuccess {
public:
[[nodiscard]] explicit ScopeSuccess(F f) : f_(std::move(f)) {}
ScopeSuccess(const ScopeSuccess&) = delete;
ScopeSuccess& operator=(const ScopeSuccess&) = delete;
~ScopeSuccess() {
if (std::uncaught_exceptions() <= in_flight_) f_(); // left normally
}
private:
F f_;
int in_flight_ = std::uncaught_exceptions();
};
void save(const char* what, bool fail) {
ScopeFail rollback{[&] { std::println(" {}: rolled back", what); }};
ScopeSuccess commit{[&] { std::println(" {}: committed", what); }};
if (fail) throw std::runtime_error("disk full");
}
// Saves while an exception is already passing through: the destructor of an object on an unwinding stack.
struct SaveOnExit {
~SaveOnExit() {
std::println(" exceptions in flight: {}", std::uncaught_exceptions());
save("save from a destructor during unwinding", false);
}
};
int main() {
std::println("our guards:");
save("save that works", false);
try {
save("save that throws", true);
} catch (const std::exception& e) {
std::println(" caught: {}", e.what());
}
try {
SaveOnExit later;
throw std::runtime_error("network down");
} catch (const std::exception& e) {
std::println(" caught: {}", e.what());
}
#if __has_include(<experimental/scope>)
std::println("this library has <experimental/scope>:");
try {
std::experimental::scope_fail undo{[] { std::println(" scope_fail ran"); }};
std::experimental::scope_success done{[] { std::println(" scope_success ran"); }};
throw std::runtime_error("failed");
} catch (const std::exception&) {
}
#else
std::println("this library has no <experimental/scope>");
#endif
} from session import run
# Guards that react to exceptions, with both compilers and their standard libraries.
run("""
clang++ -std=c++23 -Wall -Wextra scope_fail.cpp -o scope_fail && ./scope_fail
g++-16 -std=c++23 -Wall -Wextra scope_fail.cpp -o scope_fail-gcc && ./scope_fail-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra scope_fail.cpp -o scope_fail && ./scope_fail our guards: save that works: committed save that throws: rolled back caught: disk full exceptions in flight: 1 save from a destructor during unwinding: committed caught: network down this library has no <experimental/scope> $ g++-16 -std=c++23 -Wall -Wextra scope_fail.cpp -o scope_fail-gcc && ./scope_fail-gcc our guards: save that works: committed save that throws: rolled back caught: disk full exceptions in flight: 1 save from a destructor during unwinding: committed caught: network down this library has <experimental/scope>: scope_fail ran
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scope_fail_session.py
The third call shows why the guards compare two counts instead of asking whether any exception is in flight.
SaveOnExit’s destructor runs while “network down” unwinds the stack, so one exception is in flight during the whole
of save, and yet this save succeeds and is committed. Step through that call and watch the count:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- No exception yet. The try block creates SaveOnExit later. Nothing is being thrown, so std::uncaught_exceptions() returns 0.
- network down is thrown. throw std::runtime_error("network down") starts the stack unwinding: the count of exceptions in flight in this thread becomes 1, and the locals of the try block are destroyed.
- The destructor runs. ~SaveOnExit() runs during the unwinding and prints "exceptions in flight: 1". Any code it calls sees that one exception.
- Both guards save 1. save() creates its two guards. Each one stores what std::uncaught_exceptions() returns at that moment: 1, the exception that is already passing through.
- save() returns normally. fail is false, so save() returns without throwing. When its guards are destroyed, the count is still 1: no new exception is leaving this scope.
- ScopeSuccess commits. commit was made last, so it is destroyed first. It runs because 1 is not more than the 1 it saved, and prints "save from a destructor during unwinding: committed".
- ScopeFail stays quiet. rollback runs its lambda only when the count is higher than the one it saved, and 1 is not higher than 1. A guard that asked "is any exception in flight?" would have rolled back this successful save.
- The handler runs. The unwinding ends at the catch block, which prints "caught: network down". The save made during the unwinding stays committed.
The older std::uncaught_exception(), singular, only answered yes or no, so it could not tell those cases apart. It
was deprecated in C++17 and removed in C++20, and N4152, the paper that added the counting function, names scope
guards as the use it serves. The guards themselves are still outside the C++ standard: the Library Fundamentals
Technical Specification, version 3, defines std::experimental::scope_exit, scope_fail and scope_success in the
header <experimental/scope>, from paper P0052R10, and C++26 does not include them. The last lines of each run show
that GCC 16’s standard library ships that header and Apple clang’s libc++ does not, so portable code still carries a
short guard of its own, like the ones above. The editions, one at a time:
- C++98
std::uncaught_exception() answers yes or no
It returns true while stack unwinding is in progress in the thread. A guard made inside a destructor during unwinding sees true even when its own scope succeeds, so it cannot tell the two apart.
- C++17
std::uncaught_exceptions() counts them the count
The new function returns how many exceptions are uncaught in the thread. Paper N4152, which added it, names scope guards as its use: save the count when the guard is made, compare when it is destroyed. The singular function is deprecated.
- C++20
The yes-or-no function is removed
std::uncaught_exception() is gone from the standard library, so a guard written for C++20 or later uses the counting function.
- LFTS v3
scope_exit, scope_fail and scope_success experimental
Version 3 of the Library Fundamentals Technical Specification defines the three guards in <experimental/scope>, from paper P0052R10. GCC 16's library ships the header; Apple clang's libc++ does not.
- C++26
Still no scope guard in the standard
The three guards did not move into C++26, so portable code keeps a short guard of its own, like the ScopeExit, ScopeFail and ScopeSuccess of this lesson.
The timeline has 5 events, from C++98 to C++26.
Checkpoint
Check: Guards that count exceptions
Two mistakes that a compiler may not flag
Both programs below compile, and both do something their author did not mean:
C++ (unnamed_guard.cpp) · unnamed_guard.cpp
// unnamed_guard.cpp: a guard without a name is a temporary, so its cleanup runs at the end of the line.
#include "scope_exit.h"
#include <print>
#include <utility>
// The same guard with [[nodiscard]] on the class instead of on its constructor.
template <class F>
class [[nodiscard]] ClassOnlyGuard {
public:
explicit ClassOnlyGuard(F f) : f_(std::move(f)) {}
ClassOnlyGuard(const ClassOnlyGuard&) = delete;
ClassOnlyGuard& operator=(const ClassOnlyGuard&) = delete;
~ClassOnlyGuard() { f_(); }
private:
F f_;
};
int main() {
std::println("open");
ScopeExit{[] { std::println("close (ScopeExit)"); }}; // no name: destroyed right here
ClassOnlyGuard{[] { std::println("close (ClassOnlyGuard)"); }};
std::println("work that needed it open");
} C++ (return_value.cpp) · return_value.cpp
// return_value.cpp: a guard that changes the variable a function returns.
#include "scope_exit.h"
#include <print>
#include <string>
std::string one_return() {
std::string text = "total: 42";
ScopeExit finish{[&] { text += " (end)"; }};
return text;
}
std::string two_returns(bool empty) {
std::string text = "total: 42";
ScopeExit finish{[&] { text += " (end)"; }};
if (empty) return {};
return text;
}
int main() {
std::println("one return: [{}]", one_return());
std::println("two returns: [{}]", two_returns(false));
} from session import run
# An unnamed guard, then a guard that changes the returned variable, with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra unnamed_guard.cpp -o unnamed && ./unnamed
g++-16 -std=c++23 -Wall -Wextra unnamed_guard.cpp -o unnamed-gcc && ./unnamed-gcc
clang++ -std=c++23 -Wall -Wextra return_value.cpp -o return_value && ./return_value
g++-16 -std=c++23 -Wall -Wextra return_value.cpp -o return_value-gcc && ./return_value-gcc
# The same program without the optional copy elision:
clang++ -std=c++23 -Wall -Wextra -fno-elide-constructors return_value.cpp -o return_value-copy && ./return_value-copy
""") Output
$ clang++ -std=c++23 -Wall -Wextra unnamed_guard.cpp -o unnamed && ./unnamed
unnamed_guard.cpp:20:5: warning: ignoring temporary created by a constructor declared with 'nodiscard' attribute [-Wunused-value]
20 | ScopeExit{[] { std::println("close (ScopeExit)"); }}; // no name: destroyed right here
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
unnamed_guard.cpp:21:5: warning: ignoring temporary of type 'ClassOnlyGuard<(lambda at unnamed_guard.cpp:21:20)>' declared with 'nodiscard' attribute [-Wunused-value]
21 | ClassOnlyGuard{[] { std::println("close (ClassOnlyGuard)"); }};
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2 warnings generated.
open
close (ScopeExit)
close (ClassOnlyGuard)
work that needed it open
$ g++-16 -std=c++23 -Wall -Wextra unnamed_guard.cpp -o unnamed-gcc && ./unnamed-gcc
unnamed_guard.cpp: In function 'int main()':
unnamed_guard.cpp:20:56: warning: ignoring return value of 'ScopeExit<F>::ScopeExit(F) [with F = main()::<lambda()>]', declared with attribute 'nodiscard' [-Wunused-result]
20 | ScopeExit{[] { std::println("close (ScopeExit)"); }}; // no name: destroyed right here
| ^
In file included from unnamed_guard.cpp:2:
scope_exit.h:10:28: note: declared here
10 | [[nodiscard]] explicit ScopeExit(F f) noexcept(std::is_nothrow_move_constructible_v<F>)
| ^~~~~~~~~
open
close (ScopeExit)
close (ClassOnlyGuard)
work that needed it open
$ clang++ -std=c++23 -Wall -Wextra return_value.cpp -o return_value && ./return_value
one return: [total: 42 (end)]
two returns: [total: 42]
$ g++-16 -std=c++23 -Wall -Wextra return_value.cpp -o return_value-gcc && ./return_value-gcc
one return: [total: 42 (end)]
two returns: [total: 42]
# The same program without the optional copy elision:
$ clang++ -std=c++23 -Wall -Wextra -fno-elide-constructors return_value.cpp -o return_value-copy && ./return_value-copy
one return: [total: 42]
two returns: [total: 42]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mistakes_session.py
A guard without a name. ScopeExit{…}; creates a temporary object: An unnamed object made while an expression is evaluated, such as the std::string that city + ' Central' produces. It is destroyed at the end of the full expression that made it, unless a reference bound to it keeps it alive. More in “std::string and std::string_view”, and a temporary
is destroyed at the end of its full-expression: An expression that is not part of a larger one, usually a whole statement or the initialiser of a declaration. The temporaries made while it is evaluated are destroyed at its end, unless lifetime extension keeps one alive. More in “C++ references: lifetime extension and dangling”, so “close” prints before the work
it was meant to follow. Step through the first program’s main, with what each compiler said about it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first line. main() prints "open". The next line creates a guard whose cleanup prints "close", meant to run after the work.
- A guard with no name. ScopeExit{…}; names no variable, so it creates a temporary. A temporary is destroyed at the end of its full-expression, the semicolon on the same line, so its cleanup runs at once.
- The same slip again. ClassOnlyGuard{…}; is a temporary too, and prints "close (ClassOnlyGuard)" straight away. Both cleanups have run before any work was done.
- The work comes last. The line that needed the guard's scope prints "work that needed it open" after both closes: the order the author meant is reversed.
- What Clang said. Apple clang 21 warned about both lines: "ignoring temporary created by a constructor declared with 'nodiscard' attribute" and "ignoring temporary of type 'ClassOnlyGuard…' declared with 'nodiscard' attribute".
- What GCC said. GCC 16 warned only about ScopeExit, whose constructor carries [[nodiscard]], and stayed silent about ClassOnlyGuard, where only the class does. Put the attribute on the constructor.
The C++ standard recommends a warning whenever an object of a [[nodiscard]]: An attribute for a function whose result must not be dropped: a call that ignores the result, such as start.add_days(30); on its own, makes the compiler warn, because a function that only returns a value did nothing useful. More in “Designing value types and strong types in C++” class, or
one built by a [[nodiscard]] constructor, is discarded like this; its own example is an unnamed scope guard. Clang
warned about both guards. GCC 16 warned only about ScopeExit, whose constructor carries the attribute, and stayed
silent about ClassOnlyGuard, where only the class does. Put the attribute on the constructor.
A guard that changes the returned variable. Both functions append “ (end)” to text from a guard, but only
one_return returns the longer text. With a single return text;, both compilers build text directly in the
caller’s result, an optional copy elision: Building an object directly in its final place instead of making it elsewhere and copying it. Since C++17 it is guaranteed when a function returns a temporary of its return type. More in “C++ pass by value, reference or const reference” known as the
named return value optimisation: Copy elision for return of a named local variable: the compiler builds the local directly in the caller's variable. The standard allows it but does not require it; -fno-elide-constructors turns it off. More in “C++ pass by value, reference or const reference”, so the guard’s change, which runs
after the return statement, lands in the result. two_returns may return two different objects, so the compiler
moves text into the result first, and the guard then changes a local that nobody reads again. Step through both
functions:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two functions, one guard each. Both functions set text to "total: 42" and create a guard whose lambda appends " (end)" to text when the function's scope ends, after its return statement.
- One return statement. one_return has a single return text;, so both compilers build text directly in the caller's result. This optional copy elision is the named return value optimisation: text and the result are one object.
- Two return statements. two_returns may return {} or text, two different objects, so the compiler cannot build text in the result. At return text; it moves text into the result: the result holds "total: 42" and text is left behind.
- The guards run. Now each scope ends and each guard appends " (end)" to text. In one_return, text is the result, so the change lands in what the caller gets: "total: 42 (end)".
- A change nobody reads. In two_returns the guard changes the local text, which is destroyed right after. The caller prints "total: 42", without the change.
- Without the elision. With -fno-elide-constructors, Clang copies or moves text into the result of one_return too, and both functions return "total: 42". A guard must never change the variable its function returns.
With -fno-elide-constructors, which turns the optional elision off, Clang returns the shorter text from both
functions, so what the caller receives depends on a choice the compiler is allowed to make.
Key idea
Give every guard a name, put [[nodiscard]] on its constructor, and never let a guard change the variable its
function returns.
Checkpoint
Check: Unnamed guards and returned variables
Pimpl: a header that stops changing
A class’s private members are private to other code, not to the compiler. Every file that includes the header needs their types, to work out the object’s size and where each member sits in it, so the header must include whatever those types need. When a private member changes, every one of those files is compiled again, and code that was compiled against the old layout no longer matches the new one. That layout is part of the class’s ABI (application binary interface): What compiled code relies on: the sizes and layouts of types, the symbols and the calling conventions. A new version of a library keeps its ABI when programs built against the old version still work with it. More in “Static and shared libraries in C++”, and rule I.27 of the C++ Core Guidelines suggests pimpl for libraries that must keep their ABI stable.
ContactBook stores phone numbers in a std::map and checks each one with a std::regex. Its pimpl version keeps the
same public functions, but replaces both members with one pointer to Impl, a struct that the header only declares:
What each file of a pimpl class sees
Text description of the diagram
The diagram has two parts, one above the other.
The upper part, "When the program is compiled", shows three boxes joined by arrows. 1. Client files: client.cpp and every other file that uses a ContactBook. An arrow labelled "#include" leads to the header. 2. contact_book.h: the public member functions, the declaration struct Impl; without a definition, and the member std::unique_ptr<Impl> impl_. An arrow labelled "Impl is defined only here" leads to the source file. 3. contact_book.cpp: it includes <map> and <regex>, and it defines struct Impl and every member function.
The lower part, "When the program runs", shows two boxes. 1. A ContactBook object, which is one 8-byte pointer. An arrow labelled "impl_ points to" leads to the second box. 2. Its Impl, on the heap, which holds the map of phone numbers and the regex.
before/contact_book.h · book/before/contact_book.h
// before/contact_book.h: every private member is visible, so every client includes what the members need.
#pragma once
#include <cstddef>
#include <functional>
#include <map>
#include <optional>
#include <regex>
#include <string>
#include <string_view>
class ContactBook {
public:
bool add(std::string name, std::string phone); // false for a bad number or a known name
std::optional<std::string> phone_of(std::string_view name) const;
std::size_t size() const;
private:
std::map<std::string, std::string, std::less<>> phones_;
std::regex ten_digits_{R"(\d{10})"};
}; after/contact_book.h · book/after/contact_book.h
// after/contact_book.h: the same public interface; the members live in Impl, which only contact_book.cpp defines.
#pragma once
#include <cstddef>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
class ContactBook {
public:
ContactBook();
~ContactBook(); // declared here, defined where Impl is complete
ContactBook(ContactBook&&) noexcept;
ContactBook& operator=(ContactBook&&) noexcept;
bool add(std::string name, std::string phone); // false for a bad number or a known name
std::optional<std::string> phone_of(std::string_view name) const;
std::size_t size() const;
private:
struct Impl; // declared, not defined
std::unique_ptr<Impl> impl_;
}; after/contact_book.cpp · book/after/contact_book.cpp
#include "contact_book.h"
#include <functional>
#include <map>
#include <regex>
struct ContactBook::Impl {
std::map<std::string, std::string, std::less<>> phones;
std::regex ten_digits{R"(\d{10})"};
};
ContactBook::ContactBook() : impl_(std::make_unique<Impl>()) {}
ContactBook::~ContactBook() = default; // Impl is complete here
ContactBook::ContactBook(ContactBook&&) noexcept = default;
ContactBook& ContactBook::operator=(ContactBook&&) noexcept = default;
bool ContactBook::add(std::string name, std::string phone) {
if (!std::regex_match(phone, impl_->ten_digits)) return false;
return impl_->phones.emplace(std::move(name), std::move(phone)).second;
}
std::optional<std::string> ContactBook::phone_of(std::string_view name) const {
if (auto it = impl_->phones.find(name); it != impl_->phones.end()) return it->second;
return std::nullopt;
}
std::size_t ContactBook::size() const { return impl_->phones.size(); } book/client.cpp
// client.cpp: uses only the public interface, so it compiles unchanged against before/ and after/.
#include "contact_book.h"
#include <print>
int main() {
ContactBook book;
std::println("add Asha: {}", book.add("Asha", "9876543210"));
std::println("add Ravi: {}", book.add("Ravi", "98765"));
std::println("add Asha again: {}", book.add("Asha", "9123456780"));
std::println("Asha's number: {}", book.phone_of("Asha").value_or("none"));
std::println("contacts: {}, sizeof(ContactBook): {} bytes", book.size(), sizeof(ContactBook));
} from session import run
# The same client, built against the header-heavy class and against its pimpl version, with both compilers;
# then the lines of source the compiler reads for the header alone, after preprocessing.
run("""
clang++ -std=c++23 -Wall -Wextra -Ibook/before book/client.cpp book/before/contact_book.cpp -o before && ./before
clang++ -std=c++23 -Wall -Wextra -Ibook/after book/client.cpp book/after/contact_book.cpp -o after && ./after
g++-16 -std=c++23 -Wall -Wextra -Ibook/before book/client.cpp book/before/contact_book.cpp -o before-gcc && ./before-gcc
g++-16 -std=c++23 -Wall -Wextra -Ibook/after book/client.cpp book/after/contact_book.cpp -o after-gcc && ./after-gcc
clang++ -std=c++23 -E -P -Ibook/before book/just_the_header.cpp | wc -l
clang++ -std=c++23 -E -P -Ibook/after book/just_the_header.cpp | wc -l
g++-16 -std=c++23 -E -P -Ibook/before book/just_the_header.cpp | wc -l
g++-16 -std=c++23 -E -P -Ibook/after book/just_the_header.cpp | wc -l
""") Output
$ clang++ -std=c++23 -Wall -Wextra -Ibook/before book/client.cpp book/before/contact_book.cpp -o before && ./before add Asha: true add Ravi: false add Asha again: false Asha's number: 9876543210 contacts: 1, sizeof(ContactBook): 88 bytes $ clang++ -std=c++23 -Wall -Wextra -Ibook/after book/client.cpp book/after/contact_book.cpp -o after && ./after add Asha: true add Ravi: false add Asha again: false Asha's number: 9876543210 contacts: 1, sizeof(ContactBook): 8 bytes $ g++-16 -std=c++23 -Wall -Wextra -Ibook/before book/client.cpp book/before/contact_book.cpp -o before-gcc && ./before-gcc add Asha: true add Ravi: false add Asha again: false Asha's number: 9876543210 contacts: 1, sizeof(ContactBook): 80 bytes $ g++-16 -std=c++23 -Wall -Wextra -Ibook/after book/client.cpp book/after/contact_book.cpp -o after-gcc && ./after-gcc add Asha: true add Ravi: false add Asha again: false Asha's number: 9876543210 contacts: 1, sizeof(ContactBook): 8 bytes $ clang++ -std=c++23 -E -P -Ibook/before book/just_the_header.cpp | wc -l 44273 $ clang++ -std=c++23 -E -P -Ibook/after book/just_the_header.cpp | wc -l 19360 $ g++-16 -std=c++23 -E -P -Ibook/before book/just_the_header.cpp | wc -l 78071 $ g++-16 -std=c++23 -E -P -Ibook/after book/just_the_header.cpp | wc -l 40056
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pimpl_session.py
client.cpp compiled unchanged against both headers and printed the same results, because it uses only the public
functions. Two numbers changed, and the table puts them side by side:
| What the session measured | Members in the header | Pimpl |
|---|---|---|
sizeof(ContactBook) with libc++ |
88 bytes | 8 bytes |
sizeof(ContactBook) with libstdc++ |
80 bytes | 8 bytes |
| The header after preprocessing, Apple clang 21 | 44,273 lines | 19,360 lines |
| The header after preprocessing, GCC 16 | 78,071 lines | 40,056 lines |
The object shrank to the size of one pointer, and it stays 8 bytes whatever Impl comes to hold, so adding a member
no longer changes the size that clients have compiled in. The header lost about half its lines, because <map>,
<regex> and <functional> moved into contact_book.cpp. In their place it includes <memory>, for
std::unique_ptr, and it keeps <string>, because its functions still take std::string. Every file that includes
the header is spared that work on every build.
Checkpoint
Check: What pimpl moves out of the header
A pimpl class defines its destructor in the source file
Pimpl with std::unique_ptr has one well-known trap. trap/contact_book.h is the pimpl header without its destructor
and move operations, which looks tidier:
// trap/contact_book.h: pimpl without declaring the destructor. Every file that destroys a ContactBook fails.
#pragma once
#include <cstddef>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
class ContactBook {
public:
ContactBook();
bool add(std::string name, std::string phone);
std::optional<std::string> phone_of(std::string_view name) const;
std::size_t size() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
}; from session import run
# The client compiled against the pimpl header that does not declare a destructor, with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra -Ibook/trap -c book/client.cpp -o client.o
g++-16 -std=c++23 -Wall -Wextra -Ibook/trap -c book/client.cpp -o client-gcc.o
""") Output
$ clang++ -std=c++23 -Wall -Wextra -Ibook/trap -c book/client.cpp -o client.o
In file included from book/client.cpp:2:
In file included from book/trap/contact_book.h:4:
In file included from …/c++/v1/memory:950:
In file included from …/c++/v1/__memory/inout_ptr.h:16:
In file included from …/c++/v1/__memory/shared_ptr.h:36:
…/c++/v1/__memory/unique_ptr.h:73:19: error: invalid application of 'sizeof' to an incomplete type 'ContactBook::Impl'
73 | static_assert(sizeof(_Tp) >= 0, "cannot delete an incomplete type");
| ^~~~~~~~~~~
…/c++/v1/__memory/unique_ptr.h:291:7: note: in instantiation of member function 'std::default_delete<ContactBook::Impl>::operator()' requested here
291 | __deleter_(__tmp);
| ^
…/c++/v1/__memory/unique_ptr.h:257:71: note: in instantiation of member function 'std::unique_ptr<ContactBook::Impl>::reset' requested here
257 | _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX23 ~unique_ptr() { reset(); }
| ^
book/trap/contact_book.h:9:7: note: in instantiation of member function 'std::unique_ptr<ContactBook::Impl>::~unique_ptr' requested here
9 | class ContactBook {
| ^
book/client.cpp:6:17: note: in implicit destructor for 'ContactBook' first required here
6 | ContactBook book;
| ^
book/trap/contact_book.h:17:12: note: forward declaration of 'ContactBook::Impl'
17 | struct Impl;
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -Ibook/trap -c book/client.cpp -o client-gcc.o
In file included from …/c++/16/memory:80,
from book/trap/contact_book.h:4,
from book/client.cpp:2:
…/c++/16/bits/unique_ptr.h: In instantiation of 'constexpr void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = ContactBook::Impl]':
…/c++/16/bits/unique_ptr.h:408:17: required from 'constexpr std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = ContactBook::Impl; _Dp = std::default_delete<ContactBook::Impl>]'
408 | get_deleter()(std::move(__ptr));
| ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~
book/trap/contact_book.h:9:7: required from here
9 | class ContactBook {
| ^~~~~~~~~~~
…/c++/16/bits/unique_ptr.h:90:23: error: invalid application of 'sizeof' to incomplete type 'ContactBook::Impl'
90 | static_assert(sizeof(_Tp)>0,
| ^~~~~~~~~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trap_session.py
A class that declares no destructor gets one from the compiler, and that destructor is defined wherever it is first
needed: here in client.cpp, as Clang’s note “in implicit destructor for ‘ContactBook’ first required here” says. It
destroys impl_, and the default deleter of std::unique_ptr must see the complete Impl to delete it. The
standard mandates a complete type there, both libraries check it with a static_assert, and in client.cpp the type
is only declared, an incomplete type: A class that is declared but not yet defined at a point in the code, such as struct Impl; in a pimpl header. There its size and members are unknown, so an object of it cannot be created, copied or deleted., so both compilers stop. Step through what the
compiler writes for client.cpp, then the fix:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A ContactBook in main. client.cpp creates ContactBook book; in main, so the compiler needs a destructor for it at the closing brace of main.
- None is declared. The trap header declares no destructor, so the compiler writes the implicit one where it is first needed: here, in client.cpp. Clang's note says it: "in implicit destructor for 'ContactBook' first required here".
- It destroys impl_. The implicit destructor destroys the member impl_, so std::unique_ptr<Impl>'s destructor is compiled in client.cpp as well, and it deletes the Impl through std::default_delete<Impl>.
- Impl is incomplete here. Deleting needs the complete type, and both libraries check it with a static_assert on sizeof. In client.cpp, Impl is only declared, so both compilers stop with "invalid application of 'sizeof' to an incomplete type".
- The fix moves it. In after/contact_book.h the destructor is declared, and contact_book.cpp defines it as = default after struct Impl. Now client.cpp only calls it, and the deleter is compiled where Impl is complete.
The fix is already in after/contact_book.h: declare the destructor in the header and define it in the source file
after Impl, even if only as = default. A declared destructor also stops the compiler from generating the move
operations, so declare those in the header and default them in the source file too. Copying needs a decision of its
own. The class cannot be copied now, because a std::unique_ptr cannot be; to make it copyable, write a copy
constructor in the source file that builds a new Impl from *impl_.
= default in the header is the same trap
~ContactBook() = default; written in the header looks like the fix, but a destructor defaulted where it is
declared is still compiled in each client, where Impl is incomplete, and fails the same way. Declare
~ContactBook(); in the header and write ContactBook::~ContactBook() = default; in contact_book.cpp, after
Impl.
Checkpoint
Check: Where a pimpl destructor is defined
Pimpl costs an allocation and loosens const
One more thing changes without a sound. In a const member function the pointer impl_ is const, but the Impl
it points to is not, so a const function can change the object’s state through it:
C++ (const_hole.cpp) · const_hole.cpp
// const_hole.cpp: a const member function changes the object through its pimpl pointer, and it compiles.
#include <memory>
#include <print>
class Meter {
public:
Meter() : impl_(std::make_unique<Impl>()) {}
void add(int units) { impl_->units += units; }
int read() const { return impl_->units++; } // a bug: reading changes the meter, yet read() is const
private:
struct Impl { int units = 0; };
std::unique_ptr<Impl> impl_; // const Meter means a const pointer, not a const Impl
};
int main() {
Meter meter;
meter.add(40);
const Meter& view = meter;
int first = view.read();
int second = view.read();
std::println("two reads of a const meter: {}, then {}", first, second);
} C++ (const_fixed.cpp) · const_fixed.cpp
// const_fixed.cpp: the same class with propagate_const, which makes the pointed-to Impl const in const functions.
#include <experimental/propagate_const>
#include <memory>
#include <print>
class Meter {
public:
Meter() : impl_(std::make_unique<Impl>()) {}
void add(int units) { impl_->units += units; }
int read() const { return impl_->units++; } // a bug: reading changes the meter, yet read() is const
private:
struct Impl { int units = 0; };
std::experimental::propagate_const<std::unique_ptr<Impl>> impl_; // const Meter: const Impl
};
int main() {
Meter meter;
meter.add(40);
const Meter& view = meter;
int first = view.read();
int second = view.read();
std::println("two reads of a const meter: {}, then {}", first, second);
} from session import run
# A const member function that changes the object through its pimpl pointer, then the same code with
# propagate_const, with both compilers; last, whether each library has C++26 std::indirect.
run("""
clang++ -std=c++23 -Wall -Wextra const_hole.cpp -o const_hole && ./const_hole
g++-16 -std=c++23 -Wall -Wextra const_hole.cpp -o const_hole-gcc && ./const_hole-gcc
clang++ -std=c++23 -Wall -Wextra const_fixed.cpp -o const_fixed
g++-16 -std=c++23 -Wall -Wextra const_fixed.cpp -o const_fixed-gcc
clang++ -std=c++26 -Wall -Wextra indirect_check.cpp -o indirect_check && ./indirect_check
g++-16 -std=c++26 -Wall -Wextra indirect_check.cpp -o indirect_check-gcc && ./indirect_check-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra const_hole.cpp -o const_hole && ./const_hole
two reads of a const meter: 40, then 41
$ g++-16 -std=c++23 -Wall -Wextra const_hole.cpp -o const_hole-gcc && ./const_hole-gcc
two reads of a const meter: 40, then 41
$ clang++ -std=c++23 -Wall -Wextra const_fixed.cpp -o const_fixed
const_fixed.cpp:10:43: error: cannot assign to return value because function 'operator->' returns a const value
10 | int read() const { return impl_->units++; } // a bug: reading changes the meter, yet read() is const
| ~~~~~~~~~~~~^
…/c++/v1/experimental/propagate_const:255:41: note: function 'operator->' which returns const-qualified type 'const element_type *' (aka 'const Meter::Impl *') declared here
255 | _LIBCPP_HIDE_FROM_ABI constexpr const element_type* operator->() const { return get(); }
| ^~~~~~~~~~~~~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra const_fixed.cpp -o const_fixed-gcc
const_fixed.cpp: In member function 'int Meter::read() const':
const_fixed.cpp:10:38: error: increment of member 'Meter::Impl::units' in read-only object
10 | int read() const { return impl_->units++; } // a bug: reading changes the meter, yet read() is const
| ~~~~~~~^~~~~
[exit status 1]
$ clang++ -std=c++26 -Wall -Wextra indirect_check.cpp -o indirect_check && ./indirect_check
std::indirect: not in this library yet
$ g++-16 -std=c++26 -Wall -Wextra indirect_check.cpp -o indirect_check-gcc && ./indirect_check-gcc
std::indirect: yes, __cpp_lib_indirect is 202502
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 const_session.py
read() is const, yet two reads of the same const meter returned 40 and then 41. The wrapper
std::experimental::propagate_const, from version 2 of the Library Fundamentals Technical Specification, makes the
pointer behave as a pointer to const inside const functions. Both libraries ship it, and with it both compilers
reject the faulty line. C++26 adds std::indirect<T> (paper P3019R14), which owns one object and does the three things
a pimpl member needs: it copies the object when its owner is copied, propagates const and accepts an incomplete T.
The last two runs show that GCC 16 provides it in C++26 mode and that Apple clang’s libc++ does not have it yet.
The rest of the trade comes down to three questions about your own class. Answer them in the table, which carries this lesson’s numbers:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Layout | A new object | Member access | A private member changes | What clients compile | Copies, moves and const |
|---|---|---|---|---|---|
| Members in the header A client sees: every private member, and the headers their types need | Good no allocation of its own: 88 bytes (libc++) or 80 (libstdc++) in place | Good direct; functions defined in the header can be inlined | Poor every client is compiled again, and the size and offsets they compiled in change | Poor 44,273 preprocessed lines with Apple clang, 78,071 with GCC 16 | Good the compiler writes the copies and moves, and const covers every member |
| Pimpl with unique_ptr A client sees: one pointer to an Impl that the header only declares | Poor one heap allocation for its Impl, on top of the 8-byte object | Mixed through impl_; inlined into clients only with link-time optimisation | Good only contact_book.cpp is compiled again; the object stays 8 bytes | Good 19,360 lines with Apple clang, 40,056 with GCC 16 | Mixed moves declared in the header, defined in the .cpp; *impl_ is not const without propagate_const |
How the answers decide
- “Is the class part of a library whose binary interface must stay the same from one version to the next?” Yes, a stable library interface: rules out Members in the header. Applications compiled the old size and member offsets in, so a new private member breaks them until they are rebuilt. Pimpl keeps the object one pointer.
- “Is the class part of a library whose binary interface must stay the same from one version to the next?” No, it is rebuilt with its users and “Are objects of it created by the million, or its members read in tight loops?” Yes, a small value in hot code: rules out Pimpl with unique_ptr. Nothing needs a stable binary interface, and the allocation per object and the indirection per access are paid a million times over.
- “Are objects of it created by the million, or its members read in tight loops?” Yes, a small value in hot code: adds a caveat to Pimpl with unique_ptr. Each object costs a heap allocation and each member access goes through the pointer: measure the hot loop before you pay that.
- “Do many source files include its header?” Yes, many files: adds a caveat to Members in the header. Every file that includes the header compiles <map> and <regex> again: 44,273 lines with Apple clang, against 19,360 with pimpl.
Pimpl pays one allocation per object and one indirection per member access, and in return keeps headers small and a library’s binary interface stable. That is a good trade for large classes at the edge of a library or a module, included by many files, and a poor one for small value types, such as a point or a price, that are created by the million in tight loops.
Checkpoint
Check: What pimpl costs, and const
Practise: move ScoreBoard behind a pointer
ScoreBoard in scores.h keeps its members in the header. Convert it to pimpl without changing its public functions
or what they do. The sample tests check the behaviour first, then read scores.h itself:
C header · scores.h
// scores.h: the class to convert to pimpl. Keep its public interface and its behaviour exactly as they are.
#pragma once
#include <cstddef>
#include <functional>
#include <map>
#include <string>
#include <string_view>
#include <vector>
class ScoreBoard {
public:
void record(std::string player, int points);
int total(std::string_view player) const; // 0 for a player who has not scored
std::string leader() const; // the highest total; on a tie, the name that sorts first; "" if empty
std::string last_player() const; // who scored most recently; "" if empty
std::size_t players() const;
private:
std::map<std::string, int, std::less<>> totals_;
std::vector<std::string> history_; // every player who scored, in order
}; C++ (scores.cpp) · scores.cpp
// scores.cpp: the implementation. After the conversion, the members live in ScoreBoard::Impl, defined here.
#include "scores.h"
void ScoreBoard::record(std::string player, int points) {
totals_[player] += points;
history_.push_back(std::move(player));
}
int ScoreBoard::total(std::string_view player) const {
auto it = totals_.find(player);
return it == totals_.end() ? 0 : it->second;
}
std::string ScoreBoard::leader() const {
std::string best;
int best_total = 0;
for (const auto& [name, points] : totals_) { // names in sorted order, so the first of a tie wins
if (best.empty() || points > best_total) {
best = name;
best_total = points;
}
}
return best;
}
std::string ScoreBoard::last_player() const { return history_.empty() ? "" : history_.back(); }
std::size_t ScoreBoard::players() const { return totals_.size(); } C++ (scores_tests.cpp) · scores_tests.cpp
// scores_tests.cpp: sample tests for the exercise. The behaviour checks pass before and after the conversion;
// the last three read scores.h itself and pass only once ScoreBoard keeps its members behind a pointer.
#include "scores.h"
#include <fstream>
#include <print>
#include <sstream>
#include <string>
#include <utility>
namespace {
int passed = 0;
int failed = 0;
void check(bool ok, const char* what) {
std::println("{} {}", ok ? "pass" : "FAIL", what);
(ok ? passed : failed) += 1;
}
std::string file_text(const char* path) {
std::ifstream in(path);
std::stringstream text;
text << in.rdbuf();
return text.str();
}
} // namespace
int main() {
ScoreBoard board;
check(board.players() == 0 && board.leader().empty() && board.last_player().empty(), "an empty board");
board.record("Ravi", 45);
board.record("Asha", 30);
board.record("Meera", 45);
board.record("Asha", 15);
check(board.total("Asha") == 45 && board.total("Ravi") == 45, "totals add up");
check(board.total("Kabir") == 0, "a player who has not scored has 0");
check(board.leader() == "Asha", "a tie goes to the name that sorts first");
check(board.last_player() == "Asha" && board.players() == 3, "the last player and the count");
ScoreBoard moved = std::move(board);
check(moved.total("Meera") == 45, "a moved board keeps its scores");
const std::string header = file_text("scores.h");
check(header.find("#include <map>") == std::string::npos && header.find("#include <vector>") == std::string::npos,
"scores.h no longer includes <map> or <vector>");
check(header.find("struct Impl;") != std::string::npos, "scores.h only declares struct Impl;");
check(sizeof(ScoreBoard) == sizeof(void*), "a ScoreBoard is one pointer");
std::println("{} of {} checks passed", passed, passed + failed);
return failed == 0 ? 0 : 1;
} from session import run
# The sample tests against the starter code, with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra scores_tests.cpp scores.cpp -o scores_tests && ./scores_tests
g++-16 -std=c++23 -Wall -Wextra scores_tests.cpp scores.cpp -o scores_tests-gcc && ./scores_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra scores_tests.cpp scores.cpp -o scores_tests && ./scores_tests pass an empty board pass totals add up pass a player who has not scored has 0 pass a tie goes to the name that sorts first pass the last player and the count pass a moved board keeps its scores FAIL scores.h no longer includes <map> or <vector> FAIL scores.h only declares struct Impl; FAIL a ScoreBoard is one pointer 6 of 9 checks passed [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra scores_tests.cpp scores.cpp -o scores_tests-gcc && ./scores_tests-gcc pass an empty board pass totals add up pass a player who has not scored has 0 pass a tie goes to the name that sorts first pass the last player and the count pass a moved board keeps its scores FAIL scores.h no longer includes <map> or <vector> FAIL scores.h only declares struct Impl; FAIL a ScoreBoard is one pointer 6 of 9 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scores_session.py
The starter code passes the six behaviour checks and fails the three that read the header. To make all 9 pass with both compilers:
- In
scores.h, replace the two members withstruct Impl;andstd::unique_ptr<Impl> impl_;, and replace the includes of<map>and<vector>with<memory>. - Declare a constructor, the destructor and both move operations in the header.
- In
scores.cpp, include<map>and<vector>, definestruct ScoreBoard::Implwith the two members, create it in the constructor withstd::make_unique<Impl>(), and default the destructor and the moves after it. - Change each member function to use
impl_->totalsandimpl_->history.
A forgotten move declaration fails to compile at the move check, and a destructor left out of the header brings back the trap from earlier in this lesson.
Seventeen questions on the six ideas follow, one at a time. The session opens with the idea your answers show is weakest, steps up a level after a right answer, and explains every choice as you pick it:
Practice that adapts to you
Practise this lesson
17 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher to mid · What is a scope guard, and when would you write one instead of a dedicated RAII class?
A scope guard is a small object that runs a callable, usually a lambda, from its destructor, so that code runs on
every way out of a scope: at the end, on a return, or during stack unwinding. It suits one-off cleanup or rollback
that has no type of its own, such as undoing the first step of an update when a later step fails, and release()
switches it off at the point where the work is committed. A dedicated RAII: Resource acquisition is initialisation: a class acquires a resource, such as an open file or a locked mutex, in its constructor and releases it in its destructor, so every path out of the object's scope releases it exactly once. More in “Destructors and RAII in C++” class is the
better choice when the same kind of resource is managed in many places, such as a file handle, a socket or a lock,
because the class names the resource, can be moved and returned, and leaves nothing to write at each use. In both
cases the cleanup runs in a destructor, so it must not throw.
The follow-ups go where this lesson’s examples went: a cleanup that is repeated, a cleanup that throws, and a pimpl class that changes. Decide each one:
Checkpoint
Interview round: three follow-up questions
Two design questions take both idioms further, a rollback across real services and a crash after a library update:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A function reserves stock, charges a card and writes an order row, and each step can fail. Today a catch block undoes the earlier steps by hand. Rewrite the rollback with scope guards, and say what each undo step must and must not do.
-
Senior to staff
A shared library adds a private std::string member to a class in its public header. Applications built against the old header start crashing once they load the new library, without being rebuilt. Explain the crash, and how you would redesign the class so that later changes to its members stay safe.
Key takeaways
- A scope guard runs a callable from its destructor: create it right after the step it undoes and call
release()once the whole operation has succeeded, and the guards undo the finished steps in reverse order. std::uncaught_exceptions()lets a guard run only on failure, or only on success, even during unwinding; the standard library has no scope guard yet, outside the Library Fundamentals TS.- Give a guard a name, put
[[nodiscard]]on its constructor, and never let it change the variable a function returns. - Pimpl moves private members into an
Impldefined in the source file: the header shrinks, the object becomes one pointer, and the ABI survives changes to the members. - Declare the destructor and the moves of a pimpl class in the header and define them in the source file, after
Impl, where its type is complete. - Pimpl costs an allocation per object and an indirection per access, so it suits large classes at a library’s edge;
propagate_constor C++26std::indirectkeepsconstmeaningful.
Terms in this lesson
- incomplete type
- A class that is declared but not yet defined at a point in the code, such as struct Impl; in a pimpl header. There its size and members are unknown, so an object of it cannot be created, copied or deleted.
- scope guard
- An object that runs a callable, usually a lambda, from its destructor, so that cleanup or an undo step runs on every way out of a scope: the closing brace, a return or stack unwinding. A release() call switches it off once the work has succeeded.
References
- Working Draft, Programming Languages: C++ (N4950): [dcl.attr.nodiscard], [class.default], [class.copy.ctor], [class.copy.elision], [unique.ptr.dltr.dflt], [uncaught.exceptions] (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: I.27, for stable library ABI, consider the Pimpl idiom (Standard C++ Foundation)
- C++ Core Guidelines: E.19, use a final_action object to express cleanup if no suitable resource handle is available (Standard C++ Foundation)
- P0052R10: Generic Scope Guard and RAII Wrapper for the Standard Library (ISO/IEC JTC1/SC22/WG21)
- N4152: uncaught_exceptions (ISO/IEC JTC1/SC22/WG21)
- P3019R14: indirect and polymorphic: Vocabulary Types for Composite Class Design (ISO/IEC JTC1/SC22/WG21)
- C++26 (cppreference.com)
- std::experimental::scope_exit (Library Fundamentals TS v3) (cppreference.com)
- std::experimental::propagate_const (Library Fundamentals TS v2) (cppreference.com)
- std::uncaught_exception, std::uncaught_exceptions (cppreference.com)
- PImpl (cppreference.com)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress