Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 25 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

A booking in three steps, and the guard that undoes them

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;
};
One booking that works and two that fail Python · booking_session.py
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:

Interactive · step by step The third booking fails at its last step Step through book() for seat B1-23. The left column is each step, the right column the guard it leaves behind; watch the order in which the guards run.
The third booking fails at its last step, step 7 of 7. Showing 1. train.hold(seat) (B1-23 held), 2. wallet.charge(845) (charged 845, balance 1155), 3. ledger.record(…) (throws: ledger is full), undo_hold (freed B1-23), undo_charge (refunded 845), Seats, wallet, tickets (2 free, 2000, 1 ticket). In focus: Seats, wallet, tickets.then a guardthen a guard1. train.hold(seat)B1-23 held2. wallet.charge(845)charged 845, balance 11553. ledger.record(…)throws: ledger is fullundo_holdfreed B1-23undo_chargerefunded 845Seats, wallet, tickets2 free, 2000, 1 ticket

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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()

  1. Question 1 of 3 In book(), why is undo_charge created on the line after wallet.charge(fare) and not before it?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: So that it can only ever undo a charge that really happened

    Each guard is created right after its step has succeeded. When the charge fails, as in the second booking, only undo_hold exists, so leaving book() frees the seat and refunds nothing.

  2. Question 2 of 3 Someone deletes the line undo_hold.release(); from book(). What does the first booking, which succeeds, print after "recorded B1-21 for 845"?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: "undo: freed B1-21", then "booked", and the summary shows 3 free seats

    A guard runs on every way out of its scope unless release() switched it off. The successful booking now frees seat B1-21 as it leaves book(), and the summary line says 3 free seats while the ticket is recorded.

  3. Question 3 of 3 In the third booking, what is the wallet's balance right after undo_charge has run?

    Type a number.

    A hint

    The balance was 2000 before the call, and charge(845) left 1155.

    Show the answer: Question 3 of 3

    Answer: 2000 rupees

    charge(845) took the balance from 2000 to 1155. When record() threw, undo_charge ran first and refunded 845, so the balance was 2000 again before undo_hold freed the seat.

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 C++ · scope_fail.cpp
// 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
}
Commit, roll back, and a save during unwinding Python · scope_fail_session.py
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:

Interactive · step by step A save that succeeds while an exception unwinds the stack Step through the third call of scope_fail.cpp. Watch the number of exceptions in flight, and what each guard saved when it was made.
A save that succeeds while an exception unwinds the stack, step 8 of 8. Showing main's try block (caught network down), ~SaveOnExit() (running during unwinding), save(…, false) (returned normally), uncaught_exceptions() (1), ScopeFail rollback (did not run), ScopeSuccess commit (ran: committed). In focus: main's try block.callsmain's try blockcaught network down~SaveOnExit()running during unwindingsave(…, false)returned normallyuncaught_exceptions()1ScopeFail rollbackdid not runScopeSuccess commitran: committed

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. No exception yet. The try block creates SaveOnExit later. Nothing is being thrown, so std::uncaught_exceptions() returns 0.
  2. 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.
  3. The destructor runs. ~SaveOnExit() runs during the unwinding and prints "exceptions in flight: 1". Any code it calls sees that one exception.
  4. 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.
  5. 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.
  6. 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".
  7. 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.
  8. 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:

Timeline · 5 events in order The standard pieces behind a scope guard, edition by edition Walk through the editions. Each one changed what a portable guard can ask the library, or whether the library brings one of its own.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. Question 1 of 2 What does std::uncaught_exceptions() return inside ~SaveOnExit() while "network down" unwinds the stack?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 1 exceptions

    One exception, "network down", has been thrown and not yet caught, so the count is 1, as the program printed: "exceptions in flight: 1".

  2. Question 2 of 2 A ScopeFail guard is created while 1 exception is in flight. When it is destroyed, 2 are in flight. Does its lambda run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yes, because a new exception is leaving its scope

    ScopeFail runs its lambda when the count at its destruction is higher than the count it saved. 2 is higher than 1, so a new exception is passing through its scope and the rollback runs.

Two mistakes that a compiler may not flag

Both programs below compile, and both do something their author did not mean:

An unnamed guard, and a guard that changes the returned variable

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));
}
Two guard mistakes, with both compilers Python · mistakes_session.py
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:

Interactive · step by step Two guards without a name, line by line Step through main() of unnamed_guard.cpp. The left column is each line, the right column what is printed so far and what each compiler warned about.
Two guards without a name, line by line, step 6 of 6. Showing println("open"), ScopeExit{…}; (destroyed at its semicolon), ClassOnlyGuard{…}; (destroyed at its semicolon), println("work …"), Printed so far (open, close, close, work), Apple clang 21 (warned about both), GCC 16 (warned about ScopeExit only). In focus: GCC 16, ScopeExit{…};.println("open")ScopeExit{…};destroyed at its semicolonClassOnlyGuard{…};destroyed at its semicolonprintln("work …")Printed so faropen, close, close, workApple clang 21warned about bothGCC 16warned about ScopeExit only

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The first line. main() prints "open". The next line creates a guard whose cleanup prints "close", meant to run after the work.
  2. 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.
  3. The same slip again. ClassOnlyGuard{…}; is a temporary too, and prints "close (ClassOnlyGuard)" straight away. Both cleanups have run before any work was done.
  4. 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.
  5. 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".
  6. 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:

Interactive · step by step A guard that changes the variable a function returns Step through both functions of return_value.cpp. Each column shows the local text and the result the caller receives; watch where the guard's change lands.
A guard that changes the variable a function returns, step 6 of 6. Showing one_return: text (total: 42 (end)), two_returns: text (moved from), one_return's result ("total: 42" with the flag), two_returns' result (total: 42), finish (a guard), finish (a guard). In focus: one_return's result, two_returns' result.the same objectmovedone_return: texttotal: 42 (end)two_returns: textmoved fromone_return's result"total: 42" with the flagtwo_returns' resulttotal: 42finish (a guard)appends " (end)"finish (a guard)appends " (end)"

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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)".
  5. 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.
  6. 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

  1. Question 1 of 2 What is wrong with these three lines?

    Read the code, then choose one answer.

    std::FILE* f = std::fopen("log.txt", "a");
    ScopeExit{[&] { std::fclose(f); }};
    std::fputs("started\n", f);
    Show the answer: Question 1 of 2

    Answer: The file is closed before fputs writes to it

    ScopeExit{…}; creates a temporary, which is destroyed at the end of its full-expression, so fclose runs at once and fputs then uses a file that is already closed. Give the guard a name, such as ScopeExit close_log{…};.

  2. Question 2 of 2 A function builds a report string, and a guard appends a footer to it. The function has a single return report;. Why should the footer still move out of the guard?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Whether the footer reaches the caller depends on an optional copy elision

    The guard's change lands in the result only when the compiler builds the local directly in the caller's result. That elision is optional: one_return got " (end)" from both compilers, and lost it with -fno-elide-constructors. Append the footer before the return statement.

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:

Client files include contact_book.h, which only declares Impl; contact_book.cpp defines it. A ContactBook is one pointer to its Impl.When the program is compiledWhen the program runsClient files:client.cpp and every other filethat uses a ContactBookcontact_book.h:the public member functions,struct Impl; (declared only),std::unique_ptr<Impl> impl_contact_book.cpp:includes <map> and <regex>,defines struct Impland every member functionA ContactBook object:one 8-byte pointerIts Impl, on the heap:the map of phone numbersand the regex#includeImpl is defined only hereimpl_ points to

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.

The class before and after the change, and the client that uses it

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));
}
The same client against both versions, and the header's size Python · pimpl_session.py
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

  1. Question 1 of 2 A private member of the pimpl ContactBook changes from std::map to std::unordered_map. Which files are compiled again?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only contact_book.cpp, which defines Impl

    Pimpl moves the members into Impl, defined only in contact_book.cpp. A change there recompiles that one file, and every client keeps the header it was compiled with.

  2. Question 2 of 2 Impl of the pimpl ContactBook gains a std::vector<std::string> of e-mail addresses. What does sizeof(ContactBook) print with libc++ now?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 8 bytes

    A pimpl ContactBook is one std::unique_ptr, 8 bytes, whatever Impl holds. The new member makes each Impl bigger, on the heap, and leaves the size that clients compiled in at 8.

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 C header · book/trap/contact_book.h
// 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_;
};
client.cpp against the trap header Python · trap_session.py
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:

Interactive · step by step Where the compiler writes the destructor of a pimpl class Step through the compilation of client.cpp against the trap header, then against the fixed one. The right column is code the compiler writes for you.
Where the compiler writes the destructor of a pimpl class, step 5 of 5. Showing client.cpp, contact_book.h (declares ~ContactBook()), contact_book.cpp, ~ContactBook() (defined in the .cpp file), ~unique_ptr<Impl>(), default_delete<Impl> (built where Impl is complete). In focus: contact_book.cpp, ~ContactBook(), default_delete<Impl>.needs it herecallsdeletes withclient.cppContactBook book;contact_book.hdeclares ~ContactBook()contact_book.cppdefines struct Impl~ContactBook()defined in the .cpp file~unique_ptr<Impl>()destroys impl_default_delete<Impl>built where Impl is complete

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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".
  3. 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>.
  4. 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".
  5. 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

  1. Question 1 of 2 In the trap header's version, where did the compiler write ContactBook's implicit destructor?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: In client.cpp, where it was first needed

    An implicit destructor is written where it is first needed. client.cpp destroys book at the end of main, so the destructor, and with it std::default_delete<Impl>, was compiled there, where Impl is only declared.

  2. Question 2 of 2 You declare ~ContactBook(); in the header and default it in contact_book.cpp, and nothing else. What does ContactBook copy = std::move(book); do now?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fails to compile

    Declaring the destructor removes the implicit move operations, so the move falls back to the copy constructor, which is deleted. Declare the move constructor and move assignment in the header and default them in the source file, after Impl.

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:

A const function that changes the object, and the wrapper that stops 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);
}
The const hole, and propagate_const closing it Python · const_session.py
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:

Interactive · answer and compare Members in the header, or behind a pimpl pointer? Answer three questions about your own class. The table marks the layout that fits and says why the other one does not.
Is the class part of a library whose binary interface must stay the same from one version to the next?
Are objects of it created by the million, or its members read in tight loops?
Do many source files include its header?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

The two layouts of ContactBook against the five things that tell them apart
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

  1. Question 1 of 2 Meter::read() is const and returns impl_->units++. Without propagate_const, what happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It compiles, and each read changes the meter, 40 and then 41

    const applies to the members of the object, and impl_ is a pointer, so only the pointer becomes const. The run printed 40, then 41. propagate_const, or C++26 std::indirect, makes the pointed-to Impl const too.

  2. Question 2 of 2 Which of these classes gains the least from pimpl?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A Price of two ints, created by the million in a pricing loop

    Pimpl pays one allocation per object and one indirection per access for a small, stable header and a stable binary interface. That suits large classes at the edge of a library or a module, and not small value types made by the million.

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:

The starter code and its sample tests

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;
}
The sample tests against the starter code Python · scores_session.py
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:

  1. In scores.h, replace the two members with struct Impl; and std::unique_ptr<Impl> impl_;, and replace the includes of <map> and <vector> with <memory>.
  2. Declare a constructor, the destructor and both move operations in the header.
  3. In scores.cpp, include <map> and <vector>, define struct ScoreBoard::Impl with the two members, create it in the constructor with std::make_unique<Impl>(), and default the destructor and the moves after it.
  4. Change each member function to use impl_->totals and impl_->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.

  1. Question 1 of 17 When does the lesson's ScopeExit run its callable?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: When it is destroyed, unless release() was called first

    ScopeExit stores a callable and calls it from its destructor, so it runs on every way out of its scope, unless release() was called before.

  2. Question 2 of 17 ship() throws while it prints its first line. Which lines does place_order print before the exception leaves it?

    Read the code, then choose one answer.

    void place_order() {
        reserve_stock();
        ScopeExit undo_stock{[] { std::println("release stock"); }};
        take_payment();
        ScopeExit undo_payment{[] { std::println("refund payment"); }};
        ship();
        undo_payment.release();
        undo_stock.release();
    }
    Show the answer: Question 2 of 17

    Answer: refund payment, then release stock

    When ship() throws, both guards are still active and are destroyed as the stack unwinds, in the reverse order of their creation: undo_payment first, then undo_stock. The undo steps therefore run in the reverse order of the steps they undo.

  3. Question 3 of 17 The guards in book() capture with [&]. Why is capturing by reference safe there?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: Each guard is destroyed before the parameters and earlier locals its lambda refers to

    A guard created in a function is destroyed before the locals created before it and before the parameters, so a lambda that refers to train, wallet, seat and fare by reference never outlives them.

  4. Question 4 of 17 What does std::uncaught_exceptions() return?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: How many exceptions have been thrown and not yet caught in the current thread

    std::uncaught_exceptions(), added in C++17, returns the number of exceptions thrown and not yet caught in the current thread. ScopeFail and ScopeSuccess save it when they are created and compare it when they are destroyed.

  5. Question 5 of 17 Why does ScopeFail save std::uncaught_exceptions() when it is created, instead of testing std::uncaught_exceptions() > 0 in its destructor?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: Because a guard created during stack unwinding must still tell whether its own scope failed

    Code that runs inside a destructor during unwinding already sees one exception in flight. A guard there must roll back only when a new exception leaves its own scope, so it compares the count at its destruction with the count at its creation.

  6. Question 6 of 17 A new version of save() reports a failure by returning false instead of throwing. Which of its two guards runs when it returns false?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: ScopeSuccess, because no exception left the scope

    Guards that count exceptions tell failure from success only when failures are thrown. A function that reports errors with a return value needs the release() style of ScopeExit, which reacts to every way out until the commit.

  7. Question 7 of 17 Where did [[nodiscard]] have to be for GCC 16 to warn about an unnamed guard?

    Choose one answer.

    Show the answer: Question 7 of 17

    Answer: On the guard's constructor

    The standard recommends a warning when an object of a [[nodiscard]] class, or one built by a [[nodiscard]] constructor, is discarded. Clang warned about both guards; GCC 16 only about the one whose constructor carries the attribute, so put it on the constructor.

  8. Question 8 of 17 What does this print?

    Read the code, then choose one answer.

    std::println("open");
    ScopeExit{[] { std::println("close"); }};
    std::println("work");
    Show the answer: Question 8 of 17

    Answer: open, close, work

    The guard has no name, so it is a temporary, destroyed at the end of its full expression, the semicolon on the same line. Its cleanup therefore runs before the work. It compiles, with a warning when the constructor is declared [[nodiscard]].

  9. Question 9 of 17 In return_value.cpp, what did two_returns(false) return with both compilers?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: [total: 42]

    With two return statements the compiler cannot build text in the caller's result, so it moves text there at the return, and the guard's " (end)" lands in a local that is destroyed right after.

  10. Question 10 of 17 What does the header of a pimpl class keep?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: Its public functions and one pointer to an Impl it only declares

    Pimpl leaves the public interface in the header and replaces the private members with a pointer to a struct the header only declares; the source file defines that struct.

  11. Question 11 of 17 The pimpl contact_book.h includes <memory> and <string>, but no longer <map> or <regex>. Why?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: It still names std::unique_ptr and std::string in its declarations

    <map>, <regex> and <functional> moved into contact_book.cpp, because only Impl's members used them. The header declares functions that take std::string and a std::unique_ptr member, so it includes <string> and, new in the pimpl version, <memory>.

  12. Question 12 of 17 A pimpl class holds std::unique_ptr<Impl>, and Impl is only declared in the header. Where must its destructor be defined?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: In the source file, after the definition of Impl, even if only as = default

    The destructor destroys impl_, and std::unique_ptr's default deleter needs a complete type. Only the source file sees the definition of Impl, so the destructor is declared in the header and defined there.

  13. Question 13 of 17 How do you make the pimpl ContactBook copyable?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: Write a copy constructor in the source file that builds a new Impl from *impl_

    A std::unique_ptr member makes the class move-only. To copy it, write the copy constructor, and a copy assignment if needed, in the source file, where a new Impl can be made from *impl_.

  14. Question 14 of 17 A colleague's pimpl class compiles on its own, but every file that uses it fails with "invalid application of 'sizeof' to an incomplete type". What is the likely cause?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: The header declares no destructor, so each client writes one where Impl is incomplete

    The error comes from std::default_delete's static_assert, compiled wherever ContactBook's destructor is written. With no destructor declared, that is every client. Declare it in the header and define it in the source file after Impl.

  15. Question 15 of 17 Which of these does pimpl add to a class?

    Choose every answer that is right.

    Show the answer: Question 15 of 17

    Answer:

    • A heap allocation for every object
    • A pointer indirection on each access to the members
    • Less inlining of its member functions into client code

    Each object allocates its Impl, every member access goes through impl_, and the member functions are defined in the source file, where clients cannot inline them without link-time optimisation. The implementation's headers move out of the header, which is the point of the idiom.

  16. Question 16 of 17 Which of these does C++26 std::indirect<T> do for a pimpl member?

    Choose every answer that is right.

    Show the answer: Question 16 of 17

    Answer:

    • It makes the object const inside const member functions
    • It accepts an incomplete T in the header
    • It copies the object it owns when its owner is copied

    std::indirect (paper P3019R14) 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 type. The allocation per object remains.

  17. Question 17 of 17 A Point of two doubles is used in a hot geometry loop, and a teammate proposes pimpl to make builds faster. What do you answer?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: Keep the members in the header, because an allocation per point and an indirection per access cost more

    A Point holds two doubles that never change shape and is made by the million. Pimpl would add a heap allocation for each and an indirection for every coordinate read, for a header that is already small.

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

  1. Question 1 of 3 An interviewer asks: forty functions open and close the same kind of socket handle. Would you give each of them a scope guard?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: No, a dedicated RAII class for the handle, so no function writes the cleanup at all

    A scope guard brings RAII to cleanup that has no class of its own. When the same kind of resource is managed in many places, a small RAII class is better: it can be moved and returned, and leaves nothing to write at each use.

  2. Question 2 of 3 The follow-up: in the third booking, what happens if refund() throws inside undo_charge while "ledger is full" is still unwinding the stack?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: std::terminate ends the program

    The guard's lambda runs inside its destructor, and destructors must not throw. An exception leaving one while another exception unwinds the stack calls std::terminate, as the destructors lesson showed, so undo code must not throw.

  3. Question 3 of 3 A last follow-up: a library ships a pimpl class and adds a member to its Impl in the next version. Must the applications that use the class be compiled again?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: No, because the object is still one pointer and only the library's own code touches Impl

    With pimpl, client code knows the class as one pointer and a list of functions defined in the library. Adding a member changes Impl, which only the library's source file defines, so the size and offsets the applications compiled in stay valid.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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 Impl defined 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_const or C++26 std::indirect keeps const meaningful.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.