C++ · OS · Networks GuideCON GuideOct 2026

OOP · threads · locks · networks · memory · move semantics

Know it, then drill it.

Twenty-six chapters of interview material for C++ and systems roles. Each chapter explains a topic from the ground up and walks through compiled code examples. It then gives you about 25 drills: concept checks, tricky questions, predict-the-output and find-the-bug snippets, and write-the-code problems with reference solutions.

struct S {
  S()                  { std::puts("ctor"); }
  S(const S&)          { std::puts("copy"); }
  S(S&&) noexcept      { std::puts("move"); }
};
S make() { S s; return s; }

int main() {
  std::vector<S> v;
  v.push_back(make());   // what prints?
}

With NRVO, make() builds s directly in the returned temporary, and push_back then moves the temporary into the vector. The usual output is ctor, move. Chapters 20 to 24 explain every line.

Chapters

Read each part in order. The parts are independent, so you can start anywhere. Each chapter has three kinds of section: Theory, Drills and Coding. Use the switch in the top bar to show only one kind.

How to use the drills

Answer each drill out loud or on paper before you open it, and treat a wrong answer as the main thing to revise. Predict-the-output snippets were compiled with GCC 16 and Clang 22 (libc++) in C++20 mode. Where the answer is undefined behaviour or implementation-defined, the drill says so. Each coding problem has an estimated Codeforces-style rating. Press / to search, and use the contents list on the right to jump around a chapter.

Suggested plan

WeekReadDrill
1Ch 1–3 (OOP)All OOP drills, then redo the ones you missed
2Ch 4–6 (threads, locks)Thread and lock drills, then write the bounded queue and the reader-writer lock
3Ch 7–8 (networks), Ch 9–13Network drills, then the smart-pointer coding problems
4Ch 14–19 (allocators, cache)Write a pool, an arena and an SoA benchmark
5Ch 20–24 (moves, elision)Every predict-the-output drill, then Ch 25 and the Ch 26 mocks

Part A · Object-oriented programming · Chapter 1

Classes, objects and object lifetime

A class bundles data with the rules that keep that data valid, and an object is a region of memory that obeys those rules from its constructor to its destructor. This chapter covers how objects are laid out, how they are built and destroyed, and which special member functions the compiler writes for you. Interviewers use these questions to find out whether you can write a resource-owning class that does not leak, double free or copy by accident.

TheoryDrillsCoding· about 4 hours· prerequisites: pointers, references, basic templates

Classes and objects

Theory

A class is a type: a description of what data an object holds and which operations are allowed on it. An object is an instance of that type, a block of bytes with a lifetime. The class exists only at compile time. At run time there are just objects and the machine code of their member functions.

The two keywords class and struct define the same kind of thing. The only differences are defaults: members of a struct are public by default and it inherits publicly by default, while a class is private on both counts. By convention, struct is used for plain bundles of data with no invariant, and class for types that protect an invariant.

What an object looks like in memory

Only non-static data members take space in an object. Member functions live once in the code segment and are shared by all objects. They receive the object's address as a hidden this parameter. Static data members live once per program, outside every object. A class with virtual functions also carries a hidden pointer to its vtable (Ch 2).

Members are stored in declaration order, and each one is placed at an offset that is a multiple of its alignment. The compiler inserts padding bytes to satisfy this, and it pads the end of the object so that in an array every element is aligned too. So the order in which you declare members can change sizeof:

#include <cstdio>

struct A { char c; int i; char d; };
struct B { int i; char c; char d; };
struct Empty {};
struct WithFn { int x; void f() {} static int count; };

int main() {
    std::printf("A=%zu B=%zu Empty=%zu WithFn=%zu\n",
                sizeof(A), sizeof(B), sizeof(Empty), sizeof(WithFn));
}
A=12 B=8 Empty=1 WithFn=4
struct Asizeof 12 c padding 3 int i d tail padding 3 04812 struct Bsizeof 8 int i c d pad 2 Each cell is one byte. int needs a 4-byte aligned offset, and the total size rounds up to a multiple of 4.
Member order changes the size. In A the int must start at offset 4, so 3 bytes are wasted after c and 3 more at the end. Putting the largest member first, as B does, packs the two chars together and saves 4 bytes per object.

An empty class still has size 1, because two distinct objects must have distinct addresses. When an empty class is used as a base class, the empty base optimisation lets it take zero bytes. C++20 [[no_unique_address]] gives members the same freedom.

Know this cold

Functions, static members and type information add nothing to sizeof. Non-static data members, padding and (for polymorphic classes) the vptr are the only things that do.

Encapsulation and invariants

Theory

An invariant is a condition that every object of a class must satisfy whenever outside code can see it. Examples: a fraction's denominator is never zero, a vector's size never exceeds its capacity, a string's buffer is always null-terminated. Encapsulation means making the data private, so that the only way to change it is through member functions that preserve the invariant. The constructor establishes the invariant, every public member function keeps it, and the destructor releases what the object owns.

AccessWho can use the member
publicAny code
protectedMembers and friends of the class, and members of derived classes (through a derived object)
privateMembers and friends of the class only

Access control is per class, not per object. A member function can read the private members of any object of its own class, which is how copy constructors and operator== read the other object's data. Access control is checked at compile time only. It has no run-time cost and is no security boundary, because a pointer cast can still reach the bytes.

Interview trap

A getter and a setter for every field is not encapsulation. If setDenominator(0) is allowed, the invariant is gone and the class is a struct with extra typing. Expose operations that mean something (add, push_back, withdraw) and validate inside them.

Constructors

Theory

A constructor turns raw storage into a valid object. It has the class's name, has no return type, and runs automatically when the object is created. A class can have several constructors, distinguished by their parameters.

KindSignatureCalled by
DefaultT() (or every parameter defaulted)T t; T t{}; new T, arrays of T
ParameterisedT(int, std::string)T t(1, "x"); T t{1, "x"};
CopyT(const T&)T b = a; T b(a);, pass or return an lvalue by value
MoveT(T&&) noexceptT b = std::move(a);, from temporaries when not elided (Ch 20)
Convertingany non-explicit constructor callable with one argumentimplicit conversions such as T t = 5;
DelegatingT() : T(0, "") {}a constructor that forwards to another one of the same class

explicit and implicit conversions

A constructor callable with one argument doubles as an implicit conversion. That is useful for std::string s = "hi"; and dangerous for Meters m = 5.0; or a function void sleep(Seconds) called as sleep(5). Mark single-argument constructors explicit unless the conversion is lossless and obvious. An explicit constructor still works with direct initialisation (Meters m(5.0) or Meters m{5.0}), but it is rejected for copy initialisation (Meters m = 5.0) and for implicit argument conversion.

= default, = delete and default member initialisers

= default asks the compiler for its usual implementation, and = delete makes any call ill-formed. A deleted function still takes part in overload resolution, so void f(double); void f(int) = delete; blocks f(3) instead of silently converting. A default member initialiser (int count = 0;) is used by every constructor that does not mention that member in its initialiser list, which removes a whole class of "forgot to initialise in one constructor" bugs.

class Connection {
public:
    Connection() : Connection("localhost", 80) {}
    Connection(std::string host, int port) : host_(std::move(host)), port_(port) {}
    Connection(const Connection&) = delete;
    Connection& operator=(const Connection&) = delete;
private:
    std::string host_;
    int port_;
    int retries_ = 3;
};

Interview trap

T t(); does not create an object. It declares a function named t that returns a T (the "most vexing parse"). Write T t; or T t{}; instead.

Member initialiser lists and construction order

Theory

A constructor body runs after every base and member has been constructed. So assigning to members inside the body means constructing them first (default constructor) and then assigning (assignment operator). The member initialiser list constructs each member directly with the right value:

Person(std::string name) : name_(std::move(name)), id_(next_id++) {}   // construct once
Person(std::string name) { name_ = name; id_ = next_id++; }            // default-construct, then assign

Some members must go in the initialiser list, because they cannot be assigned or default-constructed afterwards: const members, reference members, members of a type with no default constructor, and base classes that need arguments.

The order is fixed by the class, not by the list

Construction always happens in this order:

  1. Virtual base classes (only in the most-derived class).
  2. Direct base classes, in the order they appear in the base-specifier list.
  3. Non-static data members, in declaration order.
  4. The constructor body.

Destruction runs exactly in reverse. The order in which you write the initialiser list is ignored. GCC and Clang warn about a mismatch with -Wreorder, which -Wall enables. The order matters whenever one member's initialiser reads another member.

construction 1. base classesin base-list order 2. data membersin declaration order 3. constructor bodyobject is now complete destruction 1. destructor bodymembers still alive 2. data membersreverse declaration order 3. base classesreverse base-list order The order of the member initialiser list never changes these steps.
Objects are built from the inside out and torn down from the outside in. When the destructor body runs, every member is still valid, and by the time the base destructor runs, the derived parts are gone.

This program traces the whole lifecycle:

#include <cstdio>

struct Tracer {
    const char* name;
    explicit Tracer(const char* n) : name(n) { std::printf("ctor %s\n", name); }
    ~Tracer() { std::printf("dtor %s\n", name); }
};

struct Base {
    Base() { std::puts("ctor Base"); }
    ~Base() { std::puts("dtor Base"); }
};

struct Widget : Base {
    Tracer a{"a"};
    Tracer b;
    Widget() : b("b") { std::puts("Widget body"); }
    ~Widget() { std::puts("~Widget body"); }
};

int main() {
    Widget w;
    std::puts("end of main");
}
ctor Base
ctor a
ctor b
Widget body
end of main
~Widget body
dtor b
dtor a
dtor Base

Destructors and RAII

Theory

A destructor (~T()) runs when an object's lifetime ends. It takes no arguments, cannot be overloaded, and is implicitly noexcept. When it runs depends on the storage duration:

ObjectDestroyed
Local variableAt the end of its scope, in reverse order of construction, including during stack unwinding from an exception
TemporaryAt the end of the full expression, unless it is bound to a const& or && local, which extends its life to the reference's scope
Heap objectOnly when you delete it (or a smart pointer does)
Array elementIn reverse index order
Static or globalAfter main returns, in reverse order of construction
Member or baseAfter the enclosing object's destructor body, in reverse order

RAII

Resource Acquisition Is Initialisation ties a resource (memory, a file, a lock, a socket) to an object's lifetime: acquire it in the constructor and release it in the destructor. Because destructors of locals run on every exit path, including return, break and exceptions, the release cannot be forgotten. std::unique_ptr, std::lock_guard, std::fstream and std::vector are all RAII types.

When a constructor throws

An object whose constructor did not finish never existed, so its destructor does not run. The members and bases that had already been fully constructed are destroyed, in reverse order. So a class that grabs two raw resources in its constructor leaks the first one if acquiring the second one throws. The fix is to hold each resource in its own RAII member, such as a unique_ptr, so that each member cleans itself up.

class Bad {
    int* a_; int* b_;
public:
    Bad() : a_(new int[100]), b_(new int[100]) {}   // if the second new throws, a_ leaks
    ~Bad() { delete[] a_; delete[] b_; }
};
class Good {
    std::unique_ptr<int[]> a_ = std::make_unique<int[]>(100);
    std::unique_ptr<int[]> b_ = std::make_unique<int[]>(100);   // a_ is destroyed if this throws
};

Interview trap

Never let an exception escape a destructor. Destructors are noexcept by default, so a throw calls std::terminate. Even with noexcept(false), throwing while another exception is unwinding the stack terminates the program.

The special member functions

Theory

The compiler can write six functions for you: the default constructor, the destructor, the copy constructor, copy assignment, the move constructor and move assignment. The generated versions work member by member. They copy, move, assign or destroy each base and member in order. Whether each one is generated depends on what you declared yourself:

You declaredefault ctordestructorcopy ctorcopy assignmove ctormove assign
nothingdefaulteddefaulteddefaulteddefaulteddefaulteddefaulted
any constructornot declareddefaulteddefaulteddefaulteddefaulteddefaulted
destructordefaultedyoursdefaulted*defaulted*not declarednot declared
copy ctornot declareddefaultedyoursdefaulted*not declarednot declared
copy assigndefaulteddefaulteddefaulted*yoursnot declarednot declared
move ctornot declareddefaulteddeleteddeletedyoursnot declared
move assigndefaulteddefaulteddeleteddeletednot declaredyours

* Generated, but deprecated since C++11: rely on it and a future standard may break your code. "Not declared" for a move operation is subtle. The class still works with std::move, but overload resolution then picks the copy operation, so a "move" silently copies.

Know this cold

Declaring a destructor, even an empty one or ~T() = default, suppresses the implicit move operations. Declaring any move operation deletes both copy operations.

The rules of three, five and zero

Theory

If a class needs a hand-written destructor, it almost certainly owns a resource, and then the generated copy operations are wrong: they copy the pointer, not what it points to (a shallow copy), so two objects free the same memory.

  • Rule of three (C++98): if you write one of destructor, copy constructor or copy assignment, write all three.
  • Rule of five (C++11): add the move constructor and move assignment, or the type will copy where it could move.
  • Rule of zero: better still, write none of them. Hold resources in members that already manage themselves (std::string, std::vector, std::unique_ptr), and let the compiler generate correct code. Only classes whose whole job is managing one resource should implement the five.

A rule-of-five class with copy-and-swap

The copy-and-swap idiom writes assignment once, taking its argument by value. The parameter is built by the copy constructor (from an lvalue) or by the move constructor (from an rvalue), and then swapped in. The old contents are released when the parameter is destroyed. This gives the strong exception guarantee and handles self-assignment for free.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : size_(n), data_(new int[n]()) {}
    ~Buffer() { delete[] data_; }
    Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
        std::copy(other.data_, other.data_ + size_, data_);
    }
    Buffer(Buffer&& other) noexcept
        : size_(std::exchange(other.size_, 0)), data_(std::exchange(other.data_, nullptr)) {}
    Buffer& operator=(Buffer other) noexcept {
        swap(other);
        return *this;
    }
    void swap(Buffer& other) noexcept {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }
    int& operator[](std::size_t i) { return data_[i]; }
    std::size_t size() const { return size_; }

private:
    std::size_t size_;
    int* data_;
};

int main() {
    Buffer a(3);
    a[0] = 7;
    Buffer b = a;
    b[0] = 9;
    Buffer c(1);
    c = a;
    c = std::move(b);
    std::printf("%d %d %zu %zu\n", a[0], c[0], c.size(), b.size());
}
7 9 3 0

Note that size_ is declared before data_, and that the copy constructor's new int[other.size_] reads other, not its own size_. Both choices keep the code safe if someone reorders the members later.

Tip

The by-value operator= costs one extra move when you assign from an rvalue, compared with a separate operator=(Buffer&&), and it always allocates on copy even when the existing buffer is big enough. std::vector avoids both costs by writing the operators separately. Mention this trade-off when you show the idiom.

Concept and tricky questions

Drills

ConceptWhat is the difference between struct and class in C++?

Answer

Answer. The defaults are the only difference. A struct has public members and public inheritance by default, while a class has private members and private inheritance by default.

Why. Both can have constructors, virtual functions, templates and access specifiers. Candidates often say "structs cannot have member functions", which is true in C and false in C++. Convention: use struct for passive data with no invariant, and class when you protect one.

ConceptWhich members must be initialised in the member initialiser list rather than in the constructor body?

Answer

Answer. const data members, reference members, members whose type has no default constructor, and base classes that need constructor arguments.

Why. By the time the body runs, every member already exists. A const or a reference cannot be assigned afterwards, and a type without a default constructor could not have been built in the first place. For everything else, the list is still preferred, because it constructs once instead of default-constructing and then assigning.

ConceptWhen does the compiler not generate a default constructor?

Answer

Answer. In two cases. It is not declared when you declare any constructor yourself, including a copy or move constructor. It is defined as deleted when some member or base cannot be default-constructed: a reference member without a default member initialiser, a const member of a type that would be left uninitialised, or a member whose own default constructor is deleted or inaccessible.

Why. The compiler assumes that a class with its own constructor has rules for building objects. T() = default; brings the default constructor back.

ConceptWhat is RAII, and why does it make code exception safe?

Answer

Answer. RAII binds a resource to an object: the constructor acquires the resource and the destructor releases it. Destructors of local objects run on every way out of a scope, including stack unwinding after an exception, so the resource is always released.

Why. Manual cleanup has to be repeated on every return and in every catch, and that is where leaks come from. With RAII, cleanup is written once. Examples: unique_ptr (memory), lock_guard (a mutex), fstream (a file), jthread (joining a thread).

ConceptState the rule of three, the rule of five and the rule of zero. Which one should you follow by default?

Answer

Answer. Rule of three: if you write the destructor, the copy constructor or copy assignment, write all three. Rule of five: add the move constructor and move assignment too. Rule of zero: write none, by holding resources in members that already manage themselves. Rule of zero is the default.

Why. A hand-written destructor means the class owns something, so the member-wise copies the compiler generates would share it. Writing the five correctly is error-prone, so confine it to small resource handles (one per resource type) and compose everything else from them.

ConceptWhat is the difference between a shallow copy and a deep copy?

Answer

Answer. A shallow copy duplicates the pointer, so both objects point to the same buffer. A deep copy allocates a new buffer and copies the contents, so each object owns its own.

Why. The implicit copy constructor copies each member, and copying a raw pointer member is a shallow copy. For an owning class that leads to a double free when both destructors run, and to changes through one object showing up in the other. Shallow sharing is correct only when it is intended and reference counted, as in shared_ptr.

TrickyCan a member function access the private members of a different object of the same class?

Answer

Answer. Yes. Access control in C++ is per class, not per object.

Why. That is how a copy constructor reads other.data_ and how operator== compares private fields. People who answer "no" are usually thinking of object-level privacy in languages like Smalltalk or Ruby. The check happens at compile time, on the name being accessed and the context it is accessed from.

TrickyWhat is sizeof an empty class, and why isn't it 0? Is it ever 0?

Answer

Answer. It is at least 1 (in practice exactly 1). A complete object never has size 0, but an empty base subobject can take zero bytes (the empty base optimisation), and so can a member marked [[no_unique_address]] (C++20).

Why. Distinct objects need distinct addresses: in Empty arr[2], &arr[0] != &arr[1] requires each element to take space. The EBO is why std::unique_ptr<T> with the default deleter is the size of a raw pointer.

TrickyA constructor throws halfway through. Does the destructor run? What happens to the members that were already built?

Answer

Answer. The class's destructor does not run. Every base and member that was already fully constructed is destroyed, in reverse order, and then the exception propagates. If the object was being created with new, the memory is freed too.

Why. The object's lifetime begins only when its constructor completes. Raw resources acquired in the constructor body leak in this case, which is the reason to hold each resource in an RAII member. A function-try-block on the constructor can observe or translate the exception, but it cannot stop it from propagating.

TrickyWhy must a copy constructor take its parameter by reference? What happens with T(T other)?

Answer

Answer. T(T) is ill-formed and the compiler rejects it. If it were allowed, passing the argument by value would itself need a copy, which would call the copy constructor, which would need another copy, forever.

Why. The standard says such a constructor is never a copy constructor and is ill-formed ([class.copy.ctor]). The usual answer "infinite recursion at run time" is the motivation, but in practice you get a compile error. The parameter is const T& so that temporaries and const objects can be copied. T(T&) is also a legal copy constructor, but it cannot copy const objects.

TrickyDoes T b = a; call the copy constructor or copy assignment?

Answer

Answer. The copy constructor. The = in a declaration is initialisation syntax, not assignment. Assignment happens only to an object that already exists: T b; b = a;.

Why. The rule of thumb: if a new object is being created, a constructor runs. Assignment operators run only on objects that are already alive, and they must release whatever the object held before.

TrickyCan a destructor be private? Why would you want that?

Answer

Answer. Yes. Objects with automatic, static or member storage then cannot be created outside the class, because their destructor would be called implicitly from code that has no access to it. Code can only create them on the heap and must destroy them through a member or friend, for example a destroy() function or reference counting.

Why. This is used to force heap-only objects, such as intrusively reference-counted COM-style objects that delete themselves when the count reaches zero. A protected destructor in a base class is the other common case: it stops delete through a base pointer without making the destructor virtual (Ch 2).

ConceptWhat does explicit do on a constructor, and when should you leave it off?

Answer

Answer. explicit stops the constructor from being used for implicit conversions and for copy initialisation (T t = x;). Direct initialisation (T t(x) or T t{x}) still works. Leave it off only when the conversion is natural and lossless, as with std::string from const char* or a Fraction from an integer.

Why. Implicit conversions let calls like sleep(5) compile against sleep(Duration) with the wrong unit. Since C++11, explicit also matters for multi-argument constructors, because it blocks return {a, b}; and f({a, b}). C++20 adds explicit(bool) for conditional explicitness in templates.

TrickyYou add an empty destructor ~Holder() {} to a class whose only member is a std::vector. What changes?

Answer

Answer. The implicit move constructor and move assignment are no longer declared, so std::move on a Holder now copies the vector. The class also stops being trivially destructible. Program behaviour is unchanged, but performance can collapse, for example when a std::vector<Holder> reallocates.

Why. A user-declared destructor suggests resource management, so C++11 kept the old copying behaviour for safety. ~Holder() = default; still counts as user-declared and has the same effect. Either delete the destructor or add Holder(Holder&&) = default; and friends (and then the copy operations too, since declaring a move deletes them). The Holder drill in the next section shows the effect.

Predict the output and find the bug

Drills

OutputWhat does this print? Note the order of the initialiser list.

#include <cstdio>

struct Part {
    const char* name;
    Part(const char* n) : name(n) { std::printf("+%s ", name); }
    ~Part() { std::printf("-%s ", name); }
};

struct Base {
    Base() { std::printf("+Base "); }
    ~Base() { std::printf("-Base "); }
};

struct Car : Base {
    Part engine;
    Part wheel{"wheel"};
    Car() : wheel("spare"), engine("engine") { std::printf("+Car "); }
    ~Car() { std::printf("-Car "); }
};

int main() {
    Car car;
    std::printf("| ");
}
Answer
+Base +engine +spare +Car | -Car -spare -engine -Base

Why. The base comes first, then the members in declaration order (engine, then wheel), whatever order the initialiser list uses. Because wheel appears in the initialiser list, its default member initialiser {"wheel"} is ignored and "spare" is used. Destruction is the exact reverse. Both compilers warn with -Wreorder.

OutputWhich special member functions run, and in what order?

#include <cstdio>

struct T {
    T() { std::puts("default"); }
    T(const T&) { std::puts("copy ctor"); }
    T& operator=(const T&) { std::puts("copy assign"); return *this; }
};

int main() {
    T a;
    T b = a;
    T c(a);
    b = c;
    T d = T();
}
Answer
default
copy ctor
copy ctor
copy assign
default

Why. T b = a; creates a new object, so the copy constructor runs, not assignment. b = c; assigns to an existing object. T d = T(); runs only the default constructor: since C++17, a prvalue initialises d directly, with no temporary and no copy (guaranteed copy elision, Ch 23).

OutputWhen does each temporary die?

#include <cstdio>

struct Noisy {
    int id;
    Noisy(int i) : id(i) { std::printf("make %d\n", id); }
    ~Noisy() { std::printf("drop %d\n", id); }
};

int main() {
    Noisy a(1);
    Noisy(2);
    const Noisy& r = Noisy(3);
    std::printf("end %d\n", r.id);
}
Answer
make 1
make 2
drop 2
make 3
end 3
drop 3
drop 1

Why. Noisy(2); creates a temporary that is destroyed at the end of its full expression, the same line. Binding Noisy(3) to a const& local extends its lifetime to the end of r's scope. Locals are destroyed in reverse order of creation, so the extended temporary (created after a) dies first. Watch out for Noisy(x); with an identifier: that line declares a variable named x.

OutputHow many objects get constructed?

#include <cstdio>

struct P {
    P() { std::puts("P()"); }
};

int main() {
    P a;
    P b();
    P c{};
    std::puts("done");
}
Answer
P()
P()
done

Why. Only two objects, a and c. P b(); is the most vexing parse: it declares a function b that takes no arguments and returns a P. Brace initialisation (P c{};) cannot be parsed as a function declaration, which is one reason to prefer it. GCC warns with -Wvexing-parse.

OutputWhat are the two sizes after the "move"?

#include <cstdio>
#include <utility>
#include <vector>

struct Holder {
    std::vector<int> v;
    ~Holder() {}
};

int main() {
    Holder a{{1, 2, 3}};
    Holder b = std::move(a);
    std::printf("a=%zu b=%zu\n", a.v.size(), b.v.size());
}
Answer
a=3 b=3

Why. The user-declared destructor suppresses the implicit move constructor. std::move(a) produces an rvalue, but the only constructor that can take it is the implicit copy constructor (const Holder& binds to rvalues), so the vector is copied. Remove the destructor and the output becomes a=0 b=3 with both libstdc++ and libc++. (A moved-from vector is formally "valid but unspecified", but both implementations leave it empty.)

Find the bugThe author expects 10 15. What does the program actually do?

#include <cstdio>

struct Range {
    int end;
    int begin;
    Range(int b, int len) : begin(b), end(begin + len) {}
};

int main() {
    Range r(10, 5);
    std::printf("%d %d\n", r.begin, r.end);
}
Answer

Answer. The behaviour is undefined. end is declared first, so it is initialised first, and begin + len reads begin before it has been initialised. The program can print any value for end.

Fix. Declare begin before end, or initialise end from the parameters: end(b + len). The second fix is more robust, because it does not depend on member order at all. -Wall warns about both the reordering and the uninitialised read.

Find the bugThis small string class works in a quick test and crashes in production. Why?

#include <cstring>

struct Str {
    char* p;
    Str(const char* s) : p(new char[std::strlen(s) + 1]) { std::strcpy(p, s); }
    ~Str() { delete[] p; }
};

int main() {
    Str a("hi");
    Str b = a;
}
Answer

Answer. It is a double free. The implicit copy constructor copies the pointer, so a.p == b.p. Both destructors delete[] the same buffer. AddressSanitizer reports attempting double-free.

Fix. Follow the rule of three/five: write a deep-copying copy constructor and copy assignment (and the moves), or delete copying. The best fix is the rule of zero: hold a std::string.

Find the bugThis assignment operator passes every test except one. Which input breaks it?

#include <cstdio>
#include <cstring>

class Name {
public:
    Name(const char* s) : p_(new char[std::strlen(s) + 1]) { std::strcpy(p_, s); }
    Name(const Name& o) : Name(o.p_) {}
    Name& operator=(const Name& o) {
        delete[] p_;
        p_ = new char[std::strlen(o.p_) + 1];
        std::strcpy(p_, o.p_);
        return *this;
    }
    ~Name() { delete[] p_; }
    const char* get() const { return p_; }

private:
    char* p_;
};

int main() {
    Name n("alice");
    Name& alias = n;
    n = alias;
    std::puts(n.get());
}
Answer

Answer. Self-assignment. When &o == this, delete[] p_ frees the buffer that o.p_ still points to, and then strlen(o.p_) reads freed memory: a use after free. ASan reports heap-use-after-free. Self-assignment usually arrives through aliases such as v[i] = v[j], not as literal n = n.

Fix. Allocate and copy first, then delete. That also gives the strong exception guarantee if new throws. Alternatively, use copy-and-swap. An if (this == &o) check alone fixes self-assignment but still leaves the object broken if new throws.

Find the bugWhy does this not compile, and is that a bug or a feature?

struct Meters {
    explicit Meters(double v) : value(v) {}
    double value;
};

static double twice(Meters m) { return 2 * m.value; }

int main() {
    Meters a(3.0);
    Meters b = 4.0;
    return static_cast<int>(twice(a) + twice(5.0) + b.value);
}
Answer

Answer. There are two errors, and both are the intended effect of explicit. Meters b = 4.0; is copy initialisation, which cannot use an explicit constructor. twice(5.0) would need an implicit conversion from double to Meters. Meters a(3.0) is direct initialisation and is fine.

Fix. Be explicit at the call site: Meters b{4.0}; and twice(Meters{5.0}). Unit types are the textbook case for explicit, because a bare 5.0 could mean feet.

Write the code

Coding

CodeImplement a String class that owns a heap buffer and follows the rule of five.

Requirements: a constructor from const char* (default ""), a deep-copying copy constructor and copy assignment, a noexcept move constructor and move assignment that leave the source empty, size(), and c_str(), which must work on a moved-from object. Self-assignment and self-move must be safe.

Answer

Approach. Store size_ and a char*, with size declared first because the buffer allocation reads it. The copy constructor allocates and copies size_ + 1 bytes. Moves steal the pointer with std::exchange. Both assignments build a local copy or moved temporary and swap it in, so self-assignment needs no special handling and the old buffer is freed by the temporary's destructor.

#include <cstdio>
#include <cstring>
#include <utility>

class String {
public:
    String(const char* s = "") : size_(std::strlen(s)), data_(new char[size_ + 1]) {
        std::memcpy(data_, s, size_ + 1);
    }
    String(const String& other) : size_(other.size_), data_(new char[other.size_ + 1]) {
        std::memcpy(data_, other.data_, size_ + 1);
    }
    String(String&& other) noexcept
        : size_(std::exchange(other.size_, 0)), data_(std::exchange(other.data_, nullptr)) {}
    String& operator=(const String& other) {
        String copy(other);
        swap(copy);
        return *this;
    }
    String& operator=(String&& other) noexcept {
        String moved(std::move(other));
        swap(moved);
        return *this;
    }
    ~String() { delete[] data_; }

    void swap(String& other) noexcept {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }
    std::size_t size() const { return size_; }
    const char* c_str() const { return data_ ? data_ : ""; }

private:
    std::size_t size_;
    char* data_;
};

int main() {
    String a("hello");
    String b = a;
    String c;
    c = a;
    String d = std::move(b);
    String& alias = a;
    a = alias;
    String& self = c;
    c = std::move(self);
    std::printf("%s %s %s [%s] %zu\n", a.c_str(), c.c_str(), d.c_str(), b.c_str(), b.size());
}
hello hello hello [] 0

Complexity and pitfalls. Copies are O(n) and moves are O(1). Common mistakes: forgetting the + 1 for the terminator, leaving the moved-from pointer non-null (double free), returning nullptr from c_str() after a move, and omitting noexcept. Without noexcept, std::vector<String> copies on reallocation (Ch 20).

Estimated rating: ~1500 · straightforward logic, but six special members with lifetime and exception corner cases.

CodeWrite a class Counted that reports how many objects are alive now and how many were ever created.

Every way of creating an object (default, copy, move) must count. Assignment creates nothing. Counted::live() must return 0 once every object has been destroyed.

Answer

Approach. Use two inline static counters. Every constructor increments both, and the destructor decrements live_. The trap is the implicit copy and move constructors, which would create objects without counting them and drive live() negative. So all three constructors are written by hand, and the assignments are defaulted, because they do not change the number of objects.

#include <cstdio>
#include <utility>

class Counted {
public:
    Counted() { ++live_; ++created_; }
    Counted(const Counted&) { ++live_; ++created_; }
    Counted(Counted&&) noexcept { ++live_; ++created_; }
    Counted& operator=(const Counted&) = default;
    Counted& operator=(Counted&&) noexcept = default;
    ~Counted() { --live_; }

    static int live() { return live_; }
    static int created() { return created_; }

private:
    inline static int live_ = 0;
    inline static int created_ = 0;
};

int main() {
    {
        Counted a;
        Counted b = a;
        Counted c = std::move(b);
        a = c;
        std::printf("inside: live=%d created=%d\n", Counted::live(), Counted::created());
    }
    std::printf("after: live=%d created=%d\n", Counted::live(), Counted::created());
}
inside: live=3 created=3
after: live=0 created=3

Pitfalls. Defining only the default constructor gives after: live=-2. For use across threads, make the counters std::atomic<int>. In a CRTP base, Counted<T> gives every derived type its own counters.

Estimated rating: ~1100 · tests whether you know every path that creates an object.

CodeWrite an RAII wrapper File for C's FILE*.

It must open the file in its constructor and close it in its destructor, be non-copyable and movable, and offer explicit operator bool and get(). Moving must transfer ownership, so the file is closed exactly once.

Answer

Approach. Delete both copy operations. The move constructor takes the handle with std::exchange(other.f_, nullptr). Move assignment closes its current handle before taking the new one, and guards against self-move. The destructor closes only a non-null handle.

#include <cstdio>
#include <utility>

class File {
public:
    File(const char* path, const char* mode) : f_(std::fopen(path, mode)) {}
    ~File() { if (f_) std::fclose(f_); }
    File(const File&) = delete;
    File& operator=(const File&) = delete;
    File(File&& other) noexcept : f_(std::exchange(other.f_, nullptr)) {}
    File& operator=(File&& other) noexcept {
        if (this != &other) {
            if (f_) std::fclose(f_);
            f_ = std::exchange(other.f_, nullptr);
        }
        return *this;
    }
    explicit operator bool() const { return f_ != nullptr; }
    std::FILE* get() const { return f_; }

private:
    std::FILE* f_;
};

int main() {
    {
        File out("ch01_demo.txt", "w");
        std::fputs("raii\n", out.get());
    }
    File in("ch01_demo.txt", "r");
    char line[16] = {};
    if (!std::fgets(line, sizeof line, in.get())) return 1;
    File moved = std::move(in);
    std::printf("%s", line);
    std::printf("in=%d moved=%d\n", static_cast<bool>(in), static_cast<bool>(moved));
    std::remove("ch01_demo.txt");
}
raii
in=0 moved=1

Pitfalls. The inner block matters: the writer's destructor flushes and closes the file before it is reopened for reading. In production, prefer std::unique_ptr<FILE, decltype(&fclose)> or a stateless deleter (Ch 12), or simply std::fstream. Also decide what a failed fopen means: here it gives a null handle that converts to false, and throwing is the other option.

Estimated rating: ~1300 · a move-only handle, with the close-before-overwrite and self-move details.

CodeImplement IntVector, a growable array of int with the rule of five.

Support push_back with amortised O(1) growth (doubling), operator[], size() and capacity(), deep copy, and O(1) noexcept moves. A default-constructed vector must not allocate.

Answer

Approach. Store three members with default member initialisers, so the defaulted default constructor yields an empty vector. When the vector is full, grow() allocates double the capacity, copies, and frees the old buffer. Copying allocates exactly size elements. Assignment is copy-and-swap.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <utility>

class IntVector {
public:
    IntVector() = default;
    IntVector(const IntVector& other)
        : data_(other.size_ ? new int[other.size_] : nullptr), size_(other.size_), cap_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }
    IntVector(IntVector&& other) noexcept
        : data_(std::exchange(other.data_, nullptr)),
          size_(std::exchange(other.size_, 0)),
          cap_(std::exchange(other.cap_, 0)) {}
    IntVector& operator=(IntVector other) noexcept {
        swap(other);
        return *this;
    }
    ~IntVector() { delete[] data_; }

    void push_back(int value) {
        if (size_ == cap_) grow();
        data_[size_++] = value;
    }
    void swap(IntVector& other) noexcept {
        std::swap(data_, other.data_);
        std::swap(size_, other.size_);
        std::swap(cap_, other.cap_);
    }
    int operator[](std::size_t i) const { return data_[i]; }
    std::size_t size() const { return size_; }
    std::size_t capacity() const { return cap_; }

private:
    void grow() {
        std::size_t next = cap_ ? 2 * cap_ : 1;
        int* bigger = new int[next];
        std::copy(data_, data_ + size_, bigger);
        delete[] data_;
        data_ = bigger;
        cap_ = next;
    }

    int* data_ = nullptr;
    std::size_t size_ = 0;
    std::size_t cap_ = 0;
};

int main() {
    IntVector a;
    for (int i = 1; i <= 5; ++i) a.push_back(i);
    IntVector b = a;
    b.push_back(6);
    IntVector c;
    c = std::move(b);
    std::printf("a size=%zu cap=%zu\n", a.size(), a.capacity());
    std::printf("b size=%zu\n", b.size());
    std::printf("c:");
    for (std::size_t i = 0; i < c.size(); ++i) std::printf(" %d", c[i]);
    std::printf("\n");
}
a size=5 cap=8
b size=0
c: 1 2 3 4 5 6

Complexity and pitfalls. Doubling makes n pushes cost O(n) copies in total, so each push is amortised O(1). Growing by a constant amount instead would make n pushes cost O(n²). grow() allocates before it frees, so a throwing new leaves the vector unchanged. A generic Vector<T> would need raw storage and placement new so that capacity does not construct elements (Ch 14).

Estimated rating: ~1600 · rule of five, plus amortised growth and the correct moved-from state.

CodeDesign a Date class that can never hold an invalid date.

A Date has a year, month and day, and must always be a real calendar date (leap years included: 2000 is a leap year, 1900 is not). Invalid input is common and expected, so report it without exceptions: provide static std::optional<Date> make(int y, int m, int d), and make it impossible to construct a Date any other way, except by copying one. Add read-only accessors and next_day(), which must keep the invariant.

Answer

Approach. This is the named constructor idiom. The real constructor is private and trusts its arguments, and the public static make validates first, so every Date in the program went through the check. A constructor cannot return "no object", which is why the factory returns std::optional; throwing from a public constructor is the other option when bad input is truly exceptional. There are no setters: next_day() is the only mutation, and it rolls the day, month and year over in that order so the invariant holds on exit.

#include <cstdio>
#include <optional>

class Date {
public:
    static std::optional<Date> make(int y, int m, int d) {
        if (m < 1 || m > 12 || d < 1 || d > days_in(y, m)) return std::nullopt;
        return Date(y, m, d);
    }
    int year() const { return y_; }
    int month() const { return m_; }
    int day() const { return d_; }
    void next_day() {
        if (++d_ <= days_in(y_, m_)) return;
        d_ = 1;
        if (++m_ <= 12) return;
        m_ = 1;
        ++y_;
    }

private:
    Date(int y, int m, int d) : y_(y), m_(m), d_(d) {}
    static bool leap(int y) { return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; }
    static int days_in(int y, int m) {
        static constexpr int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
        return m == 2 && leap(y) ? 29 : days[m - 1];
    }

    int y_, m_, d_;
};

static void show(const Date& d) { std::printf("%04d-%02d-%02d\n", d.year(), d.month(), d.day()); }

int main() {
    const int tests[][3] = {{2024, 2, 29}, {2023, 2, 29}, {1900, 2, 29}, {2000, 2, 29}, {2024, 13, 1}};
    for (const auto& t : tests) {
        auto d = Date::make(t[0], t[1], t[2]);
        std::printf("%d-%d-%d %s\n", t[0], t[1], t[2], d ? "ok" : "rejected");
    }
    Date d = *Date::make(2023, 12, 31);
    d.next_day();
    show(d);
    d = *Date::make(2024, 2, 28);
    d.next_day();
    show(d);
    d.next_day();
    show(d);
    // Date bad(2024, 2, 30);  // error: the constructor is private
}
2024-2-29 ok
2023-2-29 rejected
1900-2-29 rejected
2000-2-29 ok
2024-13-1 rejected
2024-01-01
2024-02-29
2024-03-01

Pitfalls. The leap-year rule has three parts (divisible by 4, except centuries, except multiples of 400), and forgetting the last two accepts 1900-02-29. The implicit copy operations are correct here, because copying a valid date gives a valid date, so the rule of zero applies. std::optional<Date> can still hold a Date built by a private constructor because make, a member, builds it. Dereferencing an empty optional with * is undefined behaviour, so production callers check it or use .value(), which throws. A one-line struct with three public ints would be simpler and would let any caller write d.day = 31 in February.

Estimated rating: ~1200 · simple calendar logic, judged on making invalid states unrepresentable.

Cheat sheet

Theory
  • struct and class differ only in default access and default inheritance.
  • sizeof counts data members, padding and the vptr. Order members from largest to smallest to reduce padding. An empty class has size 1, except as a base (EBO).
  • Construction order: virtual bases, bases, members in declaration order, then the body. Destruction is the reverse. The initialiser-list order is ignored.
  • const members, references, members without a default constructor and bases with arguments must be in the initialiser list.
  • If a constructor throws, the destructor does not run, but the members already built are destroyed. Hold each resource in its own RAII member.
  • A user-declared destructor or copy operation suppresses the implicit moves (so std::move copies). A user-declared move deletes the copies.
  • Follow the rule of zero by default. Write the five only for a class whose single job is owning one resource, and make its moves noexcept.
  • T b = a; is construction, not assignment. T t(); declares a function. Mark single-argument constructors explicit unless the conversion is lossless.

Part A · Object-oriented programming · Chapter 2

Inheritance and polymorphism

How C++ decides which function runs: inheritance access, static and dynamic binding, the vtable and vptr, and the rules around destructors, slicing, construction, name hiding and multiple inheritance. Interviewers love it because every rule has a sharp edge that compiles cleanly and then misbehaves, and "draw the object layout" separates memorised keywords from understanding.

TheoryDrillsCoding· about 6 hours· prerequisites: Ch 1 (constructors, destructors, rule of 3/5/0)

Inheritance, is-a and access

Theory

Mental model. A derived class object contains a complete base class object (the base subobject) followed by the derived class's own members. Public inheritance promises is-a: anywhere code expects a Base&, a Derived must work and keep every promise the base makes. If that promise does not hold, use composition (see Ch 3 for the Liskov substitution principle and the square/rectangle trap).

Public, protected and private inheritance

The inheritance access specifier caps the access of inherited members as seen through the derived class, and it controls who may convert Derived* to Base*. It never changes what the derived class itself can see: a derived class can always use the base's public and protected members, and never its private ones.

Base member: public Base: protected Base: private Base
publicpublicprotectedprivate
protectedprotectedprotectedprivate
privatenot accessiblenot accessiblenot accessible
Derived* → Base* allowed foreveryoneDerived, its friends and its derived classesDerived and its friends only

The default is private for class and public for struct, so class D : B {} is private inheritance. That catches people out when the upcast suddenly fails to compile.

Private inheritance means is-implemented-in-terms-of. It is composition with three extras: the derived class can override the base's virtual functions, can reach its protected members, and gets the empty base optimisation (an empty base takes no space, while an empty member takes at least one byte). C++20's [[no_unique_address]] gives the space saving to members too, so private inheritance is now rarely needed for that reason alone.

The protected access rule

A derived class may access a protected member of its base only through an object of its own type (or a type derived from it). It may not reach into an arbitrary Base:

class Account {
protected:
    long balance_ = 0;
};
class Savings : public Account {
public:
    void ok(Savings& other) { other.balance_ = 0; }   // fine: other is a Savings
    void bad(Account& other) { other.balance_ = 0; }  // error: other may be a Current account
};

The rule exists so that one derived class cannot break the invariants of a sibling class by going through the shared base. ([class.protected])

Construction order with bases

Virtual bases first, then direct bases in base-specifier order (never initialiser-list order), then members in declaration order, then the body; destruction is the exact reverse (Ch 1). -Wreorder warns when the initialiser list disagrees.

Static and dynamic binding

Theory

Mental model. Every expression that names an object has a static type (what the compiler sees in the declaration) and the object it refers to has a dynamic type (what was actually constructed). A call to a non-virtual function is bound at compile time using the static type. A call to a virtual function through a pointer or reference is bound at run time using the dynamic type.

#include <iostream>

struct Base {
    void hello() const { std::cout << "Base::hello\n"; }
    virtual void speak() const { std::cout << "Base::speak\n"; }
    virtual ~Base() = default;
};
struct Derived : Base {
    void hello() const { std::cout << "Derived::hello\n"; }
    void speak() const override { std::cout << "Derived::speak\n"; }
};

int main() {
    Derived d;
    Base& r = d;
    Base* p = &d;
    r.hello();         // non-virtual: static type Base
    r.speak();         // virtual: dynamic type Derived
    p->Base::speak();  // qualified name: dispatch is suppressed
    Base b = d;        // copy: b is a brand new Base object
    b.speak();
}
Base::hello
Derived::speak
Base::speak
Base::speak

Three conditions must hold for a virtual call: the function is virtual in the static type, the call goes through a pointer or reference (or this, implicitly), and the name is not qualified. A call on an object by value (b.speak()) always knows the exact type, so the compiler binds it statically.

What counts as an override

A function in a derived class overrides a base virtual function when it has the same name, the same parameter types, the same cv-qualifiers and the same ref-qualifier. The return type must be the same or covariant (see covariant returns below). If any of these differ, you get a new, unrelated function that hides the base one, and nothing warns you unless you wrote override.

struct Base { virtual void f(int) const; };
struct D1 : Base { void f(int); };                  // missing const: hides, does not override
struct D2 : Base { void f(long) const; };           // different parameter: hides
struct D3 : Base { void f(int) const override; };   // overrides
struct D4 : Base { void f(int) override; };         // error: marks the bug at compile time
  • virtual is implied on an override; repeating it is legal but adds nothing.
  • override changes no behaviour. It only asks the compiler to check that the function really overrides something. Clang's -Winconsistent-missing-override and GCC's -Wsuggest-override help enforce it.
  • final on a function forbids further overriding. On a class it forbids deriving from it. Both let the compiler devirtualise calls (see the cost of a virtual call).
  • override and final are contextual keywords, so old code with a variable called final still compiles.

The vtable and vptr

Theory

Mental model. The standard does not mention vtables, but every mainstream compiler implements virtual functions the same way. Each polymorphic class has one table of function pointers (the vtable), stored once in read-only data. Each object of that class carries a hidden pointer to its class's table (the vptr). A virtual call loads the vptr, indexes the table at a fixed slot chosen at compile time, and calls through the pointer it finds.

d1 (on the stack) +0 +8 +16 vptr Base::id_ Derived::legs_ d2 (on the heap) vptr Base::id_, legs_ vtable for Derived (.rodata, one per class) offset-to-top = 0 &typeinfo for Derived [0] ~Derived (complete) [1] ~Derived (deleting) [2] Derived::speak [3] Base::move (inherited) both point to the address point, slot [0] .text Derived::speak code Base::move code Slot numbers are fixed at compile time. p->speak() compiles to: load [p], load [vptr+16], call. Each slot is 8 bytes on a 64-bit target.
Objects carry one pointer; classes carry the table. Every Derived object stores a vptr at offset 0 (Itanium C++ ABI, used by GCC and Clang on Linux and macOS). The vptr points past two header words, offset-to-top and the RTTI pointer, to the first function slot. An inherited function that is not overridden simply reuses the base's code address.

What the Itanium ABI puts in a vtable

  • Virtual function slots in declaration order, base class slots first. A derived class appends slots for its new virtual functions after the inherited ones, so a Base* call to slot 2 works on every derived class.
  • Two destructor slots for a virtual destructor: the complete object destructor (runs the destructor chain) and the deleting destructor (runs it and then calls operator delete with the right size). delete p on a Base* calls the deleting destructor of the dynamic type, which is why it frees the right number of bytes.
  • RTTI pointer to the std::type_info object, used by typeid and dynamic_cast.
  • Offset-to-top: how far this subobject is from the start of the complete object. It is zero except in multiple inheritance, and dynamic_cast<void*> uses it.
  • Virtual base offsets when the class has virtual bases (see multiple inheritance).

Where is the vtable emitted? In the translation unit that defines the class's key function: the first virtual function that is not pure and not defined inline. If you never define the key function, the linker reports undefined reference to `vtable for X', which confuses people because the missing thing is a function, not the table. A class whose virtual functions are all inline gets a copy of the vtable in every TU that needs it, merged by the linker.

#include <iostream>

struct Empty {};
struct EmptyVirtual { virtual ~EmptyVirtual() = default; };
struct Plain { int x; };
struct OneVirtual { int x; virtual void f() {} };
struct ManyVirtual { int x; virtual void f() {} virtual void g() {} virtual void h() {} };

int main() {
    std::cout << sizeof(Empty) << ' ' << sizeof(EmptyVirtual) << ' '
              << sizeof(Plain) << ' ' << sizeof(OneVirtual) << ' '
              << sizeof(ManyVirtual) << '\n';
}
1 8 4 16 16

On a 64-bit target, the first virtual function adds an 8-byte vptr, and padding rounds OneVirtual up to 16 for alignment. More virtual functions make the shared vtable longer but leave every object the same size.

The cost of a virtual call

A virtual call is two dependent loads (the vptr, then the slot) and an indirect call. When the branch predictor has seen the target before and the vtable is in cache, that is a few cycles more than a direct call. The real costs are elsewhere:

  • No inlining. The compiler cannot inline a function it cannot name, so it also loses everything inlining enables: constant propagation, vectorisation of the loop around the call, and dead code removal. For a tiny function such as area() in a hot loop, this dominates.
  • Misprediction. If a loop over a mixed container calls a different target each time, the indirect branch mispredicts, costing about 15 to 20 cycles on a modern core. Sorting objects by type, or keeping one container per type, restores predictability.
  • Size and cache. Every object grows by 8 bytes, and objects usually live behind pointers, which costs a cache miss per element. This is often worse than the call itself (see Ch 19).

Devirtualisation. The compiler turns a virtual call into a direct call when it can prove the dynamic type: the object is a local by value, the class or function is final, or link-time optimisation sees the whole hierarchy. GCC also devirtualises speculatively: it compares the loaded function pointer with the likely target and, if they match, runs an inlined copy of that target. Low-latency code often prefers compile-time polymorphism (templates, CRTP in Ch 3, std::variant) on the hot path for these reasons.

Key factValue or rule
vptr location (Itanium ABI)offset 0 of each polymorphic subobject
vtables per classone, shared by all objects, in read-only data
Object size costone pointer per polymorphic base subobject, independent of the number of virtual functions
Virtual callload vptr, load slot, indirect call; blocks inlining unless devirtualised
Dispatch needsvirtual function, pointer or reference (or this), unqualified name
Inside constructors and destructorsdispatch uses the class currently being built or destroyed
Default argumentstaken from the static type, never the dynamic type
Cannot be virtualconstructors, static member functions, member function templates
Can be virtualdestructors (including pure), operators, private functions, inline functions

Pure virtual functions and abstract classes

Theory

A pure virtual function (= 0) says "every concrete derived class must provide this". A class with at least one pure virtual function, declared or inherited and not overridden, is abstract: you cannot create an object of it, but you can have pointers and references to it, and it can have constructors, data members and non-virtual functions.

Two facts people miss:

  • A pure virtual function can have a definition. Derived classes can call it with a qualified name, for example to share default behaviour while still forcing every class to opt in. The definition must be outside the class.
  • A pure virtual destructor must have a definition, because every derived destructor calls the base destructor. Declaring the destructor pure is a way to make a class abstract when it has no other natural pure function.
#include <iostream>
#include <memory>

struct Shape {
    virtual double area() const = 0;
    virtual ~Shape() = 0;
};
Shape::~Shape() {}                          // required: derived destructors call it
double Shape::area() const { return 0.0; }  // optional default, reachable by name

struct Square : Shape {
    explicit Square(double s) : side(s) {}
    double area() const override { return side * side; }
    double side;
};
struct Dot : Shape {
    double area() const override { return Shape::area(); }  // opt in to the default
};

int main() {
    std::unique_ptr<Shape> shapes[] = {std::make_unique<Square>(3), std::make_unique<Dot>()};
    for (const auto& s : shapes) std::cout << s->area() << '\n';
    // Shape s;  // error: Shape is abstract
}
9
0

Pure virtual call. If a pure virtual function is called through dispatch while the object's dynamic type is still the abstract class (in practice: indirectly from the abstract class's constructor or destructor), behaviour is undefined ([class.abstract]). GCC and Clang put __cxa_pure_virtual in the slot, which prints pure virtual method called and calls std::terminate. A reading drill below shows it.

Virtual destructors and object slicing

Theory

Why delete through a base pointer needs a virtual destructor

delete p where p is a Base* but the object is a Derived is undefined behaviour unless Base has a virtual destructor ([expr.delete]/3). In practice, without the virtual destructor the compiler calls Base::~Base directly: Derived's destructor never runs (its members leak, its locks stay held), and operator delete receives the wrong size. With a virtual destructor, delete calls the deleting destructor through the vtable, which runs the whole chain and frees the right size.

#include <iostream>
#include <memory>
#include <string>

struct Base {
    virtual ~Base() { std::cout << "~Base\n"; }
};
struct Derived : Base {
    std::string name = "a string long enough to live on the heap";
    ~Derived() override { std::cout << "~Derived\n"; }
};

int main() {
    std::unique_ptr<Base> p = std::make_unique<Derived>();
    p.reset();  // calls Derived's deleting destructor through the vtable
}
~Derived
~Base

The guideline (C++ Core Guidelines C.35): a base class destructor should be either public and virtual (you may delete through a base pointer) or protected and non-virtual (you may not, so the compiler rejects delete base_ptr outside the hierarchy). A class marked final, or a class never used polymorphically, needs neither.

Interview trap

"shared_ptr<Base> works without a virtual destructor, so virtual destructors are not needed with smart pointers." Half true. std::shared_ptr<Base> p = std::make_shared<Derived>() (and shared_ptr<Base>(new Derived)) records a deleter for Derived in the control block when it is created, so the right destructor runs. std::unique_ptr<Base> uses default_delete<Base>, which is a plain delete on a Base*, so it needs the virtual destructor. See Ch 11.

Arrays are the second exception people miss: Base* p = new Derived[4]; delete[] p; is undefined behaviour even with a virtual destructor, because delete[] needs the static and dynamic types to match. Indexing such an array through a Base* is also broken, because p[1] steps by sizeof(Base).

Object slicing

Slicing happens when a derived object is copied into a base object by value. The base copy constructor copies only the base subobject, and the new object is a genuine Base with Base's vptr. Nothing derived survives, and later virtual calls go to Base.

Derived d vptr → vtable for Derived Base part: id_ = 7 Derived part: legs_ = 4 Base b = d; vptr → vtable for Base id_ = 7 (copied) Base(const Base&) dropped set by Base's constructor, never copied Same thing happens for: pass by value to f(Base), return Base from a Derived, push_back into vector<Base>, and catch (Base e). Assignment through Base& (r = other) is the partial-assignment variant: only Base fields change.
Slicing keeps the base part and resets the type. The vptr is never copied: each constructor writes its own class's vptr. That is why a sliced copy behaves as a plain Base for ever after.

How to prevent it. Pass and store polymorphic objects by reference or (smart) pointer, catch exceptions by const&, and make polymorphic bases non-copyable (delete or protect the copy operations) so the compiler rejects accidental slicing. When you genuinely need a copy of an unknown dynamic type, add a virtual clone() (the "virtual constructor" idiom, drilled in Write the code).

Virtual calls in constructors and destructors

Theory

Mental model. While Base's constructor runs, the Derived part does not exist yet, so calling a Derived override would read uninitialised members. C++ prevents that: during a constructor or destructor, the object's dynamic type is the class whose constructor or destructor is running ([class.cdtor]). Under the hood, each constructor writes its own class's vptr into the object before running its body, and each destructor writes it back before running its body.

time Base() body Derived() body object in use ~Derived() body ~Base() body vptr → Base vptr → Derived vptr → Base who() → Base::who who() → Derived::who who() → Base::who Each constructor stores its own vptr before its body runs; each destructor restores its own vptr before its body runs.
The dynamic type grows and shrinks. Dispatch is always correct for the part of the object that is alive. A call that would reach a pure virtual function at the Base stage is undefined behaviour and, with GCC or Clang, aborts with "pure virtual method called".
#include <iostream>

struct Base {
    Base() { std::cout << "Base(): "; who(); }
    virtual ~Base() { std::cout << "~Base(): "; who(); }
    virtual void who() const { std::cout << "Base\n"; }
    void call() const { who(); }
};
struct Derived : Base {
    Derived() { std::cout << "Derived(): "; who(); }
    ~Derived() override { std::cout << "~Derived(): "; who(); }
    void who() const override { std::cout << "Derived\n"; }
};

int main() {
    Derived d;
    std::cout << "main: ";
    d.call();
}
Base(): Base
Derived(): Derived
main: Derived
~Derived(): Derived
~Base(): Base

Fixes when the base needs derived-specific data at construction time: pass the data up as a constructor argument, use a factory function that constructs and then calls an init(), or make the base a template on the derived type (CRTP, Ch 3). Java and C# dispatch to the derived override from a base constructor, so candidates with that background often give the wrong answer here.

Name hiding, using-declarations and default arguments

Theory

Name hiding

Name lookup happens before overload resolution, and it stops at the first scope that contains the name. If a derived class declares any member named f, every base member named f is hidden, whatever their parameters and whether or not they are virtual. Overload resolution then picks among the derived fs only, using conversions if it must.

#include <iostream>

struct Base {
    void f(int) { std::cout << "Base::f(int)\n"; }
    void f(const char*) { std::cout << "Base::f(const char*)\n"; }
};
struct Hides : Base {
    void f(double) { std::cout << "Hides::f(double)\n"; }
};
struct Unhides : Base {
    using Base::f;  // brings every Base::f overload into this scope
    void f(double) { std::cout << "Unhides::f(double)\n"; }
};

int main() {
    Hides h;
    h.f(1);       // finds only Hides::f, converts 1 to double
    // h.f("x");  // error: no Hides::f takes a const char*
    h.Base::f(1); // qualified lookup still reaches the base
    Unhides u;
    u.f(1);
    u.f("x");
    u.f(2.5);
}
Hides::f(double)
Base::f(int)
Base::f(int)
Base::f(const char*)
Unhides::f(double)

A using-declaration brings all overloads of the name and can also change their access (a public: using Base::f; in a privately derived class re-exposes f). If the derived class declares a function with the same signature as one brought in by using, the derived one wins. using Base::Base; is the related C++11 feature for inheriting constructors.

Interview trap

Overriding one overload of a virtual function hides the other overloads. If Base has virtual void draw(int) and virtual void draw(double) and Derived overrides only draw(int), then derived.draw(2.5) calls draw(int) with a truncated argument. Calls through a Base& still see both. Add using Base::draw;. GCC's -Woverloaded-virtual warns about this.

Default arguments are statically bound

The function body is chosen at run time, but default arguments are pasted in at compile time from the declaration the compiler sees, which is the one in the static type ([dcl.fct.default]/10). So an override with a different default produces a call that mixes the derived body with the base default.

struct Base    { virtual void draw(int width = 1) const; };
struct Derived : Base { void draw(int width = 5) const override; };
Derived d; Base& b = d;
b.draw();   // Derived::draw(1): derived body, base default
d.draw();   // Derived::draw(5)

The rule: never redefine a default argument in an override (Scott Meyers, Effective C++ item 37). If defaults are needed, use the non-virtual interface idiom: a public non-virtual function with the default calls a private virtual one without.

Access and virtual functions

Access is checked at the call site using the static type, while the function that runs comes from the dynamic type ([class.access.virt]). So a private override is reachable through a public base function, which is what makes the non-virtual interface (NVI) idiom work: public non-virtual functions fix the policy and call private virtual hooks.

class Logger {
public:
    void log(std::string_view msg) { if (!msg.empty()) write(msg); }  // fixed policy
    virtual ~Logger() = default;
private:
    virtual void write(std::string_view msg) = 0;  // derived classes override a private hook
};

Multiple inheritance, the diamond and virtual inheritance

Theory

Layout and pointer adjustment

With struct File : Readable, Writable, a File object contains a complete Readable subobject, then a complete Writable subobject, then File's own members. Each polymorphic subobject has its own vptr. Converting a File* to a Writable* therefore changes the address: the compiler adds the offset of the Writable subobject (and checks for null first). A virtual call through the Writable* to a function that File overrides goes through a small thunk that subtracts the offset again before jumping to File's function.

struct File : Readable, Writable vptr (Readable-in-File) Readable::r vptr (Writable-in-File) Writable::w File::f +0 +16 +28 File* and Readable* point to +0. Writable* points to +16: the upcast adds 16. f reuses the tail padding of Writable, so sizeof(File) is 32, not 40. Writable* struct D : B, C (B, C : virtual A) vptr (B-in-D) B::b vptr (C-in-D) C::c D::d A::a (one shared copy) B and C do not know where A is: the distance differs in a B, a C and a D. Each vtable stores a vbase offset; reaching a.a costs an extra load.
Every polymorphic base gets its own vptr; a virtual base gets one shared slot. Offsets are for the Itanium ABI on a 64-bit target with int members. With non-virtual inheritance the diamond would hold two separate A subobjects, one inside B and one inside C.
#include <iostream>

struct Readable { virtual ~Readable() = default; virtual int read() { return 1; } int r = 0; };
struct Writable { virtual ~Writable() = default; virtual int write() { return 2; } int w = 0; };
struct File : Readable, Writable { int f = 0; };

int main() {
    File file;
    const char* base = reinterpret_cast<const char*>(&file);
    Readable* rp = &file;
    Writable* wp = &file;  // implicit upcast adjusts the pointer
    std::cout << "Readable at +" << reinterpret_cast<const char*>(rp) - base << '\n';
    std::cout << "Writable at +" << reinterpret_cast<const char*>(wp) - base << '\n';
    std::cout << "wp == &file: " << (wp == &file) << '\n';           // compares after converting &file
    std::cout << "most derived: " << (dynamic_cast<void*>(wp) == static_cast<void*>(&file)) << '\n';
    std::cout << "sizeof(File) = " << sizeof(File) << '\n';
}
Readable at +0
Writable at +16
wp == &file: 1
most derived: 1
sizeof(File) = 32

This is also why casting a File* to void* and then to Writable* is wrong: void* erases the type, so no adjustment happens and the result points at the Readable subobject. Always cast back to exactly the type you converted from.

The diamond and virtual inheritance

If B and C both derive from A normally and D derives from both, a D holds two A subobjects. d.a and the conversion D* → A* are ambiguous and fail to compile; you must say d.B::a. Declaring the inheritance virtual (struct B : virtual A) makes all virtual A bases in a complete object share one subobject.

Rules that come with virtual inheritance:

  • The most derived class initialises the virtual base. D's constructor calls A's constructor directly, before any non-virtual base. The A(...) initialisers in B's and C's constructors are ignored when they run as part of a D. If D does not name A, A is default-constructed, which fails to compile if A has no default constructor.
  • Virtual bases are constructed first, in depth-first, left-to-right order of the class graph, and destroyed last.
  • Cost: access to a virtual base's members goes through a vbase offset in the vtable, and static_cast from a virtual base down to a derived class is ill-formed, because the offset is not a compile-time constant. Use dynamic_cast.
  • The standard library uses it in iostreams: std::iostream derives from std::istream and std::ostream, which both derive virtually from std::basic_ios.

In practice, keep multiple inheritance for interfaces: classes with only pure virtual functions and no data, where the diamond has nothing to duplicate. The design side of this is in Ch 3.

Covariant returns, dynamic_cast and RTTI

Theory

Covariant return types

An override may return a pointer or reference to a class derived from the base function's return type (with the same or fewer cv-qualifiers). The classic use is clone(): callers holding a Circle get a Circle* back without a cast, and callers holding a Shape get a Shape*. When the override is called through the base, the compiler adjusts the returned pointer if the base subobject is not at offset 0 (another thunk). Covariance works only for raw pointers and references, so std::unique_ptr<Circle> cannot override a function returning std::unique_ptr<Shape>; the usual workaround is a public non-virtual clone() that wraps a private virtual one returning a raw pointer.

#include <iostream>
#include <memory>

struct Shape {
    virtual ~Shape() = default;
    virtual Shape* clone() const = 0;
    virtual const char* name() const = 0;
};
struct Circle : Shape {
    double r = 2;
    Circle* clone() const override { return new Circle(*this); }  // covariant
    const char* name() const override { return "circle"; }
};

int main() {
    Circle c;
    std::unique_ptr<Circle> c2(c.clone());  // Circle*, no cast needed
    const Shape& s = c;
    std::unique_ptr<Shape> s2(s.clone());   // through the base: Shape*
    std::cout << c2->r << ' ' << s2->name() << '\n';
}
2 circle

dynamic_cast

dynamic_cast asks the run-time type information (RTTI) whether the object really has the requested type. It requires a polymorphic source type (at least one virtual function) for downcasts and cross-casts. Its results:

  • dynamic_cast<Dog*>(animal_ptr) returns nullptr on failure.
  • dynamic_cast<Dog&>(animal_ref) throws std::bad_cast on failure, because there is no null reference.
  • dynamic_cast<void*>(p) returns the address of the most derived object.
  • Cross-casts work: with File : Readable, Writable, a Readable* can be dynamic_cast to Writable* when the object is a File.
  • Upcasts need no RTTI and compile to the same code as static_cast.

Cost. With GCC and Clang, a downcast reads the type_info from the vtable and walks the class hierarchy description, comparing type names or pointers. That costs tens of nanoseconds for deep or multiple hierarchies, much more than a virtual call. When the target class is final, a compiler is allowed to replace the hierarchy walk with a single vtable-pointer comparison (recent Clang does this as an optimisation), but do not rely on it. Frequent dynamic_cast is usually a design smell: a virtual function or the visitor pattern expresses the same thing.

static_cast downcasts are unchecked. If the object is not really a Dog, using the result is undefined behaviour. They are fine when the type is known by construction (CRTP does exactly this).

typeid and type_info

typeid(expr) returns a const std::type_info&. For a glvalue of polymorphic type it evaluates the expression and reports the dynamic type; for anything else it reports the static type at compile time. typeid(*p) with a null polymorphic p throws std::bad_typeid. type_info::name() is implementation-defined (GCC and Clang return the mangled name such as 3Dog), so compare type_info objects or use std::type_index as a map key rather than printing names. -fno-rtti disables typeid and dynamic_cast (except upcasts) and is common in embedded and some game and trading codebases.

#include <iostream>
#include <typeinfo>

struct Animal { virtual ~Animal() = default; };
struct Dog : Animal { void bark() const { std::cout << "woof\n"; } };
struct Cat : Animal {};

static void poke(Animal& a) {
    if (auto* d = dynamic_cast<Dog*>(&a)) d->bark();
    else std::cout << "not a dog\n";
    try {
        (void)dynamic_cast<Cat&>(a);
        std::cout << "a cat\n";
    } catch (const std::bad_cast&) {
        std::cout << "bad_cast\n";
    }
    std::cout << (typeid(a) == typeid(Dog)) << '\n';  // dynamic type
}

int main() {
    Dog d;
    Cat c;
    poke(d);
    poke(c);
}
woof
bad_cast
1
not a dog
a cat
0

Concept and tricky questions

Drills

ConceptWalk through what the machine does for p->speak() when p is a Base* pointing at a Derived.

Answer

Answer. Load the vptr from offset 0 of *p, load the function pointer from the fixed slot for speak in that vtable, then make an indirect call with p (possibly adjusted) as this.

Why. The compiler knows from Base's definition which slot speak occupies, and every derived class keeps that slot at the same index, so the only run-time work is finding which table this object uses. Under multiple inheritance the slot may hold a thunk that adjusts this before jumping to the real override. A common wrong answer is "the runtime searches for the function by name": nothing is looked up by name, and the cost is constant regardless of hierarchy depth.

TrickyWhy can a constructor not be virtual, and how do you get the effect of one?

Answer

Answer. Virtual dispatch needs an existing object whose vptr names its dynamic type, and the constructor is what creates the object and writes the vptr. To create an object of a type known only at run time, use a factory function, or for copies, a virtual clone() (the "virtual constructor" idiom).

Why. new T requires the exact type at compile time so it knows the size and which constructor to call. A virtual function selects behaviour based on an object that already exists. clone() works because it is called on an existing object, which then constructs a copy of its own type. Candidates sometimes answer "because constructors have no name": that is a grammar detail, not the reason.

ConceptWhat is the difference between overloading, overriding and hiding? Which are decided at compile time?

Answer

Answer. Overloading is several functions with the same name and different parameter lists in the same scope; overload resolution picks one at compile time from the static types of the arguments. Overriding is a derived class redeclaring a base virtual function with the same parameter types, cv-qualifiers and ref-qualifier; the body is picked at run time from the dynamic type of the object. Hiding is what happens when a derived class declares any member with the same name that does not override: every base member of that name disappears from lookup in the derived scope, decided at compile time.

Why. The textbook line "overloading is compile-time polymorphism, overriding is run-time polymorphism" is right but incomplete, and interviewers probe the gap. Overloading never crosses scopes, so a Derived::f(double) does not add an overload to Base::f(int): it hides it, and d.f(1) converts 1 to double. Writing override turns an accidental hide into a compile error, and using Base::f; turns a deliberate one back into overloading. Return types play no part in overloading, and only covariant changes are allowed when overriding.

TrickyCan a pure virtual function have a body? Does a pure virtual destructor need one? Must a class derived from a base with a pure virtual destructor declare its own destructor to be concrete?

Answer

Answer. Yes, yes, and no. A pure virtual function may have a definition, written outside the class (virtual void f() = 0 {} is ill-formed), and derived classes can call it as Base::f(). A pure virtual destructor must be defined, or the link fails with undefined reference to Base::~Base(). A derived class is concrete without writing a destructor, because its implicit destructor overrides the pure one.

Why. = 0 means "derived classes must override this", not "this has no code". Every derived destructor calls the base destructor directly (not through the vtable), so that call needs a body. The pure virtual destructor is the idiom for making a class abstract when no other function is a natural candidate. The common wrong answer, from Java and C# abstract methods, is "pure means no implementation, so a body is a compile error".

TrickyCan a virtual function be inline? static? A member function template?

Answer

Answer. Inline: yes. Static: no. Template: no (although a virtual function inside a class template is fine).

Why. inline is about linkage (multiple definitions allowed); the compiler inlines the body wherever it can devirtualise the call, and still emits an out-of-line copy for the vtable slot. A static member function has no this and is called without an object, so there is no vptr to dispatch on. A member function template can be instantiated with any number of types in any translation unit, so the vtable size and slot numbers could not be fixed when the class is defined. A class template's virtual functions are fine because each instantiation is a separate class with a fixed set of functions.

TrickyA class has one char member and ten virtual functions. What is its sizeof on x86-64 Linux?

Answer

Answer. 16.

Why. The object holds one vptr (8 bytes) whatever the number of virtual functions; the ten function pointers live in the single shared vtable. The char sits at offset 8 and the size rounds up to the 8-byte alignment of the vptr. The common wrong answer is 88 (ten pointers plus a padded char), which confuses the per-class table with the per-object pointer. The answer changes with multiple inheritance, where each polymorphic base subobject brings its own vptr.

ConceptWhen does a base class not need a virtual destructor?

Answer

Answer. When no one deletes a derived object through a pointer to it. The standard way to enforce that is a protected non-virtual destructor. Classes that are never used polymorphically, and final classes, also do not need one.

Why. [expr.delete]/3 makes delete base_ptr undefined only when the dynamic type differs and the destructor is not virtual. A protected destructor turns that misuse into a compile error, so you save the vptr and keep the class trivially destructible where possible. This is C++ Core Guidelines C.35: "public and virtual, or protected and non-virtual". Saying "every class with inheritance needs one" is safe advice but wrong as a rule: mixins and CRTP bases (Ch 3) deliberately use protected non-virtual destructors.

TrickyBase has a virtual destructor. Is Base* p = new Derived[3]; delete[] p; correct?

Answer

Answer. No. It is undefined behaviour even with the virtual destructor.

Why. [expr.delete]/3: for delete[], the dynamic type must be the same as the static type. delete[] walks the array using sizeof of the static type to find each element, and sizeof(Base) is not sizeof(Derived), so it would call destructors at the wrong addresses. The same stride problem breaks p[1]. The virtual destructor fixes single-object delete only. Use std::vector<std::unique_ptr<Base>> for a polymorphic collection.

TrickyCan a derived class override a private virtual function of its base? Why would anyone do that?

Answer

Answer. Yes. Access controls who may call a name; overriding is not a call. It is the basis of the non-virtual interface (NVI) idiom.

Why. The base has a public non-virtual function that checks preconditions, takes locks, logs or times the operation, and then calls a private virtual hook. Derived classes customise the hook but cannot skip the fixed policy, and outside code cannot call the hook directly. Many candidates think private members are "not inherited": they are inherited and present in the object, just not accessible by name from the derived class. The C++ standard library uses this pattern in std::streambuf (public sputc, protected virtual overflow).

TrickyBase::run() is public and virtual. Derived overrides it in a private: section. Which calls compile: base_ref.run() or derived_ref.run()? Which function executes?

Answer

Answer. base_ref.run() compiles and runs Derived::run. derived_ref.run() does not compile.

Why. [class.access.virt]: access is checked at the call site using the static type of the object expression, while the function executed comes from the dynamic type. Through Base& the name is public. Through Derived& it is private. This surprises people who expect the override's access to "win" at run time; access is purely a compile-time property.

ConceptWhen would you choose private inheritance over a data member?

Answer

Answer. When you need to override a virtual function of the class you are using, access its protected members, or (before C++20) get the empty base optimisation. Otherwise prefer a member.

Why. Both model "implemented in terms of" without an is-a relationship, since outside code cannot convert to the private base. A member is simpler, has a name and allows several instances, while a private base's names enter your scope and can hide or clash. C++20 [[no_unique_address]] gives an empty member zero size, which removes the main historical reason (storing an empty deleter or allocator for free).

TrickyA constructor does std::memset(this, 0, sizeof(*this)); to clear all members. The class has a virtual function. What happens?

Answer

Answer. The vptr is overwritten with zero, so the next virtual call jumps through a null table and crashes. Formally it is undefined behaviour, because a polymorphic class is not trivially copyable.

Why. The vptr is written by the constructor before the body runs, so the memset in the body wipes it. The same applies to memcpy between polymorphic objects, calloc plus a cast instead of construction, and writing polymorphic objects to a file and reading them back (the stored vptr is meaningless in another run, especially with ASLR). The fix is to use member initialisers (int x = 0;) or value initialisation. std::is_trivially_copyable_v<T> tells you whether byte copies are allowed.

TrickyThe link fails with undefined reference to `vtable for Widget'. Every function you call is defined. What is the likely cause?

Answer

Answer. Some non-pure virtual function of Widget is declared but never defined, most often the first one out of line (the key function), or the .cpp that defines it is not linked in.

Why. GCC and Clang emit the vtable only in the translation unit that defines the key function: the first virtual function that is not pure and not inline. Every constructor references the vtable, so the error appears wherever an object is constructed, even if nobody calls the missing function. Common culprits: a declared but undefined virtual destructor, forgetting to add a file to the build, or (with Qt) a missing moc step. A pure virtual function does not cause this; the slot simply holds __cxa_pure_virtual.

TrickyDoes marking a class final make all virtual calls on it faster?

Answer

Answer. Only calls made through a pointer, reference or object whose static type is the final class (or that otherwise prove the type). Calls through a Base* still dispatch.

Why. If the static type is Leaf& and Leaf is final, the dynamic type must be exactly Leaf, so the compiler can call Leaf::f directly and inline it. A Base* could point at any class, so final on Leaf tells the compiler nothing at that call site (unless whole-program devirtualisation can see the entire hierarchy). final also lets dynamic_cast<Leaf*> become a vptr comparison.

ConceptCompare compile-time and run-time polymorphism in C++. What does each one cost, and when would you choose each?

Answer

Answer. Compile-time (static) polymorphism is overloading, templates constrained by concepts, and CRTP: the call target is fixed during compilation, so it is a direct, inlinable call with no per-object overhead. Run-time (dynamic) polymorphism is virtual functions called through a pointer or reference: one vptr per object, two dependent loads and an indirect call, usually no inlining. Choose static dispatch when the set of types is known at build time and the call is hot; choose virtual functions when the type is picked at run time, objects of different types share one container, or the implementation must stay behind a library boundary.

Why. Static polymorphism pays in other currencies: one instantiation per type (code size and instruction cache), longer compile times, implementation in headers, and no heterogeneous containers. std::variant with std::visit sits between the two: a closed set of types, value storage and a jump table instead of a vtable. The usual wrong answer is "virtual calls are slow because of the vtable lookup". The lookup costs a few cycles when predicted; the real costs are the lost inlining and mispredicted branches on mixed targets (see the cost of a virtual call).

ConceptWith a diamond using virtual inheritance, who constructs the shared base, and when?

Answer

Answer. The most derived class constructs it, first, before any non-virtual base. Initialisers for the virtual base written in intermediate classes are ignored when those classes are not the most derived.

Why. There is only one virtual base subobject, so exactly one constructor call must initialise it; letting either intermediate class do it would be arbitrary. If the most derived class does not name the virtual base in its initialiser list, the virtual base is default-constructed, which is a compile error if it has no default constructor. The reading drill on virtual bases below shows the order.

ConceptWhen must you use dynamic_cast for a downcast, and when is static_cast fine?

Answer

Answer. Use static_cast when you know the dynamic type by construction (it is free). Use dynamic_cast when you need to ask, when you cast across branches of a hierarchy, or when the base is virtual.

Why. static_cast<Derived*>(base_ptr) just subtracts a compile-time offset; if the object is not a Derived, using the result is undefined behaviour. dynamic_cast consults RTTI and returns null (or throws std::bad_cast for references). From a virtual base, static_cast is ill-formed because the offset depends on the most derived type and is known only at run time. Cross-casts (Readable* to Writable*) are only possible with dynamic_cast.

TrickyYou have void collide(const Shape&) and void collide(const Circle&). Given Shape& s that refers to a Circle, which one does collide(s) call? How would you make it choose by dynamic type?

Answer

Answer. collide(const Shape&). Overload resolution is done at compile time on static types. To select on the dynamic type, make the operation a virtual member, and for two dynamic types use double dispatch (the visitor pattern) or std::variant with std::visit.

Why. C++ virtual functions give single dispatch: only the object the function is called on is examined at run time. Arguments are never examined. The visitor pattern makes two virtual calls, one on each object, to recover both types; it is the coding drill on double dispatch below. Candidates coming from languages with multimethods sometimes expect the runtime to pick the more specific overload.

Predict the output and find the bug

Drills

OutputDefault arguments on a virtual function. What does this print?

#include <iostream>

struct Base {
    virtual void show(int n = 1) const { std::cout << "Base " << n << '\n'; }
    virtual ~Base() = default;
};
struct Derived : Base {
    void show(int n = 2) const override { std::cout << "Derived " << n << '\n'; }
};

int main() {
    Derived d;
    Base& b = d;
    Base* p = &d;
    b.show();
    p->show();
    d.show();
    static_cast<Base>(d).show();
}
Answer
Derived 1
Derived 1
Derived 2
Base 1

Why. The body is chosen by the dynamic type, the default argument by the static type ([dcl.fct.default]/10). Through Base& and Base* the compiler inserts 1, then dispatch runs Derived::show. Through d the static type is Derived, so the default is 2. The last line creates a temporary Base by slicing, so it is a real Base object. Rule: never change a default argument in an override.

OutputFour ways to hold a dog. What does this print?

#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Animal {
    virtual std::string sound() const { return "..."; }
    virtual ~Animal() = default;
};
struct Dog : Animal {
    std::string sound() const override { return "woof"; }
};

int main() {
    std::vector<Animal> zoo;
    zoo.push_back(Dog{});
    std::vector<std::unique_ptr<Animal>> zoo2;
    zoo2.push_back(std::make_unique<Dog>());
    Dog rex;
    Animal& ref = rex;
    Animal copy = ref;
    std::cout << zoo[0].sound() << ' ' << zoo2[0]->sound() << ' '
              << ref.sound() << ' ' << copy.sound() << '\n';
}
Answer
... woof woof ...

Why. std::vector<Animal> stores Animal objects, so push_back(Dog{}) copies (moves) only the Animal part into a new Animal: slicing. Animal copy = ref; slices too, even though ref refers to a dog, because the copy is a fresh Animal whose constructor installs Animal's vptr. Only the reference and the pointer keep the dynamic type. Making Animal's copy operations protected or deleted would turn both slicing lines into compile errors.

Find the bugThis program prints one line and exits normally. What is wrong, and how would you catch it?

#include <iostream>
#include <vector>

struct Buffer {
    ~Buffer() { std::cout << "~Buffer\n"; }
    virtual void fill() {}
};
struct FileBuffer : Buffer {
    std::vector<char> data = std::vector<char>(4096);
    ~FileBuffer() { std::cout << "~FileBuffer\n"; }
};

int main() {
    Buffer* b = new FileBuffer;
    b->fill();
    delete b;
}
Answer

Answer. Buffer is polymorphic but its destructor is not virtual, so delete b is undefined behaviour. In practice it prints only ~Buffer: ~FileBuffer never runs and the 4 KB vector leaks. On Linux, LeakSanitizer reports the leaked 4096-byte buffer, and ASan can also report new-delete-type-mismatch, because the object was allocated with 32 bytes and sized deallocation frees it as an 8-byte Buffer. The compiler warning below is the most reliable detector.

Why. Without virtual, delete b is compiled as a direct call to Buffer::~Buffer followed by operator delete(b, sizeof(Buffer)). GCC also warns with -Wdelete-non-virtual-dtor, which is in -Wall. Fix: virtual ~Buffer(), or hold the object in a unique_ptr<FileBuffer>. The output is not compared here because the behaviour is undefined.

OutputOverriding one overload. What does this print?

#include <iostream>

struct Printer {
    virtual void print(int x) { std::cout << "int " << x << '\n'; }
    virtual void print(double x) { std::cout << "double " << x << '\n'; }
    virtual ~Printer() = default;
};
struct Fancy : Printer {
    void print(int x) override { std::cout << "fancy int " << x << '\n'; }
};

int main() {
    Fancy f;
    Printer& p = f;
    f.print(2.5);
    p.print(2.5);
    p.print(3);
}
Answer
fancy int 2
double 2.5
fancy int 3

Why. In Fancy's scope, the name print finds only Fancy::print(int); the base print(double) is hidden, so 2.5 is converted to 2. Through Printer&, lookup sees both base overloads, picks print(double) at compile time, and since Fancy does not override it, Printer::print(double) runs. p.print(3) picks print(int) and dispatches to the override. Fix: using Printer::print; in Fancy. GCC's -Woverloaded-virtual flags this.

Find the bugThe thermostat always reports 0. Why?

#include <iostream>

struct Sensor {
    virtual double read() const { return 0.0; }
    virtual ~Sensor() = default;
};
struct Thermo : Sensor {
    double read() { return 21.5; }  // meant to override
};

static void report(const Sensor& s) { std::cout << s.read() << '\n'; }

int main() {
    Thermo t;
    report(t);
    std::cout << t.read() << '\n';
}
Answer
0
21.5

Answer. Thermo::read is not const, so it does not override Sensor::read() const. It is a new function that hides the base one in Thermo's scope. Through const Sensor&, the base version runs.

Why. An override must match the cv-qualifiers and ref-qualifier exactly. Writing double read() const override fixes it, and writing override on the broken version would have turned the bug into a compile error ("marked override, but does not override"). This is the single best argument for always writing override.

OutputVirtual base initialisation. What does this print?

#include <iostream>

struct A {
    explicit A(int v) : v(v) { std::cout << "A(" << v << ")\n"; }
    int v;
};
struct B : virtual A { B() : A(1) { std::cout << "B\n"; } };
struct C : virtual A { C() : A(2) { std::cout << "C\n"; } };
struct D : B, C { D() : A(3) { std::cout << "D\n"; } };

int main() {
    D d;
    std::cout << "d.v = " << d.v << '\n';
    C c;
    std::cout << "c.v = " << c.v << '\n';
}
Answer
A(3)
B
C
D
d.v = 3
A(2)
C
c.v = 2

Why. When D is the most derived class, D's constructor initialises the virtual base A first, with A(3). The A(1) and A(2) initialisers in B and C are skipped because those constructors are not running for the most derived object. When a plain C is created, C is the most derived class, so its A(2) is used. Remove A(3) from D and the program fails to compile, because A has no default constructor: many candidates expect A(1) from the first base instead.

Find the bugWhy does this not compile, and what are the two ways to fix it?

struct Device { int id = 0; };
struct Scanner : Device {};
struct Printer : Device {};
struct Copier : Scanner, Printer {};

int main() {
    Copier c;
    c.id = 7;
    return c.id;
}
Answer

Answer. A Copier contains two Device subobjects (one in Scanner, one in Printer), so c.id is ambiguous. Converting Copier* to Device* is ambiguous for the same reason.

Why. Fix 1: say which one you mean, c.Scanner::id = 7;, if the copier really has two independent devices. Fix 2: if there should be one device, inherit virtually, struct Scanner : virtual Device and struct Printer : virtual Device, so both share a single subobject. Fix 2 costs a vbase offset per access and makes Copier responsible for constructing Device. Often the best fix is design: make Scanner and Printer data-free interfaces and keep the id in Copier.

Find the bugThis prints nothing on stdout and aborts. Why? (Output is not compared: the behaviour is undefined.)

#include <iostream>

struct Task {
    virtual ~Task() { finish(); }
    void finish() { cleanup(); }  // indirect, so the compiler cannot reject it
    virtual void cleanup() = 0;
};
struct Download : Task {
    void cleanup() override { std::cout << "closing socket\n"; }
};

int main() {
    { Download d; }
    std::cout << "done\n";
}
Answer

Answer. When d is destroyed, ~Download runs first, then ~Task sets the vptr back to Task's vtable. finish() then calls cleanup() through that table, whose slot is the pure virtual stub. With GCC and Clang the runtime prints pure virtual method called to stderr and calls std::terminate, so done never prints.

Why. [class.abstract]/6 makes a virtual call to a pure virtual function from the abstract class's constructor or destructor undefined. A direct call would usually be caught at compile or link time; going through a helper hides it. Fix: do the cleanup in ~Download (each class releases what it owns, RAII style), or make cleanup non-pure. Never rely on derived behaviour from a base destructor.

OutputOverloads and dynamic type. What does this print?

#include <iostream>

struct Shape { virtual ~Shape() = default; };
struct Circle : Shape {};

static void collide(const Shape&) { std::cout << "shape\n"; }
static void collide(const Circle&) { std::cout << "circle\n"; }

int main() {
    Circle c;
    Shape& s = c;
    collide(c);
    collide(s);
}
Answer
circle
shape

Why. Overload resolution uses the static type of each argument at compile time. s has static type Shape&, so collide(const Shape&) is chosen even though the object is a Circle. Virtual dispatch happens only on the object a member function is called on. The visitor pattern (coding drill below) is the standard way to dispatch on argument types.

OutputAssignment through a base reference. What does this print?

#include <iostream>

struct Point {
    int x = 0, y = 0;
    virtual ~Point() = default;
    virtual void print() const { std::cout << x << ',' << y << '\n'; }
};
struct Point3 : Point {
    int z = 0;
    void print() const override { std::cout << x << ',' << y << ',' << z << '\n'; }
};

int main() {
    Point3 a;
    a.x = 1; a.y = 2; a.z = 3;
    Point3 b;
    b.x = 7; b.y = 8; b.z = 9;
    Point& ra = a;
    ra = b;
    a.print();
}
Answer
7,8,3

Why. ra = b calls Point::operator=, chosen statically because assignment operators are not virtual here. It copies only the Point fields, leaving a.z at 3: a half-assigned object that matches neither original. This is the assignment form of slicing. Making operator= virtual does not really help, because the override would have to accept a const Point& and decide what to do with a plain Point. The usual fix is to make polymorphic bases non-assignable (protected or deleted copy assignment).

Outputtypeid with and without virtual functions. What does this print?

#include <iostream>
#include <typeinfo>

struct A {};
struct B : A {};
struct P { virtual ~P() = default; };
struct Q : P {};

int main() {
    B b;
    A& a = b;
    Q q;
    P& p = q;
    std::cout << (typeid(a) == typeid(B)) << (typeid(p) == typeid(Q)) << '\n';
    P* none = nullptr;
    try {
        std::cout << typeid(*none).name() << '\n';
    } catch (const std::bad_typeid&) {
        std::cout << "bad_typeid\n";
    }
}
Answer
01
bad_typeid

Why. A is not polymorphic, so typeid(a) is resolved at compile time from the static type A: there is no vptr to consult. P is polymorphic, so typeid(p) reads the dynamic type Q. For a polymorphic glvalue obtained by dereferencing a null pointer, typeid throws std::bad_typeid instead of crashing ([expr.typeid]/3); this is one of the few places where *nullptr has defined behaviour. The name string itself is implementation-defined, which is why the program never prints a successful one.

Write the code

Coding

CodePolymorphic deep copy. Write an abstract Shape with area(), name(), scale(double) and a clone() that returns std::unique_ptr<Shape>, plus two concrete shapes. Then write Drawing, which owns a std::vector<std::unique_ptr<Shape>> and is copyable with deep-copy semantics. Requirements: copying a Drawing must not share shapes; slicing a Shape by accident must not compile; moving must be noexcept.

Answer

Approach. Give Shape a public non-virtual clone() that wraps a private virtual do_clone() returning a raw pointer, so each override can use a covariant return type and the public interface still returns a smart pointer. Make Shape's copy operations protected: derived classes can still copy themselves inside do_clone, but outside code cannot slice. Drawing's copy constructor clones each element; assignment uses copy-and-swap so a single by-value operator= handles copy and move and gives the strong exception guarantee (rule of five from Ch 1).

#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
    virtual std::string name() const = 0;
    virtual void scale(double k) = 0;
    std::unique_ptr<Shape> clone() const { return std::unique_ptr<Shape>(do_clone()); }
protected:
    Shape() = default;
    Shape(const Shape&) = default;             // derived classes may copy, outsiders may not
    Shape& operator=(const Shape&) = default;
private:
    virtual Shape* do_clone() const = 0;
};

class Rect final : public Shape {
public:
    Rect(double w, double h) : w_(w), h_(h) {}
    double area() const override { return w_ * h_; }
    std::string name() const override { return "rect"; }
    void scale(double k) override { w_ *= k; h_ *= k; }
private:
    Rect* do_clone() const override { return new Rect(*this); }  // covariant
    double w_, h_;
};

class Tri final : public Shape {
public:
    Tri(double b, double h) : b_(b), h_(h) {}
    double area() const override { return b_ * h_ / 2; }
    std::string name() const override { return "tri"; }
    void scale(double k) override { b_ *= k; h_ *= k; }
private:
    Tri* do_clone() const override { return new Tri(*this); }
    double b_, h_;
};

class Drawing {
public:
    Drawing() = default;
    Drawing(const Drawing& other) {
        shapes_.reserve(other.shapes_.size());
        for (const auto& s : other.shapes_) shapes_.push_back(s->clone());
    }
    Drawing(Drawing&&) noexcept = default;
    Drawing& operator=(Drawing other) noexcept {  // copy-and-swap: copy happens in the parameter
        shapes_.swap(other.shapes_);
        return *this;
    }
    ~Drawing() = default;

    void add(std::unique_ptr<Shape> s) { shapes_.push_back(std::move(s)); }
    Shape& at(std::size_t i) { return *shapes_.at(i); }
    double total_area() const {
        double total = 0;
        for (const auto& s : shapes_) total += s->area();
        return total;
    }
private:
    std::vector<std::unique_ptr<Shape>> shapes_;
};

int main() {
    Drawing a;
    a.add(std::make_unique<Rect>(2, 3));
    a.add(std::make_unique<Tri>(4, 5));
    Drawing b = a;  // deep copy
    b.at(0).scale(2);
    Drawing c;
    c = b;          // copy assignment
    c.at(1).scale(0.5);
    std::cout << a.total_area() << ' ' << b.total_area() << ' ' << c.total_area() << '\n';
    std::cout << c.at(0).name() << ' ' << c.at(1).name() << '\n';
    // Rect r = static_cast<Rect&>(a.at(0)); fine, but  Shape s = a.at(0);  does not compile
}
16 34 26.5
rect tri

Complexity and pitfalls. Copy is O(n) allocations; move is O(1). Pitfalls: forgetting to override do_clone in a further-derived class silently clones the parent type (marking leaf classes final prevents that class of bug, and some codebases add a CRTP helper that writes do_clone for you); returning std::unique_ptr<Rect> from an override does not compile because smart pointers are not covariant; and a defaulted copy constructor on Drawing would fail to compile (unique_ptr is move-only), which is the compiler telling you a decision is needed.

Estimated rating: ~1400 · standard idiom, but it needs covariance, protected copies and copy-and-swap together

CodeDynamic dispatch without virtual. Implement an Animal "class" in C-style C++: a struct holding a pointer to a table of function pointers (speak, destroy) and a name. Implement Dog (with a bark count) and Cat (grumpy or not) that embed Animal as their first member, factory functions returning Animal*, and free functions speak(const Animal*) and destroy(Animal*) that dispatch. This is how C codebases (the Linux kernel's file_operations, for example) do dispatch, and how C++ compilers implement virtual functions.

Answer

Approach. One constant table per "class" plays the vtable; the first field of every object plays the vptr. Because Animal is the first member of a standard-layout Dog, a Dog* and a pointer to its base member are pointer-interconvertible, so the "override" can reinterpret_cast back to Dog legally. The destroy slot plays the virtual deleting destructor: it frees the object as its real type.

#include <iostream>

struct Animal;
struct AnimalVTable {
    void (*speak)(const Animal*);
    void (*destroy)(Animal*);
};
struct Animal {
    const AnimalVTable* vptr;
    const char* name;
};

static void speak(const Animal* a) { a->vptr->speak(a); }  // the "virtual call"
static void destroy(Animal* a) { a->vptr->destroy(a); }

struct Dog { Animal base; int barks; };
static void dog_speak(const Animal* a) {
    const Dog* d = reinterpret_cast<const Dog*>(a);  // ok: base is the first member
    std::cout << a->name << ':';
    for (int i = 0; i < d->barks; ++i) std::cout << " woof";
    std::cout << '\n';
}
static void dog_destroy(Animal* a) { delete reinterpret_cast<Dog*>(a); }
static constexpr AnimalVTable dog_vtable{dog_speak, dog_destroy};

static Animal* make_dog(const char* name, int barks) {
    Dog* d = new Dog{{&dog_vtable, name}, barks};
    return &d->base;
}

struct Cat { Animal base; bool grumpy; };
static void cat_speak(const Animal* a) {
    const Cat* c = reinterpret_cast<const Cat*>(a);
    std::cout << a->name << ": " << (c->grumpy ? "hiss" : "meow") << '\n';
}
static void cat_destroy(Animal* a) { delete reinterpret_cast<Cat*>(a); }
static constexpr AnimalVTable cat_vtable{cat_speak, cat_destroy};

static Animal* make_cat(const char* name, bool grumpy) {
    Cat* c = new Cat{{&cat_vtable, name}, grumpy};
    return &c->base;
}

int main() {
    Animal* zoo[] = {make_dog("Rex", 2), make_cat("Tom", false), make_cat("Grim", true)};
    for (const Animal* a : zoo) speak(a);
    for (Animal* a : zoo) destroy(a);
}
Rex: woof woof
Tom: meow
Grim: hiss

Complexity and pitfalls. Each call is the same two loads and an indirect call as a real virtual call. Pitfalls: the cast back is only valid while the embedded base is the first member of a standard-layout struct (otherwise you need offsetof, the kernel's container_of); calling delete on the Animal* itself would be undefined, exactly like a missing virtual destructor; and nothing checks that a table's functions match its object type, which is what the compiler-generated version guarantees for you. Mention in the interview that real vtables also hold RTTI and offset-to-top, and that you have reimplemented single inheritance only.

Estimated rating: ~1600 · needs the vtable model and the pointer-interconvertibility rule, little code

CodeType erasure with value semantics. Write a class Drawable that can hold any type T for which a free function draw(const T&, std::ostream&, std::size_t indent) exists, with no common base class required. Drawable must be copyable (deep copy), movable and storable in a std::vector<Drawable>. Make a Document (a vector of Drawable) itself drawable, so a document can contain a copy of itself. Draw a document holding an int, a std::string, a user Circle and a nested copy of the document.

Answer

Approach. This is Sean Parent's "runtime polymorphism without inheritance". Keep the hierarchy private: a nested abstract Concept with virtual draw_ and clone, and a class template Model<T> that stores a T and forwards to the free draw found by argument-dependent lookup. Drawable owns a unique_ptr<Concept>, copies by cloning, and exposes draw as a hidden friend. Constrain the converting constructor so it never hijacks copying a Drawable. This is the same technique as std::function and std::any.

#include <iostream>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

static void draw(int x, std::ostream& out, std::size_t indent) {
    out << std::string(indent, ' ') << x << '\n';
}
static void draw(const std::string& s, std::ostream& out, std::size_t indent) {
    out << std::string(indent, ' ') << s << '\n';
}

class Drawable {
public:
    template <class T>
        requires(!std::is_same_v<std::remove_cvref_t<T>, Drawable>)
    Drawable(T x) : self_(std::make_unique<Model<T>>(std::move(x))) {}
    Drawable(const Drawable& other) : self_(other.self_->clone()) {}
    Drawable(Drawable&&) noexcept = default;
    Drawable& operator=(Drawable other) noexcept {
        self_ = std::move(other.self_);
        return *this;
    }
    ~Drawable() = default;

    friend void draw(const Drawable& d, std::ostream& out, std::size_t indent) {
        d.self_->draw_(out, indent);
    }

private:
    struct Concept {
        virtual ~Concept() = default;
        virtual void draw_(std::ostream& out, std::size_t indent) const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
    };
    template <class T>
    struct Model final : Concept {
        explicit Model(T x) : data(std::move(x)) {}
        void draw_(std::ostream& out, std::size_t indent) const override { draw(data, out, indent); }
        std::unique_ptr<Concept> clone() const override { return std::make_unique<Model>(*this); }
        T data;
    };
    std::unique_ptr<Concept> self_;
};

using Document = std::vector<Drawable>;
static void draw(const Document& doc, std::ostream& out, std::size_t indent) {
    out << std::string(indent, ' ') << "<document>\n";
    for (const auto& d : doc) draw(d, out, indent + 2);
    out << std::string(indent, ' ') << "</document>\n";
}

struct Circle { double r; };
static void draw(const Circle& c, std::ostream& out, std::size_t indent) {
    out << std::string(indent, ' ') << "circle r=" << c.r << '\n';
}

int main() {
    Document doc;
    doc.emplace_back(0);
    doc.emplace_back(std::string("hello"));
    doc.emplace_back(Circle{2});
    Drawable snapshot = doc;  // a deep copy of the document so far
    doc.push_back(std::move(snapshot));
    draw(doc, std::cout, 0);
}
<document>
  0
  hello
  circle r=2
  <document>
    0
    hello
    circle r=2
  </document>
</document>

Complexity and pitfalls. One heap allocation per stored value (a production version adds a small-buffer optimisation, see Ch 18), and one virtual call per draw. Pitfalls: the requires clause is defensive here, because a by-value template is never used to copy its own class ([class.copy.ctor]/5), but if you change the parameter to a forwarding reference T&&, the template beats the copy constructor for a non-const Drawable lvalue and wraps a Drawable inside a Drawable; the free draw for user types must be findable by ADL at instantiation, so declare it in the type's namespace; and copying doc while pushing into it (doc.push_back(doc)) is safe only because the argument is converted to a Drawable before the vector changes, which is why the reference makes the snapshot explicit.

Estimated rating: ~1800 · combines templates, a hidden class hierarchy, ADL and value semantics; many candidates have never seen it

CodeDouble dispatch with the visitor pattern. Build an expression tree with node types Num, Add and Mul owned by std::unique_ptr. Add a Visitor interface and two visitors: one that evaluates the tree to a long and one that prints it fully parenthesised. Adding a new operation must not require editing the node classes.

Answer

Approach. Each node implements accept(Visitor&) by calling v.visit(*this). The first dispatch (virtual accept) recovers the node type; inside it, *this has a static type, so overload resolution picks the right visit overload, and the second dispatch (virtual visit) recovers the visitor type. Visitors keep their state as members. The trade-off: new operations are cheap, new node types require changing every visitor. std::variant plus std::visit is the closed-set, allocation-free alternative.

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Num;
struct Add;
struct Mul;

struct Visitor {
    virtual ~Visitor() = default;
    virtual void visit(const Num&) = 0;
    virtual void visit(const Add&) = 0;
    virtual void visit(const Mul&) = 0;
};

struct Expr {
    virtual ~Expr() = default;
    virtual void accept(Visitor& v) const = 0;
};
using ExprPtr = std::unique_ptr<Expr>;

struct Num final : Expr {
    explicit Num(long v) : value(v) {}
    void accept(Visitor& v) const override { v.visit(*this); }
    long value;
};
struct Add final : Expr {
    Add(ExprPtr l, ExprPtr r) : lhs(std::move(l)), rhs(std::move(r)) {}
    void accept(Visitor& v) const override { v.visit(*this); }
    ExprPtr lhs, rhs;
};
struct Mul final : Expr {
    Mul(ExprPtr l, ExprPtr r) : lhs(std::move(l)), rhs(std::move(r)) {}
    void accept(Visitor& v) const override { v.visit(*this); }
    ExprPtr lhs, rhs;
};

static ExprPtr num(long v) { return std::make_unique<Num>(v); }
static ExprPtr add(ExprPtr l, ExprPtr r) { return std::make_unique<Add>(std::move(l), std::move(r)); }
static ExprPtr mul(ExprPtr l, ExprPtr r) { return std::make_unique<Mul>(std::move(l), std::move(r)); }

class Evaluator final : public Visitor {
public:
    long eval(const Expr& e) { e.accept(*this); return result_; }
    void visit(const Num& n) override { result_ = n.value; }
    void visit(const Add& a) override { long l = eval(*a.lhs); result_ = l + eval(*a.rhs); }
    void visit(const Mul& m) override { long l = eval(*m.lhs); result_ = l * eval(*m.rhs); }
private:
    long result_ = 0;
};

class Printer final : public Visitor {
public:
    std::string print(const Expr& e) { out_.clear(); e.accept(*this); return out_; }
    void visit(const Num& n) override { out_ += std::to_string(n.value); }
    void visit(const Add& a) override { binary(*a.lhs, " + ", *a.rhs); }
    void visit(const Mul& m) override { binary(*m.lhs, " * ", *m.rhs); }
private:
    void binary(const Expr& l, const char* op, const Expr& r) {
        out_ += '(';
        l.accept(*this);
        out_ += op;
        r.accept(*this);
        out_ += ')';
    }
    std::string out_;
};

int main() {
    ExprPtr exprs[] = {mul(add(num(2), num(3)), num(4)), add(num(1), mul(num(2), num(-5)))};
    Printer printer;
    Evaluator evaluator;
    for (const auto& e : exprs) std::cout << printer.print(*e) << " = " << evaluator.eval(*e) << '\n';
}
((2 + 3) * 4) = 20
(1 + (2 * -5)) = -9

Complexity and pitfalls. O(n) per traversal, two virtual calls per node. Pitfalls: in Evaluator, storing the left result in a local before evaluating the right side matters, because both recursive calls overwrite result_; forward declarations of every node are needed before Visitor; and deep trees recurse, so a pathological input can overflow the stack (an explicit stack fixes that). Interviewers often follow up with "how would you add a Sub node?" (every visitor changes) and "how is this different from std::visit?" (closed set, no heap, no virtual calls).

Estimated rating: ~1500 · classic pattern; the subtle part is explaining why two dispatches recover two types

CodeHand-rolled RTTI. The codebase is built with -fno-rtti, so dynamic_cast is unavailable. Implement LLVM-style isa<T>, dyn_cast<T> and cast<T> for a hierarchy Shape → Polygon → {Square, Triangle} and Shape → Circle, including a check for the intermediate class Polygon. Use it to sum polygon sides and square side lengths.

Answer

Approach. Store a kind enum in the base, set by each constructor. Order the enumerators so that each intermediate class covers a contiguous range (all polygons between K_Square and K_LastPolygon); then each class's static classof is one or two comparisons. dyn_cast checks classof and then does a static_cast, which is safe because the check proved the type. cast asserts instead of checking. This is exactly how LLVM's llvm/Support/Casting.h works.

#include <cassert>
#include <iostream>
#include <memory>
#include <vector>

class Shape {
public:
    enum Kind { K_Square, K_Triangle, K_LastPolygon = K_Triangle, K_Circle };
    Kind kind() const { return kind_; }
    virtual ~Shape() = default;  // only so unique_ptr<Shape> deletes correctly
protected:
    explicit Shape(Kind k) : kind_(k) {}
private:
    const Kind kind_;
};

class Polygon : public Shape {
public:
    int sides() const { return sides_; }
    static bool classof(const Shape* s) { return s->kind() >= K_Square && s->kind() <= K_LastPolygon; }
protected:
    Polygon(Kind k, int n) : Shape(k), sides_(n) {}
private:
    int sides_;
};

class Square final : public Polygon {
public:
    explicit Square(double s) : Polygon(K_Square, 4), side(s) {}
    static bool classof(const Shape* s) { return s->kind() == K_Square; }
    double side;
};

class Triangle final : public Polygon {
public:
    Triangle() : Polygon(K_Triangle, 3) {}
    static bool classof(const Shape* s) { return s->kind() == K_Triangle; }
};

class Circle final : public Shape {
public:
    explicit Circle(double r) : Shape(K_Circle), radius(r) {}
    static bool classof(const Shape* s) { return s->kind() == K_Circle; }
    double radius;
};

template <class To, class From>
static bool isa(const From* p) { return To::classof(p); }

template <class To, class From>
static To* dyn_cast(From* p) { return p && To::classof(p) ? static_cast<To*>(p) : nullptr; }

template <class To, class From>
static To* cast(From* p) {
    assert(isa<To>(p) && "cast<> to the wrong type");
    return static_cast<To*>(p);
}

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Square>(2));
    shapes.push_back(std::make_unique<Circle>(1));
    shapes.push_back(std::make_unique<Triangle>());
    shapes.push_back(std::make_unique<Square>(3));

    int polygons = 0, sides = 0;
    double square_sides = 0;
    for (const auto& s : shapes) {
        if (auto* p = dyn_cast<Polygon>(s.get())) { ++polygons; sides += p->sides(); }
        if (auto* sq = dyn_cast<Square>(s.get())) square_sides += sq->side;
    }
    std::cout << "polygons=" << polygons << " sides=" << sides << '\n';
    std::cout << "square side sum=" << square_sides << '\n';
    std::cout << "circle radius=" << cast<Circle>(shapes[1].get())->radius << '\n';
    std::cout << "isa<Circle>(square)=" << isa<Circle>(shapes[0].get()) << '\n';
}
polygons=3 sides=11
square side sum=5
circle radius=1
isa<Circle>(square)=0

Complexity and pitfalls. Each check is O(1) with no memory access beyond the kind field, much cheaper than dynamic_cast's hierarchy walk. Pitfalls: adding a new polygon after K_LastPolygon silently breaks Polygon::classof, so new kinds must go inside the range and the "last" marker must move; the hierarchy is closed (a plugin cannot add a kind without editing the enum); and cast becomes an unchecked static_cast in release builds where assert is compiled out.

Estimated rating: ~1600 · small code, but the range trick for intermediate classes and the safety argument are what interviewers probe

CodeSelf-registering plugin factory. Design a CodecRegistry that maps names to factories returning std::unique_ptr<Codec>, where each concrete codec registers itself from its own source file with one line, without editing a central list. Requirements: registration must work regardless of the order in which translation units are initialised; create of an unknown name returns nullptr; duplicate names are rejected; names() lists registered codecs in sorted order.

Answer

Approach. The registry is a function-local static ("Meyers singleton"), created on first use, so a static registration object in any translation unit can safely call into it during static initialisation: this sidesteps the static initialisation order fiasco covered in Ch 3. A small Register<T> helper turns registration into one line per codec. A std::map keeps names sorted and emplace reports duplicates.

#include <cctype>
#include <functional>
#include <iostream>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include <vector>

class Codec {
public:
    virtual ~Codec() = default;
    virtual std::string encode(const std::string& in) const = 0;
};

class CodecRegistry {
public:
    using Factory = std::function<std::unique_ptr<Codec>()>;
    static CodecRegistry& instance() {
        static CodecRegistry registry;  // constructed on first call, thread-safe since C++11
        return registry;
    }
    bool add(std::string name, Factory f) { return factories_.emplace(std::move(name), std::move(f)).second; }
    std::unique_ptr<Codec> create(const std::string& name) const {
        auto it = factories_.find(name);
        return it == factories_.end() ? nullptr : it->second();
    }
    std::vector<std::string> names() const {
        std::vector<std::string> out;
        for (const auto& [name, factory] : factories_) out.push_back(name);
        return out;
    }
private:
    CodecRegistry() = default;
    std::map<std::string, Factory> factories_;
};

template <class T>
struct Register {
    explicit Register(const char* name) {
        CodecRegistry::instance().add(name, [] { return std::make_unique<T>(); });
    }
};

// Each block below would normally live in its own .cpp file.
class Upper final : public Codec {
public:
    std::string encode(const std::string& in) const override {
        std::string out = in;
        for (char& c : out) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
        return out;
    }
};
static const Register<Upper> reg_upper("upper");

class Reverse final : public Codec {
public:
    std::string encode(const std::string& in) const override { return {in.rbegin(), in.rend()}; }
};
static const Register<Reverse> reg_reverse("reverse");

class Rot13 final : public Codec {
public:
    std::string encode(const std::string& in) const override {
        std::string out = in;
        for (char& c : out) {
            if (c >= 'a' && c <= 'z') c = static_cast<char>('a' + (c - 'a' + 13) % 26);
            else if (c >= 'A' && c <= 'Z') c = static_cast<char>('A' + (c - 'A' + 13) % 26);
        }
        return out;
    }
};
static const Register<Rot13> reg_rot13("rot13");

int main() {
    auto& registry = CodecRegistry::instance();
    const auto names = registry.names();
    for (std::size_t i = 0; i < names.size(); ++i) std::cout << (i ? " " : "") << names[i];
    std::cout << '\n';
    for (const char* name : {"upper", "rot13", "reverse", "zip"}) {
        auto codec = registry.create(name);
        std::cout << name << ": " << (codec ? codec->encode("Hello") : "<unknown>") << '\n';
    }
    std::cout << "duplicate accepted: " << registry.add("upper", [] { return std::make_unique<Reverse>(); }) << '\n';
}
reverse rot13 upper
upper: HELLO
rot13: Uryyb
reverse: olleH
zip: <unknown>
duplicate accepted: 0

Complexity and pitfalls. Lookup is O(log n). Pitfalls worth raising: if codecs live in a static library, the linker drops object files that nothing references, so their registration objects never run (fix with --whole-archive or an explicit reference); registration during static initialisation is single-threaded, but adding codecs later from several threads needs a mutex; and the registry must not be a namespace-scope global, because another TU's registration could run before it is constructed.

Estimated rating: ~1500 · easy code; the static initialisation and linker questions are what make it senior-level

Cheat sheet

Theory
  • Dispatch happens only for a virtual function, called through a pointer or reference (or this), with an unqualified name. Overload resolution and default arguments always use the static type.
  • Layout (Itanium ABI): one vtable per class in read-only data; one vptr per polymorphic subobject at its offset 0. The vtable holds virtual base offsets, offset-to-top, the RTTI pointer, then function slots, including two destructor slots. A virtual call is two loads and an indirect call; the real cost is lost inlining and mispredicted branches.
  • Always write override; a missing const or a different parameter type silently creates a new function that hides the base one. Use using Base::f; to unhide overloads. final enables devirtualisation when the static type is the final class.
  • Destructors: public and virtual, or protected and non-virtual. unique_ptr<Base> needs the virtual destructor; shared_ptr<Base> created from a Derived does not. delete[] through a base pointer is undefined even with a virtual destructor.
  • Slicing happens on copy or assignment by value into a base; the copy gets the base vptr. Pass by reference, store smart pointers, catch by const&, protect base copy operations, add clone().
  • Constructors and destructors dispatch to the class currently being built or destroyed, never further down. An indirect call to a pure virtual function there aborts with "pure virtual method called".
  • Multiple inheritance adjusts pointers on conversion and uses thunks. Non-virtual diamonds duplicate the base and make access ambiguous; virtual inheritance shares one base, which the most derived class constructs first. static_cast down from a virtual base is ill-formed.
  • RTTI: dynamic_cast returns null for pointers and throws bad_cast for references; typeid sees the dynamic type only for polymorphic glvalues. Covariant returns work for raw pointers and references, not smart pointers.

Part A · Object-oriented programming · Chapter 3

Abstraction and class design

How to design classes that other people can use correctly: interfaces, operator overloading with the canonical signatures, static members, friends, const correctness and the this pointer, then the design rules (SOLID, composition over inheritance, PIMPL) and compile-time polymorphism with CRTP. Interviewers ask these to see whether you can write a type that behaves like a built-in type and a class hierarchy that survives change.

TheoryDrillsCoding· about 6 hours· prerequisites: Ch 1 (special members, rule of 3/5/0), Ch 2 (virtual functions)

Abstraction and encapsulation

Theory

Mental model. Abstraction decides what a type offers: the smallest set of operations that lets callers think about the concept without the details. Encapsulation decides how that is protected: the representation is hidden behind access control so that only the class's own functions can break its invariants. Abstraction is a design activity (choosing the interface); encapsulation is a language mechanism (private, the PIMPL idiom, modules) that enforces it.

A std::vector abstracts "a resizable sequence" (push_back, size, indexing). It encapsulates three pointers whose relationship (begin <= end <= capacity_end) is an invariant that no caller can violate because the pointers are private. You could replace the representation with a pointer plus two sizes and no caller would notice: that is the test of a good abstraction.

  • You can have encapsulation without much abstraction: a class with private fields and a getter and setter for each one hides the representation in name only, because every caller is still coupled to it.
  • You can have abstraction without encapsulation: a struct of public fields with free functions that operate on it, as in C APIs. Nothing stops a caller from breaking the invariant.

Constructors establish the invariant and the destructor releases resources (Ch 1); every public member function may assume the invariant on entry and must restore it on exit, including when it throws.

Interfaces via pure virtual classes

Theory

C++ has no interface keyword. The idiom is a class with only pure virtual functions, a virtual (or protected) destructor and no data. Concrete classes inherit from one or more interfaces publicly and implement them. Because an interface has no data, multiple inheritance of interfaces has no diamond problem worth worrying about (Ch 2).

#include <cstdint>
#include <iostream>

class Clock {  // the interface: what the code needs, nothing more
public:
    virtual ~Clock() = default;
    virtual std::int64_t now_ms() const = 0;
protected:
    Clock() = default;
    Clock(const Clock&) = default;             // no slicing through the interface
    Clock& operator=(const Clock&) = default;
};

class FakeClock final : public Clock {
public:
    std::int64_t now_ms() const override { return t_; }
    void advance(std::int64_t ms) { t_ += ms; }
private:
    std::int64_t t_ = 0;
};

class Timeout {  // depends on the abstraction, not on a real clock
public:
    Timeout(const Clock& clock, std::int64_t ms) : clock_(clock), deadline_(clock.now_ms() + ms) {}
    bool expired() const { return clock_.now_ms() >= deadline_; }
private:
    const Clock& clock_;
    std::int64_t deadline_;
};

int main() {
    FakeClock clock;
    Timeout t(clock, 100);
    std::cout << t.expired();
    clock.advance(99);
    std::cout << t.expired();
    clock.advance(1);
    std::cout << t.expired() << '\n';
}
001

Design rules for an interface class:

  • Destructor: public virtual if owners delete through the interface, otherwise protected non-virtual (Core Guidelines C.35).
  • No data members and no constructors that take arguments. Data turns the interface into a base class with state, and then the diamond and slicing problems come back.
  • Small and focused: clients should not depend on functions they do not use (the interface segregation principle, below). Several small interfaces composed by multiple inheritance beat one large one.
  • Protected or deleted copy operations, so code cannot copy "a Clock" and slice it.

The alternative in modern C++ is a concept: a compile-time interface checked at template instantiation, with no vtable and no inheritance. Use a virtual interface when the implementation is chosen at run time or must be hidden behind a library boundary; use a concept (or CRTP, below) when the type is known at compile time and the call is on a hot path.

Operator overloading

Theory

Mental model. An overloaded operator is an ordinary function with a special name, picked by overload resolution. a + b looks for both a.operator+(b) and operator+(a, b). The rule for a good overload: it must do what the operator does for int, with the same arity, precedence (which you cannot change anyway) and expected semantics. If + would surprise a reader, use a named function.

Member or non-member?

  • Must be members: =, (), [], -> and conversion operators. (C++23 lets operator() and operator[] be static members, and lets operator[] take several arguments.)
  • Should be members: operators that modify the left operand: compound assignment (+=, -=) and ++/--.
  • Should be non-members: symmetric binary operators (+, ==, <), so that implicit conversions apply to both operands. With a member operator+, money + 5 compiles but 5 + money does not, because conversions are never applied to the object a member function is called on.
  • Must be non-members: operator<< and operator>> for streams, because the left operand is the stream, not your class.
  • Hidden friends: define the non-member inside the class as a friend. It can access private members, it is found only by argument-dependent lookup (so it does not pollute overload sets for unrelated types) and it is the standard modern style.

Canonical signatures

OperatorCanonical declaration (for a class T)Notes
copy =T& operator=(const T& other);member; returns *this for chaining; handle self-assignment (Ch 1)
move =T& operator=(T&& other) noexcept;member; noexcept so containers use it
+=T& operator+=(const T& rhs);member; does the real work
+friend T operator+(T lhs, const T& rhs) { lhs += rhs; return lhs; }non-member; returns a new value, not a reference and not const T
==friend bool operator==(const T&, const T&) = default;C++20: also gives != and the reversed order
<=>auto operator<=>(const T&) const = default;C++20: gives < > <= >=; defaulting it also declares a defaulted ==
[]V& operator[](std::size_t i); and const V& operator[](std::size_t i) const;member; provide both const overloads
()R operator()(Args...) const;member; makes a function object
<<friend std::ostream& operator<<(std::ostream& os, const T& x);non-member; returns the stream for chaining
prefix ++T& operator++();member; increments and returns *this
postfix ++T operator++(int);member; the int is a dummy tag; returns the old value by copy
conversionexplicit operator bool() const;explicit still allows if (x) (contextual conversion) but blocks int n = x;

Arithmetic in terms of compound assignment

Write += as the member that does the work and + as a non-member that copies its left operand and calls +=. Taking lhs by value lets the caller's temporary be moved in, and returning the local enables NRVO (Ch 24). Do not return const T from operator+: that old advice (to block (a + b) = c) also blocks moving from the result. If you want to block assignment to temporaries, ref-qualify the assignment operator instead: T& operator=(const T&) &;.

#include <compare>
#include <cstdint>
#include <iomanip>
#include <iostream>

class Money {
public:
    constexpr Money(std::int64_t cents = 0) : cents_(cents) {}  // implicit on purpose: 5 means 5 cents
    Money& operator+=(const Money& rhs) { cents_ += rhs.cents_; return *this; }
    Money& operator-=(const Money& rhs) { cents_ -= rhs.cents_; return *this; }
    Money operator-() const { return Money(-cents_); }

    friend Money operator+(Money lhs, const Money& rhs) { lhs += rhs; return lhs; }
    friend Money operator-(Money lhs, const Money& rhs) { lhs -= rhs; return lhs; }
    friend auto operator<=>(const Money&, const Money&) = default;  // also declares ==
    friend std::ostream& operator<<(std::ostream& os, const Money& m) {
        std::int64_t c = m.cents_ < 0 ? -m.cents_ : m.cents_;
        return os << (m.cents_ < 0 ? "-" : "") << c / 100 << '.' << std::setw(2) << std::setfill('0') << c % 100;
    }
private:
    std::int64_t cents_;
};

int main() {
    Money price = 1999;
    Money total = price + 5;    // Money + int: 5 converts
    Money more = 250 + price;   // int + Money: works because operator+ is a non-member
    std::cout << total << ' ' << more << ' ' << -(more - total) << '\n';
    std::cout << (total < more) << (total == 2004) << (2004 != total) << '\n';
}
20.04 22.49 -2.45
110

Prefix and postfix increment

The postfix form takes a dummy int parameter purely so the two can be overloaded. Prefix increments and returns a reference to the same object. Postfix must return the old value, so it makes a copy, increments, and returns the copy by value. Implement postfix in terms of prefix:

Iter& operator++()    { advance(); return *this; }
Iter  operator++(int) { Iter old = *this; ++*this; return old; }

That copy is why ++it is the habit for iterators; for int and simple iterators the optimiser removes it.

Stream operators

operator<< takes the stream by non-const reference and the object by const reference, writes only the object's representation (no trailing newline) and returns the stream so that os << a << b chains. operator>> takes the object by non-const reference and must set std::ios::failbit on malformed input rather than leaving the object half-written.

C++20 comparisons: <=> and ==

C++20 replaced writing six comparison operators with two primary ones. The compiler rewrites secondary operators in terms of them ([over.match.oper]):

  • a != b is rewritten as !(a == b), and a == b also tries the reversed b == a. So one operator==(const T&, int) handles t == 1, 1 == t, t != 1 and 1 != t.
  • a < b is rewritten as (a <=> b) < 0, also trying 0 < (b <=> a).
  • = default on <=> compares bases, then members in declaration order, lexicographically. Defaulting <=> implicitly declares a defaulted ==; a user-written <=> does not, because equality can often be checked faster (compare sizes first) than ordering.
  • The return type says what kind of ordering you have: std::strong_ordering (equal values are interchangeable: integers), std::weak_ordering (equivalent but distinguishable values: case-insensitive strings) or std::partial_ordering (some values are unordered: floating point with NaN). auto picks the weakest category among the members.
#include <cctype>
#include <compare>
#include <iostream>
#include <string>
#include <utility>

class Caseless {  // equal ignoring case, so the ordering is only weak
public:
    explicit Caseless(std::string s) : s_(std::move(s)) {}
    std::weak_ordering operator<=>(const Caseless& o) const {
        for (std::size_t i = 0; i < s_.size() && i < o.s_.size(); ++i) {
            int a = std::tolower(static_cast<unsigned char>(s_[i]));
            int b = std::tolower(static_cast<unsigned char>(o.s_[i]));
            if (a != b) return a <=> b;
        }
        return s_.size() <=> o.s_.size();
    }
    bool operator==(const Caseless& o) const { return (*this <=> o) == 0; }  // not implied
private:
    std::string s_;
};

int main() {
    Caseless x("Hello"), y("hello"), z("help");
    std::cout << (x == y) << (x < z) << (z > y) << '\n';
}
111

Interview trap

Overloading &&, || or the comma operator is legal but loses the built-in behaviour: an overloaded && is a function call, so both operands are always evaluated and there is no short-circuit. (C++17 at least fixed the evaluation order of the overloaded forms to left to right.) Overloading unary & breaks code that takes addresses, which is why generic code uses std::addressof. Operators you cannot overload at all: ::, ., .*, ?:, sizeof, alignof, typeid.

Static members and the initialisation order fiasco

Theory

A static data member belongs to the class, not to any object: one instance for the whole program, stored outside every object, so it does not contribute to sizeof. A static member function has no this; it can be called as T::f() without an object, can access private members of any T passed to it, and cannot be virtual, const or ref-qualified.

Before C++17, a non-const static data member had to be defined in exactly one .cpp file (int Widget::count = 0;), otherwise the linker reported an undefined reference. C++17 inline variables remove that: inline static int count = 0; in the class is a definition, merged across translation units. static constexpr data members are implicitly inline since C++17.

#include <iostream>

class Connection {
public:
    Connection() : id_(next_id_++) { ++open_; }
    ~Connection() { --open_; }
    Connection(const Connection&) = delete;            // a counted resource: not copyable
    Connection& operator=(const Connection&) = delete;
    static int open() { return open_; }                // no this: callable as Connection::open()
    int id() const { return id_; }
    static constexpr int max_open = 64;                // implicitly inline since C++17
private:
    inline static int open_ = 0;                       // C++17: no out-of-class definition
    inline static int next_id_ = 1;
    int id_;
};

int main() {
    Connection a;
    {
        Connection b, c;
        std::cout << Connection::open() << " open, last id " << c.id() << '\n';
    }
    std::cout << Connection::open() << " open of max " << Connection::max_open << '\n';
    std::cout << "sizeof " << sizeof(Connection) << '\n';
}
3 open, last id 3
1 open of max 64
sizeof 4

The static initialisation order fiasco

Objects with static storage duration at namespace scope (globals, static members) are initialised in two phases. Constant initialisation happens at compile time when the initialiser is a constant expression. Dynamic initialisation runs code before main, in declaration order within one translation unit, but in an unspecified order across translation units. If a global in a.cpp uses a global in b.cpp during its own construction, it may see it unconstructed (zero-initialised memory). That bug is the fiasco, and it typically appears or disappears when the link order changes.

Fixes, from most to least preferred:

  • Make it constant: constexpr or C++20 constinit, which forces constant initialisation and fails to compile if the initialiser is not constant. A constinit variable can still be modified at run time.
  • Construct on first use: a function-local static, Registry& registry() { static Registry r; return r; }. It is built the first time the function is called, and since C++11 that initialisation is thread-safe (the compiler adds a guard variable and a lock). Ch 2's plugin registry depends on this.
  • Avoid the dependency: pass the object explicitly instead of reaching for a global.

The mirror problem exists at exit: statics are destroyed in reverse order of construction completion, so a static destructor that uses another static may find it already destroyed. The usual answers are to construct the dependency first (it is then destroyed last) or to deliberately leak it: static Registry& r = *new Registry;.

Friend

Theory

A friend declaration grants one function or class access to the private and protected members of the class that declares it. The class grants access; nobody can take it. The rules:

  • Not inherited: a friend of Base has no special access to Derived's own members, and a class derived from a friend is not a friend.
  • Not transitive: a friend of a friend is not a friend.
  • Not reciprocal: if A declares friend class B;, B can see into A but not the reverse.
  • Not affected by access specifiers: writing it under private: changes nothing.

Friendship does not break encapsulation when it is used for functions that are logically part of the class's interface but cannot be members, such as operator<< or a symmetric operator==. It extends the set of functions that can touch the representation by exactly the ones the class author listed. Overuse (befriending a whole unrelated class to save writing an accessor) is the smell.

Hidden friends. A friend function defined inside the class body is not visible to ordinary name lookup at namespace scope; it is found only by argument-dependent lookup when one of the arguments is the class. That keeps the overload set small (faster compiles, better error messages) and prevents surprising implicit conversions, since the function is considered only when a real T is involved.

namespace geo {
class Point {
public:
    Point(int x, int y) : x_(x), y_(y) {}
    friend bool operator==(const Point&, const Point&) = default;  // hidden friend
    friend std::ostream& operator<<(std::ostream& os, const Point& p) {
        return os << '(' << p.x_ << ", " << p.y_ << ')';
    }
private:
    int x_, y_;
};
}  // std::cout << p finds operator<< through ADL on geo::Point

The attorney-client and passkey idioms give finer control: instead of befriending a whole class, a member function takes a parameter of a Key type whose constructor is private and befriends only the intended caller.

const member functions, mutable and the this pointer

Theory

The this pointer

Every non-static member function receives a hidden parameter: the address of the object it was called on. Inside a member function of T, this is a prvalue of type T*, or const T* in a const member function (volatile works the same way). Member names used inside the function implicitly mean this->name. You need this explicitly to return *this for chaining, to pass the object elsewhere, to check self-assignment and to name members of a dependent base class inside a template (this->x).

this is never null in a valid program: calling a member function through a null pointer is undefined behaviour even if the function does not touch members, and compilers delete if (this == nullptr) checks for that reason. delete this is legal under strict conditions (see the drill below).

const member functions

A trailing const makes this a pointer to const, so the function cannot modify non-static members or call non-const member functions. Only const member functions can be called on a const object or through a const reference, which is how most objects are passed. So the rule is simple: every member function that does not change the observable state must be const, or your class becomes unusable through const T&.

The compiler enforces bitwise constness of the object's own bytes, which is shallow: in a const member function, a member int* p becomes int* const p, not const int*, so *p = 1 compiles. The PIMPL section shows the consequence. What the class should promise is logical constness: callers cannot observe a change.

mutable

mutable on a data member lets const member functions modify it. Use it for state that is not part of the observable value: caches, memoisation, access statistics, and the mutex that protects the object. Since the standard library treats const member functions as safe to call concurrently ([res.on.data.races]), a const function that writes a mutable cache must synchronise that write, typically with a mutable std::mutex or an atomic.

#include <iostream>
#include <mutex>
#include <string>
#include <thread>

class Document {
public:
    explicit Document(std::string text) : text_(std::move(text)) {}
    std::size_t word_count() const {           // logically const
        std::lock_guard lock(m_);              // const functions may be called concurrently
        if (!cached_) {
            std::size_t n = 0;
            bool in_word = false;
            for (char c : text_) {
                bool space = (c == ' ');
                if (!space && !in_word) ++n;
                in_word = !space;
            }
            count_ = n;
            cached_ = true;
            ++computations_;
        }
        return count_;
    }
    int computations() const { std::lock_guard lock(m_); return computations_; }
private:
    std::string text_;
    mutable std::mutex m_;                     // mutable: locking is not a logical change
    mutable bool cached_ = false;
    mutable std::size_t count_ = 0;
    mutable int computations_ = 0;
};

int main() {
    const Document doc("the quick brown fox");
    std::thread t([&doc] { (void)doc.word_count(); });
    std::size_t n = doc.word_count();
    t.join();
    std::cout << n << " words, computed " << doc.computations() << " time\n";
}
4 words, computed 1 time

const overloads

Member functions can be overloaded on const: the const version is chosen for const objects, the non-const one otherwise. Accessors that return references need both, so v[i] = x works on a mutable container and v[i] still works on a const one. To avoid writing the body twice, the classic trick is to implement the non-const version by calling the const one and casting the result (Effective C++ item 3); casting away const from the result is safe here because the object is known to be non-const. C++23 "deducing this" removes the duplication properly with one template.

#include <iostream>
#include <utility>
#include <vector>

class Grid {
public:
    Grid(std::size_t w, std::size_t h) : w_(w), cells_(w * h) {}

    // C++20 style: two overloads, the non-const one reusing the const one
    const int& at(std::size_t x, std::size_t y) const { return cells_.at(y * w_ + x); }
    int& at(std::size_t x, std::size_t y) {
        return const_cast<int&>(std::as_const(*this).at(x, y));
    }

    // C++23 deducing this: one template, self is Grid& or const Grid&
    template <class Self>
    auto&& cell(this Self&& self, std::size_t x, std::size_t y) {
        return std::forward<Self>(self).cells_.at(y * self.w_ + x);
    }
private:
    std::size_t w_;
    std::vector<int> cells_;
};

int main() {
    Grid g(3, 2);
    g.at(1, 1) = 7;
    g.cell(2, 0) = 5;
    const Grid& cg = g;
    std::cout << cg.at(1, 1) << ' ' << cg.cell(2, 0) << '\n';
    // cg.cell(2, 0) = 1;  // error: returns const int&
}
7 5

Ref-qualifiers overload on the value category of *this: std::string name() const & returns a copy for lvalues, std::string name() && can move the member out of a temporary. A function cannot mix ref-qualified and unqualified overloads of the same name and parameters.

SOLID with small C++ examples

Theory

SOLID is five heuristics for classes that survive change. Interviewers want a one-line definition, a C++ example of the violation and the fix, not a recital.

PrincipleOne lineViolation in C++Fix
Single responsibilitya class has one reason to changeReport computes totals, formats HTML and writes filessplit into ReportData, HtmlFormatter, FileSink
Open/closedextend behaviour without editing working codea switch (shape.kind) repeated in ten functionsa virtual function, a strategy object, or std::variant with one visitor per operation
Liskov substitutiona derived object must keep every promise of the baseSquare : Rectangle whose set_width also changes the heightdo not inherit; share an immutable Shape interface instead
Interface segregationclients should not depend on functions they do not useone IDevice with print, scan, fax; a printer must stub faxPrinter, Scanner, Fax interfaces; a copier implements two
Dependency inversionhigh-level code and low-level code both depend on an abstractionOrderService constructs a PostgresDb membertake a Database& (or a template parameter) in the constructor
Before: policy depends on detail OrderService PostgresDb owns and calls Tests need a real database; switching DB edits OrderService. After: both depend on the abstraction OrderService Database (interface) PostgresDb FakeDb uses dashed: implements. Arrows now point toward the abstraction.
Dependency inversion flips the arrow. The concrete database depends on an interface owned by the high-level code, so the service can be tested with a fake and the database swapped without recompiling the service. The Clock example above and the token bucket drill below use the same shape.

Liskov substitution and the square/rectangle problem

The Liskov substitution principle says: if code works with a Base&, it must keep working when given any derived object. That is about behaviour, not taxonomy. A mutable Rectangle promises that set_width leaves the height unchanged. A Square cannot keep that promise, so Square : Rectangle breaks any caller that sets width and height independently, even though "a square is a rectangle" in geometry. The reading drill below shows a function that computes 16 where it expected 20.

Formally, a derived class may weaken preconditions (accept more) and strengthen postconditions (promise more), and must preserve the base's invariants; it may not throw new kinds of exceptions the caller does not expect. Fixes for the square case: make shapes immutable (then a square really is a rectangle, because nobody can set the sides independently), or make Square and Rectangle siblings under an abstract Shape with only area(). Other classic violations: a ReadOnlyList derived from List whose add throws, and a derived class that requires a call to init() the base did not need.

Composition versus inheritance

Theory

Mental model. Inheritance says "is a, and shares the implementation"; composition says "has a, and uses it through its public interface". Composition couples less: the containing class depends only on the member's public interface, can swap it for another implementation, can hold several, and does not inherit functions it never wanted. Hence the rule from the Gang of Four: prefer composition over inheritance; inherit to be reused polymorphically, not to reuse code.

The fragile base class problem. A derived class that overrides virtual functions depends on how the base calls its own functions, which is an implementation detail. The classic example: a CountingSet that overrides both insert and insert_all to count insertions double-counts, because the base's insert_all happens to call insert. If the base author changes that detail in a later version, the derived class breaks without any change of its own. A reading drill below reproduces it. A wrapper that holds a set and forwards calls has no such dependency.

Strategy by composition. Behaviour that varies is a member, injected through the constructor: an interface pointer (run-time choice), a template parameter (compile-time choice, zero overhead) or a std::function (any callable, one allocation and an indirect call).

class Retrier {
public:
    explicit Retrier(std::function<std::chrono::milliseconds(int attempt)> backoff)
        : backoff_(std::move(backoff)) {}
    // ... uses backoff_(n) between attempts; no subclass per policy
private:
    std::function<std::chrono::milliseconds(int)> backoff_;
};

When inheritance is right: the derived type really is substitutable (Liskov holds), you need run-time polymorphism through a base pointer, or the base is an interface designed for extension (an abstract class with a documented contract for each virtual function, often in NVI style, Ch 2). Mark classes not designed for inheritance final.

PIMPL: the compilation firewall

Theory

Mental model. The header declares the class with one private member, a pointer to an incomplete Impl type. All real data members and private helpers live in Impl, defined only in the .cpp file. Clients see a class whose size and layout never change, so editing the implementation recompiles one file and keeps the binary interface (ABI) stable for shared libraries.

widget.h (seen by every client) class Widget std::unique_ptr<Impl> p_ (8 bytes) struct Impl; // declared, not defined ~Widget(); // declared, not defined here widget.cpp (private, on the heap) struct Widget::Impl std::string name_ std::vector<int> data_ ThirdPartyCache cache_ Editing Impl recompiles widget.cpp only. sizeof(Widget) stays 8, so clients built against the old header still link. Cost: one heap allocation per object and one extra pointer hop per call; no inlining of the hidden functions.
The header holds a pointer, the source file holds the class. Third-party headers needed by Impl (here ThirdPartyCache) are included only in widget.cpp, so clients do not pay their compile time.
// widget.h
class Widget {
public:
    Widget();
    ~Widget();                              // declared here, defined in widget.cpp
    Widget(Widget&&) noexcept;              // likewise: these need the complete Impl
    Widget& operator=(Widget&&) noexcept;
    void draw() const;
private:
    struct Impl;
    std::unique_ptr<Impl> p_;
};

// widget.cpp
struct Widget::Impl { std::string name_; std::vector<int> data_; };
Widget::Widget() : p_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;                // Impl is complete here
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;

The rules that trip people up:

  • The destructor must be defined where Impl is complete. If you let the compiler generate ~Widget() in the header, it instantiates std::default_delete<Impl> in every client, where Impl is incomplete, and the library's static_assert(sizeof(Impl) > 0) fails ("invalid application of sizeof to an incomplete type"). Declaring ~Widget(); in the header and writing Widget::~Widget() = default; in the .cpp fixes it. The same applies to move assignment (it destroys the old Impl) and to the move constructor (the generated code must be able to destroy p_ if a later member's construction throws). Declaring the destructor also suppresses the implicit move operations, so you must declare them anyway.
  • Copying is not automatic: unique_ptr is move-only, so write a deep-copy constructor that does std::make_unique<Impl>(*other.p_), or leave the class move-only.
  • Shallow const: in a const member function, p_->name_ = "x" compiles, because constness does not propagate through the pointer. Discipline (or std::experimental::propagate_const) restores it.
  • Moved-from state: after a move, p_ is null, so every member function must either tolerate that or document that a moved-from object may only be assigned or destroyed.

std::shared_ptr<Impl> does not have the incomplete-destructor problem (its deleter is captured at construction, Ch 12) but gives shared, not value, semantics, which is rarely what you want.

CRTP: static polymorphism

Theory

Mental model. In the Curiously Recurring Template Pattern a class derives from a base template instantiated with itself: class Square : public Shape<Square>. Inside the base, the derived type is a template parameter, so the base can call derived functions with static_cast<Derived&>(*this).impl(). The call is resolved at compile time and can be inlined: polymorphism without a vptr or an indirect call.

virtual: Shape& s; s.area() s.area() load vptr load slot indirect call run time: any derived type, chosen per object; one vtable per class, 8 bytes per object CRTP: Shape<Square>& s; s.area() s.area() static_cast<const Square&>(*this) Square::area_impl, inlined compile time: the type is fixed by the template argument; no vptr, nothing to predict
CRTP trades flexibility for speed. Shape<Square> and Shape<Circle> are unrelated types, so you cannot put both in one container or choose between them at run time. In exchange, every call is direct and inlinable.
#include <iostream>

template <class Derived>
class Shape {
public:
    double area() const { return self().area_impl(); }               // no virtual
    void describe() const { std::cout << self().name() << " area " << area() << '\n'; }
private:
    const Derived& self() const { return static_cast<const Derived&>(*this); }
    Shape() = default;
    friend Derived;  // only Derived can construct Shape<Derived>: catches Circle : Shape<Square>
};

class Square : public Shape<Square> {
public:
    explicit Square(double s) : s_(s) {}
    double area_impl() const { return s_ * s_; }
    const char* name() const { return "square"; }
private:
    double s_;
};

class Rect : public Shape<Rect> {
public:
    Rect(double w, double h) : w_(w), h_(h) {}
    double area_impl() const { return w_ * h_; }
    const char* name() const { return "rect"; }
private:
    double w_, h_;
};

template <class D>
static double doubled(const Shape<D>& s) { return 2 * s.area(); }  // generic over every Shape<D>

int main() {
    Square sq(3);
    Rect r(2, 5);
    sq.describe();
    r.describe();
    std::cout << doubled(sq) + doubled(r) << ' ' << sizeof(Square) << '\n';
}
square area 9
rect area 10
38 8

Common uses: mixins that add operations to a class (std::enable_shared_from_this<T>, an instance counter per type, comparison or printing helpers); static interfaces in hot loops (expression templates in Eigen, HFT order book handlers); and clone helpers that write do_clone for every derived class.

Virtual functionsCRTP
Bindingrun timecompile time
Per-object costvptr (8 bytes)none
Call costindirect call, usually not inlineddirect, inlinable
Heterogeneous containeryes: vector<unique_ptr<Base>>no: each Base<D> is a different type (wrap in std::variant)
Code in headersnoyes, and one instantiation per derived type (code size)
Missing implementationabstract class, error at instantiation of the objecterror at the call inside the base template, often a long message; or infinite recursion if the base and derived functions share a name

C++23 "deducing this" (template <class Self> void f(this const Self& self)) gives the base access to the derived type without the class being a template, and removes the static_cast. CRTP remains common because a great deal of existing code and C++20 codebases use it.

Concept and tricky questions

Drills

ConceptWhat is the difference between abstraction and encapsulation? Give a C++ example where you have one without the other.

Answer

Answer. Abstraction chooses the operations a type exposes so callers can ignore the details. Encapsulation hides the representation behind access control so only the class can break its invariants. A C struct plus free functions (like FILE used through fopen/fread but with public fields) is an abstraction without enforced encapsulation; a class with a private field and a getter and setter for each one is encapsulated in syntax but offers no abstraction.

Why. Interviewers want to hear that the two are related but different levels: abstraction is the design decision, encapsulation the enforcement mechanism. The test of both together is whether you could change the representation (three pointers in std::vector versus a pointer and two sizes) without any caller noticing. Answering "encapsulation is data hiding and abstraction is hiding complexity" without an example sounds memorised.

ConceptName the four pillars of object-oriented programming and the C++ feature behind each. Does polymorphism need inheritance?

Answer

Answer. Encapsulation: access control (private, protected) so that only member functions can break an invariant. Abstraction: choosing a small public interface, expressed as a class's public members, an abstract class or a concept. Inheritance: derivation, with public, protected or private access and optional virtual bases. Polymorphism: one interface, many types, either at compile time (overloading, templates, CRTP) or at run time (virtual functions). Polymorphism does not need inheritance.

Why. Interviewers want each pillar tied to a mechanism and a caveat, not four definitions recited. Templates, std::function and std::variant give polymorphism with no common base class, and a non-virtual or private base gives inheritance with no polymorphism. C++ is multi-paradigm, so a design can use only some of the pillars. A frequent weak answer treats abstraction and encapsulation as synonyms; the previous drill separates them.

TrickyWhat is the difference between an abstract class and an interface in C++? Can an abstract class have a constructor, data members and non-virtual functions?

Answer

Answer. An abstract class is any class with at least one pure virtual function, declared or inherited and not overridden. An "interface" is only a convention: an abstract class with nothing but pure virtual functions, a virtual (or protected) destructor and no data. So yes, an abstract class can have constructors, data members, non-virtual functions and implemented virtual functions. You just cannot create an object of it, or pass or return one by value.

Why. Derived constructors call the abstract base's constructor to initialise its part, so the constructor is useful (make it protected, since only derived classes can call it anyway). The common wrong answer is "an abstract class cannot have a constructor because it cannot be instantiated". Use pure interfaces at module boundaries and for multiple inheritance, because without data there is nothing to duplicate in a diamond. Use an abstract class with state for shared implementation, as in the template method and NVI idioms (Ch 2).

TrickyWhich operators must be member functions? Why can operator<< for printing your class not usefully be a member?

Answer

Answer. operator=, operator(), operator[], operator-> and conversion operators must be members ([over.oper]). A member operator<< would make your object the left operand, so you would have to write obj << std::cout.

Why. For a member operator, the left operand is the object the function is called on. Streams put the stream on the left, and you cannot add members to std::ostream, so the printing operator must be a non-member, usually a hidden friend. The reason these five must be members is that the language wants them tied to the class (for example, a non-member operator= could change the meaning of assignment for a type from outside). Many candidates also list +=: it should be a member by convention, but a non-member operator+= is legal.

TrickyWhy does postfix operator++ take an unused int, why does it return by value, and should it return const T?

Answer

Answer. The int is a dummy tag that lets prefix and postfix overload each other. It returns by value because it must return the old value, which no longer exists in the object. It should not return const T in modern C++.

Why. Prefix returns T& to the incremented object. Postfix must copy the old state first, so it returns that copy. Older books recommended const T to make it++++ fail to compile, but a const return value cannot be moved from, which costs a copy wherever the result is used. Some style guides still say "return const for postfix", so be ready to explain the trade-off; the C++ Core Guidelines and the standard library return plain T. The copy is also why ++it is preferred for non-trivial iterators.

TrickyIn C++20 you define only bool operator==(const Id&, int). Do id != 5, 5 == id and 5 != id compile? If you write your own operator<=>, does == work?

Answer

Answer. All three compile in C++20: the compiler rewrites != as !(==) and also tries operands in reversed order. A user-written operator<=> does not provide ==; only a defaulted <=> implicitly declares a defaulted ==.

Why. [over.match.oper]/3 adds rewritten candidates: for x != y, x == y and y == x; for x < y, (x <=> y) < 0 and 0 < (y <=> x). Equality is deliberately separate from ordering because it can often be faster (two strings of different length are unequal without comparing characters). In C++17 you needed four functions for the four expressions above. A pitfall from the reversal: a member bool operator==(const T&) that forgot its const makes a == b formally ambiguous with its own reversed form in C++20; GCC and Clang accept it with a warning (Clang's -Wambiguous-reversed-operator), but code that was clean in C++17 starts warning. Make comparison operators const or hidden friends.

TrickyWhat can a const member function still modify?

Answer

Answer. mutable members, anything a member pointer or reference points to (constness is shallow), static members, globals, and other objects reached through parameters. It can also const_cast away const, which is defined only if the object itself was not created const.

Why. const on a member function only changes this to const T*, so the members of *this become const: a member int* p becomes int* const p, and *p = 1 still compiles. Modifying an object that was defined const (for example a global const Widget w;) through a const_cast is undefined behaviour, and the compiler may have placed it in read-only memory. The design rule is logical constness: callers must not observe a change, and a mutable cache written from a const function must be synchronised because const functions are assumed safe to call concurrently.

ConceptHow does a static member function differ from a non-static one? Can it access private members? Can it be const or virtual?

Answer

Answer. It has no this, so it can be called without an object (T::f()) and cannot use non-static members directly. It can access private members of any T object it is given. It cannot be const, volatile, ref-qualified or virtual.

Why. const and ref-qualifiers describe the implicit object, and virtual dispatches on it; without an object neither means anything. Access control is per class, so a static factory can call a private constructor. A pointer to a static member function is an ordinary function pointer (usable as a C callback), unlike a pointer to a non-static member function. People often wrongly say a static member function cannot touch private members.

TrickyWhat is the static initialisation order fiasco, and what are the fixes? Is a function-local static thread-safe?

Answer

Answer. Dynamic initialisation of namespace-scope statics in different translation units happens in an unspecified order, so a global constructor that uses a global from another file may see it before it is constructed. Fixes: make the dependency constexpr or constinit, construct it on first use with a function-local static, or remove the global. Function-local static initialisation is thread-safe since C++11 ([stmt.dcl]/4).

Why. Across units the order depends on the link order, so the bug comes and goes with build changes. A function-local static is initialised when control first passes through its declaration, behind a guard variable and a lock (__cxa_guard_acquire), so concurrent first calls block until one finishes. The often-forgotten half is destruction, in reverse order of construction completion, so one static's destructor can touch an already destroyed static. A common slip: constinit does not make the variable const; it only guarantees there is no dynamic initialisation to order.

ConceptIs friendship inherited, transitive or symmetric? Does friend break encapsulation?

Answer

Answer. None of the three: not inherited, not transitive, not symmetric. Used properly, it does not break encapsulation, because the class itself names exactly which functions may see its representation.

Why. A friend function such as operator<< is part of the class's interface that happens to need non-member syntax; making it a friend is no worse than making it a member. Stroustrup's phrasing: friendship is granted, not taken. It becomes a problem when a class befriends an unrelated class to avoid designing an interface, because then changes to the representation can break code far away. Hidden friends (defined inside the class) are the preferred form for operators.

TrickyIs deriving Square from Rectangle always a Liskov violation?

Answer

Answer. No. It is a violation when Rectangle is mutable with independent set_width and set_height. If rectangles are immutable values, a square satisfies every promise a rectangle makes, and the inheritance is fine.

Why. Liskov substitution is about behavioural contracts, not real-world taxonomy. The mutable rectangle's contract includes "setting the width leaves the height unchanged", which a square cannot honour. Remove the setters and the only promises left (it has a width, a height and area() == w * h) hold for squares. The common wrong answers are "always a violation because squares have one side" and "never, because a square is a rectangle in maths". Both miss that the answer depends on the operations.

ConceptWhen should you use composition, and when is inheritance the right tool?

Answer

Answer. Default to composition for reuse. Use public inheritance only when the derived type is truly substitutable for the base (Liskov holds) and you need run-time polymorphism through a base pointer or reference, typically from an interface or an abstract class designed for extension.

Why. Composition depends only on the member's public interface, so it survives changes to the member's implementation, lets you hold several or swap implementations, and exposes only what you forward. Inheriting for code reuse couples you to how the base calls its own virtual functions (the fragile base class problem in the reading drills) and exposes every public base member on your type. Private inheritance is the middle ground for "implemented in terms of" when you need to override a virtual function of the helper (Ch 2).

TrickyA PIMPL class holds std::unique_ptr<Impl>. Clients that create a Widget fail to compile with "invalid application of sizeof to an incomplete type". The header declares no destructor. Why, and what is the fix?

Answer

Answer. The implicitly defined destructor is generated inline in each client's translation unit, where Impl is incomplete, and it instantiates std::default_delete<Impl>::operator(), which static-asserts that the type is complete. Fix: declare ~Widget(); in the header and define Widget::~Widget() = default; in the .cpp after Impl is defined. Do the same for the move operations.

Why. delete on a pointer to an incomplete class type is undefined if the class has a non-trivial destructor, so std::unique_ptr turns that into a compile error. Defining the special members out of line moves the instantiation to the one file where Impl is complete. std::shared_ptr<Impl> would compile without this because it captures the deleter when constructed, which happens in the .cpp, but it also gives the class shared semantics, which is usually wrong.

TrickyWhat can CRTP not do that virtual functions can, and how do you stop someone writing class Circle : public Shape<Square> by mistake?

Answer

Answer. CRTP cannot choose the type at run time or store different derived types in one container, because Shape<Square> and Shape<Circle> are unrelated types. To catch the wrong template argument, make the base constructor private and declare friend Derived;, so only the named derived class can construct it.

Why. With the wrong argument, static_cast<const Square&>(*this) inside the base is applied to a Circle, which is undefined behaviour that usually reads garbage. With a private constructor, Circle's constructor cannot call Shape<Square>() and compilation fails. For a heterogeneous collection of CRTP types, combine them with std::variant and std::visit, or add a thin virtual interface on top. A third limitation: if the derived class forgets to implement the function and it has the same name as the base one, the base calls itself recursively for ever, which is why implementations often use a distinct name such as area_impl.

TrickyCan a member function execute delete this;? Under what conditions is it safe?

Answer

Answer. Yes. It is safe if the object was allocated with plain new (not new[], not on the stack, not as a member or inside a container), nothing in the function touches members or calls virtual functions after the delete, and no other code uses the object afterwards.

Why. After delete this, this is a dangling pointer, but the function's code is still valid and can return. Intrusive reference counting does exactly this: void release() { if (--refs_ == 0) delete this; }. To force heap allocation, make the destructor private or protected so stack objects fail to compile. Interviewers sometimes follow up with "can this be null?": not in a valid program, because calling a member function through a null pointer is already undefined behaviour.

ConceptWhich operators cannot be overloaded, and which can but usually should not be?

Answer

Answer. Cannot: ::, ., .*, ?:, sizeof, alignof, typeid and the cast keywords. Should not: &&, || and , (they lose short-circuiting and built-in sequencing semantics) and unary & (it breaks taking addresses).

Why. The non-overloadable ones either take a name rather than a value as an operand (., ::) or are needed to reason about any type (sizeof). An overloaded && is a function call, so both operands are evaluated before the call. Libraries that overload them (Boost.Spirit's ,, expression templates) do so for embedded languages, never for ordinary value types. Generic code uses std::addressof to be robust against overloaded &.

Predict the output and find the bug

Drills

OutputPrefix and postfix. What does this print?

#include <iostream>

struct Counter {
    int n = 0;
    Counter& operator++() { ++n; std::cout << "pre "; return *this; }
    Counter operator++(int) { Counter old = *this; ++*this; std::cout << "post "; return old; }
};

int main() {
    Counter c;
    Counter a = c++;
    Counter b = ++c;
    std::cout << '\n' << a.n << ' ' << b.n << ' ' << c.n << '\n';
    ++++c;
    std::cout << '\n' << c.n << '\n';
}
Answer
pre post pre
0 2 2
pre pre
4

Why. c++ runs the postfix operator, which saves the old value, calls the prefix operator (printing pre) and then prints post, returning the old n == 0. ++c prints pre and returns a reference to c, which is copied into b with n == 2. ++++c works because prefix returns a non-const reference, so the second increment applies to c itself, giving 4. With postfix, c++++ would increment a temporary and leave c unchanged by the second step.

Find the bugThis operator+ compiles (with a warning). What is wrong? (Output is not compared: the behaviour is undefined, and with GCC the program crashes.)

#include <iostream>

struct Vec { int x, y; };

static const Vec& operator+(const Vec& a, const Vec& b) {
    Vec r{a.x + b.x, a.y + b.y};
    return r;
}

int main() {
    Vec a{1, 2}, b{3, 4};
    const Vec& c = a + b;
    std::cout << c.x << ',' << c.y << '\n';
}
Answer

Answer. It returns a reference to a local variable that is destroyed when the function returns, so c dangles. Binding the result to const Vec& in main does not extend anything: lifetime extension applies only to temporaries bound directly, not to a reference returned from a function.

Why. GCC warns with -Wreturn-local-addr and, since the behaviour is undefined, compiles the function to return a null pointer, so the program crashes; Clang warns with -Wreturn-stack-address and may print garbage or the "right" answer by luck. Arithmetic operators produce new values, so the canonical signature returns by value: Vec operator+(Vec a, const Vec& b) { a.x += b.x; a.y += b.y; return a; }. Returning a value is cheap thanks to copy elision (Ch 23).

OutputWhich overload of operator[] runs? What does this print?

#include <iostream>
#include <vector>

class Row {
public:
    int& operator[](std::size_t i) { std::cout << "mutable "; return v_[i]; }
    const int& operator[](std::size_t i) const { std::cout << "const "; return v_[i]; }
private:
    std::vector<int> v_{1, 2, 3};
};

static int first(const Row& r) { return r[0]; }

int main() {
    Row r;
    const Row& cr = r;
    r[1] = 20;
    int a = cr[1];
    int b = first(r);
    std::cout << '\n' << a << ' ' << b << '\n';
}
Answer
mutable const const
20 1

Why. The overload is chosen by the constness of the object expression, not by whether the result is read or written. r is non-const, so r[1] uses the mutable overload. cr and the parameter r inside first are const references, so they use the const overload, even though they refer to the same non-const object. A common wrong answer is that "reading uses the const version": int x = r[0]; on a non-const r still calls the mutable one.

Find the bugA set of points silently loses an element. What does this print, and what is the fix?

#include <iostream>
#include <set>

struct Point {
    int x, y;
    friend bool operator<(const Point& a, const Point& b) { return a.x < b.x; }
    friend bool operator==(const Point&, const Point&) = default;
};

int main() {
    std::set<Point> pts{{1, 2}, {1, 3}, {2, 0}};
    std::cout << pts.size() << ' ' << pts.count({1, 99}) << '\n';
    Point p{1, 2}, q{1, 3};
    std::cout << (p == q) << (p < q) << (q < p) << '\n';
}
Answer
2 1
000

Answer. std::set never calls ==. It treats a and b as the same key when !(a < b) && !(b < a), and this operator< looks only at x, so {1, 3} counts as a duplicate of {1, 2} and is dropped, and count({1, 99}) finds a "match". Meanwhile == says p and q differ, so the type has two inconsistent notions of sameness.

Why. Ordered containers and algorithms use equivalence derived from the comparator, not equality. The fix is to compare every member that == compares, in a fixed order: friend auto operator<=>(const Point&, const Point&) = default;, which also keeps == consistent, or std::tie(a.x, a.y) < std::tie(b.x, b.y) before C++20. The common wrong answer is "the set uses == to detect duplicates". A worse variant, a.x < b.x || a.y < b.y, is not a strict weak ordering at all, and using it in a std::set is undefined behaviour.

OutputDefaulted comparisons. What does this print?

#include <cmath>
#include <compare>
#include <iostream>
#include <string>

struct Version {
    int major, minor, patch;
    auto operator<=>(const Version&) const = default;
};
struct Player {
    std::string name;
    int score;
    auto operator<=>(const Player&) const = default;
};

int main() {
    Version a{1, 10, 0}, b{1, 9, 7};
    std::cout << (a > b) << (a == Version{1, 10, 0}) << '\n';
    Player p{"bob", 10}, q{"alice", 99};
    std::cout << (p < q) << '\n';
    auto c = 1.0 <=> std::nan("");
    std::cout << (c == std::partial_ordering::unordered) << (1.0 < std::nan("")) << '\n';
}
Answer
11
0
10

Why. A defaulted <=> compares members lexicographically in declaration order. 1.10.0 > 1.9.7 because the minor field 10 is greater than 9 (which a string comparison of "1.10.0" and "1.9.7" would get wrong). Player compares name first, so "bob" < "alice" is false and the score is never looked at: member order is part of the meaning. For double, <=> returns std::partial_ordering, and any comparison with NaN is unordered, so 1.0 < NaN is false.

Find the bugA Liskov violation. What does this print, and which design rule does it break?

#include <iostream>

class Rectangle {
public:
    virtual ~Rectangle() = default;
    virtual void set_width(int w) { w_ = w; }
    virtual void set_height(int h) { h_ = h; }
    int area() const { return w_ * h_; }
protected:
    int w_ = 0, h_ = 0;
};

class Square : public Rectangle {
public:
    void set_width(int w) override { w_ = h_ = w; }
    void set_height(int h) override { w_ = h_ = h; }
};

static void stretch(Rectangle& r) {
    r.set_width(5);
    r.set_height(4);
    std::cout << "expected 20, got " << r.area() << '\n';
}

int main() {
    Rectangle r;
    Square s;
    stretch(r);
    stretch(s);
}
Answer
expected 20, got 20
expected 20, got 16

Answer. Square breaks the Liskov substitution principle: stretch relies on the rectangle's promise that set_height does not change the width, and the square's override silently changes both.

Why. The code compiles and every class is "correct" in isolation; the bug only appears when a Square is used where a Rectangle is expected, which is the whole point of public inheritance. Fixes: drop the inheritance and make both siblings under an abstract Shape with area(), or make the shapes immutable so there are no setters to break. Throwing from Square::set_width when the sides differ is not a fix: it adds a failure mode the base never had, which is also a Liskov violation.

Find the bugWhy does this not compile, and what is the right fix?

#include <vector>

class Account {
public:
    long balance() const {
        audit();
        return balance_;
    }
    void audit() { log_.push_back(balance_); }
private:
    long balance_ = 0;
    std::vector<long> log_;
};

int main() {
    const Account acct;
    return static_cast<int>(acct.balance());
}
Answer

Answer. balance() is const, so this is const Account*, and it calls the non-const member audit(). Calling a non-const member function on a const object is an error ("passing const Account as this argument discards qualifiers").

Why. The fix depends on whether the audit log is part of the account's observable value. It is not: reading a balance should not count as changing the account. So declare mutable std::vector<long> log_; and make audit() const, and add a mutable std::mutex if the class may be read from several threads, because const member functions are expected to be safe to call concurrently. The wrong fixes are removing const from balance() (then no const reference can read the balance) and const_cast<Account*>(this)->audit() (undefined behaviour here, because acct was defined const).

OutputConstruct on first use. What does this print?

#include <iostream>

struct Logger {
    Logger() { std::cout << "Logger ready\n"; }
    ~Logger() { std::cout << "Logger gone\n"; }
    void log(const char* msg) { std::cout << msg << '\n'; }
};

static Logger& logger() {
    static Logger instance;
    return instance;
}

struct App {
    App() { logger().log("App starting"); }
    ~App() { std::cout << "App gone\n"; }
};

static App app;

int main() {
    std::cout << "main\n";
    logger().log("hello");
}
Answer
Logger ready
App starting
main
hello
App gone
Logger gone

Why. The global app is dynamically initialised before main (all mainstream implementations do this at start-up). Its constructor calls logger(), which constructs the function-local static on first use, so the logger exists before App needs it, whichever file either lives in. At exit, statics are destroyed in reverse order of completed construction: the logger finished constructing first (inside App's constructor), so app is destroyed first and the logger last. That is exactly the order that keeps ~App safe if it logs. If logger were a plain global in another file, App() could run before it was constructed: the fiasco.

OutputA member operator<<. What does this print, and why does std::cout << t not compile?

#include <iostream>

struct Temp {
    double celsius;
    std::ostream& operator<<(std::ostream& os) const { return os << celsius << 'C'; }
};

int main() {
    Temp t{21.5};
    t << std::cout;  // the object is the left operand
    std::cout << '\n';
    // std::cout << t;  // error: no operator<< for (ostream, Temp)
}
Answer
21.5C

Why. A member binary operator always takes the object as its left operand, so this operator<< means t.operator<<(std::cout), written t << std::cout. For std::cout << t the compiler looks for a member of std::ostream or a non-member taking (std::ostream&, const Temp&), and finds neither. The fix is the canonical hidden friend: friend std::ostream& operator<<(std::ostream& os, const Temp& t).

Find the bugThe fragile base class. The counter should report 4 insertion attempts. What does it print, and what is the robust fix?

#include <iostream>
#include <set>
#include <vector>

class IntSet {
public:
    virtual ~IntSet() = default;
    virtual bool insert(int x) { return items_.insert(x).second; }
    virtual void insert_all(const std::vector<int>& xs) { for (int x : xs) insert(x); }
    std::size_t size() const { return items_.size(); }
private:
    std::set<int> items_;
};

class CountingSet : public IntSet {
public:
    bool insert(int x) override { ++attempts; return IntSet::insert(x); }
    void insert_all(const std::vector<int>& xs) override {
        attempts += xs.size();
        IntSet::insert_all(xs);
    }
    std::size_t attempts = 0;
};

int main() {
    CountingSet s;
    s.insert_all({1, 2, 3});
    s.insert(3);
    std::cout << s.attempts << ' ' << s.size() << '\n';
}
Answer
7 3

Answer. IntSet::insert_all calls the virtual insert for each element, which dispatches to CountingSet::insert, so the three elements are counted twice: 3 + 3 + 1 = 7.

Why. The derived class depends on an undocumented implementation detail of the base (whether insert_all calls insert). Removing the override of insert_all gives the right answer today, but breaks again if the base author later optimises insert_all to bypass insert. The robust fix is composition: a CountingSet that holds an IntSet and forwards to it, counting in its own functions, depends only on the public behaviour. This is Effective Java item 18 ("favour composition over inheritance") and applies unchanged to C++.

Find the bugA copy-paste error in a CRTP hierarchy. What is wrong? (Output is not compared: the behaviour is undefined.)

#include <iostream>

template <class D>
struct Shape {
    double area() const { return static_cast<const D*>(this)->area_impl(); }
};

struct Square : Shape<Square> {
    double side = 2;
    double area_impl() const { return side * side; }
};

struct Circle : Shape<Square> {  // should be Shape<Circle>
    double radius = 1;
    double area_impl() const { return 3.14159 * radius * radius; }
};

int main() {
    Circle c;
    std::cout << c.area() << '\n';
}
Answer

Answer. Circle derives from Shape<Square>, so area() casts a Circle to Square and calls Square::area_impl on it. That is undefined behaviour; in practice it reads radius as if it were side and prints 1 instead of about 3.14.

Why. static_cast down a hierarchy is unchecked, and both are valid-looking types, so nothing complains. The guard is to make the base's constructor private and befriend the template parameter; then Circle cannot construct a Shape<Square> and the mistake becomes a compile error:

template <class D>
class Shape {
public:
    double area() const { return static_cast<const D*>(this)->area_impl(); }
private:
    Shape() = default;
    friend D;  // only D may construct Shape<D>
};

struct Square : Shape<Square> {
    double side = 2;
    double area_impl() const { return side * side; }
};

struct Circle : Shape<Square> {
    double radius = 1;
    double area_impl() const { return 3.14159 * radius * radius; }
};

int main() {
    Circle c;  // error: Shape<Square>::Shape() is private within this context
    return static_cast<int>(c.area());
}

Write the code

Coding

CodeA Fraction value type. Store a numerator and denominator in std::int64_t, always normalised (lowest terms, positive denominator). Provide + - * / and their compound forms, unary minus, ==, a correct <=>, operator<< (print 3 rather than 3/1) and an explicit conversion to double. Integers must mix freely on either side (1 + f). A zero denominator, including division by zero, throws std::domain_error.

Answer

Approach. Normalise in the constructor so every object is canonical; then equality is member-wise and can be defaulted. Compound assignments do the work and build a new normalised fraction; the binary operators are hidden friends that take the left operand by value. A non-explicit constructor from int64_t plus non-member operators gives mixed arithmetic on both sides. Ordering cross-multiplies, which is correct because both denominators are positive.

#include <compare>
#include <cstdint>
#include <iostream>
#include <numeric>
#include <stdexcept>

class Fraction {
public:
    Fraction(std::int64_t num = 0, std::int64_t den = 1) : num_(num), den_(den) {
        if (den_ == 0) throw std::domain_error("zero denominator");
        if (den_ < 0) { num_ = -num_; den_ = -den_; }
        std::int64_t g = std::gcd(num_, den_);  // gcd(0, d) == d, so 0/d becomes 0/1
        num_ /= g;
        den_ /= g;
    }
    std::int64_t num() const { return num_; }
    std::int64_t den() const { return den_; }

    Fraction& operator+=(const Fraction& r) { return *this = Fraction(num_ * r.den_ + r.num_ * den_, den_ * r.den_); }
    Fraction& operator-=(const Fraction& r) { return *this += -r; }
    Fraction& operator*=(const Fraction& r) { return *this = Fraction(num_ * r.num_, den_ * r.den_); }
    Fraction& operator/=(const Fraction& r) { return *this = Fraction(num_ * r.den_, den_ * r.num_); }  // throws if r == 0
    Fraction operator-() const { return Fraction(-num_, den_); }

    friend Fraction operator+(Fraction a, const Fraction& b) { return a += b; }
    friend Fraction operator-(Fraction a, const Fraction& b) { return a -= b; }
    friend Fraction operator*(Fraction a, const Fraction& b) { return a *= b; }
    friend Fraction operator/(Fraction a, const Fraction& b) { return a /= b; }

    friend bool operator==(const Fraction&, const Fraction&) = default;  // valid: always normalised
    friend std::strong_ordering operator<=>(const Fraction& a, const Fraction& b) {
        return a.num_ * b.den_ <=> b.num_ * a.den_;  // denominators are positive
    }
    explicit operator double() const { return static_cast<double>(num_) / static_cast<double>(den_); }

    friend std::ostream& operator<<(std::ostream& os, const Fraction& f) {
        os << f.num_;
        if (f.den_ != 1) os << '/' << f.den_;
        return os;
    }
private:
    std::int64_t num_, den_;
};

int main() {
    Fraction a(1, 2), b(-3, 4);
    std::cout << a + b << ' ' << a - b << ' ' << a * b << ' ' << a / b << '\n';
    std::cout << Fraction(6, -8) << ' ' << Fraction(0, 5) << ' ' << 1 + a << ' ' << a * 4 << '\n';
    std::cout << (a > b) << (Fraction(2, 4) == a) << (a != 1) << (Fraction(1, 3) < a) << '\n';
    std::cout << static_cast<double>(b) << '\n';
    try {
        (void)(a / Fraction(0));
    } catch (const std::domain_error& e) {
        std::cout << e.what() << '\n';
    }
}
-1/4 5/4 -3/8 -2/3
-3/4 0 3/2 2
1111
-0.75
zero denominator

Complexity and pitfalls. Each operation is O(log n) for the gcd. Pitfalls: the products overflow int64_t for large terms (reduce by the gcd of cross terms first, or use __int128 for the intermediate products and comparison); defaulting == is only correct because of the normalisation invariant, so a constructor that skipped it would make 1/2 != 2/4; and return a += b; returns an lvalue reference expression, so the result is copied out of the parameter; the two-statement form a += b; return a; moves instead (a by-value parameter is never eligible for NRVO, but returning it by name is an implicit move). For two int64_t fields the difference is nil; for a big type it matters.

Estimated rating: ~1400 · straightforward, but tests every canonical signature and the invariant that makes defaulted equality valid

CodeA dense Matrix of double. Requirements: construction from a size or from nested braces (Matrix{{1, 2}, {3, 4}}), element access with m(r, c) that works on const and non-const matrices, +=, +, *, ==, a static identity(n), and operator<<. Shape mismatches throw std::invalid_argument. Store the elements contiguously.

Answer

Approach. One std::vector<double> in row-major order plus the two dimensions, so the class follows the rule of zero (Ch 1). operator() is overloaded on const: a reference for writing, a value for reading. Multiplication uses the i-k-j loop order so the inner loop walks both b and the result contiguously, which matters for cache behaviour (Ch 19). Defaulted == compares dimensions and data.

#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <vector>

class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols, double fill = 0.0)
        : rows_(rows), cols_(cols), data_(rows * cols, fill) {}
    Matrix(std::initializer_list<std::initializer_list<double>> init)
        : rows_(init.size()), cols_(init.size() ? init.begin()->size() : 0) {
        data_.reserve(rows_ * cols_);
        for (const auto& row : init) {
            if (row.size() != cols_) throw std::invalid_argument("ragged rows");
            data_.insert(data_.end(), row.begin(), row.end());
        }
    }
    static Matrix identity(std::size_t n) {
        Matrix m(n, n);
        for (std::size_t i = 0; i < n; ++i) m(i, i) = 1.0;
        return m;
    }

    std::size_t rows() const { return rows_; }
    std::size_t cols() const { return cols_; }
    double& operator()(std::size_t r, std::size_t c) { return data_[r * cols_ + c]; }
    double operator()(std::size_t r, std::size_t c) const { return data_[r * cols_ + c]; }

    Matrix& operator+=(const Matrix& o) {
        if (rows_ != o.rows_ || cols_ != o.cols_) throw std::invalid_argument("shape mismatch");
        for (std::size_t i = 0; i < data_.size(); ++i) data_[i] += o.data_[i];
        return *this;
    }
    friend Matrix operator+(Matrix a, const Matrix& b) { a += b; return a; }
    friend Matrix operator*(const Matrix& a, const Matrix& b) {
        if (a.cols_ != b.rows_) throw std::invalid_argument("shape mismatch");
        Matrix m(a.rows_, b.cols_);
        for (std::size_t i = 0; i < a.rows_; ++i)
            for (std::size_t k = 0; k < a.cols_; ++k) {
                const double aik = a(i, k);
                for (std::size_t j = 0; j < b.cols_; ++j) m(i, j) += aik * b(k, j);
            }
        return m;
    }
    friend bool operator==(const Matrix&, const Matrix&) = default;
    friend std::ostream& operator<<(std::ostream& os, const Matrix& m) {
        for (std::size_t r = 0; r < m.rows_; ++r) {
            if (r) os << '\n';
            os << '[';
            for (std::size_t c = 0; c < m.cols_; ++c) os << (c ? " " : "") << m(r, c);
            os << ']';
        }
        return os;
    }
private:
    std::size_t rows_, cols_;
    std::vector<double> data_;
};

int main() {
    Matrix a{{1, 2}, {3, 4}};
    Matrix b{{0, 1}, {1, 0}};
    const Matrix& ca = a;
    std::cout << a * b << '\n';
    std::cout << (a * Matrix::identity(2) == a) << ' ' << ca(1, 0) << '\n';
    std::cout << a + a << '\n';
    try {
        (void)(Matrix(2, 3) * Matrix(2, 3));
    } catch (const std::invalid_argument& e) {
        std::cout << e.what() << '\n';
    }
}
[2 1]
[4 3]
1 3
[2 4]
[6 8]
shape mismatch

Complexity and pitfalls. Multiplication is O(n·m·p); addition is O(n·m). Pitfalls: the const operator() must return by value or const double&, never double&; Matrix{2, 3} with braces picks the initialiser-list constructor and is easy to misuse, so size construction uses parentheses; and exact == on floating point is only meaningful for exactly representable values like these (real code compares with a tolerance). C++23 allows m[r, c] with a multi-argument operator[].

Estimated rating: ~1300 · routine class design; the cache-friendly loop order and the const overload are the discussion points

CodePIMPL with value semantics. Write a Phonebook whose header exposes only add(name, number), find(name) returning std::optional<std::string>, and size(), with all data hidden in an Impl. It must be copyable (deep copy) and cheaply movable, and sizeof(Phonebook) must equal one pointer. Put the "header" and "source" parts in one file, marked with comments.

Answer

Approach. Hold std::unique_ptr<Impl>. Declare all five special members in the header and define them in the source after Impl is complete, which avoids the incomplete-type error. Copy construction clones the Impl; copy assignment uses copy-and-swap for the strong guarantee; the move operations are defaulted (in the source file) and leave the source with a null pointer.

#include <iostream>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <utility>

// ---- phonebook.h: what clients include ----
class Phonebook {
public:
    Phonebook();
    ~Phonebook();
    Phonebook(const Phonebook& other);
    Phonebook& operator=(const Phonebook& other);
    Phonebook(Phonebook&& other) noexcept;
    Phonebook& operator=(Phonebook&& other) noexcept;

    void add(std::string name, std::string number);
    std::optional<std::string> find(const std::string& name) const;
    std::size_t size() const;
private:
    struct Impl;
    std::unique_ptr<Impl> p_;  // a moved-from Phonebook holds null: assign or destroy only
};

// ---- phonebook.cpp: the only file that sees Impl ----
struct Phonebook::Impl {
    std::map<std::string, std::string> entries;
};

Phonebook::Phonebook() : p_(std::make_unique<Impl>()) {}
Phonebook::~Phonebook() = default;
Phonebook::Phonebook(const Phonebook& other)
    : p_(other.p_ ? std::make_unique<Impl>(*other.p_) : nullptr) {}
Phonebook& Phonebook::operator=(const Phonebook& other) {
    Phonebook tmp(other);  // may throw; *this is untouched if it does
    p_.swap(tmp.p_);
    return *this;
}
Phonebook::Phonebook(Phonebook&&) noexcept = default;
Phonebook& Phonebook::operator=(Phonebook&&) noexcept = default;

void Phonebook::add(std::string name, std::string number) {
    p_->entries[std::move(name)] = std::move(number);
}
std::optional<std::string> Phonebook::find(const std::string& name) const {
    auto it = p_->entries.find(name);
    if (it == p_->entries.end()) return std::nullopt;
    return it->second;
}
std::size_t Phonebook::size() const { return p_->entries.size(); }

// ---- main.cpp ----
int main() {
    Phonebook a;
    a.add("alice", "555-0100");
    a.add("bob", "555-0199");
    Phonebook b = a;  // deep copy
    b.add("carol", "555-0123");
    Phonebook c = std::move(b);
    std::cout << a.size() << ' ' << c.size() << '\n';
    a = c;            // copy assignment
    std::cout << a.size() << ' ' << a.find("carol").value_or("?") << ' '
              << c.find("dave").value_or("none") << '\n';
    std::cout << "one pointer: " << (sizeof(Phonebook) == sizeof(void*)) << '\n';
}
2 3
3 555-0123 none
one pointer: 1

Complexity and pitfalls. Every operation costs one extra pointer dereference, construction costs a heap allocation, and nothing in Impl can be inlined into callers. Pitfalls: defining the destructor or move operations in the header (or leaving them implicit) brings back the incomplete-type error; in a const member function p_->entries.clear() would compile, because constness does not propagate through the pointer; and the copy constructor must handle a moved-from source, or copying such an object dereferences null.

Estimated rating: ~1500 · the code is short; knowing exactly which special members must be out of line, and why, is the test

CodeCRTP mixins. Write two mixins: Printable<T>, which gives any T with a print(std::ostream&) const member an operator<<, and Counted<T>, which keeps a live-instance count per type, correct under copying. Apply both to two unrelated classes. The mixins must add no size to the classes and must not be usable on their own.

Answer

Approach. Printable<T> defines a hidden friend operator<<(std::ostream&, const T&); ADL finds it because Printable<T> is a base class of T, and base classes are associated classes. Counted<T> holds an inline static counter, so each T gets its own, and increments in every constructor including the copy constructor. Both have protected constructors and destructors (not usable alone, no virtual destructor needed) and no data, so the empty base optimisation makes them free.

#include <iostream>
#include <string>
#include <utility>

template <class T>
class Printable {
public:
    friend std::ostream& operator<<(std::ostream& os, const T& x) {
        x.print(os);
        return os;
    }
protected:
    Printable() = default;
    ~Printable() = default;
};

template <class T>
class Counted {
public:
    static int alive() { return alive_; }
protected:
    Counted() { ++alive_; }
    Counted(const Counted&) { ++alive_; }  // copies are new objects too
    Counted& operator=(const Counted&) = default;
    ~Counted() { --alive_; }
private:
    inline static int alive_ = 0;          // one counter per T
};

class Point : public Printable<Point>, public Counted<Point> {
public:
    Point(int x, int y) : x_(x), y_(y) {}
    void print(std::ostream& os) const { os << '(' << x_ << ", " << y_ << ')'; }
private:
    int x_, y_;
};

class Order : public Printable<Order>, public Counted<Order> {
public:
    Order(std::string id, int qty) : id_(std::move(id)), qty_(qty) {}
    void print(std::ostream& os) const { os << "order " << id_ << " x" << qty_; }
private:
    std::string id_;
    int qty_;
};

int main() {
    Point p(1, 2);
    {
        Point q = p;
        Order o("A1", 5);
        std::cout << p << ' ' << q << ' ' << o << '\n';
        std::cout << Point::alive() << ' ' << Order::alive() << '\n';
    }
    std::cout << Point::alive() << ' ' << Order::alive() << '\n';
    std::cout << "sizeof(Point) = " << sizeof(Point) << '\n';
}
(1, 2) (1, 2) order A1 x5
2 1
1 0
sizeof(Point) = 8

Complexity and pitfalls. Everything is resolved at compile time; there is no run-time cost beyond the counter update. Pitfalls: forgetting the counting copy constructor gives negative counts (the Counted drill in Ch 1); the counter is not thread-safe (use std::atomic<int> if objects are created on several threads); and two empty bases of the same type cannot share an address, so Counted<Base> inherited twice would add a byte. Before C++20, CRTP mixins were also the way to derive !=, > and friends from == and < (Boost.Operators); defaulted comparisons replace that use.

Estimated rating: ~1500 · needs ADL through base classes, per-instantiation statics and EBO, all small but easy to get wrong

CodeA testable rate limiter. Implement a token bucket: capacity burst tokens, refilled continuously at rate tokens per second, with bool try_acquire() that takes one token if available. The class must not call the system clock directly: inject a Clock interface so a unit test can drive time with a fake clock. Avoid floating-point drift.

Answer

Approach. Dependency inversion: TokenBucket depends on an abstract Clock returning microseconds, with a real implementation on std::chrono::steady_clock and a fake for tests. Refill lazily on each call from the elapsed time. Store the level in micro-tokens (tokens × 10⁶): then elapsed microseconds × tokens per second is exactly the number of micro-tokens earned, so all arithmetic is integer and exact.

#include <algorithm>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <string>

class Clock {
public:
    virtual ~Clock() = default;
    virtual std::int64_t now_us() const = 0;
};

class SteadyClock final : public Clock {
public:
    std::int64_t now_us() const override {
        using namespace std::chrono;
        return duration_cast<microseconds>(steady_clock::now().time_since_epoch()).count();
    }
};

class FakeClock final : public Clock {
public:
    std::int64_t now_us() const override { return t_; }
    void advance_us(std::int64_t us) { t_ += us; }
private:
    std::int64_t t_ = 0;
};

class TokenBucket {
public:
    TokenBucket(const Clock& clock, std::int64_t rate_per_sec, std::int64_t burst)
        : clock_(clock), rate_(rate_per_sec), cap_(burst * kScale), level_(cap_), last_(clock.now_us()) {}

    bool try_acquire() {
        refill();
        if (level_ < kScale) return false;
        level_ -= kScale;
        return true;
    }
private:
    static constexpr std::int64_t kScale = 1'000'000;  // micro-tokens per token
    void refill() {
        const std::int64_t now = clock_.now_us();
        const std::int64_t elapsed = std::min<std::int64_t>(now - last_, 3'600'000'000);  // cap at 1 h: no overflow
        level_ = std::min(cap_, level_ + elapsed * rate_);
        last_ = now;
    }
    const Clock& clock_;
    std::int64_t rate_, cap_, level_, last_;
};

static std::string attempts(TokenBucket& tb, int n) {
    std::string s;
    for (int i = 0; i < n; ++i) s += tb.try_acquire() ? '1' : '0';
    return s;
}

int main() {
    FakeClock clock;
    TokenBucket tb(clock, 10, 3);    // 10 per second, burst of 3
    std::cout << attempts(tb, 4) << '\n';
    clock.advance_us(100'000);       // 0.1 s earns exactly one token
    std::cout << attempts(tb, 2) << '\n';
    clock.advance_us(5'000'000);     // long idle: capped at the burst size
    std::cout << attempts(tb, 4) << '\n';

    SteadyClock real;
    TokenBucket live(real, 1000, 5);
    std::cout << live.try_acquire() << '\n';
}
1110
10
1110
1

Complexity and pitfalls. O(1) per call, no background thread. Pitfalls: using double tokens makes "exactly one token after 0.1 s" fail by one ulp, which produces flaky tests; using system_clock breaks when the wall clock jumps (NTP, daylight saving), so rate limiters use a monotonic clock; storing the Clock by reference requires it to outlive the bucket; and the class is not thread-safe (add a mutex, or a CAS loop on a packed state word for a lock-free version, Ch 5).

Estimated rating: ~1500 · simple algorithm; the interview is about injecting time and getting exact integer arithmetic

CodeIterators for a fixed-capacity ring buffer. Implement RingBuffer<T, N> where push overwrites the oldest element when full, with iterator and const_iterator that visit elements oldest first. They must satisfy std::forward_iterator (check with static_assert), support prefix and postfix ++, *, -> and ==, and an iterator must convert to a const_iterator. Range-for and standard algorithms must work.

Answer

Approach. Store std::array<T, N>, the index of the oldest element and the size. An iterator holds the buffer pointer and a logical index from 0 to size; dereferencing maps it to (head + i) % N, so end() is simply index size. One template Iter<bool Const> produces both iterator types, with the conversion constrained to the non-const one. The member types (value_type, difference_type, iterator_concept) plus a default constructor and defaulted == are what the C++20 concept checks.

#include <algorithm>
#include <array>
#include <cstddef>
#include <iostream>
#include <iterator>
#include <numeric>
#include <string>
#include <type_traits>

template <class T, std::size_t N>
class RingBuffer {
    template <bool Const>
    class Iter {
        using Buf = std::conditional_t<Const, const RingBuffer, RingBuffer>;
    public:
        using iterator_concept = std::forward_iterator_tag;
        using iterator_category = std::forward_iterator_tag;
        using value_type = T;
        using difference_type = std::ptrdiff_t;
        using reference = std::conditional_t<Const, const T&, T&>;

        Iter() = default;
        Iter(Buf* buf, std::size_t i) : buf_(buf), i_(i) {}
        operator Iter<true>() const requires(!Const) { return {buf_, i_}; }

        reference operator*() const { return buf_->data_[(buf_->head_ + i_) % N]; }
        auto operator->() const { return &**this; }
        Iter& operator++() { ++i_; return *this; }
        Iter operator++(int) { Iter old = *this; ++*this; return old; }
        friend bool operator==(const Iter&, const Iter&) = default;
    private:
        Buf* buf_ = nullptr;
        std::size_t i_ = 0;
    };

public:
    using iterator = Iter<false>;
    using const_iterator = Iter<true>;

    void push(const T& v) {
        data_[(head_ + size_) % N] = v;
        if (size_ < N) ++size_;
        else head_ = (head_ + 1) % N;  // full: the oldest element was overwritten
    }
    std::size_t size() const { return size_; }
    iterator begin() { return {this, 0}; }
    iterator end() { return {this, size_}; }
    const_iterator begin() const { return {this, 0}; }
    const_iterator end() const { return {this, size_}; }
private:
    std::array<T, N> data_{};
    std::size_t head_ = 0, size_ = 0;
};

static_assert(std::forward_iterator<RingBuffer<int, 4>::iterator>);
static_assert(std::forward_iterator<RingBuffer<int, 4>::const_iterator>);

template <class Range>
static void print(const Range& r) {
    bool first = true;
    for (const auto& x : r) { std::cout << (first ? "" : " ") << x; first = false; }
    std::cout << '\n';
}

int main() {
    RingBuffer<int, 4> rb;
    for (int i = 1; i <= 6; ++i) rb.push(i);  // keeps the last four
    print(rb);
    std::cout << "sum " << std::accumulate(rb.begin(), rb.end(), 0) << '\n';
    for (int& x : rb) x *= 10;
    const auto& crb = rb;
    print(crb);
    auto it = rb.begin();
    auto old = it++;
    std::cout << *old << ' ' << *it << '\n';
    RingBuffer<int, 4>::const_iterator cit = it;  // iterator converts to const_iterator
    auto found = std::find(crb.begin(), crb.end(), 50);
    std::cout << "found " << *found << " at " << std::distance(crb.begin(), found)
              << ", cit==it " << (cit == crb.begin()) << '\n';
    RingBuffer<std::string, 2> names;
    names.push("ab");
    names.push("cde");
    std::cout << names.begin()->size() << '\n';
}
3 4 5 6
sum 18
30 40 50 60
30 40
found 50 at 2, cit==it 0
2

Complexity and pitfalls. Every operation is O(1); std::distance is O(n) because the iterator is only forward (adding +=, - and <=> would make it random access). Pitfalls: storing a physical index makes end() equal begin() when the buffer is full, which is why the reference uses a logical index; postfix must return the old iterator by value; omitting the default constructor or difference_type makes std::forward_iterator fail with a long concept error; and pushing invalidates the meaning of existing iterators, which the class should document. The last comparison prints 0 because cit was taken after one increment, so it is not begin().

Estimated rating: ~1600 · the ring arithmetic is easy; the C++20 iterator requirements and the const/non-const pair are where candidates get stuck

Cheat sheet

Theory
  • Abstraction picks the operations; encapsulation hides the representation so only the class can break its invariant. Access control is per class, not per object.
  • Interfaces are classes with only pure virtual functions, no data, a public virtual or protected destructor, and protected copy operations. Use concepts or CRTP instead when the type is known at compile time.
  • Operators: = () [] -> and conversions must be members; compound assignment and ++ are members by convention; symmetric binary operators and << are non-members, ideally hidden friends. Build + from +=, take the left side by value, return a plain value. Postfix ++ takes a dummy int and returns the old value by copy.
  • C++20 comparisons: == also provides != and reversed operands; <=> provides the four relational operators. A defaulted <=> declares a defaulted ==, a user-written one does not. Members compare in declaration order. double gives partial_ordering.
  • Statics: inline static members need no out-of-class definition (C++17). Cross-TU dynamic initialisation order is unspecified: use constinit/constexpr or a function-local static (thread-safe since C++11). Destruction runs in reverse order of completed construction.
  • const: a const member function makes this a const T*; constness is shallow through pointers. Use mutable for caches and mutexes and synchronise it. Provide const and non-const overloads of accessors, or one C++23 deducing-this template.
  • Design: SOLID; Liskov is about behavioural promises (mutable square/rectangle fails, immutable passes); prefer composition, because inheriting implementation creates the fragile base class problem. Mark classes not designed for inheritance final.
  • PIMPL needs the destructor and move operations defined in the .cpp; write the deep copy yourself; remember constness does not propagate. CRTP gives inlinable static dispatch but no heterogeneous containers; guard it with a private base constructor and friend Derived;.

Part B · Threads · Chapter 4

Threads in the operating system

A thread is a scheduled stream of execution that shares its process's memory with every other thread in that process. This chapter builds the operating-system picture (what the kernel tracks, how threads are created, scheduled and switched) and then maps it onto pthread_create, std::thread and std::jthread. Interviewers use it to check that you know what is shared, what it costs, and how C++ thread objects end their lives.

TheoryDrillsCoding· about 5 hours· prerequisites: basic C++, RAII, lambdas

Process vs thread

Theory

Mental model. A process is a container of resources: one virtual address space, a table of open file descriptors, signal handlers, credentials and resource limits. A thread is a unit of execution inside that container: a program counter, a stack pointer, a register set and a stack. Every process starts with one thread. Creating more threads adds more streams of execution that all see the same memory.

The consequence that drives this whole part of the guide: two threads can read and write the same global, the same heap block and even each other's stack variables (if they are handed a pointer), with no copy and no permission check. That makes communication cheap and makes data races possible (Ch 5).

What is shared and what is private

One virtual address space (shared) main thread stack ↓ ⋮ thread 2 stack ↓ (mmap, 8 MiB default) guard page (no access) TLS blocks, one per thread shared libraries, other mappings heap ↑ (malloc, new) data + bss (globals, statics) text (machine code, read-only) Thread 1 (main): private PC, SP, flags, general registers TLS pointer (FS / TPIDR_EL0) tid, signal mask, errno, priority Thread 2: private PC, SP, flags, general registers TLS pointer (FS / TPIDR_EL0) tid, signal mask, errno, priority Per process: shared by all threads page tables, file descriptors, cwd, pid, signal handlers, limits, credentials SP SP TLS
Threads share the address space and own their execution state. Each thread's stack and TLS block live in the shared address space, so any thread with a pointer can reach them. What is truly private is the register set (including the stack pointer and the TLS base register) and a few kernel fields.
ResourceShared by all threadsPrivate to each threadNote
Address space (code, globals, heap, mappings)yesOne set of page tables; a write by one thread is visible to all (subject to the memory model)
StackyesPrivate by convention only: it is ordinary memory any thread can address
Registers, PC, SPyesSaved into the thread's kernel structure on a context switch
thread_local variables, errnoyesFound through a per-thread base register
File descriptors and file offsetsyesclose(fd) in one thread closes it for all
Signal handlers (dispositions)yesThe signal mask is per thread
Process id, cwd, umask, uid/gid, rlimitsyeschdir affects every thread
Thread id, scheduling priority, CPU affinityyesLinux schedules threads, not processes

Both facts are visible from user code. A thread sees the same process id as main but a different thread id, and globals and heap memory written by one thread are read back by another:

#include <iostream>
#include <thread>
#include <unistd.h>

int main() {
    const pid_t main_pid = getpid();
    const std::thread::id main_id = std::this_thread::get_id();
    bool same_pid = false, same_tid = true;
    std::thread t([&] {
        same_pid = (getpid() == main_pid);
        same_tid = (std::this_thread::get_id() == main_id);
    });
    t.join();  // join makes the writes above visible to main
    std::cout << "same process id: " << same_pid << '\n';
    std::cout << "same thread id:  " << same_tid << '\n';
}
same process id: 1
same thread id:  0
#include <cstdint>
#include <iostream>
#include <latch>
#include <memory>
#include <thread>

static int global_value = 0;   // data segment: one copy per process

int main() {
    auto heap_value = std::make_unique<int>(0);   // heap: one copy per process
    std::uintptr_t local_addr[2] = {0, 0};
    std::latch both_alive(2);

    auto body = [&](int idx) {
        int local = idx;               // lives on this thread's own stack
        local_addr[idx] = reinterpret_cast<std::uintptr_t>(&local);
        both_alive.arrive_and_wait();  // keep both stacks alive at the same time
        if (idx == 0) global_value = 10;
        else *heap_value = 20;
    };
    std::thread a(body, 0), b(body, 1);
    a.join();
    b.join();

    std::cout << "global written by a: " << global_value << '\n';
    std::cout << "heap written by b:   " << *heap_value << '\n';
    std::cout << "locals at different addresses: " << (local_addr[0] != local_addr[1]) << '\n';
}
global written by a: 10
heap written by b:   20
locals at different addresses: 1

Know this cold

Threads in a process share everything except the execution context: registers (PC, SP), the stack, thread-local storage, the signal mask, the thread id and scheduling attributes. Processes share nothing by default; they communicate through pipes, sockets, files or explicitly shared memory.

Why use threads at all

  • Parallelism: use several cores for one job (number crunching, compression, a parallel build).
  • Overlapping waits: one thread blocks on disk or network while another keeps computing or serving the UI.
  • Cheaper than processes: creation needs no new page tables, a switch inside one process keeps the TLB valid, and communication is a memory write.

The price is isolation. A wild pointer in one thread corrupts the whole process, and a crash in any thread kills all of them. Browsers and databases use separate processes exactly where they want that isolation back.

PCB, TCB and Linux tasks

Theory

Mental model. The kernel needs a record for everything it manages. The classic textbook split is a process control block (PCB) for the resource container and a thread control block (TCB) for each execution stream inside it.

PCB (per process)TCB (per thread)
pid, parent, credentialsthread id, state (ready, running, blocked)
memory map: pointer to page tables, list of regionssaved registers: PC, SP, flags, general and FP/SIMD registers
open file table, cwd, umaskkernel stack pointer, user stack location
signal handlers, pending process-wide signalssignal mask, pending thread-directed signals
resource limits, accounting totalspriority, scheduling policy, CPU affinity, time used
list of its threadsTLS base, pointer back to its process

On Linux, threads are tasks

Linux has no separate PCB and TCB types. Every schedulable entity is a task_struct, a task. Resources live in separately allocated, reference-counted objects (mm_struct for memory, files_struct for descriptors, sighand_struct for handlers, fs_struct for cwd). A "process" is a group of tasks that point at the same objects.

The system call that creates tasks is clone() (and the newer clone3()). Its flags choose what the child shares with the caller:

fork()            ≈ clone(SIGCHLD)                          share nothing: copy-on-write copy of memory
pthread_create()  ≈ clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
                          CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
                          CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID)
                                                            share memory, fds, cwd, handlers; join the thread group
  • CLONE_VM shares the address space, which is what makes it a thread.
  • CLONE_THREAD puts the new task in the caller's thread group. The group id (tgid) is what getpid() returns; each task's own id is what gettid() returns. For the first thread, pid equals tid.
  • CLONE_SETTLS sets the new thread's TLS base register; CLONE_CHILD_CLEARTID makes the kernel zero a word and wake a futex when the thread exits, which is how pthread_join waits.

This is why ps -eLf or top -H show threads as separate lines, why /proc/<pid>/task/ lists one directory per thread, and why a thread is sometimes called a "light-weight process" (LWP). The scheduler sees only tasks; it never needs to know which tasks form a process.

Interview trap

"Linux threads are processes" is half right. They are tasks, scheduled exactly like processes, but tasks in one thread group share one pid, one address space and one set of handlers, and a fatal signal kills the whole group. The old LinuxThreads library (before glibc 2.3 and NPTL) really did give each thread its own pid, which broke POSIX signal semantics; that is where the folklore comes from.

User-level vs kernel-level threads

Theory

Kernel-level threads are known to the kernel: each has a kernel record, the kernel scheduler places them on cores, and a blocking system call blocks only the calling thread. User-level threads are implemented by a library inside one process: the library keeps its own run queue, saves and restores registers itself, and the kernel sees only the threads that carry them.

N:1 (green threads) 1:1 (Linux NPTL, Windows) M:N (Go, Java virtual threads) user kernel library scheduler K one blocking call stalls all no parallelism K K K true parallelism, simple each thread costs a kernel task runtime scheduler K K cheap threads + parallelism complex runtime
The model is the mapping from user threads to kernel threads. N:1 multiplexes everything onto one kernel thread, 1:1 gives every user thread its own kernel task, and M:N runs many user threads on a small pool of kernel threads.
ModelExamplesStrengthsWeaknesses
N:1 (many user threads on one kernel thread)early Java green threads, GNU Pthswitches are function-call cheap, no kernel involvementa blocking syscall or page fault stops every thread; one core only
1:1Linux NPTL, Windows, macOS, std::thread everywherereal parallelism, blocking is per thread, kernel handles signals and prioritiescreation and switch go through the kernel; each thread reserves a full stack
M:NGo goroutines, Java 21 virtual threads, Erlang processes, old Solarismillions of cheap threads, still parallelthe runtime must avoid blocking carriers (it wraps I/O), harder debugging, priority inversion between levels

IBM built an M:N library for Linux (NGPT) at the same time as NPTL; NPTL won because a 1:1 design with a fast kernel was simpler and fast enough. C++ std::thread is specified as an OS thread, so on every mainstream platform it is 1:1. Cheap user-level concurrency in C++ comes from coroutines and executors, covered at the end of this chapter.

Thread states and lifecycle

Theory

A thread moves between a small number of states. The names vary between textbooks, but the machine is the same:

New Ready Running Terminated Blocked created, admitted dispatched by scheduler preempted, slice used up, yield() waits: lock, I/O, sleep, join, CV woken: unlock, I/O done, timer, notify returns, exits resources held until join (or detach) Linux ps codes: R = ready or running, S = interruptible sleep, D = uninterruptible sleep (usually disk I/O), T = stopped.
Only the scheduler moves a thread between Ready and Running; only an event moves it out of Blocked. A blocked thread never goes straight back to Running: it becomes Ready and waits its turn, which is why a wake-up does not mean "runs now".
  • New: the thread object and its stack are being set up. For std::thread this is inside the constructor; when the constructor returns, the thread is already Ready or Running.
  • Ready: runnable and waiting for a core. Linux keeps one run queue per CPU.
  • Running: executing on a core. At most one thread per hardware thread.
  • Blocked (waiting, sleeping): waiting for an event, such as a mutex, a condition variable, I/O, a timer or another thread's exit. It uses no CPU.
  • Terminated: the function returned or the thread called pthread_exit. Its stack and result stay allocated until someone calls join, unless the thread was detached.

Tip

Linux does not distinguish Ready from Running in ps: both show as R. A thread spinning on a lock is Running and burns CPU; a thread sleeping in futex() is S and costs nothing. That difference is the core of the spinlock vs mutex trade-off in Ch 5.

Context switches and scheduling

Theory

Mental model. A core can run one thread at a time. A context switch saves the running thread's state into its kernel record and loads another thread's state, so the core continues where the other thread left off.

What happens during a switch

  1. Entry to the kernel. Something transfers control: a timer interrupt (preemption), a blocking system call (read, futex wait), or a voluntary sched_yield.
  2. Save state. The user registers are already on the thread's kernel stack from the entry path. The scheduler saves the callee-saved registers and stack pointer into the task, and the FP/SIMD state is saved as well (on x86-64 Linux it is restored lazily, on the way back to user space).
  3. Pick the next task. The scheduler chooses from this CPU's run queue.
  4. Switch memory if needed. If the next task belongs to a different process, the kernel loads its page-table root (CR3 on x86-64, TTBR0 on arm64). Inside the same process this step is skipped.
  5. Restore state of the next task, including its TLS base register, and return to user space.

What it costs

The direct cost, the instructions above, is on the order of a microsecond on current hardware (much more with KPTI page-table isolation or with large SIMD state). The indirect cost is usually larger: the next thread finds caches, branch predictors and the TLB full of the previous thread's data.

Thread switch (same process)Process switch
Registers saved and restoredyesyes
Page-table root reloadednoyes
TLBstays validflushed, or kept but tagged with PCID/ASID so old entries are ignored
Cache contentsoften useful: same code, shared datamostly cold for the new process

The TLB caches virtual-to-physical translations. Without address-space tags, loading a new page-table root must discard them, and the new process then takes page-walk misses until the TLB warms up. PCID (x86-64) and ASID (arm64) tags reduce this, but a thread switch inside one process still avoids the question entirely.

Interview trap

"Threads switch faster because they do not save registers" is wrong: both save the same register state. The saving is in the memory context (no page-table switch, no TLB invalidation) and in warmer caches. And user-level switches (coroutines, green threads) are faster still because they never enter the kernel.

Scheduling basics that matter for threads

  • The unit is the thread. A process with 8 threads competes with 8 entries; Linux has no per-process fairness unless you use cgroups.
  • Policies. SCHED_OTHER (the default, time-shared fair scheduling with nice values -20 to 19; CFS until Linux 6.6, EEVDF since), SCHED_FIFO and SCHED_RR (fixed real-time priorities 1 to 99, which always beat SCHED_OTHER), SCHED_DEADLINE, SCHED_BATCH and SCHED_IDLE.
  • Preemption. A normal thread runs until it blocks, yields or uses up its slice (a few milliseconds). A SCHED_FIFO thread runs until it blocks or a higher-priority thread wakes, so a busy loop at real-time priority can starve its whole core.
  • Affinity. pthread_setaffinity_np or sched_setaffinity pins a thread to chosen cores. Low-latency systems pin one busy-polling thread per isolated core so it never migrates or gets preempted.
  • Oversubscription. More runnable threads than cores means time slicing: total throughput does not rise, and switching, cache thrashing and lock holder preemption make it fall. This is the main argument for thread pools.

Thread-local storage

Theory

Mental model. A thread_local variable has one instance per thread. Each thread reads and writes its own copy with no synchronisation, because no other thread can name that copy.

#include <iostream>
#include <thread>

thread_local int calls = 0;   // one instance per thread, zero-initialised in each

static int work(int n) {
    for (int i = 0; i < n; ++i) ++calls;   // no lock needed: nobody else sees this copy
    return calls;
}

int main() {
    int r1 = 0, r2 = 0;
    std::thread a([&] { r1 = work(3); });
    std::thread b([&] { r2 = work(5); });
    a.join();
    b.join();
    std::cout << "a: " << r1 << ", b: " << r2 << ", main: " << calls << '\n';
}
a: 3, b: 5, main: 0

Rules

  • thread_local can apply to namespace-scope variables, static data members and block-scope variables (where it implies static). Storage duration is "thread": the object lives as long as its thread.
  • Initialisation. Constant-initialised values are set up when the thread starts. Dynamically initialised namespace-scope ones are initialised before their first odr-use in that thread (implementations do it lazily), and block-scope ones the first time control passes the declaration in that thread.
  • Destruction. At thread exit, in reverse order of construction, before the thread's completion is signalled to join(). Thread-locals of the main thread are destroyed when main returns or exit is called, before static objects.
  • Implementation. The TLS base lives in a register (FS on x86-64, TPIDR_EL0 on arm64). Access in the main executable is one load at a fixed offset; access across shared libraries may call __tls_get_addr, and dynamically initialised variables add a wrapper that checks an initialisation flag.
  • Uses: per-thread caches and allocators (glibc's tcache, jemalloc arenas), random number engines, errno, per-thread counters merged later (the coding drill below), and avoiding locks on hot paths.

Interview trap

Thread pools reuse threads, so a thread_local survives between unrelated tasks. A cache keyed on "the current request" leaks data from one task to the next unless the task resets it. And the address of a thread_local handed to another thread is a plain pointer: the other thread can use it (it is shared memory), and it dangles once the owner exits.

pthreads, std::thread and std::jthread

Theory

The POSIX API

POSIX threads are a C API. The thread function has the fixed type void* (*)(void*), so arguments travel through a void* and the result comes back through pthread_join. Functions return an error number; they do not set errno.

#include <cstdio>
#include <cstring>
#include <pthread.h>

struct Job {
    int a, b;
    int sum;
};

static void* add(void* arg) {          // the only signature pthreads accepts
    auto* job = static_cast<Job*>(arg);
    job->sum = job->a + job->b;
    return job;                        // returned pointer is collected by pthread_join
}

int main() {
    Job job{2, 3, 0};
    pthread_t tid;
    if (int rc = pthread_create(&tid, nullptr, add, &job); rc != 0) {
        std::fprintf(stderr, "pthread_create: %s\n", std::strerror(rc));   // errors are returned, not errno
        return 1;
    }
    void* result = nullptr;
    pthread_join(tid, &result);
    std::printf("sum = %d, same object: %d\n", static_cast<Job*>(result)->sum, result == &job);
}
sum = 5, same object: 1
  • pthread_create(&tid, attr, fn, arg): starts the thread. attr sets stack size, detach state and scheduling policy.
  • pthread_join(tid, &ret): blocks until the thread ends and frees its resources. Joining twice, or joining a detached thread, is undefined.
  • pthread_detach(tid): resources are freed automatically when the thread ends; nobody can join it any more.
  • pthread_exit(ret): ends the calling thread. Called from main, it ends the main thread but keeps the process alive until the other threads finish.
  • pthread_self(), pthread_equal(), pthread_key_create() (the older dynamic TLS API).

std::thread

std::thread wraps one OS thread. The constructor takes any callable plus arguments and starts the thread before it returns. The object is a move-only handle; the thread itself is not the object.

#include <iostream>
#include <string>
#include <thread>

static void greet(std::string name, int times, std::string& out) {
    for (int i = 0; i < times; ++i) out += "hi " + name + "; ";
}

int main() {
    std::string out;
    std::thread t(greet, "ada", 2, std::ref(out));   // args are copied, std::ref opts out
    t.join();
    std::cout << out << '\n';

    int answer = 0;
    std::jthread j([&answer] { answer = 42; });      // C++20: joins in its destructor
    j.join();                                        // or let the destructor do it
    std::cout << "answer = " << answer << '\n';
}
hi ada; hi ada; 
answer = 42
  • joinable() is true from construction until join() or detach(), or until the handle is moved from. A default-constructed thread is not joinable.
  • join() waits for the thread to finish. Everything the thread did happens-before join() returns, so results written by the thread can be read afterwards without a lock.
  • detach() lets the thread run on alone; the handle becomes empty. You lose every way of knowing when it finishes.
  • Destructor: if the thread is still joinable, ~thread() calls std::terminate(). Move assignment onto a joinable thread does the same.

Know this cold

Destroying a joinable std::thread calls std::terminate. The committee rejected both implicit choices: an implicit join can hang during exception unwinding, and an implicit detach leaves a thread running with references to destroyed locals. C++20's std::jthread picks join, made safe by asking the thread to stop first.

Passing arguments

The constructor decay-copies the callable and every argument into storage owned by the new thread (this copy happens in the calling thread), then invokes the callable with those copies as rvalues. Three consequences:

  1. A parameter of type const T& binds to the internal copy, never to your original. Use std::ref or std::cref to pass a reference, and then you own the lifetime problem.
  2. A parameter of type T& does not compile without std::ref, because an rvalue cannot bind to a non-const lvalue reference.
  3. Implicit conversions (for example char* to std::string) happen in the new thread, possibly after the source buffer is gone. Convert explicitly before the call.

Lambdas follow their capture list instead: [=] copies when the lambda is created, [&] captures references. Capturing a loop variable or a local by reference in a thread that may outlive it is the most common thread bug in interviews.

std::jthread (C++20)

std::jthread behaves like std::thread with two additions. Its destructor calls request_stop() and then join(), so it can never terminate the program. And if the callable accepts a std::stop_token as its first parameter, the jthread passes one in, giving a standard cooperative cancellation channel (stop_requested(), std::stop_callback, and condition_variable_any::wait overloads that wake on a stop request).

Interview trap

A stop request is only a flag. A jthread whose function never checks its token, or is blocked in a plain read(), still makes the destructor wait forever. Cancellation is cooperative in C++; there is no portable way to kill a thread.

hardware_concurrency and stack size

std::thread::hardware_concurrency() returns a hint: the number of hardware threads the implementation knows about, or 0 if it cannot tell. On Linux, libstdc++ uses get_nprocs(), the online CPU count, which ignores CPU affinity masks and container CPU quotas. Treat it as an upper bound, clamp 0 to 1, and prefer sched_getaffinity when it matters.

std::thread has no way to set the stack size. glibc uses the RLIMIT_STACK soft limit (usually 8 MiB) for new threads, while macOS gives secondary threads 512 KiB. Use pthread_attr_setstacksize through the native API when you need control. The memory is virtual and committed page by page, so many threads cost address space more than RAM.

Thread pools, fork, signals and green threads

Theory

Why thread pools

Creating a thread costs a system call, a stack mapping and scheduler bookkeeping (tens of microseconds). A thread per request also lets the thread count grow with load, which leads to oversubscription and memory pressure. A thread pool creates a fixed set of workers once; producers push tasks into a shared queue and workers pop and run them. Benefits: creation cost paid once, concurrency bounded at about the core count, and a natural place for back-pressure and shutdown. The pool's queue is a producer-consumer problem solved with a mutex and condition variable (Ch 6); the coding section builds one.

Sizing rule of thumb: about the number of cores for CPU-bound tasks; more for tasks that block on I/O, roughly cores × (1 + wait time / compute time).

fork() in a multithreaded process

fork() copies the whole address space but creates only one thread in the child: a copy of the caller. Every other thread vanishes in the child, mid-instruction, without running destructors or unlocking anything. Any mutex another thread held at the moment of the fork (including the ones inside malloc, printf or the dynamic loader) stays locked forever in the child.

  • POSIX says that after fork() in a multithreaded process, the child may only call async-signal-safe functions until it calls exec. fork then exec immediately (or posix_spawn) is safe; doing real work in the child is not.
  • pthread_atfork(prepare, parent, child) lets a library take its locks before the fork and release them in both processes afterwards. glibc does this for malloc, which is why malloc usually works in such a child, but you cannot rely on it for other libraries.
  • Unflushed stdio buffers are copied too, so buffered output can be printed twice (see the drills).

Signals and threads, briefly

  • Signal handlers are per process; the signal mask is per thread (pthread_sigmask, never sigprocmask in threaded code).
  • A process-directed signal (kill, Ctrl-C) is delivered to one arbitrary thread that does not block it. A synchronous fault (SIGSEGV, SIGFPE) goes to the thread that caused it. pthread_kill targets one thread.
  • The robust pattern: block the signals in main before creating threads (new threads inherit the mask), then dedicate one thread to sigwait and handle signals as ordinary events there.
  • Handlers may only call async-signal-safe functions: no malloc, no locks, no std::cout. Setting a volatile std::sig_atomic_t or a lock-free atomic flag is the safe minimum.

Green threads and coroutines

OS threadGreen thread / fiber (stackful)C++20 coroutine (stackless)
Scheduled bykernel, preemptiveuser-space runtime, usually cooperativeyour code or an executor, cooperative at co_await
State kept infull stack (MiB reserved) + kernel taskits own small stack (KiB, sometimes growable)a heap frame holding only locals that live across suspension points
Switch costabout 1 µs plus cache effectstens of nsa function call
Parallel on many coresyesonly with M:N carriersonly if resumed on several threads
Blocking syscallblocks that thread onlyblocks the carrier unless the runtime wraps I/Oblocks the thread running it
Can suspend in a nested callyesyesno: only the coroutine body itself can co_await

Coroutines are a concurrency tool, not a parallelism tool. They let one thread interleave thousands of I/O-bound tasks; you still need threads to use more than one core, and once a coroutine is resumed on another thread all the data-race rules apply again.

Concept and tricky questions

Drills

ConceptList what threads of one process share and what each thread has privately.

Answer

Answer. Shared: the address space (code, globals, heap, memory mappings), file descriptors, cwd, signal handlers, pid, credentials and limits. Private: registers including PC and SP, the stack, thread-local storage (including errno), the signal mask, the thread id and scheduling attributes.

Why. Linux creates threads with clone(CLONE_VM | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | ...), so the resource objects are shared by reference and only the task's execution context is new. The common mistake is to say "each thread has its own heap". There is one heap; allocators keep per-thread caches (glibc tcache and arenas) for speed, but memory allocated by one thread can be freed by another. Another subtle point: a stack is "private" only by convention. Another thread can read and write it through a pointer.

ConceptWhy is a context switch between two threads of the same process usually cheaper than a switch between two processes?

Answer

Answer. The register save and restore is the same. The thread switch skips loading a new page-table root, so the TLB stays valid, and the new thread often finds useful code and data already in the caches.

Why. A process switch writes CR3 (x86-64) or TTBR0 (arm64). Without PCID or ASID tags that flushes the TLB, and even with tags the new process has few entries cached and takes page walks. Most of the measured cost of a switch is this indirect cache and TLB warm-up, not the switch code. The wrong answer is "threads have fewer registers to save".

TrickyOn Linux, what do getpid() and gettid() return in a second thread, and does the scheduler know which tasks form a process?

Answer

Answer. getpid() returns the thread group id, the same as in main. gettid() returns the task's own id, which differs. The scheduler schedules tasks individually and does not care about the grouping (apart from cgroups and group scheduling).

Why. Every Linux thread is a task_struct. CLONE_THREAD places the new task in the caller's thread group, and the group's id (the tgid, equal to the first thread's tid) is what POSIX calls the pid. People who learned from LinuxThreads-era material say each thread has its own pid; that was true before NPTL (glibc 2.3, 2003) and broke POSIX semantics such as signals sent to the process.

ConceptCompare the N:1, 1:1 and M:N threading models. Which does std::thread use on Linux?

Answer

Answer. N:1 puts all user threads on one kernel thread: cheap switches, but no parallelism and one blocking call stalls everything. 1:1 gives every user thread a kernel thread: true parallelism and per-thread blocking, at kernel cost. M:N multiplexes many user threads on a pool of kernel threads: cheap and parallel, but the runtime is complex. std::thread on Linux is 1:1 (glibc NPTL).

Why. In N:1 the kernel sees one thread, so it cannot run two of them at once and cannot know that a user thread blocked. M:N (Go, Java virtual threads) solves this by converting blocking I/O into non-blocking calls plus a scheduler park, and by moving goroutines off a carrier stuck in a syscall. A trap: M:N does not remove the need for locks. Two goroutines on two carriers race exactly like two OS threads.

TrickyWhy does destroying a joinable std::thread call std::terminate instead of joining or detaching? How does C++20 change this?

Answer

Answer. Both automatic choices are dangerous, so the standard makes forgetting a hard error. std::jthread (C++20) chooses to request a stop and join in its destructor.

Why. Implicit detach would leave a running thread that may still reference locals of the scope being destroyed: silent use-after-free. Implicit join can deadlock or hang during stack unwinding, for example when the thread waits for something the unwinding code would have done. std::terminate forces the programmer to decide. jthread makes join safe enough by first calling request_stop(), which a cooperative worker checks through its stop_token. The same terminate rule applies to move assignment onto a joinable thread ([thread.thread.assign]). Candidates often say "the destructor joins": that is jthread, not std::thread.

TrickyA program detaches a worker thread and then main returns while the worker is still running. What happens to the worker, and what can go wrong just before it dies?

Answer

Answer. Returning from main calls exit(), which runs static destructors and then ends the process with exit_group, killing every thread. The worker dies mid-instruction. Before that, while static destructors run, the worker may still be using those objects: undefined behaviour.

Why. Detach does not make a thread independent of its process. The classic crash is a detached logger thread writing to std::cout or a global logger while exit destroys them. Only pthread_exit from main (or quick_exit after the main thread reaches a safe point) keeps the process alive or skips destructors. The wrong answer is "the detached thread keeps running in the background". That is only true while the process lives.

TrickyCan a thread join itself? What happens if you call join() twice on the same std::thread?

#include <iostream>
#include <latch>
#include <system_error>
#include <thread>

static std::thread t;
static std::latch assigned(1), tried(1);

int main() {
    t = std::thread([] {
        assigned.wait();   // make sure t is fully assigned before the worker reads it
        try {
            t.join();      // the worker tries to wait for itself
        } catch (const std::system_error& e) {
            std::cout << "self join: " << (e.code() == std::errc::resource_deadlock_would_occur) << '\n';
        }
        tried.count_down();
    });
    assigned.count_down();
    tried.wait();          // never join one thread from two threads at once
    t.join();
    try {
        t.join();          // already joined: no longer joinable
    } catch (const std::system_error& e) {
        std::cout << "join twice: " << (e.code() == std::errc::invalid_argument) << '\n';
    }
}
Answer
self join: 1
join twice: 1

Answer. No. A thread waiting for its own termination could never finish, so std::thread::join() throws std::system_error with resource_deadlock_would_occur when get_id() == std::this_thread::get_id(). A second join() throws with invalid_argument, because after the first join the handle is no longer joinable.

Why. Both are specified error conditions in [thread.thread.member], so they are defined behaviour for std::thread (pthreads returns EDEADLK for a self-join, while joining an already joined pthread_t is undefined, because the id may already belong to a new thread). The common wrong answers are "it deadlocks forever" for the self-join and "the second join returns immediately". The two latches matter. Without assigned, the worker could read t while main is still move-assigning it, which is a data race. Without tried, main could already be inside join() when the worker calls it, and two simultaneous joins of one thread are undefined (macOS then reports EINVAL instead of EDEADLK). (ThreadSanitizer's pthread_join interceptor aborts on a deliberate self-join, so this program is run without it.)

ConceptWhen is a thread_local variable constructed and destroyed, and how does a thread find its copy?

Answer

Answer. Each thread gets its own instance. Constant-initialised ones are ready when the thread starts; dynamically initialised ones are constructed before first use in that thread (block-scope ones when control first passes the declaration). They are destroyed at thread exit in reverse order, before join() returns. The thread finds its copy through a per-thread base register (FS on x86-64, TPIDR_EL0 on arm64) plus an offset.

Why. The ELF TLS model lays out a template that the loader copies for each new thread. Variables defined in the main executable use a fixed offset from the register (one instruction); shared libraries loaded later may need __tls_get_addr. A common mistake is to think thread_local implies a lock or atomic: it needs neither, because only one thread can name each instance.

TrickyCan a thread pass a pointer to one of its stack variables to another thread? Is that safe?

Answer

Answer. Yes, it compiles and works: all stacks are in the shared address space. It is safe only while the variable is alive and the accesses are synchronised. Once the owning function returns or the thread exits, the pointer dangles.

Why. The OS does not protect one thread's stack from another; there is only a guard page below each stack to catch overflow. The standard library itself relies on this: std::ref(local) passed to a thread that is joined before the scope ends is fine. The bug is the lifetime, not the address: detaching such a thread, or returning before join, is the classic dangling reference.

ConceptWhat does std::thread::hardware_concurrency() return, and why can it be the wrong number for sizing a pool?

Answer

Answer. A hint: the number of hardware threads (logical CPUs) the implementation can see, or 0 if unknown. In a container with a CPU quota, or a process pinned to a subset of cores, it still reports the machine's online CPUs.

Why. The standard only says "number of concurrent threads supported... should be considered only a hint". libstdc++ uses get_nprocs(); it does not look at sched_getaffinity or cgroup cpu.max. It also counts SMT siblings, which share execution units. Always handle 0, and for CPU-bound work consider the affinity mask; for I/O-bound work more threads than cores is normal.

TrickyA multithreaded server calls fork() to run some work in a child, and the child sometimes hangs forever in malloc or a logging call. Why, and what are the safe options?

Answer

Answer. The child contains only a copy of the forking thread. If another thread held a lock (in the allocator, the logger, stdio, the loader) at the moment of the fork, the lock's memory is copied in the locked state and no thread exists in the child to release it. Safe options: call exec straight after fork (or use posix_spawn), fork before creating threads, or use pthread_atfork handlers to quiesce your own locks.

Why. POSIX restricts the child of a multithreaded process to async-signal-safe functions until exec. glibc registers atfork handlers for malloc, which is why it often works, but third-party libraries usually do not. The wrong answer is "fork copies all threads": only Solaris had a forkall.

ConceptA process receives SIGTERM. Which thread runs the handler, and what is the recommended way to handle signals in a multithreaded program?

Answer

Answer. Any one thread that does not block SIGTERM; the choice is up to the kernel. The recommended pattern is to block the signals with pthread_sigmask in main before creating threads (the mask is inherited), then have one dedicated thread call sigwait and turn each signal into a normal event, such as setting a stop flag or notifying a condition variable.

Why. Handlers are shared by the process, masks are per thread, and a handler interrupts whatever the chosen thread was doing, possibly while it holds a lock. Inside a handler only async-signal-safe functions are allowed, so locking a mutex or calling notify_one there can deadlock or corrupt state. Synchronous signals like SIGSEGV are the exception: they go to the faulting thread.

Tricky"C++20 coroutines are lightweight threads, so I can use them instead of a thread pool to speed up a CPU-bound computation." What is wrong with that statement?

Answer

Answer. Coroutines give concurrency, not parallelism. A coroutine runs on whichever thread resumes it; with one thread, a thousand coroutines still use one core. To speed up CPU-bound work you need several OS threads (often a pool that resumes coroutines).

Why. C++20 coroutines are stackless: a coroutine frame stores locals that cross a suspension point, and co_await returns control to the caller or resumer. There is no scheduler and no preemption in the language. They shine for I/O-bound work where most tasks wait. Also, a coroutine that calls a blocking function blocks its whole thread, and a coroutine resumed on a different thread can see thread_local values change across a co_await.

TrickyA program creates 10,000 threads, each doing a tiny recursive computation, and fails with "Resource temporarily unavailable" although only a few GB of RAM are in use. What limits apply?

Answer

Answer. Thread creation fails with EAGAIN (thrown as std::system_error by std::thread) when a limit is hit: the per-user process/task limit (RLIMIT_NPROC, ulimit -u), /proc/sys/kernel/threads-max, cgroup pids.max (common in containers), the number of memory mappings, or virtual address space for the stacks (8 MiB reserved each by default).

Why. On Linux, threads count as tasks against the same limits as processes. Stack memory is reserved virtually and committed lazily, so RSS stays low while the reservation can still exhaust a limited address space or vm.max_map_count (each stack plus guard page is a mapping). The real fix is a thread pool sized near the core count; shrinking stacks with pthread_attr_setstacksize only treats the symptom.

ConceptWhy use a thread pool instead of creating a thread per task?

Answer

Answer. A pool pays thread creation once, bounds the number of runnable threads (avoiding oversubscription), keeps caches and thread-locals warm, and gives one place for queueing, back-pressure and orderly shutdown.

Why. Each creation is a clone system call, a stack mapping and scheduler work, tens of microseconds, which is more than many tasks take. With one thread per request, a load spike creates thousands of threads that thrash the scheduler and memory. The pitfalls a good answer also names: tasks that block on other tasks in the same pool can deadlock it when every worker waits, and long blocking I/O tasks starve CPU tasks unless they get a separate pool.

Predict the output and find the bug

Drills

OutputWhat does this print? Each thread is joined before the next starts.

#include <functional>
#include <iostream>
#include <thread>

static int g = 0;

static void check(const int& r) {
    std::cout << (&r == &g ? "same object" : "a copy") << '\n';
}

int main() {
    std::thread t1(check, g);
    t1.join();
    std::thread t2(check, std::cref(g));
    t2.join();
    std::thread t3([] { check(g); });
    t3.join();
}
Answer
a copy
same object
same object

Answer. Only the first call sees a copy.

Why. std::thread t1(check, g) decay-copies g into the thread's own storage and the const int& binds to that copy ([thread.thread.constr]). std::cref(g) copies a reference_wrapper, which converts back to a reference to g. The lambda calls check(g) directly, so no copy is involved. People expect "same object" for the first line because the parameter is a reference; the reference binds to the thread's copy.

Find the bugWhy does this not compile, and what are the two fixes?

#include <iostream>
#include <thread>

static void increment(int& x) { ++x; }

int main() {
    int n = 0;
    std::thread t(increment, n);
    t.join();
    std::cout << n << '\n';
}
Answer

Answer. The thread stores a copy of n and calls increment with that copy as an rvalue, and an rvalue int cannot bind to int&. GCC reports "std::thread arguments must be invocable after conversion to rvalues". Fix with std::thread t(increment, std::ref(n)); or with a lambda std::thread t([&n] { increment(n); });. Either way it prints 1.

Why. The library deliberately passes rvalues so that a function taking int& cannot silently modify a temporary copy, which would look like it worked but leave n at 0. The compile error is a feature. The common wrong answer is "it compiles and prints 1 because the parameter is a reference": the reference would bind to the thread's internal copy, if it were allowed to bind at all.

Find the bugThe author expects sum = 6. What is wrong? (The sum is atomic.)

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

int main() {
    std::atomic<int> sum{0};
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i)
        threads.emplace_back([&] { sum += i; });   // expected 0+1+2+3 = 6
    for (auto& t : threads) t.join();
    std::cout << "sum = " << sum << '\n';
}
Answer

Answer. The lambda captures the loop variable i by reference. Each thread reads i whenever it gets to run, while main keeps incrementing it: a data race on i, so undefined behaviour. In practice the printed sum varies from run to run, because a thread that starts late sees a larger i (often 4). ThreadSanitizer reports the race. Fix: capture by value, [&sum, i] { sum += i; }.

Why. Making sum atomic protects only sum. The read of i in the thread and the write ++i in main are unsynchronised conflicting accesses, which is a data race by definition ([intro.races]). After the loop i is also out of scope, so a late thread reads a dead variable. The output is not shown because it is nondeterministic.

OutputWhat does this print?

#include <iostream>
#include <thread>

static void work() {}

int main() {
    std::thread t1(work);
    std::thread t2 = std::move(t1);
    std::cout << t1.joinable() << t2.joinable() << '\n';
    t1 = std::thread(work);
    std::cout << t1.joinable() << t2.joinable() << '\n';
    t1.join();
    t2.join();
    std::cout << t1.joinable() << t2.joinable() << '\n';
    std::thread t3;
    std::cout << t3.joinable() << (t3.get_id() == std::thread::id{}) << '\n';
}
Answer
01
11
00
01

Answer. 01, 11, 00, 01.

Why. Moving transfers ownership of the OS thread, leaving t1 empty (not joinable). Assigning a new thread to the empty t1 is fine; had t1 still been joinable, the move assignment would have called std::terminate. After join() both handles are empty. A default-constructed thread is not joinable and its id compares equal to std::thread::id{}, the "no thread" id.

Find the bugThe author expects caught boom and end. What actually happens?

#include <iostream>
#include <stdexcept>
#include <thread>

static void work() {}
static void risky() { throw std::runtime_error("boom"); }

int main() {
    try {
        std::thread t(work);
        risky();
        t.join();
    } catch (const std::exception& e) {
        std::cout << "caught " << e.what() << '\n';
    }
    std::cout << "end\n";
}
Answer

Answer. Nothing is printed and the program aborts: risky() throws, unwinding destroys t while it is still joinable, and ~thread() calls std::terminate. libstdc++ prints "terminate called without an active exception" to stderr. (The snippet is tagged as not compared because it aborts.)

Why. The join() after the throwing call is skipped. This is the main reason never to hold a raw std::thread across code that can throw. Fixes: use std::jthread, or an RAII joining guard (coding drill 2), or join in a catch block and rethrow. Candidates sometimes expect the catch block to run; it cannot, because terminate happens during unwinding before the handler is reached.

OutputBoth workers spin until stopped. What does this print, and in what order?

#include <iostream>
#include <stop_token>
#include <thread>

static std::jthread make(const char* name) {
    return std::jthread([name](std::stop_token st) {
        while (!st.stop_requested()) std::this_thread::yield();
        std::cout << name << " stopped\n";
    });
}

int main() {
    std::jthread a = make("a");
    std::jthread b = make("b");
    std::cout << "end of main\n";
}
Answer
end of main
b stopped
a stopped

Answer. end of main, then b stopped, then a stopped. The order is deterministic.

Why. Neither worker can print before its stop is requested. Locals are destroyed in reverse order of construction, so b's destructor runs first: request_stop() and then join(), which waits for "b stopped". Only then is a destroyed and stopped. With std::thread instead, the program would terminate at the end of main. Note that make returns the jthread by value; moving a jthread moves its stop state with it.

OutputWhat does this print? Watch when the thread-local object is created and destroyed.

#include <iostream>
#include <thread>

struct Tracker {
    int id;
    explicit Tracker(int i) : id(i) { std::cout << "ctor " << id << '\n'; }
    ~Tracker() { std::cout << "dtor " << id << '\n'; }
};

static int next_id = 0;   // touched by one thread at a time (the joins order them)

static Tracker& tracker() {
    thread_local Tracker t(++next_id);
    return t;
}

int main() {
    std::thread a([] {
        int x = tracker().id;
        std::cout << "a uses " << x << '\n';
    });
    a.join();
    std::cout << "joined a\n";
    std::thread b([] {
        int x = tracker().id;
        int y = tracker().id;
        std::cout << "b uses " << x << ' ' << y << '\n';
    });
    b.join();
    std::cout << "joined b\n";
}
Answer
ctor 1
a uses 1
dtor 1
joined a
ctor 2
b uses 2 2
dtor 2
joined b

Answer. Each thread constructs its own Tracker on first use and destroys it when the thread exits, before join() returns. Thread b calls tracker() twice but constructs only once. main never calls it, so no third object exists.

Why. A block-scope thread_local is initialised the first time control passes its declaration in each thread, and destroyed at that thread's exit. The standard sequences thread-local destructors before the thread's completion, and completion synchronises with the return from join() ([thread.thread.member]), so "dtor 1" always precedes "joined a". The local variable x exists so that construction ("ctor") is printed on its own line; with C++17's left-to-right sequencing of <<, putting tracker() inside the output statement would print "a uses ctor 1".

OutputThe test harness captures stdout through a pipe. What does it see?

#include <cstdio>
#include <sys/wait.h>
#include <unistd.h>

int main() {
    std::printf("start\n");
    pid_t pid = fork();
    if (pid == 0) {
        std::printf("child\n");
        return 0;              // exit() flushes the child's copy of the stdio buffer
    }
    waitpid(pid, nullptr, 0);
    std::printf("parent\n");
}
Answer
start
child
start
parent

Answer. start appears twice. On a terminal you would see start, child, parent.

Why. stdout is line buffered only on a terminal; on a pipe or file it is fully buffered, so "start\n" is still in the user-space buffer when fork() copies the address space. Each process flushes its own copy at exit. The child exits first (the parent waits for it), giving start child, then the parent prints start parent. Fix: fflush(stdout) before fork(), and use _exit in a child that should not flush inherited buffers. The same memory-copy rule is why locks held by other threads stay locked in the child (next drill).

Find the bugRun this and it never prints "child got the lock". Why? (Tagged compile-only because it hangs.)

#include <chrono>
#include <cstdio>
#include <mutex>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>

static std::mutex m;

int main() {
    std::thread holder([] {
        std::lock_guard lk(m);
        std::this_thread::sleep_for(std::chrono::seconds(1));
    });
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    pid_t pid = fork();
    if (pid == 0) {
        std::lock_guard lk(m);
        std::puts("child got the lock");
        _exit(0);
    }
    waitpid(pid, nullptr, 0);
    holder.join();
    std::puts("parent done");
}
Answer

Answer. The child deadlocks forever, and so does the parent in waitpid. At the fork, holder owns m. The child gets a copy of the mutex in the locked state but no copy of holder, so nobody will ever unlock it.

Why. fork() duplicates only the calling thread. Locking a mutex in the child of a multithreaded process breaks the POSIX rule that only async-signal-safe functions may be called before exec. Fixes: fork before creating threads, exec immediately, or register pthread_atfork handlers that acquire m in "prepare" and release it in both "parent" and "child". The sleeps only make the timing reliable for the demonstration; they are not a synchronisation tool.

Find the bugSometimes this prints garbage or nothing. Explain the exact moment the bug happens. (Compile-only: the result is undefined.)

#include <chrono>
#include <cstdio>
#include <iostream>
#include <string>
#include <thread>

static void log_line(const std::string& s) { std::cout << s << '\n'; }

static void start_job(int id) {
    char buf[32];
    std::snprintf(buf, sizeof buf, "job %d started", id);
    std::thread t(log_line, buf);
    t.detach();
}

int main() {
    start_job(1);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
Answer

Answer. std::thread decay-copies the argument, and buf decays to a char*. The conversion from char* to std::string happens later, inside the new thread, when log_line is invoked. By then start_job may have returned and buf is dead. Fix: std::thread t(log_line, std::string(buf)); so the string is built and copied before the constructor returns.

Why. This is the classic example from Anthony Williams' "C++ Concurrency in Action". detach() hides the lifetime problem: if start_job called t.join() before returning, buf would still be alive during the conversion and the code would happen to work. The bug is the combination of a deferred conversion and a buffer that dies first. People often think "the parameter is a std::string, so it was copied": the parameter type does not matter, the stored type is the decayed argument type.

Write the code

Coding

CodeParallel sum. Write std::int64_t parallel_sum(const std::vector<int>& v, unsigned n_threads) that splits the vector into contiguous chunks, sums each in its own thread and combines the results. Requirements: correct for any size including empty and for more threads than elements; no locks or atomics on the hot path; no thread is left joinable if something throws.

Answer

Approach. Clamp the thread count to the range 1 to v.size(). Give thread i a chunk of size / n elements, plus one for the first size % n threads, so chunk sizes differ by at most one. Each thread sums into a local variable and writes its result once into its own slot of a partial vector. Joining the threads makes those writes visible to the caller, which adds them up.

#include <algorithm>
#include <cstdint>
#include <iostream>
#include <numeric>
#include <thread>
#include <vector>

static std::int64_t parallel_sum(const std::vector<int>& v, unsigned n_threads) {
    if (v.empty()) return 0;
    n_threads = std::clamp<unsigned>(n_threads, 1, static_cast<unsigned>(v.size()));
    std::vector<std::int64_t> partial(n_threads, 0);
    const std::size_t chunk = v.size() / n_threads;
    const std::size_t extra = v.size() % n_threads;
    {
        std::vector<std::jthread> workers;   // jthread joins even if a later emplace_back throws
        workers.reserve(n_threads);
        std::size_t begin = 0;
        for (unsigned i = 0; i < n_threads; ++i) {
            const std::size_t end = begin + chunk + (i < extra ? 1 : 0);
            workers.emplace_back([&v, &partial, i, begin, end] {
                std::int64_t s = 0;          // local accumulator: one write to shared memory
                for (std::size_t k = begin; k < end; ++k) s += v[k];
                partial[i] = s;
            });
            begin = end;
        }
    }   // all workers joined here
    return std::accumulate(partial.begin(), partial.end(), std::int64_t{0});
}

int main() {
    std::vector<int> v(1'000'001);
    std::iota(v.begin(), v.end(), 1);
    for (unsigned n : {1u, 3u, 8u, 64u})
        std::cout << n << " threads: " << parallel_sum(v, n) << '\n';
    std::cout << "tiny: " << parallel_sum({5, 7}, 16) << '\n';
    std::cout << "empty: " << parallel_sum({}, 4) << '\n';
}
1 threads: 500001500001
3 threads: 500001500001
8 threads: 500001500001
64 threads: 500001500001
tiny: 12
empty: 0

Complexity and pitfalls. O(n / threads) time per thread plus O(threads) to combine. Accumulating directly into partial[i] inside the loop would be correct but slow: adjacent slots share a cache line, so every write invalidates the line in the other cores (false sharing, Ch 19). Using std::jthread in a vector means that if the fifth thread fails to start, the first four are joined during unwinding instead of calling terminate. Use a 64-bit accumulator: the sum of a million ints overflows 32 bits.

Estimated rating: ~1200 · straightforward partitioning, with the off-by-one chunking and empty input as the only traps

CodeRAII joining thread. Implement JoiningThread, a move-only wrapper around std::thread that joins in its destructor and on move assignment instead of terminating. It must forward any callable and arguments, expose join, detach and joinable, and survive an exception thrown after the thread starts.

Answer

Approach. Hold a std::thread member and perfect-forward the constructor arguments to it. Delete copying, default the move constructor (it leaves the source empty), and write move assignment to join the current thread before taking the new one, because std::thread's own move assignment terminates if the target is joinable. The destructor joins if joinable.

#include <iostream>
#include <stdexcept>
#include <thread>
#include <utility>

class JoiningThread {
public:
    JoiningThread() noexcept = default;
    template <class F, class... Args>
    explicit JoiningThread(F&& f, Args&&... args)
        : t_(std::forward<F>(f), std::forward<Args>(args)...) {}

    JoiningThread(JoiningThread&&) noexcept = default;
    JoiningThread& operator=(JoiningThread&& other) noexcept {
        if (this != &other) {
            join_if_needed();          // std::thread would call terminate here instead
            t_ = std::move(other.t_);
        }
        return *this;
    }
    JoiningThread(const JoiningThread&) = delete;
    JoiningThread& operator=(const JoiningThread&) = delete;
    ~JoiningThread() { join_if_needed(); }

    bool joinable() const noexcept { return t_.joinable(); }
    void join() { t_.join(); }
    void detach() { t_.detach(); }

private:
    void join_if_needed() noexcept {
        if (t_.joinable()) t_.join();
    }
    std::thread t_;
};

int main() {
    int result = 0;
    try {
        JoiningThread worker([&result] { result = 7 * 6; });
        throw std::runtime_error("boom");   // unwinding joins the worker, no terminate
    } catch (const std::exception& e) {
        std::cout << "caught " << e.what() << ", result " << result << '\n';
    }

    int a = 0, b = 0;
    JoiningThread t([&a] { a = 1; });
    t = JoiningThread([&b] { b = 2; });     // joins the first thread, then takes the second
    std::cout << "a after reassignment: " << a << '\n';
    t.join();
    std::cout << "b: " << b << ", joinable: " << t.joinable() << '\n';
}
caught boom, result 42
a after reassignment: 1
b: 2, joinable: 0

Complexity and pitfalls. O(1) apart from the join itself. Guard the move assignment against self-assignment, or t = std::move(t) would join and then move from itself. The variadic constructor is explicit so that a lambda does not silently convert to a JoiningThread. A joining destructor can hang if the thread waits for something that the unwinding code was going to provide; that is the gap std::jthread fills with a stop token. In C++20 code, prefer std::jthread; this exercise is how interviewers check that you know why it exists.

Estimated rating: ~1300 · small class, but move assignment and the terminate rule must both be right

CodeFixed-size thread pool. Implement ThreadPool(unsigned n) with template <class F> std::future<R> submit(F f) where R is the callable's return type. Exceptions thrown by a task must reach the caller through the future. The destructor must finish all queued tasks and then join the workers. Submitting after shutdown begins must fail loudly.

Answer

Approach. Workers loop: lock the mutex, wait on a condition variable until there is a job or the pool is stopping, pop one job, unlock, run it. submit wraps the callable in a std::packaged_task, which captures the return value or exception into the shared state behind the future. Because packaged_task is move-only and std::function needs a copyable target, the task is held by a shared_ptr (in C++23, std::move_only_function removes that allocation). The destructor sets stopping_ under the lock, notifies all, and joins; workers only exit when the queue is empty, so queued work is drained.

#include <condition_variable>
#include <functional>
#include <future>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <vector>

class ThreadPool {
public:
    explicit ThreadPool(unsigned n) {
        if (n == 0) n = 1;
        workers_.reserve(n);
        for (unsigned i = 0; i < n; ++i) workers_.emplace_back([this] { run(); });
    }
    ThreadPool(const ThreadPool&) = delete;
    ThreadPool& operator=(const ThreadPool&) = delete;
    ~ThreadPool() {
        {
            std::lock_guard lk(m_);
            stopping_ = true;
        }
        cv_.notify_all();
        for (auto& w : workers_) w.join();   // workers drain the queue first
    }

    template <class F>
    auto submit(F f) -> std::future<std::invoke_result_t<F&>> {
        using R = std::invoke_result_t<F&>;
        // packaged_task is move-only and std::function needs a copyable target: share it
        auto task = std::make_shared<std::packaged_task<R()>>(std::move(f));
        std::future<R> fut = task->get_future();
        {
            std::lock_guard lk(m_);
            if (stopping_) throw std::runtime_error("submit on a stopped pool");
            jobs_.emplace([task] { (*task)(); });
        }
        cv_.notify_one();
        return fut;
    }

private:
    void run() {
        for (;;) {
            std::function<void()> job;
            {
                std::unique_lock lk(m_);
                cv_.wait(lk, [this] { return stopping_ || !jobs_.empty(); });
                if (jobs_.empty()) return;   // stopping and nothing left to do
                job = std::move(jobs_.front());
                jobs_.pop();
            }
            job();   // run outside the lock
        }
    }

    std::mutex m_;
    std::condition_variable cv_;
    std::queue<std::function<void()>> jobs_;
    bool stopping_ = false;
    std::vector<std::thread> workers_;
};

int main() {
    ThreadPool pool(4);
    std::vector<std::future<int>> results;
    for (int i = 1; i <= 10; ++i)
        results.push_back(pool.submit([i] { return i * i; }));
    int total = 0;
    for (auto& f : results) total += f.get();
    std::cout << "sum of squares: " << total << '\n';

    auto bad = pool.submit([]() -> int { throw std::runtime_error("task failed"); });
    try {
        std::cout << bad.get() << '\n';
    } catch (const std::exception& e) {
        std::cout << "rethrown: " << e.what() << '\n';
    }

    auto text = pool.submit([] { return std::string("done"); });
    std::cout << text.get() << '\n';
}
sum of squares: 385
rethrown: task failed
done

Complexity and pitfalls. O(1) per submit and per pop, plus one lock round trip each. Pitfalls: run the job outside the lock, or the pool serialises; set stopping_ while holding the mutex, or a worker can check the predicate, miss the notification and sleep forever (a lost wake-up, Ch 6); declare workers_ after the mutex, queue and condition variable so they exist before any worker starts. A task that waits on another task's future can deadlock the pool when all workers are waiting. A single shared queue becomes a contention point at high task rates; production pools use per-worker queues with work stealing.

Estimated rating: ~1900 · combines condition variables, type deduction, packaged_task and a correct drain-then-join shutdown

CodePer-thread counters. A hot function record_hit() is called millions of times from many threads. Make it lock-free and atomic-free on the hot path by counting in thread-local storage, and merge each thread's count into a global total when the thread exits. After all workers are joined, the global total must be exact.

Answer

Approach. A thread_local object holds a plain counter. Its destructor runs at thread exit and adds the count into a global total under a mutex, which is taken once per thread instead of once per hit. Because thread-local destructors complete before join() returns, main sees the final totals after joining.

#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

struct Totals {
    std::mutex m;
    long hits = 0;
    int threads_merged = 0;
};
static Totals totals;

struct LocalCounter {
    long hits = 0;
    ~LocalCounter() {                 // runs at thread exit, before join() returns
        std::lock_guard lk(totals.m);
        totals.hits += hits;
        ++totals.threads_merged;
    }
};
thread_local LocalCounter local;

static void record_hit() { ++local.hits; }   // hot path: no lock, no atomic

int main() {
    std::vector<std::thread> workers;
    for (int t = 0; t < 4; ++t)
        workers.emplace_back([t] {
            for (int i = 0; i < 100000 * (t + 1); ++i) record_hit();
        });
    for (auto& w : workers) w.join();
    std::lock_guard lk(totals.m);
    std::cout << "hits: " << totals.hits << ", threads merged: " << totals.threads_merged << '\n';
}
hits: 1000000, threads merged: 4

Complexity and pitfalls. O(1) per hit with no shared writes; O(threads) lock acquisitions in total. Pitfalls: the global must outlive every thread-local that merges into it, which holds here because workers are joined before main returns (a detached thread exiting during static destruction would touch a destroyed mutex). The main thread's counter would merge only when main exits, after the print. Totals are only complete at join points; for a live total readable at any time, use one cache-line-aligned atomic per thread and sum them on read.

Estimated rating: ~1500 · needs precise knowledge of thread_local lifetime and the join guarantee rather than much code

Codeparallel_for with exceptions. Implement parallel_for(n, threads, f) that calls f(i) for every i in the range 0 to n-1 using up to threads threads. If any call throws, parallel_for must rethrow one of the exceptions in the calling thread after all threads have finished. The program must never terminate because of an exception in a worker.

Answer

Approach. Split the range into contiguous chunks with n * t / threads boundaries, which distributes the remainder evenly without special cases. Each worker wraps its loop in try { ... } catch (...) and stores std::current_exception() into its own slot, so no lock is needed. After the jthreads are joined at the end of the block, rethrow the first stored exception.

#include <algorithm>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>

template <class F>
void parallel_for(std::size_t n, unsigned threads, F f) {
    threads = std::max(1u, threads);
    std::vector<std::exception_ptr> errors(threads);
    {
        std::vector<std::jthread> pool;
        pool.reserve(threads);
        for (unsigned t = 0; t < threads; ++t) {
            const std::size_t begin = n * t / threads, end = n * (t + 1) / threads;
            pool.emplace_back([&f, &errors, t, begin, end] {
                try {
                    for (std::size_t i = begin; i < end; ++i) f(i);
                } catch (...) {
                    errors[t] = std::current_exception();   // an escaping exception would terminate
                }
            });
        }
    }
    for (auto& e : errors)
        if (e) std::rethrow_exception(e);   // report the first chunk that failed
}

int main() {
    std::vector<long> sq(20);
    parallel_for(sq.size(), 4, [&sq](std::size_t i) { sq[i] = static_cast<long>(i * i); });
    std::cout << "sq[19] = " << sq[19] << ", sq[7] = " << sq[7] << '\n';

    try {
        parallel_for(100, 8, [](std::size_t i) {
            if (i == 42) throw std::out_of_range("bad index " + std::to_string(i));
        });
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}
sq[19] = 361, sq[7] = 49
caught: bad index 42

Complexity and pitfalls. O(n / threads) per worker. An exception that escapes a thread function calls std::terminate, so the catch-all is mandatory. std::exception_ptr keeps the exception object alive and can be rethrown in another thread. The other chunks keep running after one fails; add a shared std::atomic<bool> "cancelled" flag (or a stop_source) to stop early. Writes by different threads to different elements of sq are not a data race: distinct objects, even in one vector, are distinct memory locations.

Estimated rating: ~1600 · the chunking is easy; exception transport across threads is what interviewers look for

CodeDynamic work distribution. Count the primes below limit with n_threads threads. Cost per number grows with its size, so a static split gives the last thread the most work. Distribute work dynamically in blocks of block numbers, using no mutex.

Answer

Approach. A shared std::atomic<int> cursor hands out blocks: each fetch_add(block) atomically claims the range starting at the returned value. A worker stops when the claimed start is past the limit. Each worker counts locally and adds its count to the total once at the end. Relaxed ordering is enough because the only requirement is that each block is claimed exactly once, and the joins publish the totals.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

static bool is_prime(int n) {
    if (n < 2) return false;
    for (int d = 2; d * d <= n; ++d)
        if (n % d == 0) return false;
    return true;
}

static int count_primes(int limit, unsigned n_threads, int block) {
    std::atomic<int> next{0};      // shared cursor: each fetch_add claims one block
    std::atomic<int> total{0};
    {
        std::vector<std::jthread> workers;
        for (unsigned t = 0; t < n_threads; ++t)
            workers.emplace_back([&] {
                int mine = 0;
                for (;;) {
                    const int start = next.fetch_add(block, std::memory_order_relaxed);
                    if (start >= limit) break;
                    const int stop = std::min(limit, start + block);
                    for (int n = start; n < stop; ++n) mine += is_prime(n);
                }
                total.fetch_add(mine, std::memory_order_relaxed);
            });
    }   // the joins order every fetch_add before the load below
    return total.load();
}

int main() {
    std::cout << count_primes(200000, 4, 1000) << '\n';
    std::cout << count_primes(200000, 7, 1) << '\n';
    std::cout << count_primes(10, 3, 4) << '\n';
}
17984
17984
4

Complexity and pitfalls. About limit × √limit / threads work, with one atomic operation per block. Block size trades contention (too small: every number is a contended fetch_add on one cache line) against load balance (too large: the last blocks leave cores idle). The cursor can overshoot limit by up to threads × block, which is harmless with int here but needs care near the type's maximum. This pattern is the core of OpenMP's schedule(dynamic).

Estimated rating: ~1400 · one atomic fetch_add and a clean loop, with block size as the design discussion

Cheat sheet

Theory
  • Threads share the address space, fds, handlers and pid; each owns registers, stack, TLS, signal mask and tid. On Linux every thread is a task created by clone(CLONE_VM | CLONE_THREAD | ...); getpid() is the group id, gettid() the task id.
  • Models: N:1 (no parallelism, a block stalls all), 1:1 (Linux NPTL, std::thread), M:N (Go, Java virtual threads).
  • States: New, Ready, Running, Blocked, Terminated. Wake-ups go to Ready, never straight to Running.
  • Thread switch vs process switch: same register work, but no page-table reload and no TLB invalidation, and warmer caches.
  • A joinable std::thread destroyed or move-assigned over calls std::terminate. std::jthread requests stop and joins. Stop is cooperative.
  • Thread arguments are decay-copied and passed as rvalues: use std::ref for references, convert char* buffers to std::string before the call, capture loop variables by value.
  • thread_local: one instance per thread, destroyed at thread exit before join() returns; beware reuse in pools.
  • fork() in a threaded process copies one thread and every lock state: exec straight away. Signals: block in main, sigwait in one thread. Coroutines give concurrency, not parallelism.

Part C · Locks and mutexes · Chapter 5

Races, locks and mutex internals

Two threads that touch the same memory without coordination give you lost updates at best and undefined behaviour at worst. This chapter builds mutual exclusion from the ground up: why counter++ breaks, the classic software solutions and why they fail on real CPUs, the atomic instructions hardware gives you, and how a real mutex uses a futex to stay in user space when nobody is waiting. Interviewers at systems and low-latency firms ask about this because it separates people who use locks from people who understand them.

TheoryDrillsCoding· about 5 hours· prerequisites: Ch 4 (threads), basic C++

Race conditions and data races

Theory

Two terms get mixed up in interviews, and the difference matters. Keep them apart from the first minute.

Race condition: a logic bug about timing

A race condition is any bug where the result depends on the relative timing of threads. The classic shape is check-then-act: a thread checks a condition, and by the time it acts on the answer, another thread has changed the world. Two ATM withdrawals both see a balance of 100, both decide 80 is affordable, and the account goes to minus 60. A race condition is a property of your program's logic. It can exist even when every single memory access is perfectly atomic.

Data race: undefined behaviour in the C++ memory model

A data race is a precise term from the C++ standard ([intro.races]). Two expressions conflict if they access the same memory location and at least one of them writes. A program has a data race if two conflicting actions happen in different threads, at least one of them is not atomic, and neither happens before the other. A data race is undefined behaviour. The whole program has no meaning, not just the racing variable.

Why so harsh? Because the compiler optimises each thread as if it ran alone. If a variable is not atomic and nothing synchronises, the compiler may keep it in a register, merge two stores into one, hoist a load out of a loop, or invent a temporary store. The hardware also reorders. Making races UB is what allows all of those optimisations in ordinary single-threaded code.

bool stop = false;            // plain bool, shared between threads
void worker() {
    while (!stop) { /* work */ }  // compiler may load stop once: infinite loop
}
// Fix: std::atomic<bool> stop{false};
Race conditionData race
Defined byYour program's intentThe C++ memory model, [intro.races]
ConsequenceWrong answer for some interleavingsUndefined behaviour for the whole program
Possible with only atomics?Yes (check-then-act on an atomic)No, by definition
Found by ThreadSanitizer?No, it is a logic errorYes, when the racy interleaving runs under test
FixMake the check and the act one atomic step (lock or CAS)Atomics or a lock that orders the accesses

Finding data races with ThreadSanitizer

ThreadSanitizer (-fsanitize=thread, in both GCC and Clang) instruments every memory access and tracks happens-before through mutexes, atomics, thread creation and join. It reports two accesses that conflict and are not ordered, even if they did not overlap in time on this run. It cannot find race conditions that involve only atomics, and it only sees code paths your test actually executes. Every correct multithreaded program in this guide is run clean under TSan, and every race drill is proven by a TSan report.

Interview trap

"The race is benign, it only reads a flag" is not an argument in C++. A racy read of a plain bool is UB even if the hardware load is a single instruction. Use std::atomic<bool> (with memory_order_relaxed if you truly need no ordering). It costs nothing extra on x86 or ARM for a plain load or store.

Why counter++ is not atomic

Theory

The mental model: ++counter is three steps, a read, a modify and a write. Any thread can be interrupted or run in parallel between those steps, so two increments can read the same old value and both write back old + 1.

What the CPU executes

Without optimisation, GCC on x86-64 emits a load into a register, an add, and a store. With optimisation it may emit a single add DWORD PTR counter[rip], 1, which looks atomic but is not: inside the core it is still a load micro-op, an add and a store, and another core can grab the cache line in between. Only the lock prefix (lock add, lock xadd) makes the read-modify-write indivisible across cores. On ARM the plain version is ldr, add, str; the atomic one is an ldxr/stxr retry loop or an ARMv8.1 ldadd.

; ++counter on x86-64, no lock prefix     ; counter.fetch_add(1)
mov  eax, DWORD PTR counter[rip]          lock xadd DWORD PTR counter[rip], eax
add  eax, 1
mov  DWORD PTR counter[rip], eax
Thread A (register rA) Thread B (register rB) counter in memory time load rA ← counter (0) load rB ← counter (0) add rA, 1 → rA = 1 add rB, 1 → rB = 1 store counter ← rA store counter ← rB 0 0 0 0 1 1 (not 2) Both threads read 0 before either wrote back, so two increments leave the counter at 1.
A lost update needs only one bad interleaving. Each increment is a load, an add and a store. If thread B loads before thread A stores, B's store overwrites A's work. In C++ this is also a data race, so the program is undefined, not just wrong.

Three correct fixes

  • An atomic read-modify-write: std::atomic<int> and fetch_add (or ++ on the atomic, which is the same operation). Best for one independent counter.
  • A mutex around the increment. Needed when the counter must change together with other data (an invariant spanning several variables).
  • No sharing: give each thread its own counter and sum at the end. Fastest, and the only option that scales with core count (see the sharded counter drill and Ch 19 on false sharing).
#include <atomic>
#include <iostream>
#include <thread>

static std::atomic<int> counter{0};

static void work() {
    for (int i = 0; i < 100000; ++i) counter.fetch_add(1, std::memory_order_relaxed);
}

int main() {
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << counter.load() << '\n';
}
200000

memory_order_relaxed is enough here: the increments are still indivisible, and join() gives the main thread a happens-before edge, so the final load sees every increment. Relaxed only drops ordering with respect to other variables.

Interview trap

hits = hits + 1 on a std::atomic<int> is an atomic load followed by an atomic store. Each half is atomic, the pair is not, so updates are lost exactly as in the figure. There is no data race, so TSan stays silent. Only ++hits, hits += 1 or fetch_add are single RMW operations.

The critical section problem

Theory

A critical section is the code that touches shared state. The classic OS formulation (Dijkstra 1965, the version in every textbook) asks for an entry protocol and an exit protocol around it, making no assumptions about the relative speed of threads or the number of CPUs.

The three requirements

RequirementMeaningBroken by
Mutual exclusionAt most one thread is inside the critical section at any time."Wait while locked, then set locked" with two separate steps
ProgressIf nobody is inside and some threads want in, one of them gets in in finite time. Threads that are not trying cannot block the decision.Strict alternation; "set my flag, wait for yours" (both wait forever)
Bounded waitingAfter a thread asks to enter, there is a limit on how many times others can enter before it does. No starvation.A plain test-and-set spinlock (an unlucky thread can lose every race)

Deadlock freedom is part of progress; starvation freedom is bounded waiting. Real locks add practical goals the textbook omits: low uncontended cost, no wasted CPU while waiting, and fairness that does not destroy throughput.

Failed software attempts (two threads)

// Attempt 1: strict alternation. Mutual exclusion holds, progress does not:
// if thread 0 never wants in again, thread 1 waits forever after one turn.
int turn = 0;
void lock(int me)   { while (turn != me) {} }
void unlock(int me) { turn = 1 - me; }

// Attempt 2: intent flags. Mutual exclusion holds, but if both set
// their flag at the same moment, both spin forever (deadlock).
bool want[2];
void lock(int me)   { want[me] = true; while (want[1 - me]) {} }
void unlock(int me) { want[me] = false; }

(These are pseudo-code in the textbook sense: written with plain variables they also contain data races. Peterson combines both ideas, as the next section shows.)

Disabling interrupts

On a single CPU, a thread loses the processor only through an interrupt (timer, device) or by blocking. So the kernel can make a sequence atomic by disabling interrupts (cli/sti on x86) around it. This fails as a general solution:

  • It is privileged. User code cannot execute cli; a user process that could would own the machine.
  • It does nothing on a multiprocessor. Disabling interrupts on CPU 0 does not stop CPU 1 from running the same code at the same time.
  • It hurts latency. While interrupts are off, timer ticks and device events wait. A long or buggy critical section freezes the system.

Linux still uses it, combined with spinlocks: spin_lock_irqsave() takes a spinlock (excludes other CPUs) and disables local interrupts (so an interrupt handler on this CPU that takes the same lock cannot deadlock against the code it interrupted).

Peterson's algorithm and why it breaks on modern CPUs

Theory

Peterson's algorithm (1981) solves the two-thread critical section problem with only loads and stores. It is a favourite interview question because it is short, provably correct on paper, and wrong on every modern CPU unless you add fences.

The algorithm

// Textbook form (shared: bool flag[2]; int turn;)
void lock(int me) {
    int other = 1 - me;
    flag[me] = true;    // I want to enter
    turn = other;       // but you go first if you also want to
    while (flag[other] && turn == other) { }  // wait
}
void unlock(int me) { flag[me] = false; }

Why it works (sequentially consistent memory). If both threads are in the loop, turn holds one value, so exactly one of them sees turn == other false and enters: mutual exclusion and progress. When the thread inside leaves and tries again, it sets turn to the other thread, so the waiting thread enters next: bounded waiting with a bound of one.

Where it breaks

The proof assumes each thread's write to flag[me] is visible to the other thread before its own read of flag[other]. Two layers violate that:

  1. The compiler. With plain variables, the loop condition reads data nobody in this thread writes, so the compiler may load it once and spin forever, or reorder the stores. And it is a data race, so the program is UB anyway. volatile stops the compiler from caching the loads, but does nothing about the CPU and does not remove the data race.
  2. The CPU's store buffer. Even x86, the most strongly ordered mainstream architecture (x86-TSO), lets a later load complete before an earlier store to a different address becomes globally visible. The store sits in the core's store buffer while the load reads memory. Both threads write their flag into their own buffer, both read the other flag from memory as false, and both enter.
Core 0 runs lock(0) 1. flag[0] = true 2. turn = 1 3. load flag[1] → false store buffer: flag[0]=1, turn=1 Core 1 runs lock(1) 1. flag[1] = true 2. turn = 0 3. load flag[0] → false store buffer: flag[1]=1, turn=0 memory: flag[0]=0 flag[1]=0 (buffers not drained yet) both loads read memory, not the other core's buffer drains later drains later
Store to load reordering breaks Peterson even on x86. Each core's store to its own flag waits in its store buffer while its next load reads the other flag from memory. Both read false, both skip the wait loop, and both enter the critical section. A full fence (mfence, or a locked instruction) between the stores and the load forces the buffer to drain first.

The fix in C++

Make the shared variables std::atomic and use the default memory_order_seq_cst. Sequential consistency guarantees a single total order over all seq_cst operations that agrees with each thread's program order, which is exactly the assumption in the proof. On x86 the compiler emits xchg (or mov + mfence) for the seq_cst stores; on ARMv8 it emits stlr and ldar, which are defined not to reorder with each other.

#include <atomic>
#include <iostream>
#include <thread>

class PetersonLock {
    std::atomic<bool> flag_[2] = {false, false};
    std::atomic<int> turn_{0};
public:
    void lock(int me) {
        int other = 1 - me;
        flag_[me].store(true);   // seq_cst by default
        turn_.store(other);
        while (flag_[other].load() && turn_.load() == other) { }
    }
    void unlock(int me) { flag_[me].store(false); }
};

static PetersonLock lk;
static int counter = 0;  // guarded by lk

static void work(int me) {
    for (int i = 0; i < 50000; ++i) {
        lk.lock(me);
        ++counter;
        lk.unlock(me);
    }
}

int main() {
    std::thread a(work, 0), b(work, 1);
    a.join();
    b.join();
    std::cout << counter << '\n';
}
100000

Know this cold

Peterson needs store→load ordering, the one reordering x86 permits. Acquire loads and release stores are not enough, because release/acquire never orders an earlier store before a later load. That is the textbook example of why seq_cst exists. In practice nobody uses Peterson: it needs one slot per thread, generalises poorly (Lamport's bakery handles N threads), and a hardware RMW does the job in one instruction.

Hardware primitives: TAS, CAS and fetch-add

Theory

Mutual exclusion with only loads and stores is possible (Peterson, bakery) but slow and fragile. Every real lock instead uses an atomic read-modify-write (RMW) instruction: the CPU reads a location, computes, and writes back with a guarantee that no other core can write the location in between. Under the hood the core takes the cache line in the exclusive (Modified) state of the coherence protocol (MESI and its variants) and holds it for the duration of the operation.

Key facts: the primitives

PrimitiveSemantics (all one indivisible step)x86-64ARMv8C++Consensus number
Test-and-set (TAS)set to 1, return the old valuexchg, lock btsswpal (v8.1 LSE) or an LL/SC loopatomic_flag::test_and_set2
Exchange (swap)old = *p; *p = v; return oldxchg (implicitly locked)swpal or LL/SCexchange2
Fetch-and-addold = *p; *p = old + v; return oldlock xaddldaddal or LL/SCfetch_add, ++, +=2
Compare-and-swap (CAS)if *p == expected then *p = desired; report success and the value seenlock cmpxchg (16-byte: cmpxchg16b)casal or LL/SCcompare_exchange_strong/weak∞
Load-linked / store-conditionalload and watch the line; the store fails if anyone wrote it sincenoneldaxr / stlxrnot exposed; it is why _weak exists∞

The consensus number (Herlihy, 1991) is the largest number of threads for which the primitive can solve wait-free consensus. Plain loads and stores have consensus number 1, TAS, swap and fetch-add have 2, and CAS and LL/SC are universal: any concurrent object can be built lock-free from them. That is why CAS is the workhorse of lock-free programming.

The CAS loop

CAS lets you build any atomic update out of "read, compute, publish if nobody interfered". The pattern:

// Atomically value = max(value, x)
void fetch_max(std::atomic<int>& value, int x) {
    int cur = value.load(std::memory_order_relaxed);
    while (cur < x && !value.compare_exchange_weak(cur, x, std::memory_order_relaxed)) {
        // on failure, cur now holds the value another thread wrote: recompute and retry
    }
}

Three facts make this loop correct. On failure compare_exchange writes the current value into expected (here cur), so you never reload by hand. The _weak form may fail spuriously even when the values are equal, because on LL/SC machines the reservation can be lost to an interrupt or a write to a nearby address in the same line; inside a loop that costs nothing and avoids a nested retry loop. The _strong form never fails spuriously; use it when a failure would make you do expensive work or when there is no loop.

The ABA problem

CAS compares values, not histories. Thread 1 reads head pointer A, gets preempted; thread 2 pops A, pops B, frees both, and pushes a new node that the allocator happens to place at address A. Thread 1 wakes, its CAS sees A and succeeds, installing a stale next pointer. Fixes: a version counter packed next to the pointer (double-width CAS), hazard pointers, epoch-based reclamation, or a design that never reuses memory while readers may hold it (the push-and-drain stack in the coding drills avoids ABA by taking the whole list with one exchange).

Spinlocks

Theory

A spinlock is the simplest lock built on an RMW: try to flip a flag from free to taken, and if it was already taken, loop. Waiting threads stay on the CPU, which is ideal when the holder will release within a few hundred nanoseconds and terrible when it will not.

A spinlock with std::atomic_flag

std::atomic_flag is the only type the standard has always guaranteed to be lock-free (C++20 adds the aliases std::atomic_signed_lock_free and std::atomic_unsigned_lock_free, which are too). Since C++20 it is default-initialised to clear (before C++20 you needed ATOMIC_FLAG_INIT) and it gained test(), a plain read, plus wait/notify. Providing lock() and unlock() makes the class BasicLockable, so std::lock_guard works with it.

#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>

class Spinlock {
    std::atomic_flag f_;  // clear since C++20
public:
    void lock() {
        while (f_.test_and_set(std::memory_order_acquire)) {
            while (f_.test(std::memory_order_relaxed)) { }  // spin on a read
        }
    }
    void unlock() { f_.clear(std::memory_order_release); }
};

static Spinlock sl;
static int counter = 0;  // guarded by sl

int main() {
    auto work = [] {
        for (int i = 0; i < 100000; ++i) {
            std::lock_guard<Spinlock> g(sl);
            ++counter;
        }
    };
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << counter << '\n';
}
200000

Acquire on lock, release on unlock. The successful test_and_set is an acquire operation and clear is a release, so everything written inside the previous critical section is visible inside the next one. The inner loop is the test-and-test-and-set (TTAS) idea, explained next.

Why spinning on a read is faster

Every test_and_set is a write, even when it fails. With four cores spinning on TAS, the cache line holding the flag bounces between their caches in the Modified state on every attempt, flooding the interconnect and slowing down the holder, which needs that same line to release. Spinning on a plain load keeps a Shared copy in each waiter's cache, so waiting generates no traffic. When the holder releases, the line is invalidated, all waiters see "free" and race with one RMW each. Two refinements:

  • A pause hint in the spin loop (_mm_pause / pause on x86, yield or isb on ARM) saves power, gives the sibling hyperthread the core, and avoids a memory-order pipeline flush when the loop exits.
  • Exponential backoff: wait longer after each failed attempt, so fewer threads stampede when the lock frees up. Eventually call std::this_thread::yield() or fall back to sleeping.

Fairness: ticket locks and queue locks

A TAS spinlock has no bounded waiting: whichever core sees the release first wins, and a core that is far away on the interconnect may lose for a long time. A ticket lock fixes that with two counters: fetch_add on next takes a ticket, and you wait until serving equals it. It is FIFO, but every waiter still spins on the same line. MCS and similar queue locks give each waiter its own node to spin on, so a handoff touches only one other cache. The Linux kernel used ticket spinlocks from 2.6.25 and moved to MCS-based queued spinlocks (qspinlock) in 4.2.

Spinlock vs mutex

SpinlockMutex (futex-based)
Uncontended lock + unlockone RMW + one release storeone RMW + one RMW (unlock must read the state to learn whether anyone sleeps), all in user space
While waitingburns a CPUsleeps in the kernel, CPU free for other work
Handoff latencya cache-line transfer, tens of nsa wake-up syscall plus scheduling, usually microseconds
Holder preempted or blockedwaiters spin for a whole time slicewaiters sleep, no waste
Single corepathological: the waiter spins until its slice ends, because the holder cannot runfine
Typical usekernel code with preemption off, interrupt handlers, latency-critical user code on pinned, isolated coresdefault for user space

Tip

Spin only if the critical section is shorter than the cost of sleeping and waking (a few microseconds) and the holder cannot be preempted. In ordinary user space you cannot promise the second part, which is why std::mutex is the default and why good mutexes are already fast when uncontended. Linus Torvalds' well-known advice is that user-space spinlocks are almost always a mistake unless you control the scheduling.

How a mutex is implemented

Theory

The mental model: a mutex is a spinlock that knows how to sleep. The uncontended case must cost one atomic instruction and no system call, and only threads that actually have to wait should enter the kernel. On Linux the bridge between the two worlds is the futex.

std::mutex in one minute

std::mutex has lock(), try_lock() and unlock(). It is neither copyable nor movable, and its default constructor is constexpr, so a namespace-scope std::mutex is constant-initialised and immune to the static initialisation order problem. libstdc++ and libc++ wrap pthread_mutex_t on POSIX systems. The rules that interviewers probe:

  • Locking a std::mutex the calling thread already owns is undefined behaviour (in practice a self-deadlock). Use std::recursive_mutex if you truly need that, see Ch 6.
  • Unlocking a mutex you do not own, or destroying a mutex that is still locked, is undefined behaviour.
  • try_lock() may fail spuriously even if no thread holds the mutex ([thread.mutex.requirements.mutex]). Never use it to test whether a mutex is free.
  • Call lock() and unlock() directly only in lock implementations. Everywhere else use an RAII wrapper (std::lock_guard, std::scoped_lock, std::unique_lock), covered in Ch 6, so an exception cannot leave the mutex locked.
#include <iostream>
#include <mutex>
#include <thread>

static std::mutex m;
static long counter = 0;  // guarded by m

static void work() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(m);
        ++counter;
    }
}

int main() {
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << counter << '\n';
}
200000

What a futex is

A futex ("fast user-space mutex", Linux 2.6) is not a lock. It is a 32-bit integer in ordinary user memory plus two system calls that let the kernel park and wake threads keyed by that integer's address:

  • futex(&word, FUTEX_WAIT, val): atomically check that word == val and, if so, put the caller to sleep on a kernel wait queue for that address. If the value differs, return EAGAIN at once.
  • futex(&word, FUTEX_WAKE, n): wake up to n threads sleeping on that address.

The atomic check inside FUTEX_WAIT is the whole trick. Between "I saw the lock taken" in user space and "I am asleep" in the kernel, the holder might unlock and call wake. If the kernel simply slept, that wake-up would be lost and the waiter would sleep forever. Because the kernel re-checks the value under its wait-queue lock, a waiter whose value is stale returns immediately and retries. The kernel keeps no state for a futex nobody waits on, so an uncontended mutex costs the kernel nothing.

The three-state mutex

The standard design, from Ulrich Drepper's paper "Futexes Are Tricky", uses three values: 0 unlocked, 1 locked with no waiters, 2 locked and possibly with waiters. The third state lets unlock() skip the system call when nobody is sleeping.

USER SPACE KERNEL lock(): CAS 0 → 1 one lock cmpxchg owned: fast path no system call old = exchange(2) mark "has waiters" CAS failed old = 0: owned futex(WAIT, &state, 2) sleep only if state is still 2 old ≠ 0 woken: retry unlock(): old = exchange(0) release the lock first old = 1 done, no syscall futex(WAKE, &state, 1) wake one sleeper old = 2 wakes Green: fast path, user space only. Red and blue: slow path, one system call each side.
Only contention reaches the kernel. An uncontended lock is one CAS and an uncontended unlock is one exchange. A thread that loses the CAS marks the word as 2 and sleeps in FUTEX_WAIT; the kernel re-checks the value, so a wake that raced ahead cannot be lost. Unlock pays for FUTEX_WAKE only when it sees 2.

A woken waiter sets the state to 2 (not 1) when it takes the lock, because it cannot know whether other waiters are still asleep. That costs at most one unnecessary wake call later, which is the price of never losing one. In Linux code, the slow path looks like this (a fragment for Linux only; the portable version is a coding drill below, using C++20 std::atomic::wait, which libstdc++ implements with futexes on Linux):

#include <atomic>
#include <linux/futex.h>
#include <sys/syscall.h>
#include <unistd.h>

// Relies on std::atomic<int> having the size and layout of int, true on Linux.
static void futex_wait(std::atomic<int>* w, int val) {
    syscall(SYS_futex, reinterpret_cast<int*>(w), FUTEX_WAIT_PRIVATE, val, nullptr, nullptr, 0);
}
static void futex_wake_one(std::atomic<int>* w) {
    syscall(SYS_futex, reinterpret_cast<int*>(w), FUTEX_WAKE_PRIVATE, 1, nullptr, nullptr, 0);
}

void lock(std::atomic<int>& state) {
    int c = 0;
    if (state.compare_exchange_strong(c, 1, std::memory_order_acquire)) return;
    if (c != 2) c = state.exchange(2, std::memory_order_acquire);
    while (c != 0) {
        futex_wait(&state, 2);
        c = state.exchange(2, std::memory_order_acquire);
    }
}
void unlock(std::atomic<int>& state) {
    if (state.exchange(0, std::memory_order_release) == 2) futex_wake_one(&state);
}

What production mutexes add

  • Adaptive spinning. Spin briefly before sleeping, betting that the holder is running on another core and will release soon. glibc offers it as PTHREAD_MUTEX_ADAPTIVE_NP; its default mutex type goes straight to the futex.
  • Lock types. Error-checking and recursive pthread mutexes store the owner thread id in the mutex.
  • Priority inheritance (PTHREAD_PRIO_INHERIT, FUTEX_LOCK_PI): the kernel boosts the owner's priority while a higher-priority thread waits. See priority inversion in Ch 6.
  • Other systems: Windows has WaitOnAddress and SRW locks, macOS has os_unfair_lock and the private ulock wait calls. The idea is the same everywhere: an atomic word in user space, a kernel queue only for waiters.

What a lock guarantees about memory

Theory

A lock does two jobs. Mutual exclusion is the obvious one. The second is visibility: data written by one critical section must be seen by the next, even though compilers and CPUs reorder memory operations freely.

Acquire and release, intuitively

  • Release (unlocking): every read and write before it in program order is completed before the release becomes visible. Think "publish everything I did".
  • Acquire (locking): every read and write after it in program order happens after it. Think "subscribe to what was published".
  • An unlock of mutex m synchronises with the next lock of m, so it creates a happens-before edge: the writer's critical section happens before the reader's. That edge is exactly what turns two conflicting accesses into a non-race.

The roach motel rule follows: operations may move into a critical section (a load after unlock may be performed before it), but never out of it. Compilers and CPUs exploit that freedom, which is why locks are cheap.

// Publishing with an atomic flag needs the same pairing
int payload;                          // plain data
std::atomic<bool> ready{false};
// producer                           // consumer
payload = 42;                         // while (!ready.load(std::memory_order_acquire)) {}
ready.store(true, std::memory_order_release);   // use(payload);  sees 42

With memory_order_relaxed on both sides there is no happens-before edge and reading payload is a data race (see the reading drills). Locks, seq_cst atomics and acquire/release pairs all create the edge; relaxed atomics do not.

Atomics or a lock?

Use an atomic whenUse a lock when
The shared state is one word: a counter, a flag, a pointerAn invariant spans several variables (size and buffer, balance and history)
The update is a single RMW or a short CAS loopThe update needs a check and an act that must not be split
You need progress even if a thread is descheduled (lock-free)Simplicity and reviewability matter more than the last 20 ns
Readers are frequent and must never blockWaiters should sleep rather than spin

"Lock-free" is not "fast". A contended atomic counter still bounces one cache line between cores and may be slower than per-thread counters merged at the end. std::atomic<T> for large T is not lock-free at all: the library hides a lock (check is_always_lock_free, and note that GCC may need -latomic).

Key facts: rough costs on a modern x86 server

OperationOrder of magnitude
Uncontended atomic RMW on a line in your own cacheabout 5 to 20 ns (tens of cycles)
Uncontended std::mutex lock + unlockabout 15 to 30 ns (two atomics, no syscall)
Moving a contended cache line to another coreabout 50 to 150 ns, more across sockets
Futex wait and wake round trip, thread context switchabout 1 to 10 µs

These are order-of-magnitude figures; measure on your hardware. The shape is what matters in interviews: uncontended locking is cheap, contention is expensive, and sleeping is the most expensive of all.

False sharing in one paragraph

Coherence works on whole cache lines (64 bytes on x86 and most ARM servers, 128 bytes on Apple M-series). Two threads updating different variables that share a line still bounce the line, so per-thread counters packed into one array can be as slow as one shared atomic. Pad or align hot per-thread data to the line size (alignas(64) or std::hardware_destructive_interference_size). The full treatment, with measurements, is in Ch 19.

Concept and tricky questions

Drills

ConceptWhat exactly is a data race in C++, and what is the consequence?

Answer

Answer. Two conflicting accesses to the same memory location (at least one a write), in different threads, at least one not atomic, with neither happening before the other. The consequence is undefined behaviour for the entire program.

Why. The rule is [intro.races]. Making races UB lets the compiler optimise every non-atomic access as if the thread were alone: keep values in registers, merge or split stores, hoist loads out of loops. The common wrong answer is "you might read a stale or torn value". That describes one possible outcome, but the standard promises nothing at all, and real compilers produce infinite loops and impossible values from racy code. The fix is to order the accesses with a lock, an atomic, or thread start and join.

ConceptGive an example of a race condition that has no data race, and explain why ThreadSanitizer will not report it.

Answer

Answer. Check-then-act on a "thread-safe" container whose every member function locks an internal mutex: if (!q.empty()) { int v = q.front(); q.pop(); }. Two consumers can both see one item, both read the same front, and the second pop() runs on an empty queue.

Why. Every access to the queue's data happens under its mutex, so there is no data race and TSan, which looks for unordered conflicting accesses where at least one is non-atomic, has nothing to report. The bug is in the interface: the check and the act are separate critical sections, and another thread can run between them. Fix it by making the combined operation one call that holds the lock throughout (std::optional<int> try_pop()), so a concurrent queue must not copy the split front()/pop() interface that std::queue uses for exception safety. The same pattern on a single atomic (load the balance, then fetch_sub) appears as a find-the-bug drill below. Interviewers use this to test whether you think "every call is locked" means "correct".

TrickyA colleague says a plain bool stop flag is fine on x86 because aligned byte loads and stores are atomic in hardware. Is it?

Answer

Answer. No. It is a data race, so it is UB, and in practice the compiler may read the flag once and turn while (!stop) into an infinite loop.

Why. Hardware atomicity is irrelevant: the C++ abstract machine decides what the compiler may do, and a non-atomic variable that this thread never writes inside the loop is assumed not to change. GCC and Clang at -O2 really do hoist such loads. std::atomic<bool> costs the same plain mov on x86 for a relaxed or acquire load, so there is no performance argument for the plain bool.

TrickyDoes declaring a shared counter volatile int make counter++ safe across threads?

Answer

Answer. No. volatile gives neither atomicity nor inter-thread ordering, and the accesses still form a data race.

Why. volatile only tells the compiler that each access is an observable side effect it must perform (for memory-mapped I/O and, with sig_atomic_t, signal handlers). counter++ is still a separate load, add and store, the CPU may still reorder around it, and the standard does not exempt volatile from [intro.races]. C++20 even deprecates ++ and compound assignment on volatile variables because people kept misreading them as atomic. Java's volatile is a different thing (closer to a seq_cst atomic), which is where much of the confusion comes from.

TrickyWith optimisation, GCC compiles ++counter to the single instruction add DWORD PTR counter[rip], 1. Is that atomic on a multicore x86? On a single-core machine?

Answer

Answer. On multicore: no, it needs the lock prefix. On a single core the single instruction happens to be atomic with respect to interrupts, but the C++ program is still a data race and the compiler is free to emit three instructions instead.

Why. A memory-destination add is decoded into load, add and store micro-ops. Without lock, another core can take the cache line between the load and the store. Interrupts are only taken at instruction boundaries, so on one core no other thread can run in the middle, which is why old uniprocessor kernels got away with it. Relying on the instruction choice is relying on codegen, which is not a contract. fetch_add guarantees lock xadd or lock add.

ConceptState the three requirements for a solution to the critical section problem, and say which one strict alternation (while (turn != me) {}, then turn = other on exit) violates.

Answer

Answer. Mutual exclusion, progress and bounded waiting. Strict alternation violates progress.

Why. Mutual exclusion holds because only the thread whose turn it is can enter. But if thread 0 finishes and never wants the lock again, thread 1 can enter once and then waits forever for a turn that thread 0 will never hand over, even though the critical section is empty. Progress says a thread outside the critical section and not trying to enter must not block the others. People often answer "bounded waiting", but strict alternation actually has perfect bounded waiting (a bound of one); the failure is progress.

TrickyWhy can't a user-space program implement a lock by disabling interrupts, and why would it not work on a multiprocessor even in the kernel?

Answer

Answer. Disabling interrupts is a privileged instruction (cli faults in ring 3), and it only affects the current CPU, so other CPUs keep running and can enter the same critical section.

Why. On a uniprocessor, preemption arrives only through interrupts, so masking them makes a code sequence atomic. On SMP, two CPUs execute in parallel and interrupts are irrelevant to that. Kernels therefore combine both: a spinlock excludes other CPUs and local_irq_save stops an interrupt handler on this CPU from trying to take the same spinlock and deadlocking against the code it interrupted (spin_lock_irqsave). Long sections with interrupts off also hurt latency because timer and device interrupts are delayed.

Trickyx86 is "strongly ordered". Does Peterson's algorithm with plain variables (or with release stores and acquire loads) work on x86?

Answer

Answer. No. x86-TSO allows a later load to complete before an earlier store to a different address is visible, and Peterson needs exactly that store-then-load order. Release/acquire does not forbid that reordering either. You need seq_cst operations or a full fence (mfence) between the stores and the loop's loads.

Why. Each core puts flag[me] = true in its store buffer, then reads flag[other] from cache, where the other core's store has not arrived yet. Both read false and both enter (see the store buffer figure). x86 forbids load-load, store-store and load-store reordering, which is why people believe it is "sequentially consistent", but store-load is the one it permits. Release only orders earlier operations before the release store; acquire only orders later operations after the acquire load; neither orders a store before a later load.

ConceptCompare test-and-set, fetch-and-add and compare-and-swap. Which would you pick to build a fair (FIFO) spinlock, and which to build a lock-free stack?

Answer

Answer. TAS sets a flag and returns the old value; fetch-and-add adds and returns the old value; CAS writes only if the current value equals the expected one. A FIFO ticket lock uses fetch-and-add to hand out tickets; a lock-free stack needs CAS to swing the head pointer.

Why. Fetch-and-add always succeeds, so each arriving thread gets a unique ticket in arrival order, which gives fairness for free. A stack push must say "set head to my node only if head is still what I linked to", which is a conditional update: only CAS (or LL/SC) expresses it. Herlihy's consensus hierarchy makes it formal: TAS, swap and fetch-add have consensus number 2, CAS is universal. TAS is the cheapest option for a simple unfair spinlock.

TrickyWhy does compare_exchange_weak exist, if it can fail even when the value matches? When should you use _strong?

Answer

Answer. On LL/SC architectures (classic ARM, POWER, RISC-V) one LL/SC pair can fail spuriously, for example after an interrupt or a write elsewhere in the same cache line. _weak maps to a single attempt; _strong must hide spurious failures with an internal retry loop. Use _weak inside your own retry loop and _strong for one-shot attempts.

Why. If you are already looping, a nested loop inside _strong is wasted code. If there is no loop (for example "try to claim this slot once"), a spurious failure would be a logic error, so you need _strong. On x86 both compile to lock cmpxchg, which never fails spuriously, so the difference only shows up on other targets. In both forms, a failure stores the observed value into expected.

ConceptWhen is a spinlock better than a mutex, and why is a spinlock a bad idea on a single-core machine?

Answer

Answer. Spin when the critical section is shorter than a sleep and wake-up (a few microseconds) and the holder is guaranteed to be running, for example in the kernel with preemption disabled or on pinned, isolated cores in a low-latency system. On one core the holder cannot run while the waiter spins, so the waiter wastes its whole time slice every time.

Why. A mutex costs about the same as a spinlock when uncontended (one atomic operation each way, all in user space). Under contention the mutex puts waiters to sleep, which costs a system call and a context switch but frees the CPU. A spinlock hands over in tens of nanoseconds when the holder runs on another core, but if the holder is preempted the waiters burn CPU until the scheduler brings it back. Uniprocessor kernels compile spinlocks down to "disable preemption" for this reason.

TrickyDoes std::mutex::lock() make a system call every time?

Answer

Answer. No. On Linux an uncontended lock is a single CAS in user space and an uncontended unlock is a single atomic exchange or decrement. Only a thread that must wait calls futex(FUTEX_WAIT), and unlock calls futex(FUTEX_WAKE) only if the state word says there may be waiters.

Why. That is the whole point of the futex design (Linux 2.6): the kernel keeps no state for an uncontended lock. The wrong answer "a mutex is slow because it enters the kernel" is twenty years out of date. A mutex becomes slow when it is contended, and then the cost is the cache-line transfers plus sleeping and waking, not the uncontended path.

TrickyIs std::atomic<T> always lock-free? What about std::atomic<Big> where Big is a 24-byte struct?

Answer

Answer. No. Only std::atomic_flag (and, since C++20, the std::atomic_signed_lock_free and std::atomic_unsigned_lock_free aliases) is guaranteed lock-free. std::atomic<Big> compiles for any trivially copyable type, but for sizes the hardware cannot handle in one instruction the library protects it with an internal lock (a hashed table of locks), so is_lock_free() is false.

Why. std::atomic<T>::is_always_lock_free (C++17) tells you at compile time. On x86-64, sizes 1, 2, 4 and 8 are lock-free; 16 bytes depends on cmpxchg16b and the library. With GCC, non-lock-free atomics call into libatomic, so you may need -latomic to link. A lock-based atomic is not safe in a signal handler and can be much slower, which surprises people who chose atomics "to avoid locks".

TrickyMay m.try_lock() return false when no thread holds m? Thread A sets x = 1 and locks m; thread B sees m.try_lock() fail and concludes that it may now read x. Is that safe?

Answer

Answer. Yes, try_lock() may fail spuriously ([thread.mutex.requirements.mutex]), and no, B's reasoning is broken: a failed try_lock tells you nothing and creates no happens-before edge, so reading x is a data race.

Why. If a failed try_lock were a reliable sign that someone holds the lock, programs could use it as a signal, and then lock() would have to order the earlier write of x before itself, which costs a full fence on every acquisition. Hans Boehm's analysis of this for the C++11 memory model is why the standard allows spurious failure: it keeps lock() a plain acquire. Only a successful lock synchronises with the previous unlock. The practical rule is to use try_lock only to attempt, never to ask "is it free?" or "is it taken?". The common wrong answer is "spurious failure only exists for LL/SC hardware"; a mutex's try_lock can be built with a strong CAS and the standard still permits it to fail. (Relocking a std::mutex you already own is covered in Ch 6.)

TrickyWhat do "lock-free" and "wait-free" mean? Is a CAS retry loop wait-free, and is a lock-free counter always faster than a mutex-protected one?

Answer

Answer. Lock-free: some thread always completes its operation in a finite number of steps, even if other threads are suspended at any point. Wait-free: every thread completes in a bounded number of its own steps. A CAS loop is lock-free but not wait-free, because one unlucky thread can keep losing the CAS while others succeed. And no, lock-free is not automatically faster.

Why. A thread that fails a CAS failed because another thread's CAS succeeded, so the system as a whole progresses, but nothing bounds how often a given thread loses (starvation is possible). A hardware fetch_add (lock xadd, ldadd) completes in one instruction for every caller, so a counter built on it is wait-free. Below lock-free sits obstruction-free (a thread finishes if it runs alone), and anything using a mutex is blocking: a holder descheduled inside the critical section stops everyone. The speed question is separate: under contention a lock-free counter still bounces one cache line between cores, and a mutex with short critical sections can match it; per-thread counters beat both. People say "lock-free means no locks so it is faster"; the real benefit is progress when threads are preempted, killed or running in a signal handler, not raw speed.

ConceptBesides mutual exclusion, what guarantee does a mutex give about memory? Can the compiler move a load from after unlock() to before it?

Answer

Answer. Visibility and ordering: unlock() is a release and lock() is an acquire, so an unlock synchronises with the next lock of the same mutex and everything done in the earlier critical section happens before the later one. Yes, the compiler or CPU may move a later load into the critical section, but nothing may move out of it.

Why. This is the "roach motel" rule: accesses can check in but not check out. Moving work into a critical section never breaks correctness, only increases the hold time slightly, so the memory model allows it; moving work out would expose it to other threads unprotected. Without the release/acquire pairing, a reader could take the lock and still see stale data, which is the bug in the "relaxed publish" reading drill.

TrickyWhat is the ABA problem, and why does a CAS-based lock-free stack pop suffer from it while push does not?

Answer

Answer. CAS succeeds if the value equals what you read earlier, even if it changed to something else and back in between. In pop you read head = A and next = A->next; if A is popped, freed and reallocated as a new node pushed back on top, your CAS from A to the stale next succeeds and corrupts the stack.

Why. pop dereferences a node that another thread may free, and its CAS decision depends on that dereferenced value. push only links a node it owns to the current head and swings head; if head changed, the CAS fails and the loop retries with the fresh head, so A to B to A is harmless. Fixes for pop: tagged pointers with a version counter (double-width CAS), hazard pointers, epoch-based reclamation, or garbage collection. A garbage collector removes the "freed and reused" step entirely, which is why ABA is mainly a C and C++ problem.

ConceptWhy does a test-and-test-and-set spinlock scale better than a plain test-and-set spinlock?

Answer

Answer. Plain TAS writes the lock's cache line on every attempt, so the line ping-pongs between waiting cores in the Modified state. TTAS spins on an ordinary load, which each core can serve from its own Shared copy, and only attempts the RMW after it sees the lock free.

Why. Under MESI, a write needs exclusive ownership, which invalidates every other copy. With N waiters doing TAS, every attempt is an invalidation broadcast, and the holder's own release has to fight for the line. With TTAS, the line stays shared while the lock is held and there is one burst of traffic per release. Backoff and a pause instruction reduce that burst further; queue locks (MCS) remove it by giving each waiter its own line.

Predict the output and find the bug

Drills

OutputWhat does this print? Watch which operations return the old value and which return the new one.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> a{5};
    int r1 = a.fetch_add(3);
    int r2 = ++a;
    int r3 = a++;
    int r4 = a.exchange(100);
    std::cout << r1 << ' ' << r2 << ' ' << r3 << ' ' << r4 << ' ' << a.load() << '\n';
}
Answer
5 9 9 10 100

Answer. 5 9 9 10 100.

Why. fetch_add(3) returns the old value 5 and leaves 8. Pre-increment on an atomic returns the new value, 9. Post-increment returns the old value 9 and leaves 10. exchange(100) returns the previous value 10. All of these are single RMW operations, and they return a value rather than a reference: ++a on a std::atomic yields T, not atomic&, because a reference would invite a second, racy read. People who expect fetch_add to return the new value (like __sync_add_and_fetch) answer 8 first.

Find the bugTwo threads each add 100000. What can this print, and what is wrong?

#include <iostream>
#include <thread>

static int counter = 0;

static void work() {
    for (int i = 0; i < 100000; ++i) ++counter;
}

int main() {
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << counter << '\n';  // often less than 200000
}
Answer

Answer. A data race on counter, so the behaviour is undefined. In practice it prints anything from about 100000 to 200000, and it may print exactly 200000 every time on one compiler. ThreadSanitizer reports the race. Fix: std::atomic<int> with fetch_add, or a mutex.

Why. Each ++counter is load, add, store, and interleavings lose updates (see the counter figure). The surprise is the 200000: at -O1 Clang may turn the whole loop into counter += 100000, one load and one store, so the window for losing an update shrinks to almost nothing, and the first thread often finishes before the second starts. That is the danger of UB: the program "passes" on your machine and fails elsewhere. Output is deliberately not compared here.

OutputWhat does this print? bool prints as 0 or 1.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> x{10};
    int expected = 7;
    bool ok1 = x.compare_exchange_strong(expected, 20);
    std::cout << ok1 << ' ' << expected << ' ' << x << '\n';
    bool ok2 = x.compare_exchange_strong(expected, 20);
    std::cout << ok2 << ' ' << expected << ' ' << x << '\n';
}
Answer
0 10 10
1 10 20

Answer. First line 0 10 10, second line 1 10 20.

Why. The first CAS compares 10 with the expected 7 and fails. On failure it writes the current value into expected, so expected becomes 10 and x is unchanged. The second CAS now expects 10, which matches, so it stores 20 and returns true; on success expected is left alone. This write-back is what makes CAS loops tidy: the next iteration already has the fresh value. The usual wrong answer is 0 7 10, from treating expected as an input only.

Find the bugThis home-made lock uses an atomic, so it should be safe. Why does the counter come out wrong?

#include <atomic>
#include <iostream>
#include <thread>

static std::atomic<bool> locked{false};
static int counter = 0;

static void lock() {
    while (locked.load()) { }  // wait until free
    locked.store(true);        // then take it
}
static void unlock() { locked.store(false); }

static void work() {
    for (int i = 0; i < 100000; ++i) {
        lock();
        ++counter;
        unlock();
    }
}

int main() {
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << counter << '\n';
}
Answer

Answer. The check (load) and the claim (store(true)) are two separate atomic operations. Both threads can see false, both store true, and both enter. Mutual exclusion fails, so counter has a data race, which TSan reports. Fix: claim with one RMW, while (locked.exchange(true, std::memory_order_acquire)) {}, and unlock with a release store.

Why. Atomic operations are indivisible individually, not in combination. This is the same check-then-act race condition as the bank account, here breaking the lock itself. The other weakness, the default seq_cst on every access, only costs speed. A correct test-and-test-and-set lock keeps the read loop but follows it with exchange and retries if the exchange returns true.

OutputWhat does this print (C++20)?

#include <atomic>
#include <iostream>

int main() {
    std::atomic_flag f;  // starts clear (C++20)
    std::cout << f.test_and_set() << f.test_and_set() << f.test() << ' ';
    f.clear();
    std::cout << f.test() << f.test_and_set() << '\n';
}
Answer
011 00

Answer. 011 00.

Why. Since C++20 a default-constructed atomic_flag is clear (P0883; before C++20 its state was unspecified unless you used ATOMIC_FLAG_INIT). The first test_and_set returns the old value false, the second returns true, and test() (new in C++20) reads true without modifying it. After clear(), test() is false and test_and_set returns false again while setting the flag. The left-to-right evaluation of the << chain is guaranteed since C++17, so the order of the three calls is well defined.

Find the bughits is atomic and ThreadSanitizer runs clean, yet the result is usually below 200000. Why?

#include <atomic>
#include <iostream>
#include <thread>

static std::atomic<int> hits{0};

static void work() {
    for (int i = 0; i < 100000; ++i) hits = hits + 1;
}

int main() {
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << hits << '\n';  // usually less than 200000
}
Answer

Answer. hits = hits + 1 is an atomic load, then an addition on a plain int, then an atomic store. Another thread can update hits between the load and the store, and that update is overwritten. Use ++hits, hits += 1 or hits.fetch_add(1).

Why. std::atomic overloads operator=, operator++ and operator+= as single atomic operations, but hits + 1 goes through the implicit conversion operator T(), a separate load. There is no data race (every access is atomic), so TSan is right to stay quiet: this is a pure race condition. The output depends on timing and is not compared.

OutputTwo threads each double value ten times using a CAS loop. Is the output deterministic? What is it?

#include <atomic>
#include <iostream>
#include <thread>

static std::atomic<long> value{1};

static void times(long k) {
    long old = value.load();
    while (!value.compare_exchange_weak(old, old * k)) { }
}

int main() {
    auto work = [] { for (int i = 0; i < 10; ++i) times(2); };
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << value << '\n';
}
Answer
1048576

Answer. Yes: always 1048576, which is 2 to the power 20.

Why. Every multiplication is applied exactly once: if another thread changes value between the load and the CAS, the CAS fails, refreshes old and recomputes from the new value. Twenty doublings in any order give the same product because multiplication commutes. The interleaving still varies from run to run; only the final value is fixed. Using _weak is correct because a spurious failure just causes one more iteration.

Find the bugPeterson's algorithm, with volatile added so the compiler cannot cache the flags. Is it correct now?

#include <iostream>
#include <thread>

static volatile bool flag[2] = {false, false};
static volatile int turn = 0;
static int counter = 0;

static void lock(int me) {
    int other = 1 - me;
    flag[me] = true;
    turn = other;
    while (flag[other] && turn == other) { }
}

static void unlock(int me) { flag[me] = false; }

static void work(int me) {
    for (int i = 0; i < 50000; ++i) {
        lock(me);
        ++counter;
        unlock(me);
    }
}

int main() {
    std::thread a(work, 0), b(work, 1);
    a.join();
    b.join();
    std::cout << counter << '\n';
}
Answer

Answer. No. volatile stops the compiler from hoisting the loads, but every access is still a data race (TSan reports it), and the CPU's store buffer can let both threads read the other flag as false, so both enter and increments are lost. Fix: std::atomic<bool> and std::atomic<int> with seq_cst, as in the theory section and the coding drill.

Why. Peterson's proof assumes sequential consistency: the store to flag[me] is visible before the load of flag[other]. Both x86 (store buffer) and ARM (much weaker ordering) break that without a full fence. volatile emits no fence. On Apple silicon or a multicore x86, runs of this program typically end short of 100000.

Find the bugThe consumer waits for ready, then reads payload. It prints 42 on every run you try. Why is it still broken?

#include <atomic>
#include <iostream>
#include <thread>

static int payload = 0;
static std::atomic<bool> ready{false};

int main() {
    std::thread producer([] {
        payload = 42;
        ready.store(true, std::memory_order_relaxed);
    });
    std::thread consumer([] {
        while (!ready.load(std::memory_order_relaxed)) { }
        std::cout << payload << '\n';
    });
    producer.join();
    consumer.join();
}
Answer

Answer. Relaxed operations create no happens-before edge, so the write of payload and the read in the consumer are unordered conflicting accesses: a data race. TSan reports it. Fix: store(true, std::memory_order_release) and load(std::memory_order_acquire) (or the seq_cst default).

Why. Relaxed guarantees only atomicity of ready itself. The compiler may sink payload = 42 below the flag store, and ARM or POWER may make the flag visible first. x86 happens not to reorder these two stores, which is why it "works" in testing, and why such bugs appear when code moves to ARM servers or Apple silicon. Release/acquire is the minimum that makes the publish correct, and it is free on x86 (plain movs).

Find the bugThe writer locks the mutex. The reader only reads, so it skips the lock "for speed". What is wrong?

#include <iostream>
#include <mutex>
#include <thread>

static std::mutex m;
static long total = 0;

static void writer() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> g(m);
        ++total;
    }
}

static void reader() {
    long seen = 0;
    for (int i = 0; i < 100000; ++i) seen = total;  // "reads are harmless"
    std::cout << (seen >= 0) << '\n';
}

int main() {
    std::thread w(writer), r(reader);
    w.join();
    r.join();
}
Answer

Answer. A lock protects data only if every access, reads included, goes through it. The unlocked read of total conflicts with the locked write, nothing orders them, so it is a data race (UB), which TSan reports. Fix: lock in the reader too, or make total a std::atomic<long> if it is a standalone value.

Why. Happens-before through a mutex needs both sides to take it: the writer's unlock synchronises with a later lock, and the reader never locks. "Reads are harmless" is wrong twice: the read may be torn on a platform where long is not written in one instruction, and the compiler may hoist the load out of the loop entirely (here it would read total once). For many readers and few writers use std::shared_mutex (Ch 6), not no lock at all.

Find the bugTwo threads try to withdraw 80 from a balance of 100. Every access is atomic. What can the final balance be?

#include <atomic>
#include <iostream>
#include <thread>

static std::atomic<int> balance{100};

static void withdraw(int amount) {
    if (balance.load() >= amount) {   // check
        balance.fetch_sub(amount);    // act
    }
}

int main() {
    std::thread a(withdraw, 80), b(withdraw, 80);
    a.join();
    b.join();
    std::cout << balance << '\n';  // 20 or -60, depending on the interleaving
}
Answer

Answer. Either 20 (one withdrawal succeeds) or minus 60 (both pass the check before either subtracts). It is a race condition without a data race, so TSan runs clean. Fix with a CAS loop that checks and subtracts in one step:

static bool withdraw(int amount) {
    int cur = balance.load();
    while (cur >= amount) {
        if (balance.compare_exchange_weak(cur, cur - amount)) return true;
    }
    return false;  // insufficient funds, decided on the value we failed to change
}

Why. The check and the act are two atomic operations with a window between them. The CAS version makes the decision and the update a single step on the same observed value: if anyone changed the balance, the CAS fails and the condition is re-evaluated on the fresh value. Thread start-up latency makes either outcome common, which is why output is not compared here.

OutputWhat does this print on a 64-bit desktop or server platform (x86-64 or ARM64)?

#include <atomic>
#include <iostream>

struct Big { long a, b, c; };

int main() {
    std::cout << std::atomic<int>::is_always_lock_free
              << std::atomic<long long>::is_always_lock_free
              << std::atomic<Big>::is_always_lock_free << '\n';
    std::atomic<Big> big{Big{1, 2, 3}};
    Big copy = big.load();
    std::cout << copy.a + copy.b + copy.c << '\n';
}
Answer
110
6

Answer. 110, then 6.

Why. 4-byte and 8-byte atomics map onto native instructions, so they are always lock-free. A 24-byte struct has no native atomic instruction on these targets, so std::atomic<Big> is implemented with a lock hidden inside the library (GCC's libatomic or libc++'s lock table); it still works, as the second line shows, but it is a lock under the hood. The exact answer is implementation-specific, which is why the standard offers is_always_lock_free and is_lock_free(). On some Linux toolchains, GCC needs -latomic to link the third case.

Write the code

Coding

CodeImplement a test-and-test-and-set spinlock with exponential backoff. It must satisfy Lockable (lock(), try_lock(), unlock()) so that std::lock_guard works, use the weakest correct memory orders, and stop hammering the cache line while the lock is held. Test it with four threads incrementing a shared counter.

Answer

Approach. Acquire with one exchange(true, acquire); if it returns true the lock was taken, so spin on a relaxed load (which hits the local cache) until it looks free, pausing between checks with a growing backoff, then retry the exchange. try_lock checks with a cheap load first so a failing try_lock does not steal the line. Unlock is a release store.

#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class TTASLock {
    std::atomic<bool> locked_{false};

    static void pause() {
#if defined(__x86_64__) || defined(__i386__)
        __builtin_ia32_pause();
#elif defined(__aarch64__)
        asm volatile("yield");
#endif
    }

public:
    void lock() {
        int backoff = 1;
        for (;;) {
            if (!locked_.exchange(true, std::memory_order_acquire)) return;
            while (locked_.load(std::memory_order_relaxed)) {
                for (int i = 0; i < backoff; ++i) pause();
                if (backoff < 1024) backoff *= 2;
                else std::this_thread::yield();
            }
        }
    }
    bool try_lock() {
        return !locked_.load(std::memory_order_relaxed) &&
               !locked_.exchange(true, std::memory_order_acquire);
    }
    void unlock() { locked_.store(false, std::memory_order_release); }
};

int main() {
    TTASLock lk;
    long counter = 0;  // guarded by lk
    std::vector<std::thread> ts;
    for (int t = 0; t < 4; ++t)
        ts.emplace_back([&] {
            for (int i = 0; i < 50000; ++i) {
                std::lock_guard<TTASLock> g(lk);
                ++counter;
            }
        });
    for (auto& t : ts) t.join();
    std::cout << counter << '\n';
    std::cout << lk.try_lock() << lk.try_lock() << '\n';
    lk.unlock();
}
200000
10

Complexity and pitfalls. O(1) uncontended: one RMW to lock, one store to unlock. Pitfalls: spinning on exchange instead of a load (cache-line ping-pong); using seq_cst everywhere (correct but slower on ARM); unbounded spinning when the holder may be preempted, which is why the loop eventually yields; forgetting that this lock is unfair, so a thread can starve. The pause/yield hint is guarded by architecture macros so the code stays portable.

Estimated rating: ~1400 · short code, but needs the TTAS insight and correct acquire/release placement

CodeImplement a FIFO ticket lock with two counters, so threads acquire the lock in the order they arrived (bounded waiting). Provide lock(), unlock() and a non-blocking try_lock(), and test it with four threads.

Answer

Approach. next_ hands out tickets with fetch_add, which always succeeds and orders arrivals. Each thread waits until serving_ equals its ticket, with an acquire load. Only the owner changes serving_, so unlock is a relaxed load plus a release store of the next number, not an RMW. try_lock takes a ticket only if it would be served at once: read serving_ as s, then CAS next_ from s to s + 1. If the CAS succeeds, nobody else held or was queued for the lock, and since serving_ never passes next_, it still equals s.

#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class TicketLock {
    std::atomic<unsigned> next_{0};     // next ticket to hand out
    std::atomic<unsigned> serving_{0};  // ticket allowed in
public:
    void lock() {
        unsigned my = next_.fetch_add(1, std::memory_order_relaxed);
        while (serving_.load(std::memory_order_acquire) != my) std::this_thread::yield();
    }
    bool try_lock() {
        unsigned s = serving_.load(std::memory_order_acquire);
        // succeed only if the next ticket is the one being served: no holder, no queue
        return next_.compare_exchange_strong(s, s + 1, std::memory_order_acquire,
                                             std::memory_order_relaxed);
    }
    void unlock() {
        // only the owner writes serving_, so a plain load plus store is enough
        unsigned s = serving_.load(std::memory_order_relaxed);
        serving_.store(s + 1, std::memory_order_release);
    }
};

int main() {
    TicketLock lk;
    long counter = 0;        // guarded by lk
    std::vector<std::thread> ts;
    for (int t = 0; t < 4; ++t)
        ts.emplace_back([&] {
            for (int i = 0; i < 20000; ++i) {
                std::lock_guard<TicketLock> g(lk);
                ++counter;
            }
        });
    for (auto& t : ts) t.join();
    std::cout << counter << '\n';
    std::cout << lk.try_lock() << lk.try_lock() << '\n';
    lk.unlock();
}
80000
10

Complexity and pitfalls. O(1) per operation, strict FIFO. Unsigned wrap-around is harmless because only equality is compared. Pitfalls: all waiters spin on serving_, so every release invalidates every waiter's cache line (MCS or Linux's qspinlock fix this); and strict FIFO hurts badly if the thread whose turn it is gets preempted, since everyone behind it waits (a "convoy"). That is why the demo yields in the spin loop. A common mistake in try_lock is to take a ticket with fetch_add and give up if it is not served yet: the ticket cannot be handed back, so every later thread waits forever. The CAS only takes a ticket that is already being served.

Estimated rating: ~1600 · needs fetch_add ordering and the single-writer unlock argument

CodeImplement Peterson's lock for two threads in portable C++ so that it is correct on x86 and ARM. Prove mutual exclusion in your test by tracking the maximum number of threads inside the critical section at once.

Answer

Approach. Use std::atomic for both flags and the victim variable, with seq_cst on the two stores and the loads in the wait loop: the algorithm needs the store-then-load order that only sequential consistency (or a full fence) provides. Writing "victim = me" is equivalent to the textbook "turn = other". The unlock can be a release store. A side atomic counts how many threads are inside.

#include <atomic>
#include <iostream>
#include <thread>

class Peterson {
    std::atomic<bool> interested_[2] = {false, false};
    std::atomic<int> victim_{0};
public:
    void lock(int me) {
        const int other = 1 - me;
        interested_[me].store(true, std::memory_order_seq_cst);
        victim_.store(me, std::memory_order_seq_cst);  // "after you"
        while (interested_[other].load(std::memory_order_seq_cst) &&
               victim_.load(std::memory_order_seq_cst) == me) {
            std::this_thread::yield();
        }
    }
    void unlock(int me) { interested_[me].store(false, std::memory_order_release); }
};

int main() {
    Peterson p;
    int counter = 0;      // guarded by p
    int max_inside = 0;   // guarded by p
    std::atomic<int> inside{0};
    auto work = [&](int me) {
        for (int i = 0; i < 50000; ++i) {
            p.lock(me);
            int now = inside.fetch_add(1) + 1;
            if (now > max_inside) max_inside = now;
            ++counter;
            inside.fetch_sub(1);
            p.unlock(me);
        }
    };
    std::thread a(work, 0), b(work, 1);
    a.join();
    b.join();
    std::cout << counter << ' ' << max_inside << '\n';
}
100000 1

Complexity and pitfalls. O(1) per acquisition without contention, with a bound of one bypass under contention. Pitfalls: weakening the stores to release or the loads to acquire (store-load reordering returns and the lock breaks); using volatile; extending it to N threads naively (use Lamport's bakery or a filter lock). It is a teaching algorithm: a single exchange does the same job for any number of threads.

Estimated rating: ~1700 · easy to type, hard to justify the memory orders correctly

CodeImplement a lock-free multi-producer stack of int that supports concurrent push and a drain_sum() that atomically takes all current elements and returns their sum. It must not suffer from ABA and must not leak memory.

Answer

Approach. push is the classic Treiber CAS loop: point the new node at the current head, then CAS the head from that value to the new node; on failure CAS refreshes n->next with the head it saw. Instead of a single-node pop (which has the ABA and reclamation problems), drain_sum takes the entire list with one exchange(nullptr), after which the detached nodes belong to one thread and can be freed safely.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

class PushStack {
    struct Node { int value; Node* next; };
    std::atomic<Node*> head_{nullptr};
public:
    ~PushStack() { free_list(head_.load()); }

    void push(int v) {
        Node* n = new Node{v, head_.load(std::memory_order_relaxed)};
        // on failure, n->next is refreshed with the current head
        while (!head_.compare_exchange_weak(n->next, n, std::memory_order_release,
                                            std::memory_order_relaxed)) { }
    }

    // Detach the whole list in one atomic step: no ABA, no use after free.
    long drain_sum() {
        Node* n = head_.exchange(nullptr, std::memory_order_acquire);
        long sum = 0;
        for (Node* p = n; p; p = p->next) sum += p->value;
        free_list(n);
        return sum;
    }

private:
    static void free_list(Node* n) {
        while (n) { Node* next = n->next; delete n; n = next; }
    }
};

int main() {
    PushStack s;
    std::vector<std::thread> ts;
    for (int t = 0; t < 4; ++t)
        ts.emplace_back([&s, t] { for (int i = 1; i <= 1000; ++i) s.push(t * 1000 + i); });
    for (auto& t : ts) t.join();
    std::cout << s.drain_sum() << '\n';
    std::cout << s.drain_sum() << '\n';
}
8002000
0

Complexity and pitfalls. Push is lock-free (some thread always makes progress) with O(1) expected work; drain is O(n) after one atomic. The release on push pairs with the acquire on drain, so node contents are visible to the draining thread. Pitfalls: a single-node pop with head.compare_exchange(old, old->next) is the textbook ABA bug and also reads old->next after another thread may have freed old; and every lock-free container needs a reclamation plan (hazard pointers, epochs, or a design like this one). This "push many, take all" shape is used in real systems, for example for deferred work queues.

Estimated rating: ~1800 · the CAS loop is standard; seeing that exchange sidesteps ABA is the insight

CodeImplement a futex-style mutex with the three-state algorithm (0 free, 1 locked, 2 locked with possible waiters). Waiters must sleep rather than spin, the uncontended path must not touch the kernel, and unlock must wake a sleeper only when one may exist. Use C++20 std::atomic::wait and notify_one so it is portable. Test with eight threads.

Answer

Approach. Lock tries CAS 0 → 1. If that fails, set the state to 2 with exchange: if the old value was 0 you now own it (pessimistically marked contended); otherwise sleep with wait(2), which returns immediately if the value is no longer 2, then retry the exchange. Unlock exchanges to 0 and calls notify_one only if the old value was 2. std::atomic::wait is built on futex on Linux, ulock on macOS and WaitOnAddress on Windows.

#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

// 0 = unlocked, 1 = locked with no waiters, 2 = locked and maybe waiters
class FutexMutex {
    std::atomic<int> state_{0};
public:
    void lock() {
        int c = 0;
        if (state_.compare_exchange_strong(c, 1, std::memory_order_acquire)) return;  // fast path
        if (c != 2) c = state_.exchange(2, std::memory_order_acquire);
        while (c != 0) {
            state_.wait(2, std::memory_order_relaxed);  // sleeps only while state_ == 2
            c = state_.exchange(2, std::memory_order_acquire);
        }
    }
    bool try_lock() {
        int c = 0;
        return state_.compare_exchange_strong(c, 1, std::memory_order_acquire);
    }
    void unlock() {
        if (state_.exchange(0, std::memory_order_release) == 2) state_.notify_one();  // slow path
    }
};

int main() {
    FutexMutex m;
    long counter = 0;  // guarded by m
    std::vector<std::thread> ts;
    for (int t = 0; t < 8; ++t)
        ts.emplace_back([&] {
            for (int i = 0; i < 20000; ++i) {
                std::lock_guard<FutexMutex> g(m);
                ++counter;
            }
        });
    for (auto& t : ts) t.join();
    std::cout << counter << '\n';
    std::cout << m.try_lock() << m.try_lock() << '\n';
    m.unlock();
}
160000
10

Complexity and pitfalls. Uncontended lock and unlock are one atomic each, no system call. Pitfalls: a two-state version (0/1) must call wake on every unlock, because it cannot tell whether anyone sleeps; setting the state to 1 instead of 2 after waking can lose a wake-up for the remaining sleepers; and checking "is it 2?" then sleeping as two steps reintroduces the lost wake-up that the atomic compare inside FUTEX_WAIT (and atomic::wait) exists to prevent. Unlock uses exchange, not a store, because it must learn the old state.

Estimated rating: ~2100 · subtle protocol; lost wake-ups and the reason for state 2 are the interview

CodeA single std::atomic<long> hit counter is the hottest line in your service. Implement a sharded counter: add(shard, v) from many threads and an approximate read() that is exact once writers stop. Avoid false sharing.

Answer

Approach. Give each shard its own atomic, aligned to a cache line so two shards never share one, and let each thread pick a shard (its index, or a hash of its thread id). add is a relaxed fetch_add on a line that, ideally, only one core writes. read sums the shards with relaxed loads. Relaxed is enough because no other data is published through the counter.

#include <atomic>
#include <cstddef>
#include <iostream>
#include <thread>
#include <vector>

class ShardedCounter {
    static constexpr std::size_t kLine = 64;  // typical cache line size
    struct alignas(kLine) Slot { std::atomic<long> n{0}; };
    std::vector<Slot> slots_;
public:
    explicit ShardedCounter(std::size_t shards) : slots_(shards) {}
    void add(std::size_t shard, long v) {
        slots_[shard % slots_.size()].n.fetch_add(v, std::memory_order_relaxed);
    }
    long read() const {  // a snapshot; exact once writers have stopped
        long sum = 0;
        for (const Slot& s : slots_) sum += s.n.load(std::memory_order_relaxed);
        return sum;
    }
};

int main() {
    ShardedCounter c(4);
    std::vector<std::thread> ts;
    for (std::size_t t = 0; t < 4; ++t)
        ts.emplace_back([&c, t] { for (int i = 0; i < 100000; ++i) c.add(t, 1); });
    for (auto& t : ts) t.join();
    std::cout << c.read() << '\n';
}
400000

Complexity and pitfalls. add is O(1) with no cross-core traffic when shards are thread-private; read is O(shards). Pitfalls: packing shards into a plain std::atomic<long>[] puts eight of them on one 64-byte line and recreates the contention (false sharing, Ch 19); read() during updates is a snapshot, not a linearisable total; and before C++17, std::vector did not honour over-aligned element types (C++17 aligned new fixed that). std::hardware_destructive_interference_size is the standard name for the padding size, but using it in a header can change ABI between compiler flags, so many codebases hard-code 64 or 128.

Estimated rating: ~1300 · simple code; the value is knowing why one atomic does not scale

Cheat sheet

Theory
  • Data race = conflicting accesses, one non-atomic, no happens-before: UB for the whole program. Race condition = timing-dependent logic bug, possible with atomics only. TSan finds the first, not the second.
  • counter++ is load, add, store. Fix with fetch_add, a mutex, or per-thread counters. x = x + 1 on an atomic is still two operations. volatile fixes nothing.
  • Critical section: mutual exclusion, progress, bounded waiting. Disabling interrupts is privileged and useless on multiprocessors. Strict alternation fails progress; TAS spinlocks fail bounded waiting.
  • Peterson needs store→load ordering: breaks on x86 and ARM without seq_cst or a full fence. Release/acquire is not enough.
  • Primitives: TAS, swap, fetch-add (consensus 2) and CAS, LL/SC (universal). CAS writes the observed value into expected on failure; _weak may fail spuriously, use it in loops. Watch for ABA.
  • Spinlock: TTAS plus pause and backoff, acquire on lock, release on unlock. Spin only for very short sections with a running holder; never on one core. Ticket and MCS locks add fairness.
  • Mutex = atomic word + futex. Fast path: one CAS, no syscall. Slow path: FUTEX_WAIT re-checks the value atomically, so wakes are not lost. Three states let unlock skip the wake.
  • Unlock is a release, lock is an acquire: they create happens-before. Lock every access to guarded data, reads included. Uncontended locks are cheap; contention and false sharing (Ch 19) are what hurt.

Part C · Locks and mutexes · Chapter 6

Mutex types, condition variables and deadlock

This chapter covers the synchronisation toolbox you use every day in C++: the mutex family, the RAII lock wrappers, condition variables, semaphores, latches and barriers. It then covers what goes wrong with them (deadlock, livelock, starvation, priority inversion) and the classic problems interviewers use to test all of it: producer-consumer, readers-writers and the dining philosophers.

TheoryDrillsCoding· about 5 hours· prerequisites: Ch 4 threads, Ch 5 races and mutex internals

The mutex family

Theory

Mental model. A mutex is a token that only one thread can hold at a time. A thread that wants the token and finds it taken goes to sleep until the holder gives it back. Ch 5 explains how that sleep works (a futex fast path in user space, a system call only on contention). This chapter is about the C++ types built on top of it and how to combine them.

The standard library gives you six mutex types in <mutex> and <shared_mutex>. They differ along three axes: can the owner lock it again (recursive), can you give up after a timeout (timed), and can many readers hold it at once (shared).

std::mutex

std::mutex has three operations: lock(), try_lock() and unlock(). It is not copyable and not movable, because other threads hold its address. Its rules are preconditions, so breaking them is undefined behaviour, not an error you can catch:

  • Locking a std::mutex that the calling thread already owns is undefined. On Linux it usually deadlocks the thread against itself; an implementation may also throw std::system_error with resource_deadlock_would_occur.
  • Unlocking a mutex the calling thread does not own is undefined. A mutex has an owner; this is the key difference from a semaphore.
  • Destroying a mutex that is still locked is undefined.
  • try_lock() is allowed to fail spuriously, that is, return false even when no thread holds the mutex. Never use it to ask "is it locked?".

On the plus side, std::mutex has a constexpr default constructor, so a global or static mutex is constant-initialised and cannot suffer from the static initialisation order problem.

recursive_mutex

std::recursive_mutex keeps an owner thread id and a count. The owner may lock it again, and it becomes free only after as many unlock() calls as lock() calls. The maximum depth is unspecified; past it, lock() throws and try_lock() returns false.

It exists for code where a public locked function calls another public locked function on the same object. Most reviewers treat it as a design smell: the inner call runs while the invariant may be half updated by the outer call, and you lose the ability to reason about "inside the lock, the invariant holds on entry". The usual fix is a private do_x_locked() helper that assumes the lock is held, called by thin public wrappers that take the lock.

timed_mutex and shared_mutex

std::timed_mutex and std::recursive_timed_mutex add try_lock_for(duration) and try_lock_until(time_point). Use them when a caller must give up, for example to report a likely deadlock or to keep a latency budget. Prefer steady_clock for deadlines, because system_clock can jump.

std::shared_mutex (C++17) is a readers-writer lock: lock() takes it exclusively, lock_shared() takes a shared (read) hold, and any number of shared holders may coexist while no exclusive holder exists. std::shared_timed_mutex (C++14, older) adds timed variants. The standard does not say whether readers or writers get priority. In practice it differs: libstdc++ builds std::shared_mutex on pthread_rwlock_t, and glibc's default rwlock prefers readers, so on Linux with GCC a steady stream of overlapping readers can starve a writer; libc++ implements its own lock that blocks new readers once a writer is waiting. The section on classic problems shows how to build a writer-preferring lock yourself.

Interview trap

"Use shared_mutex for read-heavy data" is not automatically a win. Every lock_shared() still writes to the mutex (it bumps a reader count), so with many cores the cache line holding the mutex bounces between them just as it would for a plain mutex. When critical sections are a few nanoseconds long, a plain std::mutex is often faster. Shared locking pays off when readers hold the lock for a long time.

Key facts

TypeHeader, standardRe-lock by ownerTimeoutShared modeTypical use
mutex<mutex>, C++11UBnonodefault choice
recursive_mutex<mutex>, C++11yes, countednonolegacy re-entrant APIs
timed_mutex<mutex>, C++11UByesnogive up after a deadline
recursive_timed_mutex<mutex>, C++11yesyesnorare
shared_timed_mutex<shared_mutex>, C++14UByesyesreaders-writer with timeouts
shared_mutex<shared_mutex>, C++17UBnoyesreaders-writer
counting_semaphore<semaphore>, C++20no owneryesn/alimit concurrency, signalling
condition_variable<condition_variable>, C++11n/ayesn/await for a state change

RAII lock wrappers and locking several mutexes

Theory

Mental model. Never call lock() and unlock() by hand. A wrapper object locks in its constructor and unlocks in its destructor, so an exception or an early return cannot leave the mutex locked forever.

lock_guard, unique_lock, scoped_lock, shared_lock

  • std::lock_guard<M> (C++11): the simplest. Locks one mutex for the whole scope. Not movable, no unlock(), no overhead beyond the two calls.
  • std::scoped_lock<Ms...> (C++17): like lock_guard but takes any number of mutexes and locks them all with the deadlock-avoidance algorithm of std::lock. With one mutex it behaves like lock_guard. This is the modern default.
  • std::unique_lock<M> (C++11): a movable lock that may or may not own its mutex at any moment. It supports lock(), unlock(), try_lock(), timed locking, owns_lock(), release() (detach without unlocking) and moving ownership out of a function. It costs one extra bool and a branch in the destructor. Condition variables require it.
  • std::shared_lock<M> (C++14): the shared-mode twin of unique_lock. Its constructor calls lock_shared().
std::mutex m;
std::shared_mutex rw;

void f() {
    std::scoped_lock lk(m);                  // CTAD: scoped_lock<std::mutex>
}
void g() {
    std::unique_lock lk(m);
    // ... prepare under the lock
    lk.unlock();                             // do slow work without it
    // ...
    lk.lock();                               // take it again; destructor unlocks
}
int read_side(const int& x) {
    std::shared_lock lk(rw);                 // many readers at once
    return x;
}

defer_lock, try_to_lock and adopt_lock

Tag arguments change what the constructor does:

TagConstructor doesAccepted by
std::defer_locknothing; the lock starts unownedunique_lock, shared_lock
std::try_to_lockcalls try_lock(); check owns_lock()unique_lock, shared_lock
std::adopt_lockassumes the caller already locked it; only unlockslock_guard, unique_lock, shared_lock, scoped_lock

Note the argument order: std::lock_guard g(m, std::adopt_lock) puts the tag second, but std::scoped_lock s(std::adopt_lock, m1, m2) puts it first, because the mutex list is variadic.

std::lock for several mutexes

Locking two mutexes one after the other in different orders in different threads is the classic deadlock (see deadlock). std::lock(m1, m2, ...) locks them all without deadlocking, whatever order other std::lock callers use. The standard only says "a deadlock avoidance algorithm such as try-and-back-off"; libstdc++ and libc++ both lock one, then try_lock the rest, and on failure release everything and start again from the mutex that failed. If any lock throws, the ones already taken are released.

void transfer(Account& a, Account& b, int amount) {
    // C++11 style
    std::unique_lock la(a.m, std::defer_lock);
    std::unique_lock lb(b.m, std::defer_lock);
    std::lock(la, lb);
    // C++17 style, one line, same algorithm:
    // std::scoped_lock lk(a.m, b.m);
    a.balance -= amount;
    b.balance += amount;
}

Interview trap

Two famous one-character bugs. std::lock_guard<std::mutex>(m); does not lock m: it declares a new variable named m of type lock_guard (the parentheses around a declarator are allowed). Because lock_guard has no default constructor this fails to compile, which is the lucky outcome; the same pattern with a type that has a default constructor compiles silently. std::scoped_lock{m}; does compile: it creates an unnamed temporary that locks and unlocks m at the semicolon, leaving the rest of the scope unprotected. Always name the lock object.

Also, transfer(a, a, 10) with std::scoped_lock lk(a.m, a.m) locks the same std::mutex twice, which is undefined. Check for &a == &b first.

Condition variables

Theory

Mental model. A mutex protects some shared state. A condition variable is a waiting room attached to that state: a thread that finds the state "not ready yet" goes to sleep in the waiting room, and a thread that changes the state rings the bell. The condition variable itself remembers nothing. The truth is always in the shared state, and the waiter must re-check it after every wake-up.

How wait works

cv.wait(lk) does three things, and the first two are atomic with respect to notify: it unlocks the mutex held by lk, puts the thread to sleep on the condition variable, and when woken, re-locks the mutex before returning. Because unlock and sleep are one step, a notifier that changes the state under the same mutex cannot slip its notification into the gap between "I checked the state" and "I am asleep". That gap is exactly the lost-wakeup bug that hand-rolled "check flag, then sleep" code has.

That is why wait takes a std::unique_lock<std::mutex> and not a lock_guard: it needs to unlock and re-lock the mutex, and lock_guard has no such operations. std::condition_variable_any accepts any lockable type (a shared_lock, your own spinlock) at some extra cost, and in C++20 it also has overloads that take a std::stop_token.

Waiter Notifier lock m ready? false wait: unlock m and sleep (atomic) asleep, m is free re-lock m, re-check ready? true, go lock m ready = true unlock m notify_one() wake no gap here for a notification to be lost time → The state change happens under m, so it is either before the check (waiter never sleeps) or after the sleep (waiter is woken).
The predicate is checked and the thread goes to sleep under one mutex. Because wait releases the mutex and sleeps as one step, and the notifier changes ready while holding the same mutex, the change lands either before the check or after the sleep. Both orders are safe.

Always wait with a predicate: spurious and stolen wake-ups

A waiter can return from wait without the state being ready, for two reasons:

  • Spurious wake-ups. The standard explicitly allows wait to return with no notification (POSIX allows the same for pthread_cond_wait). Implementations use this freedom so that signals and futex races do not need extra bookkeeping.
  • Stolen wake-ups. Between the notify and the moment the waiter re-acquires the mutex, a third thread can take the mutex and consume the item. This is "Mesa semantics": signal means "the state may have changed", not "the state is now true for you".

So the waiting code is always a loop. The predicate overload writes the loop for you, and the two forms are equivalent:

std::unique_lock lk(m);
cv.wait(lk, [&] { return !q.empty(); });   // preferred

// is exactly
auto pred = [&] { return !q.empty(); };
while (!pred())
    cv.wait(lk);

The timed versions follow the same rule. wait_for(lk, d, pred) returns the value of pred() at the end, so false means "timed out and still not ready". Without a predicate, wait_for returns std::cv_status::timeout or no_timeout, and no_timeout tells you nothing about the state.

Lost wake-ups

A notification is not stored. If notify_one() runs when nobody is waiting, it does nothing. A waiter that arrives later and calls wait(lk) without a predicate sleeps forever. The predicate fixes this, because the late waiter sees ready == true and never sleeps.

The second way to lose a wake-up is changing the state without the mutex, even if the state is a std::atomic<bool>:

// Waiter                               // Notifier (BROKEN)
std::unique_lock lk(m);
// pred(): ready is false
                                        ready = true;        // atomic store, no lock
                                        cv.notify_one();     // nobody waiting yet: lost
cv.wait(lk);  // sleeps forever

The waiter checked the predicate under m, then the notifier changed the state and notified in the window before the waiter went to sleep. Rule: modify the predicate's state while holding the mutex, then notify. Taking and releasing the mutex between the store and the notify is enough to close the window.

notify_one vs notify_all, and notifying under the lock

notify_one() wakes at least one waiter (usually exactly one); notify_all() wakes all of them, and they then take the mutex one at a time. Use notify_one when any single waiter can make progress with the change and all waiters wait for the same condition, such as "an item was pushed". Use notify_all when the change may matter to many waiters (shutdown, a barrier opening) or when waiters on the same condition variable wait for different predicates. In the second case notify_one may wake a thread whose predicate is still false; it goes back to sleep, and the thread that could have run is never told. The cleaner fix is one condition variable per predicate, for example not_full and not_empty in a bounded buffer.

Should you notify while holding the mutex? Both are correct, provided the state was modified under the mutex. Notifying after unlocking avoids "hurry up and wait", where the woken thread immediately blocks on the mutex the notifier still holds (some implementations move the waiter straight to the mutex queue to soften this). Notifying under the lock is required in one case: when the waiter may destroy the condition variable as soon as it sees the state, for example a waiter that owns a stack-allocated condition_variable and returns. If the notifier unlocks first, the waiter can wake spuriously, see the state, return and destroy the object before the notifier calls notify_one() on it.

Know this cold

The condition variable recipe: (1) shared state protected by a mutex; (2) the waiter takes a unique_lock and calls wait(lk, pred); (3) the notifier locks, changes the state, and calls notify_one or notify_all (under the lock or just after unlocking). Every lost-wakeup and spurious-wakeup bug is a deviation from these three lines.

Semaphores, and how they differ from a mutex

Theory

Mental model. A semaphore is a counter of permits. acquire() (Dijkstra's P) waits until the count is positive and decrements it; release() (V) increments it and wakes a waiter. A counting semaphore initialised to N lets at most N threads through at once. A binary semaphore has a maximum of 1.

C++20 provides std::counting_semaphore<LeastMaxValue> and std::binary_semaphore, an alias for counting_semaphore<1>, in <semaphore>. The operations are acquire(), try_acquire(), try_acquire_for(), try_acquire_until() and release(n = 1). Releasing past max(), or releasing a negative count, is undefined. Like try_lock, try_acquire may fail spuriously.

std::counting_semaphore<8> slots(4);   // at most 4 concurrent downloads

void download(const Url& u) {
    slots.acquire();
    fetch(u);                              // if this throws, the permit leaks: wrap in RAII
    slots.release();
}

Mutex vs binary semaphore

The two look alike (one holder at a time) but have different contracts:

MutexBinary semaphore
OwnershipYes. Only the locking thread may unlock.No. Any thread may release.
PurposeMutual exclusion around a critical sectionSignalling between threads ("item ready"), or exclusion without ownership
RecursionPossible with recursive_mutexSecond acquire by the same thread blocks
Priority inheritancePossible, because the owner is knownImpossible: no owner to boost
Wrong useUnlock from another thread is UBExtra release overflows the count (UB past max)

The ownership row is the interview answer. Because a semaphore has no owner, it is the right tool for one thread to signal another (thread A releases, thread B acquires), and the wrong tool for protecting data, because nothing stops an unrelated thread from releasing it and nothing can boost the holder's priority. Linux priority-inheritance mutexes work precisely because a PI futex word stores the owner's thread id, so the kernel knows which thread to boost.

call_once, latches and barriers

Theory

std::call_once and magic statics

std::call_once(flag, f, args...) runs f exactly once across all threads that call it with the same std::once_flag. Other callers block until the first call finishes and then see all its side effects. If f throws, the call is "exceptional": the exception propagates to that caller, the flag stays unset, and the next caller tries again.

For the common case of lazily building one object, a function-local static does the same job with less code. Since C++11, initialisation of a block-scope static is thread-safe: concurrent callers wait for the first initialisation, and if the constructor throws, the next caller retries ([stmt.dcl]/4, often called "magic statics"). This is the standard way to write a thread-safe Meyers singleton.

Config& config() {
    static Config c = load_config();   // runs once, thread-safe since C++11
    return c;
}

std::latch and std::barrier (C++20)

  • std::latch is a single-use countdown. count_down(n) decrements it, wait() blocks until it reaches zero, arrive_and_wait() does both, try_wait() polls. Once at zero it stays open. Use it for "start all workers at once" or "wait until N tasks finished".
  • std::barrier is reusable. Each phase, N threads call arrive_and_wait(); when the last one arrives, an optional completion function runs once (on one of the arriving threads), the barrier resets for the next phase, and everyone is released. arrive_and_drop() removes a participant for future phases. Use it for iterative algorithms where every thread must finish step k before anyone starts step k+1.

Before C++20 both were built from a mutex, a condition variable and a counter; the barrier also needs a generation number so that a fast thread that re-enters the next phase does not confuse threads still leaving the current one. Coding drill a reusable barrier asks you to build one.

Deadlock: conditions, prevention, detection

Theory

Mental model. A deadlock is a set of threads in which each one waits for a resource held by another one in the set, so none can ever proceed. Nothing is spinning and nothing crashes; the threads simply sleep forever, and the CPU usage of the process drops to zero. That silence is often how you notice it in production.

The four Coffman conditions

Coffman, Elphick and Shoshani (1971) showed that a deadlock over resources needs all four of these at once:

  1. Mutual exclusion. A resource can be held by only one thread at a time.
  2. Hold and wait. A thread holds at least one resource while waiting for another.
  3. No preemption. A resource cannot be taken away; the holder must release it voluntarily.
  4. Circular wait. There is a cycle T1 → T2 → ... → T1 in the wait-for graph, where an edge means "waits for a resource held by".

Break any one and deadlock is impossible. In practice you break circular wait (lock ordering) or hold and wait (acquire everything at once with std::scoped_lock, or try_lock and back off). Mutual exclusion is the reason the mutex exists, and preemption of a mutex would leave the protected data half updated.

Everyone takes left fork first P3 takes the lower-numbered fork first P0 P1 P2 P3 cycle: deadlock arrow = waits for a fork held by P0 P1 P2 P3 P3 waits for F0 holding nothing no cycle P2 gets F3 too, eats, releases: chain unwinds
Deadlock needs a cycle in the wait-for graph. With four philosophers each holding the left fork and waiting for the right one, the graph is a ring. If every thread acquires forks in increasing number order, philosopher 3 asks for fork 0 before fork 3, holds nothing while waiting, and the ring is broken.

Prevention by lock ordering

Give every mutex a rank and always acquire in increasing rank. Then a cycle would need some thread to wait for a lower rank while holding a higher one, which the rule forbids. Ways to get a rank:

  • A documented global order ("account lock before ledger lock"), checked in code review.
  • Address order for instances of the same type: lock the mutex with the lower address first. Comparing unrelated pointers with the built-in < is unspecified, so compare with std::less<>, which guarantees a strict total order over pointers.
  • A hierarchical mutex that stores its level and checks, through a thread_local "current level", that each new lock is lower than the last one; violations throw at once in tests instead of deadlocking once a month in production.
  • Acquire all at once with std::scoped_lock(a, b), which needs no global order.

The other prevention rules are simple habits: never call unknown code (callbacks, virtual functions supplied by users, signals to observers) while holding a lock, keep critical sections short, and never wait for a thread (join, a future) while holding a lock that thread may need.

Avoidance and detection

Avoidance means refusing a request that could lead to deadlock later. Dijkstra's banker's algorithm does this: each process declares its maximum need, and the system grants a request only if the resulting state is safe (some order exists in which every process can finish). It is a textbook favourite but rarely used in general-purpose systems, because the maximum needs are not known in advance.

Detection means letting deadlocks happen and finding the cycle. A database builds a wait-for graph of transactions, finds cycles periodically or on timeout, and aborts a victim. In C++ programs, detection is mostly a debugging activity: attach gdb and run thread apply all bt, look for threads blocked in futex_wait or __lll_lock_wait, and match each blocked mutex to its owner (glibc stores the owner's TID in the pthread_mutex_t, in the field __data.__owner, which gdb can print). ThreadSanitizer reports "lock-order-inversion (potential deadlock)" when it sees two locks taken in both orders, even if the deadlock did not happen on that run. The Linux kernel's lockdep does the same for kernel locks.

Recovery options are limited for mutexes: you cannot preempt a mutex safely, so a process usually dumps state and restarts. Timeouts (try_lock_for) let a thread give up, release what it holds and retry, which converts a deadlock into a possible livelock.

The dining philosophers

Dijkstra's problem: N philosophers sit around a table with one fork between each pair. A philosopher needs both neighbouring forks to eat. If all pick up their left fork at the same time, each waits for a right fork that will never be released: all four Coffman conditions hold. The standard solutions map to the conditions:

  • Resource ordering (breaks circular wait): number the forks and always take the lower number first. One philosopher ends up reaching right first.
  • An arbitrator or a limit (breaks circular wait): a counting semaphore initialised to N-1 lets at most N-1 philosophers try to pick up forks, so at least one gets both.
  • All or nothing (breaks hold and wait): take both forks with std::scoped_lock(left, right), or take the left, try_lock the right and put the left back on failure.
  • Chandy/Misra: forks are dirty or clean and requested by message; it also avoids starvation, but is rarely asked beyond its name.

Livelock, starvation and priority inversion

Theory

Livelock and starvation

Livelock: threads are not blocked, they keep running, but they keep reacting to each other and make no progress. The picture is two people in a corridor who both step aside in the same direction, again and again. In code it appears with naive try-and-back-off: two threads each lock one mutex, fail try_lock on the other, release, and retry in perfect lockstep. Random or exponential back-off, or a fixed lock order, cures it.

Starvation: the system as a whole makes progress, but one thread never does. Causes include unfair locks (a thread that just released a mutex grabs it again before a woken waiter runs; this "barging" is good for throughput and bad for fairness), reader-preferring readers-writer locks that never let a writer in, and strict priority scheduling where a low-priority thread never gets the CPU. std::mutex makes no fairness guarantee. A ticket lock (Ch 5) is FIFO and therefore starvation-free, at the cost of throughput.

Priority inversion and the Mars Pathfinder

Priority inversion happens with strict priorities and a shared lock. Low-priority task L holds a mutex. High-priority task H wakes and blocks on it, which is fine for a short time. Then medium-priority task M, which does not need the mutex at all, becomes runnable. M preempts L, so L cannot finish its critical section and release the mutex, so H waits for M: an unbounded wait, set by a task H does not even interact with.

H (high) M (medium) L (low) L locks m H runs H blocked on m, waiting for M to finish M runs (does not need m), preempts L L H gets m H calls lock(m) With priority inheritance, L runs at H's priority from the moment H blocks, so M cannot preempt it.
The medium task delays the high task without touching the lock. H's waiting time is bounded only by how long M runs. Priority inheritance raises L to H's priority while H waits, so L finishes its critical section first and H's delay is bounded by one critical section.

The famous case is the Mars Pathfinder lander in 1997. On VxWorks, a low-priority meteorological data task held the mutex guarding the shared information bus. The high-priority bus management task blocked on it, a long-running medium-priority communications task preempted the low one, and a watchdog noticed that the bus task had not completed its cycle and reset the whole system, repeatedly. Engineers reproduced it on the ground and fixed it by uploading a change that turned on priority inheritance for that mutex.

The two standard fixes:

  • Priority inheritance: while a higher-priority thread waits on a mutex, the owner temporarily runs at the waiter's priority. POSIX: pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); Linux implements it with PI futexes, and the RT kernel uses it for most locks. It needs an owner, which is why semaphores cannot do it.
  • Priority ceiling (PTHREAD_PRIO_PROTECT): each mutex has a ceiling priority equal to the highest priority of any thread that uses it, and a thread holding the mutex runs at the ceiling. It also prevents some deadlocks, but you must know every user's priority.

std::mutex has no portable way to request either; real-time code uses a pthread mutex created with the attribute, often wrapped in a small class that meets the Lockable requirements so std::scoped_lock still works.

Classic problems: producer-consumer, readers-writers, monitors

Theory

Producer-consumer with a bounded buffer

Producers put items into a fixed-capacity buffer and consumers take them out. Producers must wait while it is full and consumers while it is empty. The textbook semaphore solution uses three semaphores: empty (initialised to capacity), full (initialised to 0) and a mutex. A producer does empty.acquire(); lock; push; unlock; full.release(), and a consumer mirrors it. Acquiring the counting semaphore before the mutex matters: if a producer took the mutex first and then blocked on empty, no consumer could ever get the mutex to make space, which is a deadlock.

In C++ the usual form is a monitor: one mutex and two condition variables, not_full and not_empty. It also makes shutdown easy, because a closed flag becomes part of both predicates. Coding drill bounded queue builds it.

Readers-writers and writer starvation

Many readers may read at once; a writer needs exclusive access. The three textbook variants differ only in who goes first when both are waiting:

  • Readers preference (first readers-writers problem): a new reader may enter whenever other readers are inside. A steady stream of overlapping readers keeps the count above zero forever and the writer starves.
  • Writers preference (second problem): once a writer is waiting, new readers queue behind it. Writers can now starve readers if writes are continuous, but in most systems writes are rare and must not be delayed, so this is the usual choice.
  • Fair (third problem): arrival order (FIFO), for example with a ticket or a turnstile mutex that every thread passes through.

A writer-preferring lock needs only: readers (active count), writer (active flag), waiting_writers, one mutex and one or two condition variables. Readers wait for !writer && waiting_writers == 0; writers increment waiting_writers, wait for !writer && readers == 0, then decrement it. Coding drill readers-writer lock builds it.

Monitors

A monitor (Hoare and Brinch Hansen, 1973 to 1974) is an object whose methods all run under one implicit mutex, plus condition variables to wait inside the methods. Java's synchronized methods with wait/notify are a monitor. In C++ you write it explicitly: a class with a private std::mutex, private state, condition variables, and every public method starting with std::unique_lock lk(m_);.

The detail interviewers ask about is signalling semantics. In Hoare semantics (signal-and-wait), the signalled thread runs immediately, inside the monitor, so the condition is guaranteed true and an if would do. In Mesa semantics (signal-and-continue), used by Mesa, POSIX, Java and C++, the signaller keeps running and the woken thread only becomes runnable, so another thread may change the state first. That is the deep reason the waiting code is while (!pred()) wait(); and never if.

Concept and tricky questions

Drills

ConceptWhen do you use lock_guard, scoped_lock and unique_lock?

Answer

Answer. scoped_lock (C++17) is the default for locking one or several mutexes for a whole scope; lock_guard is its single-mutex C++11 predecessor; unique_lock is for when you need to unlock early, lock later, try or time out, move the lock, or pass it to a condition variable.

Why. lock_guard and scoped_lock have exactly two operations, lock in the constructor and unlock in the destructor, so they cannot be misused and cost nothing extra. unique_lock tracks whether it owns the mutex (an extra bool and a check in the destructor) to support defer_lock, try_to_lock, unlock(), release() and move. A common wrong answer is "unique_lock is for unique_ptr-like exclusive ownership and lock_guard is shared"; both are exclusive. For shared ownership of a shared_mutex, use shared_lock.

TrickyWhy does condition_variable::wait take a std::unique_lock<std::mutex> and not a lock_guard or a raw mutex?

Answer

Answer. Because wait must unlock the mutex while the thread sleeps and lock it again before returning, and it must be able to tell that the caller really holds it. unique_lock is the RAII type that supports unlock and relock; lock_guard does not.

Why. The atomic "unlock and sleep" step is what prevents lost wake-ups (see How wait works). Taking a unique_lock also documents and enforces that the lock is held on entry (the precondition is lk.owns_lock()) and that it is held again on exit, even if wait throws. A raw std::mutex& would lose that exception safety. Restricting the type to unique_lock<std::mutex> lets the implementation call pthread_cond_wait on the native handle directly. If you need another lock type, use std::condition_variable_any, which works with anything that has lock() and unlock(), at the cost of an internal mutex.

TrickyWhat is a spurious wake-up, why does the standard allow it, and is the predicate overload of wait only there to handle it?

Answer

Answer. A spurious wake-up is a return from wait without any notify. It is allowed so that implementations can be simpler and faster (for example after a signal interrupts the futex wait, or when racing notifies are coalesced). The predicate loop handles it, but it also handles two other problems: stolen wake-ups and notifications that happened before the wait.

Why. cv.wait(lk, pred) is defined as while (!pred()) wait(lk);. Checking first means a thread that arrives after the notify never sleeps (no lost wake-up). Checking after each wake-up handles both a spurious return and a third thread that consumed the item between the notify and this thread re-acquiring the mutex (Mesa semantics). Candidates who say "spurious wake-ups are rare, so an if is fine in practice" miss the stolen-wake-up case, which happens all the time with several consumers.

TrickyThe flag is a std::atomic<bool>, so the notifier does ready = true; cv.notify_one(); without locking the mutex. The waiter uses cv.wait(lk, [&]{ return ready.load(); }). There is no data race. Is the code correct?

Answer

Answer. No. It can lose the wake-up and hang the waiter forever, even though it is free of data races.

Why. The waiter evaluates the predicate while holding the mutex, sees false, and is about to sleep. The notifier does not take the mutex, so it can store true and call notify_one() right in that window, before the waiter is registered as waiting. The notify finds nobody and is dropped; the waiter then sleeps, and nothing will ever re-check the predicate. The fix is to make the store under the mutex ({ std::lock_guard g(m); ready = true; } cv.notify_one();), and then the flag no longer needs to be atomic. The mutex is what orders "check then sleep" against "set then notify". Atomics fix data races, not this ordering problem.

ConceptShould you call notify_one() while still holding the mutex, or after releasing it?

Answer

Answer. Either is correct as long as the shared state was changed under the mutex. Notifying after unlocking is usually slightly faster; notifying under the lock is required when the waiter may destroy the condition variable as soon as it sees the new state.

Why. If you notify while holding the lock, the woken thread runs, tries to take the mutex, finds it held and blocks again ("hurry up and wait"); some implementations avoid the extra trip with wait morphing. Unlocking first lets the waiter take the mutex at once. The danger of unlocking first: after the unlock the waiter may already see the state (through a spurious wake-up or a timed wait), return, and destroy a condition_variable that the notifier is about to touch. Holding the lock while notifying keeps the waiter from getting past its re-lock until the notify has finished. The wrong answer is "you must always notify under the lock or the notification can be lost": a notification cannot be lost if the state change itself was made under the lock.

TrickyA bounded buffer uses one condition variable for both "not full" and "not empty", and both sides call notify_one(). Producers and consumers both wait with correct predicates. Can it hang?

Answer

Answer. Yes. A notify_one() can wake a thread of the wrong kind, whose predicate is still false; it goes back to sleep, and the thread that could have made progress is never woken.

Why. Example with capacity 1: two consumers wait (buffer empty). A producer pushes and notifies one consumer, then pushes again: the buffer is full, so it waits too. The consumer pops and calls notify_one(), which may wake the other consumer instead of the producer. That consumer sees an empty buffer and sleeps. Now everyone waits. Fixes: use two condition variables (producers wait on not_full, consumers on not_empty, and each side notifies the other one), or use notify_all(). The rule: notify_one is safe only when every waiter on that condition variable waits for the same predicate, so any single waiter can use the change.

TrickyWhat is the difference between a mutex and a binary semaphore? Can a binary semaphore replace a mutex?

Answer

Answer. A mutex has an owner: only the thread that locked it may unlock it. A semaphore is just a counter: any thread may release it. A binary semaphore can provide mutual exclusion, but you lose ownership checks, recursion support and priority inheritance, so it should be used for signalling, not for protecting data.

Why. Ownership is what lets a mutex implementation detect a re-lock by the owner (PTHREAD_MUTEX_ERRORCHECK, or std::system_error from unique_lock::lock), lets a debugger show who holds it, and lets the kernel boost the owner's priority to fix priority inversion. In C++, unlocking a std::mutex from a thread that does not own it is undefined, while std::binary_semaphore::release() from another thread is the intended use ("thread A finished setup, thread B may start"). The common wrong answer is "they are the same thing with a different name", which comes from older textbooks that used a semaphore initialised to 1 as the only lock.

ConceptState the four Coffman conditions for deadlock. Which one does each common prevention technique break?

Answer

Answer. Mutual exclusion, hold and wait, no preemption, circular wait. Lock ordering and lock hierarchies break circular wait. Acquiring all locks at once (std::scoped_lock, std::lock) and try-lock with back-off break hold and wait. Timeouts plus release give a voluntary form of preemption. Lock-free algorithms remove mutual exclusion on the lock itself.

Why. All four are necessary, so removing one suffices. Mutual exclusion is the whole point of a mutex and real preemption of a mutex would expose half-modified state, so in practice the choices are circular wait and hold and wait. Note that std::lock internally does hold and wait for a moment, then releases on failure: it never waits while holding, which is the property that matters.

ConceptWhat is the difference between deadlock prevention, avoidance and detection? A system has 12 units of one resource. P0 holds 5 and may need up to 10, P1 holds 2 and may need up to 4, P2 holds 2 and may need up to 9. Is the state safe, and should the banker's algorithm grant P2 one more unit?

Answer

Answer. Prevention makes one Coffman condition impossible by design (lock ordering, all-at-once acquisition). Avoidance checks each request at run time and grants it only if the system stays in a safe state (the banker's algorithm). Detection lets deadlocks happen, finds cycles in the wait-for graph, and recovers by aborting or restarting a victim. The state is safe; the extra unit for P2 must be refused.

Why. Three units are free (12 minus 9 held) and the remaining needs are P0 5, P1 2, P2 7. The order P1, P0, P2 lets everyone finish: P1 needs 2 of the 3 free and returns its 4 (5 free), P0 needs 5 and returns 10, P2 needs 7 of the 10. If P2 gets one more unit, 2 are free and the needs become P0 5, P1 2, P2 6: P1 can finish (4 free), but then neither P0 nor P2 can, so the state is unsafe and the request waits. An unsafe state is not yet a deadlock, only a state from which the system can no longer guarantee to avoid one; mixing those up is the common wrong answer. Avoidance needs every maximum declared in advance, which is why general-purpose code uses prevention and databases use detection.

TrickyIn the semaphore version of the bounded buffer, a producer runs mtx.acquire(); empty.acquire(); push(item); mtx.release(); full.release();, taking the mutex first "to keep the critical section tidy". Consumers mirror it. What goes wrong?

Answer

Answer. Deadlock when the buffer is full. A producer holds the mutex and sleeps in empty.acquire(); every consumer then blocks on the mutex, so no consumer can ever remove an item and release empty. The correct order is to wait on the counting semaphore first and take the mutex only around the buffer operation: empty.acquire(); mtx.acquire(); push; mtx.release(); full.release();.

Why. It is hold and wait plus a cycle: the producer holds the mutex and waits for a slot, and the consumers hold no slot but wait for the mutex the producer will not release. The general rule is never to block on a condition (a semaphore, a condition variable without releasing the lock, a future, a join) while holding a lock that the thread able to satisfy it needs. The order of the two release() calls at the end does not matter for correctness. The condition-variable version avoids the trap automatically, because wait releases the mutex while it sleeps. People often answer "it is only slower because the critical section is longer"; it actually hangs as soon as the buffer fills.

TrickyA thread calls m.lock() twice on the same std::mutex. What happens? And with std::recursive_mutex?

Answer

Answer. With std::mutex it is undefined behaviour; on Linux with glibc the thread usually blocks forever on itself. With std::recursive_mutex the second lock succeeds and increments a count, and the mutex is released only after two unlock() calls.

Why. The precondition of mutex::lock() is that the calling thread does not own it ([thread.mutex.requirements.mutex]). A default pthread mutex (PTHREAD_MUTEX_NORMAL behaviour) stores no check, so the futex simply says "locked" and the thread sleeps waiting for itself. The standard also permits an implementation to detect it and throw std::system_error with errc::resource_deadlock_would_occur, but you cannot rely on that. A std::unique_lock that already owns its mutex is different: calling lk.lock() again is required to throw that error, because the lock object knows it owns the mutex. Reaching for recursive_mutex to fix this is usually a sign that the locking boundaries are wrong.

ConceptDefine deadlock, livelock and starvation, with one example of each.

Answer

Answer. Deadlock: threads block forever waiting for each other (two threads locking A then B and B then A). Livelock: threads keep running but make no progress because they keep reacting to each other (two threads that each try-lock the other's mutex, fail, back off and retry in lockstep). Starvation: the system progresses but one thread never does (a writer waiting on a reader-preferring readers-writer lock while readers overlap forever).

Why. The difference is in what the CPU does. A deadlocked process uses no CPU; a livelocked one burns 100 percent; a starving one looks healthy except for one stuck request. Fixes differ too: deadlock needs ordering or all-at-once acquisition, livelock needs randomised back-off or ordering, starvation needs a fairness policy (FIFO tickets, writer preference, ageing of priorities).

TrickyExplain priority inversion and what went wrong on the Mars Pathfinder. Why can a semaphore-based lock not use the standard fix?

Answer

Answer. A high-priority task waits for a lock held by a low-priority task, and a medium-priority task that does not need the lock preempts the low one, so the high task effectively waits for the medium one. On Pathfinder (1997, VxWorks), the low-priority meteorological task held the information bus mutex, the high-priority bus manager blocked, a medium-priority communications task kept the low one off the CPU, and a watchdog reset the computer. The fix, uploaded from Earth, enabled priority inheritance on that mutex. A semaphore cannot do it because it has no owner to boost.

Why. Inversion itself (H waits briefly for L) is unavoidable with locks; the bug is that the wait becomes unbounded because of M. Priority inheritance raises L to H's priority while H waits, bounding H's delay to the length of L's critical section. Priority ceiling (every holder runs at the lock's ceiling priority) is the alternative. Both require knowing which thread holds the lock, which a mutex records and a counting semaphore does not. On Linux use PTHREAD_PRIO_INHERIT; std::mutex does not expose the option.

TrickyDoes using std::scoped_lock everywhere make a program deadlock-free?

Answer

Answer. No. It only prevents deadlock among the mutexes passed to the same scoped_lock. Nested acquisitions still deadlock: thread 1 holds scoped_lock(a) and then calls a function that takes scoped_lock(b), while thread 2 does the reverse.

Why. std::lock avoids the cycle by never waiting while holding part of the set it was given. It cannot know about locks you took earlier in another statement, so "hold a, then wait for b" in two separate statements is still hold and wait plus a possible cycle. Deadlocks also do not need mutexes: joining a thread while holding a lock it needs, waiting on a future whose producer needs your lock, or two threads waiting on each other's condition variables all deadlock. A global lock order or lock hierarchy is still needed for nested locking.

TrickyIs std::shared_mutex always better than std::mutex for a read-mostly map? Which side gets priority when readers and a writer contend?

Answer

Answer. Not always. For short critical sections a plain mutex is often as fast or faster, and the standard leaves the reader/writer priority policy unspecified, so a writer can starve on some implementations.

Why. Every lock_shared() performs an atomic read-modify-write on the reader count, so all readers on all cores write the same cache line and it bounces between them (Ch 5 covers why that costs about as much as the lock itself). The shared lock is also larger and has more complex code paths. It wins when readers hold the lock long enough that running them in parallel matters, for example during a lookup that walks a big structure. For extreme read-heavy cases, people use RCU, seqlocks or per-core reader counters instead. On priority: libstdc++ implements std::shared_mutex with glibc's pthread_rwlock_t, whose default is reader preference, so writers can starve there; libc++ blocks new readers once a writer waits. Code that needs a particular policy must not rely on either, and should use a lock that states its policy (coding drill readers-writer lock). The wrong answer is "writers always get priority because they are rarer".

ConceptWhat is a monitor, and what is the difference between Hoare and Mesa semantics?

Answer

Answer. A monitor is an object whose operations run under one mutual-exclusion lock and which uses condition variables to wait inside those operations. Under Hoare semantics a signal hands the monitor directly to the woken thread, so its condition is guaranteed to hold. Under Mesa semantics, used by POSIX, Java and C++, the signaller continues and the woken thread merely becomes runnable, so the condition must be re-checked in a while loop.

Why. Hoare semantics need an extra context switch on every signal and make the signaller wait, so practical systems chose Mesa semantics and pushed the cost onto the waiter as a re-check. In C++ a monitor is a class with a private mutex, private state and condition variables, where every public member function starts by locking the mutex. Java's synchronized methods with wait()/notifyAll() are the built-in version.

ConceptHow do you build a thread-safe lazily initialised singleton in C++11 and later? Where does std::call_once still help?

Answer

Answer. A function-local static: static T& get() { static T t; return t; }. Since C++11 the compiler guarantees that the initialisation runs once even under concurrent calls ([stmt.dcl]/4). std::call_once helps when the one-time action is not "construct this variable", for example initialising a data member of an object lazily or running a setup function, or when you need the once-flag to live in a particular object.

Why. The compiler implements magic statics with a guard variable checked with an acquire load on the fast path and a lock (__cxa_guard_acquire) on the slow path, which is the same as hand-written double-checked locking done correctly. Hand-written double-checked locking with a plain pointer is a data race and is undefined behaviour. In both mechanisms, if the initialiser throws, nothing is marked as done and the next caller retries. Recursively re-entering the same static's initialisation from its own constructor is undefined (in practice, a deadlock or an exception from the runtime).

Predict the output and find the bug

Drills

OutputWhat does this print? Follow the ownership of the lock through the move and the release().

#include <iostream>
#include <mutex>

int main() {
    std::mutex m;
    std::unique_lock a(m, std::defer_lock);
    std::cout << a.owns_lock() << '\n';
    a.lock();
    std::cout << a.owns_lock() << '\n';

    std::unique_lock b(std::move(a));
    std::cout << a.owns_lock() << b.owns_lock() << (a.mutex() == nullptr) << '\n';

    std::mutex* p = b.release();
    std::cout << b.owns_lock() << (p == &m) << '\n';
    p->unlock();

    m.lock();
    std::unique_lock c(m, std::adopt_lock);
    std::cout << c.owns_lock() << '\n';
}
Answer

Answer.

0
1
011
01
1

Why. defer_lock associates the mutex without locking it. Moving a unique_lock transfers both the mutex pointer and the ownership, leaving the source empty (mutex() == nullptr, not owning). release() disassociates the lock from the mutex without unlocking it, so the caller becomes responsible; forgetting the p->unlock() line would leave m locked and destroying a locked mutex is undefined. adopt_lock takes over a mutex the thread already locked; the destructor of c unlocks it. People often expect release() to unlock, like the name suggests; it behaves like unique_ptr::release(), which also gives up ownership without destroying anything.

Find the bugThe waiter uses a predicate, so the author believes this is safe. ThreadSanitizer disagrees. What is wrong, and what else can go wrong without the timed wait?

#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

static std::mutex m;
static std::condition_variable cv;
static bool ready = false;
static int data = 0;

int main() {
    std::thread waiter([] {
        std::unique_lock lk(m);
        if (cv.wait_for(lk, std::chrono::seconds(2), [] { return ready; }))
            std::cout << "got " << data << '\n';
    });
    data = 42;
    ready = true;          // written without holding m
    cv.notify_one();
    waiter.join();
}
Answer

Answer. ready and data are written by the main thread without the mutex while the waiter reads them under it. That is a data race (undefined behaviour), and it also opens a lost-wake-up window: if the store and the notify land after the waiter checked the predicate but before it went to sleep, a plain wait would sleep forever. The 2-second timeout only hides that hang.

Why. The mutex does two jobs: it makes the accesses to ready race-free, and it serialises "check predicate then sleep" against "set predicate then notify". Fix:

{
    std::lock_guard g(m);
    data = 42;
    ready = true;
}
cv.notify_one();

Making ready a std::atomic<bool> would remove the data race on ready but not the lost wake-up (and data would still need ordering). TSan reports the race on ready or data on every run, whichever interleaving happens, because there is no happens-before edge between the main thread's writes and the waiter's reads.

Find the bugThis function should increment a shared counter under a lock. Why does it not compile, and what did the author mean?

#include <mutex>

static std::mutex m;
static int counter = 0;

static void bump() {
    std::lock_guard<std::mutex>(m);
    ++counter;
}

int main() { bump(); }
Answer

Answer. std::lock_guard<std::mutex>(m); is a declaration, not an expression: it declares a local variable named m of type lock_guard<std::mutex>, shadowing the global mutex. lock_guard has no default constructor, so it fails to compile. The intent was std::lock_guard<std::mutex> g(m);, or in C++17 std::scoped_lock g(m);.

Why. C++ allows redundant parentheses around a declarator (int (x); declares x), and the rule "if it can be a declaration, it is one" decides the parse. This is a cousin of the most vexing parse. Compilation failure is the lucky outcome here; with a type that has a default constructor the line would compile and silently lock nothing. Naming every lock object avoids the whole family of bugs.

Find the bugThis one compiles cleanly and has a lock. Why does ThreadSanitizer still report a race on counter?

#include <iostream>
#include <mutex>
#include <thread>

static std::mutex m;
static int counter = 0;

static void work() {
    for (int i = 0; i < 10000; ++i) {
        std::scoped_lock{m};
        ++counter;
    }
}

int main() {
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::cout << (counter <= 20000) << '\n';
}
Answer

Answer. std::scoped_lock{m}; creates an unnamed temporary. A temporary is destroyed at the end of the full expression, so the mutex is locked and immediately unlocked at the semicolon, and ++counter runs without any lock.

Why. Unlike the parenthesised form in the previous drill, braces cannot form a declarator, so this is an expression statement creating a prvalue that is materialised and destroyed at once. The fix is a named object: std::scoped_lock lk{m};. Some compilers and clang-tidy (bugprone-unused-raii) warn about unused RAII temporaries; -Wall alone does not catch it. The program prints 1 but the count is often below 20000, and TSan reports the unsynchronised increments every run.

Find the bugTwo threads transfer money between the same two accounts. The program works in tests and hangs about once a week in production. Why, and what is the smallest fix?

#include <iostream>
#include <mutex>
#include <thread>

struct Account {
    std::mutex m;
    long balance = 1000;
};

static void transfer(Account& from, Account& to, long amount) {
    std::lock_guard lf(from.m);
    std::lock_guard lt(to.m);
    from.balance -= amount;
    to.balance += amount;
}

int main() {
    Account a, b;
    std::thread t1([&] { for (int i = 0; i < 100000; ++i) transfer(a, b, 1); });
    std::thread t2([&] { for (int i = 0; i < 100000; ++i) transfer(b, a, 1); });
    t1.join();
    t2.join();
    std::cout << a.balance + b.balance << '\n';
}
Answer

Answer. Lock-order inversion. t1 locks a.m then b.m; t2 locks b.m then a.m. If each takes its first lock before the other takes its second, both wait forever. The smallest fix is to lock both at once: std::scoped_lock lk(from.m, to.m);, plus a guard for &from == &to.

Why. All four Coffman conditions hold: exclusive mutexes, holding one while waiting for the other, no preemption, and a two-node cycle. scoped_lock uses std::lock's try-and-back-off, so it never waits while holding part of the set. An alternative is ordering by address with std::less<std::mutex*>. This snippet is compile-only because the bug is a hang. ThreadSanitizer on Linux reports it as "lock-order-inversion (potential deadlock)" even on runs that do not hang, because it records that both orders were used. The self-transfer guard matters because scoped_lock(a.m, a.m) locks the same non-recursive mutex twice, which is undefined.

OutputThe main thread holds a shared (read) lock. What does a second thread observe when it tries both kinds of lock, and what after the read lock is released?

#include <iostream>
#include <shared_mutex>
#include <thread>

static std::shared_mutex sm;

int main() {
    std::shared_lock reader(sm);
    std::thread t([] {
        bool shared = sm.try_lock_shared();
        bool exclusive = sm.try_lock();
        std::cout << shared << exclusive << '\n';
        if (shared) sm.unlock_shared();
        if (exclusive) sm.unlock();
    });
    t.join();

    reader.unlock();
    std::thread w([] {
        bool exclusive = sm.try_lock();
        std::cout << exclusive << '\n';
        if (exclusive) sm.unlock();
    });
    w.join();
}
Answer

Answer.

10
1

Why. Shared holds are compatible with each other, so try_lock_shared() succeeds while the main thread reads. An exclusive hold is incompatible with any shared hold, so try_lock() fails. After reader.unlock() nobody holds the mutex and the writer gets it. The threads print one after another because of the join() calls, so the output is deterministic. Two details: the test runs in a separate thread because calling any lock function on a shared_mutex the calling thread already holds (in either mode) is undefined, and the try_ functions may in theory fail spuriously, which real implementations do not do in an uncontended case like this.

OutputWhat does this print?

#include <iostream>
#include <semaphore>

int main() {
    std::counting_semaphore<4> s(3);
    for (int i = 0; i < 4; ++i)
        std::cout << s.try_acquire();
    std::cout << '\n';
    s.release(2);
    std::cout << s.try_acquire() << s.try_acquire() << s.try_acquire() << '\n';
    std::cout << (s.max() >= 4) << '\n';
}
Answer

Answer.

1110
110
1

Why. The semaphore starts with 3 permits, so three try_acquire() calls succeed and the fourth finds zero and returns false without blocking. release(2) adds two permits, which are taken by the next two calls. The template argument is a least maximum value: max() may be larger (implementations typically return the maximum of the counter type), so the only portable statement is max() >= 4. Note that the same thread acquired and released: semaphores have no owner, which is legal here and would be undefined with a mutex.

OutputWhat happens when you misuse a unique_lock, as opposed to misusing the mutex directly?

#include <iostream>
#include <mutex>
#include <system_error>

int main() {
    std::mutex m;
    std::unique_lock lk(m);
    try {
        lk.lock();
    } catch (const std::system_error& e) {
        std::cout << "relock: " << (e.code() == std::errc::resource_deadlock_would_occur) << '\n';
    }
    lk.unlock();
    try {
        lk.unlock();
    } catch (const std::system_error& e) {
        std::cout << "double unlock: " << (e.code() == std::errc::operation_not_permitted) << '\n';
    }
    std::cout << "owns: " << lk.owns_lock() << '\n';
}
Answer

Answer.

relock: 1
double unlock: 1
owns: 0

Why. unique_lock knows whether it owns its mutex, so the standard specifies errors for misuse: lock() while owning throws std::system_error with resource_deadlock_would_occur, and unlock() while not owning throws with operation_not_permitted. The same mistakes on a raw std::mutex (m.lock() twice, or m.unlock() when not owned) are undefined behaviour with no diagnostic; on Linux the first usually hangs forever. The checks live in the wrapper, not in the mutex.

OutputThe constructor of a function-local static fails twice before it succeeds. How many times does it run, and what is printed?

#include <iostream>
#include <stdexcept>

static int attempts = 0;

struct Connection {
    Connection() {
        ++attempts;
        if (attempts < 3)
            throw std::runtime_error("init failed");
        std::cout << "ready after " << attempts << " attempts\n";
    }
};

static Connection& connection() {
    static Connection c;     // thread-safe one-time initialisation
    return c;
}

int main() {
    for (int i = 0; i < 5; ++i) {
        try {
            connection();
        } catch (const std::exception& e) {
            std::cout << "caught: " << e.what() << '\n';
        }
    }
    std::cout << "attempts = " << attempts << '\n';
}
Answer

Answer.

caught: init failed
caught: init failed
ready after 3 attempts
attempts = 3

Why. If the initialisation of a block-scope static exits by an exception, the variable is not initialised, and the next time control passes through the declaration the initialisation is tried again ([stmt.dcl]/4). After the first success, calls 4 and 5 skip the constructor. std::call_once has the same rule: a callable that throws is an "exceptional execution", the once_flag stays unset, and the next caller runs it again. The wrong answer is "it runs once and later calls rethrow or do nothing", which confuses one-time initialisation with caching the result. (Older libstdc++ builds had bugs with throwing callables in call_once on some targets, GCC bug 66146, which is one more reason to prefer the static.)

OutputThree threads run four phases of work separated by a std::barrier. The completion function checks that every thread finished the current phase. What is printed?

#include <array>
#include <atomic>
#include <barrier>
#include <iostream>
#include <thread>
#include <vector>

int main() {
    constexpr int kThreads = 3, kPhases = 4;
    std::array<std::atomic<int>, kThreads> done{};
    std::atomic<int> phases = 0;
    std::atomic<bool> consistent = true;

    std::barrier sync(kThreads, [&]() noexcept {
        int p = ++phases;
        for (auto& d : done)
            if (d.load(std::memory_order_relaxed) != p)
                consistent = false;
    });

    std::vector<std::thread> pool;
    for (int id = 0; id < kThreads; ++id)
        pool.emplace_back([&, id] {
            for (int p = 0; p < kPhases; ++p) {
                ++done[id];             // this phase's work
                sync.arrive_and_wait();
            }
        });
    for (auto& t : pool) t.join();
    std::cout << "phases " << phases << ", consistent " << consistent << '\n';
}
Answer

Answer.

phases 4, consistent 1

Why. The completion function runs exactly once per phase, after the last of the three threads arrives and before any of them is released, on one of the arriving threads. Each thread's increment of done[id] happens before its arrival, and arrival happens before the completion function, so the function sees all three counters equal to the phase number. With plain int counters this would still be race-free by the standard. The counters are atomics only to keep ThreadSanitizer quiet: libc++ implements part of std::barrier inside its compiled library, which TSan does not instrument, so TSan cannot see the synchronisation and reports a false race on plain counters. Relaxed loads are enough because the barrier itself provides the ordering. The barrier then resets automatically, which is the difference from std::latch: a latch would open once and stay open. The completion function must be noexcept.

Find the bugA helper thread is meant to "hand the lock back" to the main thread when it finishes. What is wrong?

#include <iostream>
#include <mutex>
#include <thread>

static std::mutex m;

int main() {
    m.lock();
    std::thread helper([] {
        // ... finish some work ...
        m.unlock();        // unlock a mutex owned by main
    });
    helper.join();
    std::cout << "done\n";
}
Answer

Answer. A std::mutex may only be unlocked by the thread that owns it. Unlocking it from helper is undefined behaviour; on Linux, ThreadSanitizer reports "unlock of an unlocked mutex (or by a wrong thread)". The program may well print done, which proves nothing, so its output is not compared.

Why. Mutex ownership is part of the contract so that implementations can do priority inheritance, error checking and owner tracking. With a default glibc mutex the call may appear to work, with an error-checking mutex it returns EPERM, and with a PI futex it fails. If one thread must signal another, use a semaphore or a condition variable: std::binary_semaphore done{0};, done.release() in the helper and done.acquire() in main. This is exactly the mutex vs binary semaphore distinction.

OutputThe producer thread finishes, including its notify_one(), before the consumer ever calls wait. Does the consumer hang? What is printed?

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

static std::mutex m;
static std::condition_variable cv;
static bool ready = false;
static int value = 0;

int main() {
    std::thread producer([] {
        {
            std::lock_guard g(m);
            value = 7;
            ready = true;
        }
        cv.notify_one();      // nobody is waiting yet
    });
    producer.join();

    std::unique_lock lk(m);
    cv.wait(lk, [] { return ready; });
    std::cout << "got " << value << '\n';
}
Answer

Answer.

got 7

Why. The notification is lost (a condition variable stores nothing), but it does not matter: the predicate overload checks ready before sleeping, finds it true and returns at once. With cv.wait(lk); and no predicate, the consumer would sleep forever, which is the textbook lost wake-up. The state lives in ready, protected by m; the condition variable is only a way to sleep until that state might have changed.

Write the code

Coding

CodeThread-safe bounded blocking queue. Implement BoundedQueue<T> with a fixed capacity: bool push(T) blocks while full, std::optional<T> pop() blocks while empty, and close() wakes everyone. After close(), push returns false and pop keeps returning remaining items, then std::nullopt. Test with 3 producers and 2 consumers; the total must be exact.

Answer

Approach. A monitor: one mutex guards a std::deque and a closed_ flag, with two condition variables so producers and consumers never wake each other by mistake. Each wait uses a predicate that includes closed_, so close() plus notify_all on both releases every blocked thread. pop returns nullopt only when the queue is closed and empty, so no item is lost at shutdown.

#include <condition_variable>
#include <cstddef>
#include <deque>
#include <iostream>
#include <mutex>
#include <optional>
#include <thread>
#include <vector>

template <class T>
class BoundedQueue {
public:
    explicit BoundedQueue(std::size_t capacity) : cap_(capacity) {}

    bool push(T v) {
        std::unique_lock lk(m_);
        not_full_.wait(lk, [&] { return closed_ || q_.size() < cap_; });
        if (closed_) return false;
        q_.push_back(std::move(v));
        lk.unlock();
        not_empty_.notify_one();
        return true;
    }

    std::optional<T> pop() {
        std::unique_lock lk(m_);
        not_empty_.wait(lk, [&] { return closed_ || !q_.empty(); });
        if (q_.empty()) return std::nullopt;     // closed and drained
        T v = std::move(q_.front());
        q_.pop_front();
        lk.unlock();
        not_full_.notify_one();
        return v;
    }

    void close() {
        {
            std::lock_guard g(m_);
            closed_ = true;
        }
        not_full_.notify_all();
        not_empty_.notify_all();
    }

private:
    std::mutex m_;
    std::condition_variable not_full_, not_empty_;
    std::deque<T> q_;
    std::size_t cap_;
    bool closed_ = false;
};

int main() {
    constexpr int kProducers = 3, kPerProducer = 1000, kConsumers = 2;
    BoundedQueue<int> q(4);
    std::vector<long long> sums(kConsumers, 0);
    std::vector<std::thread> producers, consumers;

    for (int p = 0; p < kProducers; ++p)
        producers.emplace_back([&q, p] {
            for (int i = 1; i <= kPerProducer; ++i) q.push(p * kPerProducer + i);
        });
    for (int c = 0; c < kConsumers; ++c)
        consumers.emplace_back([&q, &sums, c] {
            while (auto v = q.pop()) sums[c] += *v;
        });

    for (auto& t : producers) t.join();
    q.close();
    for (auto& t : consumers) t.join();
    std::cout << "total " << sums[0] + sums[1] << '\n';
    std::cout << "push after close: " << q.push(1) << '\n';
}
total 4501500
push after close: 0

Complexity and pitfalls. O(1) per operation plus the lock. Pitfalls: waiting with if instead of a predicate; one condition variable with notify_one (wakes the wrong side, see the concept drill); returning nullopt as soon as closed_ is set, which drops queued items; notifying only one consumer in close(); and returning a reference to the front element, which another thread may pop. Each consumer has its own slot in sums, so they never write the same variable. The total is 1 + 2 + ... + 3000 = 4501500.

Estimated rating: ~1600 · standard monitor, but shutdown semantics and two condition variables trip many candidates

CodeWriter-preferring readers-writer lock. Implement RWLock with lock()/unlock() for writers and lock_shared()/unlock_shared() for readers, using only std::mutex and std::condition_variable. Once a writer is waiting, new readers must wait. It must work with std::unique_lock and std::shared_lock. Test that readers never see a half-done update.

Answer

Approach. Keep three fields under an internal mutex: the number of active readers, whether a writer is active, and the number of waiting writers. Readers wait for "no active writer and no waiting writer", which gives writers priority. Writers announce themselves by incrementing waiting_writers_, then wait for "no writer and no readers". Two condition variables keep wake-ups targeted: the last reader out wakes one writer, and a writer leaving wakes the next writer if there is one, otherwise all readers.

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <vector>

class RWLock {
public:
    void lock_shared() {
        std::unique_lock lk(m_);
        readers_cv_.wait(lk, [&] { return !writer_ && waiting_writers_ == 0; });
        ++readers_;
    }
    void unlock_shared() {
        std::lock_guard g(m_);
        if (--readers_ == 0) writers_cv_.notify_one();
    }
    void lock() {
        std::unique_lock lk(m_);
        ++waiting_writers_;
        writers_cv_.wait(lk, [&] { return !writer_ && readers_ == 0; });
        --waiting_writers_;
        writer_ = true;
    }
    void unlock() {
        std::lock_guard g(m_);
        writer_ = false;
        if (waiting_writers_ > 0) writers_cv_.notify_one();
        else readers_cv_.notify_all();
    }

private:
    std::mutex m_;
    std::condition_variable readers_cv_, writers_cv_;
    int readers_ = 0;
    int waiting_writers_ = 0;
    bool writer_ = false;
};

int main() {
    RWLock rw;
    long a = 0, b = 0;                  // invariant: a == b
    std::vector<int> torn(4, 0);
    std::vector<std::thread> threads;

    for (int r = 0; r < 4; ++r)
        threads.emplace_back([&, r] {
            for (int i = 0; i < 5000; ++i) {
                std::shared_lock lk(rw);
                if (a != b) ++torn[r];
            }
        });
    for (int w = 0; w < 2; ++w)
        threads.emplace_back([&] {
            for (int i = 0; i < 2000; ++i) {
                std::unique_lock lk(rw);
                ++a;
                ++b;
            }
        });
    for (auto& t : threads) t.join();
    std::cout << "a=" << a << " b=" << b << " torn reads=" << torn[0] + torn[1] + torn[2] + torn[3] << '\n';
}
a=4000 b=4000 torn reads=0

Complexity and pitfalls. Every operation takes the internal mutex once, so this lock is slower than std::shared_mutex under heavy contention; it exists to make the policy explicit. Pitfalls: forgetting to count waiting writers (that gives reader preference and writer starvation); notifying readers while writers are still waiting (they wake and sleep again, wasting time); using notify_one for readers, which lets only one reader in after a write. Writer preference can starve readers if writes never stop; a fair lock uses a ticket or a turnstile instead. Because the class has the right member names, it satisfies Lockable and SharedLockable, so the standard wrappers work.

Estimated rating: ~1800 · the state machine is small, but the preference policy and the notification targets must both be right

CodePrint in order. Three threads cooperate to print the numbers 1 to 9 in order on one line: thread 0 prints 1, 4, 7, thread 1 prints 2, 5, 8 and thread 2 prints 3, 6, 9. Use one mutex and one condition variable, no sleeping or spinning. The output must be identical on every run.

Answer

Approach. A shared next number says whose turn it is: thread id may print when (next - 1) % 3 == id. Each thread waits for its turn, prints, advances next and notifies. Because three threads wait for three different predicates on one condition variable, notify_all is required; notify_one could wake the wrong thread and stall everyone. Printing happens under the mutex, so the order is fixed.

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class InOrder {
public:
    InOrder(int threads, int last) : threads_(threads), last_(last) {}

    void run(int id) {
        for (;;) {
            std::unique_lock lk(m_);
            cv_.wait(lk, [&] { return next_ > last_ || (next_ - 1) % threads_ == id; });
            if (next_ > last_) return;
            std::cout << next_ << (next_ == last_ ? '\n' : ' ');
            ++next_;
            lk.unlock();
            cv_.notify_all();
        }
    }

private:
    std::mutex m_;
    std::condition_variable cv_;
    int next_ = 1;
    const int threads_, last_;
};

int main() {
    InOrder p(3, 9);
    std::vector<std::thread> ts;
    for (int id = 0; id < 3; ++id) ts.emplace_back([&p, id] { p.run(id); });
    for (auto& t : ts) t.join();
}
1 2 3 4 5 6 7 8 9

Complexity and pitfalls. Each number costs one wake-up of every waiting thread (the "thundering herd" of notify_all), which is fine for 3 threads; with many threads give each its own condition variable (or binary_semaphore) and notify only the next one. Pitfalls: the termination condition must be in the predicate, or the threads whose turn never comes again sleep forever; printing after unlock() would reorder output; and a thread must not hold the lock while waiting for nothing. The two-thread version (ping-pong, LeetCode 1115 "Print FooBar Alternately") is the same code with threads = 2.

Estimated rating: ~1400 · one predicate and notify_all, but the shutdown condition and the notify_one trap are classic

CodeDining philosophers without deadlock. Five philosophers share five forks (one std::mutex each); philosopher i needs forks i and (i + 1) % 5. Each must eat 1000 times. Write it so that it cannot deadlock and print how many meals each philosopher had.

Answer

Approach. Break circular wait with resource ordering: every philosopher locks the lower-numbered fork first. Philosopher 4 therefore reaches for fork 0 before fork 4, so the ring of "holds left, waits for right" cannot close. Locks are RAII objects so a fork is never left held. An equivalent one-line solution is std::scoped_lock lk(fork[a], fork[b]);, which breaks hold and wait instead.

#include <algorithm>
#include <array>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

int main() {
    constexpr int kN = 5, kMeals = 1000;
    std::array<std::mutex, kN> fork;
    std::array<int, kN> meals{};
    long long shared_plate = 0;      // shared by all, guarded by plate_m (always locked last)
    std::mutex plate_m;

    std::vector<std::thread> table;
    for (int i = 0; i < kN; ++i)
        table.emplace_back([&, i] {
            int first = std::min(i, (i + 1) % kN);
            int second = std::max(i, (i + 1) % kN);
            for (int k = 0; k < kMeals; ++k) {
                std::lock_guard a(fork[first]);
                std::lock_guard b(fork[second]);
                ++meals[i];                       // own counter
                std::lock_guard p(plate_m);
                ++shared_plate;
            }
        });
    for (auto& t : table) t.join();

    for (int i = 0; i < kN; ++i) std::cout << "P" << i << " ate " << meals[i] << '\n';
    std::cout << "total " << shared_plate << '\n';
}
P0 ate 1000
P1 ate 1000
P2 ate 1000
P3 ate 1000
P4 ate 1000
total 5000

Complexity and pitfalls. Neighbours exclude each other, so at most two of five eat at once. Pitfalls: "pick up left, try_lock right, put left down and retry" is deadlock-free but can livelock if everyone retries in lockstep (add random back-off); a global "table" mutex around both picks is correct but serialises everything; and nothing here is fair, since a fast philosopher may eat many times in a row (it still finishes because each loop is bounded). The waiter solution, a std::counting_semaphore<4> that admits at most four philosophers, also breaks the cycle.

Estimated rating: ~1500 · the ordering trick is short once you can name the Coffman condition it breaks

CodeCounting semaphore from a mutex and a condition variable. Implement Semaphore with acquire(), try_acquire() and release(n = 1), plus an RAII Permit guard. Use it to cap a pool of 8 workers at 3 concurrent tasks and verify that the cap held.

Answer

Approach. The semaphore is a counter in a monitor: acquire waits for count_ > 0 and decrements, release(n) adds n and wakes waiters (one if n == 1, all otherwise, since several may now proceed). A Permit guard releases in its destructor so an exception cannot leak a permit. The test tracks the number of active workers and the maximum seen with atomics.

#include <atomic>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class Semaphore {
public:
    explicit Semaphore(int initial) : count_(initial) {}
    void acquire() {
        std::unique_lock lk(m_);
        cv_.wait(lk, [&] { return count_ > 0; });
        --count_;
    }
    bool try_acquire() {
        std::lock_guard g(m_);
        if (count_ == 0) return false;
        --count_;
        return true;
    }
    void release(int n = 1) {
        {
            std::lock_guard g(m_);
            count_ += n;
        }
        if (n == 1) cv_.notify_one();
        else cv_.notify_all();
    }

private:
    std::mutex m_;
    std::condition_variable cv_;
    int count_;
};

class Permit {
public:
    explicit Permit(Semaphore& s) : s_(s) { s_.acquire(); }
    ~Permit() { s_.release(); }
    Permit(const Permit&) = delete;
    Permit& operator=(const Permit&) = delete;
private:
    Semaphore& s_;
};

int main() {
    Semaphore slots(3);
    std::atomic<int> active = 0, peak = 0, finished = 0;

    std::vector<std::thread> workers;
    for (int w = 0; w < 8; ++w)
        workers.emplace_back([&] {
            for (int job = 0; job < 200; ++job) {
                Permit p(slots);
                int now = ++active;
                int seen = peak.load();
                while (now > seen && !peak.compare_exchange_weak(seen, now)) {}
                std::this_thread::yield();
                --active;
                ++finished;
            }
        });
    for (auto& t : workers) t.join();

    std::cout << "finished " << finished << '\n';
    std::cout << "peak within cap: " << (peak <= 3) << '\n';
    std::cout << "permits left: " << slots.try_acquire() << slots.try_acquire()
              << slots.try_acquire() << slots.try_acquire() << '\n';
}
finished 1600
peak within cap: 1
permits left: 1110

Complexity and pitfalls. O(1) per call plus the mutex. The peak itself is timing-dependent, so the test prints only whether it stayed within the cap, never the value. Pitfalls: changing count_ outside the mutex (lost wake-ups again); notify_one after release(3), which leaves two runnable waiters asleep; and no RAII guard, so an exception leaks a permit and the pool slowly shrinks to zero. The last line shows the semaphore is back to its 3 permits after all workers finished.

Estimated rating: ~1300 · a direct monitor, the RAII guard and release(n) notify choice are the details

CodeReusable barrier. Implement Barrier(n) with arrive_and_wait(): the first n - 1 callers block, the n-th releases them all, and the barrier is immediately ready for the next round. Do not use std::barrier. Prove it works across several phases with 4 threads.

Answer

Approach. A counter of arrivals plus a generation number. Each caller remembers the generation it arrived in; the last arrival resets the counter, increments the generation and notifies all. Waiters wait for "generation changed", not for "counter is zero", because a fast thread may already have arrived for the next phase and incremented the counter again before slow threads woke up. The test uses two barrier waits per phase: one after writing, one after reading, so writes of phase k+1 cannot overlap reads of phase k.

#include <atomic>
#include <condition_variable>
#include <cstddef>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class Barrier {
public:
    explicit Barrier(int n) : n_(n) {}

    void arrive_and_wait() {
        std::unique_lock lk(m_);
        std::size_t gen = generation_;
        if (++arrived_ == n_) {
            arrived_ = 0;
            ++generation_;
            lk.unlock();
            cv_.notify_all();
            return;
        }
        cv_.wait(lk, [&] { return generation_ != gen; });
    }

    std::size_t generation() {
        std::lock_guard g(m_);
        return generation_;
    }

private:
    std::mutex m_;
    std::condition_variable cv_;
    const int n_;
    int arrived_ = 0;
    std::size_t generation_ = 0;
};

int main() {
    constexpr int kThreads = 4, kPhases = 5;
    Barrier barrier(kThreads);
    std::vector<int> slot(kThreads, 0);
    std::atomic<int> errors = 0;

    std::vector<std::thread> ts;
    for (int id = 0; id < kThreads; ++id)
        ts.emplace_back([&, id] {
            for (int p = 1; p <= kPhases; ++p) {
                slot[id] = p;                          // write phase
                barrier.arrive_and_wait();
                for (int v : slot)                     // read phase
                    if (v != p) ++errors;
                barrier.arrive_and_wait();
            }
        });
    for (auto& t : ts) t.join();
    std::cout << "errors " << errors << ", generations " << barrier.generation() << '\n';
}
errors 0, generations 10

Complexity and pitfalls. Each phase costs n lock acquisitions and one broadcast. The generation is the whole trick: with a predicate like arrived_ == 0, a thread that wakes late may see the counter already at 1 for the next phase and sleep forever. Pitfalls: forgetting to reset arrived_; using notify_one; and testing with a single wait per phase, which lets a fast thread overwrite its slot while others are still reading (TSan would report it). The barrier synchronises through the mutex, so the plain int slots are race-free.

Estimated rating: ~1700 · the generation counter is the insight most first attempts miss

Cheat sheet

Theory
  • Default to std::mutex plus std::scoped_lock; use unique_lock only for condition variables, early unlock, timeouts or moving the lock. Always name the lock object.
  • std::mutex: re-locking by the owner, unlocking by a non-owner and destroying while locked are all undefined. try_lock may fail spuriously. recursive_mutex is a design smell; shared_mutex helps only when readers hold the lock for a while, and its reader/writer priority is unspecified.
  • Condition variable recipe: change state under the mutex, then notify; wait with cv.wait(lk, pred), which is while (!pred()) wait(lk);. This handles spurious wake-ups, stolen wake-ups and notifies that came before the wait. An atomic flag set without the mutex still loses wake-ups.
  • notify_one only when all waiters wait for the same predicate; otherwise notify_all or one condition variable per predicate. Notify after unlocking for speed, under the lock if the waiter may destroy the condition variable.
  • Semaphores count permits and have no owner: right for signalling and limiting concurrency, wrong for protecting data (no priority inheritance, anyone can release). C++20: counting_semaphore, binary_semaphore, latch (single use), barrier (reusable, completion function per phase).
  • Deadlock needs mutual exclusion, hold and wait, no preemption and circular wait. Break circular wait with a global lock order or hierarchy, or hold and wait with std::scoped_lock(a, b). Never call unknown code or join a thread while holding a lock.
  • Livelock burns CPU without progress (fix with random back-off); starvation is one thread never progressing (fix with fairness). Priority inversion (Mars Pathfinder 1997) is fixed by priority inheritance or ceilings, which need an owner.
  • Classics: bounded buffer = mutex + not_full + not_empty + closed flag; readers-writers = count readers, count waiting writers for writer preference; dining philosophers = lock forks in number order; monitors use Mesa semantics, hence the while. One-time init: function-local static or std::call_once, both retry after an exception.

Part D · Networking basics · Chapter 7

Network layers and addressing

How a packet is wrapped, addressed and delivered: the OSI and TCP/IP layer models, encapsulation, MAC and IP addresses, ARP, subnetting, IPv6, routing, NAT, DNS, DHCP and ICMP. Interviewers for backend, systems and low-latency roles use these questions to check that you can reason about what actually happens on the wire, and subnet arithmetic is a favourite whiteboard warm-up.

TheoryDrillsCoding· about 4 hours· prerequisites: binary and hexadecimal, bitwise operators in C++

Layer models: OSI and TCP/IP

Theory

Mental model. A network stack is a pile of services. Each layer solves one problem, uses only the layer below it, and offers a service to the layer above. The link layer moves a frame to the next box on the same wire. The network layer moves a packet across many links to the right machine. The transport layer moves bytes or datagrams to the right process on that machine. The application layer decides what the bytes mean.

The OSI seven layers

The OSI reference model (ISO/IEC 7498-1) has seven layers. Interviewers expect you to recite them, give one protocol per layer and know which device works where. A mnemonic from the bottom up: "Please Do Not Throw Sausage Pizza Away" (Physical, Data link, Network, Transport, Session, Presentation, Application).

#OSI layerJobPDUExamplesDevice
7ApplicationMeaning of the data for the programmessage / dataHTTP, DNS, SMTP, SSHproxy, load balancer (L7)
6PresentationEncoding, compression, encryptiondataTLS (often placed here), JPEG, ASN.1(software only)
5SessionDialogue setup, checkpoints, resumedataRPC sessions, NetBIOS(software only)
4TransportProcess to process delivery, ports, reliabilitysegment (TCP), datagram (UDP)TCP, UDP, QUICL4 load balancer, stateful firewall
3NetworkHost to host delivery across networks, routingpacketIPv4, IPv6, ICMProuter, L3 switch
2Data linkDelivery to the next node on one link, MAC addressing, error detectionframeEthernet, Wi-Fi (802.11), ARP (sits between 2 and 3)switch, bridge, NIC
1PhysicalBits as voltages, light or radiobitcopper, fibre, radiohub, repeater, cable

The TCP/IP four layers

The Internet was built on a simpler model. RFC 1122 names four layers: link, internet, transport and application. OSI 5, 6 and 7 fold into the application layer (TLS, for example, is just a library the application calls), and OSI 1 and 2 fold into the link layer. Many textbooks use a five-layer hybrid that keeps physical and data link separate. If an interviewer says "layer 4" or "layer 7" they are using OSI numbers, even when talking about TCP/IP.

TCP/IP layer (RFC 1122)OSI layers it coversAddressing at this layer
Application5, 6, 7names and URLs (www.example.com)
Transport4port numbers (16 bit)
Internet3IP addresses (32 or 128 bit)
Link1, 2MAC addresses (48 bit)

Hub, switch and router

  • Hub (layer 1). Repeats every incoming bit out of every other port. All ports share one collision domain, so only one station can talk at a time. Hubs are obsolete, but they remain a standard interview contrast.
  • Switch (layer 2). Learns which MAC address lives behind which port by reading the source MAC of incoming frames (the MAC address table, or CAM table). It forwards a frame only out of the port where the destination MAC lives. Unknown destinations and broadcasts (ff:ff:ff:ff:ff:ff) are flooded out of every port. Each port is its own collision domain, but the whole switch is one broadcast domain unless you split it into VLANs (802.1Q).
  • Router (layer 3). Connects different IP networks. It reads the destination IP, looks it up in its routing table (longest-prefix match), decrements the TTL, and sends the packet out of the chosen interface inside a new link-layer frame. Routers do not forward broadcasts, so each router interface bounds a broadcast domain.

Interview trap

"A switch works on IP addresses" is wrong for a plain switch: it never looks above the Ethernet header. A "layer 3 switch" is a switch with routing hardware, so it is a router in function. Similarly, a home "router" is really a router, a switch, a Wi-Fi access point, a NAT box, a DHCP server and a DNS forwarder in one case.

Encapsulation and decapsulation

Theory

Mental model. Sending is putting a letter in an envelope, then that envelope in a bigger envelope, once per layer. Each layer prepends its own header (and the link layer also appends a trailer) and treats everything above as opaque payload. Receiving reverses this: each layer checks and strips its own header, then uses a field in it to pick the next layer up.

Applicationmessage Transportsegment Internetpacket Linkframe send: each layer adds a header HTTP request bytes TCP 20 B payload IPv4 20 B TCP payload Ethernet 14 B IPv4 TCP payload (MSS ≤ 1460) FCS 4 IP MTU 1500 = 20 (IP) + 20 (TCP) + 1460 (payload)
Every layer wraps the one above. The TCP header turns application bytes into a segment, the IP header turns the segment into a packet, and the Ethernet header plus the frame check sequence turn the packet into a frame. The receiver strips them in reverse order.

How the receiver picks the next layer

Each header carries a "what is inside me" field, which is how decapsulation knows which code to run next:

  • Ethernet EtherType: 0x0800 IPv4, 0x86DD IPv6, 0x0806 ARP.
  • IPv4 Protocol (IPv6 Next Header): 1 ICMP, 6 TCP, 17 UDP, 58 ICMPv6.
  • TCP or UDP destination port: which socket gets the payload (80 HTTP, 443 HTTPS, 53 DNS, 22 SSH).

A server socket is found by the destination port (and address). An established TCP connection is identified by the full 5-tuple: protocol, source IP, source port, destination IP, destination port. That is why one server port 443 can serve millions of clients at once.

MTU, MSS and fragmentation

The MTU is the largest IP packet a link carries: 1500 bytes on standard Ethernet (jumbo frames go to about 9000). The Ethernet frame on the wire is bigger (14 header + 4 FCS, plus preamble and inter-frame gap). TCP announces an MSS (maximum segment size) in its SYN, normally MTU minus 40 for IPv4, so 1460. If an IPv4 packet is larger than the next link's MTU, a router may fragment it, unless the Don't Fragment (DF) bit is set, in which case it drops it and sends ICMP "fragmentation needed" (type 3, code 4). Path MTU discovery (RFC 1191) relies on that ICMP message, so firewalls that block all ICMP cause mysterious hangs on large transfers. IPv6 routers never fragment; only the sender can (RFC 8200), and every IPv6 link must carry at least 1280 bytes.

MAC addresses, IP addresses and ARP

Theory

Mental model. The IP address says where the packet is ultimately going; the MAC address says which box on this wire picks it up next. The IP destination stays the same end to end. The MAC destination changes at every router.

MAC addressIPv4 address
LayerLink (2)Network (3)
Size48 bits, written 3c:22:fb:0a:11:7e32 bits, written 192.168.1.10
Assigned byManufacturer (first 24 bits are the OUI), or randomised by the OS for privacyNetwork administrator or DHCP; depends on where the host is
StructureFlat: no routing informationHierarchical: network prefix + host part
ScopeOne link (one broadcast domain)Global (or private behind NAT)
Broadcastff:ff:ff:ff:ff:ff255.255.255.255 (limited) or the subnet broadcast

Two bits in the first octet of a MAC address carry meaning: the least significant bit is the I/G bit (1 means multicast or broadcast), and the next bit is the U/L bit (1 means locally administered, which is what randomised Wi-Fi MACs use).

ARP: from IP to MAC

To send an IPv4 packet on Ethernet the host needs the MAC of the next hop. ARP (RFC 826) finds it:

  1. The host decides the next hop. If the destination is inside its own subnet (destination AND mask equals own network), the next hop is the destination itself. Otherwise it is the default gateway.
  2. It checks its ARP cache (ip neigh on Linux). On a miss it broadcasts an ARP request to ff:ff:ff:ff:ff:ff: "who has 192.168.1.1? tell 192.168.1.10" (opcode 1).
  3. The owner answers with a unicast ARP reply (opcode 2) carrying its MAC. Both sides cache the mapping for a while (Linux keeps entries reachable for tens of seconds, then revalidates).
  4. The queued packet goes out with that MAC as the frame destination.

A gratuitous ARP is an unsolicited announcement of your own mapping, sent at boot or after a failover so that neighbours update their caches. ARP has no authentication, which is why ARP spoofing (answering for someone else's IP) enables man-in-the-middle attacks on a LAN. IPv6 replaces ARP with Neighbor Discovery (NDP, RFC 4861), which uses ICMPv6 and multicast instead of broadcast.

Host A10.0.1.10 switch Routereth0 10.0.1.1 (R1)eth1 10.0.2.1 (R2) Host B10.0.2.20 LAN 1: 10.0.1.0/24 LAN 2: 10.0.2.0/24 Frame on LAN 1 dst MAC = R1 (router eth0) src MAC = A IP 10.0.1.10 → 10.0.2.20, TTL 64 Frame on LAN 2 dst MAC = B src MAC = R2 (router eth1) IP 10.0.1.10 → 10.0.2.20, TTL 63 The router strips frame 1, decrements TTL, fixes the IP checksum, and builds frame 2.
Link addresses are per hop, IP addresses are end to end. Host A ARPs for the router's MAC (not for B, which is on another subnet), and the router ARPs for B on LAN 2. The IP source and destination never change on the way, unless a NAT device rewrites them.

IPv4 addressing, CIDR and subnetting

Theory

Mental model. An IPv4 address is one 32-bit number. A prefix length /n splits it: the top n bits name the network, the remaining 32 − n bits name a host inside it. Every subnetting question is bit masking on that 32-bit number.

CIDR notation and masks

Classful addressing (class A /8, B /16, C /24) was replaced by CIDR in 1993 (now RFC 4632). 192.168.10.77/26 means the first 26 bits are the network. The subnet mask is those 26 one-bits followed by zeros: 255.255.255.192. With address a, prefix n and mask m:

  • mask: m = ¬(232−n − 1), written in C++ as n == 0 ? 0 : 0xFFFFFFFFu << (32 - n) (shifting a 32-bit value by 32 is undefined).
  • network address: a & m (all host bits 0).
  • broadcast address: a | ~m (all host bits 1).
  • usable hosts: 232−n − 2, because the network and broadcast addresses are reserved. Exceptions: a /31 has 2 usable addresses on point-to-point links (RFC 3021) and a /32 is a single host route.
  • two hosts are on the same subnet when (a & m) == (b & m).

The fast mental method is the block size: in the octet where the mask stops being 255, the block size is 256 minus that mask octet. Networks start at multiples of the block size.

network part: 26 bits host: 6 Address192.168.10.77 11000000101010000000101001001101 Mask255.255.255.192 11111111111111111111111111000000 Network192.168.10.64 11000000101010000000101001000000 Broadcast192.168.10.127 11000000101010000000101001111111 Usable hosts = 2⁶ − 2 = 62, from 192.168.10.65 to 192.168.10.126. Block size in the last octet = 256 − 192 = 64.
Subnetting is masking. AND with the mask clears the host bits to give the network address; OR with the inverted mask sets them to give the broadcast address. Everything strictly between the two is assignable.

Worked subnetting examples

Example 1: 192.168.10.77/26. Mask 255.255.255.192, block size 64 in the last octet. 77 lies in the block 64 to 127. Network 192.168.10.64, broadcast 192.168.10.127, hosts .65 to .126, 62 usable.

Example 2: 10.20.30.40/19. 19 bits = 8 + 8 + 3, so the mask is 255.255.224.0 and the block size in the third octet is 256 − 224 = 32. 30 lies in 0 to 31. Network 10.20.0.0, broadcast 10.20.31.255, first host 10.20.0.1, last host 10.20.31.254, usable 213 − 2 = 8190.

Example 3: same subnet? Are 10.1.1.100/25 and 10.1.1.200/25 neighbours? The /25 block size is 128. 100 is in 0 to 127 (network 10.1.1.0), 200 is in 128 to 255 (network 10.1.1.128). Different subnets, so traffic between them goes through a router even though the first three octets match.

Example 4: VLSM. Carve 172.16.0.0/22 (1024 addresses) for LANs of 200, 100 and 50 hosts plus one point-to-point link. Allocate largest first so each block stays aligned:

NeedSmallest prefixSubnetUsable range
200 hosts/24 (254)172.16.0.0/24172.16.0.1 to 172.16.0.254
100 hosts/25 (126)172.16.1.0/25172.16.1.1 to 172.16.1.126
50 hosts/26 (62)172.16.1.128/26172.16.1.129 to 172.16.1.190
2 (router link)/30 (2), or /31 per RFC 3021172.16.1.192/30172.16.1.193 to 172.16.1.194

172.16.1.196 to 172.16.1.255 and all of 172.16.2.0/23 remain free. The smallest prefix for h hosts is the largest n with 232−n − 2 ≥ h.

Example 5: supernetting. 192.168.0.0/24 through 192.168.3.0/24 share their first 22 bits, so a router can advertise one summary route 192.168.0.0/22. Summarising only works when the block is aligned: 192.168.1.0/24 to 192.168.4.0/24 cannot be one prefix.

Special address ranges (key facts)

RangeMeaningSource
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16Private, not routed on the public Internet. 172.16.0.0/12 is 172.16.0.0 to 172.31.255.255.RFC 1918
127.0.0.0/8Loopback. Every address in it, not just 127.0.0.1, stays inside the host.RFC 1122, RFC 6890
169.254.0.0/16Link-local, self-assigned when DHCP fails ("APIPA"); also cloud metadata at 169.254.169.254RFC 3927
100.64.0.0/10Shared space for carrier-grade NATRFC 6598
0.0.0.0"This host, unspecified": source address before DHCP; as a bind address it means all interfaces (INADDR_ANY)RFC 1122
255.255.255.255Limited broadcast, never forwarded by routersRFC 919
224.0.0.0/4Multicast (224.0.0.0 to 239.255.255.255)RFC 5771
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24Documentation examplesRFC 5737

Interview trap

172.32.0.1 is a public address. The private block is a /12, which covers second octets 16 to 31 only. Candidates who remember "172.something is private" get this wrong.

Know this cold

Prefix to mask: /8 = 255.0.0.0, /16 = 255.255.0.0, /20 = 255.255.240.0, /22 = 255.255.252.0, /24 = 255.255.255.0, /25 = .128, /26 = .192, /27 = .224, /28 = .240, /29 = .248, /30 = .252. Usable hosts: /24 254, /25 126, /26 62, /27 30, /28 14, /29 6, /30 2.

IPv6 basics

Theory

IPv6 (RFC 8200, addressing in RFC 4291) uses 128-bit addresses, written as eight groups of 16 bits in hexadecimal: 2001:0db8:0000:0000:0000:ff00:0042:8329. The canonical text form (RFC 5952) drops leading zeros in each group, uses lowercase, and replaces the longest run of two or more all-zero groups with ::, once: 2001:db8::ff00:42:8329. Using :: twice would be ambiguous, so it is illegal.

FeatureIPv4IPv6
Address size32 bits128 bits
Header20 to 60 bytes, has a checksumfixed 40 bytes, no header checksum, options in extension headers
Hop counterTTLHop Limit
Fragmentationby sender or routersby the sender only; minimum link MTU 1280
Address resolutionARP (broadcast)Neighbor Discovery over ICMPv6 (multicast)
Broadcastyesnone; multicast such as ff02::1 (all nodes) instead
Configurationstatic or DHCPSLAAC (RFC 4862), DHCPv6, or static
Loopback127.0.0.0/8::1 (a single address)
Private-style spaceRFC 1918 + NATunique local fc00::/7 (fd00::/8 in practice); global addresses usually make NAT unnecessary

Other prefixes to recognise: fe80::/10 link-local (every interface has one, valid only on that link, so you need a zone id like fe80::1%eth0), 2000::/3 global unicast, ff00::/8 multicast, ::/128 unspecified, and ::ffff:0:0/96 IPv4-mapped addresses (::ffff:192.0.2.1), which a dual-stack socket uses to show IPv4 peers. A typical LAN is a /64: 64 bits of routing prefix and 64 bits of interface identifier, which SLAAC fills from a random or stable value.

Routing, the default gateway and TTL

Theory

Mental model. Each router only knows the next step. It holds a table of prefixes and, for each one, the next hop and the outgoing interface. Packets travel hop by hop; no router knows the full path.

Longest-prefix match

For a destination address, the router considers every route whose prefix contains that address and picks the one with the longest prefix, because it is the most specific. With routes 10.0.0.0/8 via A, 10.1.0.0/16 via B and 0.0.0.0/0 via C, a packet to 10.1.2.3 goes to B, a packet to 10.9.9.9 goes to A, and a packet to 8.8.8.8 goes to C. Hardware routers implement this with tries or TCAMs; the Linux kernel uses an LC-trie.

$ ip route
default via 192.168.1.1 dev wlan0 proto dhcp metric 600
192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.23 metric 600
$ ip route get 8.8.8.8
8.8.8.8 via 192.168.1.1 dev wlan0 src 192.168.1.23

The default route 0.0.0.0/0 matches every address with prefix length 0, so it only wins when nothing more specific does. Its next hop is the default gateway. A host with a directly connected route ("scope link") sends straight to the destination's MAC; for anything else it sends the frame to the gateway's MAC. Routing tables are filled statically or by routing protocols: OSPF and IS-IS inside an organisation, BGP between organisations on the Internet.

TTL and hop limit

Every router decrements the IPv4 TTL (IPv6 Hop Limit) by one. If it reaches 0, the router drops the packet and sends ICMP Time Exceeded (type 11) back to the source. TTL exists to kill packets caught in routing loops; despite its name it counts hops, not seconds. Linux sends with TTL 64 by default (net.ipv4.ip_default_ttl), Windows with 128, so a received TTL of 57 suggests a Linux sender 7 hops away. Because the TTL changes, every IPv4 router must also recompute the header checksum, which is one reason IPv6 dropped it.

NAT and port mapping

Theory

Mental model. A NAT router sits between private addresses and one (or a few) public addresses, rewriting packets on the way through and remembering how to rewrite the replies. NAT exists because IPv4 ran out of addresses.

The common form is NAPT (network address and port translation, RFC 3022), also called PAT or "masquerading". On an outgoing packet the router replaces the private source address and port with its public address and a free public port, and stores the mapping. Replies to that public port are translated back:

Inside (private) socketOutside (public) socketRemote peerProtocol
192.168.1.23:51000203.0.113.5:40001198.51.100.7:443TCP
192.168.1.42:51000203.0.113.5:40002198.51.100.7:443TCP
192.168.1.23:5353203.0.113.5:40003192.0.2.53:53UDP

Two inside hosts can use the same private port because the router gives them different public ports. The router also recalculates IP and TCP/UDP checksums, since both cover the addresses. Mappings time out: UDP mappings after an idle period (RFC 4787 asks for at least 2 minutes, but real home routers often use 30 to 60 seconds), TCP mappings when the router sees FIN/RST or after a long idle timeout. Long-lived idle connections therefore need keep-alives.

  • Port forwarding (static DNAT): a fixed rule "public port 8080 → 192.168.1.50:80" lets outside clients reach an inside server.
  • Consequences. NAT breaks the end-to-end model: an outside host cannot start a connection to an inside host without a forwarding rule. Peer-to-peer apps use STUN, TURN and ICE (as WebRTC does) to traverse NAT. Protocols that put IP addresses inside the payload (classic FTP active mode, SIP) need application-level helpers.
  • Carrier-grade NAT puts another NAT layer at the ISP, using 100.64.0.0/10 between the two.
  • NAT is not a firewall, although dropping unsolicited inbound traffic is a side effect people rely on.

DNS: names to addresses

Theory

Mental model. DNS (RFC 1034 and RFC 1035) is a distributed, cached, hierarchical database. The namespace is a tree read right to left: the root (an empty label, written as the trailing dot), then a top-level domain such as com, then example, then www. Each zone's authoritative servers answer for their part of the tree and delegate sub-trees with NS records.

Resolution step by step

  1. The application calls getaddrinfo("www.example.com", ...). The C library (the stub resolver) checks /etc/hosts (order set by /etc/nsswitch.conf) and any local cache such as systemd-resolved.
  2. On a miss the stub sends a recursive query (the RD, "recursion desired", bit set) to the configured resolver from /etc/resolv.conf: the ISP's resolver, a corporate one or a public one like 1.1.1.1 or 8.8.8.8.
  3. The recursive resolver checks its cache. On a miss it performs iterative queries, starting from the root servers (13 named root server identities, a to m, each served by many anycast instances; their addresses ship with the resolver as "root hints").
  4. A root server does not know the answer. It returns a referral: the NS records for com. plus their addresses (glue).
  5. The resolver asks a com TLD server, which refers it to the NS records for example.com.
  6. The resolver asks an authoritative server for example.com, which returns the A record with the authoritative-answer (AA) bit set. If the name is a CNAME, the resolver restarts resolution for the target name.
  7. The resolver caches every record it learned for its TTL and returns the answer to the stub, which returns a sockaddr list to the application.
Your hoststub resolver Recursiveresolverhas a cache Root server. TLD servercom. Authoritativeexample.com. 1 query, RD=1 8 answer 2 query 3 refer: com NS 4 query 5 refer: example.com NS 6 query 7 A 192.0.2.10 1 and 8: recursive (the resolver does the work). 2 to 7: iterative (each server answers or refers).
One recursive query fans out into iterative ones. Your host asks once and waits. The resolver walks the tree from the root, caching each referral and the final answer (here with its TTL, for example 3600 seconds), so the next lookup under com. skips steps 2 and 3, and the next lookup of the same name is answered from cache.

Record types

TypeHoldsNote
AIPv4 addressa name can have several (round-robin)
AAAAIPv6 addressRFC 3596; clients ask for A and AAAA, then race connections (Happy Eyeballs, RFC 8305)
CNAMEalias to another namecannot coexist with other data at the same name, so it is not allowed at the zone apex
MXmail server name + preferencelower preference value is tried first; must point to a name with A/AAAA, not a CNAME (RFC 2181)
NSauthoritative server names for a zoneused for delegation
SOAzone metadata: serial, refresh, negative-cache TTLone per zone, at the apex
PTRreverse mapping, IP to name10.2.0.192.in-addr.arpa for 192.0.2.10
TXT, SRV, CAAtext (SPF, domain verification), service location with port, allowed certificate authorities

Caching, TTL and transport

  • Every record carries a TTL in seconds. Caches (browser, OS, resolver) may reuse it until the TTL expires. This is why a DNS change "takes time to propagate": nothing propagates, old copies simply expire. Lower the TTL well before a migration.
  • Negative caching (RFC 2308): NXDOMAIN answers are cached too, for a time taken from the zone's SOA record.
  • Transport: queries use UDP port 53 by default because one request and one reply fit in a datagram with no handshake. Classic DNS limits UDP messages to 512 bytes; EDNS(0) (RFC 6891) lets the client advertise a bigger buffer. If the answer still does not fit, the server sets the TC (truncated) bit and the client retries over TCP port 53. Zone transfers (AXFR/IXFR) use TCP. RFC 7766 makes TCP support mandatory. Encrypted variants: DNS over TLS on port 853 (RFC 7858) and DNS over HTTPS on 443 (RFC 8484).

DHCP, ICMP, ping and traceroute

Theory

DHCP: DORA

A new host has no address, no mask, no gateway and no DNS server. DHCP (RFC 2131) supplies all of them as a lease. The client uses UDP port 68 and the server port 67. Because the client has no address yet, the first messages are broadcasts from source 0.0.0.0.

Client (port 68) DHCP server (port 67) DISCOVER: 0.0.0.0 → 255.255.255.255, "any server?" OFFER: yiaddr 192.168.1.50, mask, router, DNS, lease 24 h REQUEST (still broadcast): "I take server S's offer" ACK: lease confirmed; client configures the interface Renew at T1 (50% of lease, unicast); rebind at T2 (87.5%, broadcast)
DORA: Discover, Offer, Request, Acknowledge. The Request is broadcast so that every server that made an offer learns which one was accepted and can release its own. A relay agent on the router forwards these broadcasts to a central server on another subnet.

Before using the address, many clients send an ARP probe for it to detect a conflict (and decline the lease if someone answers). A DHCP NAK tells a client its requested address is no longer valid, for example after it moved to another network. DHCPv6 (RFC 8415) uses ports 546 and 547; many IPv6 networks use SLAAC instead, with DNS servers in router advertisements.

ICMP

ICMP (RFC 792; ICMPv6 is RFC 4443) carries control and error messages for IP. It sits directly on IP (protocol 1) and has no ports. Common types:

Type / codeMeaningUsed by
8 / 0Echo requestping (ICMPv6 type 128)
0 / 0Echo replyping (ICMPv6 type 129)
3 / 3Destination unreachable: port unreachable (a closed UDP port)end of a UDP traceroute
3 / 4Fragmentation needed and DF set (carries the next-hop MTU)path MTU discovery
11 / 0Time exceeded: TTL reached 0 in transittraceroute
5Redirect: use a better gatewayrouters on the same LAN

ping sends echo requests with an identifier and a sequence number and times the replies, reporting round-trip time and loss. A failed ping does not prove a host is down (ICMP is often filtered), and a successful ping does not prove a TCP service is reachable.

traceroute sends probes with TTL 1, 2, 3 and so on. The router at hop k decrements TTL k to 0, drops the probe and returns ICMP Time Exceeded from its own address, revealing itself. Classic Unix traceroute sends three UDP probes per hop to high ports starting at 33434; the destination answers with port unreachable, which ends the trace. Windows tracert uses ICMP echo, and traceroute -T uses TCP SYN to get through firewalls. A line of * * * means no ICMP came back for that hop (often rate limiting or filtering), not necessarily packet loss, especially if later hops answer.

$ traceroute -n example.org
 1  192.168.1.1   1.2 ms  1.0 ms  1.1 ms
 2  100.64.0.1    8.4 ms  8.1 ms  8.9 ms
 3  * * *
 4  198.51.100.9 12.7 ms 12.5 ms 12.9 ms

Concept and tricky questions

Drills

ConceptList the OSI layers from the bottom with one protocol and one device for each. Where do TCP, IP, Ethernet and HTTP sit, and how do the TCP/IP layers map onto them?

Answer

Answer. 1 Physical (cables, hub), 2 Data link (Ethernet, switch), 3 Network (IP, router), 4 Transport (TCP and UDP, L4 load balancer), 5 Session, 6 Presentation, 7 Application (HTTP, L7 proxy). TCP/IP (RFC 1122) has link (OSI 1 and 2), internet (3), transport (4) and application (5 to 7).

Why. Each layer adds one capability: bits on a medium, frames to a neighbour, packets across networks, data to a process, and meaning for the application. Session and presentation have no separate protocols in the Internet stack, which is why TCP/IP folds them into the application. A common wrong answer puts TLS at layer 4 because it runs "on top of TCP"; it is above TCP, so it is usually placed at 6 or simply treated as part of the application layer. ARP is awkward too: it is carried directly in Ethernet frames but resolves layer 3 addresses, so it is often called "layer 2.5".

TrickyHost A (10.0.1.10) sends a packet to host B (10.0.2.20) through one router, with no NAT. Of the source MAC, destination MAC, source IP and destination IP, which change when the packet crosses the router?

Answer

Answer. Both MAC addresses change; both IP addresses stay the same. The TTL drops by one and the IPv4 header checksum is recomputed.

Why. On LAN 1 the frame goes from A's MAC to the router's eth0 MAC, because A sends off-subnet traffic to its gateway. The router discards that frame, routes the packet, and builds a new frame from its eth1 MAC to B's MAC (found with ARP on LAN 2). MAC addresses only have meaning on one link; IP addresses are end to end. People who say "the destination MAC is B's MAC from the start" forget that A cannot ARP for a host outside its subnet. With NAT the source IP (and port) would also change.

TrickyHost 192.168.1.10/24 wants to reach 192.168.2.5. Whose MAC address does it ARP for? What changes if the host is misconfigured with a /16 mask?

Answer

Answer. With /24 it ARPs for its default gateway, because 192.168.2.5 is off-subnet. With /16 it believes 192.168.2.5 is on its own link and ARPs for 192.168.2.5 directly. Nobody on the LAN answers, so the connection fails (unless the router does proxy ARP).

Why. The on-link decision is purely (dst & mask) == (own & mask). With mask 255.255.255.0 the networks are 192.168.1.0 and 192.168.2.0, which differ. With 255.255.0.0 both are 192.168.0.0, so the host skips the gateway. This is the classic "I can reach the Internet but not the other office subnet" (or the reverse) symptom of a wrong mask. A wrong answer is "it ARPs for 192.168.2.5 in both cases": hosts never ARP for off-subnet addresses.

ConceptName the PDU at the link, network and transport layers. What are the MTU and the MSS on standard Ethernet with IPv4 and TCP, and why do they differ?

Answer

Answer. Frame, packet, segment (TCP) or datagram (UDP). The MTU is 1500 bytes and the MSS is 1460 bytes.

Why. The MTU is the largest IP packet the link carries, counting the IP header. The MSS is the largest TCP payload, so it is the MTU minus the 20-byte IPv4 header and the 20-byte TCP header (minus more if TCP options such as timestamps are used in every segment, which is why you often see 1448 bytes of data per segment). The Ethernet header and FCS (18 bytes) are outside the MTU. With IPv6 the header is 40 bytes, so the MSS is 1440. A frequent mistake is to subtract the Ethernet header from 1500.

TrickyWhich of these are private (RFC 1918) addresses: 10.255.0.1, 172.31.200.1, 172.32.0.1, 192.168.0.0, 192.169.1.1, 100.64.0.1?

Answer

Answer. 10.255.0.1, 172.31.200.1 and 192.168.0.0 are private. 172.32.0.1 and 192.169.1.1 are public. 100.64.0.1 is shared carrier-grade NAT space (RFC 6598), not RFC 1918.

Why. The ranges are 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. A /12 fixes the first 12 bits, so the second octet of 172.16.0.0/12 ranges over 16 to 31 only. 192.168.0.0 is inside 192.168.0.0/16 (it is the network address of that block, and also a valid host address inside a larger subnet). 100.64.0.0/10 is also non-public, but it is meant for ISPs, and using it on a home LAN can collide with the ISP's CGNAT.

TrickyThe formula for usable hosts is "2 to the host bits, minus 2". How many usable hosts are in a /31 and in a /32?

Answer

Answer. A /31 has 2 usable addresses when used on a point-to-point link (RFC 3021), and a /32 identifies exactly 1 host. The naive formula gives 0 and −1.

Why. The minus 2 reserves the network address (host bits all 0) and the directed broadcast (host bits all 1). A point-to-point link has only two ends and no need for a broadcast address, so RFC 3021 lets both addresses of a /31 be hosts, which saves half the space of a /30 on router links. A /32 is a host route, used for loopback interfaces on routers, for anycast service addresses and in routing tables. Linux and modern routers accept /31 links. Answering "0 and none" is the textbook answer that interviewers use this question to filter out.

ConceptCompare a hub, a switch and a router in terms of collision domains, broadcast domains and what each one reads in a frame.

Answer

Answer. A hub reads nothing: one collision domain, one broadcast domain. A switch reads destination and source MACs: one collision domain per port, still one broadcast domain (per VLAN). A router reads the destination IP: it separates broadcast domains, one per interface.

Why. A hub is an electrical repeater, so two stations transmitting at once collide (CSMA/CD in half-duplex Ethernet). A switch learns MAC to port mappings from source addresses and forwards selectively, and modern full-duplex links have no collisions at all. Broadcast frames (ARP requests, DHCP discovers) are flooded by switches but never forwarded by routers. VLANs split one physical switch into several broadcast domains, and traffic between VLANs must then be routed.

TrickyDoes DNS run over UDP or TCP?

Answer

Answer. Both, on port 53. Ordinary queries use UDP. TCP is used when a response is truncated (TC bit set), for zone transfers (AXFR/IXFR), and whenever the client chooses it; RFC 7766 requires every DNS implementation to support TCP.

Why. UDP avoids a handshake, so a lookup costs one round trip. Classic DNS (RFC 1035) limits UDP payloads to 512 bytes. EDNS(0) (RFC 6891) lets clients advertise larger buffers, but large answers (DNSSEC signatures, many records) still risk IP fragmentation, so servers truncate and the client retries over TCP. The answer "UDP only" is the common wrong one, and it leads to firewalls that block TCP 53 and break large responses. Encrypted DNS uses TCP-based transports: DoT on 853, DoH on 443 (HTTP/3 makes DoH run over QUIC/UDP).

ConceptWhat is the difference between a recursive and an iterative DNS query, and who performs each in a normal lookup?

Answer

Answer. In a recursive query the server must return the final answer (or an error), doing all the work itself. In an iterative query the server returns the best it knows, usually a referral to servers closer to the answer. Your stub resolver sends one recursive query to its resolver; the resolver sends iterative queries to the root, the TLD and the authoritative servers.

Why. Putting recursion in a shared resolver concentrates caching: thousands of clients share cached referrals and answers, so root and TLD servers see a tiny fraction of lookups. Authoritative servers normally refuse recursion (the RA bit is clear in their replies) to avoid being used for amplification attacks. Saying "the root server forwards the query to the TLD server" is wrong: servers do not forward on your behalf in the iterative chain, the resolver chases each referral itself.

TrickyYou want example.com (the bare domain) to point to your load balancer, whose provider gives you only a hostname. Can you create a CNAME record at example.com?

Answer

Answer. No. A CNAME cannot coexist with any other record at the same name, and the zone apex must hold SOA and NS records. DNS providers offer non-standard ALIAS/ANAME records or "CNAME flattening", which resolve the target on the provider's side and serve plain A/AAAA records at the apex.

Why. A CNAME says "this name is an alias, look up the other name for every type", so other data at the same owner name would be contradictory (RFC 1034 section 3.6.2, clarified in RFC 2181). www.example.com can be a CNAME because nothing else lives there. A related rule: MX and NS records must point to names with address records, not to CNAMEs (RFC 2181 section 10.3). Newer SVCB/HTTPS records (RFC 9460) also address the apex problem.

ConceptWalk through DHCP. Why is the DHCPREQUEST broadcast even though the client already knows which server made the offer? What happens if no server answers?

Answer

Answer. Discover (broadcast), Offer (from each server), Request (broadcast, naming the chosen server and address), Ack. The Request is broadcast so that every server that made an offer learns its offer was declined and can return that address to its pool. With no server, the client keeps retrying and many operating systems self-assign a link-local 169.254.x.y address (RFC 3927).

Why. The client has no IP address until the Ack, so it sends from 0.0.0.0 port 68 to 255.255.255.255 port 67. The Offer reserves an address tentatively; the Ack commits the lease, with mask, default gateway, DNS servers and lease time as options. Renewal is unicast to the server at T1 (half the lease); if that fails, the client broadcasts a rebind at T2. A 169.254 address on a laptop is the classic sign of "DHCP is broken", not of a network problem elsewhere. Routers forward DHCP to other subnets only through a relay agent (often called an IP helper).

TrickyWhich port does ping use? Your web server answers ping. Does that mean it can serve HTTP?

Answer

Answer. None. ping sends ICMP echo requests, and ICMP has no ports. A ping reply proves only IP reachability and that ICMP is allowed; the web server can still be down, or TCP port 443 can be filtered.

Why. ICMP is carried directly in IP (protocol number 1). Echo messages have an identifier and sequence number instead of ports, which is how the kernel matches replies to the right ping process (and how NAT boxes translate them). The reverse also holds: many hosts drop ICMP, so a failed ping does not prove the service is down. To test a service, connect to it (curl, nc -vz host 443) or use a TCP traceroute.

TrickyA traceroute shows * * * at hop 3, but hops 4 to 10 answer normally and the destination is reached. Is there packet loss at hop 3?

Answer

Answer. Almost certainly not. Hop 3 forwards packets fine (later hops answer), it just does not send ICMP Time Exceeded messages back, or rate-limits them, or sends them from an address that is filtered on the way back.

Why. Routers forward packets in hardware but generate ICMP in a slow control-plane path, so many rate-limit or disable it. Loss that matters shows up as lost probes at the final destination and at every hop after the problem. The same reasoning explains why a middle hop can show a high RTT while later hops are fast: generating ICMP is low priority on that router. Concluding that "hop 3 is dropping my traffic" is the classic misreading.

ConceptTwo laptops behind the same home router both open TCP connections from source port 51000 to the same server on port 443. How does the NAT keep them apart, and why can the server not open a connection back to a laptop?

Answer

Answer. The router translates each connection to its public address with a different public source port (for example 40001 and 40002) and records both mappings. Replies are matched on the public port and translated back. An unsolicited inbound connection has no mapping, so the router does not know which inside host should get it and drops it.

Why. NAPT (RFC 3022) multiplexes many private sockets onto one public IP by rewriting the transport port, so up to about 64 thousand concurrent flows per public address per remote endpoint and protocol. Inbound access needs an explicit port forward, or a NAT traversal technique (STUN, TURN, ICE, UDP hole punching) where both sides send outbound first. The router must also recompute the IP and TCP checksums, because the TCP checksum covers a pseudo-header containing the IP addresses.

TrickyA server inside a Docker container listens on 127.0.0.1:8080. Requests from the host to the published port fail. Why? And does 127.0.0.2 reach anything?

Answer

Answer. 127.0.0.1 is the container's own loopback interface. Traffic forwarded from the host arrives on the container's eth0 address, where nothing listens. Bind to 0.0.0.0 (INADDR_ANY, every interface) or to the container's address instead. And yes: the whole 127.0.0.0/8 block is loopback, so on Linux 127.0.0.2 reaches local services bound to 0.0.0.0 or to 127.0.0.2.

Why. A socket bound to a specific address only accepts packets whose destination is that address. Each network namespace (each container) has its own loopback. Binding a production service to 0.0.0.0 exposes it on every interface, so the safe choice depends on intent: 127.0.0.1 for local-only admin ports, 0.0.0.0 or a specific interface for services. In IPv6 the loopback is the single address ::1, and :: is the "any" address.

ConceptGive four structural differences between IPv4 and IPv6, and compress 2001:0db8:0000:0000:0001:0000:0000:0001.

Answer

Answer. 128-bit addresses; a fixed 40-byte header with no checksum; no broadcast (multicast instead) and Neighbor Discovery instead of ARP; fragmentation only at the source, with a 1280-byte minimum MTU; SLAAC autoconfiguration. The canonical compressed form is 2001:db8::1:0:0:1.

Why. RFC 5952 says: drop leading zeros in each group, then replace the longest run of zero groups with ::; if two runs tie, compress the first. Here the runs are groups 3 and 4 and groups 6 and 7, both of length two, so the first run is compressed. (Compressing the second run gives 2001:db8:0:0:1::1, which is a valid address but not the canonical form.) Writing :: twice is never valid, because the reader could not tell how many zeros each one hides. Removing the header checksum is safe because link layers (Ethernet FCS) and transports (TCP, UDP checksums, mandatory for UDP over IPv6) already check integrity.

TrickyA DNS record has TTL 300 and an IP packet arrives with TTL 52. What does each TTL measure, and what does the 52 suggest?

Answer

Answer. The DNS TTL is in seconds: caches may keep the record for 5 minutes. The IP TTL is a hop count: each router decrements it and drops the packet at 0. A received TTL of 52 suggests a sender that started at 64 (Linux, macOS) and is 12 hops away.

Why. RFC 791 originally defined the IP TTL in seconds, with every router subtracting at least 1, but in practice packets spend far less than a second per hop, so it became a pure hop counter (IPv6 renamed it Hop Limit). Common initial values are 64, 128 (Windows) and 255 (many network devices), so you infer the start value as the next one above the received value. Mixing up the two TTLs is a common interview slip, as is thinking that IP packets "expire" after some time.

ConceptWhat is ARP spoofing, why does it work, how far can it reach, and how do networks defend against it?

Answer

Answer. An attacker on the same LAN sends forged ARP replies ("192.168.1.1 is at my MAC") to a victim, and usually the reverse to the gateway. Both update their caches, so their traffic flows through the attacker, who forwards it on: a man in the middle. It only works inside one broadcast domain. Defences: Dynamic ARP Inspection on managed switches (which checks ARP replies against a DHCP snooping table), static ARP entries for critical hosts, port security, and end-to-end encryption such as TLS or SSH, which does not stop the poisoning but keeps the attacker from reading or changing the data.

Why. ARP (RFC 826) has no authentication, and most stacks accept unsolicited replies and gratuitous ARPs, because failover depends on that. Routers never forward ARP, so an attacker elsewhere on the Internet cannot poison your cache. The common wrong answer is "HTTPS prevents ARP spoofing". It does not: the attacker still controls the path and can drop traffic, read DNS over UDP and attempt downgrades. TLS only makes the content safe when the client validates certificates. A related attack is MAC flooding: the attacker fills the switch's CAM table with fake source MACs, and the switch then floods unknown unicast frames out of every port, like a hub. IPv6 Neighbor Discovery has the same weakness, and the IPv6 counterpart of Dynamic ARP Inspection is RA Guard together with ND inspection.

TrickyOver a new VPN, SSH logins work, ping works and small web pages load, but large downloads and git clone hang right after the connection opens. What is the likely cause and the fix?

Answer

Answer. A path MTU discovery black hole. The tunnel adds headers, so the path MTU drops below 1500 (often to about 1400 to 1420). TCP sends full-size segments with DF set, and the tunnel router drops them and returns ICMP "fragmentation needed" (type 3, code 4). A firewall on the way back blocks that ICMP, so the sender never learns the smaller MTU and keeps retransmitting packets that are too large. Fixes: allow ICMP type 3 code 4 (ICMPv6 Packet Too Big, type 2), clamp the TCP MSS on the tunnel router (for example the iptables TCPMSS --clamp-mss-to-pmtu target), or lower the interface MTU.

Why. Small packets (handshakes, keystrokes, short pages, the default 84-byte ping) fit within the smaller MTU, so only bulk transfers fail. The wrong answers are "the routers will just fragment it" (TCP sets DF for PMTUD on Linux, and IPv6 routers never fragment) and "blocking all ICMP is a harmless security measure" (this failure is what it costs). To test, send pings with DF set and growing sizes: ping -M do -s 1472 host sends a full 1500-byte packet on Linux. Packetization-layer PMTUD (RFC 4821, the net.ipv4.tcp_mtu_probing sysctl on Linux) finds the MTU without relying on ICMP.

Predict the output and find the bug

Drills

All programs assume a little-endian machine (x86-64 or ARM64), which is what every interview laptop and server uses. Byte order is the point of several of them.

OutputWhat does this program print?

#include <cstdint>
#include <cstdio>
#include <initializer_list>

static void print_ip(uint32_t a) {
    std::printf("%u.%u.%u.%u\n", a >> 24, (a >> 16) & 255, (a >> 8) & 255, a & 255);
}

int main() {
    for (int p : {8, 20, 26, 30}) {
        uint32_t mask = 0xFFFFFFFFu << (32 - p);
        std::printf("/%d ", p);
        print_ip(mask);
    }
    uint32_t a = (10u << 24) | (20u << 16) | (30u << 8) | 40u;
    uint32_t m = 0xFFFFFFFFu << (32 - 19);
    print_ip(a & m);
    print_ip(a | ~m);
}
Answer

Answer.

/8 255.0.0.0
/20 255.255.240.0
/26 255.255.255.192
/30 255.255.255.252
10.20.0.0
10.20.31.255

Why. Shifting all-ones left by 32 − p leaves exactly p one-bits at the top. /20 leaves four ones in the third octet: 11110000 = 240. For 10.20.30.40/19 the third octet is masked with 224 (block size 32), and 30 lies in block 0 to 31, so AND gives the network 10.20.0.0 and OR with the inverted mask gives the broadcast 10.20.31.255. The literal is 0xFFFFFFFFu, unsigned, which matters: shifting a negative signed value was undefined before C++20.

Find the bugThis route parser works for every route in the table except one. Which one, and what is the bug?

#include <cstdint>
#include <cstdio>

static uint32_t mask_of(int prefix) {
    return 0xFFFFFFFFu << (32 - prefix);
}

int main() {
    const char* routes[] = {"10.0.0.0/8", "192.168.1.0/24", "0.0.0.0/0"};
    for (const char* r : routes) {
        unsigned a, b, c, d;
        int prefix;
        if (std::sscanf(r, "%u.%u.%u.%u/%d", &a, &b, &c, &d, &prefix) != 5) continue;
        std::printf("%-15s mask 0x%08X\n", r, mask_of(prefix));
    }
}
Answer

Answer. The default route 0.0.0.0/0. For prefix 0 the code shifts a 32-bit value by 32, which is undefined behaviour ([expr.shift]: the right operand must be less than the width of the promoted left operand). UBSan reports "shift exponent 32 is too large for 32-bit type". Fix: return prefix == 0 ? 0 : 0xFFFFFFFFu << (32 - prefix);.

Why. Without the sanitizer the program usually prints 0xFFFFFFFF for /0, the mask of a /32, because x86 and ARM64 shift instructions use only the low 5 bits of the count, so a shift by 32 becomes a shift by 0. The default route then matches only 0.0.0.0 itself, and every packet to the Internet is dropped. The output is not compared here because it is undefined. People expect the "obvious" answer 0x00000000, and that is what you only get by handling 0 explicitly. The same trap applies to 1u << 32 when counting addresses in a /0; use 64-bit arithmetic.

OutputWhat does this print on a little-endian machine?

#include <arpa/inet.h>
#include <cstdint>
#include <cstdio>
#include <cstring>

int main() {
    uint32_t host = 0x0A000001;  // 10.0.0.1
    unsigned char b[4];
    std::memcpy(b, &host, 4);
    std::printf("host order bytes: %d %d %d %d\n", b[0], b[1], b[2], b[3]);
    uint32_t net = htonl(host);
    std::memcpy(b, &net, 4);
    std::printf("network order bytes: %d %d %d %d\n", b[0], b[1], b[2], b[3]);
    std::printf("htons(80) = %d\n", htons(80));
}
Answer

Answer.

host order bytes: 1 0 0 10
network order bytes: 10 0 0 1
htons(80) = 20480

Why. Little-endian machines store the least significant byte first, so 0x0A000001 sits in memory as 01 00 00 0A. Network byte order is big-endian (RFC 791 appendix B; every IP, TCP and UDP header field uses it), so htonl swaps it to 0A 00 00 01, which is the order the address appears on the wire and in in_addr. htons(80) swaps 0x0050 to 0x5000 = 20480. On a big-endian machine both functions are no-ops, which is why code that forgets them can pass tests on one platform and fail on another.

OutputWhat does this validator print for the three inputs?

#include <arpa/inet.h>
#include <cstdio>

static void check(const char* s) {
    in_addr_t a = inet_addr(s);
    if (a == INADDR_NONE) std::printf("%s: invalid\n", s);
    else std::printf("%s: ok\n", s);
}

int main() {
    check("192.168.1.1");
    check("255.255.255.255");
    check("300.1.1.1");
}
Answer

Answer.

192.168.1.1: ok
255.255.255.255: invalid
300.1.1.1: invalid

Why. inet_addr returns the address as a 32-bit value, and it signals errors with INADDR_NONE, which is 0xFFFFFFFF. That is also the valid limited broadcast address 255.255.255.255, so the function cannot tell them apart. This is why inet_addr is considered obsolete. Use inet_pton(AF_INET, s, &addr), which returns 1 on success, 0 for invalid text and −1 for a bad address family, and writes the result separately.

OutputYour SSRF filter blocks the string "127.0.0.1". An attacker sends other strings. What does this print?

#include <arpa/inet.h>
#include <cstdio>

int main() {
    const char* inputs[] = {"127.1", "1.2.3", "0x7f.0.0.1", "10.0.0.1"};
    for (const char* s : inputs) {
        in_addr a{};
        int ok_aton = inet_aton(s, &a);
        char text[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &a, text, sizeof text);
        in_addr b{};
        int ok_pton = inet_pton(AF_INET, s, &b);
        std::printf("%-11s aton=%d (%s) pton=%d\n", s, ok_aton, text, ok_pton);
    }
}
Answer

Answer.

127.1       aton=1 (127.0.0.1) pton=0
1.2.3       aton=1 (1.2.0.3) pton=0
0x7f.0.0.1  aton=1 (127.0.0.1) pton=0
10.0.0.1    aton=1 (10.0.0.1) pton=1

Why. inet_aton (and therefore inet_addr, ping, browsers and getaddrinfo in many configurations) accepts the old BSD shorthand: a.b means a 24-bit last part, a.b.c means a 16-bit last part, and each part may be hex (0x) or octal (leading 0). So "127.1" is 127.0.0.1 and "1.2.3" is 1.2.0.3. inet_pton accepts only four decimal parts. An SSRF filter that compares strings is bypassed by these spellings; the fix is to parse to a binary address first and filter on the number (and to recheck after DNS resolution). Leading zeros are a portability trap: glibc's inet_pton rejects "01.2.3.4" but macOS accepts it as decimal, and inet_aton reads it as octal.

Find the bugThis prints 0 for two hosts in the same /24. Why, and for which inputs would it ever return true?

#include <cstdint>
#include <cstdio>

static bool same_subnet(uint32_t a, uint32_t b, uint32_t m) {
    return (a & m) == b & m;
}

int main() {
    uint32_t mask = 0xFFFFFF00;  // /24
    uint32_t a = 0xC0A8010A;     // 192.168.1.10
    uint32_t b = 0xC0A80114;     // 192.168.1.20
    std::printf("%d\n", same_subnet(a, b, mask));
}
Answer

Answer. It prints 0. == binds tighter than &, so the expression is ((a & m) == b) & m. The comparison yields 0 or 1, and 1 AND 0xFFFFFF00 is 0, so for any mask shorter than /32 the function always returns false. Fix: (a & m) == (b & m).

0

Why. The bitwise operators &, ^ and | have lower precedence than the comparisons, a leftover from early C where & doubled as logical AND. GCC and Clang warn with -Wparentheses ("suggest parentheses around comparison in operand of '&'"), which is one more reason to compile with -Wall and read the warnings. The function could only return true for a mask with the lowest bit set (a /32) and b == (a & m).

OutputWhat does this host counter print, and which lines are wrong for real networks?

#include <cstdio>
#include <initializer_list>

int main() {
    for (int p : {24, 30, 31, 32}) {
        int usable = (1 << (32 - p)) - 2;
        std::printf("/%d: %d\n", p, usable);
    }
}
Answer

Answer.

/24: 254
/30: 2
/31: 0
/32: -1

Why. The arithmetic is right; the formula is not. A /31 point-to-point link has 2 usable addresses (RFC 3021) and a /32 is one host, so a real calculator special-cases them. The code also hides two integer traps for small prefixes: for /1, 1 << 31 is INT_MIN in C++20 (undefined in C and C++11, implementation-defined in C++14 and C++17) and subtracting 2 then overflows, which is undefined; for /0 the shift by 32 is undefined. Use uint64_t and 1ull << (32 - p).

Find the bugA server sets its port like this, and clients connecting to 8080 get "connection refused". What port is it really on?

#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstdio>

int main() {
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = 8080;  // passed to bind() later
    std::printf("port on the wire: %d\n", ntohs(addr.sin_port));
    addr.sin_port = htons(8080);
    std::printf("port on the wire: %d\n", ntohs(addr.sin_port));
}
Answer

Answer. Port 36895. sin_port must be in network byte order, so the first assignment needs htons(8080).

port on the wire: 36895
port on the wire: 8080

Why. 8080 is 0x1F90. Stored in host order on a little-endian machine, the bytes are 90 1F, and the kernel reads sin_port as a big-endian 16-bit value: 0x901F = 36895. ss -ltn would show the server listening on 36895. The same applies to sin_addr.s_addr: use htonl(INADDR_LOOPBACK), although INADDR_ANY is 0 and so happens to work without conversion, which hides the bug.

OutputDNS messages carry names in a label format. What does this encoder print?

#include <cstdio>
#include <string>
#include <vector>

static std::vector<unsigned char> encode_name(const std::string& name) {
    std::vector<unsigned char> out;
    size_t start = 0;
    while (start <= name.size()) {
        size_t dot = name.find('.', start);
        if (dot == std::string::npos) dot = name.size();
        size_t len = dot - start;
        if (len == 0) break;
        out.push_back(static_cast<unsigned char>(len));
        out.insert(out.end(), name.begin() + start, name.begin() + dot);
        start = dot + 1;
    }
    out.push_back(0);
    return out;
}

int main() {
    auto wire = encode_name("www.example.com");
    for (unsigned char c : wire) {
        if (c < 32) std::printf("[%d]", c);
        else std::printf("%c", c);
    }
    std::printf("\nlength %zu\n", wire.size());
}
Answer

Answer.

[3]www[7]example[3]com[0]
length 17

Why. RFC 1035 section 3.1 encodes a name as a sequence of labels, each a length byte followed by that many characters, ended by the zero-length root label. There are no dots on the wire. Length bytes are at most 63 because the top two bits are reserved: 11 marks a compression pointer to an earlier name in the message (see the decoder in the coding section). The whole name is limited to 255 bytes in this form.

OutputTrace this routing table lookup. What does it print?

#include <cstdint>
#include <cstdio>
#include <initializer_list>
#include <string>
#include <vector>

struct Route { uint32_t prefix; int len; std::string via; };

static uint32_t ip(unsigned a, unsigned b, unsigned c, unsigned d) { return a << 24 | b << 16 | c << 8 | d; }
static uint32_t mask(int len) { return len == 0 ? 0 : 0xFFFFFFFFu << (32 - len); }

static std::string lookup(const std::vector<Route>& table, uint32_t dst) {
    const Route* best = nullptr;
    for (const Route& r : table)
        if ((dst & mask(r.len)) == r.prefix && (!best || r.len > best->len)) best = &r;
    return best ? best->via : "drop";
}

int main() {
    std::vector<Route> table = {
        {ip(0, 0, 0, 0), 0, "isp"},
        {ip(10, 0, 0, 0), 8, "core"},
        {ip(10, 1, 0, 0), 16, "lab"},
        {ip(10, 1, 2, 0), 24, "rack"},
        {ip(10, 1, 2, 128), 25, "tor"},
    };
    for (uint32_t d : {ip(10, 1, 2, 200), ip(10, 1, 2, 5), ip(10, 1, 9, 9), ip(10, 200, 0, 1), ip(8, 8, 8, 8)})
        std::printf("%s\n", lookup(table, d).c_str());
}
Answer

Answer.

tor
rack
lab
core
isp

Why. 10.1.2.200 matches all five routes and the /25 (10.1.2.128 to 10.1.2.255) is the longest. 10.1.2.5 is below .128, so the /25 does not match and the /24 wins. 10.1.9.9 matches the /16, 10.200.0.1 only the /8, and 8.8.8.8 only the default route. Order in the table does not matter, only prefix length. A common wrong answer takes the first match, which would send everything to "isp".

Find the bugThis should print the /24 network of 192.168.1.10. What does it print, and why?

#include <arpa/inet.h>
#include <cstdint>
#include <cstdio>

int main() {
    in_addr a{};
    inet_pton(AF_INET, "192.168.1.10", &a);
    uint32_t mask = 0xFFFFFF00;    // /24, written in host order
    in_addr net{};
    net.s_addr = a.s_addr & mask;
    char text[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &net, text, sizeof text);
    std::printf("network: %s\n", text);
}
Answer

Answer. It mixes byte orders. s_addr is in network order but the mask is in host order.

network: 0.168.1.10

Why. inet_pton stores the bytes C0 A8 01 0A in memory. Read as a little-endian integer that is 0x0A01A8C0, and AND with 0xFFFFFF00 clears the lowest-valued byte, which is the first byte in memory, 192. Fix it by doing arithmetic in host order: uint32_t h = ntohl(a.s_addr); net.s_addr = htonl(h & mask);, or by converting the mask with htonl. A good rule: convert at the boundary (parsing and socket calls) and keep all arithmetic in host order.

Write the code

Coding

These problems are all deterministic bit manipulation and parsing, so each reference solution runs with a test main. Keep addresses as uint32_t in host order inside your logic and convert only at the edges.

CodeWrite std::optional<uint32_t> parse_ipv4(std::string_view s). Accept exactly four decimal parts 0 to 255 separated by single dots. Reject empty parts, leading zeros ("01"), signs, spaces, trailing characters and overflow. Return the address in host order. Do not call any library parser.

Answer

Approach. Scan left to right with an index. For each of the four parts, require at least one digit, accumulate the value and fail as soon as it exceeds 255 (which also prevents integer overflow on long digit runs). Reject a multi-digit part that starts with 0. Between parts require exactly one dot, and after the fourth part require the end of the string.

#include <cstdint>
#include <cstdio>
#include <optional>
#include <string_view>

std::optional<uint32_t> parse_ipv4(std::string_view s) {
    uint32_t result = 0;
    size_t i = 0;
    for (int part = 0; part < 4; ++part) {
        if (part > 0) {
            if (i >= s.size() || s[i] != '.') return std::nullopt;
            ++i;
        }
        size_t start = i;
        unsigned value = 0;
        while (i < s.size() && s[i] >= '0' && s[i] <= '9') {
            value = value * 10 + unsigned(s[i] - '0');
            if (value > 255) return std::nullopt;
            ++i;
        }
        if (i == start) return std::nullopt;                       // empty part
        if (i - start > 1 && s[start] == '0') return std::nullopt; // leading zero
        result = (result << 8) | value;
    }
    if (i != s.size()) return std::nullopt;
    return result;
}

int main() {
    const char* tests[] = {"192.168.1.1", "0.0.0.0", "255.255.255.255", "256.1.1.1", "1.2.3",
                           "1.2.3.4.", "01.2.3.4", "1..2.3", " 1.2.3.4", "1.2.3.4a", "99999999999.1.1.1"};
    for (const char* t : tests) {
        auto r = parse_ipv4(t);
        if (r) std::printf("\"%s\" -> 0x%08x\n", t, *r);
        else std::printf("\"%s\" -> invalid\n", t);
    }
}
"192.168.1.1" -> 0xc0a80101
"0.0.0.0" -> 0x00000000
"255.255.255.255" -> 0xffffffff
"256.1.1.1" -> invalid
"1.2.3" -> invalid
"1.2.3.4." -> invalid
"01.2.3.4" -> invalid
"1..2.3" -> invalid
" 1.2.3.4" -> invalid
"1.2.3.4a" -> invalid
"99999999999.1.1.1" -> invalid

Complexity and pitfalls. O(n) time, O(1) space. Common bugs: using std::stoi (accepts leading spaces, signs and trailing junk, and throws on overflow), splitting on dots and missing the empty-part and trailing-dot cases, and allowing leading zeros, which other parsers read as octal (so "010.0.0.1" means 8.0.0.1 to inet_aton). The early value > 255 check is what makes the overflow test safe.

Estimated rating: ~1200 · straightforward parsing, but the edge cases decide whether it is accepted

CodeWrite a subnet calculator. Given a string "a.b.c.d/n", print the network address, broadcast address, first and last usable host, and the number of usable hosts. Handle /0 to /32, treat /31 as a point-to-point link (RFC 3021, both addresses usable, no broadcast) and /32 as a single host. Reject malformed input and prefixes above 32.

Answer

Approach. Split at the slash, parse the address strictly and the prefix as one or two digits. Build the mask with an explicit case for /0 to avoid the undefined shift by 32. Network is addr & mask, broadcast is network | ~mask. For prefixes up to /30 the usable range excludes both ends; count in 64-bit because /0 has 232 addresses.

#include <cstdint>
#include <cstdio>
#include <initializer_list>
#include <optional>
#include <string>
#include <string_view>

static std::optional<uint32_t> parse_ipv4(std::string_view s) {
    uint32_t result = 0;
    size_t i = 0;
    for (int part = 0; part < 4; ++part) {
        if (part > 0) {
            if (i >= s.size() || s[i] != '.') return std::nullopt;
            ++i;
        }
        size_t start = i;
        unsigned value = 0;
        while (i < s.size() && s[i] >= '0' && s[i] <= '9') {
            value = value * 10 + unsigned(s[i] - '0');
            if (value > 255) return std::nullopt;
            ++i;
        }
        if (i == start || (i - start > 1 && s[start] == '0')) return std::nullopt;
        result = (result << 8) | value;
    }
    if (i != s.size()) return std::nullopt;
    return result;
}

struct Subnet {
    int prefix;
    uint32_t network, broadcast, first, last;
    uint64_t usable;
};

static std::optional<Subnet> calc(std::string_view cidr) {
    size_t slash = cidr.find('/');
    if (slash == std::string_view::npos) return std::nullopt;
    auto addr = parse_ipv4(cidr.substr(0, slash));
    std::string_view ps = cidr.substr(slash + 1);
    if (!addr || ps.empty() || ps.size() > 2) return std::nullopt;
    int prefix = 0;
    for (char c : ps) {
        if (c < '0' || c > '9') return std::nullopt;
        prefix = prefix * 10 + (c - '0');
    }
    if (prefix > 32) return std::nullopt;
    uint32_t mask = prefix == 0 ? 0 : 0xFFFFFFFFu << (32 - prefix);
    Subnet s{};
    s.prefix = prefix;
    s.network = *addr & mask;
    s.broadcast = s.network | ~mask;
    if (prefix >= 31) {  // RFC 3021 point-to-point link, or a host route
        s.first = s.network;
        s.last = s.broadcast;
        s.usable = prefix == 31 ? 2 : 1;
    } else {
        s.first = s.network + 1;
        s.last = s.broadcast - 1;
        s.usable = (uint64_t{1} << (32 - prefix)) - 2;
    }
    return s;
}

static std::string dotted(uint32_t a) {
    return std::to_string(a >> 24) + '.' + std::to_string((a >> 16) & 255) + '.' +
           std::to_string((a >> 8) & 255) + '.' + std::to_string(a & 255);
}

int main() {
    for (const char* in : {"192.168.10.77/26", "10.20.30.40/19", "172.16.5.4/31", "8.8.8.8/32",
                           "0.0.0.0/0", "10.0.0.1/33", "10.0.0.1"}) {
        auto s = calc(in);
        if (!s) {
            std::printf("%s: invalid\n", in);
            continue;
        }
        std::string bcast = s->prefix >= 31 ? "n/a" : dotted(s->broadcast);
        std::printf("%s: net %s bcast %s hosts %s-%s (%llu)\n", in, dotted(s->network).c_str(), bcast.c_str(),
                    dotted(s->first).c_str(), dotted(s->last).c_str(), static_cast<unsigned long long>(s->usable));
    }
}
192.168.10.77/26: net 192.168.10.64 bcast 192.168.10.127 hosts 192.168.10.65-192.168.10.126 (62)
10.20.30.40/19: net 10.20.0.0 bcast 10.20.31.255 hosts 10.20.0.1-10.20.31.254 (8190)
172.16.5.4/31: net 172.16.5.4 bcast n/a hosts 172.16.5.4-172.16.5.5 (2)
8.8.8.8/32: net 8.8.8.8 bcast n/a hosts 8.8.8.8-8.8.8.8 (1)
0.0.0.0/0: net 0.0.0.0 bcast 255.255.255.255 hosts 0.0.0.1-255.255.255.254 (4294967294)
10.0.0.1/33: invalid
10.0.0.1: invalid

Complexity and pitfalls. O(length of input). The three classic bugs are the shift by 32 for /0, 32-bit overflow when counting addresses, and the naive "minus 2" for /31 and /32. Interviewers often follow up with "is host X in this subnet?" (compare x & mask with the network) or "what is the smallest prefix for 500 hosts?" (/23, since 29 − 2 = 510).

Estimated rating: ~1300 · simple bit arithmetic with three well-known edge cases

CodeImplement a routing table with longest-prefix match using a binary trie: insert(prefix, len, next_hop) and lookup(addr), which returns the next hop of the most specific matching route, or "drop" if none matches. Lookups must cost O(32), independent of the number of routes.

Answer

Approach. Each trie level consumes one address bit from the most significant end. A route of length len marks the node at depth len with its next hop; the default route marks the root. A lookup walks down following the address bits and remembers the last marked node it passed, which is by construction the longest matching prefix.

#include <cstdint>
#include <cstdio>
#include <initializer_list>
#include <memory>

class RouteTrie {
    struct Node {
        std::unique_ptr<Node> child[2];
        const char* next_hop = nullptr;
    };
    Node root_;

public:
    void insert(uint32_t prefix, int len, const char* hop) {
        Node* n = &root_;
        for (int i = 0; i < len; ++i) {
            int bit = (prefix >> (31 - i)) & 1;
            if (!n->child[bit]) n->child[bit] = std::make_unique<Node>();
            n = n->child[bit].get();
        }
        n->next_hop = hop;
    }

    const char* lookup(uint32_t addr) const {
        const Node* n = &root_;
        const char* best = n->next_hop;
        for (int i = 0; i < 32 && n; ++i) {
            n = n->child[(addr >> (31 - i)) & 1].get();
            if (n && n->next_hop) best = n->next_hop;
        }
        return best ? best : "drop";
    }
};

static uint32_t ip(unsigned a, unsigned b, unsigned c, unsigned d) { return a << 24 | b << 16 | c << 8 | d; }

int main() {
    RouteTrie t;
    t.insert(ip(10, 0, 0, 0), 8, "core");
    t.insert(ip(10, 1, 0, 0), 16, "lab");
    t.insert(ip(10, 1, 2, 0), 24, "rack");
    t.insert(ip(10, 1, 2, 128), 25, "tor");
    t.insert(ip(10, 1, 2, 200), 32, "host");
    for (uint32_t a : {ip(10, 1, 2, 200), ip(10, 1, 2, 201), ip(10, 1, 2, 5), ip(10, 1, 9, 9), ip(10, 200, 0, 1), ip(8, 8, 8, 8)})
        std::printf("%u.%u.%u.%u -> %s\n", a >> 24, (a >> 16) & 255, (a >> 8) & 255, a & 255, t.lookup(a));
    t.insert(0, 0, "isp");
    std::printf("after default route: 8.8.8.8 -> %s\n", t.lookup(ip(8, 8, 8, 8)));
}
10.1.2.200 -> host
10.1.2.201 -> tor
10.1.2.5 -> rack
10.1.9.9 -> lab
10.200.0.1 -> core
8.8.8.8 -> drop
after default route: 8.8.8.8 -> isp

Complexity and pitfalls. Lookup and insert are O(32). Memory is up to 32 nodes per route, which real routers reduce with path compression (Patricia or LC-tries, as in the Linux FIB) or multibit strides of 8 or 16 bits per level, trading memory for fewer memory accesses per lookup. Pitfalls: stopping at the first marked node instead of the last, forgetting that the root itself can hold the default route, and inserting a prefix with host bits set (10.1.2.77/24); production code masks the prefix on insert.

Estimated rating: ~1500 · a standard bitwise trie; the insight is to remember the last match while descending

CodeImplement the Internet checksum (RFC 1071) used by the IPv4 header, ICMP, UDP and TCP: the ones' complement of the ones' complement sum of all 16-bit big-endian words, padding an odd final byte with zero. Compute the checksum of an IPv4 header, then show that verifying a correct header yields 0.

Answer

Approach. Add the 16-bit words into a 32-bit accumulator, which leaves room for the carries. Fold the carries back into the low 16 bits until none remain (this is what makes it a ones' complement sum), then invert. To verify, sum the header including its checksum field: a correct header sums to 0xFFFF, so the inverted result is 0.

#include <cstddef>
#include <cstdint>
#include <cstdio>

static uint16_t inet_checksum(const uint8_t* data, size_t len) {
    uint32_t sum = 0;
    for (size_t i = 0; i + 1 < len; i += 2)
        sum += uint32_t(data[i]) << 8 | data[i + 1];
    if (len % 2) sum += uint32_t(data[len - 1]) << 8;  // pad the odd byte with zero
    while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
    return static_cast<uint16_t>(~sum);
}

int main() {
    uint8_t hdr[20] = {0x45, 0x00, 0x00, 0x73, 0x00, 0x00, 0x40, 0x00, 0x40, 0x11,
                       0x00, 0x00, 0xc0, 0xa8, 0x00, 0x01, 0xc0, 0xa8, 0x00, 0xc7};
    uint16_t c = inet_checksum(hdr, sizeof hdr);
    std::printf("checksum 0x%04x\n", c);
    hdr[10] = uint8_t(c >> 8);  // store big-endian
    hdr[11] = uint8_t(c & 0xFF);
    std::printf("verify 0x%04x\n", inet_checksum(hdr, sizeof hdr));
    hdr[8] = 0x3f;  // TTL 64 -> 63 without fixing the checksum
    std::printf("after TTL change, verify 0x%04x\n", inet_checksum(hdr, sizeof hdr));
    const uint8_t odd[3] = {0x01, 0x02, 0x03};
    std::printf("odd length 0x%04x\n", inet_checksum(odd, sizeof odd));
}
checksum 0xb861
verify 0x0000
after TTL change, verify 0x0100
odd length 0xfbfd

Complexity and pitfalls. O(n). Pitfalls: summing in host byte order without care (the ones' complement sum is actually byte-order independent if you read and write the result in the same order, but mixing orders breaks it), a 16-bit accumulator that loses carries, folding only once, and forgetting the odd-byte pad. For TCP and UDP the sum also covers a pseudo-header (source IP, destination IP, protocol, length), which is why NAT must patch transport checksums. Routers update the checksum incrementally after decrementing TTL (RFC 1624) instead of resumming the header.

Estimated rating: ~1300 · short code, but carries and byte order trip people up

CodeGiven an inclusive IPv4 range [lo, hi], return the minimal list of CIDR blocks that covers exactly that range (firewall rule generation; LeetCode 751 is the count-based variant).

Answer

Approach. Greedy from the low end. At address start, the largest aligned block that begins there has size equal to the lowest set bit of start (232 when start is 0). Halve it until it fits in the remaining range, emit it, and advance. Use 64-bit arithmetic so that 232 and hi + 1 do not overflow.

#include <cstdint>
#include <cstdio>
#include <utility>
#include <vector>

static std::vector<std::pair<uint32_t, int>> range_to_cidrs(uint32_t lo, uint32_t hi) {
    std::vector<std::pair<uint32_t, int>> out;
    uint64_t start = lo, end = hi;
    while (start <= end) {
        uint64_t size = start == 0 ? (uint64_t{1} << 32) : (start & (~start + 1));  // lowest set bit
        while (size > end - start + 1) size >>= 1;
        int len = 32;
        for (uint64_t s = size; s > 1; s >>= 1) --len;
        out.push_back({static_cast<uint32_t>(start), len});
        start += size;
    }
    return out;
}

static uint32_t ip(unsigned a, unsigned b, unsigned c, unsigned d) { return a << 24 | b << 16 | c << 8 | d; }

static void show(uint32_t lo, uint32_t hi) {
    for (auto [net, len] : range_to_cidrs(lo, hi))
        std::printf("%u.%u.%u.%u/%d ", net >> 24, (net >> 16) & 255, (net >> 8) & 255, net & 255, len);
    std::printf("\n");
}

int main() {
    show(ip(10, 0, 0, 5), ip(10, 0, 0, 20));
    show(ip(192, 168, 0, 0), ip(192, 168, 3, 255));
    show(ip(192, 168, 1, 0), ip(192, 168, 4, 255));
    show(0, 0xFFFFFFFFu);
}
10.0.0.5/32 10.0.0.6/31 10.0.0.8/29 10.0.0.16/30 10.0.0.20/32
192.168.0.0/22
192.168.1.0/24 192.168.2.0/23 192.168.4.0/24
0.0.0.0/0

Complexity and pitfalls. At most about 62 blocks for any range (two per bit length), each found in O(32), so O(1) in practice. The greedy is optimal because every emitted block is the largest aligned block that starts at the current address and stays inside the range. Pitfalls: hi + 1 overflowing for 255.255.255.255, the infinite loop when start is 0 and the lowest-set-bit trick returns 0, and emitting unaligned blocks such as 10.0.0.5/30.

Estimated rating: ~1600 · needs the alignment insight and careful overflow handling

CodeWrite a DNS name decoder for a received message: read_name(msg, pos) returns the dotted name and the offset just after the name at the original position. Support compression pointers (two bytes, top bits 11, 14-bit offset; RFC 1035 section 4.1.4). It must never read out of bounds or loop forever on a hostile packet.

Answer

Approach. Read labels until the zero byte. On a pointer, record the "next" offset (pointer position + 2) the first time you jump, then continue at the target. Defences: bounds-check every read, reject the reserved length prefixes 01 and 10, require pointers to point backwards, cap the number of jumps, and cap the decoded wire length at 255 bytes.

#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <optional>
#include <string>
#include <vector>

struct Name {
    std::string text;
    size_t next;
};

static std::optional<Name> read_name(const std::vector<uint8_t>& msg, size_t pos) {
    std::string out;
    size_t next = 0;
    bool jumped = false;
    int jumps = 0;
    size_t wire_len = 1;  // the final root label
    while (true) {
        if (pos >= msg.size()) return std::nullopt;
        uint8_t len = msg[pos];
        if ((len & 0xC0) == 0xC0) {  // compression pointer
            if (pos + 1 >= msg.size() || ++jumps > 127) return std::nullopt;
            size_t target = (size_t(len & 0x3F) << 8) | msg[pos + 1];
            if (target >= pos) return std::nullopt;  // must refer to an earlier name
            if (!jumped) next = pos + 2;
            jumped = true;
            pos = target;
            continue;
        }
        if (len & 0xC0) return std::nullopt;  // 01 and 10 prefixes are reserved
        if (len == 0) {
            if (!jumped) next = pos + 1;
            return Name{out.empty() ? "." : out, next};
        }
        wire_len += len + 1u;
        if (wire_len > 255 || pos + 1 + len > msg.size()) return std::nullopt;
        if (!out.empty()) out += '.';
        out.append(reinterpret_cast<const char*>(&msg[pos + 1]), len);
        pos += 1 + len;
    }
}

int main() {
    std::vector<uint8_t> msg(12, 0);  // the 12-byte header, unused here
    auto put = [&](std::initializer_list<int> bytes) {
        for (int b : bytes) msg.push_back(uint8_t(b));
    };
    auto label = [&](const char* s) {
        msg.push_back(uint8_t(std::strlen(s)));
        for (const char* p = s; *p; ++p) msg.push_back(uint8_t(*p));
    };
    label("www"); label("example"); label("com"); put({0});  // question name at offset 12
    put({0, 1, 0, 1});                                       // QTYPE A, QCLASS IN
    size_t mail = msg.size();
    label("mail"); put({0xC0, 16});                          // "mail" + pointer to "example.com"
    size_t loop = msg.size();
    put({0xC0, int(loop)});                                  // a pointer to itself
    for (size_t at : {size_t{12}, mail, loop}) {
        auto n = read_name(msg, at);
        if (n) std::printf("@%zu %s (next %zu)\n", at, n->text.c_str(), n->next);
        else std::printf("@%zu invalid\n", at);
    }
}
@12 www.example.com (next 29)
@33 mail.example.com (next 40)
@40 invalid

Complexity and pitfalls. O(255) work per name thanks to the caps. The "next" offset is the subtle part: after a pointer, parsing of the record continues after the two pointer bytes, not after the name it points to. Real DNS parsers have had remote crashes and infinite loops from exactly the missing checks here (self-referencing pointers, pointer chains, labels running off the end of the packet), so interviewers for network-facing roles like this problem.

Estimated rating: ~1700 · easy happy path, but the safety checks and the "next" offset are where candidates fail

Cheat sheet

Theory
  • Layers: OSI 7 (physical, data link, network, transport, session, presentation, application) vs TCP/IP 4 (link, internet, transport, application, RFC 1122). PDUs: frame, packet, segment or datagram. Hub L1, switch L2 (MACs), router L3 (IPs).
  • Encapsulation: Ethernet 14 B + IPv4 20 B + TCP 20 B + payload + FCS 4 B. MTU 1500 means MSS 1460. Demultiplex by EtherType, IP protocol (6 TCP, 17 UDP, 1 ICMP) and port.
  • Addresses: MAC is per hop and flat (48 bit), IP is end to end and hierarchical (32 or 128 bit). ARP (RFC 826) broadcasts "who has", the owner unicasts the reply; off-subnet traffic ARPs for the gateway.
  • Subnetting: network = a AND mask, broadcast = network OR NOT mask, usable = 232−n − 2, except /31 = 2 (RFC 3021) and /32 = 1. Block size = 256 − mask octet. Mask /0 needs a special case in C++.
  • Special ranges: 10/8, 172.16/12 (16 to 31 only), 192.168/16 private; 127/8 loopback; 169.254/16 link-local; 100.64/10 CGNAT. IPv6: ::1, fe80::/10, fc00::/7, one :: per address.
  • Routing: longest-prefix match, default route 0.0.0.0/0 to the gateway; TTL is a hop count (64 Linux, 128 Windows), 0 means drop and ICMP Time Exceeded.
  • NAT: NAPT rewrites source IP and port, keeps a mapping table, breaks unsolicited inbound connections; port forwarding is a static inbound mapping.
  • DNS, DHCP, ICMP: stub asks the resolver recursively, the resolver iterates root, TLD, authoritative; records A, AAAA, CNAME (not at apex), MX, NS; UDP 53 with TCP fallback on TC. DHCP DORA on UDP 67/68. ping = ICMP echo (no ports); traceroute = increasing TTL plus Time Exceeded.

Part D · Networking basics · Chapter 8

TCP, UDP, sockets and HTTP

How bytes get reliably from one process to another: TCP's handshake, teardown and state machine, TIME_WAIT, retransmission, flow and congestion control, the latency knobs (Nagle, delayed ACK), the BSD sockets API in C++, and the HTTP and TLS layers on top. These are the most common networking questions in systems, backend and low-latency interviews, and the socket bugs here (short reads, missing byte-order conversion, SIGPIPE) are the ones that reach production.

TheoryDrillsCoding· about 5 hours· prerequisites: Ch 7 (layers, IP addressing), file descriptors, basic threads

TCP vs UDP

Theory

Mental model. UDP is a thin wrapper over IP: it adds ports and a checksum, then sends each datagram on its own, with no promise that it arrives, arrives once or arrives in order. TCP builds a reliable, ordered, flow-controlled and congestion-controlled byte stream between two sockets on top of the same unreliable packets. Everything TCP adds costs latency, header bytes and kernel state.

Key facts: the comparison table

TCP (RFC 9293)UDP (RFC 768)
Connectionconnection-oriented: 3-way handshake before data, teardown afterconnectionless: just send
Deliveryreliable: acknowledgements and retransmissionbest effort: datagrams can be lost or duplicated
Orderin order (sequence numbers, reassembly)no ordering
Data modelbyte stream, no message boundariesdatagrams, boundaries preserved
Flow controlyes, receive windowno
Congestion controlyes (Reno, CUBIC, BBR)no (the application must be polite)
Header20 to 60 bytes8 bytes
Setup latency1 RTT before the first byte of data (plus TLS)0 RTT
Broadcast / multicastnoyes
Typical usesHTTP/1.1 and HTTP/2, SSH, databases, file transfer, most RPCDNS, DHCP, VoIP and video, games, market data multicast, QUIC (HTTP/3)

The UDP header is four 16-bit fields: source port, destination port, length and checksum. The checksum is optional over IPv4 (0 means "none") and mandatory over IPv6. Choose UDP when stale data is worthless (a game position, a voice sample, a market data tick that the next one supersedes), when you need multicast, or when you want to build your own reliability with different trade-offs, which is exactly what QUIC does in user space.

The TCP header

FieldBitsWhat it does
Source port, destination port16 + 16identify the sockets (with the IP addresses, the 4-tuple)
Sequence number32stream offset of the first data byte in this segment (offset from the random ISN)
Acknowledgement number32next byte expected from the peer; valid when ACK is set; cumulative
Data offset4header length in 32-bit words: 5 (20 bytes) to 15 (60 bytes)
Flags8CWR, ECE (ECN), URG, ACK, PSH, RST, SYN, FIN
Window16receive window in bytes, shifted by the window scale option
Checksum16Internet checksum over a pseudo-header (IPs, protocol, length), header and data
Urgent pointer16rarely used
Options0 to 320MSS, window scale (RFC 7323), SACK permitted and SACK blocks (RFC 2018), timestamps

Most options can only be negotiated in the SYN and SYN+ACK: MSS, window scale and SACK permitted. If a middlebox strips them, the connection silently falls back to a 64 KB window, which caps throughput on long-distance links.

Connection setup and teardown

Theory

The three-way handshake

  1. The client sends SYN with its initial sequence number x and its options (MSS, window scale, SACK permitted, timestamps). Client state: SYN_SENT.
  2. The server replies SYN+ACK with its own ISN y and ack = x + 1. Server state: SYN_RCVD.
  3. The client sends ACK with ack = y + 1. Both sides are ESTABLISHED. This ACK may already carry data.

The SYN and the FIN each consume one sequence number even though they carry no data, so that they can be acknowledged like data. ISNs are chosen pseudo-randomly from a clock plus a keyed hash of the 4-tuple (RFC 6528, folded into RFC 9293), which defends against blind injection attacks and keeps segments from an old connection with the same 4-tuple from being accepted by a new one.

Why three messages? Each side must announce its ISN and have it acknowledged. That is four messages, and the server's SYN and ACK travel together, which gives three. Two would not work: the server could not know that the client actually received its ISN, and a delayed duplicate SYN from an old connection would make the server open a connection the client does not know about.

Inside the kernel: SYN queue, accept queue, SYN floods

On Linux a listening socket has two queues. Half-open connections (SYN received, SYN+ACK sent) wait in the SYN queue. When the final ACK arrives, the connection moves to the accept queue, where accept() picks it up. The backlog argument of listen() sizes the accept queue, capped by net.core.somaxconn (4096 by default since Linux 5.4, 128 before). If the accept queue is full, Linux drops the final ACK (the client thinks it is connected, the server will retransmit its SYN+ACK), so a slow accept loop looks like random connection stalls. A SYN flood fills the SYN queue with spoofed SYNs; SYN cookies (RFC 4987) defend by encoding the connection state into the server's ISN and keeping no state until a valid ACK returns.

The handshake is completed by the kernel, not by accept(). A client's connect() succeeds as soon as the server kernel answers, even if the server process never calls accept().

The four-way teardown, half-close and RST

Each direction is closed separately, because TCP is full duplex. The side that closes first (the active closer) sends FIN; the peer ACKs it, may keep sending data (the connection is half-closed), and later sends its own FIN, which the active closer ACKs. The middle ACK and FIN are often combined, which gives three segments on the wire. shutdown(fd, SHUT_WR) sends FIN while keeping the read direction open, which is how a client says "request finished" while still reading the response. close() releases the descriptor and closes both directions once the last descriptor for the socket is gone.

A RST aborts a connection immediately, with no TIME_WAIT. Causes: a SYN to a port with no listener (the client sees "connection refused"), a segment for a connection the host does not know (for example after a reboot), close() while unread data sits in the receive buffer, or SO_LINGER with a zero timeout.

Client (active open, active close) Server SYN_SENTLISTEN SYN seq=x SYN_RCVD SYN+ACK seq=y ack=x+1 ESTABLISHED ACK ack=y+1 ESTABLISHED data in both directions FIN_WAIT_1 FIN seq=u CLOSE_WAIT ACK ack=u+1 FIN_WAIT_2 LAST_ACK FIN seq=v (app called close) TIME_WAIT ACK ack=v+1 CLOSED CLOSED wait 2·MSL
Setup takes one round trip; teardown is two half-closes. The SYN and FIN each consume a sequence number, so they are acknowledged with +1. The side that sends the first FIN ends in TIME_WAIT; the passive side sits in CLOSE_WAIT until its application calls close().

The TCP state machine and TIME_WAIT

Theory
CLOSED LISTEN SYN_SENT SYN_RCVD ESTABLISHED FIN_WAIT_1 CLOSE_WAIT FIN_WAIT_2 CLOSING LAST_ACK TIME_WAIT CLOSED listen() connect(): send SYN rcv SYN / send SYN+ACK rcv SYN+ACK / send ACK rcv ACK close() / send FIN rcv FIN / send ACK rcv ACK rcv FIN / send ACK rcv FIN / send ACK rcv ACK close() / send FIN rcv ACK 2·MSL timer expires
Eleven states, two paths. The left (active) side is what the side calling close() first goes through; the right (passive) side is what the peer goes through. CLOSING is the rare simultaneous-close case, and a FIN that arrives together with the ACK of our FIN takes FIN_WAIT_1 straight to TIME_WAIT. Inspect real sockets with ss -tan.

TIME_WAIT and 2·MSL

The active closer stays in TIME_WAIT for twice the maximum segment lifetime (MSL, nominally 2 minutes in RFC 793 and RFC 9293; Linux hard-codes TIME_WAIT at 60 seconds). Two reasons:

  1. Reliable close. If the final ACK is lost, the peer retransmits its FIN. The TIME_WAIT side must still exist to ACK it again; otherwise it would answer with RST and the peer would report an error. One MSL for the lost ACK to die and one MSL for the retransmitted FIN to arrive gives 2·MSL.
  2. No ghost segments. Delayed segments of the old connection must expire before a new connection with the same 4-tuple can start, or they could be accepted as data of the new one.

TIME_WAIT costs only a small kernel structure (no file descriptor), but a busy client opening many short connections to the same server can run out of ephemeral ports (the 4-tuple must be unique, and Linux's default range 32768 to 60999 gives about 28 thousand). Fixes: connection reuse (keep-alive, pooling), more ephemeral ports or more client IPs, and net.ipv4.tcp_tw_reuse, which lets a new outgoing connection reuse a TIME_WAIT 4-tuple when timestamps prove it safe. (The old tcp_tw_recycle broke clients behind NAT and was removed in Linux 4.12.)

SO_REUSEADDR and SO_REUSEPORT

A server that restarts while connections it closed are still in TIME_WAIT gets EADDRINUSE from bind(). Setting SO_REUSEADDR before bind() allows binding a port that has TIME_WAIT connections on it. Every TCP server should set it. On Linux it does not allow two sockets to listen on the same address and port; that is SO_REUSEPORT (Linux 3.9), which lets several sockets (usually one per worker thread or process) bind the same port, with the kernel load-balancing incoming connections between them. BSD and macOS semantics differ in details, so check the platform when it matters.

Interview trap

"The server is in TIME_WAIT" is not a property of servers. Whichever side calls close() first enters TIME_WAIT. HTTP servers that close idle keep-alive connections accumulate TIME_WAIT sockets; clients that close first push the cost to the client side. Conversely, thousands of sockets in CLOSE_WAIT mean your application received FIN and never called close(): a descriptor leak in your code, not a kernel tuning problem.

Sequence numbers, ACKs and retransmission

Theory

Mental model. TCP numbers every byte. The receiver says "I have everything before byte N" (a cumulative ACK of N). The sender keeps every unacknowledged byte in its send buffer and resends whatever is not acknowledged in time.

  • Sequence number of a segment = stream position of its first byte. A segment with sequence number 1001 and 100 bytes of data covers 1001 to 1100, and the receiver acknowledges it with ACK 1101.
  • Cumulative ACKs mean a lost segment holds back the ACK number even if later segments arrive; the receiver buffers out-of-order data and sends duplicate ACKs for the hole. SACK (RFC 2018) adds option blocks that tell the sender exactly which later ranges arrived, so it only resends the holes.
  • Sequence numbers are 32-bit and wrap around (after 4 GiB of data). Comparisons use serial arithmetic: a is before b when (int32_t)(a - b) < 0. On very fast links, timestamps (PAWS, RFC 7323) guard against an old segment from the previous wrap being accepted.

Retransmission timeout

The sender measures round-trip times and keeps a smoothed RTT and its variance. RFC 6298 sets RTO = SRTT + 4·RTTVAR, with an initial RTO of 1 second and a recommended minimum of 1 second (Linux uses a 200 ms minimum). Each timeout doubles the RTO (exponential backoff). Karn's algorithm: never take an RTT sample from a retransmitted segment, because you cannot tell which transmission the ACK answers. A timeout is treated as a strong congestion signal (see below).

Fast retransmit

Waiting a full RTO for each loss is slow. When the sender receives three duplicate ACKs (four ACKs with the same number), it assumes the segment at that number was lost, because later segments are clearly arriving, and retransmits it immediately (RFC 5681). Modern stacks also use RACK (time-based loss detection, RFC 8985) and tail loss probes.

Flow control vs congestion control

Theory

Mental model. Two different limits stop a sender from sending too fast. Flow control protects the receiver: do not send more than its buffer can hold. Congestion control protects the network: do not send more than the path can carry. The sender may have at most min(rwnd, cwnd) unacknowledged bytes in flight.

Flow control: the receive window

Every segment carries the receiver's free buffer space (rwnd) in the window field. If the application stops reading, the window shrinks to zero and the sender stops; it then sends periodic zero-window probes until the window opens. The 16-bit field caps the window at 64 KB, so the window scale option (RFC 7323, negotiated in the SYN) shifts it left by up to 14 bits, allowing windows up to 1 GiB. To fill a path, the window must cover the bandwidth-delay product: 1 Gbit/s × 100 ms = 12.5 MB in flight. A 64 KB window on that path gives at most 64 KB per 100 ms, about 5 Mbit/s.

Congestion control: slow start and AIMD

The sender keeps a congestion window (cwnd), which it learns by probing, and a threshold (ssthresh). Classic Reno (RFC 5681):

  • Slow start (cwnd < ssthresh): start with an initial window of up to 10 segments (RFC 6928, the Linux default) and add one MSS per ACK, so cwnd doubles every RTT. "Slow" refers to starting small, not to the growth rate.
  • Congestion avoidance (cwnd ≥ ssthresh): add about one MSS per RTT (MSS·MSS/cwnd per ACK). This is the additive increase.
  • Fast retransmit and fast recovery on three duplicate ACKs: set ssthresh = max(FlightSize / 2, 2·MSS), retransmit the lost segment, set cwnd = ssthresh + 3·MSS and inflate it per further duplicate ACK; when new data is acknowledged, deflate cwnd to ssthresh and continue in congestion avoidance. This is the multiplicative decrease.
  • Timeout: ssthresh = max(FlightSize / 2, 2·MSS) and cwnd = 1 MSS, then slow start again. A timeout means the ACK clock stopped, which is worse news than a few duplicate ACKs.
time (RTTs) cwnd (segments) 05101520 081624 ssthresh 16 ssthresh 11 ssthresh 8 slow start: ×2 per RTT congestion avoidance: +1 per RTT 3 dup ACKs: halve timeout: cwnd = 1
The Reno sawtooth. Exponential growth until ssthresh, then linear probing. A triple duplicate ACK halves the window (fast recovery, no slow start); a timeout drops it to one segment and slow-starts back to the new, lower ssthresh. This is a per-RTT model of the same rules the coding section simulates.

Linux uses CUBIC by default (RFC 9438): it grows cwnd as a cubic function of the time since the last loss, which recovers faster on high-bandwidth, long-delay paths and is fairer between flows with different RTTs. BBR (Google) instead models the bottleneck bandwidth and minimum RTT and paces packets to match, so it does not need loss as its signal. ECN lets routers mark packets (CE) instead of dropping them; the receiver echoes ECE and the sender reacts as if a loss had happened and answers with CWR.

Know this cold

Flow control is end to end and receiver driven (rwnd in every ACK). Congestion control is a sender-side estimate of the network (cwnd, never sent on the wire). In flight ≤ min(rwnd, cwnd). Slow start is exponential, congestion avoidance is linear, a loss signal halves (AIMD), a timeout resets to 1.

Nagle, delayed ACK and head-of-line blocking

Theory

Nagle's algorithm and delayed ACKs

Nagle (RFC 896) reduces tiny packets: while any sent data is unacknowledged, small writes are buffered and sent together, either when the ACK arrives or when a full MSS has accumulated. Delayed ACK (RFC 1122, RFC 9293) reduces pure ACKs: the receiver waits a little (it must ACK within 500 ms and should ACK at least every second full-size segment; Linux uses 40 ms to 200 ms) hoping to piggyback the ACK on response data.

Each is fine alone; together they stall. A client that does write, write, read (for example a header and a body in two send() calls) sends the first small segment at once. The second is held by Nagle until the first is ACKed, and the server holds its ACK hoping to piggyback it on a response it cannot produce until it gets the second part. The connection sits idle until the delayed-ACK timer fires, adding about 40 ms (Linux) or up to 200 ms per request.

Fixes, best first: build the whole message and send it in one call (or use writev/sendmsg with an iovec); set TCP_NODELAY to disable Nagle, which every latency-sensitive system does (trading, RPC frameworks, games, interactive protocols); or on Linux, use TCP_CORK to batch explicitly and uncork to flush. TCP_QUICKACK turns off delayed ACKs temporarily on Linux.

int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);  // needs <netinet/tcp.h>

Head-of-line blocking

Because TCP delivers a single ordered byte stream, one lost segment blocks delivery of everything after it, even data that has already arrived, until the retransmission fills the hole. This is head-of-line (HOL) blocking, and it appears at two levels:

  • HTTP/1.1: a connection carries one request/response at a time (pipelining exists but is effectively unused), so a slow response blocks the requests behind it. Browsers open about 6 connections per host to compensate.
  • HTTP/2 multiplexes many streams over one TCP connection, which fixes HTTP-level HOL blocking, but a single lost TCP segment still stalls every stream, which is worse than HTTP/1.1's six independent connections on lossy networks.
  • HTTP/3 runs over QUIC on UDP, where each stream is ordered independently, so a loss only stalls the stream it belongs to.

The BSD sockets API in C and C++

Theory

Mental model. A socket is a file descriptor that names one end of a communication channel inside the kernel. The kernel owns the protocol state, the send buffer and the receive buffer; your calls only copy bytes between your memory and those buffers, and ask the kernel to change state.

CallServerClientWhat it really does
socket(AF_INET, SOCK_STREAM, 0)yesyescreates an unconnected socket, returns an fd
setsockopt(SO_REUSEADDR)before bindrarelyallows rebinding a port with TIME_WAIT connections
bind(fd, addr, len)yesoptionalattaches a local address and port (port 0 lets the kernel choose)
listen(fd, backlog)yesmarks it passive: the kernel now completes handshakes
accept(fd, peer, len)yestakes one finished connection from the accept queue; returns a new fd
connect(fd, addr, len)yespicks an ephemeral port, runs the 3-way handshake
send/recv (or write/read)yesyescopy into the send buffer / out of the receive buffer; may do less than asked
shutdown(fd, SHUT_WR)optionaloptionalsends FIN, keeps reading
close(fd)yesyesreleases the fd; FIN when the last reference goes

A blocking echo server

The minimal correct server. Notice the new descriptor returned by accept, the loop around send, the n == 0 end-of-stream check, and the byte-order conversions. This program blocks forever, so it is compile-only here.

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>

static bool send_all(int fd, const char* p, size_t n) {
    while (n > 0) {
        ssize_t w = send(fd, p, n, MSG_NOSIGNAL);  // no SIGPIPE if the peer is gone
        if (w < 0) {
            if (errno == EINTR) continue;
            return false;
        }
        p += w;
        n -= size_t(w);
    }
    return true;
}

int main() {
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd < 0) { std::perror("socket"); return 1; }
    int yes = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);  // before bind

    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);  // all interfaces
    addr.sin_port = htons(9000);               // network byte order
    if (bind(lfd, reinterpret_cast<sockaddr*>(&addr), sizeof addr) < 0) { std::perror("bind"); return 1; }
    if (listen(lfd, SOMAXCONN) < 0) { std::perror("listen"); return 1; }

    for (;;) {
        sockaddr_in peer{};
        socklen_t plen = sizeof peer;
        int cfd = accept(lfd, reinterpret_cast<sockaddr*>(&peer), &plen);
        if (cfd < 0) {
            if (errno == EINTR) continue;
            std::perror("accept");
            break;
        }
        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip);
        std::printf("client %s:%d\n", ip, ntohs(peer.sin_port));
        char buf[4096];
        for (;;) {
            ssize_t n = recv(cfd, buf, sizeof buf, 0);
            if (n == 0) break;  // peer sent FIN
            if (n < 0) {
                if (errno == EINTR) continue;
                break;
            }
            if (!send_all(cfd, buf, size_t(n))) break;
        }
        close(cfd);  // forgetting this leaks fds and leaves peers in FIN_WAIT_2
    }
    close(lfd);
}

This server handles one client at a time. Real servers use a thread per connection (simple, but costly beyond thousands of connections), a thread pool, or an event loop with non-blocking sockets and epoll.

A client with getaddrinfo

getaddrinfo resolves names for IPv4 and IPv6 alike and returns a list of candidate addresses; a robust client tries each in turn. Never hand-fill sockaddr_in from a hostname, and never use the obsolete gethostbyname (not thread-safe, IPv4 only).

#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <string>

static int connect_to(const char* host, const char* port) {
    addrinfo hints{};
    hints.ai_family = AF_UNSPEC;  // IPv4 or IPv6
    hints.ai_socktype = SOCK_STREAM;
    addrinfo* res = nullptr;
    if (int rc = getaddrinfo(host, port, &hints, &res); rc != 0) {
        std::fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return -1;
    }
    int fd = -1;
    for (addrinfo* ai = res; ai; ai = ai->ai_next) {
        fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
        if (fd < 0) continue;
        if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break;
        close(fd);
        fd = -1;
    }
    freeaddrinfo(res);
    return fd;
}

int main(int argc, char** argv) {
    const char* host = argc > 1 ? argv[1] : "example.com";
    int fd = connect_to(host, "80");
    if (fd < 0) return 1;
    std::string req = std::string("GET / HTTP/1.1\r\nHost: ") + host + "\r\nConnection: close\r\n\r\n";
    send(fd, req.data(), req.size(), 0);  // small request; production code loops like send_all
    char buf[4096];
    ssize_t n;
    while ((n = recv(fd, buf, sizeof buf, 0)) > 0) std::fwrite(buf, 1, size_t(n), stdout);
    close(fd);
}

Byte order

Every multi-byte field in IP, TCP and UDP headers is big-endian ("network byte order"). The sockets API passes ports and IPv4 addresses in network order inside sockaddr_in, so convert with htons/htonl on the way in and ntohs/ntohl on the way out. Use inet_pton and inet_ntop for text conversion. Your own wire formats should also pick one order explicitly and serialise byte by byte (or with std::byteswap in C++23), never by memcpy of a struct, which also copies padding and host byte order.

Blocking vs non-blocking, select, poll and epoll

By default accept, connect, recv and (when the send buffer is full) send block the thread. With fcntl(fd, F_SETFL, flags | O_NONBLOCK) they return −1 with errno set to EAGAIN/EWOULDBLOCK instead, and a non-blocking connect returns EINPROGRESS; you then wait for writability and read SO_ERROR. To wait on many sockets at once you use a readiness API:

APIInterfaceCost per waitLimits
selectthree fd_set bitmaps, rebuilt every call, nfds = highest fd + 1O(highest fd)FD_SETSIZE, usually 1024; fds above it overflow the bitmap
pollarray of pollfd {fd, events, revents}O(number of fds)no fixed limit
epoll (Linux)kernel-side interest set: epoll_create1, epoll_ctl, epoll_waitO(ready fds)Linux only; BSD and macOS have kqueue, Windows has IOCP

epoll is level-triggered by default (reports a socket as long as it has data). With EPOLLET (edge-triggered) it reports only changes, so you must read until EAGAIN every time, or the remaining data is never reported again. A Linux-only sketch of the loop:

int ep = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_event ready[64];
for (;;) {
    int n = epoll_wait(ep, ready, 64, -1);
    for (int i = 0; i < n; ++i) {
        if (ready[i].data.fd == listen_fd) { /* accept, set O_NONBLOCK, EPOLL_CTL_ADD the new fd */ }
        else { /* recv until EAGAIN; on 0 or error: EPOLL_CTL_DEL and close */ }
    }
}

Newer Linux code can also use io_uring, which submits the operations themselves (completion-based, like IOCP) instead of waiting for readiness.

Message framing: TCP is a byte stream

TCP preserves the order of bytes and nothing else. One send of 12 bytes can arrive as one recv of 12, as 12 reads of 1 byte, or merged with the next message. recv(fd, buf, n, 0) returns whatever is available, between 1 and n bytes (0 means the peer closed). Any protocol on TCP therefore needs framing: a delimiter (HTTP header lines end with CRLF, Redis uses CRLF), a length prefix (most binary protocols, gRPC, Kafka) or a fixed message size. The receiver keeps a buffer, appends what each recv returns, and extracts complete messages from the front.

#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>

int main() {
    int sv[2];
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) return 1;
    write(sv[0], "HELLO ", 6);  // the sender's 12-byte message arrives in two pieces
    char buf[12];
    ssize_t n = recv(sv[1], buf, sizeof buf, 0);
    std::printf("asked for %zu, got %zd\n", sizeof buf, n);
    write(sv[0], "WORLD!", 6);
    ssize_t m = recv(sv[1], buf + n, sizeof buf - size_t(n), 0);
    std::printf("then got %zd: %.12s\n", m, buf);
    close(sv[0]);
    close(sv[1]);
}
asked for 12, got 6
then got 6: HELLO WORLD!

Interview trap

Code that does a single recv per message works on localhost and in tests, because small messages usually arrive whole, and then fails under load or over a real network. Interviewers often hand you exactly this bug. The same is true of send: on a blocking socket it normally sends everything, but it can return less (signals, non-blocking mode), so loop until all bytes are written.

Tip

Two more production habits: ignore SIGPIPE (or pass MSG_NOSIGNAL), because writing to a socket the peer has reset kills the process by default; and treat EINTR as "retry", not as an error.

HTTP/1.1, HTTP/2 and HTTP/3

Theory

HTTP/1.1 messages

HTTP semantics are in RFC 9110 and the HTTP/1.1 wire format in RFC 9112. A request is a request line, header lines, an empty line, and an optional body, with CRLF line endings:

GET /search?q=tcp HTTP/1.1
Host: www.example.com
User-Agent: curl/8.5.0
Accept: */*

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 1256
Cache-Control: max-age=60

<!doctype html>...
  • Host is mandatory in HTTP/1.1 (a server answers 400 without it), which is what lets one IP address host many sites.
  • Header names are case-insensitive. HTTP/2 and HTTP/3 send them lowercase.
  • The body length is known from Content-Length, or from Transfer-Encoding: chunked (hex size lines, terminated by a zero-size chunk), or, for responses only, by the server closing the connection.
  • Persistent connections are the default in HTTP/1.1 ("keep-alive"): the connection stays open for further requests until either side sends Connection: close or times out. HTTP/1.0 needed Connection: keep-alive explicitly. Reusing connections avoids a new TCP and TLS handshake and a new slow start per request.

Methods and status codes

MethodSafeIdempotentUse
GET, HEADyesyesread a resource (HEAD: headers only)
OPTIONSyesyescapabilities, CORS preflight
PUTnoyescreate or replace at a known URL
DELETEnoyesremove
POSTnonocreate under a collection, or any non-idempotent action
PATCHnonot guaranteedpartial update (RFC 5789)

Safe means no intended side effects. Idempotent means sending it twice has the same effect as once, which is what makes a request safe to retry after a timeout. Status classes: 1xx informational, 2xx success, 3xx redirection, 4xx client error, 5xx server error.

CodeMeaningCodeMeaning
200OK400Bad Request (malformed)
201Created (with Location)401Unauthorized: not authenticated
204No Content403Forbidden: authenticated but not allowed
206Partial Content (Range)404Not Found
301 / 308Moved permanently (308 keeps the method)409 / 429Conflict / Too Many Requests
302 / 307Found, temporary (307 keeps the method)500Internal Server Error
304Not Modified (cache revalidation)502 / 503 / 504Bad Gateway / Service Unavailable / Gateway Timeout

HTTP/2 and HTTP/3 in one breath

  • HTTP/2 (RFC 9113): same semantics, binary framing, many concurrent streams multiplexed on one TCP connection, header compression with HPACK (RFC 7541), stream priorities and flow control. Browsers use it only over TLS (negotiated with ALPN "h2"); cleartext h2c appears mainly between backend services, for example gRPC. It removes HTTP-level HOL blocking but keeps TCP's.
  • HTTP/3 (RFC 9114): HTTP over QUIC (RFC 9000), a transport on UDP that builds in TLS 1.3 (RFC 9001), independent streams (no cross-stream HOL blocking), 1-RTT setup for transport and crypto together (0-RTT on resumption), connection IDs that survive a change of IP address (Wi-Fi to cellular), and QPACK header compression (RFC 9204). Servers advertise it with an Alt-Svc header or an HTTPS DNS record.

HTTPS and the TLS 1.3 handshake

Theory

Mental model. TLS turns a TCP connection into an authenticated, encrypted, integrity-protected byte stream. Asymmetric crypto is used only during the handshake, to agree on symmetric keys (ephemeral Diffie-Hellman) and to prove the server's identity (a certificate signature); the data itself is protected with fast symmetric AEAD ciphers such as AES-GCM or ChaCha20-Poly1305.

ClientServer SYN SYN+ACK TCP: 1 RTT ACK + ClientHello: versions, ciphers, key_share, SNI, ALPN ServerHello: key_share (keys now derived) {EncryptedExtensions, Certificate, CertificateVerify, Finished} TLS 1.3: 1 RTT {Finished} + {GET / HTTP/1.1} {HTTP/1.1 200 OK ...} { } = encrypted. First response byte after 3 RTTs: TCP 1 + TLS 1 + HTTP 1 (TLS 1.2 needed 2 RTTs for its handshake).
TLS 1.3 needs one round trip. The client guesses the key exchange group and sends its key share in the first message, so the server can derive keys and encrypt everything after ServerHello, including its certificate. The client's request travels with its Finished message.
  1. ClientHello: supported versions and cipher suites, a random value, an ephemeral (EC)DHE public key share (usually X25519, increasingly a hybrid post-quantum group), SNI (the hostname, still sent in clear unless Encrypted Client Hello is used) and ALPN (h2 or http/1.1).
  2. ServerHello: the chosen suite and the server's key share. Both sides now compute the shared secret and derive handshake keys (HKDF). Everything from here is encrypted.
  3. The server sends EncryptedExtensions, its Certificate chain, CertificateVerify (a signature over the handshake transcript with the certificate's private key, which proves it owns the key) and Finished (a MAC over the transcript).
  4. The client validates the certificate: chain to a trusted root CA, hostname matches the subject alternative name, within its validity dates, not revoked. Then it checks CertificateVerify and Finished, sends its own Finished, and starts sending application data.

TLS 1.3 (RFC 8446) removed RSA key transport and static Diffie-Hellman, so every connection has forward secrecy: stealing the server's private key later does not decrypt recorded traffic. On resumption, a pre-shared key from an earlier session allows 0-RTT early data, which can be replayed by an attacker and is not forward secret with respect to the resumption key, so servers should accept only idempotent requests as early data. HSTS (a response header) tells browsers to use HTTPS only for a domain, which prevents downgrade to plain HTTP.

What happens when you type a URL

Theory

The classic open question. A strong answer walks the layers in order, says what is cached at each step, and offers depth where the interviewer wants it. For https://www.example.com/index.html:

  1. Parse the input. The browser decides it is a URL (not a search), extracts scheme, host, port (443 by default for https) and path, and checks the HSTS list (an http URL for an HSTS host is upgraded to https before anything is sent).
  2. Reuse check. If an open connection to that origin exists (keep-alive, or an HTTP/2 connection whose certificate covers the name), the browser skips to step 6. It also checks its HTTP cache: a fresh cached response skips the network entirely.
  3. DNS. Browser cache, then OS cache and /etc/hosts, then a recursive query to the configured resolver, which iterates from root to com to example.com (Ch 7). The browser asks for A and AAAA (and possibly HTTPS records, which can advertise HTTP/3).
  4. Route the first packet. The destination is off-subnet, so the OS sends to the default gateway, using ARP (or cached ARP) for the gateway's MAC. The home router applies NAT, and routers hop by hop forward on longest-prefix match.
  5. Transport. A TCP 3-way handshake to port 443 (or a QUIC handshake over UDP for HTTP/3, which merges this step with the next).
  6. TLS 1.3. ClientHello with SNI and ALPN, server certificate validated against the hostname, keys derived, 1 RTT.
  7. HTTP request. GET /index.html with Host, cookies and accept headers, over HTTP/2 if ALPN chose h2.
  8. Server side. A CDN edge or load balancer terminates TLS, routes the request (L4 or L7) to an application server, which may hit caches and databases, and produces a response with a status code and caching headers.
  9. Response and rendering. The browser parses HTML into the DOM, discovers CSS, JavaScript and images and fetches them (over the same connection with HTTP/2), builds the CSSOM, runs scripts, computes layout and paints.
  10. Afterwards. The connection stays open for reuse until idle timeout; DNS answers and cacheable responses stay cached for their TTL and max-age.

Tip

Count the round trips: DNS (often 0 if cached), TCP 1, TLS 1, HTTP 1, so about 3 RTTs before the first byte on a cold HTTPS connection over TCP, and 2 with HTTP/3 (QUIC combines the transport and TLS handshakes). On a 100 ms RTT that is the difference between 300 ms and 200 ms, which is why CDNs put servers close to users.

Concept and tricky questions

Drills

ConceptGive four differences between TCP and UDP, and three situations where UDP is the better choice.

Answer

Answer. TCP is connection-oriented, reliable, ordered, a byte stream, and flow and congestion controlled, with a 20 to 60 byte header. UDP is connectionless, best effort, unordered, message-oriented, with an 8-byte header and no handshake. UDP wins for DNS queries (one request, one reply, no handshake), real-time media and games (late data is useless, so retransmission only adds delay), multicast (market data feeds, service discovery) and protocols that implement their own reliability, such as QUIC.

Why. TCP's guarantees come from state and waiting: a handshake before data, retransmission timers, and in-order delivery that holds back later data behind a loss. When the application would rather skip a lost packet than wait for it, those guarantees hurt. The wrong answer "UDP is faster" is too vague: UDP has no setup round trip and no head-of-line blocking, but raw throughput on a clean path is similar, and UDP without congestion control can be worse for everyone.

TrickyWhy does TCP need a three-way handshake? Why would two messages not be enough?

Answer

Answer. Both sides must choose an initial sequence number and learn that the other side received it. The client's ISN is confirmed by the SYN+ACK, and the server's ISN is confirmed only by the third message. With two messages the server would never know its ISN arrived, and a delayed duplicate SYN from an old connection would make the server create a connection that the client does not want.

Why. RFC 9293 section 3.5 describes exactly the old-duplicate case: with the third step, the client sees a SYN+ACK for a SYN it did not just send and answers with RST, so the bogus half-open connection dies. The "four messages, two piggybacked into one" view explains the count: SYN, ACK, SYN, ACK, with the server's ACK and SYN combined. An answer about "checking that the network works" misses the point that the purpose is synchronising sequence numbers (that is what SYN stands for).

TrickyWhich side of a TCP connection enters TIME_WAIT, how long does it stay there, and why is the duration 2·MSL?

Answer

Answer. The side that sends the first FIN (the active closer), whether client or server. It stays for 2·MSL, nominally 4 minutes by RFC (MSL is 2 minutes), 60 seconds on Linux. The two reasons: to re-ACK the peer's FIN if the last ACK is lost, and to let delayed segments of this connection expire before the same 4-tuple can be reused.

Why. If the final ACK is lost, the peer in LAST_ACK retransmits its FIN; that FIN can arrive up to one MSL after the lost ACK would have expired, so the active closer must survive two segment lifetimes. Without TIME_WAIT the retransmitted FIN would get a RST and the peer's close would be reported as an error. The common wrong answers are "the server" and "the client": it depends only on who closes first. Disabling TIME_WAIT (with SO_LINGER and a zero timeout, which sends RST) is almost always the wrong fix; reusing connections is the right one.

TrickyDoes SO_REUSEADDR let two server processes listen on the same TCP port on Linux? What does?

Answer

Answer. No. On Linux SO_REUSEADDR lets a socket bind a port that still has connections in TIME_WAIT (so a restarted server can bind at once), but a second listener on the same address and port still fails with EADDRINUSE. SO_REUSEPORT (Linux 3.9 and later) allows several sockets, owned by the same user, to bind and listen on the same port, and the kernel distributes new connections between them by hashing the 4-tuple.

Why. The two options solve different problems: restart after close (REUSEADDR) and load-balancing across workers without a shared accept queue (REUSEPORT, used by nginx with reuseport and by many multi-threaded servers). On BSD and macOS, SO_REUSEADDR also allows binding a specific address while another socket holds the wildcard address, which is where the confusion comes from. The option must be set before bind().

ConceptExplain the difference between flow control and congestion control. Which variable implements each, and who sets it?

Answer

Answer. Flow control stops the sender from overrunning the receiver's buffer; it uses the receive window (rwnd), which the receiver advertises in every segment. Congestion control stops the sender from overloading the network; it uses the congestion window (cwnd), which the sender estimates from ACKs, losses and delay and never transmits. The sender keeps in flight at most min(rwnd, cwnd).

Why. A fast server talking to a slow phone is limited by rwnd even on an empty network; a fast phone and fast server across a congested link are limited by cwnd even with large buffers. Mixing them up is the classic mistake, as is saying "slow start is flow control". The window scale option only affects rwnd. A useful follow-up: throughput ≤ window / RTT, so a 64 KB window over a 100 ms RTT gives at most about 5 Mbit/s regardless of link speed.

TrickyWhat is a SYN flood, and how do SYN cookies let a server survive one without keeping state? What does the server give up while cookies are in use?

Answer

Answer. An attacker sends many SYNs, usually from spoofed source addresses, and never completes the handshake. Each SYN creates a half-open entry in the listener's SYN queue, and once the queue is full, real clients' SYNs are dropped. With SYN cookies the server stores nothing: it encodes a coarse timestamp, an index for the client's MSS and a keyed hash of the 4-tuple into the ISN of its SYN+ACK. A real client's final ACK carries that value plus one, and the server checks the hash and rebuilds the connection from it. While cookies are active, the server loses options that need state from the SYN: window scale and SACK survive only if TCP timestamps are on (Linux stores them in the timestamp value), and the MSS is rounded to one of a few values.

Why. The attack exhausts state, not bandwidth, so the fix is to stop holding state before the client proves it can receive packets at its claimed address. A spoofed SYN never produces a valid ACK. Linux turns cookies on automatically when the SYN queue overflows (net.ipv4.tcp_syncookies=1, the default), so normal traffic keeps every option. A common wrong answer is "increase the backlog": that only delays the overflow. Another is "SYN cookies stop DDoS": they do nothing against floods that saturate the link or against attackers that complete real handshakes. RFC 4987 surveys this and the other mitigations.

Trickysend() returned the full length of your message. Has the peer received it? And if you call close() right after, is delivery guaranteed?

Answer

Answer. No and no. A successful send means only that the bytes were copied into the local kernel send buffer. After close the kernel keeps trying to deliver them in the background, but your process gets no report if that fails, and if unread data is still sitting in your receive buffer at close time, Linux sends RST instead of FIN, and the peer may discard data it has not read yet.

Why. TCP acknowledgements are between kernels, never surfaced to the application, so "delivered to the peer application" needs an application-level reply. The RST-on-close-with-unread-data behaviour (described in RFC 1122 and RFC 2525) catches servers that send an error response and close without draining the request body: the client sees "connection reset" and loses the response. The graceful pattern is shutdown(fd, SHUT_WR), read until 0, then close. SO_LINGER with a timeout makes close block until the data is acknowledged or the timeout expires.

ConceptA request/response protocol shows a steady 40 ms latency per request on Linux, although the server answers in microseconds. The client writes a header and then a body with two send calls. What is happening and how do you fix it?

Answer

Answer. Nagle's algorithm holds the second small write until the first is acknowledged, and the server's delayed-ACK timer (40 ms minimum on Linux) holds the ACK because the server has nothing to send until it has the whole request. Fix by writing the request in one call (writev or one buffer), or set TCP_NODELAY on the client.

Why. Nagle (RFC 896) allows only one small unacknowledged segment in flight; delayed ACK (RFC 1122) waits to piggyback. Each is sensible alone, and together they wait for each other until the timer fires. The pattern is write-write-read; write-read-write-read does not trigger it. Turning off delayed ACK on the server (TCP_QUICKACK) also helps but must be re-armed, and disabling Nagle without batching your writes produces many tiny packets, so the best fix is one write per message plus TCP_NODELAY.

TrickyDoes HTTP/2 solve head-of-line blocking?

Answer

Answer. Only at the HTTP layer. HTTP/2 multiplexes streams on one TCP connection, so a slow response no longer blocks others, but one lost TCP segment still stalls every stream until it is retransmitted. HTTP/3 over QUIC removes transport-level HOL blocking because QUIC orders each stream independently.

Why. TCP delivers one ordered byte stream and knows nothing about HTTP/2 streams; bytes after a hole wait in the kernel even if they belong to a different stream. On lossy links, HTTP/2 on one connection can perform worse than HTTP/1.1 on six. "Yes, HTTP/2 fixed it" is the half-right answer interviewers are listening for. QUIC still has HOL blocking within a single stream, which is inherent to ordered delivery.

Conceptss -tan on your server shows 20,000 sockets in CLOSE_WAIT and the count keeps rising. What does that mean and where is the bug?

Answer

Answer. The peers have closed their side (sent FIN, which the kernel ACKed), but your application never called close() on those sockets. The bug is in your code: a missing close on some path (often an error path or a recv returning 0 that is not handled), or threads stuck elsewhere. Eventually the process runs out of file descriptors (EMFILE).

Why. CLOSE_WAIT has no timeout in TCP: the kernel waits for the application indefinitely, since only the application knows whether it still wants to send. Contrast TIME_WAIT, which is normal, bounded by a timer, and holds no descriptor. Tuning kernel parameters does not fix CLOSE_WAIT. Look for the code path that reads EOF and does not close, and use RAII wrappers for descriptors in C++ so every path closes.

TrickyCan you call connect() on a UDP socket? What does it do, given that UDP has no connection?

Answer

Answer. Yes. It sends nothing; it records a default peer address. Afterwards you can use send/recv without addresses, the kernel discards datagrams from other sources, and asynchronous ICMP errors (such as port unreachable) are reported to you as ECONNREFUSED on a later call.

Why. A connected UDP socket also lets the kernel cache the route, which makes sending slightly cheaper, and it is how you find your own outgoing address: connect a UDP socket to a remote IP and call getsockname, with no packet sent. On an unconnected UDP socket ICMP errors are not delivered, because the kernel cannot tell which destination they belong to. Saying "connect is TCP-only" is the common wrong answer.

TrickyWhat does the backlog argument to listen() limit on Linux, and what happens to new clients when that limit is reached?

Answer

Answer. It sizes the accept queue: connections whose handshake has completed but that the application has not yet accepted, capped by net.core.somaxconn. When the queue is full, Linux drops the client's final ACK (and may drop new SYNs), so clients see retransmissions and connection delays of a second or more, not an immediate refusal.

Why. Since Linux 2.2 the half-open (SYN_RCVD) connections live in a separate SYN queue sized by tcp_max_syn_backlog, protected by SYN cookies. Older BSD semantics counted both together, which is the source of the classic "backlog is the number of pending connections" answer. The client may already believe it is connected (it got the SYN+ACK) and send data that the server silently drops until the handshake completes. net.ipv4.tcp_abort_on_overflow=1 makes Linux send RST instead. The real fix is to call accept faster.

TrickyThe peer has closed its socket. You call write() twice on yours. What happens on each call?

Answer

Answer. Over TCP, the first write usually succeeds: the data is buffered and sent. The peer's kernel has no socket for it any more and replies with RST. The second write then fails with EPIPE, and by default the kernel also sends SIGPIPE, which terminates the process (exit status 141 from a shell) unless it is ignored or MSG_NOSIGNAL is used.

Why. Receiving the peer's FIN only means "they will send no more"; they might still be reading (half-close), so TCP cannot fail the first write. Only the RST proves nobody is listening. Many servers have died "mysteriously" with no log line because of SIGPIPE. Standard practice: signal(SIGPIPE, SIG_IGN) at startup or MSG_NOSIGNAL on every send (on macOS, the SO_NOSIGPIPE socket option is the traditional equivalent), and handle EPIPE and ECONNRESET as ordinary errors.

ConceptWalk through the TLS 1.3 handshake. How many round trips does it take, what is encrypted, and what is the risk of 0-RTT?

Answer

Answer. One round trip. ClientHello carries an ephemeral key share; ServerHello returns the server's share, after which both derive keys, and the server sends encrypted EncryptedExtensions, Certificate, CertificateVerify and Finished. The client verifies the certificate chain and hostname and the signature, sends Finished, and its first request goes with it. Everything after ServerHello is encrypted, including the certificate; SNI in the ClientHello is still visible unless ECH is used. 0-RTT early data on resumption can be replayed by an attacker, so it must only carry idempotent requests.

Why. TLS 1.2 needed two round trips because the key exchange parameters came only after the server picked a cipher suite. TLS 1.3 (RFC 8446) cut the options to a few (EC)DHE groups so the client can guess and send its share up front (if it guesses wrong, HelloRetryRequest costs an extra round trip). Ephemeral keys give forward secrecy. Certificates authenticate; Diffie-Hellman provides the key; symmetric AEAD protects the data. The common error is "the client encrypts a session key with the server's RSA public key", which is TLS 1.2 RSA key exchange and was removed in 1.3.

Tricky"TCP guarantees delivery." True or false?

Answer

Answer. False as stated. TCP guarantees that the bytes the receiver gets are in order, without gaps or duplicates, and that loss is retried while the connection lives. It cannot guarantee delivery if the connection breaks: bytes in the send buffer or in flight are lost, and the sender learns only that the connection failed, not which bytes the peer application processed.

Why. This is why payment systems, message queues and RPC frameworks add application-level acknowledgements, request ids and idempotent retries. A related trap is the "exactly once" claim: after a timeout the client cannot tell whether the server processed the request, so retries need idempotency keys. Also note that TCP's 16-bit checksum is weak; very rare corruption can pass it, which is one reason TLS (with its MAC) also gives integrity.

TrickyA server listens on port 443. Is the number of simultaneous clients limited to about 65 thousand because ports are 16-bit?

Answer

Answer. No. A connection is identified by the 4-tuple (source IP, source port, destination IP, destination port), and all server-side connections share the server port. The real limits are memory, file descriptors (ulimit -n) and CPU. The 16-bit limit applies to a client host: about 28 thousand ephemeral ports (Linux default range) per (destination IP, destination port) pair, per source IP.

Why. accept returns a new socket that shares the listening port; the kernel demultiplexes incoming segments by the full tuple. Servers handle millions of connections on one port. The client-side limit bites load generators and proxies that open many connections to one backend, especially with TIME_WAIT on the client side; fixes include more source IPs, a wider ip_local_port_range and connection pooling.

ConceptExplain these pairs of HTTP status codes: 401 vs 403, 301 vs 308, 502 vs 504. Which methods are idempotent, and why does that matter?

Answer

Answer. 401 means "not authenticated" (send credentials; it comes with WWW-Authenticate), 403 means "authenticated, or authentication would not help, but not allowed". 301 and 308 are both permanent redirects, but clients may change a POST to GET after 301, while 308 requires keeping the method and body (307 is the temporary version of 308). 502 means the gateway got an invalid response from the upstream (for example a crash or reset), 504 means the upstream did not answer in time. GET, HEAD, OPTIONS, PUT and DELETE are idempotent; POST is not, and PATCH is not guaranteed to be.

Why. Idempotency (RFC 9110 section 9.2.2) decides what a client, proxy or library may retry automatically after a timeout or connection reset: retrying an idempotent request cannot double its effect, while retrying a POST might charge a card twice. That is why APIs add idempotency keys to POST. Idempotent is not the same as safe: DELETE changes state but is idempotent.

ConceptCompare select, poll and epoll. Why does epoll scale to 100,000 idle connections when the other two do not, and what extra rule does edge-triggered mode impose?

Answer

Answer. All three tell you which descriptors are ready. select passes three bitmaps limited to FD_SETSIZE (usually 1024) and rewrites them, so you rebuild them every call. poll passes an array with no fixed limit. Both copy the whole interest set into the kernel on every call and scan all of it, so each wait costs O(n) even when only one descriptor is ready. epoll keeps the interest set inside the kernel (epoll_ctl once per descriptor), and the kernel puts descriptors on a ready list as events arrive, so epoll_wait costs O(ready). In edge-triggered mode (EPOLLET) you are told only when a descriptor becomes ready, so you must use non-blocking sockets and read (or accept, or write) until EAGAIN. Otherwise the data left behind is never reported again.

Why. With 100,000 connections and 10 active, poll copies and scans 100,000 entries per wakeup, while epoll returns 10. The common wrong answer is "epoll is always faster": with a few busy descriptors poll is just as fast, and epoll costs one system call per interest change. Level-triggered epoll (the default) behaves like poll and is the safer choice. Edge-triggered mode saves wakeups but turns a missed drain into a hung connection. With several threads waiting on one epoll set, use EPOLLEXCLUSIVE or EPOLLONESHOT to avoid thundering-herd wakeups and two threads handling the same socket. All three report readiness, which differs from completion: io_uring and Windows IOCP perform the I/O and tell you when it is done.

TrickyHow many round trips pass before the first response byte of an HTTPS request on a new connection, with DNS already cached, for: TCP + TLS 1.2, TCP + TLS 1.3, TCP + TLS 1.3 with 0-RTT resumption, and HTTP/3 (QUIC) on a first and on a resumed connection?

Answer

Answer. TCP + TLS 1.2: 4 (TCP 1, TLS 2, HTTP 1). TCP + TLS 1.3: 3. TCP + TLS 1.3 with 0-RTT: 2, because the request rides in the first TLS flight but the TCP handshake still costs a round trip. QUIC first connection: 2 (one combined transport and crypto handshake, then the request). QUIC with 0-RTT: 1, since the request goes in the first packet.

Why. TLS 1.3 saves a round trip because the client sends its key share in ClientHello. 0-RTT reuses a pre-shared key from an earlier session, so it is available only on resumption. QUIC also merges the transport handshake into the TLS one. The trap is the name: "0-RTT TLS" over TCP still costs at least one round trip, unless TCP Fast Open also carries the ClientHello in the SYN, which is rarely deployed because middleboxes interfere with it. A cold DNS lookup adds at least one round trip to the resolver, plus the resolver's own queries on a cache miss. On a 100 ms path, going from 4 round trips to 1 saves 300 ms, which is why CDNs and HTTP/3 matter most on mobile networks. 0-RTT data can be replayed, so servers accept only idempotent requests in it.

Predict the output and find the bug

Drills

Programs that run use only a socketpair or pure computation, so they are deterministic. Programs that would block or need a network are compile-only. Byte-order answers assume a little-endian machine (x86-64, ARM64).

OutputThis decodes the first 20 bytes of a captured TCP segment. What does it print, and what kind of segment is it?

#include <arpa/inet.h>
#include <cstdint>
#include <cstdio>
#include <cstring>

int main() {
    const uint8_t seg[20] = {0xc3, 0x50, 0x01, 0xbb,
                             0x00, 0x00, 0x03, 0xe8,
                             0x00, 0x00, 0x00, 0x00,
                             0x50, 0x02, 0xfa, 0xf0,
                             0x00, 0x00, 0x00, 0x00};
    uint16_t sport, dport, win;
    uint32_t seq;
    std::memcpy(&sport, seg, 2);
    std::memcpy(&dport, seg + 2, 2);
    std::memcpy(&seq, seg + 4, 4);
    std::memcpy(&win, seg + 14, 2);
    int hlen = (seg[12] >> 4) * 4;
    uint8_t f = seg[13];
    std::printf("%d -> %d seq=%u hlen=%d win=%d\n", ntohs(sport), ntohs(dport), ntohl(seq), hlen, ntohs(win));
    std::printf("SYN=%d ACK=%d FIN=%d RST=%d\n", (f >> 1) & 1, (f >> 4) & 1, f & 1, (f >> 2) & 1);
}
Answer

Answer. A SYN from ephemeral port 50000 to HTTPS (443), the first segment of a handshake.

50000 -> 443 seq=1000 hlen=20 win=64240
SYN=1 ACK=0 FIN=0 RST=0

Why. Ports and numbers are big-endian on the wire: 0xc350 = 50000, 0x01bb = 443, 0x000003e8 = 1000. The data offset is the top nibble of byte 12, in 32-bit words: 5 × 4 = 20 bytes, so no options (a real SYN almost always carries MSS and other options, so you would usually see 0xa0, 40 bytes). The flag byte 0x02 is SYN alone; the bit order from the low end is FIN, SYN, RST, PSH, ACK, URG, ECE, CWR. Using memcpy plus ntohs avoids both the byte-order bug and the misaligned-pointer cast that *(uint16_t*)(seg + 2) would be.

OutputFill in the numbers: what does this connection trace print?

#include <cstdint>
#include <cstdio>

int main() {
    uint32_t c = 1000, s = 5000;
    std::printf("C->S SYN seq=%u\n", c);
    std::printf("S->C SYN+ACK seq=%u ack=%u\n", s, c + 1);
    c += 1;
    s += 1;
    std::printf("C->S ACK seq=%u ack=%u\n", c, s);
    for (int i = 0; i < 2; ++i) {
        std::printf("C->S data seq=%u len=100\n", c);
        c += 100;
    }
    std::printf("S->C ACK ack=%u\n", c);
    std::printf("C->S FIN seq=%u\n", c);
    c += 1;
    std::printf("S->C ACK ack=%u\n", c);
}
Answer

Answer.

C->S SYN seq=1000
S->C SYN+ACK seq=5000 ack=1001
C->S ACK seq=1001 ack=5001
C->S data seq=1001 len=100
C->S data seq=1101 len=100
S->C ACK ack=1201
C->S FIN seq=1201
S->C ACK ack=1202

Why. The SYN occupies sequence number 1000, so the first data byte is 1001, and the server's SYN+ACK acknowledges 1001 ("next byte I expect"). The server's own SYN uses 5000, so the client acknowledges 5001. Two 100-byte segments cover 1001 to 1200; one cumulative ACK of 1201 acknowledges both (with delayed ACK, a receiver typically sends one ACK per two full segments). The FIN takes 1201 and is acknowledged with 1202. The pure ACK in the third line consumes nothing, which is why the first data segment also carries 1001. In real captures the ISNs are random 32-bit values; Wireshark shows "relative sequence numbers" starting at 0 to make traces like this readable.

OutputTwo sends of two bytes each, then one receive of up to 16 bytes, on a stream socket pair and on a datagram socket pair. What does each first receive return?

#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <initializer_list>

int main() {
    for (int type : {SOCK_STREAM, SOCK_DGRAM}) {
        int sv[2];
        if (socketpair(AF_UNIX, type, 0, sv) < 0) return 1;
        send(sv[0], "ab", 2, 0);
        send(sv[0], "cd", 2, 0);
        char buf[16];
        ssize_t n = recv(sv[1], buf, sizeof buf, 0);
        std::printf("%s: first recv got \"%.*s\"\n", type == SOCK_STREAM ? "stream" : "dgram", int(n), buf);
        close(sv[0]);
        close(sv[1]);
    }
}
Answer

Answer.

stream: first recv got "abcd"
dgram: first recv got "ab"

Why. A stream socket has no message boundaries: both writes sit in one receive buffer and one read returns everything available. A datagram socket returns exactly one datagram per receive, and the second recv would return "cd". TCP behaves like the stream case and UDP like the datagram case. On a local socket pair the merge is reliable; over a network, a TCP reader may see "abcd", "ab" then "cd", or "a" then "bcd", which is why protocols on TCP need explicit framing. With UDP, a buffer smaller than the datagram silently truncates it (the excess is discarded, and MSG_TRUNC is set in recvmsg flags).

Find the bugThe socket has data waiting, yet select reports nothing ready. Why?

#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>

int main() {
    int sv[2];
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) return 1;
    send(sv[0], "x", 1, 0);  // sv[1] is now readable
    int fd = sv[1];
    fd_set rd;
    FD_ZERO(&rd);
    FD_SET(fd, &rd);
    timeval tv{0, 0};        // check without blocking
    int ready = select(fd, &rd, nullptr, nullptr, &tv);
    std::printf("ready = %d\n", ready);
    close(sv[0]);
    close(sv[1]);
}
Answer

Answer. The first argument of select is the highest descriptor plus one, not the highest descriptor. With nfds = fd the kernel checks descriptors 0 to fd − 1 and never looks at fd.

ready = 0

Why. nfds is a count of bits to scan in the bitmaps. Here sv[1] is the highest open descriptor, so it is skipped and the call times out immediately with 0; with select(fd + 1, ...) it returns 1. Two more select traps: the sets are modified in place, so you must rebuild them before every call, and FD_SET on a descriptor ≥ FD_SETSIZE (usually 1024) writes past the bitmap, which is memory corruption. Use poll or epoll in new code.

Find the bugwrite_some behaves like send under pressure: it may write fewer bytes than asked. What does the peer receive?

#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>

static std::string wire;  // what the peer receives

static long write_some(const char* p, size_t n) {
    size_t k = std::min<size_t>(n, 4);
    wire.append(p, k);
    return long(k);
}

static void send_all(const char* buf, size_t len) {
    size_t sent = 0;
    while (sent < len) {
        long n = write_some(buf, len - sent);
        if (n < 0) return;
        sent += size_t(n);
    }
}

int main() {
    const char* msg = "hello world";
    send_all(msg, std::strlen(msg));
    std::printf("peer got \"%s\"\n", wire.c_str());
}
Answer

Answer. The loop tracks how much was sent but always passes buf, not buf + sent, so it resends the start of the message.

peer got "hellhellhel"

Why. The calls write "hell" (4), then "hell" again (4), then "hel" (the remaining length is 3), and the loop ends believing 11 bytes went out. The fix is write_some(buf + sent, len - sent). A real send_all must also retry on EINTR, treat EAGAIN on a non-blocking socket as "wait for POLLOUT", and stop on other errors. Partial writes are rare on blocking sockets, so this bug survives testing and appears under load.

Find the bugThis echo server compiles, accepts connections, and never echoes anything. After running for a while it stops accepting. Find two bugs.

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

int main() {
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in a{};
    a.sin_family = AF_INET;
    a.sin_addr.s_addr = htonl(INADDR_ANY);
    a.sin_port = htons(9000);
    bind(lfd, reinterpret_cast<sockaddr*>(&a), sizeof a);
    listen(lfd, 16);
    for (;;) {
        int cfd = accept(lfd, nullptr, nullptr);
        if (cfd < 0) continue;
        char buf[1024];
        ssize_t n = recv(lfd, buf, sizeof buf, 0);
        if (n > 0) send(cfd, buf, size_t(n), 0);
    }
}
Answer

Answer. (1) It reads from the listening socket lfd instead of the connected socket cfd; recv on a listening socket fails with ENOTCONN, so nothing is echoed. (2) It never closes cfd, so every client leaks a descriptor; at the ulimit -n limit (often 1024) accept fails with EMFILE, and the continue turns that into a busy loop at 100% CPU.

Why. accept returns a new descriptor per connection; the listening socket only produces connections, never data. Leaked sockets also leave clients hanging, since no FIN is ever sent. Further problems: no error checks on bind (a port in use goes unnoticed), no SO_REUSEADDR, a single recv per connection instead of a loop until 0, and no send_all. In C++, wrap descriptors in an RAII type so the close cannot be forgotten.

Find the bugAfter a restart, this server fails with "bind: Address already in use" for about a minute, even though it sets SO_REUSEADDR. Why?

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>

int main() {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in a{};
    a.sin_family = AF_INET;
    a.sin_addr.s_addr = htonl(INADDR_ANY);
    a.sin_port = htons(8080);
    if (bind(fd, reinterpret_cast<sockaddr*>(&a), sizeof a) < 0) {
        std::perror("bind");
        return 1;
    }
    int yes = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
    listen(fd, SOMAXCONN);
    for (;;) {
        int c = accept(fd, nullptr, nullptr);
        if (c >= 0) close(c);
    }
}
Answer

Answer. The option is set after bind. The address check happens inside bind, so SO_REUSEADDR must be set between socket and bind.

Why. This server closes every connection first (close(c) right after accept), so each of those connections sits in TIME_WAIT on the server for 60 seconds on Linux. A restarted process trying to bind port 8080 then conflicts with them unless SO_REUSEADDR was already set when it called bind. Many socket options have the same "before the call that uses them" rule: SO_REUSEPORT before bind, and buffer sizes (SO_RCVBUF) before listen/connect because the window scale is negotiated in the SYN.

Find the bugA client reads the body length from a response. It works against one server and reads an empty body from another. What does it print?

#include <cstdio>
#include <string>
#include <string_view>

static size_t content_length(std::string_view head) {
    size_t pos = head.find("Content-Length:");
    if (pos == std::string_view::npos) return 0;
    return std::stoul(std::string(head.substr(pos + 15)));
}

int main() {
    std::string_view a = "HTTP/1.1 200 OK\r\nContent-Length: 42\r\n\r\n";
    std::string_view b = "HTTP/1.1 200 OK\r\ncontent-length: 42\r\n\r\n";
    std::printf("%zu %zu\n", content_length(a), content_length(b));
}
Answer

Answer. HTTP header field names are case-insensitive (RFC 9110 section 5.1), and the lookup is case-sensitive.

42 0

Why. Servers, proxies and especially HTTP/2 and HTTP/3 (which require lowercase names on the wire) send content-length. Parse each header line into a name and a value, lowercase the name, and trim optional whitespace from the value. The substring search has more bugs: it would match "X-Content-Length:", it would find the text inside the body, and a missing header should not mean "length 0" (it means chunked encoding or read-until-close). std::stoul also throws on garbage, which a network parser must not do on hostile input.

OutputA server stores the peer address from accept in a sockaddr_in. What does this print, and what breaks when IPv6 clients arrive?

#include <netinet/in.h>
#include <sys/socket.h>
#include <cstdio>

int main() {
    std::printf("sockaddr %zu\n", sizeof(sockaddr));
    std::printf("sockaddr_in %zu\n", sizeof(sockaddr_in));
    std::printf("sockaddr_in6 %zu\n", sizeof(sockaddr_in6));
    std::printf("sockaddr_storage %zu\n", sizeof(sockaddr_storage));
}
Answer

Answer.

sockaddr 16
sockaddr_in 16
sockaddr_in6 28
sockaddr_storage 128

Why. An IPv6 address alone is 16 bytes, so sockaddr_in6 (family, port, flow info, address, scope id) does not fit in the 16-byte sockaddr or sockaddr_in. accept truncates the address to the length you pass and reports the real length, so code using sockaddr_in on a dual-stack listener silently gets a cut-off address. Use sockaddr_storage, which is large enough and suitably aligned for every family, and switch on ss_family. The sizes are the same on Linux and macOS (macOS adds a length byte but keeps the totals).

OutputThe peer has gone away. What does this print? What happens if you delete the signal line?

#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <csignal>
#include <cstdio>

int main() {
    std::signal(SIGPIPE, SIG_IGN);
    int sv[2];
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) return 1;
    close(sv[1]);
    ssize_t w = write(sv[0], "x", 1);
    std::printf("write returned %zd, EPIPE=%d\n", w, errno == EPIPE);
    char c;
    ssize_t r = read(sv[0], &c, 1);
    std::printf("read returned %zd\n", r);
    close(sv[0]);
}
Answer

Answer.

write returned -1, EPIPE=1
read returned 0

Why. Writing to a stream socket whose peer is closed raises SIGPIPE; ignored, the write fails with EPIPE instead. Without the signal line the default action of SIGPIPE terminates the process on the write, before anything is printed, with no error message. Reading returns 0, which is end of stream, not an error. With a local socket pair the kernel knows at once that the peer is gone; over TCP, as the drill in the concept section explains, the first write usually succeeds and the error appears on a later one, after the peer's RST.

Write the code

Coding

Every reference solution here runs: framing and parsing are tested on buffers, and the socket solutions use a socketpair or a loopback server on an ephemeral port with receive timeouts, so they cannot hang.

CodeImplement a length-prefixed message framer for a TCP stream. Each frame is a 4-byte big-endian length followed by that many payload bytes. FrameDecoder::feed(data, n, out) is called with whatever each recv returned (any split, including one byte at a time) and appends every complete frame to out. Reject frames larger than a configured maximum. Show that the output does not depend on how the stream is split.

Answer

Approach. Keep one buffer of unconsumed bytes. After appending new data, loop: if fewer than 4 bytes are buffered, stop; decode the length; if the whole payload is not buffered yet, stop; otherwise emit the frame and advance. Erase the consumed prefix once per call rather than once per frame, which keeps the cost linear. Check the length against the maximum before waiting for the payload, so a hostile peer cannot make you buffer 4 GiB.

#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>

static std::string encode_frame(const std::string& payload) {
    uint32_t n = static_cast<uint32_t>(payload.size());
    std::string out = {char(n >> 24), char(n >> 16), char(n >> 8), char(n)};
    return out + payload;
}

class FrameDecoder {
public:
    explicit FrameDecoder(uint32_t max_frame) : max_(max_frame) {}

    // Returns false if the stream is corrupt; the connection should then be closed.
    bool feed(const char* data, size_t n, std::vector<std::string>& out) {
        buf_.append(data, n);
        size_t pos = 0;
        while (buf_.size() - pos >= 4) {
            const auto* p = reinterpret_cast<const unsigned char*>(buf_.data() + pos);
            uint32_t len = uint32_t(p[0]) << 24 | uint32_t(p[1]) << 16 | uint32_t(p[2]) << 8 | p[3];
            if (len > max_) return false;
            if (buf_.size() - pos - 4 < len) break;  // payload not complete yet
            out.emplace_back(buf_, pos + 4, len);
            pos += 4 + size_t(len);
        }
        buf_.erase(0, pos);
        return true;
    }
    size_t buffered() const { return buf_.size(); }

private:
    std::string buf_;
    uint32_t max_;
};

static std::vector<std::string> decode(const std::string& stream, const std::vector<size_t>& sizes, bool& ok) {
    FrameDecoder d(1 << 20);
    std::vector<std::string> frames;
    size_t pos = 0, k = 0;
    ok = true;
    while (ok && pos < stream.size()) {
        size_t n = std::min(sizes[k++ % sizes.size()], stream.size() - pos);
        ok = d.feed(stream.data() + pos, n, frames);
        pos += n;
    }
    ok = ok && d.buffered() == 0;
    return frames;
}

int main() {
    std::vector<std::string> msgs = {"hello", "", "a longer message with spaces", std::string(300, 'z')};
    std::string stream;
    for (const auto& m : msgs) stream += encode_frame(m);
    std::vector<std::pair<const char*, std::vector<size_t>>> patterns = {
        {"1 byte at a time", {1}}, {"chunks 1,2,3,5,7", {1, 2, 3, 5, 7}}, {"all at once", {stream.size()}}};
    for (const auto& [name, sizes] : patterns) {
        bool ok;
        auto frames = decode(stream, sizes, ok);
        std::printf("%s: %zu frames, %s\n", name, frames.size(), ok && frames == msgs ? "match" : "MISMATCH");
    }
    FrameDecoder d(1 << 20);
    std::vector<std::string> out;
    const char evil[4] = {char(0xFF), char(0xFF), char(0xFF), char(0xFF)};
    std::printf("4 GiB length accepted: %s\n", d.feed(evil, 4, out) ? "yes" : "no");
}
1 byte at a time: 4 frames, match
chunks 1,2,3,5,7: 4 frames, match
all at once: 4 frames, match
4 GiB length accepted: no

Complexity and pitfalls. O(total bytes) time; memory is bounded by one maximum frame plus one recv buffer. Pitfalls: assuming the 4-byte header arrives in one piece, reading the length with memcpy into a uint32_t and forgetting ntohl, erasing from the front of the buffer after every frame (quadratic with many small frames), treating a zero-length frame as "no frame", and trusting the length before checking it. A ring buffer or a read offset with occasional compaction avoids the copy in erase.

Estimated rating: ~1500 · simple state machine, but partial headers and hostile lengths are where solutions break

CodeWrite send_all(fd, p, n) and recv_exact(fd, p, n) for a blocking stream socket. send_all must handle partial writes and EINTR and never raise SIGPIPE. recv_exact must return the number of bytes read, fewer than n only if the peer closed early, and −1 on error. Test by moving 1 MiB through a socket pair, which is far bigger than the socket buffer.

Answer

Approach. Both are loops that advance a pointer by whatever each call managed to transfer. EINTR means a signal interrupted the call before any data moved, so retry. recv returning 0 is end of stream. Because 1 MiB does not fit in the kernel buffer, the writer must run concurrently with the reader, so it gets its own thread.

#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <thread>
#include <vector>

static bool send_all(int fd, const char* p, size_t n) {
    while (n > 0) {
        ssize_t w = send(fd, p, n, MSG_NOSIGNAL);
        if (w < 0) {
            if (errno == EINTR) continue;
            return false;
        }
        p += w;
        n -= size_t(w);
    }
    return true;
}

static ssize_t recv_exact(int fd, char* p, size_t n) {
    size_t got = 0;
    while (got < n) {
        ssize_t r = recv(fd, p + got, n - got, 0);
        if (r == 0) break;  // peer closed
        if (r < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        got += size_t(r);
    }
    return ssize_t(got);
}

int main() {
    int sv[2];
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) return 1;
    std::vector<char> data(1 << 20);
    for (size_t i = 0; i < data.size(); ++i) data[i] = char(i * 31 + 7);
    std::thread writer([&data, fd = sv[0]] {
        send_all(fd, data.data(), data.size());
        close(fd);  // FIN: the reader will see end of stream
    });
    std::vector<char> in(data.size());
    ssize_t got = recv_exact(sv[1], in.data(), in.size());
    char extra;
    ssize_t tail = recv_exact(sv[1], &extra, 1);
    writer.join();
    std::printf("received %zd bytes, match=%d, next read %zd (EOF)\n", got, int(in == data), tail);
    close(sv[1]);
}
received 1048576 bytes, match=1, next read 0 (EOF)

Complexity and pitfalls. O(n) bytes copied, in as many system calls as the buffers require (hundreds here). Pitfalls: not advancing the pointer (see the reading drills), treating 0 as an error or as "try again" (an infinite loop on a closed socket), retrying on EAGAIN in a busy loop on a non-blocking socket (wait with poll instead), and forgetting MSG_NOSIGNAL. The writer thread captures the descriptor by value, so the two threads share no mutable state and the program is clean under ThreadSanitizer.

Estimated rating: ~1200 · short loops, judged on error handling and EOF semantics

CodeWrite a single-threaded TCP echo server that serves many clients at once with poll and non-blocking sockets. It must cope with partial writes (keep unsent bytes per client and wait for POLLOUT), clients that disconnect, and a clean shutdown request. Test it in-process: run the server on 127.0.0.1 with an ephemeral port in one thread, and two clients with receive timeouts in the main thread.

Answer

Approach. Keep a pollfd array: the listening socket, a pipe used as a stop signal, then one entry per client, with a parallel array of pending output. On readiness, accept all new clients, read each readable client until EAGAIN and append to its pending output, then write as much pending output as the socket takes. Ask for POLLOUT only while output is pending (otherwise poll returns at once forever). Close clients on EOF or error, and compact the arrays after each round.

#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <string>
#include <thread>
#include <vector>

static void set_nonblocking(int fd) { fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); }

static bool would_block() { return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; }

// Echoes every byte back to its sender until stop_fd becomes readable.
static void echo_server(int lfd, int stop_fd) {
    std::vector<pollfd> fds = {{lfd, POLLIN, 0}, {stop_fd, POLLIN, 0}};
    std::vector<std::string> out(2);  // pending output, parallel to fds
    for (;;) {
        if (poll(fds.data(), nfds_t(fds.size()), -1) < 0) {
            if (errno == EINTR) continue;
            break;
        }
        if (fds[1].revents) break;
        if (fds[0].revents & POLLIN) {
            for (int c; (c = accept(lfd, nullptr, nullptr)) >= 0;) {
                set_nonblocking(c);
                fds.push_back({c, POLLIN, 0});
                out.emplace_back();
            }
        }
        for (size_t i = 2; i < fds.size(); ++i) {
            bool dead = false;
            if (fds[i].revents & (POLLIN | POLLHUP | POLLERR)) {
                char buf[4096];
                for (;;) {
                    ssize_t n = recv(fds[i].fd, buf, sizeof buf, 0);
                    if (n > 0) { out[i].append(buf, size_t(n)); continue; }
                    if (n == 0 || !would_block()) dead = true;  // EOF or a real error
                    break;
                }
            }
            while (!out[i].empty()) {
                ssize_t w = send(fds[i].fd, out[i].data(), out[i].size(), MSG_NOSIGNAL);
                if (w > 0) { out[i].erase(0, size_t(w)); continue; }
                if (w < 0 && !would_block()) dead = true;
                break;
            }
            fds[i].events = short(out[i].empty() ? POLLIN : (POLLIN | POLLOUT));
            if (dead) {
                close(fds[i].fd);
                fds[i].fd = -1;
            }
        }
        size_t keep = 2;
        for (size_t i = 2; i < fds.size(); ++i) {
            if (fds[i].fd < 0) continue;
            fds[keep] = fds[i];
            out[keep] = std::move(out[i]);
            ++keep;
        }
        fds.resize(keep);
        out.resize(keep);
    }
    for (size_t i = 2; i < fds.size(); ++i) close(fds[i].fd);
}

static std::string roundtrip(int fd, const std::string& msg) {
    if (send(fd, msg.data(), msg.size(), 0) != ssize_t(msg.size())) return "send failed";
    std::string got(msg.size(), '\0');
    size_t have = 0;
    while (have < got.size()) {
        ssize_t r = recv(fd, got.data() + have, got.size() - have, 0);
        if (r <= 0) return "timeout or error";
        have += size_t(r);
    }
    return got;
}

int main() {
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    addr.sin_port = 0;  // let the kernel choose a free port
    socklen_t len = sizeof addr;
    if (lfd < 0 || bind(lfd, reinterpret_cast<sockaddr*>(&addr), sizeof addr) < 0 || listen(lfd, 16) < 0 ||
        getsockname(lfd, reinterpret_cast<sockaddr*>(&addr), &len) < 0) {
        std::perror("setup");
        return 1;
    }
    set_nonblocking(lfd);
    int stop[2];
    if (pipe(stop) < 0) return 1;
    std::thread server(echo_server, lfd, stop[0]);

    auto client = [&addr] {
        int fd = socket(AF_INET, SOCK_STREAM, 0);
        timeval tv{2, 0};
        setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);  // a broken server cannot hang the test
        connect(fd, reinterpret_cast<const sockaddr*>(&addr), sizeof addr);
        return fd;
    };
    int a = client(), b = client();
    std::printf("a: %s\n", roundtrip(a, "hello from a").c_str());
    std::printf("b: %s\n", roundtrip(b, "hello from b").c_str());
    std::string big(100000, 'x');
    std::printf("a: %s\n", roundtrip(a, big) == big ? "100000 bytes echoed" : "mismatch");
    close(a);
    std::printf("b after a left: %s\n", roundtrip(b, "still here").c_str());
    close(b);

    if (write(stop[1], "q", 1) != 1) return 1;
    server.join();
    close(stop[0]);
    close(stop[1]);
    close(lfd);
}
a: hello from a
b: hello from b
a: 100000 bytes echoed
b after a left: still here

Complexity and pitfalls. Each poll costs O(clients); for tens of thousands of clients switch to epoll or kqueue, keeping the same structure. Pitfalls: a blocking listening socket (one spurious wakeup and accept blocks the whole server), always requesting POLLOUT (busy loop), dropping unsent bytes after a short write, erasing from fds while iterating, and unbounded pending output: a client that sends but never reads makes this server buffer forever. Production servers stop reading from a client while its output exceeds a limit, which pushes backpressure to the client through TCP flow control.

Estimated rating: ~1900 · event-loop bookkeeping with several subtle readiness rules

CodeParse a complete HTTP/1.1 response held in a buffer: the status line, headers (case-insensitive names, trimmed values) and the body, using Transfer-Encoding: chunked if present, else Content-Length, else everything until the end. Return nothing for malformed or truncated input; never throw.

Answer

Approach. Split head and body at the first blank line (CRLF CRLF). Validate the status line by position: "HTTP/x.y", space, three digits, optional reason. Store header names lowercased. For chunked bodies loop: read a hex size line (ignoring extensions after ';'), copy that many bytes, require CRLF, stop at size 0. Parse numbers by hand, so bad input yields "invalid" instead of an exception.

#include <cctype>
#include <cstdio>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

struct Response {
    int status = 0;
    std::string reason;
    std::vector<std::pair<std::string, std::string>> headers;  // names lowercased
    std::string body;
    const std::string* header(std::string_view name) const {
        for (const auto& [k, v] : headers)
            if (k == name) return &v;
        return nullptr;
    }
};

static std::string lower(std::string_view s) {
    std::string r(s);
    for (char& c : r) c = char(std::tolower(static_cast<unsigned char>(c)));
    return r;
}

static std::string_view trim(std::string_view s) {
    while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
    while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1);
    return s;
}

static std::optional<size_t> parse_num(std::string_view s, int base) {
    if (s.empty() || s.size() > 12) return std::nullopt;
    size_t v = 0;
    for (char c : s) {
        int d;
        if (c >= '0' && c <= '9') d = c - '0';
        else if (base == 16 && c >= 'a' && c <= 'f') d = c - 'a' + 10;
        else if (base == 16 && c >= 'A' && c <= 'F') d = c - 'A' + 10;
        else return std::nullopt;
        v = v * size_t(base) + size_t(d);
    }
    return v;
}

static std::optional<std::string> decode_chunked(std::string_view s) {
    std::string body;
    for (;;) {
        size_t eol = s.find("\r\n");
        if (eol == std::string_view::npos) return std::nullopt;
        std::string_view line = s.substr(0, eol);
        auto n = parse_num(trim(line.substr(0, line.find(';'))), 16);
        if (!n) return std::nullopt;
        s.remove_prefix(eol + 2);
        if (*n == 0) return body;  // trailers (if any) are ignored here
        if (s.size() < *n + 2 || s.substr(*n, 2) != "\r\n") return std::nullopt;
        body.append(s.substr(0, *n));
        s.remove_prefix(*n + 2);
    }
}

static std::optional<Response> parse_response(std::string_view raw) {
    size_t end = raw.find("\r\n\r\n");
    if (end == std::string_view::npos) return std::nullopt;
    std::string_view head = raw.substr(0, end), rest = raw.substr(end + 4);
    size_t eol = head.find("\r\n");
    std::string_view status = head.substr(0, eol);
    if (status.size() < 12 || status.substr(0, 5) != "HTTP/" || status[8] != ' ') return std::nullopt;
    auto code = parse_num(status.substr(9, 3), 10);
    if (!code || *code < 100 || *code > 599 || (status.size() > 12 && status[12] != ' ')) return std::nullopt;
    Response r;
    r.status = int(*code);
    r.reason = std::string(status.size() > 13 ? status.substr(13) : std::string_view{});
    head = eol == std::string_view::npos ? std::string_view{} : head.substr(eol + 2);
    while (!head.empty()) {
        size_t e = head.find("\r\n");
        std::string_view line = head.substr(0, e);
        head = e == std::string_view::npos ? std::string_view{} : head.substr(e + 2);
        size_t colon = line.find(':');
        if (colon == std::string_view::npos || colon == 0) return std::nullopt;
        r.headers.emplace_back(lower(line.substr(0, colon)), std::string(trim(line.substr(colon + 1))));
    }
    if (const std::string* te = r.header("transfer-encoding"); te && lower(*te).find("chunked") != std::string::npos) {
        auto body = decode_chunked(rest);
        if (!body) return std::nullopt;
        r.body = std::move(*body);
    } else if (const std::string* cl = r.header("content-length")) {
        auto n = parse_num(*cl, 10);
        if (!n || rest.size() < *n) return std::nullopt;
        r.body = std::string(rest.substr(0, *n));
    } else {
        r.body = std::string(rest);  // delimited by the server closing the connection
    }
    return r;
}

int main() {
    const char* tests[] = {
        "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhelloEXTRA",
        "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n7\r\nMozilla\r\n11\r\nDeveloper Network\r\n0\r\n\r\n",
        "HTTP/1.1 404 Not Found\r\ncontent-length:   9  \r\n\r\nnot found",
        "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort",
        "HTP/1.1 200 OK\r\n\r\n",
    };
    for (const char* t : tests) {
        auto r = parse_response(t);
        if (!r) {
            std::printf("invalid\n");
            continue;
        }
        const std::string* type = r->header("content-type");
        std::printf("%d %s type=%s body=\"%s\"\n", r->status, r->reason.c_str(), type ? type->c_str() : "-", r->body.c_str());
    }
}
200 OK type=text/plain body="hello"
200 OK type=- body="MozillaDeveloper Network"
404 Not Found type=- body="not found"
invalid
invalid

Complexity and pitfalls. O(n). In the first test "EXTRA" is not part of the body: on a keep-alive connection it is the start of the next response, which is why Content-Length must be honoured exactly. Security pitfalls: a message with both Transfer-Encoding and Content-Length must use chunked (and proxies should reject it), because disagreeing parsers enable request smuggling; chunk sizes must be bounded; and headers must not be accepted without a colon. A streaming parser would also need to work incrementally, like the frame decoder above.

Estimated rating: ~1600 · careful string handling with a protocol state machine for chunked bodies

CodeModel TCP Reno's congestion window in segments at RTT granularity. Events: R (a round trip in which everything was acknowledged), D (three duplicate ACKs) and T (retransmission timeout). Start with cwnd 1 and ssthresh 16, print cwnd after each event, and report the send window for a given receive window.

Answer

Approach. Follow RFC 5681 with FlightSize approximated by cwnd. Per RTT: below ssthresh, double (capped at ssthresh, so slow start ends exactly there); otherwise add one. Triple duplicate ACK: ssthresh = max(cwnd/2, 2) and, after fast recovery completes, cwnd = ssthresh. Timeout: same ssthresh, cwnd = 1. The usable window is min(cwnd, rwnd).

#include <algorithm>
#include <cstdio>
#include <string>

class Reno {
public:
    explicit Reno(int ssthresh) : ssthresh_(ssthresh) {}
    void on_rtt_all_acked() {
        if (cwnd_ < ssthresh_) cwnd_ = std::min(cwnd_ * 2, ssthresh_);  // slow start
        else cwnd_ += 1;                                                // congestion avoidance
    }
    void on_triple_dupack() {  // fast retransmit, then fast recovery ends at ssthresh
        ssthresh_ = std::max(cwnd_ / 2, 2);
        cwnd_ = ssthresh_;
    }
    void on_timeout() {
        ssthresh_ = std::max(cwnd_ / 2, 2);
        cwnd_ = 1;
    }
    int cwnd() const { return cwnd_; }
    int ssthresh() const { return ssthresh_; }
    int send_window(int rwnd) const { return std::min(cwnd_, rwnd); }

private:
    int cwnd_ = 1;
    int ssthresh_;
};

int main() {
    Reno r(16);
    const std::string events = "RRRRRRRRRRDRRRRRTRRRRR";
    for (char e : events) {
        if (e == 'R') r.on_rtt_all_acked();
        else if (e == 'D') r.on_triple_dupack();
        else r.on_timeout();
        std::printf("%c:%d ", e, r.cwnd());
    }
    std::printf("\nssthresh=%d, send window with rwnd=6: %d\n", r.ssthresh(), r.send_window(6));
}
R:2 R:4 R:8 R:16 R:17 R:18 R:19 R:20 R:21 R:22 D:11 R:12 R:13 R:14 R:15 R:16 T:1 R:2 R:4 R:8 R:9 R:10
ssthresh=8, send window with rwnd=6: 6

Complexity and pitfalls. O(1) per event. This is exactly the sawtooth in the congestion control figure. Real stacks count in bytes and update per ACK (slow start adds one MSS per ACK, congestion avoidance adds MSS·MSS/cwnd), inflate cwnd by one MSS per duplicate ACK during fast recovery, and use CUBIC rather than Reno's linear growth by default on Linux. Interview follow-ups: why a timeout is punished harder than duplicate ACKs (the ACK clock has stopped), and why the final window is 6 here (flow control, the receiver's limit, wins over congestion control).

Estimated rating: ~1300 · simulation is easy once the rules are known; the test is knowing them

CodeImplement a TCP receive-side reassembler. on_segment(seq, data) receives segments in any order, possibly duplicated, overlapping or outside the window, and appends bytes to the in-order stream as soon as they become contiguous. ack() returns the next expected sequence number. Sequence numbers are 32-bit and wrap around; test with an ISN just below 232.

Answer

Approach. Compare sequence numbers with serial arithmetic: the offset of a segment from the next expected byte is int32_t(seq - next), which is correct across wrap-around while the window is below 231. Trim the part of a segment we already have, drop it if nothing remains or it falls outside the window, then store it in an ordered map keyed by a 64-bit absolute stream offset (which never wraps). After each insert, drain map entries that start at or before the delivered offset.

#include <cstdint>
#include <cstdio>
#include <map>
#include <string>

class Reassembler {
public:
    Reassembler(uint32_t isn, uint32_t window) : next_(isn), window_(window) {}

    void on_segment(uint32_t seq, std::string data) {
        int32_t off = int32_t(seq - next_);  // serial number arithmetic
        if (off < 0) {
            uint64_t have = uint64_t(-int64_t(off));
            if (have >= data.size()) return;  // pure duplicate
            data.erase(0, size_t(have));
            off = 0;
        }
        if (uint64_t(off) + data.size() > window_) return;  // outside the receive window
        std::string& slot = pending_[delivered_ + uint64_t(off)];
        if (data.size() > slot.size()) slot = std::move(data);
        while (!pending_.empty() && pending_.begin()->first <= delivered_) {
            auto it = pending_.begin();
            uint64_t skip = delivered_ - it->first;
            if (skip < it->second.size()) {
                uint64_t added = it->second.size() - skip;
                stream_.append(it->second, size_t(skip), std::string::npos);
                delivered_ += added;
                next_ += uint32_t(added);
            }
            pending_.erase(it);
        }
    }
    uint32_t ack() const { return next_; }
    const std::string& stream() const { return stream_; }

private:
    uint32_t next_;
    uint32_t window_;
    uint64_t delivered_ = 0;
    std::map<uint64_t, std::string> pending_;  // keyed by absolute stream offset
    std::string stream_;
};

int main() {
    const uint32_t isn = 0xFFFFFFF0;  // wraps after 16 bytes
    Reassembler r(isn, 64);
    struct Seg { uint32_t seq; const char* data; };
    const Seg segs[] = {{isn + 10, "KLMNO"}, {isn, "ABCDE"}, {isn + 5, "FGHIJ"},
                        {isn, "ABC"}, {isn + 13, "NOPQRSTU"}, {isn + 500, "far"}};
    for (const Seg& s : segs) {
        r.on_segment(s.seq, s.data);
        std::printf("seg %08x %-8s -> ack %08x stream %s\n", s.seq, s.data, r.ack(), r.stream().c_str());
    }
}
seg fffffffa KLMNO    -> ack fffffff0 stream
seg fffffff0 ABCDE    -> ack fffffff5 stream ABCDE
seg fffffff5 FGHIJ    -> ack ffffffff stream ABCDEFGHIJKLMNO
seg fffffff0 ABC      -> ack ffffffff stream ABCDEFGHIJKLMNO
seg fffffffd NOPQRSTU -> ack 00000005 stream ABCDEFGHIJKLMNOPQRSTU
seg 000001e4 far      -> ack 00000005 stream ABCDEFGHIJKLMNOPQRSTU

Complexity and pitfalls. O(log k) per segment for k buffered segments, plus copying. The decisive bug to avoid is comparing raw 32-bit sequence numbers with <: after the wrap, 0x00000005 is "after" 0xfffffffd. Keying the map by raw sequence numbers fails the same way, hence the 64-bit offsets. The out-of-order "KLMNO" segment is held, and the cumulative ACK stays at the hole until "FGHIJ" fills it, which is when a real receiver would have been sending duplicate ACKs (and SACK blocks). Real TCP also trims segments to the window instead of dropping them, and bounds total buffered bytes.

Estimated rating: ~1800 · interval merging plus wrap-around arithmetic that most first attempts get wrong

Cheat sheet

Theory
  • TCP vs UDP: TCP (RFC 9293) = connection, reliable, ordered byte stream, flow and congestion control, 20 to 60 B header. UDP (RFC 768) = datagrams, best effort, boundaries kept, 8 B header, multicast. QUIC builds TCP-like reliability on UDP.
  • Handshake: SYN(x), SYN+ACK(y, x+1), ACK(y+1). SYN and FIN each consume one sequence number. The kernel completes handshakes; accept just dequeues. Backlog = accept queue, capped by somaxconn.
  • Teardown: FIN, ACK, FIN, ACK; half-close with shutdown(SHUT_WR). Active closer gets TIME_WAIT for 2·MSL (60 s on Linux) to re-ACK a lost FIN and let old segments die. Many CLOSE_WAIT = your app forgot close. Set SO_REUSEADDR before bind.
  • Reliability: cumulative ACKs, SACK, RTO = SRTT + 4·RTTVAR (RFC 6298) with backoff and Karn's rule, fast retransmit after 3 duplicate ACKs. Compare sequence numbers with (int32_t)(a - b) < 0.
  • Windows: in flight ≤ min(rwnd, cwnd). rwnd = receiver buffer (flow control, window scale up to 1 GiB). cwnd: slow start doubles per RTT, congestion avoidance +1 MSS per RTT, 3 dup ACKs halve, timeout resets to 1 (RFC 5681). Linux default CUBIC.
  • Latency: Nagle + delayed ACK on write-write-read costs 40 to 200 ms; send each message in one call and set TCP_NODELAY. TCP HOL blocking remains in HTTP/2; HTTP/3 over QUIC removes it.
  • Sockets: socket, (setsockopt), bind, listen, accept (new fd) / connect; loop on send and recv; recv 0 = EOF; htons/htonl for ports and addresses; ignore SIGPIPE or use MSG_NOSIGNAL; frame messages (length prefix or delimiter); select nfds = max fd + 1; epoll for scale.
  • HTTP and TLS: HTTP/1.1 needs Host, keeps connections alive by default, bodies by Content-Length or chunked; idempotent = GET, HEAD, PUT, DELETE, OPTIONS. TLS 1.3 = 1 RTT, ephemeral key share in ClientHello, certificate encrypted, forward secrecy, 0-RTT replayable. A cold HTTPS fetch over TCP costs about 3 RTTs before the first byte.

Part E · Memory management · Chapter 9

new/delete vs malloc/free

A new-expression is two operations glued together: it gets raw memory from an allocation function, then runs a constructor in it. malloc does only the first half. This chapter takes both apart, from failure modes and array cookies to what glibc does under the hood, because interviewers use the topic to test whether you understand object lifetime and the heap at the same time.

TheoryDrillsCoding· about 3 hours· prerequisites: Ch 1 (constructors, destructors, lifetime)

What a new-expression actually does

Theory

Mental model. new T(args) is a language construct, not a function. The compiler turns it into: call an allocation function named operator new to get sizeof(T) raw bytes, then run T's constructor in those bytes, then hand you a typed T*. delete p is the mirror image: run p->~T(), then call operator delete to give the bytes back. malloc and free are plain library functions that only deal in bytes; they know nothing about types, constructors or destructors.

new T(args) operator new(sizeof(T)) raw bytes, may throw T::T(args) lifetime begins T* p constructor throws: operator delete(raw), rethrow delete p p->~T() lifetime ends operator delete(p) bytes returned malloc(n) bytes only, void* free(p) bytes only no constructor, no destructor
A new-expression is allocation plus construction. If the constructor throws, the compiler-generated code calls the matching operator delete on the raw bytes and lets the exception continue; the destructor never runs because the object never existed. malloc and free stop at the blue boxes.

"new operator" vs "operator new"

Interviewers like this pair of names. The new operator (the standard calls it a new-expression) is the thing you write: new Widget(3). You cannot change what it does. operator new is the allocation function it calls. You can replace it globally or overload it per class, and you can call it directly: void* raw = ::operator new(64); gives you 64 bytes and constructs nothing. The same split exists for delete: the delete-expression always runs the destructor, then calls operator delete, which only frees.

When the constructor throws

The language guarantees no leak if a constructor called from a new-expression throws: [expr.new] requires the implementation to call the deallocation function that matches the allocation function used. That is why every placement form of operator new you write should have a matching placement operator delete. If it does not, the compiler cannot free the memory when the constructor throws, and you leak silently. The program below traces both paths with a class-specific allocation function (class-specific functions are never elided, so the trace is reliable).

#include <cstdio>
#include <cstdlib>
#include <new>
#include <stdexcept>

struct Widget {
    int id;
    explicit Widget(int i) : id(i) {
        if (i < 0) throw std::invalid_argument("negative id");
        std::printf("  Widget(%d)\n", id);
    }
    ~Widget() { std::printf("  ~Widget(%d)\n", id); }

    static void* operator new(std::size_t n) {
        std::printf("  operator new(%zu)\n", n);
        if (void* p = std::malloc(n)) return p;
        throw std::bad_alloc{};
    }
    static void operator delete(void* p) noexcept {
        std::puts("  operator delete");
        std::free(p);
    }
};

int main() {
    std::puts("new Widget(7):");
    Widget* w = new Widget(7);
    std::puts("delete w:");
    delete w;
    std::puts("new Widget(-1):");
    try {
        Widget* bad = new Widget(-1);
        delete bad;
    } catch (const std::invalid_argument& e) {
        std::printf("  caught: %s\n", e.what());
    }
}
new Widget(7):
  operator new(4)
  Widget(7)
delete w:
  ~Widget(7)
  operator delete
new Widget(-1):
  operator new(4)
  operator delete
  caught: negative id

Notice that the failed construction prints no destructor. A destructor runs only for an object whose constructor finished; the members and bases that were fully built are destroyed, and the storage is released.

What malloc gives you

void* malloc(std::size_t n) returns a pointer to at least n bytes aligned for any fundamental type (at least alignof(std::max_align_t): 16 on x86-64 and arm64 Linux; on Apple arm64 max_align_t is only 8, though macOS malloc still returns 16-byte aligned blocks), or a null pointer on failure. No object of class type exists in that memory until you create one, usually with placement new (Ch 14). Since C++20 (P0593, "implicit creation of objects") malloc does implicitly create objects of implicit-lifetime types such as int, arrays and aggregates without a user-provided destructor, which made the decades-old C idiom int* a = (int*)malloc(10 * sizeof(int)); officially well-defined. For a type like std::string it is still undefined behaviour to use the memory without constructing an object first.

Failure behaviour: bad_alloc, nothrow and the new_handler

Theory

Mental model. malloc reports failure by returning null. A plain new-expression never returns null: the global operator new either returns memory or throws std::bad_alloc. Before it gives up it calls a user-installable hook, the new_handler, in a loop.

The operator new loop

The standard ([new.delete.single]) describes the default operator new(std::size_t) as this loop:

void* operator new(std::size_t n) {
    if (n == 0) n = 1;                       // must return a unique non-null pointer
    for (;;) {
        if (void* p = std::malloc(n)) return p;
        std::new_handler h = std::get_new_handler();
        if (!h) throw std::bad_alloc{};
        h();                                 // then try again
    }
}

A new_handler must do one of four things: make more memory available (for example release an emergency reserve), install a different handler, uninstall itself with std::set_new_handler(nullptr) so the next iteration throws, or not return at all (throw bad_alloc or call std::abort). A handler that returns without changing anything turns the loop into an infinite loop.

nothrow new

new (std::nothrow) T calls operator new(std::size_t, const std::nothrow_t&), which returns null instead of throwing. Two details catch people out. First, it only suppresses allocation failure: if T's constructor throws, the exception still propagates. Second, since C++11 the default nothrow version is specified as calling the throwing version inside a try block, so it still runs the new_handler loop first. There is also std::bad_array_new_length (derived from bad_alloc), which new T[n] throws when n is negative or n * sizeof(T) overflows. GCC follows that rule; Clang passes SIZE_MAX to operator new[] instead, which then throws a plain bad_alloc, so always catch bad_alloc.

Interview trap

"On Linux, if new succeeded, the memory exists." Not necessarily. With the default overcommit policy (vm.overcommit_memory = 0) the kernel hands out virtual address space and only finds physical pages when you first touch them. A large new can succeed and the process can be killed later by the OOM killer while writing to the memory, with no exception anywhere. Mode 0 refuses only obviously impossible requests (more than RAM plus swap); mode 2 does strict accounting against CommitLimit, which is when bad_alloc becomes reliable. Checking for bad_alloc is still correct code; just do not promise the interviewer that it protects you from running out of RAM.

Seeing the failure modes

Asking for 262 bytes fails on any real machine, so the program below shows all three behaviours. The volatile sink stops the compiler from removing the allocations, which C++14 allows (see the tricky drills), and the volatile size stops Clang from rejecting the constant array bound at compile time ("array is too large").

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <new>

static void* volatile sink;
static volatile std::size_t huge_v = std::size_t(1) << 62;  // hides the constant from Clang

int main() {
    const std::size_t huge = huge_v;

    char* p = new (std::nothrow) char[huge];
    sink = p;
    std::puts(p ? "nothrow: got memory" : "nothrow: null");

    try {
        char* q = new char[huge];
        sink = q;
    } catch (const std::bad_alloc&) {
        std::puts("plain new: bad_alloc");
    }

    std::size_t n = SIZE_MAX / 2;           // n * sizeof(int) overflows
    try {
        int* r = new int[n];
        sink = r;
    } catch (const std::bad_alloc& e) {     // bad_array_new_length is a bad_alloc
        std::puts("overflowing array size: caught a bad_alloc");
    }
}
nothrow: null
plain new: bad_alloc
overflowing array size: caught a bad_alloc

This program is not tagged for ASan on purpose: ASan treats a request that large as a bug in its own right and aborts unless you set allocator_may_return_null=1.

Type safety, sizes and the comparison table

Theory

Mental model. new knows the type, so it computes the size, returns a correctly typed pointer and runs constructors. malloc knows only a byte count, so you must compute the size, cast the result and construct objects yourself. Everything else in the table follows from that.

// C style: three chances to get it wrong (size, cast, no construction)
Point* a = static_cast<Point*>(std::malloc(sizeof(Point)));
// C++ style: size, type and construction come from the type
Point* b = new Point{1, 2};

A classic C bug is malloc(sizeof(p)) instead of malloc(sizeof(*p)), which allocates the size of a pointer. new cannot make that mistake. In C you do not cast malloc's result (the implicit conversion from void* hides a missing #include in old C); in C++ the cast is required because void* does not convert implicitly to T*.

new / deletemalloc / free
What it isLanguage expression (calls operator new)Library function
SizeComputed from the typeYou pass bytes
Return typeT*void*, cast needed in C++
ConstructionRuns constructor / destructorNone (C++20: implicit-lifetime types only)
FailureThrows std::bad_alloc (nothrow form returns null)Returns null
Hook on failurestd::set_new_handlerNone
Arraysnew[] / delete[], may store a cookieOne block, you do the arithmetic
ResizeNo equivalentrealloc
CustomisableReplace globally, overload per class, placement formsOnly by interposing the symbol (LD_PRELOAD)
Over-aligned typesAutomatic since C++17 (align_val_t)aligned_alloc, posix_memalign
Null argumentdelete nullptr is a no-opfree(NULL) is a no-op
Size 0Unique non-null pointerNull or a unique pointer (implementation-defined)

Know this cold

new = operator new + constructor; delete = destructor + operator delete. new throws, malloc returns null. Pair every allocation with its own deallocation form: new/delete, new[]/delete[], malloc/free. Anything else is undefined behaviour even when it seems to work.

new[], delete[] and the array cookie

Theory

Mental model. delete[] p receives only a pointer, yet it must run the destructor on every element. So when the element type has a non-trivial destructor, new T[n] asks for a few extra bytes and stores n just before the first element. That hidden header is the array cookie.

cookie n = 3 (8 bytes) T[0] T[1] T[2] operator new[](3·sizeof(T) + 8) returns here new T[3] yields p here delete[] p reads n at p − 8, runs ~T() three times (T[2], T[1], T[0]), frees p − 8
The cookie sits in front of the elements. Under the Itanium C++ ABI (Linux, x86-64 and generic arm64) the cookie is max(sizeof(std::size_t), alignof(T)) bytes and exists only when T has a non-trivial destructor or when the operator delete[] that will be used takes a size parameter. Apple's arm64 ABI stores both element size and count (16 bytes). delete p without brackets frees p itself, which is not the address malloc returned.
  • Trivially destructible element type (int, a POD struct): no cookie. Nothing must run per element, and the allocator knows the block size anyway. This is why int* p = new int[10]; delete p; usually "works": it is still undefined behaviour.
  • Non-trivial destructor: cookie present. delete p on such an array runs one destructor and passes the wrong address to operator delete, which typically crashes the allocator or corrupts the heap.
  • Sized array delete: if the usual deallocation function for the class is operator delete[](void*, std::size_t), the cookie is present even for a trivially destructible type, because the size passed to delete[] must be recomputed from the count. Give struct Plain below such a class-specific operator and GCC asks for 20 bytes instead of 12.
  • The cookie is an ABI detail, not a language rule. MSVC does something similar. The language rule is only: use delete[] for new[].
  • Elements are destroyed in reverse order of construction. If the k-th constructor throws during new T[n], the k already-built elements are destroyed in reverse order and the storage is released with operator delete[].

You can see the cookie by replacing operator new[] and printing the requested size:

#include <cstdio>
#include <cstdlib>
#include <new>

void* operator new[](std::size_t n) {
    std::printf("operator new[](%zu)\n", n);
    if (void* p = std::malloc(n)) return p;
    throw std::bad_alloc{};
}
void operator delete[](void* p) noexcept { std::free(p); }

struct Plain { int v; };                 // trivial destructor
struct Owner { int v; ~Owner() {} };     // user-provided destructor

static void* volatile sink;              // keeps the allocations alive

int main() {
    Plain* a = new Plain[3]; sink = a; delete[] a;
    Owner* b = new Owner[3]; sink = b; delete[] b;
}
operator new[](12)
operator new[](20)

The output shown is GCC's (Itanium ABI: 12 bytes of elements plus an 8-byte cookie). Clang on Linux prints the same. Clang on Apple arm64 prints 28 for the second line because Apple's ABI cookie is 16 bytes.

Mismatched allocation and deallocation

Theory

Mental model. Each allocation family owns its own bookkeeping. Handing a pointer to the wrong family is undefined behaviour ([expr.delete]: the operand of non-array delete must come from a non-array new-expression, and vice versa; C says free must receive a pointer from malloc, calloc, realloc or aligned_alloc).

PairWhy it breaksWhat usually happens
new T then freeDestructor skipped; operator new may not be malloc (replaced, class-specific, aligned)Leak of whatever the object owned; heap corruption if operator new is not malloc
malloc then deleteDestructor runs on an object that never existed; operator delete may not be freeUB in the destructor; works by luck for int
new T[n] then deleteOne destructor; wrong address freed when there is a cookieCrash or heap corruption for non-trivial T
new T then delete[]Reads a cookie that does not existRuns a garbage number of destructors
new of derived, delete via base without virtual destructorWrong destructor and wrong size (Ch 2)Leaks, sized-delete mismatch

On glibc, global operator new happens to call malloc, so new int followed by free appears to work. That is the most dangerous kind of UB: it passes tests and breaks the day someone links tcmalloc, adds a class-specific operator new, or compiles with ASan. On Linux, ASan reports alloc-dealloc-mismatch for the first four rows of the table; the last row shows up as new-delete-type-mismatch when sized deallocation is on. On macOS the check is off by default (ASAN_OPTIONS=alloc_dealloc_mismatch=1 turns it on), which is why the drills in this chapter prove the array case with a type whose cookie makes the bug visible everywhere.

Tip

The real fix is to stop writing delete at all. std::vector, std::unique_ptr<T[]> and std::make_unique (Ch 11) pick the right deallocation form for you.

realloc, and why C++ has no renew

Theory

realloc(p, n) grows or shrinks a malloc block. If it can extend in place it returns p; otherwise it allocates a new block, copies the old bytes with the equivalent of memcpy, frees the old block and returns the new pointer. On failure it returns null and leaves the old block alive, so p = realloc(p, n); leaks p when it fails. Write void* q = realloc(p, n); if (!q) { /* p is still valid */ } else p = q;.

C++ has no equivalent for objects because a bytewise copy is not a valid way to move most objects:

  • An object may point into itself. libstdc++'s std::string holds a pointer to its own small buffer, so after a memcpy to a new address the copy points into freed memory.
  • Other objects may hold pointers to it (an intrusive list node, an observer registration). Moving the bytes does not update them.
  • Moving requires running move constructors and destructors, which can throw, so containers need std::move_if_noexcept to keep the strong guarantee (Ch 20).

So std::vector grows by allocating a new block, move-constructing elements into it, destroying the old ones and freeing the old block. Libraries such as folly's fbvector use realloc for types they mark as relocatable. The standard has crept towards the missing pieces: C++23 added std::allocator::allocate_at_least, which tells you how much memory you really got. Trivial relocation is not in C++26. P2786 was voted into the C++26 working draft in February 2025 (Hagenberg), then removed again in November 2025 (Kona, P3920) because of unresolved design concerns; the competing P1144 was never adopted. The committee aims to return to the topic for C++29, so today "relocatable" is still a library-specific trait.

One glibc detail worth knowing: when the block is large enough to have been served by mmap, realloc uses mremap, which moves page table entries instead of copying bytes. Growing a 1 GB buffer that way costs almost nothing.

#include <cstdio>
#include <cstdlib>
#include <cstring>

int main() {
    int* a = static_cast<int*>(std::malloc(4 * sizeof(int)));
    if (!a) return 1;
    for (int i = 0; i < 4; ++i) a[i] = i * i;

    int* grown = static_cast<int*>(std::realloc(a, 1000 * sizeof(int)));
    if (!grown) { std::free(a); return 1; }  // on failure a is still ours
    a = grown;                               // the old value of a is now invalid
    for (int i = 4; i < 1000; ++i) a[i] = i * i;

    std::printf("%d %d %d\n", a[0], a[3], a[999]);
    std::free(a);
}
0 9 998001

Overloading operator new and operator delete

Theory

Mental model. You cannot change what a new-expression does, but you choose which allocation function it calls. Name lookup for new T first looks in the scope of T (class-specific operator new), then at global scope. ::new T skips the class and goes straight to the global one.

Replacing the global functions

The standard library defines the global allocation functions as replaceable: if your program defines void* operator new(std::size_t) at global scope with exactly that signature, the linker uses yours everywhere, including inside the standard library. The full set is: single and array, throwing and nothrow, with and without std::align_val_t (C++17), and sized delete (C++14). The defaults are defined in terms of each other: operator new[] calls operator new, sized delete calls unsized delete, and the nothrow forms call the throwing ones. Replacing the plain operator new and operator delete therefore catches most allocations, but replace the aligned pair as well if you track over-aligned types. Rules for a replacement:

  • Return memory suitably aligned for any object of that size (at least __STDCPP_DEFAULT_NEW_ALIGNMENT__, 16 on mainstream 64-bit targets).
  • Size 0 must return a unique non-null pointer. Failure must throw bad_alloc (or call the new_handler loop).
  • Do not call anything that allocates through operator new from inside it (std::cout, std::string, std::map), or you recurse. Use printf or write and fixed-size storage.
  • Do not put it in an inline function or a namespace; it must be a global, non-inline definition.

Class-specific allocation

Declaring static void* operator new(std::size_t) in a class (the static is implicit even if you omit it) makes every new T use it. Typical uses: a free-list pool for a hot node type (Ch 15), tracking, or placing objects in shared memory. Two rules: it is inherited, so a derived class that is bigger gets the base's allocator with a larger n (check n == sizeof(Base) or forward to ::operator new); and you should declare the matching operator delete, otherwise the global one frees memory your allocator handed out.

struct Node {
    static void* operator new(std::size_t n);           // used by new Node
    static void  operator delete(void* p) noexcept;     // used by delete and on ctor throw
    static void* operator new(std::size_t n, Arena& a); // placement form: new (arena) Node
    static void  operator delete(void* p, Arena& a) noexcept; // called only if the ctor throws
};

Placement forms, sized and destroying delete

Any operator new with extra parameters is a "placement" form, invoked as new (args...) T. The one in <new> that takes a void* and returns it unchanged is the famous placement new, which constructs an object at an address you provide; it gets its own chapter (Ch 14). Sized delete (C++14) gives operator delete(void*, std::size_t) the object size, so allocators with size classes skip a lookup; GCC enables it by default, and Clang does since version 19. Destroying delete (C++20), void operator delete(T*, std::destroying_delete_t), receives the object before the destructor has run and becomes responsible for destroying it, which lets a class with a variable-length tail compute its real size.

Aligned new (C++17) and zero-size allocations

Theory

Over-aligned types

Before C++17, new on a type declared alignas(64) only guaranteed the default alignment (16), so cache-line aligned structures silently came out misaligned and SIMD code crashed. C++17 (P0035) added operator new(std::size_t, std::align_val_t). When alignof(T) > __STDCPP_DEFAULT_NEW_ALIGNMENT__, the new-expression calls the aligned form and the delete-expression calls the matching operator delete(void*, std::align_val_t). You get this for free with new, std::make_unique and std::vector; with malloc you must call aligned_alloc(alignment, size) (size must be a multiple of the alignment in C11; C17 relaxed that) or posix_memalign.

#include <cstdint>
#include <cstdio>
#include <memory>
#include <vector>

struct alignas(64) CacheLine { long counter = 0; };

static bool aligned64(const void* p) {
    return reinterpret_cast<std::uintptr_t>(p) % 64 == 0;
}

int main() {
    std::printf("default new alignment: %zu\n", std::size_t(__STDCPP_DEFAULT_NEW_ALIGNMENT__));
    auto* a = new CacheLine;
    auto b = std::make_unique<CacheLine>();
    std::vector<CacheLine> v(3);
    std::printf("new: %d, make_unique: %d, vector: %d\n",
                aligned64(a), aligned64(b.get()), aligned64(v.data()));
    delete a;   // calls operator delete(void*, std::align_val_t)
}
default new alignment: 16
new: 1, make_unique: 1, vector: 1

Zero-size allocations

  • operator new(0) and new T[0] must return a non-null pointer distinct from every other live allocation. You may not dereference it, but you must delete it (delete[] for the array). Implementations treat the request as 1 byte or as the minimum chunk.
  • malloc(0) is implementation-defined: either null or a unique pointer that must be passed to free. glibc and macOS return a unique minimum-size chunk; code that treats a null return from malloc(0) as out of memory is not portable.
  • realloc(p, 0) is the nastiest: historically it freed p on some systems and returned a minimum block on others. C23 made it undefined behaviour. Do not use it.
  • Why must C++ return unique pointers? Distinct objects must have distinct addresses, so two live allocations cannot compare equal.

What malloc does under the hood (glibc ptmalloc)

Theory

Mental model. malloc is a user-space library that buys memory from the kernel in large pieces and sells it to you in small pieces. Each piece is a chunk with a small header. Freed chunks are kept in caches and bins for reuse, and only rarely go back to the kernel. Interviewers at systems and HFT firms expect you to sketch this.

Getting memory from the kernel

  • brk/sbrk moves the end of the process's data segment (the "program break"). glibc's main arena grows its heap this way. Only the top of that heap can be given back, by moving the break down.
  • mmap maps fresh anonymous pages anywhere in the address space. Requests at or above M_MMAP_THRESHOLD (128 KiB by default) get their own mapping, which free returns to the kernel immediately with munmap. The threshold is dynamic: freeing an mmapped chunk raises it to that chunk's size, up to 32 MiB on 64-bit, so a program that repeatedly allocates 1 MiB buffers stops paying for mmap/munmap.
  • Additional arenas (for other threads) get their memory from mmapped "heaps" of up to 64 MiB each.
Allocated chunk prev_size 8 B (only if prev free) size | A M P 8 B, low 3 bits = flags user data (your n bytes, rounded up) malloc returns chunk + 16 Free chunk (in a bin) prev_size size | A M P fd bk unused next.prev_size = this size P = previous chunk in use · M = chunk came from mmap · A = not in the main arena fd/bk link the chunk into a bin; the footer copy of the size lets free() find the previous chunk and merge (boundary tags) 64-bit: 16-byte alignment, minimum chunk 32 B, so malloc(1) uses 32 B and malloc_usable_size reports 24
Every chunk carries its size just before your pointer. That is how free(p) knows how much to release without being told. A freed chunk reuses its first user bytes for free-list pointers, which is why a use-after-free write can corrupt the allocator itself.

Where a request is served from

  1. tcache (glibc 2.26+): a per-thread cache with 64 size-indexed singly linked lists, 7 chunks each by default, for chunks up to 1040 bytes (requests up to 1032). No lock, a handful of instructions. Most small malloc/free pairs never leave it. Since glibc 2.42 the tcache can also cache large blocks (up to 4 MiB) if you raise the glibc.malloc.tcache_max tunable; the default limit is unchanged.
  2. fastbins: per-arena singly linked LIFO lists for small chunks (chunks up to 128 bytes by default, M_MXFAST, so requests up to 120 bytes). Freed chunks here are not coalesced right away.
  3. small bins (62 exact-size doubly linked lists below 1024 bytes) and large bins (ranges, kept sorted by size, best fit). Freed chunks first go to the unsorted bin, a one-shot staging list that malloc sorts into bins while it searches.
  4. top chunk: if no bin fits, malloc carves from the free space at the end of the heap and extends it with brk (main arena) when needed.

Free does roughly the reverse: put the chunk in the tcache if there is room; otherwise coalesce it with free neighbours (using the P bit and boundary tag) and put it in the unsorted bin; if it borders the top chunk, merge into top, and if top grows beyond M_TRIM_THRESHOLD (128 KiB default) shrink the heap with brk. Why freed memory often does not lower RSS is a fragmentation question, covered in Ch 10.

Arenas and threads

One global lock would serialise every thread's malloc. ptmalloc keeps several arenas, each with its own lock and bins. A thread is attached to an arena at its first malloc and keeps using it: glibc creates a new arena for each new thread until there are 8 × number of cores arenas on 64-bit (2 × cores on 32-bit), and after that threads share the existing ones. The cost is memory: each arena holds its own free chunks, so a program with many threads can use much more RSS than its live data. MALLOC_ARENA_MAX=2 (or the tunable glibc.malloc.arena_max) is a common fix in production. jemalloc, tcmalloc and mimalloc take different approaches (Ch 10).

Know this cold

Small requests: tcache, then bins, then the top chunk, grown with brk. Large requests (128 KiB and up by default): a private mmap, returned with munmap on free. The size lives in a header just before the pointer. Several arenas reduce lock contention at the price of memory.

Memory leaks and how to find them

Theory

Mental model. A leak is memory that is still allocated but that no live pointer can reach any more, so nobody can free it. In a long-running server, a leak of a few bytes per request becomes gigabytes. "Still reachable at exit" (a global cache never freed) is not the same thing, and tools report it separately.

Typical causes in C++

  • An early return or an exception between new and delete. RAII fixes this class of leak completely.
  • Ownership confusion: unique_ptr::release() with the result ignored, a raw pointer stored in a container of raw pointers, or a C API that returns memory you must free.
  • shared_ptr cycles (Ch 13): every count stays above zero.
  • A base class without a virtual destructor, so derived members are never destroyed.
  • Logical leaks: a cache or map that only grows. No tool calls this a leak because it is reachable.

Tools

ToolHowCostFinds
ASan + LeakSanitizer-fsanitize=address -g; LSan runs at exit (on by default on Linux; off by default on macOS, where ASAN_OPTIONS=detect_leaks=1 enables it if your toolchain supports it)About 2× time, 2 to 3× memoryLeaks with allocation stacks, plus use-after-free, double free, overflows, mismatches
Valgrind memcheckvalgrind --leak-check=full ./a.out, no rebuild10 to 50× slower"definitely lost", "indirectly lost", "possibly lost", "still reachable"; uninitialised reads
heaptrack, massifRecord every allocation over timeModerateWho allocates the most, growth over time (logical leaks)
Counting operator newReplace the global functions (coding drills below)TinyLive count and bytes in tests
g++ -std=c++20 -g -O1 -fsanitize=address leak.cpp -o leak && ./leak
# ==1234==ERROR: LeakSanitizer: detected memory leaks
# Direct leak of 40 byte(s) in 1 object(s) allocated from:
#     #0 ... in operator new[](unsigned long)
#     #1 ... in make_buffer() leak.cpp:5

valgrind --leak-check=full --show-leak-kinds=all ./leak_without_asan

"Indirectly lost" in valgrind means a block reachable only from another leaked block: fix the definitely lost one and the indirect ones usually disappear. Do not run ASan and valgrind on the same binary; they both intercept malloc.

Concept and tricky questions

Drills

ConceptWhat are the steps of new T(args) and of delete p, and what happens if T's constructor throws?

Answer

Answer. new: call operator new(sizeof(T)) (class-specific if T declares one, else global), construct T in the storage, yield a T*. delete: call p->~T() (virtually if the destructor is virtual), then call operator delete. If the constructor throws, the matching operator delete releases the storage and the exception propagates; no destructor runs.

Why. [expr.new] requires the matching deallocation on constructor failure, so new on its own never leaks. A common wrong answer is "the destructor cleans up": the destructor runs only for objects whose construction completed. Subobjects that were completed are destroyed by the normal unwinding rules.

ConceptHow does free(p) know how many bytes to release when you never told it the size?

Answer

Answer. The allocator records the size itself. glibc stores it in an 8-byte header immediately before the pointer it returned (with flag bits in the low three bits). Size-class allocators such as jemalloc or tcmalloc look the size class up from the page the pointer lives in.

Why. The C API predates sized deallocation, so the allocator must recover the size from the address. C++14 sized delete passes the size explicitly, which lets size-class allocators skip that lookup. "The OS tracks it" is the usual wrong answer: the kernel knows only about pages and mappings, not individual malloc blocks.

ConceptCan you release memory from new with free()? And if free() manages without a count, why does delete[] need to know how many elements there are?

Answer

Answer. No: memory from a new-expression must go back through the matching delete-expression, and anything else is undefined behaviour. delete[] needs the count because it must run one destructor per element; the allocator only knows the byte size of the block, which may be rounded up, so it cannot tell how many objects live there. That is why compilers store the count in an array cookie when the elements have non-trivial destructors.

Why. free skips the destructor, and operator new need not be malloc at all (replaced, class-specific, aligned or tcmalloc-backed), so free may receive an address its heap never handed out. The common wrong answer is "yes, new calls malloc anyway": that is true of glibc's default, and it is exactly why the bug passes tests and then breaks when the allocator changes. For the count, the usual wrong answer is "delete[] asks the allocator for the block size": the block can be larger than n * sizeof(T), so dividing would run destructors on objects that never existed.

ConceptWhat may a new_handler do, and what happens if it simply returns?

Answer

Answer. It must make progress: free some memory (for example an emergency reserve), install another handler, uninstall itself with std::set_new_handler(nullptr), or not return (throw std::bad_alloc, or abort). If it returns having done none of these, operator new retries, fails, calls it again, forever.

Why. The standard specifies operator new as a loop: try to allocate; on failure, if a handler is installed call it and retry, otherwise throw bad_alloc. The handler is process-wide and is consulted only by operator new, not by malloc.

ConceptWhy does C++ have no renew or object-aware realloc, and how does std::vector grow instead?

Answer

Answer. realloc may move the block with a raw byte copy, and a byte copy is not a valid move for objects that point into themselves or are pointed to by others. vector allocates a new buffer, move-constructs (or copies, if the move constructor may throw) each element into it, destroys the old elements and frees the old buffer.

Why. Object lifetime in C++ is tied to an address; moving means running constructors. libstdc++'s std::string with its internal SSO pointer is the standard example of a type that breaks under memcpy. The "just realloc the vector's buffer" answer is correct only for trivially copyable types, which is why some libraries special-case them.

ConceptWhen does malloc call brk and when does it call mmap?

Answer

Answer. In glibc, requests at or above the mmap threshold (128 KiB by default, raised dynamically up to 32 MiB on 64-bit) get a private anonymous mmap that free returns with munmap. Smaller requests come from an arena; the main arena grows its heap with brk when the top chunk is too small, and other arenas use mmapped heaps.

Why. mmap avoids fragmenting the main heap with big blocks and gives the memory back immediately on free, but each call is a system call plus page faults, so small requests are pooled. The dynamic threshold exists because a program that repeatedly allocates and frees 1 MiB buffers would otherwise pay mmap and munmap every time.

ConceptWhat do valgrind's "definitely lost", "indirectly lost" and "still reachable" mean, and which do you fix first?

Answer

Answer. Definitely lost: no pointer to the block exists at exit. Indirectly lost: the block is reachable only through a definitely lost block (the nodes of a leaked list). Still reachable: a live pointer exists (often a global), the program just never freed it. Fix definitely lost first; the indirect ones usually go with it.

Why. Freeing the root of a leaked structure frees its children. "Still reachable" is often deliberate (singletons torn down by the OS), but it hides logical leaks such as caches that only grow, which you find with heap profilers rather than leak checkers. "Possibly lost" means only an interior pointer points into the block.

TrickyDoes new (std::nothrow) Widget guarantee that no exception escapes?

Answer

Answer. No. It only turns allocation failure into a null result. If Widget's constructor throws, the exception propagates as usual (after the storage is freed with the nothrow operator delete).

Why. The std::nothrow tag selects the allocation function operator new(std::size_t, const std::nothrow_t&) noexcept. It has nothing to do with construction. People also forget that the default nothrow version runs the new_handler loop before returning null, and that on an overcommitting Linux system it rarely returns null at all.

Trickyint* p = new int[10]; delete p; runs without a crash on GCC and Clang. Is it correct?

Answer

Answer. No, it is undefined behaviour by [expr.delete]. It appears to work only because int is trivially destructible, so the Itanium ABI stores no cookie, and the global array and single operator delete both end in free.

Why. Change int to a type with a destructor and the pointer passed to operator delete is 8 bytes past the start of the block, which crashes or corrupts the heap. ASan on Linux reports alloc-dealloc-mismatch even for int. "It's fine for built-in types" is the wrong answer interviewers listen for.

TrickyYou replace global operator new with a version that counts calls, then write int* p = new int(5); std::printf("%d", *p); delete p;. At -O2 the counter says zero. Is the compiler allowed to do that?

Answer

Answer. Yes. Since C++14 (N3664, now [expr.new]) an implementation may omit or merge calls to replaceable global allocation functions made by new-expressions, even if your replacement has side effects. Clang and GCC both do it when the pointer does not escape.

Why. The rule exists so that short-lived heap objects can live on the stack or in registers. It applies to new-expressions only: direct calls such as ::operator new(n), class-specific operator new and malloc are not covered by that permission (GCC still treats malloc as a builtin it can sometimes optimise). To make a counting test reliable, let the pointer escape, for example by storing it in a volatile global, as the programs in this chapter do.

TrickyIs int* a = static_cast<int*>(std::malloc(4 * sizeof(int))); a[0] = 1; undefined behaviour in C++?

Answer

Answer. Not since C++20. P0593 says malloc, operator new, memcpy and a few others implicitly create objects of implicit-lifetime types (scalars, arrays, aggregates whose destructor is not user-provided, and classes with a trivial non-deleted destructor and at least one trivial eligible constructor) when that would give the program defined behaviour. Before C++20 it was technically UB, though every compiler accepted it.

Why. The rule was applied retroactively as a defect report, so the answer for "C++17" is debated; say "formally UB before P0593, fine now". For std::string or anything with a non-trivial constructor, using malloc'd memory without placement new is still UB. Candidates often say "always UB" or "always fine"; both are wrong.

TrickyCan you delete a pointer to const? Can you call delete this?

Answer

Answer. Both are legal. const Widget* p = new const Widget; delete p; is fine: constness controls modification during the object's lifetime, not who may end it (a const local is destroyed at scope exit, too). delete this is legal if the object was created by a non-array new-expression and nothing touches the object (members, virtual calls, this) after the delete.

Why. [expr.delete] accepts a pointer to cv-qualified type. delete this appears in intrusive reference counting (COM's Release). It is a bug for stack objects, members, arrays or objects from make_shared. The common wrong answer to the first part is "no, delete modifies the object".

TrickyWhat do malloc(0), new int[0] and ::operator new(0) return, and must you free them?

Answer

Answer. malloc(0) returns either null or a unique pointer (implementation-defined; glibc returns a minimum 32-byte chunk) and the non-null result must be freed. new int[0] and ::operator new(0) must return unique non-null pointers that you must release with delete[] and ::operator delete respectively. None may be dereferenced.

Why. C++ requires distinct live allocations to have distinct addresses, so it cannot return null or a shared dummy. The trap is code that checks if (!malloc(n)) out_of_memory(); with n == 0: on a platform that returns null for zero it reports a false out-of-memory. realloc(p, 0) is UB in C23.

Predict the output and find the bug

Drills

OutputThe third element's constructor throws during new Item[4]. What does the program print?

#include <cstdio>
#include <cstdlib>
#include <new>
#include <stdexcept>

static int built = 0;

struct Item {
    int id;
    Item() : id(built) {
        if (built == 2) throw std::runtime_error("boom");
        ++built;
        std::printf("Item(%d) ", id);
    }
    ~Item() { std::printf("~Item(%d) ", id); }
    static void* operator new[](std::size_t n) {
        if (void* p = std::malloc(n)) return p;
        throw std::bad_alloc{};
    }
    static void operator delete[](void* p) noexcept {
        std::printf("operator delete[] ");
        std::free(p);
    }
};

int main() {
    try {
        Item* items = new Item[4];
        delete[] items;
    } catch (const std::exception& e) {
        std::printf("caught %s\n", e.what());
    }
}
Answer
Item(0) Item(1) ~Item(1) ~Item(0) operator delete[] caught boom

Answer. Two items are built, the third throws, the two built items are destroyed in reverse order, the storage is released, and then the handler runs.

Why. An array new-expression is exception-safe on its own: the implementation destroys the fully constructed elements in reverse order ([expr.new], [dcl.init]) and calls the matching operator delete[]. Element 2 never gets a destructor because its constructor did not finish. A common wrong answer prints ~Item(2) or destroys in forward order.

OutputThe new_handler counts calls and gives up after the third. What is printed?

#include <cstddef>
#include <cstdio>
#include <new>

static int calls = 0;
static void* volatile sink;
static volatile std::size_t huge = std::size_t(1) << 62;

static void handler() {
    ++calls;
    std::printf("handler call %d\n", calls);
    if (calls == 3) std::set_new_handler(nullptr);
}

int main() {
    std::set_new_handler(handler);
    try {
        char* p = new char[huge];
        sink = p;
        std::puts("allocated");
    } catch (const std::bad_alloc&) {
        std::printf("bad_alloc after %d handler calls\n", calls);
    }
}
Answer
handler call 1
handler call 2
handler call 3
bad_alloc after 3 handler calls

Answer. The handler runs three times; after the third it uninstalls itself, so the fourth allocation attempt finds no handler and throws.

Why. This is the operator new loop from [new.delete.single]. Each failure calls the current handler and retries. If the handler never uninstalled itself and never freed anything, the program would loop forever, which is the main bug in hand-written handlers. (Not tagged for ASan: ASan aborts on impossible sizes by default.)

Find the bugThis compiles cleanly and the destructor message even prints once. What is wrong?

#include <cstdio>
#include <string>

struct Record {
    std::string name = "row";
    ~Record() { std::puts("~Record"); }
};

int main() {
    Record* rows = new Record[3];
    delete rows;
}
Answer

Answer. new[] paired with plain delete. Use delete[] rows;, or better, std::vector<Record> rows(3);.

Why. Record has a non-trivial destructor, so the block starts with an array cookie and rows points past it. Plain delete runs one destructor and passes rows itself to operator delete, an address malloc never returned. ASan reports "attempting free on address which was not malloc()-ed"; without ASan the result ranges from silence to a glibc abort ("free(): invalid pointer"). Output is not compared because the behaviour is undefined.

Find the bugA cleanup path frees a node twice. Spot it.

#include <cstdio>

struct Node { int value; Node* next; };

static void destroy(Node* n) {
    while (n) {
        Node* next = n->next;
        delete n;
        n = next;
    }
}

int main() {
    Node* tail = new Node{2, nullptr};
    Node* head = new Node{1, tail};
    std::printf("%d %d\n", head->value, head->next->value);
    destroy(head);
    delete tail;   // "just to be safe"
}
Answer

Answer. destroy(head) already deleted tail; the extra delete tail is a double free. Remove it, and model the list with a single owner (for example std::unique_ptr<Node> next or a std::forward_list).

Why. Deleting a pointer does not change other pointers to the same object, so tail still holds the old address. A double free can corrupt glibc's bins (the classic "tcache double free" exploit primitive); glibc detects some cases and aborts with "free(): double free detected in tcache 2". ASan reports "attempting double-free". Setting a pointer to null after delete only protects that one variable, not its copies.

Find the bugThe buffer grows with realloc. Where is the use-after-free?

#include <cstdio>
#include <cstdlib>

int main() {
    int* data = static_cast<int*>(std::malloc(2 * sizeof(int)));
    if (!data) return 1;
    data[0] = 10;
    data[1] = 20;
    int* first = &data[0];

    int* bigger = static_cast<int*>(std::realloc(data, 100000 * sizeof(int)));
    if (!bigger) { std::free(data); return 1; }
    data = bigger;

    std::printf("%d\n", *first);
    std::free(data);
}
Answer

Answer. first points into the old block. If realloc moved the data (here it must, from 8 bytes to 400 KB), the old block is freed and *first reads freed memory. Keep an index instead of a pointer, or recompute first after the realloc.

Why. realloc may return a different address; every pointer and reference into the old block is invalid afterwards, exactly like iterators after std::vector reallocation. ASan always moves on realloc growth and reports heap-use-after-free. Without ASan the program may print 10 and look correct, which is why this bug survives code review.

Find the bugA C library returns a malloc'd string and the C++ caller releases it. What is wrong, and why might ASan stay quiet on some machines?

#include <cstdio>
#include <cstdlib>
#include <cstring>

extern "C" char* c_duplicate(const char* s) {   // stands in for a C API such as strdup
    std::size_t n = std::strlen(s) + 1;
    char* p = static_cast<char*>(std::malloc(n));
    if (p) std::memcpy(p, s, n);
    return p;
}

int main() {
    char* copy = c_duplicate("hello");
    std::printf("%s\n", copy);
    delete[] copy;
}
Answer

Answer. Memory from malloc must go back with free(copy). Better: wrap it at the boundary, std::unique_ptr<char, decltype(&std::free)> copy(c_duplicate("hello"), &std::free); (custom deleters are Ch 12).

Why. delete[] must receive a pointer from new[]. With glibc the default operator delete[] ends in free, so it appears to work, until a replaced or class-specific allocator, a different heap (Windows DLLs each have their own CRT heap) or a cookie read gets involved. ASan on Linux reports alloc-dealloc-mismatch (malloc vs operator delete []). On macOS that check is off by default (alloc_dealloc_mismatch=0), so this drill is marked UB without the sanitizer-must-fail flag.

OutputWhich allocation function does each expression call?

#include <cstdio>
#include <cstdlib>
#include <new>

struct Base {
    static void* operator new(std::size_t n) {
        std::printf("Base::operator new(%zu)\n", n);
        return ::operator new(n);
    }
    static void operator delete(void* p) noexcept {
        std::puts("Base::operator delete");
        ::operator delete(p);
    }
    virtual ~Base() = default;
    long a = 0;
};

struct Derived : Base { long b = 0; long c = 0; };

int main() {
    Base* x = new Derived;
    delete x;
    Base* y = ::new Base;
    ::delete y;
}
Answer
Base::operator new(32)
Base::operator delete

Answer. new Derived finds Base::operator new by inheritance and asks for sizeof(Derived) = 32 (vptr + three longs on a 64-bit target). delete x calls Base::operator delete, found through the virtual destructor. The ::new and ::delete forms skip the class functions and print nothing.

Why. Lookup for the allocation function starts in the class scope of the allocated type, and class-specific functions are inherited. That is why a pool allocator written for sizeof(Base) must check n: here it would receive 32 instead of 16. The deallocation function is looked up in the scope of the dynamic type's destructor, so the virtual destructor matters for picking the right operator delete too.

OutputZero-size requests. What is printed?

#include <cstdio>
#include <new>

int main() {
    int* a = new int[0];
    int* b = new int[0];
    void* c = ::operator new(0);
    std::printf("a non-null: %d\n", a != nullptr);
    std::printf("a != b: %d\n", a != b);
    std::printf("c non-null: %d\n", c != nullptr);
    delete[] a;
    delete[] b;
    ::operator delete(c);
}
Answer
a non-null: 1
a != b: 1
c non-null: 1

Answer. All non-null, and the two arrays have distinct addresses.

Why. [basic.stc.dynamic.allocation]: a successful allocation of size zero returns a non-null pointer that differs from every other live allocation. You must still release each one. Compare malloc(0), which may legally return null.

Find the bugA string pool keeps raw pointers into a vector of strings. Where does it read freed memory?

#include <cstdio>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> names;
    names.reserve(1);
    names.push_back("a fairly long string that lives on the heap");
    const char* first = names[0].c_str();
    names.push_back("another long string that also lives on the heap");
    names.clear();
    std::printf("%s\n", first);
}
Answer

Answer. names.clear() destroys the strings, which frees the heap buffer that first points into; the printf reads freed memory. (The second push_back also reallocates the vector; for a long string the character buffer survives the move, but nothing guarantees that.)

Why. c_str() is valid only while the string is alive and unmodified. Raw pointers do not extend lifetime. ASan reports heap-use-after-free at the printf. The usual wrong answer blames only the reallocation; the guaranteed bug is the destruction.

Write the code

Coding

CodeCounting allocator. Replace the global operator new and operator delete so that a test can ask how many allocations are live, how many bytes are live and what the peak was. Requirements: correct alignment (at least 16), size 0 handled, failure throws bad_alloc, no allocation through operator new inside the hooks, thread-safe counters.

Answer

Approach. Prefix every block with a 16-byte header that stores the requested size, so operator delete can subtract it without a lookup table. Use std::atomic counters and a compare-exchange loop for the peak. Hand the caller the address just past the header, which keeps malloc's 16-byte alignment. Use a volatile sink in the test so the compiler cannot elide the allocations.

#include <atomic>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <string>
#include <vector>

namespace stats {
std::atomic<std::size_t> live_blocks{0}, live_bytes{0}, peak_bytes{0};
}

static constexpr std::size_t kHeader = 16;   // keeps 16-byte alignment

void* operator new(std::size_t n) {
    void* raw = std::malloc(n + kHeader);
    if (!raw) throw std::bad_alloc{};
    *static_cast<std::size_t*>(raw) = n;
    stats::live_blocks.fetch_add(1, std::memory_order_relaxed);
    std::size_t now = stats::live_bytes.fetch_add(n, std::memory_order_relaxed) + n;
    std::size_t peak = stats::peak_bytes.load(std::memory_order_relaxed);
    while (now > peak && !stats::peak_bytes.compare_exchange_weak(peak, now)) {}
    return static_cast<char*>(raw) + kHeader;
}

void operator delete(void* p) noexcept {
    if (!p) return;
    void* raw = static_cast<char*>(p) - kHeader;
    std::size_t n = *static_cast<std::size_t*>(raw);
    stats::live_blocks.fetch_sub(1, std::memory_order_relaxed);
    stats::live_bytes.fetch_sub(n, std::memory_order_relaxed);
    std::free(raw);
}

void operator delete(void* p, std::size_t) noexcept { ::operator delete(p); }

static void* volatile sink;

static void report(const char* label) {
    std::printf("%-14s blocks=%zu bytes=%zu\n", label,
                stats::live_blocks.load(), stats::live_bytes.load());
}

int main() {
    std::size_t base_blocks = stats::live_blocks, base_bytes = stats::live_bytes;
    stats::peak_bytes = base_bytes;
    int* one = new int(42);
    sink = one;
    double* arr = new double[10];
    sink = arr;
    std::printf("after 2 news: +%zu blocks, +%zu bytes\n",
                stats::live_blocks - base_blocks, stats::live_bytes - base_bytes);
    delete one;
    delete[] arr;
    {
        std::vector<int> v(1000);
        sink = v.data();
    }
    std::printf("back to base: %d\n", stats::live_blocks == base_blocks && stats::live_bytes == base_bytes);
    std::printf("peak above base: %zu\n", stats::peak_bytes - base_bytes);
    (void)report;
}
after 2 news: +2 blocks, +84 bytes
back to base: 1
peak above base: 4000

Complexity and pitfalls. O(1) per call plus malloc. The header costs 16 bytes per block. The counts are relative to a baseline because the runtime may allocate before main (iostream buffers, locale data). Pitfalls: forgetting that operator new[] and sized delete default to the replaced functions (fine here, but replace them too if you use a different scheme), forgetting the aligned overloads (align_val_t allocations bypass this header and would be freed through the default aligned delete, which is fine, but not counted), and calling anything that allocates inside the hooks.

Estimated rating: ~1500 · straightforward header trick, but alignment, elision and baseline details trip people up

CodeLeak detector with file and line. Provide a macro TRACK_NEW so that TRACK_NEW Widget(3) records the file and line, and a function report_leaks() that lists every tracked allocation not yet deleted. Constraint: the bookkeeping must not itself call operator new.

Answer

Approach. Add a placement form operator new(std::size_t, const char* file, int line) and define TRACK_NEW as new (__FILE__, __LINE__). Store each allocation in a fixed-size open table allocated with malloc semantics (a static array), protected by a mutex. Provide the matching placement operator delete so a throwing constructor does not leak a table entry, and a replaced plain operator delete that removes entries.

#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <new>
#include <stdexcept>

namespace leak {
struct Entry { void* p; std::size_t size; const char* file; int line; };
constexpr int kMax = 1024;
Entry table[kMax];
std::mutex mu;

void add(void* p, std::size_t n, const char* file, int line) {
    std::lock_guard<std::mutex> lock(mu);
    for (Entry& e : table)
        if (!e.p) { e = {p, n, file, line}; return; }
}
void remove(void* p) {
    std::lock_guard<std::mutex> lock(mu);
    for (Entry& e : table)
        if (e.p == p) { e.p = nullptr; return; }
}
int report() {
    std::lock_guard<std::mutex> lock(mu);
    int count = 0;
    for (const Entry& e : table) {
        if (!e.p) continue;
        std::printf("leak: %zu bytes from line %d\n", e.size, e.line);
        ++count;
    }
    return count;
}
}  // namespace leak

void* operator new(std::size_t n, const char* file, int line) {
    void* p = std::malloc(n ? n : 1);
    if (!p) throw std::bad_alloc{};
    leak::add(p, n, file, line);
    return p;
}
void operator delete(void* p, const char*, int) noexcept {   // used if the ctor throws
    leak::remove(p);
    std::free(p);
}
void* operator new(std::size_t n) {
    void* p = std::malloc(n ? n : 1);
    if (!p) throw std::bad_alloc{};
    return p;
}
void operator delete(void* p) noexcept {
    leak::remove(p);
    std::free(p);
}
void operator delete(void* p, std::size_t) noexcept { ::operator delete(p); }

#define TRACK_NEW new (__FILE__, __LINE__)

struct Widget {
    int id;
    explicit Widget(int i) : id(i) { if (i < 0) throw std::invalid_argument("bad id"); }
};

int main() {
    Widget* kept = TRACK_NEW Widget(1);
    Widget* freed = TRACK_NEW Widget(2);
    delete freed;
    try { Widget* bad = TRACK_NEW Widget(-1); delete bad; } catch (const std::exception&) {}
    int n = leak::report();
    std::printf("%d leak(s)\n", n);
    delete kept;
    std::printf("%d leak(s) after cleanup\n", leak::report());
}
leak: 4 bytes from line 65
1 leak(s)
0 leak(s) after cleanup

Complexity and pitfalls. O(table size) per operation; a real tool uses a hash table keyed by address with its own malloc-backed storage. Pitfalls: delete on a tracked pointer goes through the plain operator delete (there is no placement delete-expression), so that one must remove the entry; the placement operator delete is called only when a constructor throws; TRACK_NEW breaks code that already uses placement syntax; arrays need operator new[] overloads too. The demo prints only the line; a real report prints e.file too.

Estimated rating: ~1700 · needs placement new/delete pairing and recursion-free bookkeeping

CodeAligned allocation on top of malloc. Implement void* aligned_malloc(std::size_t size, std::size_t align) and void aligned_free(void* p) using only std::malloc and std::free. align is a power of two. This is a frequent phone-screen question.

Answer

Approach. Over-allocate by align - 1 + sizeof(void*) bytes. Skip past room for one pointer, round the address up to the next multiple of align with (addr + align - 1) & ~(align - 1), and store the original malloc pointer in the word just before the aligned address. aligned_free reads that word and frees it.

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <initializer_list>

static bool is_pow2(std::size_t x) { return x && !(x & (x - 1)); }

void* aligned_malloc(std::size_t size, std::size_t align) {
    if (!is_pow2(align)) return nullptr;
    if (align < alignof(void*)) align = alignof(void*);
    std::size_t extra = align - 1 + sizeof(void*);
    if (size > SIZE_MAX - extra) return nullptr;          // overflow guard
    void* raw = std::malloc(size + extra);
    if (!raw) return nullptr;
    std::uintptr_t start = reinterpret_cast<std::uintptr_t>(raw) + sizeof(void*);
    std::uintptr_t aligned = (start + align - 1) & ~(std::uintptr_t(align) - 1);
    void* result = reinterpret_cast<void*>(aligned);
    std::memcpy(static_cast<char*>(result) - sizeof(void*), &raw, sizeof raw);
    return result;
}

void aligned_free(void* p) {
    if (!p) return;
    void* raw;
    std::memcpy(&raw, static_cast<char*>(p) - sizeof(void*), sizeof raw);
    std::free(raw);
}

int main() {
    bool ok = true;
    for (std::size_t align : {8, 16, 64, 256, 4096}) {
        for (std::size_t size : {1, 7, 100, 5000}) {
            void* p = aligned_malloc(size, align);
            ok = ok && p && reinterpret_cast<std::uintptr_t>(p) % align == 0;
            std::memset(p, 0xAB, size);                     // whole range is writable
            aligned_free(p);
        }
    }
    std::printf("all aligned: %d\n", ok);
    std::printf("bad alignment gives null: %d\n", aligned_malloc(10, 24) == nullptr);
}
all aligned: 1
bad alignment gives null: 1

Complexity and pitfalls. O(1), wasting at most align - 1 + 8 bytes per block. Pitfalls: forgetting the room for the stored pointer (then the header can land before the malloc block), rounding with % on signed types, not guarding size + extra against overflow, and freeing the aligned pointer with plain free. In production use std::aligned_alloc, posix_memalign or aligned new; this exercise shows how they can be built.

Estimated rating: ~1400 · classic bit trick plus a stored back-pointer

CodeEmulate new T[n] and delete[]. Write template<class T> T* new_array(std::size_t n) that allocates raw memory with ::operator new, stores the count in a cookie, default-constructs the elements with placement new, and destroys the ones already built in reverse order if a constructor throws. Write delete_array(T*) that reads the cookie, destroys in reverse order and frees.

Answer

Approach. The cookie must keep the elements aligned, so its size is max(sizeof(std::size_t), alignof(T)), which is what the Itanium ABI does. Construct in a loop counting successes; on an exception, destroy [0, built) backwards, free and rethrow. delete_array steps back by the cookie size to find the count and the original block.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <new>
#include <stdexcept>

template <class T>
constexpr std::size_t cookie_size = std::max(sizeof(std::size_t), alignof(T));

template <class T>
T* new_array(std::size_t n) {
    static_assert(alignof(T) <= __STDCPP_DEFAULT_NEW_ALIGNMENT__, "use aligned new for this");
    char* raw = static_cast<char*>(::operator new(cookie_size<T> + n * sizeof(T)));
    ::new (raw + cookie_size<T> - sizeof(std::size_t)) std::size_t(n);
    T* first = reinterpret_cast<T*>(raw + cookie_size<T>);
    std::size_t built = 0;
    try {
        for (; built < n; ++built) ::new (first + built) T();
    } catch (...) {
        while (built > 0) first[--built].~T();
        ::operator delete(raw);
        throw;
    }
    return first;
}

template <class T>
void delete_array(T* first) {
    if (!first) return;
    char* raw = reinterpret_cast<char*>(first) - cookie_size<T>;
    std::size_t n = *reinterpret_cast<std::size_t*>(raw + cookie_size<T> - sizeof(std::size_t));
    while (n > 0) first[--n].~T();
    ::operator delete(raw);
}

static int next_id = 0;
static int fail_at = -1;

struct Probe {
    int id;
    Probe() : id(next_id++) {
        if (id == fail_at) throw std::runtime_error("ctor failed");
        std::printf("+%d ", id);
    }
    ~Probe() { std::printf("-%d ", id); }
};

int main() {
    Probe* a = new_array<Probe>(3);
    std::printf("| built\n");
    delete_array(a);
    std::printf("| deleted\n");

    next_id = 0;
    fail_at = 2;
    try {
        new_array<Probe>(4);
    } catch (const std::exception& e) {
        std::printf("| %s\n", e.what());
    }
}
+0 +1 +2 | built
-2 -1 -0 | deleted
+0 +1 -1 -0 | ctor failed

Complexity and pitfalls. O(n) construction and destruction. Pitfalls: a cookie smaller than alignof(T) misaligns every element; destroying in forward order; destroying the element whose constructor threw; leaking the block on exception; forgetting that a real implementation skips the cookie for trivially destructible types. Placing the count with placement new (instead of a cast and assignment) starts the lifetime of the size_t properly.

Estimated rating: ~1700 · placement new plus exception-safe partial construction and alignment arithmetic

CodeImplement the operator new loop with an emergency reserve. Write void* allocate(std::size_t n) that behaves like the standard operator new (retry, call the current new_handler, throw bad_alloc when there is none) on top of a pluggable low-level allocator. Then write a handler that releases a reserved block the first time memory runs out and uninstalls itself the second time. Test it with a low-level allocator that has a fixed budget.

Answer

Approach. Model the heap as a budget of bytes so failure is deterministic. allocate loops: try the low-level allocator; on failure read std::get_new_handler(), throw if null, otherwise call it and retry. The handler gives the reserve back to the budget once; on the next call it sets the handler to null so the loop throws.

#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <new>

namespace heap {
std::size_t budget = 1000;                 // bytes the fake heap can still hand out
void* low_level(std::size_t n) {
    if (n > budget) return nullptr;
    budget -= n;
    return std::malloc(n);
}
void release(void* p, std::size_t n) { budget += n; std::free(p); }
}

void* allocate(std::size_t n) {
    if (n == 0) n = 1;
    for (;;) {
        if (void* p = heap::low_level(n)) return p;
        std::new_handler h = std::get_new_handler();
        if (!h) throw std::bad_alloc{};
        h();
    }
}

static void* reserve = nullptr;
constexpr std::size_t kReserve = 300;

static void emergency_handler() {
    if (reserve) {
        std::puts("handler: releasing reserve");
        heap::release(reserve, kReserve);
        reserve = nullptr;
    } else {
        std::puts("handler: nothing left, giving up");
        std::set_new_handler(nullptr);
    }
}

int main() {
    reserve = allocate(kReserve);                 // budget 700 left
    std::set_new_handler(emergency_handler);
    void* a = allocate(600);                      // fits: 100 left
    std::puts("a ok");
    void* b = allocate(350);                      // needs the reserve: 400 -> 50
    std::puts("b ok");
    try {
        allocate(200);
        std::puts("c ok");
    } catch (const std::bad_alloc&) {
        std::puts("c: bad_alloc");
    }
    heap::release(a, 600);
    heap::release(b, 350);
    std::printf("budget restored: %zu\n", heap::budget);
}
a ok
handler: releasing reserve
b ok
handler: nothing left, giving up
c: bad_alloc
budget restored: 1000

Complexity and pitfalls. Each failure costs one handler call and one retry. The final line checks the bookkeeping: the reserve went back during the handler call and a and b go back at the end, so the fake heap is whole again. Pitfalls: a handler that returns without changing state loops forever; a handler that allocates through operator new re-enters the failing path; the handler is global state, so libraries should not install one behind the application's back. Real reserves are megabytes, used to log and shut down cleanly.

Estimated rating: ~1500 · requires knowing the standard loop exactly and designing a deterministic test

CodeA class-specific free-list allocator. Give struct Order (40 bytes) a class-specific operator new/operator delete that recycles freed blocks through an intrusive singly linked free list and only falls back to ::operator new when the list is empty. Derived classes of a different size must still work. Report how many blocks came from the system.

Answer

Approach. A freed block is at least as big as a pointer, so its first bytes can hold the next-free link. operator new pops from the list when n == sizeof(Order), otherwise forwards to the global allocator (the inherited-size trap). operator delete takes the size (sized class-specific delete) so it can make the same decision on the way back. Pools in depth are Ch 15.

#include <cstddef>
#include <cstdio>
#include <new>
#include <vector>

struct Order {
    long id = 0;
    double price = 0, qty = 0;
    long side = 0, venue = 0;

    static void* operator new(std::size_t n) {
        if (n != sizeof(Order)) return ::operator new(n);    // a bigger derived class
        if (free_list) {
            FreeNode* head = free_list;
            free_list = head->next;
            return head;
        }
        ++from_system;
        return ::operator new(n);
    }
    static void operator delete(void* p, std::size_t n) noexcept {
        if (!p) return;
        if (n != sizeof(Order)) { ::operator delete(p); return; }
        free_list = ::new (p) FreeNode{free_list};
    }
    static void release_all() noexcept {
        while (free_list) {
            FreeNode* next = free_list->next;
            ::operator delete(free_list);
            free_list = next;
        }
    }
    virtual ~Order() = default;

    struct FreeNode { FreeNode* next; };
    static inline FreeNode* free_list = nullptr;
    static inline int from_system = 0;
};

struct BigOrder : Order { char note[64] = {}; };

int main() {
    std::vector<Order*> live;
    for (int round = 0; round < 3; ++round) {
        for (int i = 0; i < 100; ++i) live.push_back(new Order);
        for (Order* o : live) delete o;
        live.clear();
    }
    Order* big = new BigOrder;      // must not come from the 40-byte free list
    delete big;
    std::printf("sizeof(Order) = %zu\n", sizeof(Order));
    std::printf("blocks from system: %d\n", Order::from_system);
    Order::release_all();
}
sizeof(Order) = 48
blocks from system: 100

Complexity and pitfalls. O(1) allocate and free, 300 objects served from 100 system blocks. Note that sizeof(Order) is 48, not 40: the virtual destructor adds a vptr. That is exactly why the size check matters. Because the destructor is virtual, the sized operator delete receives the dynamic type's size, so BigOrder blocks go back to the global allocator. Pitfalls: not thread-safe (add a mutex or a thread-local list), never returns memory to the system until release_all, and a use-after-free on an Order now corrupts your free list instead of being caught by ASan.

Estimated rating: ~1600 · intrusive free list plus the inherited operator new size trap

Cheat sheet

Theory
  • new = operator new + constructor; delete = destructor + operator delete. malloc/free only move bytes. If a constructor throws inside new, the matching operator delete frees the storage and no destructor runs.
  • Failure: new throws std::bad_alloc after looping through the new_handler; new (std::nothrow) returns null but does not stop constructor exceptions; malloc returns null. On Linux, overcommit means real exhaustion usually shows up as the OOM killer.
  • Pair the families: new/delete, new[]/delete[], malloc/free. Mixing them is UB; the array cookie (present for non-trivial destructors) is why new[] + delete crashes for class types.
  • No renew: objects cannot be moved by memcpy in general, so containers allocate, move and destroy. On realloc failure the old block survives; never write p = realloc(p, n).
  • Customise by replacing the global functions (all forms default to plain new/delete) or with class-specific ones (inherited, so check the size). Always pair a placement new with a placement delete. C++17 aligned new handles alignas beyond 16 automatically.
  • Zero size: new returns a unique non-null pointer; malloc(0) is implementation-defined; realloc(p, 0) is UB in C23.
  • glibc malloc: an 8-byte size word just before your pointer (the chunk starts 16 bytes earlier), tcache then bins then top chunk; brk for the main heap, mmap at 128 KiB and above; per-thread arenas trade memory for less locking.
  • Leaks: RAII prevents most; find the rest with ASan/LSan (-fsanitize=address), valgrind memcheck, or heap profilers for growth that is still reachable.

Part E · Memory management · Chapter 10

Memory fragmentation

Fragmentation is memory you own but cannot use: bytes wasted inside blocks, or free space split into holes too small for the next request. This chapter explains where it comes from, how allocators fight it (fit policies, size classes, buddies, slabs, coalescing), why a process's RSS stays high after free, and what you can do about it in a long-running C++ service. Interviewers at HFT firms and systems teams ask about it because it explains real production incidents: latency spikes, slow memory growth and out-of-memory kills with plenty of "free" memory.

TheoryDrillsCoding· about 3 hours· prerequisites: Ch 9 (how malloc works)

Internal vs external fragmentation

Theory

Mental model. An allocator hands out blocks from a bigger region. Internal fragmentation is waste inside a block: you asked for 33 bytes and got a 48-byte block, so 15 bytes are allocated but useless. External fragmentation is waste between blocks: the free memory exists, but it is split into many small holes, so a large request fails even though the total free space would be enough.

Internal: requests rounded up to size classes req 3315 req 2012 req 65 (class 96)31 wasted blue = bytes the program asked for, red = rounding waste that nobody can use until the block is freed External: enough free bytes in total, no hole big enough used free 20 used free 20 used free 20 used malloc(48) fails 60 bytes free in total, largest hole only 20
Two different wastes. Internal fragmentation is decided at allocation time by rounding policy. External fragmentation builds up over time from the order of frees, and a non-moving allocator can only reduce it by merging neighbouring holes.

Sources of internal fragmentation

  • Alignment and minimum size. glibc rounds every request up to a multiple of 16 bytes plus an 8-byte header, with a 32-byte minimum chunk on 64-bit, so malloc(1) occupies 32 bytes.
  • Size classes. Allocators that keep one free list per size round up to the class: jemalloc's spacing keeps the worst-case waste near 20% once sizes pass the first few tiny classes (a 17-byte request still takes a 32-byte slot), while a power-of-two scheme wastes up to 50% at every size.
  • Pages. The kernel maps memory in 4 KiB pages (or 2 MiB huge pages), so the last page of every mapping is on average half empty.
  • Padding inside objects (Ch 1) and container slack: a std::vector with capacity 1024 and size 513 wastes 511 elements.

Sources of external fragmentation

External fragmentation needs two ingredients: varying sizes and interleaved lifetimes. If every block were the same size, any hole would fit any request. If everything were freed in reverse allocation order (a stack), holes would always merge back into the top. Real programs break both: a server allocates a 200-byte request object, then a 4 KiB buffer, then a 48-byte session entry that lives for an hour. When the short-lived neighbours are freed, the long-lived entry pins the middle of the region, and the space around it can only be reused by requests that fit exactly.

How fragmentation builds up in long-running processes

Theory

Mental model. A fresh heap is one big free block. Every allocation that outlives its neighbours becomes a pin. After hours of traffic the heap looks like a car park after a busy day: many single empty spaces scattered between parked cars, and no room for a bus.

The typical patterns behind production incidents:

  • Mixed lifetimes in one heap. Long-lived cache entries allocated between short-lived request buffers. Each cache entry prevents its neighbours from coalescing.
  • Growing then shrinking containers. A burst makes a std::vector or std::string grow through 1, 2, 4, ... 64 MB; the freed smaller buffers leave holes behind that only small requests can use.
  • Size drift. The workload changes from many 64-byte objects to many 80-byte objects. The free 64-byte chunks (or a size class's dedicated pages) cannot serve the new size, so the heap grows while old free memory sits idle.
  • Per-thread caches and arenas. Memory freed by thread A is cached for thread A. If thread B now does the allocating, the heap grows again. glibc's per-thread arenas make this visible as RSS that is several times the live data (Ch 9).
  • Producer-consumer across threads. One thread allocates messages, another frees them. Allocators with thread-local caches must ship the memory back, and some (older ptmalloc versions especially) do this badly.

Know this cold

C++ cannot compact the heap. Raw pointers and references are plain addresses, the language allows hiding them in integers, and nothing records where they are, so an allocator can never move a live block to close a hole. Fragmentation therefore only goes down when blocks next to each other are both freed. Java and .NET compacting collectors can move objects because the runtime knows every reference.

Placement policies: first, best, next and worst fit

Theory

Mental model. Given a list of free holes and a request, which hole do you carve the block from? The answer changes both speed and how the holes evolve.

PolicyRuleStrengthWeakness
First fitScan from the start, take the first hole that fitsSimple; address-ordered first fit keeps low addresses denseSmall leftovers pile up at the front, so scans get slower
Next fitLike first fit, but resume where the last search stoppedSpreads work across the listScatters long-lived blocks everywhere; measured fragmentation is worse than first fit
Best fitTake the smallest hole that fitsKeeps big holes intact for big requestsLeaves tiny slivers; needs an ordered structure to be fast
Worst fitTake the largest holeLeftovers stay usableDestroys large holes; performs badly in practice
Free list (address order), request = 25 hole A: 100 used hole B: 30 used hole C: 60 used first fit → A (leaves 75) best fit → B (leaves 5) next fit → C if the last search stopped after B Then requests 55 and 90 arrive: first fit: 55 → A, 90 fails (holes 20, 30, 60) best fit: 55 → C, 90 → A (holes 10, 5, 5)
The policy decides which hole survives. In this sequence best fit serves all three requests and first fit fails the third. Other sequences favour first fit; there is no policy that wins on every input.

What measurements say

The classic survey by Wilson, Johnstone, Neely and Boles (1995) replayed real program traces and found that address-ordered first fit and best fit both fragment very little on real workloads, while next fit is noticeably worse. Two older results are interview favourites. Knuth's fifty-percent rule: in an allocator that always coalesces adjacent free blocks, at equilibrium with N allocated blocks there are about N/2 free holes, so roughly a third of all blocks are holes. (It is a statistical result under steady-state assumptions, and it says nothing about how big the holes are.) Robson's bound: for any allocator that cannot move blocks there exist adversarial request sequences that need memory proportional to M·log2 n, where M is the maximum live data and n the ratio of largest to smallest block size. Robson proved a lower bound of roughly ½·M·log2 n for every non-moving allocator and gave one that stays within about M·log2 n. No placement policy can prevent fragmentation in the worst case; the good ones exploit the fact that real programs are not adversarial.

Splitting and coalescing

Theory

Mental model. Splitting carves the requested size off a larger hole and keeps the remainder as a smaller hole. Coalescing is the reverse: when a block is freed, merge it with a free neighbour on either side so that holes grow back. Without coalescing, a heap that has served many small requests can never serve a large one again.

Before free(B) used free 32footer: 32 B: 64 (being freed) free 16 used read the word before B: the previous block's footer gives its size and status B's address + 64 = next header After coalescing used one free hole: 32 + 64 + 16 = 112 used
Boundary tags make coalescing O(1). Knuth's trick stores the size at both ends of a free block. The header of the next block is at B + size; the footer of the previous block sits just before B. glibc keeps only one bit (PREV_INUSE) in each header and reuses the footer space when the previous block is in use.

Immediate vs deferred coalescing

Coalescing on every free costs time and can be wasted work if the program immediately asks for the same size again (free 64, malloc 64, free 64...). So allocators often defer it: glibc's tcache and fastbins keep small chunks unmerged and marked "in use", and coalesce the fastbins in bulk (malloc_consolidate) when a large-bin request arrives or when a free produces a merged chunk of 64 KiB or more. Chunks in the tcache are never coalesced while they sit there. The trade-off: deferred coalescing is faster for repeating sizes and worse for fragmentation when the size mix changes. Size-class allocators (next section) mostly avoid the question: blocks in a size-class page never merge with each other; the whole page becomes free when its last block is freed.

Segregated lists, size classes, buddies and slabs

Theory

Segregated free lists and size classes

Mental model. Instead of one list of holes of every size, keep a separate free list per size class, and carve each class from its own pages. A request is rounded up to its class and popped from that list in O(1). Since every block on a list is the same size, any free block fits any request of that class: external fragmentation within the class disappears. The price is internal fragmentation from rounding, plus pages that belong to one class sitting half-empty when the program stops using that size.

Class spacing is the main design knob. Powers of two are simple but waste up to 50%. jemalloc uses four classes per doubling (for example 80, 96, 112, 128, then 160, 192, 224, 256), capping waste at about 20% of the block (the request is rounded up by at most a quarter of the class's power of two). tcmalloc uses a hand-tuned table of roughly 60 to 90 classes (Google's documentation says 60 to 80, gperftools about 88), chosen from allocation profiles to keep rounding waste small; it publishes no single worst-case bound, so do not quote one.

The buddy system

The buddy allocator manages a power-of-two region. Every block has size 2k times a minimum unit, and every block (except the whole region) has a unique buddy of the same size: the other half of the block it was split from. The buddy's address is found with one XOR: buddy = offset ^ size. Allocation rounds up to a power of two, takes a free block of that order, or splits a larger one in halves repeatedly. Freeing checks whether the buddy is free; if so, merges and repeats one level up.

1024 KiB region, requests 100, 200, 60 KiB 1024 (split) 512 (split) 512 free 256 (split) 256: req 200 128: 100 128 split 64 free waste inside blocks: 28 + 56 + 4 = 88 KiB buddy of offset 128 (size 64) = 128 ^ 64 = 192 freeing 128 KiB block at 0: buddy at 128 is split, so no merge yet
Buddies split in halves and merge by XOR. Splitting and merging are O(log n) and need no search, which is why the Linux kernel uses a buddy allocator for physical pages. The cost is internal fragmentation: a request just above a power of two wastes almost half its block.

The Linux kernel's page allocator is a buddy system over physical page frames with orders 0 to 10 (4 KiB up to 4 MiB with 4 KiB pages). cat /proc/buddyinfo shows how many free blocks of each order every memory zone has; a machine with plenty of order-0 pages and no order-9 pages cannot satisfy a 2 MiB huge page without compaction.

Slab allocation

A slab allocator (Bonwick, SunOS 1994; SLUB in today's Linux kernel) dedicates one or more pages to objects of a single type or size, for example all struct inode objects or all 64-byte kmalloc requests. Each slab is carved into equal slots tracked by a free list. Benefits: no external fragmentation within a slab, O(1) allocation, good cache behaviour, and (in the original design) objects kept in a constructed state so initialisation is not repeated. /proc/slabinfo or slabtop shows the caches. User-space equivalents are fixed-size pools (Ch 15) and object pools (Ch 16).

Measuring fragmentation

Theory

There is no single number, so say which one you mean.

MetricFormulaReads as
Internal fragmentation(bytes allocated − bytes requested) / bytes allocatedRounding and header waste
External fragmentation1 − largest free block / total free bytes0 when all free space is one hole; near 1 when it is shattered
Heap overhead ratioheap size from the OS / live bytes requestedEverything together, plus allocator metadata
RSS ratio (Redis mem_fragmentation_ratio)RSS / live bytes requestedWhat operators see; above about 1.5 usually means fragmentation, below 1 means swapping
Kernel: /proc/buddyinfo, extfrag_indexFree blocks per orderWhether high-order (contiguous) allocations can succeed

Tools: glibc malloc_stats() and malloc_info(0, stdout) print per-arena totals; mallinfo2() returns them as numbers. jemalloc has malloc_stats_print and the stats.* mallctl namespace, tcmalloc has MallocExtension::GetStats. For RSS, read VmRSS in /proc/self/status or use smem/pmap.

Key facts

FactValue (Linux, glibc, 64-bit)
Minimum chunk / alignment32 bytes / 16 bytes
mmap threshold128 KiB default, dynamic up to 32 MiB
Trim threshold (top of heap returned with brk)128 KiB default
Max arenas8 × cores (tunable MALLOC_ARENA_MAX)
Page size / huge page4 KiB / 2 MiB (x86-64)
Kernel buddy orders0 to 10 (4 KiB to 4 MiB)
Power-of-two classes, worst internal wasteJust under 50%
vector growth factor2 (libstdc++, libc++), 1.5 (MSVC, folly)

RSS, virtual memory and why free() does not shrink the process

Theory

Mental model. Three different numbers describe a process's memory. Virtual size (VSZ) is all mapped address space, including reserved and never-touched regions. Resident set size (RSS) is the physical pages currently mapped in. Live heap is what your program has allocated and not freed. free() lowers the third; whether RSS goes down depends on whether the allocator gives pages back to the kernel.

Why RSS stays high after free

  • brk can only shrink from the top. The main glibc heap is one contiguous segment. If one live chunk sits near the top, nothing below it can be released with brk, however much is free. glibc trims automatically only when the free top chunk exceeds M_TRIM_THRESHOLD (128 KiB by default).
  • Free chunks stay in caches and bins, ready for reuse. That is the point of an allocator: going back to the kernel costs a system call and later page faults.
  • Fragmentation inside pages. A 4 KiB page that holds one live 32-byte chunk stays resident.
  • Arenas. Each thread's arena holds its own free memory.
  • Lazy release. Allocators that release pages with madvise(MADV_FREE) (Linux 4.5+; jemalloc's "muzzy" stage when it is enabled, some Go runtime versions) tell the kernel it may take the pages, but RSS only drops when the kernel actually reclaims them under memory pressure. Monitoring then shows "high" RSS that is in fact reclaimable.

What you can do

#include <malloc.h>   // glibc only

// Return free memory to the kernel: trims the top of the main heap and, since
// glibc 2.8, calls madvise(MADV_DONTNEED) on whole free pages inside every arena.
malloc_trim(0);

// Tune at startup (or with GLIBC_TUNABLES=glibc.malloc.trim_threshold=...).
mallopt(M_MMAP_THRESHOLD, 64 * 1024);   // fixing it also disables the dynamic threshold
mallopt(M_ARENA_MAX, 2);

Large allocations (above the mmap threshold) avoid the problem entirely: each one is its own mapping and free unmaps it at once. That is also why a program that frees one huge buffer sees RSS drop immediately, while one that frees a million small nodes does not.

Interview trap

"Our service leaks: RSS grows and never comes down." Before blaming a leak, check the live heap (allocator stats or a heap profiler). If live bytes are flat while RSS is high, the problem is fragmentation, arena count or retention policy, not a leak, and the fixes are different: fewer arenas, another allocator, malloc_trim, or changing allocation patterns.

jemalloc, tcmalloc and mimalloc in brief

Theory

All three replace malloc and operator new (by linking or LD_PRELOAD), use size classes for small objects plus thread-local caches, and give memory back to the OS more actively than ptmalloc. The differences that come up in interviews:

AllocatorSmall objectsConcurrencyReturning memoryKnown for
jemalloc (FreeBSD, Meta)Size classes, 4 per doubling, in "slabs" within extentsThread cache in front of several arenas (4 × cores)Time-based decay purging with madvise (dirty pages, then optionally "muzzy" lazily freed ones; muzzy decay is off by default since 5.2)Low fragmentation, rich statistics and profiling
tcmalloc (Google)About 60 to 90 hand-tuned size classes, spans of pagesPer-thread or per-CPU caches (per-CPU uses Linux restartable sequences), central free lists, page heapBackground release of free spans at a configurable rateVery fast small allocations, huge-page aware design
mimalloc (Microsoft)Free-list sharding: a free list per page, not per size classPages owned by a thread; cross-thread frees go to a separate per-page listEager page reset and segment releaseSmall code base, consistently fast in benchmarks

Switching allocators is a common production fix for RSS bloat in multithreaded C++ services. Measure first: each allocator wins on some workloads and loses on others, and the decision is usually made from a load test that records both RSS and tail latency.

Mitigation: pools, arenas, preallocation and growth policy

Theory

Mental model. Fragmentation comes from mixing sizes and lifetimes in one heap. Every mitigation separates them: same-size objects together, same-lifetime objects together, or no allocation at all on the hot path.

  • Fixed-size pools for hot objects (orders, nodes, messages): one size, so no external fragmentation, O(1) allocation (Ch 15, Ch 16).
  • Arenas per lifetime: everything allocated for one request or one frame goes into a bump allocator that is reset at the end; no individual frees, so no holes (Ch 17). std::pmr::monotonic_buffer_resource is the standard one.
  • Preallocate at startup: HFT systems size every buffer, ring and pool before the market opens, then never call malloc on the hot path. A heap that does not change cannot fragment.
  • reserve() for containers whose final size you know, so they never pass through the 1, 2, 4, 8... sequence of intermediate buffers.
  • Reuse objects instead of freeing and reallocating: clear() a vector or string and keep its capacity.
  • Avoid node-based containers on hot paths: std::list and std::map make one allocation per element; a sorted std::vector or a flat hash map makes a few large ones.
  • Small-buffer optimisation keeps short strings and small functors inside the object (Ch 18).

The vector growth factor

libstdc++ and libc++ double a vector's capacity on reallocation; MSVC and folly's fbvector multiply by 1.5. Both give amortised O(1) push_back. The fragmentation argument for 1.5: with factor f, can the new buffer ever fit in the space released by all the previous buffers? During reallocation the current buffer is still live (its elements are being moved), so the new size fn+1 must fit into the sum of the older blocks 1 + f + ... + fn−1. For f = 2 that sum is 2n − 1, always less than the old block alone, so the new buffer never fits in the old space and the vector keeps walking forward through memory. The condition can hold only when f is below the golden ratio, about 1.618; at 1.5 the old space becomes reusable after a few reallocations. The argument assumes the freed blocks are adjacent and get coalesced, which real allocators with size classes and mmap thresholds do not guarantee, so treat it as a tie-breaker, not a law.

Tip

clear() keeps capacity and shrink_to_fit() is only a request. To really release a vector's buffer, swap with an empty one: std::vector<T>().swap(v);. In a long-running process, prefer keeping the capacity if the vector will grow again, since repeated grow-shrink cycles are a fragmentation source.

The paging angle: physical memory and huge pages

Theory

Mental model. Virtual memory solves external fragmentation of physical memory: the page table can map any free physical frame behind any virtual page, so a process that needs 1 GB of contiguous addresses gets them even if its frames are scattered all over RAM. What it does not solve:

  • Internal fragmentation in pages. Memory is mapped in whole pages, so every mapping wastes on average half a page at its end. With 2 MiB huge pages that average waste is 1 MiB per mapping, which is why huge pages suit big heaps and hurt programs with many small mappings.
  • Fragmentation of the virtual address space. On 32-bit systems a long-running process could fail to find 500 MB of contiguous virtual addresses with gigabytes of RAM free. On 64-bit with 47 bits of user address space it is rarely a problem.
  • Physically contiguous requests. Huge pages, DMA buffers for devices without an IOMMU and some kernel allocations need contiguous frames, which brings back external fragmentation at the physical level. Linux fights it with the buddy allocator, by grouping pages by mobility (movable user pages apart from unmovable kernel pages), and with compaction (kcompactd), which migrates movable pages to build large free blocks. Transparent huge pages depend on compaction succeeding; when it stalls, page faults can take milliseconds, a known cause of latency spikes in trading systems.
  • Fragmentation inside the heap (the rest of this chapter) happens in virtual addresses, above the page tables, and paging does nothing for it.

So the accurate interview answer to "doesn't paging eliminate fragmentation?" is: it eliminates external fragmentation of physical memory for ordinary page-sized mappings, it introduces internal fragmentation at page granularity, and it does nothing about fragmentation inside your process's heap.

Concept and tricky questions

Drills

ConceptDefine internal and external fragmentation and give one cause of each.

Answer

Answer. Internal: memory allocated to a block but not requested by the program (a 33-byte request served from a 48-byte class wastes 15). External: free memory split into holes too small to satisfy a request although the total would suffice. Internal comes from rounding (alignment, size classes, page granularity); external comes from mixed sizes with interleaved lifetimes.

Why. The distinction matters because the fixes differ: finer size classes reduce internal waste, while separating lifetimes or sizes (pools, arenas) reduces external waste. A common slip is calling a leak "fragmentation": leaked memory is live from the allocator's point of view.

ConceptA heap has free blocks of 64, 16, 16 and 32 bytes. Compute the external fragmentation metric and say whether malloc(100) can succeed without growing the heap.

Answer

Answer. Total free = 128, largest = 64, so external fragmentation = 1 − 64/128 = 0.5. A 100-byte request fails without growing the heap, even though 128 bytes are free.

Why. A request needs one contiguous block. The metric is 0 when all free memory is a single hole and approaches 1 when it is shattered into tiny pieces. The heap would extend the top chunk (brk) or mmap, so RSS grows while free memory sits unused.

ConceptHow does the buddy system find a block's buddy, and what is its worst-case internal fragmentation?

Answer

Answer. For a block of size 2k at offset off (relative to the region start), the buddy is at off ^ 2k. Worst-case internal fragmentation is just under 50%: a request of 2k + 1 units gets a 2k+1 block.

Why. Splitting a block of size 2k+1 aligned to its size produces two halves that differ only in bit k of their offset, so flipping that bit finds the other half. That makes merging a constant-time check per level. Average waste for uniformly distributed sizes is about 25%.

ConceptWhat is a slab allocator and why does the Linux kernel use one on top of its buddy allocator?

Answer

Answer. A slab allocator dedicates pages to objects of one size or type and hands out equal slots from them via a free list. The buddy allocator only deals in whole pages (4 KiB and up), so allocating a 64-byte kernel object from it directly would waste most of a page; SLUB carves pages from the buddy allocator into many small slots.

Why. One size per slab means no external fragmentation within it and O(1) operations, and grouping same-type objects is cache friendly. The original Bonwick design also cached constructed objects to skip initialisation. User-space pools apply the same idea.

ConceptA service's live heap is flat at 2 GB but its RSS has grown to 6 GB over a week. Give three likely causes and how you would confirm each.

Answer

Answer. (1) Heap fragmentation: compare allocator stats (malloc_info, jemalloc stats) for free-but-retained bytes. (2) Too many glibc arenas holding per-thread free memory: count arenas in malloc_info, try MALLOC_ARENA_MAX=2. (3) Memory not returned to the kernel (no trim, or lazy MADV_FREE): call malloc_trim(0) in a test and watch RSS drop, or check LazyFree in /proc/PID/smaps.

Why. A flat live heap rules out a classic leak. The usual production fixes are fewer arenas, a different allocator (jemalloc/tcmalloc), separating long-lived from short-lived allocations, or periodic trimming. Heap profilers (heaptrack, jemalloc prof) confirm which call sites dominate.

ConceptWhy do HFT and game engines preallocate everything at startup instead of trusting a good malloc?

Answer

Answer. Predictability. A hot-path malloc can take a lock, walk bins, call brk or mmap, take page faults on fresh memory, or trigger consolidation; the average is fast but the tail is not. A heap that does not change after startup cannot fragment and never needs the kernel.

Why. Latency-sensitive code cares about the 99.9th percentile. Preallocation plus pools, arenas and rings turns allocation into a pointer bump or a free-list pop, and prefaulting (touching or mlocking the memory) removes page faults too.

Tricky"Paging eliminates fragmentation." True or false?

Answer

Answer. Partly false. Paging eliminates external fragmentation of physical memory for page-sized mappings, because any free frame can back any virtual page. It introduces internal fragmentation (on average half a page per mapping), does nothing about fragmentation inside a process's heap, and physical external fragmentation returns for anything that needs contiguous frames: huge pages, DMA buffers, high-order kernel allocations.

Why. That is why Linux has compaction and groups pages by mobility, and why transparent huge pages can stall page faults on a fragmented machine. The one-word "true" is the answer the interviewer is fishing for.

TrickyBest fit picks the tightest hole. Does it always fragment less than first fit?

Answer

Answer. No. Best fit tends to leave tiny unusable slivers, and for some request sequences first fit succeeds where best fit fails (and vice versa). On real program traces, address-ordered first fit and best fit perform similarly well; next fit is the one measurably worse.

Why. Robson showed every non-moving policy has adversarial inputs, and Wilson et al. (1995) found trace behaviour, not the policy name, dominates. Example where first fit wins: holes 20 and 15, requests 10, 10, 15. First fit puts both 10s into the 20 hole and the 15 into the 15 hole. Best fit puts the first 10 into the 15 hole (leaving 5), the second into the 20 (leaving 10), and the 15 fails.

TrickyWhy can a C++ allocator not compact the heap the way the JVM does?

Answer

Answer. Moving a block requires updating every pointer to it, and in C++ nothing knows where those pointers are. They can live in registers, on the stack, inside other objects, be cast to std::uintptr_t, XOR-ed, or written to a file. The JVM's collector knows the exact location of every reference.

Why. Without compaction, external fragmentation only shrinks through coalescing of adjacent free blocks. Systems that need compaction in C++ use handles (an index into a table that the allocator can update) instead of raw pointers, which is what some game engines and databases do.

TrickyWhy does MSVC grow vectors by 1.5 while libstdc++ uses 2, and is the "1.5 lets you reuse memory" argument actually true?

Answer

Answer. With factor f, the new buffer can fit into the coalesced space of all earlier buffers only if f is below the golden ratio (about 1.618), because the current buffer is still live during the copy. At 2 the sum of earlier buffers (2n − 1) is always smaller than the next request, so memory is never reused by the vector itself. Factor 2 wins on fewer reallocations and copies.

Why. The argument assumes the old buffers were adjacent, freed and merged, and that nothing else claimed them. Size-class allocators, the mmap threshold for big buffers and other allocations break those assumptions, so in practice the benefit is small. Both factors give amortised O(1) push_back; the trade-off is copies vs memory.

TrickyA program frees a 1 GB buffer and a million 1 KB nodes (another 1 GB). RSS drops by about 1 GB, not 2. Why?

Answer

Answer. The 1 GB buffer was above the mmap threshold, so it had its own mapping and free unmapped it immediately. The nodes came from the brk heap and arenas; their freed chunks stay in bins and caches, and the heap can only shrink from the top, so most of that memory stays resident until reused or until malloc_trim releases whole free pages.

Why. glibc returns memory to the kernel automatically only for mmapped chunks and for a free top chunk above the trim threshold. People assume free means "give back to the OS"; for small blocks it almost never does.

TrickyDoes std::list fragment the heap more than std::vector? Does reserve() help with fragmentation or only with speed?

Answer

Answer. Usually yes: a list makes one small allocation per element, interleaved with everything else the program allocates, and freeing elements in arbitrary order leaves holes all over the heap. A vector makes O(log n) large allocations. reserve() helps both: it removes the reallocation copies and the trail of discarded intermediate buffers (1, 2, 4, ... n/2) that would otherwise become holes.

Why. Each node also carries allocator overhead (header plus rounding: a 4-byte int in a list node uses a 32-byte chunk with glibc). The counter-argument is that list nodes are all the same size, so their holes are reusable by other nodes; mixed with other sizes, they still fragment.

Tricky"Size-class allocators such as jemalloc and tcmalloc have no external fragmentation, because every block in a class is the same size." True?

Answer

Answer. False. Within one class any free slot fits any request of that class, so there is no hole-too-small problem. But free slots are tied to their class and their page: a run of 64-byte slots with one live object cannot serve a 96-byte request and cannot go back to the OS. After a burst of 64-byte objects is mostly freed, those pages sit nearly empty while the 96-byte class asks for new pages. This is external fragmentation one level up, between classes and pages.

Why. The allocator can only release a page (or span, or extent) when every slot on it is free, and a single long-lived object pins the whole page. That is why these allocators track per-page occupancy, prefer to allocate from the fullest pages so the emptier ones can drain, and report "fragmentation" as resident bytes in partly used runs. The common wrong answer is "size classes trade external for internal fragmentation completely": they only remove the within-class part.

Predict the output and find the bug

Drills

OutputPrint a vector's capacity each time it changes during 17 push_backs.

#include <cstdio>
#include <vector>

int main() {
    std::vector<int> v;
    std::size_t last = v.capacity();
    std::printf("%zu", last);
    for (int i = 0; i < 17; ++i) {
        v.push_back(i);
        if (v.capacity() != last) {
            last = v.capacity();
            std::printf(" %zu", last);
        }
    }
    std::printf("\n");
}
Answer
0 1 2 4 8 16 32

Answer. libstdc++ and libc++ both start at 0 and double: 1, 2, 4, 8, 16, 32. The 17th push_back moves from 16 to 32.

Why. The growth factor is unspecified; the standard only requires amortised constant time. MSVC would print 0 1 2 3 4 6 9 13 19 (factor 1.5, with a minimum growth of one). Each step allocates a new buffer and frees the old one, so without reserve(17) this loop performs six allocations and leaves five freed buffers behind.

OutputWhat are size and capacity after clear(), after shrink_to_fit(), and after the swap trick?

#include <cstdio>
#include <vector>

int main() {
    std::vector<double> v(1000);
    v.clear();
    std::printf("after clear: size=%zu capacity=%zu\n", v.size(), v.capacity());
    v.shrink_to_fit();
    std::printf("after shrink_to_fit: capacity=%zu\n", v.capacity());

    std::vector<double> w(1000);
    std::vector<double>().swap(w);
    std::printf("after swap: capacity=%zu\n", w.capacity());
}
Answer
after clear: size=0 capacity=1000
after shrink_to_fit: capacity=0
after swap: capacity=0

Answer. clear() destroys the elements but keeps the 8000-byte buffer. shrink_to_fit() releases it on both libstdc++ and libc++. The swap trick releases it on every implementation.

Why. shrink_to_fit is a non-binding request ([vector.capacity]), so portable code that must release memory uses the swap idiom. Keeping capacity after clear() is deliberate: a vector that is refilled every iteration then never reallocates, which is a fragmentation and latency win in loops.

OutputA size-class allocator with classes 16, 32, 48, 64, 96 and 128 serves five requests. What does it report?

#include <cstdio>

static const int kClasses[] = {16, 32, 48, 64, 96, 128};

static int class_for(int n) {
    for (int c : kClasses)
        if (n <= c) return c;
    return -1;
}

int main() {
    const int requests[] = {1, 17, 33, 65, 100};
    int asked = 0, given = 0;
    for (int r : requests) {
        int c = class_for(r);
        std::printf("%d->%d ", r, c);
        asked += r;
        given += c;
    }
    std::printf("\nrequested %d, allocated %d, internal waste %.1f%%\n",
                asked, given, 100.0 * (given - asked) / given);
}
Answer
1->16 17->32 33->48 65->96 100->128
requested 216, allocated 320, internal waste 32.5%

Answer. Each request rounds up to the next class; 104 of the 320 allocated bytes are waste, 32.5%.

Why. Internal fragmentation is (allocated − requested) / allocated. The worst offenders are requests just above a class boundary (17, 33, 65). Finer class spacing reduces this at the cost of more free lists and more partly used pages, which is the trade-off every size-class allocator tunes.

OutputHoles of 100, 30 and 60 bytes; requests of 25, 55 and 90. What do first fit and best fit do?

#include <cstdio>
#include <vector>

static int pick(const std::vector<int>& holes, int n, bool best) {
    int chosen = -1;
    for (int i = 0; i < static_cast<int>(holes.size()); ++i) {
        if (holes[i] < n) continue;
        if (!best) return i;
        if (chosen < 0 || holes[i] < holes[chosen]) chosen = i;
    }
    return chosen;
}

static void run(bool best) {
    std::vector<int> holes = {100, 30, 60};
    std::printf("%s:", best ? "best " : "first");
    for (int n : {25, 55, 90}) {
        int i = pick(holes, n, best);
        if (i < 0) { std::printf(" %d->fail", n); continue; }
        holes[i] -= n;
        std::printf(" %d->hole%d", n, i);
    }
    std::printf(" | left:");
    for (int h : holes) std::printf(" %d", h);
    std::printf("\n");
}

int main() {
    run(false);
    run(true);
}
Answer
first: 25->hole0 55->hole0 90->fail | left: 20 30 60
best : 25->hole1 55->hole2 90->hole0 | left: 10 5 5

Answer. First fit carves both small requests from the big hole and then cannot serve 90. Best fit puts them into the tight holes and keeps the big hole for 90.

Why. This sequence favours best fit; others favour first fit (see the tricky drill on best fit). Note the cost: best fit leaves two 5-byte slivers, which are likely too small to ever be used, the classic best-fit weakness.

Find the bugA bump arena aligns every allocation to 8 bytes. Two allocations later the program corrupts the heap. Why?

#include <cstddef>
#include <cstdio>
#include <cstring>

class Arena {
public:
    explicit Arena(std::size_t cap) : buf_(new char[cap]), cap_(cap) {}
    ~Arena() { delete[] buf_; }
    Arena(const Arena&) = delete;
    Arena& operator=(const Arena&) = delete;

    void* alloc(std::size_t n) {
        std::size_t start = (used_ + 7) & ~std::size_t(7);
        if (used_ + n > cap_) return nullptr;
        used_ = start + n;
        return buf_ + start;
    }

private:
    char* buf_;
    std::size_t cap_;
    std::size_t used_ = 0;
};

int main() {
    Arena arena(64);
    char* a = static_cast<char*>(arena.alloc(5));
    char* b = static_cast<char*>(arena.alloc(58));
    if (!a || !b) { std::puts("out of space"); return 0; }
    std::memset(a, 'a', 5);
    std::memset(b, 'b', 58);
    std::puts("done");
}
Answer

Answer. The capacity check uses used_ instead of the aligned start. The second call computes start = 8 but checks 5 + 58 = 63 <= 64, so it returns [8, 66), two bytes past the end of the buffer. Fix: if (start + n > cap_ || start + n < start) return nullptr; (the second test guards overflow).

Why. Alignment padding is internal fragmentation that the bounds check must account for. ASan reports a heap-buffer-overflow in the second memset. Without ASan the two bytes land in the allocator's next chunk header, and the crash happens much later, in an unrelated free. Arenas are covered fully in Ch 17.

Find the bugThe code keeps a reference to the first element while the vector grows.

#include <cstdio>
#include <vector>

int main() {
    std::vector<int> prices = {100, 101, 102};
    int& best = prices[0];
    for (int i = 0; i < 10; ++i) prices.push_back(103 + i);
    best = 99;
    std::printf("%d\n", prices[0]);
}
Answer

Answer. The fourth push_back exceeds capacity 3, so the vector moves to a new buffer and frees the old one. best still refers to the old buffer, and best = 99 writes freed memory. Store an index, take the reference after the growth, or reserve enough first.

Why. Reallocation invalidates all references, pointers and iterators ([vector.modifiers]). Under glibc the write lands in a freed chunk that may now be a tcache entry, corrupting the allocator's own list. ASan reports heap-use-after-free on the assignment.

Find the bugThis toy allocator should end with one free 128-byte block after everything is freed, but it prints something else. What is printed, and what is missing?

#include <cstddef>
#include <cstdio>
#include <iterator>
#include <map>

struct Heap {
    std::map<std::size_t, std::size_t> free_blocks;   // offset -> size, address ordered
    explicit Heap(std::size_t n) { free_blocks[0] = n; }

    std::size_t alloc(std::size_t n) {                // first fit, assumes success
        for (auto it = free_blocks.begin(); it != free_blocks.end(); ++it) {
            if (it->second < n) continue;
            std::size_t off = it->first, rest = it->second - n;
            free_blocks.erase(it);
            if (rest) free_blocks[off + n] = rest;
            return off;
        }
        return SIZE_MAX;
    }

    void release(std::size_t off, std::size_t n) {
        auto it = free_blocks.emplace(off, n).first;
        auto next = std::next(it);
        if (next != free_blocks.end() && off + n == next->first) {
            it->second += next->second;
            free_blocks.erase(next);
        }
    }
};

int main() {
    Heap h(128);
    std::size_t a = h.alloc(32), b = h.alloc(32), c = h.alloc(32);
    h.release(a, 32);
    h.release(b, 32);
    h.release(c, 32);
    std::size_t largest = 0;
    for (auto& [off, size] : h.free_blocks) largest = size > largest ? size : largest;
    std::printf("free blocks: %zu, largest: %zu\n", h.free_blocks.size(), largest);
}
Answer
free blocks: 3, largest: 64

Answer. It prints three free blocks with the largest only 64. release merges with the following block but never with the preceding one. Freeing a then b leaves [0,32) and [32,64) separate; freeing c merges it only with the tail. Add the backward merge: if it != begin and prev.offset + prev.size == off, grow prev and erase it.

Why. Coalescing must look both ways, or the heap slowly turns into slivers and large requests fail with plenty of free bytes, which is exactly external fragmentation. Real allocators use boundary tags so the previous block can be found in O(1); an address-ordered map gives O(log n).

OutputA buddy allocator over 1024 KiB receives requests of 100, 200, 60 and 300 KiB. What block sizes are used and how much is wasted?

#include <cstdio>

static unsigned round_pow2(unsigned n) {
    unsigned p = 1;
    while (p < n) p <<= 1;
    return p;
}

int main() {
    const unsigned requests[] = {100, 200, 60, 300};
    unsigned total_req = 0, total_block = 0;
    for (unsigned r : requests) {
        unsigned b = round_pow2(r);
        std::printf("%u KiB -> %u KiB block (waste %u)\n", r, b, b - r);
        total_req += r;
        total_block += b;
    }
    std::printf("requested %u of 1024, blocks use %u, fits: %s\n",
                total_req, total_block, total_block <= 1024 ? "maybe" : "no");
}
Answer
100 KiB -> 128 KiB block (waste 28)
200 KiB -> 256 KiB block (waste 56)
60 KiB -> 64 KiB block (waste 4)
300 KiB -> 512 KiB block (waste 212)
requested 660 of 1024, blocks use 960, fits: maybe

Answer. Blocks of 128, 256, 64 and 512 KiB; 300 KiB wastes 212 KiB because it sits just above 256. The blocks total 960 KiB, so they fit, but only because their placement works out: 512 + 256 + 128 + 64 can all be carved from one 1024 region.

Why. Buddy allocation trades internal fragmentation (here 300 of 960 KiB, 31%) for O(log n) splits and merges with no searching. "Maybe" is the honest answer for total ≤ capacity in general: a buddy system can also fail on external fragmentation if the free blocks are not buddies of each other.

Write the code

Coding

CodeFirst-fit allocator simulation with splitting and coalescing. Manage a region of N bytes by offset. alloc(n) rounds n up to 8, uses first fit and returns an offset or std::nullopt. release(off) frees the block and merges it with both neighbours. Expose total free bytes, largest free block and the external fragmentation metric.

Answer

Approach. Keep free blocks in an address-ordered std::map<offset, size> and live blocks in another map so release knows the size. Allocation scans in address order (first fit) and splits. Release inserts the block, then merges with the successor and the predecessor if they touch.

#include <cstddef>
#include <cstdio>
#include <iterator>
#include <map>
#include <optional>

class FirstFit {
public:
    explicit FirstFit(std::size_t size) { free_[0] = size; }

    std::optional<std::size_t> alloc(std::size_t n) {
        n = (n + 7) & ~std::size_t(7);
        if (n == 0) n = 8;
        for (auto it = free_.begin(); it != free_.end(); ++it) {
            if (it->second < n) continue;
            std::size_t off = it->first, rest = it->second - n;
            free_.erase(it);
            if (rest) free_[off + n] = rest;
            used_[off] = n;
            return off;
        }
        return std::nullopt;
    }

    void release(std::size_t off) {
        auto u = used_.find(off);
        if (u == used_.end()) return;                    // unknown or double free
        std::size_t n = u->second;
        used_.erase(u);
        auto it = free_.emplace(off, n).first;
        if (auto next = std::next(it); next != free_.end() && it->first + it->second == next->first) {
            it->second += next->second;
            free_.erase(next);
        }
        if (it != free_.begin()) {
            auto prev = std::prev(it);
            if (prev->first + prev->second == it->first) {
                prev->second += it->second;
                free_.erase(it);
            }
        }
    }

    std::size_t total_free() const {
        std::size_t t = 0;
        for (auto& [off, size] : free_) t += size;
        return t;
    }
    std::size_t largest_free() const {
        std::size_t m = 0;
        for (auto& [off, size] : free_) m = size > m ? size : m;
        return m;
    }
    double external_frag() const {
        std::size_t t = total_free();
        return t ? 1.0 - double(largest_free()) / double(t) : 0.0;
    }
    std::size_t holes() const { return free_.size(); }

private:
    std::map<std::size_t, std::size_t> free_, used_;
};

static void show(const char* label, const FirstFit& h) {
    std::printf("%-12s holes=%zu free=%zu largest=%zu frag=%.2f\n", label,
                h.holes(), h.total_free(), h.largest_free(), h.external_frag());
}

int main() {
    FirstFit h(256);
    auto a = h.alloc(64), b = h.alloc(64), c = h.alloc(64), d = h.alloc(30);
    show("4 allocs", h);
    h.release(*a);
    h.release(*c);
    show("free a, c", h);
    std::printf("alloc(100): %s\n", h.alloc(100) ? "ok" : "fails");
    h.release(*b);
    show("free b", h);
    auto e = h.alloc(100);
    std::printf("alloc(100): at %zu\n", *e);
    h.release(*e);
    h.release(*d);
    show("free all", h);
}
4 allocs     holes=1 free=32 largest=32 frag=0.00
free a, c    holes=3 free=160 largest=64 frag=0.60
alloc(100): fails
free b       holes=2 free=224 largest=192 frag=0.14
alloc(100): at 0
free all     holes=1 free=256 largest=256 frag=0.00

Complexity and pitfalls. Allocation is O(h) in the number of holes (first fit scans), release is O(log h). The d = alloc(30) request rounds to 32, so only 32 bytes remain at first. With a and c free there are 160 free bytes and the 100-byte request still fails: external fragmentation in action. After freeing b, three adjacent blocks merge into one 192-byte hole. Pitfalls: forgetting the backward merge, double free (handled by the used_ lookup), and taking iterators after erase.

Estimated rating: ~1500 · ordered-map bookkeeping with careful two-sided merging

CodeBuddy allocator. Manage 2K units. alloc(units) rounds up to a power of two, takes the smallest free block that fits, and splits it; release(off) merges with free buddies all the way up. Show the free lists after a few operations.

Answer

Approach. One std::set of offsets per order. To allocate order k, find the smallest order j ≥ k with a free block, remove it, and split down: each split puts the upper half (off + 2j−1) on list j−1. Remember the order of each live block. To free, repeatedly check whether off ^ 2k is on list k; if so remove it, take the lower offset and go up one order.

#include <cstddef>
#include <cstdio>
#include <map>
#include <optional>
#include <set>
#include <vector>

class Buddy {
public:
    explicit Buddy(int max_order) : max_(max_order), free_(max_order + 1) {
        free_[max_].insert(0);
    }

    std::optional<std::size_t> alloc(std::size_t units) {
        int k = 0;
        while ((std::size_t(1) << k) < units) ++k;
        int j = k;
        while (j <= max_ && free_[j].empty()) ++j;
        if (j > max_) return std::nullopt;
        std::size_t off = *free_[j].begin();
        free_[j].erase(free_[j].begin());
        while (j > k) {
            --j;
            free_[j].insert(off + (std::size_t(1) << j));   // upper half stays free
        }
        order_of_[off] = k;
        return off;
    }

    void release(std::size_t off) {
        auto it = order_of_.find(off);
        if (it == order_of_.end()) return;
        int k = it->second;
        order_of_.erase(it);
        while (k < max_) {
            std::size_t buddy = off ^ (std::size_t(1) << k);
            auto b = free_[k].find(buddy);
            if (b == free_[k].end()) break;
            free_[k].erase(b);
            off = off < buddy ? off : buddy;
            ++k;
        }
        free_[k].insert(off);
    }

    void dump(const char* label) const {
        std::printf("%-10s", label);
        for (int k = 0; k <= max_; ++k)
            for (std::size_t off : free_[k]) std::printf(" [%zu:%zu]", off, std::size_t(1) << k);
        std::printf("\n");
    }

private:
    int max_;
    std::vector<std::set<std::size_t>> free_;
    std::map<std::size_t, int> order_of_;
};

int main() {
    Buddy b(10);                         // 1024 units, think KiB
    auto x = b.alloc(100);               // 128
    auto y = b.alloc(200);               // 256
    auto z = b.alloc(60);                // 64
    std::printf("x=%zu y=%zu z=%zu\n", *x, *y, *z);
    b.dump("allocated");
    b.release(*x);
    b.dump("free x");
    b.release(*z);
    b.dump("free z");
    b.release(*y);
    b.dump("free y");
}
x=0 y=256 z=128
allocated  [192:64] [512:512]
free x     [192:64] [0:128] [512:512]
free z     [0:256] [512:512]
free y     [0:1024]

Complexity and pitfalls. O(K log n) per operation with sets (O(K) with intrusive lists and a bitmap of free blocks, as the kernel does). Freeing x cannot merge because its buddy at 128 is split; freeing z (64 units at 128) merges it with its free buddy at 192 into a 128-unit block at 128, which then merges with x's block at 0 into 256 at 0. Pitfalls: computing the buddy with + instead of XOR, merging with a buddy that is free but of a different order (the per-order set prevents it), and forgetting to keep the lower offset after a merge.

Estimated rating: ~1700 · split/merge invariants and XOR buddy addressing

CodeBest fit in O(log n) with coalescing. Same interface as the first-fit drill, but choose the smallest hole that fits using an index by size. Create holes of 100, 30 and 60 bytes and serve 25, 55, 90.

Answer

Approach. Keep two indexes over the same free blocks: by_addr (offset to size) for coalescing, and by_size, a std::set of (size, offset) pairs for best fit via lower_bound({n, 0}). Every insertion and removal updates both. No rounding here so the numbers match the reading drill.

#include <cstddef>
#include <cstdio>
#include <iterator>
#include <map>
#include <optional>
#include <set>
#include <utility>

class BestFit {
public:
    explicit BestFit(std::size_t size) { add(0, size); }

    std::optional<std::size_t> alloc(std::size_t n) {
        auto it = by_size_.lower_bound({n, 0});
        if (it == by_size_.end()) return std::nullopt;
        auto [size, off] = *it;
        remove(off, size);
        if (size > n) add(off + n, size - n);
        used_[off] = n;
        return off;
    }

    void release(std::size_t off) {
        auto u = used_.find(off);
        if (u == used_.end()) return;
        std::size_t n = u->second;
        used_.erase(u);
        auto next = by_addr_.lower_bound(off);
        if (next != by_addr_.end() && off + n == next->first) {
            std::size_t ns = next->second;
            remove(next->first, ns);
            n += ns;
        }
        auto after = by_addr_.lower_bound(off);
        if (after != by_addr_.begin()) {
            auto prev = std::prev(after);
            if (prev->first + prev->second == off) {
                std::size_t po = prev->first, ps = prev->second;
                remove(po, ps);
                off = po;
                n += ps;
            }
        }
        add(off, n);
    }

    void dump() const {
        for (auto& [off, size] : by_addr_) std::printf(" [%zu:%zu]", off, size);
        std::printf("\n");
    }

private:
    void add(std::size_t off, std::size_t size) { by_addr_[off] = size; by_size_.insert({size, off}); }
    void remove(std::size_t off, std::size_t size) { by_addr_.erase(off); by_size_.erase({size, off}); }

    std::map<std::size_t, std::size_t> by_addr_, used_;
    std::set<std::pair<std::size_t, std::size_t>> by_size_;
};

int main() {
    BestFit h(220);
    auto a = h.alloc(100); h.alloc(10);
    auto c = h.alloc(30);  h.alloc(10);
    auto e = h.alloc(60);  h.alloc(10);
    h.release(*a); h.release(*c); h.release(*e);
    std::printf("holes:"); h.dump();
    for (std::size_t n : {25, 55, 90}) {
        auto off = h.alloc(n);
        if (off) std::printf("alloc(%zu) -> %zu\n", n, *off);
        else std::printf("alloc(%zu) -> fail\n", n);
    }
    std::printf("holes:"); h.dump();
}
holes: [0:100] [110:30] [150:60]
alloc(25) -> 110
alloc(55) -> 150
alloc(90) -> 0
holes: [90:10] [135:5] [205:5]

Complexity and pitfalls. O(log n) allocate and free. The 10-byte separators keep the three holes from merging. Pitfalls: updating one index and forgetting the other (the private add/remove pair prevents that), using a std::multimap by size and then erasing the wrong block with the same size (the (size, offset) pair makes keys unique), and invalidated iterators after remove (the code re-queries lower_bound). Note the two 5-byte slivers: best fit's characteristic leftover.

Estimated rating: ~1700 · two synchronised ordered indexes with merge in both directions

CodeSegregated size-class allocator. Over a fixed 4096-byte arena, serve requests up to 128 bytes from classes 16, 32, 64 and 128. Each class has an intrusive free list; when empty it carves a 512-byte run from the arena. Larger requests return null (they would go to a different path). Track requested and allocated bytes to report internal fragmentation, and show that a freed block is reused by the next request of the same class.

Answer

Approach. Map size to class index with a loop over four classes. A free block stores the next pointer in its first bytes. Refill carves a run of 512 / class blocks from the arena's bump pointer and threads them onto the list. free needs the size (as with sized delete) to find the class.

#include <cstddef>
#include <cstdio>
#include <new>

class SizeClassHeap {
public:
    static constexpr std::size_t kClass[] = {16, 32, 64, 128};
    static constexpr int kNum = 4;
    static constexpr std::size_t kRun = 512;

    void* alloc(std::size_t n) {
        int c = class_of(n);
        if (c < 0) return nullptr;
        if (!lists_[c] && !refill(c)) return nullptr;
        Free* f = lists_[c];
        lists_[c] = f->next;
        requested_ += n;
        allocated_ += kClass[c];
        return f;
    }

    void free(void* p, std::size_t n) {
        int c = class_of(n);
        lists_[c] = ::new (p) Free{lists_[c]};
        requested_ -= n;
        allocated_ -= kClass[c];
    }

    std::size_t offset(const void* p) const { return static_cast<const char*>(p) - arena_; }
    double internal_frag() const { return allocated_ ? 1.0 - double(requested_) / double(allocated_) : 0.0; }
    std::size_t arena_used() const { return bump_; }

private:
    struct Free { Free* next; };

    static int class_of(std::size_t n) {
        for (int c = 0; c < kNum; ++c)
            if (n <= kClass[c]) return c;
        return -1;
    }

    bool refill(int c) {
        if (bump_ + kRun > sizeof arena_) return false;
        char* run = arena_ + bump_;
        bump_ += kRun;
        for (std::size_t off = kRun; off >= kClass[c]; off -= kClass[c])
            lists_[c] = ::new (run + off - kClass[c]) Free{lists_[c]};
        return true;
    }

    alignas(16) char arena_[4096];
    std::size_t bump_ = 0;
    Free* lists_[kNum] = {};
    std::size_t requested_ = 0, allocated_ = 0;
};

int main() {
    static SizeClassHeap heap;
    void* a = heap.alloc(10);      // class 16
    void* b = heap.alloc(20);      // class 32, new run
    void* c = heap.alloc(100);     // class 128, new run
    std::printf("a@%zu b@%zu c@%zu, arena used %zu\n",
                heap.offset(a), heap.offset(b), heap.offset(c), heap.arena_used());
    std::printf("internal fragmentation: %.3f\n", heap.internal_frag());
    heap.free(b, 20);
    void* d = heap.alloc(30);      // same class as b
    std::printf("d reuses b: %d\n", d == b);
    std::printf("too big: %s\n", heap.alloc(200) ? "served" : "null");
    heap.free(a, 10); heap.free(c, 100); heap.free(d, 30);
    std::printf("after freeing all: arena used %zu (runs are kept)\n", heap.arena_used());
}
a@0 b@512 c@1024, arena used 1536
internal fragmentation: 0.261
d reuses b: 1
too big: null
after freeing all: arena used 1536 (runs are kept)

Complexity and pitfalls. O(1) allocate and free after refill. The three live requests total 10 + 20 + 100 = 130 bytes in blocks of 16 + 32 + 128 = 176 bytes, so internal fragmentation is 1 − 130/176 = 0.261. Each class owns its own 512-byte run, so the arena is 1536 bytes used for 176 bytes of blocks: unused slots in a class's run are the size-class version of external fragmentation, and freeing every block does not give the runs back. Pitfalls: free with the wrong size puts the block on the wrong list (sized delete has the same contract), class sizes must be multiples of the alignment you promise, and the free-list pointer overwrites the first bytes of a freed block, so use-after-free bugs corrupt the list.

Estimated rating: ~1600 · intrusive free lists plus run carving and per-class accounting

CodeGrowth factor experiment. Model a vector that starts at capacity 1000 and grows by factor f (new capacity = max(old + 1, floor(old · f))). Old buffers are freed after the copy and coalesce into one region at the start of memory; the current buffer is live while the next one is allocated. For f = 1.5, 1.6, 1.7 and 2.0, report the first reallocation whose new buffer fits into the freed region, within 40 reallocations.

Answer

Approach. Track freed (sum of all buffers before the current one) and cur. At each step compute next; if next <= freed the allocator could place it in the old space. Otherwise the current buffer joins the freed region after the copy and cur becomes next. The golden-ratio argument predicts reuse for f below about 1.618 and never for f above it.

#include <cmath>
#include <cstdio>
#include <initializer_list>

static int first_reuse(double f, long long* at_capacity) {
    long long cur = 1000, freed = 0;
    for (int step = 1; step <= 40; ++step) {
        long long next = static_cast<long long>(std::floor(cur * f));
        if (next < cur + 1) next = cur + 1;
        if (next <= freed) { *at_capacity = next; return step; }
        freed += cur;          // the old buffer is released after the copy
        cur = next;
    }
    return -1;
}

int main() {
    for (double f : {1.5, 1.6, 1.7, 2.0}) {
        long long cap = 0;
        int step = first_reuse(f, &cap);
        if (step > 0) std::printf("f=%.1f: reuse at reallocation %d (capacity %lld)\n", f, step, cap);
        else std::printf("f=%.1f: never within 40 reallocations\n", f);
    }
}
f=1.5: reuse at reallocation 5 (capacity 7593)
f=1.6: reuse at reallocation 8 (capacity 42940)
f=1.7: never within 40 reallocations
f=2.0: never within 40 reallocations

Complexity and pitfalls. O(steps). The result matches the theory: below the golden ratio the freed prefix eventually catches up (after 5 reallocations at 1.5, after 8 at 1.6, and later the closer f gets to 1.618), above it never does. For f = 1.5 the condition is 2(1.5n − 1) ≥ 1.5n+1, which first holds at n = 4 previous buffers, the fifth reallocation. Starting at 1000 keeps integer rounding out of the way: from capacity 1 the forced minimum growth (1, 2, 3, 4, 6...) produces early "reuse" that is an artefact of rounding, not of the factor. The model ignores size classes, other allocations and the mmap threshold, so real allocators show a weaker effect; say so when you quote it.

Estimated rating: ~1300 · simple simulation, the insight is in setting up the invariant correctly

Cheat sheet

Theory
  • Internal = waste inside blocks (rounding, headers, pages). External = free memory split into holes too small to use. External frag metric: 1 − largest free / total free.
  • Fragmentation needs mixed sizes and interleaved lifetimes. C++ cannot compact, so only coalescing adjacent free blocks reduces it.
  • Fit policies: first and best fit both do well on real traces; next fit is worse; none is safe against adversarial sequences (Robson). Best fit leaves slivers.
  • Coalescing with boundary tags is O(1); allocators defer it for small sizes (tcache, fastbins) for speed. Size classes remove external frag per class at the cost of rounding; buddy splits and merges by XOR with up to 50% internal waste; slab = pages per object size.
  • RSS stays high after free because brk only shrinks from the top, free chunks are cached, pages are partly used, threads have their own arenas, and MADV_FREE is lazy. Large blocks (above 128 KiB by default) are mmapped and returned at once. malloc_trim(0) and MALLOC_ARENA_MAX are the glibc levers.
  • Mitigate by separating sizes and lifetimes: pools, arenas, preallocation, reserve, reusing capacity, flat containers instead of node-based ones, or a different allocator (jemalloc, tcmalloc, mimalloc).
  • Growth factor: 2 in libstdc++/libc++, 1.5 in MSVC; only factors below about 1.618 can ever reuse their own freed buffers.
  • Paging removes external fragmentation of physical memory, adds internal fragmentation per page, and brings external fragmentation back for contiguous needs (huge pages, DMA), which the kernel handles with buddy lists and compaction.

Part E · Memory management · Chapter 11

Smart pointers

Smart pointers turn ownership into a type: unique_ptr means "I alone own this", shared_ptr means "we own this together", and weak_ptr means "I want to look at it if it still exists". This chapter covers what each one costs, how the control block works, and the pitfalls that come up in almost every C++ interview: make_shared trade-offs, the aliasing constructor, enable_shared_from_this, double ownership and thread safety.

TheoryDrillsCoding· about 4 hours· prerequisites: Ch 9 (new and delete), Ch 20 (move semantics)

Ownership models

Theory

Mental model. Every heap object needs exactly one answer to "who calls delete, and when?". A raw T* cannot express that answer; it looks the same whether it owns, borrows or points into an array. Smart pointers are RAII classes (Ch 1) whose destructor releases the object, and whose copy and move rules encode the ownership policy.

ModelTypeCopyCostUse for
Exclusive ownershipstd::unique_ptr<T>Move onlySame as a raw pointerThe default for owning heap objects: factories, pimpl, trees, polymorphic members
Shared ownershipstd::shared_ptr<T>Copy increments an atomic countTwo pointers plus a control blockLifetime genuinely shared by independent parties: caches, async callbacks, graph-like data
Non-owning observer that can detect expirystd::weak_ptr<T>Copy increments the weak countTwo pointersCaches, observers, breaking cycles (Ch 13)
Non-owning borrowT&, T*, std::spanFreeNoneFunction parameters, back pointers whose target outlives the pointer

The guideline is: own with values first, then with unique_ptr, and reach for shared_ptr only when you cannot name a single owner. With smart pointers as members, a class usually needs no destructor, copy or move operations of its own (the rule of zero). Raw pointers stay perfectly fine as non-owning parameters and observers; the C++ Core Guidelines (R.3) say a raw T* is non-owning by convention.

Know this cold

unique_ptr: single owner, move-only, zero overhead with a stateless deleter. shared_ptr: shared owners, copyable, an atomic reference count in a separately allocated control block. weak_ptr: observes a shared_ptr-managed object without keeping it alive; call lock() to use it.

unique_ptr

Theory

Mental model. A unique_ptr<T, D> is a pointer plus a deleter D (by default std::default_delete<T>, which calls delete). Its copy constructor and copy assignment are deleted; its move operations transfer the pointer and leave the source null. The destructor calls the deleter if the pointer is non-null.

The API in one program

#include <cstdio>
#include <memory>
#include <utility>

struct Res {
    int id;
    explicit Res(int i) : id(i) { std::printf("+Res%d\n", id); }
    ~Res() { std::printf("-Res%d\n", id); }
};

int main() {
    auto a = std::make_unique<Res>(1);        // C++14
    std::unique_ptr<Res> b = std::move(a);    // ownership moves, a becomes null
    std::printf("a is null: %d, b->id = %d\n", a == nullptr, b->id);

    b.reset(new Res(2));                      // Res2 built first, then Res1 deleted
    Res* raw = b.release();                   // b gives up ownership, deletes nothing
    std::printf("b is null: %d\n", !b);
    std::unique_ptr<Res> c(raw);              // someone must own it again

    auto arr = std::make_unique<int[]>(3);    // value-initialised: 0 0 0
    arr[1] = 7;
    std::printf("arr: %d %d %d\n", arr[0], arr[1], arr[2]);
}
+Res1
a is null: 1, b->id = 1
+Res2
-Res1
b is null: 1
arr: 0 7 0
-Res2
  • get() returns the pointer and keeps ownership. release() returns it and gives up ownership: the caller must delete it, and ignoring the result is a leak (it is [[nodiscard]] in recent libraries). reset(p) stores p and then deletes the old pointer, so reset is safe even if the old object's destructor somehow reaches the unique_ptr.
  • std::make_unique<T>(args...) (C++14) avoids writing new, and before C++17 it also avoided a leak in expressions such as f(std::unique_ptr<T>(new T), g()). C++20 adds std::make_unique_for_overwrite, which default-initialises (no zeroing) for large buffers you are about to fill.
  • unique_ptr<T[]> calls delete[], offers operator[] and no * or ->. Prefer std::vector unless you need exactly a fixed-size heap buffer without size and capacity members.
  • A unique_ptr<Derived> converts to unique_ptr<Base>. The deletion then goes through Base*, so Base needs a virtual destructor (Ch 2).
  • A unique_ptr converts to a shared_ptr by move: std::shared_ptr<T> s = std::move(u);. That is why factories should return unique_ptr: the caller can still choose to share.

Size and the deleter

The deleter is part of the type and is stored inside the unique_ptr. Implementations use the empty base optimisation (or [[no_unique_address]]) so a stateless deleter takes no space. A function pointer deleter costs a pointer, and a std::function deleter costs much more. Custom deleters in depth are Ch 12.

unique_ptr<T> (default_delete, empty) T* ptr 8 bytes: deleter folded away by EBO T object (heap) unique_ptr<T, void(*)(T*)> T* ptr void(*del)(T*) 16 bytes, plus an indirect call on destruction
The deleter lives in the unique_ptr. An empty function object or captureless lambda costs nothing, so sizeof(std::unique_ptr<T>) == sizeof(T*). Any state in the deleter makes every unique_ptr of that type bigger.
#include <cstdio>
#include <memory>

static void free_int(int* p) { delete p; }

int main() {
    auto lambda = [](int* p) { delete p; };
    std::printf("unique_ptr<int>:               %zu\n", sizeof(std::unique_ptr<int>));
    std::printf("unique_ptr<int, lambda>:       %zu\n", sizeof(std::unique_ptr<int, decltype(lambda)>));
    std::printf("unique_ptr<int, void(*)(int*)>: %zu\n", sizeof(std::unique_ptr<int, void (*)(int*)>));
    std::printf("shared_ptr<int>:               %zu\n", sizeof(std::shared_ptr<int>));
    std::printf("weak_ptr<int>:                 %zu\n", sizeof(std::weak_ptr<int>));
    std::unique_ptr<int, void (*)(int*)> p(new int(1), free_int);
}
unique_ptr<int>:               8
unique_ptr<int, lambda>:       8
unique_ptr<int, void(*)(int*)>: 16
shared_ptr<int>:               16
weak_ptr<int>:                 16

unique_ptr and incomplete types (pimpl)

std::default_delete<T> requires T to be complete where the deleter is instantiated, which is wherever the unique_ptr's destructor is instantiated. In the pimpl idiom, if the outer class has an implicitly defined destructor, it is generated inline in the header where Impl is incomplete, and compilation fails with "invalid application of sizeof to an incomplete type". The fix: declare ~Widget(); in the header and define Widget::~Widget() = default; in the .cpp file after Impl is defined (the same applies to the move operations). shared_ptr does not have this problem because it captures the deleter when the pointer is first stored.

Passing smart pointers to functions

Theory

Mental model. The parameter type is a statement about ownership. Pass a smart pointer only when the function does something to the ownership; if it only uses the object, pass a reference or raw pointer.

ParameterMeansCaller writes
Widget& / const Widget&Uses the object, must not be null, ownership irrelevantf(*up)
Widget*Uses the object, may be null, does not ownf(up.get())
std::unique_ptr<Widget> by valueSink: takes ownershipf(std::move(up))
std::unique_ptr<Widget>&May reseat or reset the caller's pointerf(up)
const std::unique_ptr<Widget>&Nothing useful; use Widget* or Widget&Avoid
std::shared_ptr<Widget> by valueShares ownership (stores a copy)f(sp) or f(std::move(sp))
const std::shared_ptr<Widget>&Might share (copies only on some path)f(sp)
void render(const Widget& w);                       // just uses it
void attach(std::unique_ptr<Widget> w);             // sink: now owns it
void replace(std::unique_ptr<Widget>& slot);        // may reset or reseat
void subscribe(std::shared_ptr<Listener> l) {       // stores a copy
    listeners_.push_back(std::move(l));             // move the parameter into place
}

Passing shared_ptr by value to a function that only reads the object costs two atomic operations (increment and decrement), which matters on hot paths and in contention (see the next section). Returning: return unique_ptr by value from factories; return p; moves implicitly from a local, so return std::move(p); is unnecessary.

shared_ptr and the control block

Theory

Mental model. A shared_ptr<T> is two pointers: one to the object (what get() returns) and one to a control block shared by every shared_ptr and weak_ptr that manages the same object. The control block holds the strong count (number of shared_ptrs), the weak count, the deleter and the allocator. When the strong count reaches zero the object is destroyed; when the weak count also reaches zero the control block is freed.

std::shared_ptr<T>(new T): two allocations T* ptr cb* shared_ptr, 16 B T object vptr (deleter dispatch) strong = 2 weak = 1+1 T* (to delete), deleter, allocator deletes std::make_shared<T>(): one allocation T* ptr cb* vptr strong weak T object stored inline the whole block is freed only when the weak count reaches 0
Two layouts for one interface. Implementations count "weak" as the number of weak_ptrs plus one for the group of strong owners, so the control block is freed by whoever drops the last reference of either kind. With make_shared the object's bytes are part of the control block's allocation, so a surviving weak_ptr keeps all of them allocated (the destructor has still run).

Strong and weak counts

  • Copying a shared_ptr increments the strong count; destroying or resetting one decrements it. Moving transfers ownership without touching the count, which is why std::move into a member or container is cheaper than copying.
  • When the strong count drops to zero, the deleter destroys the object. Then the "strong owners" share of the weak count is dropped; if no weak_ptr remains, the control block is deallocated.
  • use_count() returns the strong count. In multithreaded code it is a snapshot that can be stale before you read it, so never use it for synchronisation. unique() was deprecated in C++17 and removed in C++20 for that reason.
  • The deleter is type-erased: shared_ptr<Widget> has the same type whatever deleter or allocator it uses, unlike unique_ptr. A consequence many people do not know: std::shared_ptr<Base> p(new Derived); deletes through Derived*, so ~Derived runs even without a virtual destructor. The same is true for shared_ptr<void>.

The cost of atomic reference counting

The counts must be updated atomically because copies of the same shared_ptr may be created and destroyed in different threads. On x86-64 each increment is a lock xadd or lock inc (roughly 5 to 20 ns uncontended, much more under contention), and each decrement also needs acquire-release ordering so the thread that destroys the object sees all writes made through other copies. When many threads copy the same shared_ptr, the control block's cache line bounces between cores, which is a scalability bottleneck that does not show in single-threaded benchmarks. Mitigations: pass by reference, move instead of copy, keep one shared_ptr per thread and hand out references, or use unique_ptr or intrusive counting where ownership allows. (libstdc++ skips the atomic instructions while the process is single-threaded: on glibc 2.32 and later it checks __libc_single_threaded at run time, so a program pays for atomics only once it has started a second thread. libc++ always uses atomics. Do not let a single-threaded benchmark convince you copies are free.)

make_shared: one allocation, and its trade-off

Theory

std::make_shared<T>(args...) allocates the control block and the object together. Advantages: one allocation instead of two (faster, less overhead, better locality because the counts and the object share cache lines), no visible new, and (before C++17) protection against the interleaving leak in f(std::shared_ptr<T>(new T), g()).

The trade-off is memory lifetime. Because the object lives inside the control block's allocation, its memory cannot be freed until the weak count reaches zero. The destructor runs on time, but the bytes stay allocated as long as any weak_ptr exists. For a large object observed by long-lived weak_ptrs (a cache), shared_ptr<T>(new T) frees the object's memory sooner. The program below shows the difference by tracing operator new and operator delete.

#include <cstdio>
#include <cstdlib>
#include <memory>
#include <new>

static bool trace = false;

void* operator new(std::size_t n) {
    if (trace) std::printf("    allocate %s\n", n > 200 ? "large block" : "small block");
    if (void* p = std::malloc(n)) return p;
    throw std::bad_alloc{};
}
void operator delete(void* p) noexcept {
    if (trace && p) std::puts("    deallocate");
    std::free(p);
}
void operator delete(void* p, std::size_t) noexcept { ::operator delete(p); }

struct Big {
    char data[256];
    ~Big() { std::puts("    ~Big"); }
};

static void* volatile sink;

int main() {
    trace = true;
    std::puts("make_shared:");
    {
        auto sp = std::make_shared<Big>();
        sink = sp.get();
        std::weak_ptr<Big> wp = sp;
        std::puts("  reset shared_ptr");
        sp.reset();
        std::puts("  reset weak_ptr");
        wp.reset();
    }
    std::puts("shared_ptr(new Big):");
    {
        std::shared_ptr<Big> sp(new Big);
        sink = sp.get();
        std::weak_ptr<Big> wp = sp;
        std::puts("  reset shared_ptr");
        sp.reset();
        std::puts("  reset weak_ptr");
        wp.reset();
    }
    trace = false;
}
make_shared:
    allocate large block
  reset shared_ptr
    ~Big
  reset weak_ptr
    deallocate
shared_ptr(new Big):
    allocate large block
    allocate small block
  reset shared_ptr
    ~Big
    deallocate
  reset weak_ptr
    deallocate

When make_shared is not an option

  • Custom deleter: make_shared has no deleter parameter; it always destroys the object in place and frees the combined block itself. Use the shared_ptr(p, deleter) constructor (Ch 12).
  • Private or protected constructors: make_shared constructs from inside the library, so it cannot call them. Use a passkey type or std::shared_ptr<T>(new T(...)) from a static factory.
  • Class-specific operator new: make_shared bypasses it (it allocates the combined block with std::allocator). Use std::allocate_shared with a custom allocator instead.
  • Large objects with long-lived weak_ptrs: the memory trade-off above.
  • Adopting an existing pointer (from a C API or a unique_ptr): there is nothing to make.

C++20 added std::make_shared<T[]>(n) and make_shared_for_overwrite; shared_ptr<T[]> itself works since C++17.

The aliasing constructor

Theory

Mental model. The two pointers inside a shared_ptr do not have to agree. std::shared_ptr<U> alias(owner, ptr) shares owner's control block (so it keeps the owner alive) but get() returns ptr, usually a member or subobject of the owned object.

Order* ptr cb* order string* ptr cb* venue (aliasing) control blockstrong = 2 Order: id Order: venue (std::string)
One owner, two views. The aliasing shared_ptr hands out a std::string* but its count keeps the whole Order alive. The same mechanism powers std::static_pointer_cast and friends.
#include <cstdio>
#include <memory>
#include <string>

struct Order {
    int id;
    std::string venue;
    Order(int i, std::string v) : id(i), venue(std::move(v)) {}
    ~Order() { std::puts("~Order"); }
};

int main() {
    auto order = std::make_shared<Order>(7, "XNAS");
    std::shared_ptr<std::string> venue(order, &order->venue);   // aliasing constructor
    std::printf("use_count = %ld\n", order.use_count());
    order.reset();
    std::printf("venue = %s, use_count = %ld\n", venue->c_str(), venue.use_count());
    venue.reset();
    std::puts("end");
}
use_count = 2
venue = XNAS, use_count = 1
~Order
end

Uses: handing out a pointer to a member or an array element while guaranteeing the container stays alive, implementing std::static_pointer_cast/dynamic_pointer_cast (same control block, converted pointer), and node-based structures where many views share one allocation. Pitfall: std::shared_ptr<T>(std::shared_ptr<void>{}, p) creates a non-null pointer with no owner (use_count() == 0), which nothing will ever delete; that is occasionally used on purpose for objects with static lifetime.

enable_shared_from_this and double ownership

Theory

Two shared_ptrs from one raw pointer

Every shared_ptr constructor that takes a raw pointer creates a new control block. So this code creates two independent owners of the same object, each with a count of one, and the object is deleted twice:

Widget* raw = new Widget;
std::shared_ptr<Widget> a(raw);
std::shared_ptr<Widget> b(raw);     // second control block: double delete later
std::shared_ptr<Widget> c(this);    // inside a member function: same bug

The rule: a raw pointer becomes a shared_ptr exactly once, ideally at the new (or with make_shared, so no raw pointer exists). Afterwards, copy the shared_ptr.

enable_shared_from_this

Sometimes a member function needs a shared_ptr to its own object, for example to register itself as a callback that may outlive the call (async I/O sessions in Asio are the classic case). Inherit publicly from std::enable_shared_from_this<T>. It contains a hidden weak_ptr<T> that the shared_ptr constructor (and make_shared) fills in when it first takes ownership. shared_from_this() then locks that weak_ptr, producing a shared_ptr that shares the existing control block.

#include <cstdio>
#include <functional>
#include <memory>
#include <vector>

class Session : public std::enable_shared_from_this<Session> {
public:
    void start(std::vector<std::function<void()>>& loop) {
        auto self = shared_from_this();              // keeps us alive in the callback
        loop.push_back([self] { self->on_read(); });
    }
    void on_read() { std::puts("on_read"); }
    ~Session() { std::puts("~Session"); }
};

int main() {
    std::vector<std::function<void()>> loop;
    {
        auto s = std::make_shared<Session>();
        s->start(loop);
        std::printf("use_count = %ld\n", s.use_count());
    }                                                // s gone, callback still owns it
    std::puts("running callbacks");
    for (auto& cb : loop) cb();
    loop.clear();
    std::puts("end");
}
use_count = 2
running callbacks
on_read
~Session
end

Interview trap

shared_from_this() works only after a shared_ptr owns the object. Calling it on a stack object, on an object owned by unique_ptr, or inside the constructor (the owning shared_ptr does not exist yet) throws std::bad_weak_ptr since C++17; before C++17 it was undefined behaviour. The usual fix is a static factory: static std::shared_ptr<Session> create() that calls make_shared and then does the registration. Also: inherit publicly, or the shared_ptr constructor cannot see the base and silently skips the setup. C++17's weak_from_this() returns an empty weak_ptr instead of throwing.

Thread safety: the control block vs the pointer vs the pointee

Theory

Mental model. There are three separate things, with three separate answers:

  1. The control block is thread-safe. Different shared_ptr objects that share it can be copied, assigned and destroyed concurrently in different threads; the counts are atomic and the object is destroyed exactly once.
  2. One shared_ptr object is not thread-safe, like any other standard library object: concurrent reads (copying from it, get()) are fine, but a write (assignment, reset) concurrent with any other access is a data race. Copying is a two-step operation (read the pointers, increment the count), and an assignment in between can free the control block. C++20 offers std::atomic<std::shared_ptr<T>> for this case (libstdc++ since GCC 12 and MSVC have it; libc++ still does not, as of LLVM 22, where std::atomic<std::shared_ptr<int>> fails with "requires that T be a trivially copyable type"); otherwise protect the shared variable with a mutex. The C++11 free functions such as std::atomic_load(&sp) are deprecated in C++20 and removed in C++26 (P2869). Note that libstdc++'s atomic<shared_ptr> uses an internal lock bit, so is_lock_free() is false: it is correct, not necessarily faster than a mutex.
  3. The pointee has whatever thread safety its own type has. shared_ptr<std::vector<int>> does nothing to make push_back safe.

The correct pattern for a configuration object that a writer replaces while readers use it: readers take a copy of the current shared_ptr under a short lock (or an atomic load), then use the immutable snapshot without any lock; the writer builds a new object and swaps the pointer.

#include <cstdio>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>

struct Config { int limit; };

class ConfigHolder {
public:
    std::shared_ptr<const Config> get() const {
        std::lock_guard<std::mutex> lock(mu_);
        return current_;                      // copy under the lock: count +1
    }
    void set(std::shared_ptr<const Config> c) {
        std::lock_guard<std::mutex> lock(mu_);
        current_.swap(c);                     // old config released after unlock, by c
    }
private:
    mutable std::mutex mu_;
    std::shared_ptr<const Config> current_ = std::make_shared<const Config>(Config{0});
};

int main() {
    ConfigHolder holder;
    std::thread writer([&] {
        for (int i = 1; i <= 1000; ++i) holder.set(std::make_shared<const Config>(Config{i}));
    });
    std::vector<std::thread> readers;
    long seen_max[3] = {};
    for (int t = 0; t < 3; ++t) {
        readers.emplace_back([&, t] {
            for (int i = 0; i < 1000; ++i) {
                std::shared_ptr<const Config> snap = holder.get();   // no lock while reading
                if (snap->limit > seen_max[t]) seen_max[t] = snap->limit;
            }
        });
    }
    writer.join();
    for (auto& r : readers) r.join();
    std::printf("final limit = %d\n", holder.get()->limit);
}
final limit = 1000

weak_ptr: observers and caches

Theory

Mental model. A weak_ptr holds a reference to the control block, not to the object. It does not keep the object alive and you cannot dereference it. To use the object, call lock(), which atomically checks the strong count and, if it is non-zero, increments it and returns a shared_ptr; otherwise it returns an empty one.

std::weak_ptr<Texture> cached = texture;          // does not extend the lifetime
if (std::shared_ptr<Texture> t = cached.lock()) { // atomic check and acquire
    draw(*t);                                     // safe: t keeps it alive
} else {
    reload();                                     // the object is gone
}
  • expired() is true when the strong count is zero. In multithreaded code if (!w.expired()) use(w.lock()) is a race: the object can die between the two calls. Always use the result of lock().
  • std::shared_ptr<T>(weak) (the constructor) throws std::bad_weak_ptr if expired, unlike lock().
  • weak_ptr has no operator<, == or std::hash. To key a std::map by it, use std::owner_less, which compares control blocks (owner_before) and so stays valid after the object dies. C++26 adds std::owner_hash and std::owner_equal for unordered containers.
  • Typical uses: caches that hand out shared objects but do not keep them alive (the coding drills build one), observer lists where subjects must not keep observers alive, and back pointers (child to parent) that break ownership cycles (Ch 13).

auto_ptr, deleters and cycles

Theory

Why auto_ptr was removed

C++98's std::auto_ptr tried to express unique ownership before the language had move semantics. Its "copy" constructor and copy assignment silently transferred ownership and set the source to null. So auto_ptr<T> b = a; left a null, passing one by value to a function destroyed the caller's object at the end of the call, and it could not be safely stored in standard containers or used with algorithms that copy elements (sort can keep a copy as a pivot). It also called delete, never delete[]. C++11 introduced unique_ptr, which makes the transfer explicit with std::move; auto_ptr was deprecated in C++11 and removed in C++17.

Custom deleters and cycles

Both smart pointers accept a custom deleter, which turns them into RAII wrappers for any resource: FILE* with fclose, sockets, memory from malloc or a C library. For unique_ptr the deleter is part of the type and its size; for shared_ptr it is type-erased into the control block. The details and size trade-offs are Ch 12. Reference counting cannot reclaim cycles: two objects that hold shared_ptrs to each other keep both counts at one forever. Breaking cycles with weak_ptr and parent pointers is Ch 13.

Key facts

unique_ptrshared_ptrweak_ptr
Size (64-bit, default deleter)8 bytes16 bytes + control block16 bytes
CopyableNo (move only)Yes (atomic increment)Yes (atomic weak increment)
DeleterPart of the type, stored inlineType-erased in the control blockn/a
Arraysunique_ptr<T[]>shared_ptr<T[]> (C++17)n/a
Factorymake_unique (C++14)make_shared, allocate_sharedfrom a shared_ptr
Incomplete T at destructionError with default deleterFine (deleter captured earlier)Fine
Thread safetyLike a raw pointerControl block safe; one instance not; pointee notlock() is atomic
HistoryC++11 (replaces auto_ptr, removed in C++17)C++11 (from Boost and TR1)C++11

Concept and tricky questions

Drills

ConceptWhen do you use unique_ptr, shared_ptr, weak_ptr and a raw pointer or reference?

Answer

Answer. unique_ptr whenever one owner can be named (the default for heap objects). shared_ptr when the lifetime really is shared by parties that end independently (async callbacks, caches handing out objects). weak_ptr to observe a shared object without keeping it alive. A raw pointer or reference for non-owning access, especially function parameters.

Why. The type documents ownership and the compiler enforces it. Overusing shared_ptr hides who is responsible for lifetime, costs atomic operations on every copy and invites cycles. "Never use raw pointers" is the over-correction interviewers push back on: raw pointers are fine when they do not own.

ConceptWhat is the difference between get(), release() and reset() on a unique_ptr?

Answer

Answer. get() returns the pointer and keeps ownership. release() returns the pointer, sets the unique_ptr to null and gives up ownership without deleting. reset(p) takes ownership of p (or null) and deletes the previously owned object.

Why. Confusing release with reset is a classic leak: p.release(); with the result discarded leaks the object. reset assigns the new pointer before deleting the old one, so a destructor that reaches back into the owner sees a consistent state.

ConceptHow should a function take a smart pointer if it (a) only reads the object, (b) takes ownership, (c) may replace the caller's object, (d) stores a shared copy?

Answer

Answer. (a) const T& or const T*. (b) std::unique_ptr<T> by value, called with std::move. (c) std::unique_ptr<T>&. (d) std::shared_ptr<T> by value, then move it into the member.

Why. These are the C++ Core Guidelines R.30 to R.37. Taking a smart pointer for (a) restricts callers to one ownership model for no reason and, for shared_ptr by value, costs two atomic operations. const unique_ptr& is almost always a mistake: it cannot transfer or reset, so it just means T* with extra indirection.

ConceptGive the advantages of make_shared over std::shared_ptr<T>(new T), and the cases where you cannot or should not use it.

Answer

Answer. Advantages: one allocation instead of two, better cache locality, less allocator overhead, no naked new, and exception safety in pre-C++17 argument lists. Cannot: custom deleter, private constructor, or adopting an existing pointer. Should not: large object observed by long-lived weak_ptrs (its memory stays allocated until the last weak_ptr goes), or a class with its own operator new that you want honoured (make_shared bypasses it; use allocate_shared).

Why. The combined block is the source of both the speed and the memory trade-off: the object's storage is inside the control block, which lives until the weak count is zero.

ConceptWhat exactly does a shared_ptr control block contain?

Answer

Answer. The strong (use) count, the weak count, the deleter, the allocator and either a pointer to the managed object (separate allocation) or the object itself (make_shared). Implementations use a vtable (libstdc++ _Sp_counted_base, libc++ __shared_weak_count) to dispatch to the right deleter and deallocation code, which is how the deleter type is erased.

Why. The pointer stored in the control block can differ from the one in the shared_ptr (aliasing, base class conversions), and the control block's copy is the one that gets deleted. That is why shared_ptr<Base>(new Derived) deletes as Derived. Typical size: 16 bytes of header (vptr plus two 32-bit counts on libstdc++) plus the stored pointer and deleter.

ConceptWhy was std::auto_ptr removed, and what replaced it?

Answer

Answer. Its copy operations transferred ownership and nulled the source, so ordinary-looking copies (passing by value, storing in a container, an algorithm keeping a temporary) silently destroyed or stole objects. unique_ptr replaced it in C++11 with explicit, move-only transfer; auto_ptr was deprecated in C++11 and removed in C++17.

Why. Copy that mutates the source breaks the requirements of containers and algorithms (CopyConstructible means the source is unchanged). Without rvalue references C++98 had no other way to spell "transfer". auto_ptr also had no array form and no custom deleter.

TrickyWhat is sizeof of a unique_ptr<int> with (a) the default deleter, (b) a captureless lambda, (c) a function pointer, (d) a lambda that captures a pointer?

Answer

Answer. On 64-bit: (a) 8, (b) 8, (c) 16, (d) 16.

Why. The deleter is stored in the unique_ptr, but empty classes take no space thanks to the empty base optimisation (libstdc++ uses a tuple with EBO, libc++ a compressed pair). A captureless lambda is an empty class. A function pointer is 8 bytes of state, and a capture is too. People often answer "always 8" or "16 because it stores a deleter". shared_ptr is 16 regardless of deleter, because the deleter lives in the control block.

Tricky"Is shared_ptr thread-safe?" Give the precise answer.

Answer

Answer. The reference counting is: distinct shared_ptr objects sharing one control block may be copied and destroyed concurrently. A single shared_ptr object is not: writing it (assign, reset) while another thread reads or writes it is a data race. The pointee is not protected at all.

Why. The standard gives shared_ptr the same guarantee as other library types ([res.on.data.races]) and additionally makes the count updates atomic. For a shared variable that is swapped while read, use a mutex or C++20 std::atomic<std::shared_ptr<T>>. "Yes" and "no" are both wrong; interviewers want the three-part answer.

TrickyA class derived from enable_shared_from_this calls shared_from_this() in its constructor to register itself. What happens?

Answer

Answer. It throws std::bad_weak_ptr (C++17 and later; undefined behaviour before). During construction no shared_ptr owns the object yet, so the internal weak_ptr is still empty.

Why. The hidden weak_ptr is assigned by the shared_ptr constructor (or make_shared) after the object is fully constructed. Fix: a static create() factory that calls make_shared and then registers, with the constructor made private (and a passkey so make_shared can still call it). The same exception occurs for stack objects and objects owned by unique_ptr.

TrickyWhat is wrong with if (!weak.expired()) weak.lock()->update();?

Answer

Answer. It is a check-then-act race. Another thread can drop the last shared_ptr between expired() and lock(), so lock() returns null and ->update() dereferences it. Write if (auto sp = weak.lock()) sp->update();.

Why. lock() is the only atomic "check and acquire" operation: it increments the strong count only if it is non-zero (a compare-exchange loop). expired() is useful only as a hint, for example when purging dead cache entries. Even single-threaded, the version with lock() is shorter.

TrickyA pimpl class has std::unique_ptr<Impl> impl_; with Impl only forward-declared in the header, and no user-declared destructor. Why does code that destroys a Widget fail to compile, and why would shared_ptr<Impl> compile?

Answer

Answer. The implicit ~Widget() is defined inline wherever it is used, which instantiates std::default_delete<Impl>::operator() while Impl is incomplete; the library's static_assert (or sizeof) rejects it. Fix: declare ~Widget(); in the header and define it as = default in the .cpp after Impl's definition (also the move operations). shared_ptr compiles because its deleter is created where the pointer is first stored (in the .cpp, where Impl is complete) and called through the control block.

Why. Deleting a pointer to an incomplete class type is undefined behaviour if the class has a non-trivial destructor, so default_delete forbids it at compile time. This difference between the two pointers is a favourite follow-up question.

TrickyBefore C++17, could process(std::shared_ptr<Widget>(new Widget), priority()); leak? Can it in C++17? Is make_shared still worth using?

Answer

Answer. Before C++17, yes: the compiler could evaluate new Widget, then priority(), then the shared_ptr constructor. If priority() threw, the Widget leaked. In C++17 (P0145) each function argument is evaluated completely before another starts (they are indeterminately sequenced, not interleaved), so that leak is gone. make_shared is still worth it for the single allocation and for never writing new.

Why. Many candidates still give the pre-C++17 reasoning as the main reason for make_shared. The argument order remains unspecified in C++17; only interleaving was removed.

Trickyauto order = std::make_shared<Order>(); std::shared_ptr<std::string> v(order, &order->venue); order.reset(); Is v dangling? And what does std::shared_ptr<int>(std::shared_ptr<int>{}, &global) give you?

Answer

Answer. v is not dangling: the aliasing constructor shares order's control block, so v is now the only strong owner (use_count() == 1) and the whole Order stays alive until v goes. The second expression gives a non-null shared_ptr that owns nothing: get() returns &global, use_count() is 0 and nothing is ever deleted.

Why. A shared_ptr carries two independent pointers: the stored pointer (what get() and * use) and the control block (what decides lifetime). The aliasing constructor copies the second and sets the first. The usual wrong answers are "v points into a destroyed object" for the first part and "an empty owner means a null pointer" for the second; if (p) tests the stored pointer, not ownership, so an ownerless aliasing pointer tests true. C++20 adds a move overload, shared_ptr(shared_ptr<Y>&&, T*), which steals the owner instead of incrementing the count.

TrickyA function returns a local std::unique_ptr<T> p with return p;. Why does that compile when unique_ptr cannot be copied, and should you write return std::move(p);?

Answer

Answer. A returned local variable is treated as an rvalue first (implicit move, [class.copy.elision]), so the move constructor is chosen, or the copy is elided entirely by NRVO. Do not add std::move: it makes the expression a different kind and disables NRVO, and GCC and Clang warn about it (-Wpessimizing-move, -Wredundant-move).

Why. C++11 already moved from returned locals; C++20 (P1825) and C++23 (P2266) extended the implicit move to more cases, such as returning an rvalue reference parameter. Returning a unique_ptr<Derived> local from a function returning unique_ptr<Base> also moves implicitly since C++14's CWG 1579. NRVO details are Ch 24.

Predict the output and find the bug

Drills

OutputTrack the reference count.

#include <cstdio>
#include <memory>
#include <utility>

int main() {
    auto a = std::make_shared<int>(5);
    std::printf("%ld ", a.use_count());
    auto b = a;
    std::printf("%ld ", a.use_count());
    auto c = std::move(b);
    std::printf("%ld %ld ", a.use_count(), b.use_count());
    std::weak_ptr<int> w = a;
    std::printf("%ld ", a.use_count());
    {
        auto d = w.lock();
        std::printf("%ld ", a.use_count());
    }
    a.reset();
    std::printf("%ld %d ", c.use_count(), w.expired());
    c.reset();
    std::printf("%ld %d\n", w.use_count(), w.expired());
}
Answer
1 2 2 0 2 3 1 0 0 1

Answer. 1 after creation; 2 after copying into b; moving b into c leaves the count at 2 and b empty (0); a weak_ptr does not change it (2); lock() adds a strong owner (3); after d dies and a resets, c is the only owner (1) and w is not expired; after c resets the count is 0 and w is expired.

Why. use_count() counts strong owners only; an empty shared_ptr reports 0. Moves transfer ownership without touching the count, which is why they are cheaper than copies.

Find the bugTwo owners are built from the same raw pointer.

#include <cstdio>
#include <memory>

struct Account {
    long balance = 100;
    ~Account() { std::puts("~Account"); }
};

int main() {
    Account* raw = new Account;
    std::shared_ptr<Account> owner(raw);
    std::shared_ptr<Account> audit(raw);
    std::printf("%ld %ld\n", owner.use_count(), audit.use_count());
}
Answer

Answer. Each raw-pointer constructor creates its own control block, so both report use_count() == 1 and each deletes the object: a double free at the end of main. Fix: auto owner = std::make_shared<Account>(); auto audit = owner;.

Why. A shared_ptr cannot find an existing control block from a raw pointer (only enable_shared_from_this provides a way back). ASan reports "attempting double-free"; without it glibc may abort with "free(): double free detected in tcache 2" after printing ~Account twice. The output is not compared because the behaviour is undefined.

Outputshared_from_this() on objects with different owners. What is printed?

#include <cstdio>
#include <memory>

struct Node : std::enable_shared_from_this<Node> {
    long owners() { return shared_from_this().use_count() - 1; }
};

int main() {
    auto shared = std::make_shared<Node>();
    std::printf("shared: %ld\n", shared->owners());

    auto unique = std::make_unique<Node>();
    try {
        unique->owners();
    } catch (const std::bad_weak_ptr&) {
        std::puts("unique: bad_weak_ptr");
    }

    Node local;
    std::printf("local weak_from_this expired: %d\n", local.weak_from_this().expired());
}
Answer
shared: 1
unique: bad_weak_ptr
local weak_from_this expired: 1

Answer. The make_shared object works (one owner besides the temporary). The unique_ptr-owned object throws std::bad_weak_ptr, and the stack object's weak_from_this() is empty.

Why. Only a shared_ptr constructor fills the internal weak_ptr. Since C++17 shared_from_this() on an unowned object is specified to throw (it was UB in C++11 and C++14); weak_from_this() (C++17) is the non-throwing way to ask.

Find the bugAn array is put in a unique_ptr.

#include <cstdio>
#include <memory>
#include <string>

struct Level {
    std::string name = "level";
};

int main() {
    std::unique_ptr<Level> levels(new Level[4]);
    std::printf("%s\n", levels->name.c_str());
}
Answer

Answer. unique_ptr<Level> uses default_delete<Level>, which calls delete, but the memory came from new[]. Use std::unique_ptr<Level[]> (or std::make_unique<Level[]>(4)), or simply std::vector<Level>.

Why. Level has a non-trivial destructor, so new Level[4] stores an array cookie before the first element (Ch 9). Plain delete destroys one element and frees an address malloc never returned; ASan reports "attempting free on address which was not malloc()-ed". Nothing in the type system catches it because new Level[4] has type Level*.

Find the bugA raw pointer from get() is kept for later.

#include <cstdio>
#include <memory>

struct Quote { double bid = 99.5, ask = 100.5; };

struct Book {
    std::unique_ptr<Quote> top = std::make_unique<Quote>();
    void refresh() { top = std::make_unique<Quote>(); }
};

int main() {
    Book book;
    const Quote* q = book.top.get();
    book.refresh();
    std::printf("%.1f\n", q->bid);
}
Answer

Answer. refresh() assigns a new unique_ptr, which deletes the old Quote; q now dangles and q->bid reads freed memory. Re-read book.top.get() after any operation that can reseat it, or do not cache the pointer.

Why. get() returns a non-owning pointer whose validity is tied to the owner's next modification. It is the smart-pointer version of iterator invalidation. ASan reports heap-use-after-free; without it the program likely prints 99.5 because the freed memory still holds the old bytes.

OutputThe object is held only through shared_ptr<Base>, and Base has no virtual destructor. Which destructors run?

#include <cstdio>
#include <memory>

struct Base { ~Base() { std::puts("~Base"); } };
struct Derived : Base { ~Derived() { std::puts("~Derived"); } };

int main() {
    {
        std::shared_ptr<Base> p(new Derived);
    }
    std::puts("--");
    {
        std::shared_ptr<void> v = std::make_shared<Derived>();
    }
}
Answer
~Derived
~Base
--
~Derived
~Base

Answer. Both run in both cases.

Why. The shared_ptr constructor is a template that remembers the pointer's original type (Derived*) in the control block and deletes through it, whatever the shared_ptr's own element type. std::unique_ptr<Base>(new Derived) would delete through Base*, which is undefined behaviour without a virtual destructor. Do not rely on this to skip the virtual destructor: as soon as the object is created as a Base* first, or owned elsewhere, the protection is gone.

Find the bugThis counts live Order objects. Why does it report one at the end?

#include <cstdio>
#include <memory>

struct Order {
    static inline int live = 0;
    Order() { ++live; }
    ~Order() { --live; }
};

static void archive(Order* o) { (void)o; /* writes it somewhere, does not delete */ }

int main() {
    {
        auto o = std::make_unique<Order>();
        archive(o.release());
        auto p = std::make_unique<Order>();
        p.reset();
    }
    std::printf("live orders: %d\n", Order::live);
}
Answer
live orders: 1

Answer. release() gives up ownership, and archive does not take it, so the first Order is never deleted. Pass o.get() (or *o) to a function that only uses the object; use release() only to hand ownership to something that will delete it, ideally a function taking std::unique_ptr<Order> by value.

Why. release and reset sound alike but are opposites: reset() deletes, release() does not. LeakSanitizer would report the leak at exit (it is disabled in this chapter's test harness, which is why the counter is used).

Find the bugWhy does this not compile, and what are the two correct fixes?

#include <memory>
#include <vector>

struct Task { int id; };

int main() {
    std::vector<std::unique_ptr<Task>> queue;
    auto t = std::make_unique<Task>(Task{1});
    queue.push_back(t);
}
Answer

Answer. push_back(t) tries to copy a unique_ptr, whose copy constructor is deleted. Either move it, queue.push_back(std::move(t)); (and stop using t), or construct it in place, queue.push_back(std::make_unique<Task>(Task{1}));.

Why. Move-only types work in every standard container as long as you move them in. The compile error is the point of unique_ptr: with auto_ptr this line would have compiled and silently nulled t. Copying a vector of unique_ptr also fails to compile, for the same reason.

Find the bugA writer replaces a global configuration while a reader copies it. The counts are atomic, so what is wrong?

#include <cstdio>
#include <memory>
#include <thread>

static std::shared_ptr<int> config = std::make_shared<int>(0);

int main() {
    std::thread writer([] {
        for (int i = 1; i <= 1000; ++i) config = std::make_shared<int>(i);
    });
    std::thread reader([] {
        long sum = 0;
        for (int i = 0; i < 1000; ++i) {
            std::shared_ptr<int> local = config;
            sum += *local;
        }
        std::printf("%d\n", sum >= 0);
    });
    writer.join();
    reader.join();
}
Answer

Answer. Both threads access the same shared_ptr object config, and one of them writes it. Copying reads the two internal pointers and then increments the count; the assignment can replace the pointers and drop the old control block's last reference in between, so the reader increments a freed count. Fix: guard config with a mutex (as in the snapshot example) or use std::atomic<std::shared_ptr<int>> (C++20).

Why. Atomic reference counts make different shared_ptr objects safe to use concurrently, not one object. ThreadSanitizer reports a data race on config. Output is not compared because the program has undefined behaviour.

OutputA sink function takes the unique_ptr by value. In what order do the lines appear?

#include <cstdio>
#include <memory>
#include <utility>

struct Job {
    ~Job() { std::puts("~Job"); }
};

static void consume(std::unique_ptr<Job> j) {
    std::puts("consume: start");
    (void)j;
    std::puts("consume: end");
}

static void inspect(const Job&) { std::puts("inspect"); }

int main() {
    auto job = std::make_unique<Job>();
    inspect(*job);
    consume(std::move(job));
    std::printf("after consume, job is null: %d\n", job == nullptr);
}
Answer
inspect
consume: start
consume: end
~Job
after consume, job is null: 1

Answer. The Job is destroyed together with the parameter j, after consume's body has finished and before the next statement in main runs; job is null afterwards.

Why. Taking unique_ptr by value makes the function the owner, so the object dies with the parameter. Exactly when a parameter is destroyed is implementation-defined ([expr.call]): at the end of the function or at the end of the caller's full-expression. GCC and Clang follow the Itanium ABI, where the caller destroys a parameter with a non-trivial destructor at the end of the full-expression consume(std::move(job));; MSVC destroys it in the callee. Both put ~Job before the next line, so the output is the same everywhere. A common wrong answer is that ~Job prints at the end of main, as if job still owned it. inspect takes a reference because it only uses the object.

Write the code

Coding

CodeImplement my::unique_ptr<T, D>: default and nullptr construction, construction from a pointer (and deleter), move construction and assignment (including from unique_ptr<U, E> when U* converts to T*), deleted copies, get, release, reset, operator*, operator->, explicit operator bool, get_deleter, and a make_unique. A stateless deleter must add no size.

Answer

Approach. Store the pointer and the deleter, marking the deleter [[no_unique_address]] so empty deleters take no space. Every ownership transfer goes through release() and reset(), which keeps the logic in two places: release exchanges the pointer with null, reset exchanges in the new pointer and then deletes the old one. That ordering makes self-move-assignment safe.

#include <cstddef>
#include <cstdio>
#include <type_traits>
#include <utility>

namespace my {

template <class T>
struct default_delete {
    default_delete() = default;
    template <class U, class = std::enable_if_t<std::is_convertible_v<U*, T*>>>
    default_delete(const default_delete<U>&) noexcept {}
    void operator()(T* p) const noexcept {
        static_assert(sizeof(T) > 0, "cannot delete an incomplete type");
        delete p;
    }
};

template <class T, class D = default_delete<T>>
class unique_ptr {
public:
    constexpr unique_ptr() noexcept = default;
    constexpr unique_ptr(std::nullptr_t) noexcept {}
    explicit unique_ptr(T* p) noexcept : p_(p) {}
    unique_ptr(T* p, D d) noexcept : p_(p), d_(std::move(d)) {}

    unique_ptr(unique_ptr&& o) noexcept : p_(o.release()), d_(std::move(o.d_)) {}
    template <class U, class E, class = std::enable_if_t<std::is_convertible_v<U*, T*>>>
    unique_ptr(unique_ptr<U, E>&& o) noexcept : p_(o.release()), d_(std::move(o.get_deleter())) {}

    unique_ptr& operator=(unique_ptr&& o) noexcept {
        reset(o.release());
        d_ = std::move(o.d_);
        return *this;
    }
    unique_ptr& operator=(std::nullptr_t) noexcept { reset(); return *this; }

    unique_ptr(const unique_ptr&) = delete;
    unique_ptr& operator=(const unique_ptr&) = delete;
    ~unique_ptr() { if (p_) d_(p_); }

    T* get() const noexcept { return p_; }
    D& get_deleter() noexcept { return d_; }
    T* release() noexcept { return std::exchange(p_, nullptr); }
    void reset(T* p = nullptr) noexcept {
        T* old = std::exchange(p_, p);
        if (old) d_(old);
    }
    T& operator*() const noexcept { return *p_; }
    T* operator->() const noexcept { return p_; }
    explicit operator bool() const noexcept { return p_ != nullptr; }

private:
    T* p_ = nullptr;
    [[no_unique_address]] D d_{};
};

template <class T, class... Args>
unique_ptr<T> make_unique(Args&&... args) {
    return unique_ptr<T>(new T(std::forward<Args>(args)...));
}

}  // namespace my

struct Shape {
    static inline int live = 0;
    Shape() { ++live; }
    virtual ~Shape() { --live; }
    virtual int sides() const { return 0; }
};
struct Square : Shape { int sides() const override { return 4; } };

static int deleted_by_lambda = 0;

int main() {
    static_assert(sizeof(my::unique_ptr<int>) == sizeof(int*));
    static_assert(!std::is_copy_constructible_v<my::unique_ptr<int>>);

    my::unique_ptr<Shape> s = my::make_unique<Square>();          // converting move
    std::printf("sides=%d live=%d\n", s->sides(), Shape::live);

    my::unique_ptr<Shape> t;
    t = std::move(s);
    std::printf("s empty=%d t ok=%d\n", !s, static_cast<bool>(t));
    t = std::move(t);                                              // self-move is safe
    std::printf("after self-move live=%d\n", Shape::live);

    Shape* raw = t.release();
    std::printf("released, live=%d\n", Shape::live);
    t.reset(raw);
    t.reset();
    std::printf("after reset live=%d\n", Shape::live);

    auto del = [](int* p) { ++deleted_by_lambda; delete p; };
    {
        my::unique_ptr<int, decltype(del)> q(new int(3), del);
        std::printf("custom deleter size=%zu value=%d\n", sizeof q, *q);
    }
    std::printf("lambda deleter calls=%d\n", deleted_by_lambda);
}
sides=4 live=1
s empty=1 t ok=1
after self-move live=1
released, live=1
after reset live=0
custom deleter size=8 value=3
lambda deleter calls=1

Complexity and pitfalls. Everything is O(1) and as cheap as a raw pointer. Pitfalls: deleting in reset before storing the new pointer (breaks self-reset and reentrancy), forgetting explicit on operator bool (allows int x = p;), a converting constructor without the convertibility constraint, and forgetting that the converting move needs default_delete<Derived> to convert to default_delete<Base>. The real one also has an array specialisation, comparison operators and swap. Clang and GCC honour [[no_unique_address]]; MSVC needs [[msvc::no_unique_address]].

Estimated rating: ~1600 · move-only RAII with EBO and the converting constructor constraint

CodeImplement my::shared_ptr<T> and my::weak_ptr<T> with a separately allocated control block and atomic counts: copy and move, reset, use_count, weak_ptr::lock and expired. The object must be destroyed when the last shared_ptr goes and the control block freed when the last weak_ptr goes too. Copies in several threads must be safe.

Answer

Approach. The control block holds strong and weak atomics; weak starts at 1, representing all strong owners together, so freeing happens in one place. Increments can be relaxed (you already hold a reference). Decrements use acquire-release so the thread that destroys sees every other thread's writes. lock() must never resurrect a dead object, so it uses a compare-exchange loop that increments only from a non-zero value.

#include <atomic>
#include <cstdio>
#include <thread>
#include <utility>
#include <vector>

namespace my {

struct control_block {
    std::atomic<long> strong{1};
    std::atomic<long> weak{1};                 // +1 held by the strong owners as a group
    virtual void destroy_object() noexcept = 0;
    virtual ~control_block() = default;

    void release_weak() noexcept {
        if (weak.fetch_sub(1, std::memory_order_acq_rel) == 1) delete this;
    }
    void release_strong() noexcept {
        if (strong.fetch_sub(1, std::memory_order_acq_rel) == 1) {
            destroy_object();
            release_weak();
        }
    }
};

template <class T>
struct block_for : control_block {
    T* ptr;
    explicit block_for(T* p) : ptr(p) {}
    void destroy_object() noexcept override { delete ptr; }
};

template <class T> class weak_ptr;

template <class T>
class shared_ptr {
public:
    shared_ptr() noexcept = default;
    explicit shared_ptr(T* p) : ptr_(p) {
        try { cb_ = new block_for<T>(p); } catch (...) { delete p; throw; }
    }
    shared_ptr(const shared_ptr& o) noexcept : ptr_(o.ptr_), cb_(o.cb_) {
        if (cb_) cb_->strong.fetch_add(1, std::memory_order_relaxed);
    }
    shared_ptr(shared_ptr&& o) noexcept
        : ptr_(std::exchange(o.ptr_, nullptr)), cb_(std::exchange(o.cb_, nullptr)) {}
    shared_ptr& operator=(shared_ptr o) noexcept { swap(o); return *this; }   // copy and swap
    ~shared_ptr() { if (cb_) cb_->release_strong(); }

    void reset() noexcept { shared_ptr().swap(*this); }
    void swap(shared_ptr& o) noexcept { std::swap(ptr_, o.ptr_); std::swap(cb_, o.cb_); }
    long use_count() const noexcept { return cb_ ? cb_->strong.load(std::memory_order_relaxed) : 0; }
    T* get() const noexcept { return ptr_; }
    T& operator*() const noexcept { return *ptr_; }
    T* operator->() const noexcept { return ptr_; }
    explicit operator bool() const noexcept { return ptr_ != nullptr; }

private:
    friend class weak_ptr<T>;
    shared_ptr(T* p, control_block* cb) noexcept : ptr_(p), cb_(cb) {}   // adopts a strong ref
    T* ptr_ = nullptr;
    control_block* cb_ = nullptr;
};

template <class T>
class weak_ptr {
public:
    weak_ptr() noexcept = default;
    weak_ptr(const shared_ptr<T>& s) noexcept : ptr_(s.ptr_), cb_(s.cb_) { acquire(); }
    weak_ptr(const weak_ptr& o) noexcept : ptr_(o.ptr_), cb_(o.cb_) { acquire(); }
    weak_ptr(weak_ptr&& o) noexcept
        : ptr_(std::exchange(o.ptr_, nullptr)), cb_(std::exchange(o.cb_, nullptr)) {}
    weak_ptr& operator=(weak_ptr o) noexcept {
        std::swap(ptr_, o.ptr_);
        std::swap(cb_, o.cb_);
        return *this;
    }
    ~weak_ptr() { if (cb_) cb_->release_weak(); }

    long use_count() const noexcept { return cb_ ? cb_->strong.load(std::memory_order_relaxed) : 0; }
    bool expired() const noexcept { return use_count() == 0; }
    shared_ptr<T> lock() const noexcept {
        if (!cb_) return {};
        long n = cb_->strong.load(std::memory_order_relaxed);
        while (n != 0) {
            if (cb_->strong.compare_exchange_weak(n, n + 1, std::memory_order_acq_rel,
                                                  std::memory_order_relaxed))
                return shared_ptr<T>(ptr_, cb_);
        }
        return {};
    }

private:
    void acquire() noexcept { if (cb_) cb_->weak.fetch_add(1, std::memory_order_relaxed); }
    T* ptr_ = nullptr;
    control_block* cb_ = nullptr;
};

}  // namespace my

struct Probe {
    static inline std::atomic<int> live{0};
    int value;
    explicit Probe(int v) : value(v) { ++live; }
    ~Probe() { --live; }
};

int main() {
    my::weak_ptr<Probe> w;
    {
        my::shared_ptr<Probe> a(new Probe(42));
        my::shared_ptr<Probe> b = a;
        w = a;
        std::printf("use_count=%ld value=%d\n", a.use_count(), b->value);
        if (auto c = w.lock()) std::printf("locked, use_count=%ld\n", c.use_count());
        a.reset();
        std::printf("after reset: b.use_count=%ld expired=%d\n", b.use_count(), w.expired());
    }
    std::printf("scope left: expired=%d live=%d lock null=%d\n", w.expired(), Probe::live.load(), !w.lock());

    my::shared_ptr<Probe> shared(new Probe(7));
    my::weak_ptr<Probe> watcher = shared;
    std::vector<std::thread> threads;
    for (int t = 0; t < 4; ++t) {
        threads.emplace_back([shared, watcher] {          // each thread owns its own copies
            for (int i = 0; i < 10000; ++i) {
                my::shared_ptr<Probe> copy = shared;
                my::shared_ptr<Probe> locked = watcher.lock();
                (void)copy;
                (void)locked;
            }
        });
    }
    for (auto& th : threads) th.join();
    std::printf("after threads: use_count=%ld\n", shared.use_count());
    shared.reset();
    std::printf("final: live=%d expired=%d\n", Probe::live.load(), watcher.expired());
}
use_count=2 value=42
locked, use_count=3
after reset: b.use_count=1 expired=0
scope left: expired=1 live=0 lock null=1
after threads: use_count=1
final: live=0 expired=1

Complexity and pitfalls. O(1) per operation, one extra allocation per object (make_shared would remove it by storing T inside the block). Pitfalls: a lock() that does if (strong > 0) ++strong in two steps (resurrects objects being destroyed), relaxed decrements (the destroying thread might not see other threads' writes to the object), freeing the control block when the strong count hits zero although weak_ptrs still read it, and not deleting the pointer when allocating the control block throws. The standard version adds aliasing, deleters, allocators, conversions and enable_shared_from_this support.

Estimated rating: ~1900 · two-count lifetime protocol, memory ordering and a correct lock() loop

CodeImplement an intrusive reference-counted pointer. Classes inherit from RefCounted, which holds an atomic count; intrusive_ptr<T> increments on copy and deletes the object when the count reaches zero. Show that it is one pointer wide and that wrapping the same raw pointer twice is safe, unlike shared_ptr.

Answer

Approach. The count lives in the object, so there is no control block, no second allocation and no way to create two disagreeing counts. The base class has a protected virtual destructor so the pointer can delete the most derived object. Copies are relaxed increments; the decrement that reaches zero uses acquire-release before deleting.

#include <atomic>
#include <cstdio>
#include <utility>

class RefCounted {
public:
    void add_ref() const noexcept { refs_.fetch_add(1, std::memory_order_relaxed); }
    void release() const noexcept {
        if (refs_.fetch_sub(1, std::memory_order_acq_rel) == 1) delete this;
    }
    int ref_count() const noexcept { return refs_.load(std::memory_order_relaxed); }
protected:
    RefCounted() = default;
    RefCounted(const RefCounted&) {}                   // copies start with their own count
    virtual ~RefCounted() = default;
private:
    mutable std::atomic<int> refs_{0};
};

template <class T>
class intrusive_ptr {
public:
    intrusive_ptr() noexcept = default;
    intrusive_ptr(T* p) noexcept : p_(p) { if (p_) p_->add_ref(); }
    intrusive_ptr(const intrusive_ptr& o) noexcept : intrusive_ptr(o.p_) {}
    intrusive_ptr(intrusive_ptr&& o) noexcept : p_(std::exchange(o.p_, nullptr)) {}
    intrusive_ptr& operator=(intrusive_ptr o) noexcept { std::swap(p_, o.p_); return *this; }
    ~intrusive_ptr() { if (p_) p_->release(); }
    T* get() const noexcept { return p_; }
    T* operator->() const noexcept { return p_; }
    T& operator*() const noexcept { return *p_; }
    explicit operator bool() const noexcept { return p_ != nullptr; }
private:
    T* p_ = nullptr;
};

struct Texture : RefCounted {
    int id;
    explicit Texture(int i) : id(i) {}
    ~Texture() override { std::printf("~Texture(%d)\n", id); }
};

int main() {
    static_assert(sizeof(intrusive_ptr<Texture>) == sizeof(Texture*));
    Texture* raw = new Texture(1);
    intrusive_ptr<Texture> a(raw);
    intrusive_ptr<Texture> b(raw);                     // safe: same count, now 2
    std::printf("count=%d\n", raw->ref_count());
    {
        intrusive_ptr<Texture> c = a;
        std::printf("count=%d\n", c->ref_count());
    }
    a = intrusive_ptr<Texture>();
    std::printf("count=%d\n", b->ref_count());
    b = new Texture(2);                                // releases Texture 1
    std::printf("b->id=%d\n", b->id);
}
count=2
count=3
count=1
~Texture(1)
b->id=2
~Texture(2)

Complexity and pitfalls. O(1), 8 bytes per pointer, one allocation per object. Trade-offs vs shared_ptr: no weak_ptr (adding one needs a separate block again), the type must opt in by inheriting, and the object cannot live on the stack or be owned any other way. Pitfalls: the copy constructor of RefCounted must not copy the count; the destructor must be virtual or the deleting call must know the concrete type; and an object whose count was never incremented (created and never wrapped) leaks. Used by COM, Boost.intrusive_ptr, LLVM's IntrusiveRefCntPtr and many game engines.

Estimated rating: ~1500 · small, but the count-in-object design and copy semantics need care

CodeA thread-safe cache with weak_ptr. TextureCache::get(name) returns a std::shared_ptr<const Texture>: if a live texture with that name exists, return it; otherwise load a new one. The cache must not keep textures alive. Add purge() to drop expired entries. Explain the make_shared choice.

Answer

Approach. Map names to weak_ptrs under a mutex. get tries lock(); on failure it creates the texture and stores a weak reference. The texture is created with shared_ptr(new ...), not make_shared, so that an expired texture's memory is freed immediately even though its weak_ptr remains in the map until purge(). This is Herb Sutter's "favourite C++ 10-liner" with that refinement.

#include <atomic>
#include <cstdio>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

struct Texture {
    std::string name;
    std::vector<unsigned char> pixels;
    explicit Texture(std::string n) : name(std::move(n)), pixels(4096) {}
};

class TextureCache {
public:
    std::shared_ptr<const Texture> get(const std::string& name) {
        std::lock_guard<std::mutex> lock(mu_);
        std::weak_ptr<const Texture>& slot = cache_[name];
        if (std::shared_ptr<const Texture> hit = slot.lock()) return hit;
        std::shared_ptr<const Texture> fresh(new Texture(name));   // not make_shared, see text
        slot = fresh;
        ++loads_;
        return fresh;
    }

    std::size_t purge() {
        std::lock_guard<std::mutex> lock(mu_);
        std::size_t removed = 0;
        for (auto it = cache_.begin(); it != cache_.end();) {
            if (it->second.expired()) { it = cache_.erase(it); ++removed; }
            else ++it;
        }
        return removed;
    }

    int loads() const { std::lock_guard<std::mutex> lock(mu_); return loads_; }

private:
    mutable std::mutex mu_;
    std::map<std::string, std::weak_ptr<const Texture>> cache_;
    int loads_ = 0;
};

int main() {
    TextureCache cache;
    auto stone = cache.get("stone");                    // keep one alive for the whole test
    std::atomic<int> same{0};
    std::vector<std::thread> workers;
    for (int t = 0; t < 4; ++t) {
        workers.emplace_back([&] {
            for (int i = 0; i < 500; ++i)
                if (cache.get("stone") == stone) ++same;
        });
    }
    for (auto& w : workers) w.join();
    std::printf("loads=%d, all hits returned the same object: %d\n", cache.loads(), same.load() == 2000);

    {
        auto grass = cache.get("grass");
        std::printf("loads=%d\n", cache.loads());
    }                                                   // grass expires here
    std::printf("purged=%zu\n", cache.purge());
    stone.reset();
    std::printf("purged=%zu\n", cache.purge());
    auto again = cache.get("stone");
    std::printf("reload: loads=%d\n", cache.loads());
}
loads=1, all hits returned the same object: 1
loads=2
purged=1
purged=1
reload: loads=3

Complexity and pitfalls. O(log n) per lookup under one mutex; a high-traffic cache would shard the map or load outside the lock (with care to avoid two threads loading the same name). Pitfalls: storing shared_ptr in the cache (then nothing ever expires), checking expired() then calling lock() (race), and using make_shared here, which would keep each dead texture's 4 KB alive inside its control block until purge() removes the weak_ptr. Without periodic purging, the map itself grows with every distinct name ever requested: a logical leak.

Estimated rating: ~1500 · weak_ptr lock pattern plus locking and the make_shared memory trade-off

CodeA singly linked list owned by unique_ptr. Implement push_front, pop_front, reverse (in place, no allocation) and sum. The list must survive one million nodes: explain why the default destructor would not, and write one that does.

Answer

Approach. Each node owns the next through std::unique_ptr<Node>, so ownership is automatic. The implicit destructor would destroy head, whose destructor destroys next, recursively: one stack frame per node, which overflows an 8 MB stack long before a million nodes. The destructor instead detaches nodes in a loop: head_ = std::move(head_->next) first takes the successor out of the old head (via release), then deletes the old head, which now owns nothing.

#include <cstdio>
#include <memory>
#include <optional>
#include <utility>

class IntList {
public:
    IntList() = default;
    IntList(IntList&&) noexcept = default;
    IntList& operator=(IntList&& o) noexcept { clear(); head_ = std::move(o.head_); return *this; }
    ~IntList() { clear(); }

    void push_front(int v) {
        auto n = std::make_unique<Node>();
        n->value = v;
        n->next = std::move(head_);
        head_ = std::move(n);
    }
    std::optional<int> pop_front() {
        if (!head_) return std::nullopt;
        int v = head_->value;
        head_ = std::move(head_->next);
        return v;
    }
    void reverse() noexcept {
        std::unique_ptr<Node> done;
        while (head_) {
            std::unique_ptr<Node> rest = std::move(head_->next);
            head_->next = std::move(done);
            done = std::move(head_);
            head_ = std::move(rest);
        }
        head_ = std::move(done);
    }
    long long sum() const noexcept {
        long long s = 0;
        for (const Node* n = head_.get(); n; n = n->next.get()) s += n->value;
        return s;
    }
    void clear() noexcept {
        while (head_) head_ = std::move(head_->next);   // iterative: no deep recursion
    }

private:
    struct Node {
        int value = 0;
        std::unique_ptr<Node> next;
    };
    std::unique_ptr<Node> head_;
};

int main() {
    IntList small;
    for (int i = 1; i <= 4; ++i) small.push_front(i);   // 4 3 2 1
    small.reverse();                                    // 1 2 3 4
    std::printf("pop: %d %d\n", *small.pop_front(), *small.pop_front());
    std::printf("sum of rest: %lld\n", small.sum());

    IntList big;
    for (int i = 0; i < 1000000; ++i) big.push_front(1);
    big.reverse();
    std::printf("big sum: %lld\n", big.sum());
}                                                       // destroys a million nodes safely
pop: 1 2
sum of rest: 7
big sum: 1000000

Complexity and pitfalls. push and pop O(1), reverse and sum O(n), destruction O(n) time with O(1) stack. Pitfalls: relying on the recursive default destructor (stack overflow, often only in production with large inputs), writing head_ = std::move(head_->next) with your own pointer class that deletes before moving (that destroys the node you are reading from), and a defaulted move assignment that would destroy the old list recursively (hence the explicit one). Copy is deliberately not provided. The same recursion problem hits trees of unique_ptr and long shared_ptr chains.

Estimated rating: ~1400 · unique_ptr ownership transfer plus the recursive-destructor trap

CodeCopy-on-write string with shared_ptr. CowString copies share one buffer; the first modifying operation on a shared buffer makes a private copy. Provide str(), append, set(i, c) and shares_buffer_with, and state why this design is not thread-safe as written.

Answer

Approach. Hold std::shared_ptr<std::string>. Copying a CowString copies the pointer (cheap). Every mutator calls detach() first, which clones the buffer if use_count() > 1. Readers return a const reference to the shared buffer.

#include <cstddef>
#include <cstdio>
#include <memory>
#include <string>
#include <utility>

class CowString {
public:
    explicit CowString(std::string s = {}) : buf_(std::make_shared<std::string>(std::move(s))) {}

    const std::string& str() const noexcept { return *buf_; }
    void append(const std::string& s) { detach(); buf_->append(s); }
    void set(std::size_t i, char c) { detach(); (*buf_)[i] = c; }
    bool shares_buffer_with(const CowString& o) const noexcept { return buf_ == o.buf_; }

private:
    void detach() {
        if (buf_.use_count() > 1) buf_ = std::make_shared<std::string>(*buf_);
    }
    std::shared_ptr<std::string> buf_;
};

int main() {
    CowString a("order book");
    CowString b = a;                       // shares
    CowString c = a;
    std::printf("b shares with a: %d\n", b.shares_buffer_with(a));
    b.append(" v2");                       // b detaches
    c.set(0, 'O');                         // c detaches
    std::printf("a=\"%s\" b=\"%s\" c=\"%s\"\n", a.str().c_str(), b.str().c_str(), c.str().c_str());
    std::printf("shares: a-b %d, a-c %d\n", a.shares_buffer_with(b), a.shares_buffer_with(c));
    a.append("!");                         // a is the sole owner now: no copy
    std::printf("a=\"%s\"\n", a.str().c_str());
}
b shares with a: 1
a="order book" b="order book v2" c="Order book"
shares: a-b 0, a-c 0
a="order book!"

Complexity and pitfalls. Copies are O(1); the first write after sharing is O(n). Not thread-safe: use_count() is a snapshot, so two threads holding copies could both see 2 and both clone (harmless), or one could see 1 while another thread is in the middle of copying it from a third object, and then write into a buffer that just became shared. Also, str() returns a reference into a buffer that a later mutation may replace. This is why C++11 banned copy-on-write for std::string (references and iterators must stay valid, and operator[] must not invalidate them); libstdc++ switched to SSO with its new ABI in GCC 5.

Estimated rating: ~1300 · simple with shared_ptr, but explaining the thread-safety hole is the real test

Cheat sheet

Theory
  • Default to unique_ptr (8 bytes, move-only, zero overhead with a stateless deleter); use make_unique; return it from factories; convert to shared_ptr by move when sharing is needed.
  • Parameters: T&/T* to use, unique_ptr by value to take ownership, unique_ptr& to reseat, shared_ptr by value to share. Avoid const unique_ptr&.
  • shared_ptr = object pointer + control block (strong count, weak count, type-erased deleter). Copies cost atomic operations and can bounce cache lines; move when you can.
  • make_shared: one allocation and better locality, but the object's memory lives until the last weak_ptr dies; cannot take a custom deleter or call private constructors.
  • Never build two shared_ptrs from one raw pointer (two control blocks, double delete). Inside the object use enable_shared_from_this (public base), and never call shared_from_this() in the constructor or on an unowned object (bad_weak_ptr).
  • Aliasing constructor shares ownership of a whole object while pointing at a member; it is how the pointer casts work.
  • Thread safety: the counts are atomic; one shared_ptr instance written concurrently is a race (mutex or atomic<shared_ptr>); the pointee is unprotected. Use weak.lock(), never expired() then lock().
  • auto_ptr moved on copy and was removed in C++17. Custom deleters: Ch 12. Cycles and weak back pointers: Ch 13.

Part E · Memory management · Chapter 12

Custom deleters

A smart pointer is a pointer plus a rule for releasing it. This chapter is about that rule: how unique_ptr stores it in its type (and what that costs in bytes), how shared_ptr hides it in the control block, and how to wrap C handles, file descriptors, pool memory and mappings safely. Interviewers use deleters to test whether you understand type erasure, the empty base optimisation and the exact null and exception rules.

TheoryDrillsCoding· about 3 hours· prerequisites: Ch 11 smart pointers, Ch 9 new/delete

Why custom deleters exist

Theory

Mental model. Ownership is about resources, and memory is only one kind of resource. A FILE* must go back through fclose, a file descriptor through close, a mapping through munmap, a block from a pool back to that pool, and an array from new[] through delete[]. If a smart pointer could only call delete, every one of these would need a hand-written RAII class. A custom deleter lets you reuse the whole smart pointer machinery (move semantics, exception safety, reset, release, comparison) and change only the last step.

Under the hood. std::unique_ptr<T, D> stores a pointer of type D::pointer (or T* if D has no such member) and an object of type D. Its destructor is, in effect:

~unique_ptr() {
    if (get() != nullptr) get_deleter()(get());
}

std::shared_ptr<T> does the same thing later, when the strong count reaches zero, and it keeps the deleter in the control block instead of in the pointer object (Ch 11 covers the control block itself).

ResourceAcquireRelease (the deleter calls)Null value
C streamfopen, tmpfile, popenfclose (pclose for popen)nullptr
File descriptor, socketopen, socket, accept, pipeclose-1 (0 is a valid fd)
Memory mappingmmapmunmap(addr, length), needs the lengthMAP_FAILED, which is not nullptr
C allocationmalloc, strdup, getlinefreenullptr
C library handlesqlite3_open, EVP_MD_CTX_new, curl_easy_initthe matching *_close or *_freenullptr
Arraynew T[n]delete[] (use unique_ptr<T[]>)nullptr
Pool or arena blockpool.acquire()pool.release(p), needs the poolnullptr

Two columns in that table drive the design of a deleter. The release column decides whether the deleter needs state (a length, a pool pointer). The null value column decides whether a plain pointer type can represent "no resource" at all. For a file descriptor it cannot, and a custom pointer type fixes that.

#include <cstdio>
#include <memory>

struct FileCloser {
    void operator()(std::FILE* f) const noexcept {
        std::puts("fclose");
        std::fclose(f);
    }
};
using File = std::unique_ptr<std::FILE, FileCloser>;

static File open_temp() { return File(std::tmpfile()); }

int main() {
    File f = open_temp();
    if (!f) return 1;
    std::fputs("hello\n", f.get());
    std::rewind(f.get());
    char line[16];
    if (std::fgets(line, sizeof line, f.get())) std::printf("read: %s", line);
    File g = std::move(f);
    std::printf("f empty: %d\n", f == nullptr);
}
read: hello
f empty: 1
fclose

The stream is closed exactly once, by g, at the end of main. The moved-from f holds nullptr, so its destructor does not call the deleter. Early returns and exceptions get the same guarantee for free.

unique_ptr<T, D>: three ways to spell the deleter

Theory

Mental model. For unique_ptr the deleter is part of the type. unique_ptr<FILE, A> and unique_ptr<FILE, B> are unrelated types: you cannot assign one to the other or put both in one vector. That is the price of zero overhead, because the compiler sees the exact deleter and can inline it.

Function object, lambda, function pointer

#include <cstdio>
#include <memory>

struct FileCloser {                       // 1. a named function-object type
    void operator()(std::FILE* f) const noexcept { std::fclose(f); }
};

static void close_file(std::FILE* f) { std::fclose(f); }

int main() {
    std::unique_ptr<std::FILE, FileCloser> a(std::tmpfile());

    using LambdaCloser = decltype([](std::FILE* f) { std::fclose(f); });  // 2. C++20 only
    std::unique_ptr<std::FILE, LambdaCloser> b(std::tmpfile());

    auto closer = [](std::FILE* f) { std::fclose(f); };                   // 2b. lambda object
    std::unique_ptr<std::FILE, decltype(closer)> c(std::tmpfile(), closer);

    std::unique_ptr<std::FILE, decltype(&close_file)> d(std::tmpfile(), &close_file);  // 3. pointer

    std::printf("%d %d %d %d\n", a != nullptr, b != nullptr, c != nullptr, d != nullptr);
}
1 1 1 1
  • Function-object type. The best default. It is default constructible, so File(fopen(...)) works with one argument, it costs no space, and the name documents the intent. Mark operator() noexcept.
  • Lambda. Before C++20 a lambda closure type has no default constructor and a lambda cannot appear in decltype, so you must pass the lambda object to every constructor (form 2b). C++20 made captureless lambdas default constructible and allowed lambdas in unevaluated operands, which gives form 2. A stateless lambda costs no space either.
  • Function pointer. decltype(&fclose) is int (*)(FILE*). The pointer is a runtime value, so it takes 8 bytes inside every unique_ptr, the call is indirect unless the optimiser proves the value, and you must pass it at every construction: a value-initialised function pointer is null, so the one-argument constructor does not take part in overload resolution for pointer deleters (it would leave you with a null deleter to call later).

Interview trap

std::unique_ptr<FILE, decltype(&fclose)> f(fopen("a", "r")); does not compile. The single-argument constructor requires D to be default constructible and not a pointer type ([unique.ptr.single.ctor]). Write f(fopen("a", "r"), &fclose), or better, use a function-object type.

A note on taking the address of library functions

C++20 [namespace.std] says the behaviour is unspecified (possibly ill-formed) if a program takes the address of a standard library function that is not designated addressable, and almost none are. &std::fclose works on every real implementation, but the strictly portable spelling is a lambda or function object that calls fclose. It also avoids a GCC -Wignored-attributes warning on glibc, where fclose carries a nonnull attribute that is lost in decltype(&fclose). One more trick you may see in code reviews turns a function pointer into a zero-size type:

template <auto F>
struct FnDeleter {
    template <class P> void operator()(P p) const noexcept { F(p); }
};
using File = std::unique_ptr<std::FILE, FnDeleter<&std::fclose>>;   // pointer is a template argument, 0 bytes

What unique_ptr requires of D

  • d(ptr) must be well-formed and must not throw. The destructor and reset call it.
  • get_deleter() returns a reference to the stored deleter, so a stateful deleter can be inspected or changed.
  • D may be an lvalue reference type, unique_ptr<T, D&>. Then the pointer stores a reference to an external deleter object, which must outlive it. This is how several pointers share one stateful deleter without copying it.
  • If D has a nested type pointer, that type is stored instead of T*. It must satisfy NullablePointer: value-initialises to null, compares with nullptr, converts to bool in a condition.
  • release() gives up ownership without calling the deleter. reset(q) stores q first and then deletes the old pointer, so a deleter that inspects the smart pointer sees the new state.

The size cost and the empty base optimisation

Theory

Mental model. An empty class still has sizeof 1, so a naive struct { T* p; D d; } would be 16 bytes even for an empty D, because of padding. Library implementations avoid this. libstdc++ stores the pair in a std::tuple whose elements are base classes when they are empty, and libc++ uses a __compressed_pair. Both rely on the empty base optimisation (EBO): an empty base class subobject may occupy zero bytes. In your own code, C++20 [[no_unique_address]] on a member gives the same effect without inheritance.

#include <cstdio>
#include <functional>
#include <memory>

struct CloseFile { void operator()(std::FILE* f) const noexcept { std::fclose(f); } };
static void close_file(std::FILE* f) { std::fclose(f); }

int main() {
    auto lam = [](std::FILE* f) { std::fclose(f); };
    int log_fd = 2;
    auto stateful = [log_fd](std::FILE* f) { (void)log_fd; std::fclose(f); };
    using F = std::FILE;
    std::printf("default_delete   %zu\n", sizeof(std::unique_ptr<int>));
    std::printf("function object  %zu\n", sizeof(std::unique_ptr<F, CloseFile>));
    std::printf("stateless lambda %zu\n", sizeof(std::unique_ptr<F, decltype(lam)>));
    std::printf("function pointer %zu\n", sizeof(std::unique_ptr<F, decltype(&close_file)>));
    std::printf("stateful lambda  %zu\n", sizeof(std::unique_ptr<F, decltype(stateful)>));
    std::printf("std::function    %zu\n", sizeof(std::unique_ptr<F, std::function<void(F*)>>));
}
default_delete   8
function object  8
stateless lambda 8
function pointer 16
stateful lambda  16
std::function    40

These numbers are for 64-bit Linux and macOS with both libstdc++ and libc++. The last line depends on sizeof(std::function), which is 32 on both libraries today but is not fixed by the standard. The stateful lambda captures an int (4 bytes), and alignment pads the pair to 16.

sizeof(unique_ptr<FILE, D>) on a 64-bit target (one cell = 8 bytes) function object / default_delete FILE* p D 8 B: D is an empty base, 0 bytes stateless lambda FILE* p λ 8 B: closure has no members function pointer FILE* p int(*)(FILE*) 16 B: pointer value stored lambda capturing an int FILE* p int pad 16 B: state plus padding std::function deleter FILE* p std::function (32 B, may allocate) 40 B
Stateless deleters are free, stateful ones are paid per pointer. The empty base optimisation lets an empty deleter share the address of the pointer member, so it adds nothing. A function pointer is state (a runtime value), so it doubles the size. A std::function deleter adds type erasure and possibly a heap allocation to every pointer.

Why the size matters. A vector<unique_ptr<Node, decltype(&free_node)>> with ten million entries wastes 80 MB on copies of the same function pointer, and halves the number of pointers per cache line. In latency-sensitive code the indirect call also blocks inlining. Prefer an empty function-object type whenever the release action is fixed at compile time.

Doing it yourself: [[no_unique_address]]

template <class T, class D>
class Handle {
    T* p_ = nullptr;
    [[no_unique_address]] D d_;     // occupies no storage if D is empty (GCC, Clang; MSVC ignores it)
};

MSVC accepts but ignores the standard attribute for ABI reasons and offers [[msvc::no_unique_address]] instead. Before C++20 the portable technique was to inherit privately from D when std::is_empty_v<D> and D is not final.

shared_ptr deleters: type-erased, in the control block

Theory

Mental model. For shared_ptr<T> the deleter is not part of the type. Every shared_ptr<int> is the same 16-byte object, two pointers: one to the int and one to a control block. The control block is a class template instantiated for the exact pointer, deleter and allocator types, and the shared_ptr talks to it through virtual functions. That is classic type erasure.

Same static type shared_ptr<int>, different control block types shared_ptr<int> a int* ptrctrl* shared_ptr<int> b int* ptrctrl* control block, default deleter vptr → dispose: delete p use_count = 1 weak_count = 1 int* p control block, Pool deleter vptr → dispose: d(p) use_count, weak_count int* p PoolDeleter d { Pool* } int (heap) int (pool) virtual call when use_count → 0
The deleter lives in the control block. Both pointers have the same type and size. The control block for b is a different class that also stores the deleter object, and the shared_ptr reaches it through a virtual dispose call when the last strong owner goes away. The dashed arrows are the stored object pointers.
#include <cstdio>
#include <memory>
#include <vector>

static int global_counter = 3;

int main() {
    std::vector<std::shared_ptr<int>> v;
    v.push_back(std::shared_ptr<int>(new int(1)));
    v.push_back(std::shared_ptr<int>(new int(2), [](int* p) {
        std::printf("lambda frees %d\n", *p);
        delete p;
    }));
    v.push_back(std::shared_ptr<int>(&global_counter, [](int*) { std::puts("no-op for a static"); }));
    std::printf("all the same type, sizeof %zu\n", sizeof(v[0]));
    for (auto& p : v) p.reset();
}
all the same type, sizeof 16
lambda frees 2
no-op for a static

Rules that differ from unique_ptr

  • Capture at construction. The constructor template<class Y, class D> shared_ptr(Y* p, D d) records Y and D in the control block type. Later conversions to shared_ptr<Base> or shared_ptr<void> keep calling the original deleter with the original Y*, which is why shared_ptr<void>(new Foo) runs ~Foo and shared_ptr<Base>(new Derived) is correct even without a virtual destructor.
  • Requirements on D. D must be move constructible, and d(p) must be well-formed and must not throw.
  • Allocation failure. The constructor allocates the control block. If that throws, it calls d(p) before rethrowing, so the resource never leaks. That is why shared_ptr<FILE>(fopen(...), fclose) is exception safe on its own.
  • Inspection. std::get_deleter<D>(sp) returns a D* to the stored deleter, or nullptr if the type does not match exactly. There is no way to ask "what is the deleter type?", only "is it this type?".
  • Cost. The deleter is stored once per owned object, in the control block, not once per pointer. Calling it is a virtual call, which is irrelevant compared with the atomic decrement before it.
  • Conversion from unique_ptr. shared_ptr<T>(std::move(u)) moves the unique_ptr's deleter into the new control block, so a unique_ptr<FILE, FileCloser> becomes a shared_ptr<FILE> that still calls FileCloser. If D is a reference type, the control block stores std::ref(u.get_deleter()) instead, so the external deleter object must outlive every copy of the shared_ptr.

Know this cold

unique_ptr: deleter in the type, stored inline, zero cost if empty, statically dispatched. shared_ptr: deleter erased, stored in the control block, one virtual call, any deleter fits the same shared_ptr<T>.

When is the deleter object itself destroyed?

The deleter is called when the strong count reaches zero. When the deleter object is destroyed is left to the implementation: the standard only promises that get_deleter stays valid while some shared_ptr or weak_ptr shares ownership. libstdc++ destroys the deleter together with the control block, when the weak count also reaches zero. libc++ destroys it right after calling it. If your deleter captures something heavy (a shared_ptr to a pool, a big buffer), a long-lived weak_ptr may keep that alive on libstdc++.

Deleters and the make functions

Theory

Mental model. make_unique and make_shared both decide how the object is allocated, so they also decide how it is freed. There is no overload that takes a deleter, and there cannot be a sensible one: a deleter is the answer to "how do I free memory someone else allocated?", and with a make function nobody else allocated it.

  • make_unique<T>(args...) is unique_ptr<T>(new T(args...)), always with default_delete. For a custom deleter, call the constructor: unique_ptr<T, D>(pool.acquire(), D{&pool}).
  • make_shared<T>(args...) allocates the control block and the object in one block, and destroys the object with a direct destructor call, not with delete. A user deleter would have nothing valid to free.
  • std::allocate_shared<T>(alloc, args...) is the customisation point instead. It takes an allocator, rebinds it to the internal control-block-plus-object type, allocates once through it and constructs the object with allocator_traits::construct. Use it when the memory must come from a pool or arena but you still want a single allocation.
  • The three-argument constructor shared_ptr(p, d, alloc) uses alloc only for the control block. The object was allocated by you, and d frees it.
#include <cstddef>
#include <cstdio>
#include <memory>
#include <new>

static int allocations = 0;

template <class T>
struct CountingAlloc {
    using value_type = T;
    CountingAlloc() = default;
    template <class U> CountingAlloc(const CountingAlloc<U>&) {}
    T* allocate(std::size_t n) {
        ++allocations;
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    void deallocate(T* p, std::size_t) noexcept { ::operator delete(p); }
    friend bool operator==(const CountingAlloc&, const CountingAlloc&) { return true; }
};

struct Point { int x, y; };

int main() {
    auto a = std::allocate_shared<Point>(CountingAlloc<Point>{}, 1, 2);
    std::printf("allocate_shared: %d allocation(s) through the allocator\n", allocations);

    allocations = 0;
    std::shared_ptr<Point> b(new Point{3, 4}, std::default_delete<Point>{}, CountingAlloc<Point>{});
    std::printf("shared_ptr(p, d, a): %d allocation(s) through the allocator, plus the new\n", allocations);
    std::printf("%d %d\n", a->x + a->y, b->x + b->y);
}
allocate_shared: 1 allocation(s) through the allocator
shared_ptr(p, d, a): 1 allocation(s) through the allocator, plus the new
3 7

Tip

For "objects from my pool, shared ownership", you have two designs. allocate_shared with a pool allocator gives one allocation and the pool sees the combined block size. A raw pool pointer plus a PoolDeleter gives two allocations (the control block comes from the global heap) but keeps the pool's block size equal to sizeof(T). Pick the second if the pool is a fixed-size block pool (Ch 15).

Null handles, exceptions and close errors

Theory

Is the deleter called for a null pointer?

This is the single most asked deleter question, and the two smart pointers answer it differently.

  • unique_ptr: no. The destructor and reset only call the deleter if the stored pointer is not null ([unique.ptr.single.dtor]). So unique_ptr<FILE, FileCloser>(fopen("missing", "r")) is safe even though fclose(nullptr) is undefined.
  • shared_ptr: yes, if you gave it a deleter. shared_ptr(nullptr_t, D d) and shared_ptr(p, d) with p == nullptr both create a control block that owns the null pointer. use_count() is 1, and when it drops to 0 the deleter is called with nullptr. A default-constructed shared_ptr has no control block, so nothing is called.
#include <cstdio>
#include <memory>

static void say(int* p) {
    std::printf("deleter called with %s\n", p ? "an object" : "nullptr");
    delete p;
}

int main() {
    {
        std::unique_ptr<int, void (*)(int*)> u(nullptr, say);
        std::puts("unique_ptr leaves scope");
    }
    {
        std::shared_ptr<int> s(nullptr, say);
        std::printf("use_count %ld\n", s.use_count());
        std::puts("shared_ptr leaves scope");
    }
    {
        std::shared_ptr<int> e;
        std::printf("empty use_count %ld\n", e.use_count());
    }
}
unique_ptr leaves scope
use_count 1
shared_ptr leaves scope
deleter called with nullptr
empty use_count 0

So a shared_ptr deleter for a C API that rejects null must check: [](FILE* f) { if (f) fclose(f); }. Note also that a shared_ptr holding null with a deleter is non-empty but if (sp) is false, because operator bool tests the stored pointer, not the control block.

Handles that are not pointers

A file descriptor is an int whose null value is -1. unique_ptr<int, D> would store an int*, which is wrong. Give the deleter a nested pointer type that wraps the fd and satisfies NullablePointer, and unique_ptr stores that instead. The coding section builds this completely. The common alternative is a 30-line dedicated UniqueFd class, which is what most codebases (for example Android's unique_fd) actually ship. Both are fine answers in an interview, as long as you say why int* is wrong and why 0 is a valid descriptor.

Deleters must not throw

The standard requires that the deleter call does not throw, and destructors are implicitly noexcept. If a deleter throws from inside ~unique_ptr, std::terminate is called. Mark your deleter noexcept so the contract is visible and checked.

A deleter also has no way to report an error. fclose flushes buffered output, and the flush can fail (disk full, NFS errors). close on some file systems reports deferred write errors. If the error matters, close explicitly on the success path and check the result:

bool save(const char* path, const std::string& data) {
    File f(std::fopen(path, "w"));
    if (!f) return false;
    if (std::fwrite(data.data(), 1, data.size(), f.get()) != data.size()) return false;
    return std::fclose(f.release()) == 0;   // explicit close: the error is observed; the deleter only covers failure paths
}

Interview trap

Retrying close after EINTR is wrong on Linux: the descriptor is already released, and a retry may close a descriptor another thread just received. A deleter should call close once and ignore the result.

Key factsunique_ptr<T, D>shared_ptr<T>
Deleter part of the typeyesno (type-erased)
Where it is storedinside the pointer object (EBO if empty)in the control block
Size with a stateless deleter8 bytes16 bytes (always)
Size with a function pointer deleter16 bytes16 bytes (the pointer is in the control block)
Deleter called for nullneveryes, if constructed with a deleter
Works with the make functionno, call the constructorno, use allocate_shared for allocator control
Inspect the deleterget_deleter() memberstd::get_deleter<D>(sp) free function
Incomplete T at destructionerror with default_deletefine, deleter captured at construction
Custom handle type (fd)yes, via D::pointerno, always stores T*

Arrays, no-op deleters, pools and mmap

Theory

Arrays

unique_ptr<T[]> uses default_delete<T[]>, which calls delete[], and provides operator[] instead of * and ->. It also refuses conversions from Derived*, because indexing an array through a base pointer is broken. shared_ptr<T[]> works the same way since C++17, and make_shared<T[]>(n) since C++20. Before C++17 you had to write shared_ptr<T>(new T[n], std::default_delete<T[]>()), and forgetting the deleter meant delete on a new[] pointer, which is undefined behaviour (Ch 9 explains the array cookie). For buffers from malloc use unique_ptr<char[], FreeDeleter>. In most code, std::vector is still the better array owner.

No-op deleters

A deleter that does nothing turns a smart pointer into a non-owning one: shared_ptr<Logger>(&global_logger, [](Logger*) {}). It is useful when an interface demands a shared_ptr and you want to pass a static or stack object that is known to outlive every user. It is also a design smell, because the type now lies: it says "shared ownership" while nobody owns the object, and if the stack object dies first you get a dangling pointer that the type system cannot see. The aliasing constructor (Ch 11) is the better tool when there is a real owner to tie the lifetime to. The cleaner fix is to change the interface to take T& or T*.

Stateful deleters: pools and mappings

A pool deleter must know which pool to return to, so it carries a Pool*, and every unique_ptr grows to 16 bytes. If there is exactly one pool per type, a stateless deleter that calls a static or thread-local pool keeps the size at 8. A munmap deleter must know the length. Store it in the deleter, or wrap the mapping in a span-like type and give the deleter a pointer typedef.

struct Unmap {
    std::size_t len;
    void operator()(void* p) const noexcept { ::munmap(p, len); }
};
void* raw = ::mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd, 0);
if (raw == MAP_FAILED) throw std::system_error(errno, std::generic_category());   // MAP_FAILED is (void*)-1, not null
std::unique_ptr<void, Unmap> map(raw, Unmap{len});

Interview trap

Check for MAP_FAILED before handing the pointer to the smart pointer. unique_ptr only skips the deleter for nullptr, so a MAP_FAILED pointer would be passed to munmap. The same applies to INVALID_HANDLE_VALUE on Windows and SIG_ERR-style sentinels.

A pool deleter also creates a lifetime rule: the pool must outlive every pointer it handed out. Declare the pool before the pointers (members are destroyed in reverse order of declaration), or make the deleter hold a shared_ptr to the pool. The reading drills include the bug you get when this is wrong.

default_delete and incomplete types: the PIMPL destructor

Theory

Mental model. std::default_delete<T>::operator() contains static_assert(sizeof(T) > 0) (in effect), so it refuses to compile if T is incomplete at the point where it is instantiated. That is deliberate: delete on a pointer to an incomplete class type compiles with a warning but is undefined behaviour if the class has a non-trivial destructor, because the destructor is never called. C++26 goes further and makes deleting a pointer to an incomplete class type ill-formed (P3144).

In the PIMPL idiom the header only declares struct Impl;. If the class does not declare a destructor, the compiler defines one implicitly inline, wherever it is used, which is in client code that only saw the header. There Impl is incomplete, so default_delete<Impl> fails.

parser.h class Parser { struct Impl; unique_ptr<Impl> p; }; main.cpp (client) #include "parser.h" Parser x; // ~Parser here Impl incomplete: error parser.cpp struct Parser::Impl {…}; Parser::~Parser() = default; Impl complete: OK Fix: declare ~Parser() in the header and define it (even as = default) in parser.cpp, after the definition of Impl. Do the same for the move constructor and move assignment, which also destroy an Impl. shared_ptr<Impl> has no such problem: its deleter is captured where the object is created, inside parser.cpp.
The destructor is instantiated where it is defined. An implicit destructor is defined inline at the first use in client code, where Impl is incomplete. Declaring it in the header and defaulting it in the .cpp moves the instantiation of default_delete<Impl> to a place where Impl is complete.
// parser.h
class Parser {
public:
    Parser();
    ~Parser();                              // declared only
    Parser(Parser&&) noexcept;              // these also destroy an Impl
    Parser& operator=(Parser&&) noexcept;
    int parse(std::string_view s);
private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

// parser.cpp
struct Parser::Impl { std::vector<int> tokens; };
Parser::Parser() : impl_(std::make_unique<Impl>()) {}
Parser::~Parser() = default;                // Impl is complete here
Parser::Parser(Parser&&) noexcept = default;
Parser& Parser::operator=(Parser&&) noexcept = default;

The same error appears with any class that has a unique_ptr member to a forward-declared type, not only PIMPL. Note that unique_ptr<Impl> as a member is fine while Impl is incomplete: only the functions that destroy (destructor, reset, move assignment) need the complete type.

C++23 std::out_ptr and std::inout_ptr

Theory

C APIs often return a handle through an output parameter: int sqlite3_open(const char*, sqlite3** out). Before C++23 you wrote a temporary raw pointer, called the function and then reset the smart pointer, and every early return between the two steps was a leak. std::out_ptr(sp) (in <memory>) creates a temporary object that converts to T**, and at the end of the full expression its destructor calls sp.reset(p) if the C function wrote a non-null pointer. It also resets sp first, so the old handle is released before the call. std::inout_ptr is for APIs that take an existing handle, free or reallocate it and write back the new one: it passes the current pointer in after calling release().

#include <cstdio>
#include <memory>

struct Widget { int id; };
// A C-style API: the handle comes back through an out parameter.
static int widget_create(Widget** out, int id) { *out = new Widget{id}; return 0; }
static void widget_destroy(Widget* w) { std::printf("destroy %d\n", w->id); delete w; }

struct WidgetDeleter { void operator()(Widget* w) const noexcept { widget_destroy(w); } };

int main() {
    std::unique_ptr<Widget, WidgetDeleter> w;
    if (widget_create(std::out_ptr(w), 7) != 0) return 1;
    std::printf("got %d\n", w->id);
    widget_create(std::out_ptr(w), 8);   // w is reset (destroy 7) before the call
    std::printf("got %d\n", w->id);
}
got 7
destroy 7
got 8
destroy 8

For a shared_ptr, out_ptr needs the deleter as an extra argument, std::out_ptr(sp, WidgetDeleter{}), because shared_ptr::reset(p) alone would use delete. inout_ptr does not work with shared_ptr at all, because a shared object cannot be released.

Concept and tricky questions

Drills

ConceptWhy does a smart pointer need a custom deleter at all? Give three resources where delete is the wrong release.

Answer

Answer. Because the release action depends on how the resource was acquired. FILE* from fopen needs fclose, a buffer from malloc needs free, an mmap region needs munmap(addr, len), a pool block needs pool.release, and new T[n] needs delete[].

Why. Calling the wrong release is undefined behaviour: delete on malloc memory or on a FILE* corrupts the heap or skips flushing. The deleter lets you keep all of unique_ptr's guarantees (single owner, exception safety, moves) and change only the last step. A weak answer says "for logging"; that is a use, but not the reason the feature exists.

TrickyYour stateless deleter is declared struct Closer final { void operator()(int* p) const noexcept { delete p; } };. It is still an empty class. Is unique_ptr<int, Closer> still 8 bytes?

Answer

Answer. No. With both libstdc++ (GCC 16) and libc++ (LLVM 22) it is 16 bytes, the same as a function pointer deleter. Remove final and it goes back to 8. A hand-written pair with [[no_unique_address]] stays at 8 even for a final type.

Why. The library size optimisation is the empty base optimisation, and you cannot derive from a final class. So the libraries store the deleter as an ordinary member instead, which takes 1 byte and is padded to pointer alignment. [[no_unique_address]] works on a member, so it does not care about final. The standard fixes neither size. The usual wrong answer is "8, the deleter is empty". Being empty is necessary for the zero-cost layout, but it is not enough. (The basic sizes for function pointers and lambdas are a Ch 11 drill.)

#include <cstdio>
#include <memory>

struct Closer { void operator()(int* p) const noexcept { delete p; } };
struct FinalCloser final { void operator()(int* p) const noexcept { delete p; } };

template <class T, class D>
struct Pair { T* p; [[no_unique_address]] D d; };

int main() {
    std::printf("plain %zu, final %zu, no_unique_address final %zu\n",
                sizeof(std::unique_ptr<int, Closer>), sizeof(std::unique_ptr<int, FinalCloser>),
                sizeof(Pair<int, FinalCloser>));
}
plain 8, final 16, no_unique_address final 8

TrickyIs the deleter called when the managed pointer is null? Answer for unique_ptr and for shared_ptr.

Answer

Answer. unique_ptr: never. shared_ptr: yes, if it was constructed with a deleter, even from nullptr; no, if it is default-constructed or constructed from a plain nullptr without a deleter.

Why. ~unique_ptr is specified as "if get() != nullptr, calls get_deleter()(get())". shared_ptr(nullptr_t, D) is specified to own p (a null pointer): a control block is allocated, use_count() is 1, and when it drops to zero the deleter runs with nullptr. So a shared_ptr<FILE>(fopen(...), fclose) whose fopen failed calls fclose(nullptr), which is undefined. Guard inside the deleter. People usually assume the two smart pointers agree.

TrickyCan you store a shared_ptr<int> with a lambda deleter and one with default_delete in the same vector? What about unique_ptr?

Answer

Answer. Yes for shared_ptr: both are shared_ptr<int>. No for unique_ptr: unique_ptr<int> and unique_ptr<int, Lambda> are different types.

Why. shared_ptr's deleter is type-erased into the control block; the pointer object only holds a pointer to a polymorphic control block base. unique_ptr carries the deleter as a template parameter so it can store it inline and call it without indirection. To mix deleters in a unique_ptr container you would need a type-erased deleter such as std::function<void(int*)>, at a cost of 32 extra bytes per element and a possible allocation.

ConceptWhy is there no make_shared or make_unique overload that takes a deleter? What do you use instead?

Answer

Answer. A make function allocates the object itself, so it already knows how to free it; a deleter only makes sense for memory or a resource that someone else acquired. Use the constructor (unique_ptr<T, D>(p, d) or shared_ptr<T>(p, d)) when you acquired the resource, and std::allocate_shared when you want to control where make_shared's single block comes from.

Why. make_shared places the object inside the control block allocation and destroys it with a direct destructor call. A user deleter receiving that pointer could not legally free it, since it is not the start of any allocation. The allocator is the right customisation point: it controls both allocation and deallocation of the combined block. Saying "it was just forgotten" is the common wrong answer.

TrickyCan unique_ptr manage a POSIX file descriptor? What goes wrong with unique_ptr<int, FdCloser> written naively?

Answer

Answer. Yes, but only if the deleter defines a nested pointer type that holds the fd and satisfies NullablePointer, with -1 as its null state. Written naively, unique_ptr<int, FdCloser> stores an int*, so you would have to heap-allocate the int or cast the fd to a pointer.

Why. unique_ptr stores D::pointer if it exists, otherwise T*. Casting an fd to int* (reinterpret_cast<int*>(fd)) breaks for fd 0, which is a valid descriptor (stdin) but would compare equal to nullptr, so it would never be closed, while an invalid fd -1 would be "non-null" and passed to close. A small dedicated UniqueFd class is often clearer; the coding section shows both.

ConceptWhat happens if a deleter throws an exception?

Answer

Answer. The standard requires that the deleter call does not throw, so it is a contract violation. In practice the exception leaves ~unique_ptr (or the shared_ptr release path), which is noexcept, and std::terminate is called.

Why. Destructors are implicitly noexcept(true) since C++11 unless a member or base has a throwing destructor, and unique_ptr's destructor is not one of those. Even outside a destructor, a throwing release would make reset unable to give the strong exception guarantee. Mark deleters noexcept, swallow or log errors inside, and close explicitly on the success path if the error must be observed.

Trickyu is a std::unique_ptr<int, Counter&> that refers to an external counting deleter. Which deleter does std::shared_ptr<int> s(std::move(u)); store, and what follows from that? Can you go the other way, from a shared_ptr to a unique_ptr?

Answer

Answer. For a reference deleter type, the converting constructor stores std::ref(u.get_deleter()), a std::reference_wrapper<Counter>. It does not store a copy of the Counter. So std::get_deleter<Counter>(s) is null, and the external Counter must outlive every copy of s, including copies on other threads. For a non-reference D, the deleter is moved into the control block. The other direction does not exist: shared_ptr has no release().

Why. [util.smartptr.shared.const] specifies the unique_ptr<Y, D>&& constructor as shared_ptr(r.release(), std::ref(r.get_deleter())) when D is a reference, because a reference cannot be stored in a control block. A shared_ptr cannot give up ownership, because other owners or weak_ptrs may exist and the deleter belongs to the control block. Even with use_count() == 1, there is no way to take the object out. The usual wrong answers are "it copies the Counter" and "call release() when you are the last owner". If you may need unique ownership later, keep the unique_ptr and convert as late as possible.

#include <cstdio>
#include <functional>
#include <memory>

struct Counter {
    int n = 0;
    void operator()(int* p) noexcept { ++n; delete p; }
};

int main() {
    Counter c;
    {
        std::unique_ptr<int, Counter&> u(new int(1), c);
        std::shared_ptr<int> s(std::move(u));
        std::printf("reference_wrapper stored: %d, Counter stored: %d\n",
                    std::get_deleter<std::reference_wrapper<Counter>>(s) != nullptr,
                    std::get_deleter<Counter>(s) != nullptr);
    }
    std::printf("external counter: %d\n", c.n);
}
reference_wrapper stored: 1, Counter stored: 0
external counter: 1

ConceptHow does shared_ptr<T> store a deleter of any type without the deleter becoming part of its own type? Sketch the mechanism.

Answer

Answer. It uses type erasure. The shared_ptr holds a pointer to a non-template control block base class that has virtual functions. The constructor template shared_ptr(Y* p, D d) knows Y and D, so it allocates a derived class template instantiated for exactly those types. That derived class stores p and d and overrides the virtual "dispose" function to call d(p).

struct ControlBase {
    std::atomic<long> strong{1}, weak{1};
    virtual void dispose() noexcept = 0;   // destroy the object: strong count hit 0
    virtual void destroy() noexcept = 0;   // free this block: weak count hit 0
    virtual ~ControlBase() = default;
};

template <class Y, class D>
struct ControlWithDeleter final : ControlBase {
    Y* p;
    D d;
    ControlWithDeleter(Y* p, D d) : p(p), d(std::move(d)) {}
    void dispose() noexcept override { d(p); }
    void destroy() noexcept override { delete this; }
};
// shared_ptr<T> = { T* ptr; ControlBase* ctrl; }: the same layout for every D

Why. The type information is captured once, at construction, inside a class the shared_ptr only sees through its base. That is why any deleter fits the same shared_ptr<T>, why shared_ptr<void>(new Foo) still runs ~Foo (it stores a Foo*), and why std::get_deleter must check the type at run time. The costs are a virtual call when the count reaches zero and a separate allocation for the block (unless you use make_shared). A common wrong answer is "it stores a std::function". That would also work, but real implementations use a hand-written virtual base, with no extra allocation for the callable.

ConceptWhat does std::get_deleter do, and why is it a free function template rather than a member like unique_ptr::get_deleter?

Answer

Answer. std::get_deleter<D>(sp) returns a D* to the deleter stored in sp's control block if its type is exactly D, and nullptr otherwise.

Why. The deleter type is erased, so shared_ptr<T> has no static knowledge of it; the caller must guess the type and the control block checks it at run time (implementations compare typeid). unique_ptr::get_deleter() can be a member returning D& because D is in the type. Use cases: reading state from a stateful deleter (a pool id, a length), or detecting that a pointer came from a particular factory.

ConceptWrite the custom deleter you would use in production to own a FILE* with unique_ptr, and justify each choice. What is wrong with std::unique_ptr<FILE> f(fopen("a.txt", "r"));?

Answer

Answer.

struct FileCloser {
    void operator()(std::FILE* f) const noexcept { std::fclose(f); }
};
using File = std::unique_ptr<std::FILE, FileCloser>;

File f(std::fopen("a.txt", "r"));   // one argument: FileCloser is default constructible
if (!f) { /* handle the error */ }

The version with the default deleter calls delete on a pointer that came from fopen. That is undefined behaviour: the stream is never flushed or closed, and the C library's memory goes back to the wrong allocator.

Why. An empty function object costs no space and lets the one-argument constructor work, and its call can be inlined. It is noexcept because it runs from a destructor. It needs no null check, because unique_ptr never calls the deleter on null (a shared_ptr deleter would need if (f)). It calls fclose rather than taking &fclose, which avoids the 8-byte function pointer, the extra constructor argument and the rule that standard library functions are not addressable. It ignores the return value. When you wrote data and need to know that the close succeeded, call std::fclose(f.release()) and check it. The common wrong answers are unique_ptr<FILE, decltype(&fclose)> f(fopen(...)), which does not compile, and the default deleter.

TrickyWhen is the deleter object of a shared_ptr destroyed: when the strong count reaches zero, or when the control block is freed?

Answer

Answer. The standard leaves it to the implementation. libstdc++ destroys it with the control block (when the weak count also reaches zero); libc++ destroys it immediately after calling it.

Why. [util.smartptr.getdeleter] only says the returned pointer stays valid while a shared_ptr or weak_ptr shares ownership of the deleter, and that it is unspecified whether it remains valid longer. Programs that depend on the deleter's destructor running at a specific time (for example a deleter that captures a shared_ptr to a pool and you expect the pool to die with the last strong reference) behave differently across libraries when weak_ptrs are alive. The program below shows it.

#include <cstdio>
#include <memory>

struct D {
    D() = default;
    D(const D&) = default;
    D(D&&) noexcept = default;
    void operator()(int* p) const noexcept { std::puts("deleter called"); delete p; }
    ~D() { if (live) std::puts("deleter destroyed"); }
    bool live = false;
};

int main() {
    std::weak_ptr<int> w;
    {
        std::shared_ptr<int> s(new int(1), D{});
        std::get_deleter<D>(s)->live = true;  // mark the stored copy only
        w = s;
    }
    std::puts("only a weak_ptr left");
    w.reset();
    std::puts("end");
}

libstdc++ prints "deleter called", "only a weak_ptr left", "deleter destroyed", "end". libc++ prints "deleter called", "deleter destroyed", "only a weak_ptr left", "end". The output is not compared because it is implementation-specific.

ConceptWhy should a deleter for fclose or close ignore the return value, and how do you handle close errors that matter?

Answer

Answer. The deleter runs from a destructor, often during stack unwinding, so it cannot throw and has no caller to return an error to. When the error matters (a file you wrote), call fclose(f.release()) explicitly on the success path and check it; the deleter then only covers failure paths, where the data is being abandoned anyway.

Why. fclose flushes the stdio buffer, so it is where "disk full" is first reported. Silently ignoring it in a destructor can lose data. Throwing from the deleter is worse: it calls std::terminate. For close on Linux, do not retry on EINTR: the fd is gone regardless and a retry can close someone else's new descriptor.

ConceptWhat problem does C++23 std::out_ptr solve?

Answer

Answer. It lets you pass a smart pointer directly to a C function that returns a handle through a T** out parameter. The temporary adaptor converts to T**, and when the full expression ends it calls reset on the smart pointer with whatever the function wrote.

Why. The manual pattern (raw temporary, call, then reset) has a window where the handle is owned by nobody, and it is easy to forget the reset on one path. out_ptr also resets the smart pointer before the call, so the old handle is released first. For shared_ptr you must pass the deleter: std::out_ptr(sp, Deleter{}). std::inout_ptr covers APIs that consume the old handle and write a new one.

Predict the output and find the bug

Drills

OutputWhat does this print on a 64-bit target with GCC or Clang?

#include <cstdio>
#include <memory>

struct Empty { void operator()(int* p) const noexcept { delete p; } };
struct WithId {
    int id = 0;
    void operator()(int* p) const noexcept { delete p; }
};

template <class T, class D>
struct Naive { T* p; D d; };

template <class T, class D>
struct Compressed { T* p; [[no_unique_address]] D d; };

int main() {
    std::printf("%zu %zu\n", sizeof(Naive<int, Empty>), sizeof(Compressed<int, Empty>));
    std::printf("%zu %zu\n", sizeof(std::unique_ptr<int, Empty>), sizeof(std::unique_ptr<int, WithId>));
    std::printf("%zu\n", sizeof(std::unique_ptr<int, Empty&>));
}
Answer
16 8
8 16
16

Answer. The naive pair is 16 because the empty member takes 1 byte and is padded to pointer alignment; [[no_unique_address]] lets the empty member overlap, giving 8. unique_ptr with an empty deleter is 8 and with an int member is 16. A reference deleter type Empty& stores a reference, which is a pointer under the hood, so it is 16 even though Empty is empty.

Why. EBO and [[no_unique_address]] only apply to empty class types. A reference is not an object of class type, it is stored as an address. People often answer 8 for the last line because "the deleter is empty". (MSVC ignores the standard [[no_unique_address]], so the second number would be 16 there.)

Find the bugWhy does this not compile, and what are two fixes?

#include <cstdio>
#include <memory>

static void close_file(std::FILE* f) { std::fclose(f); }

int main() {
    std::unique_ptr<std::FILE, decltype(&close_file)> f(std::tmpfile());
    std::fputs("x", f.get());
}
Answer

Answer. The one-argument constructor of unique_ptr<T, D> is constrained to deleters that are default constructible and are not pointer types. D here is void (*)(FILE*), so overload resolution finds no viable constructor. Fix 1: pass the function, f(std::tmpfile(), &close_file). Fix 2 (better): use a function-object type, struct CloseFile { void operator()(FILE* f) const noexcept { fclose(f); } };, which also halves the size.

Why. A value-initialised function pointer is null; if the constructor were allowed, the destructor would call a null function pointer. The standard rules it out at compile time ([unique.ptr.single.ctor]: "Constraints: is_pointer_v<deleter_type> is false and is_default_constructible_v<deleter_type> is true").

OutputWhat does this print?

#include <cstdio>
#include <memory>

struct Base { ~Base() { std::puts("~Base"); } };         // not virtual
struct Derived : Base { ~Derived() { std::puts("~Derived"); } };

int main() {
    {
        std::shared_ptr<void> v(new Derived);
    }
    std::puts("--");
    {
        std::shared_ptr<Base> b(new Derived);
    }
    std::puts("--");
    {
        std::shared_ptr<Base> c(static_cast<Base*>(new Derived));
        std::shared_ptr<Base> keep = c;
    }
}
Answer
~Derived
~Base
--
~Derived
~Base
--
~Base

Answer. The first two blocks run the full Derived destructor; the third runs only ~Base, which is undefined behaviour (deleting a Derived through a Base* without a virtual destructor).

Why. The shared_ptr(Y* p) constructor deduces Y from the argument and builds a control block that deletes a Y*. In the first two blocks Y = Derived. In the third, the cast happened before the constructor saw the pointer, so Y = Base. Copying (keep) never changes the stored deleter. This is a favourite because candidates either say "always ~Base without a virtual destructor" or "shared_ptr always gets it right". (ASan does not flag the third block because Derived adds no members, so the sizes match; it is still UB.)

Find the bugA connection pool counts open and closed handles. At the end it reports one leak. Where is it?

#include <cstdio>
#include <memory>

static int opened = 0, closed = 0;

struct Conn { int id; };
struct ConnCloser { void operator()(Conn* c) const noexcept { ++closed; delete c; } };
using ConnPtr = std::unique_ptr<Conn, ConnCloser>;

static ConnPtr connect(int id) { ++opened; return ConnPtr(new Conn{id}); }

int main() {
    ConnPtr a = connect(1);
    ConnPtr b = connect(2);
    ConnPtr c = connect(3);
    a.reset();            // done with 1
    b.release();          // done with 2
    c = connect(4);       // replace 3
    std::printf("opened %d closed-before-exit %d\n", opened, closed);
    c.reset();
    std::printf("opened %d closed %d\n", opened, closed);
}
Answer
opened 4 closed-before-exit 2
opened 4 closed 3

Answer. b.release() drops ownership without calling the deleter, and the returned pointer is discarded, so connection 2 leaks. It should be b.reset(). The move assignment c = connect(4) correctly closes connection 3.

Why. release means "I take the resource back", and clang-tidy's bugprone-unused-return-value check flags a discarded release() by default, because discarding its result is almost always a leak. LeakSanitizer would also catch this on Linux, but the counters prove it on any platform.

OutputIn what order do the lines appear?

#include <cstdio>
#include <memory>

struct Noisy {
    int id;
    ~Noisy() { std::printf("~Noisy %d\n", id); }
};

struct Tell {
    const std::unique_ptr<Noisy, Tell>* owner = nullptr;
    void operator()(Noisy* p) const noexcept {
        std::printf("deleting %d, owner now holds %d\n", p->id, (*owner) ? (*owner)->id : -1);
        delete p;
    }
};

int main() {
    std::unique_ptr<Noisy, Tell> u(new Noisy{1});
    u.get_deleter().owner = &u;
    u.reset(new Noisy{2});
    u.reset();
    std::puts("end");
}
Answer
deleting 1, owner now holds 2
~Noisy 1
deleting 2, owner now holds -1
~Noisy 2
end

Answer. In reset(q) the new pointer is stored first and the old one is deleted afterwards, so the deleter already sees the new value. reset() stores null first, then deletes.

Why. [unique.ptr.single.modifiers] specifies: assign p to the stored pointer, then if the old value was not null call get_deleter()(old). That order makes reset safe if the deleted object's destructor reaches back into the owner (for example a node that resets its parent's child pointer). Many candidates assume "delete old, then assign new".

Find the bugThis compiles cleanly. What happens at run time?

#include <cstdio>
#include <memory>

static int sum(const std::unique_ptr<int[]>& a, int n) {
    int s = 0;
    for (int i = 0; i < n; ++i) s += a[i];
    return s;
}

int main() {
    int scratch[4] = {1, 2, 3, 4};
    std::unique_ptr<int[]> view(scratch);   // "just to pass it to sum"
    std::printf("%d\n", sum(view, 4));
}
Answer

Answer. When view is destroyed, default_delete<int[]> calls delete[] on a stack array. That is undefined behaviour; ASan reports "attempting free on address which was not malloc()-ed", and without it the program usually crashes in the allocator.

Why. A smart pointer with the default deleter asserts ownership of heap memory from new[]. To pass a non-owning view, take a std::span<const int> (or const int* and a size). A no-op deleter would silence the crash but keep the misleading type. GCC may also warn with -Wfree-nonheap-object.

Find the bugA pool hands out objects with a deleter that returns them to the pool. Sometimes the program crashes on exit. Why?

#include <cstdio>
#include <memory>
#include <vector>

struct Pool {
    std::vector<int*> free_list;
    ~Pool() { for (int* p : free_list) delete p; }
    int* acquire() {
        if (free_list.empty()) return new int(0);
        int* p = free_list.back();
        free_list.pop_back();
        return p;
    }
    void release(int* p) { free_list.push_back(p); }
};

struct ToPool {
    Pool* pool;
    void operator()(int* p) const noexcept { pool->release(p); }
};

struct Service {
    std::unique_ptr<int, ToPool> current;   // declared first
    Pool pool;                              // declared second
    Service() : current(nullptr, ToPool{&pool}) {
        for (int i = 0; i < 4; ++i) pool.release(new int(i));
        current.reset(pool.acquire());
    }
};

int main() {
    Service s;
    std::printf("value %d\n", *s.current);
}
Answer

Answer. Members are destroyed in reverse order of declaration, so pool is destroyed before current. Then current's deleter calls release on a destroyed pool, and push_back writes into the vector's freed buffer: heap-use-after-free, which ASan reports.

Why. A stateful deleter that points to its pool creates a lifetime dependency the type system does not check. Fix: declare Pool pool; before every member that holds pointers from it, or let the deleter hold a shared_ptr<Pool> (or a weak_ptr and fall back to delete when the pool is gone). Using pool in current's member initialiser is also suspicious, since pool is not yet constructed there; taking its address is fine, calling it would not be. Compilers warn about that with -Wuninitialized only in some cases.

OutputTwo unique_ptrs share one counting deleter through a reference type. What is printed?

#include <cstdio>
#include <memory>

struct Counter {
    int freed = 0;
    void operator()(int* p) noexcept { ++freed; delete p; }
};

int main() {
    Counter shared_counter;
    {
        std::unique_ptr<int, Counter&> a(new int(1), shared_counter);
        std::unique_ptr<int, Counter&> b(new int(2), shared_counter);
        std::unique_ptr<int, Counter> c(new int(3), shared_counter);   // copies the deleter
    }
    std::printf("freed through the reference: %d\n", shared_counter.freed);
}
Answer
freed through the reference: 2

Answer. 2. a and b store a Counter& and increment the same object. c stores its own copy of Counter, so its increment goes to the copy, which dies with c.

Why. With D = Counter& the deleter is a reference to an external object, so state is shared and the external object must outlive the pointers. With D = Counter the constructor copies the argument. Note that operator() here is non-const, which works because unique_ptr calls the deleter through a non-const reference. The common wrong answer is 3.

Find the bugThis shared_ptr wrapper for a C stream crashes on some platforms when the file is missing. Why, and what is the one-line fix?

#include <cstdio>
#include <memory>

static int closes = 0;
static int checked_close(std::FILE* f) {
    ++closes;
    std::printf("fclose called with %s\n", f ? "a stream" : "nullptr");
    return f ? std::fclose(f) : EOF;   // stand-in: real fclose(nullptr) is undefined
}

int main() {
    std::shared_ptr<std::FILE> log(std::fopen("/no/such/dir/log.txt", "r"), checked_close);
    if (!log) std::puts("open failed");
}
Answer

Answer. When fopen fails, the shared_ptr still owns the null pointer through a control block, and its deleter is called with nullptr. With the real fclose as the deleter that is undefined behaviour (glibc crashes). Fix: guard in the deleter, [](FILE* f) { if (f) std::fclose(f); }, or construct the shared_ptr only after checking the result. The program prints "open failed" and then "fclose called with nullptr".

Why. shared_ptr(p, d) takes ownership of p whatever its value; only unique_ptr skips null. if (!log) is true because operator bool checks the stored pointer, yet use_count() is 1. It is marked data-mode="ub" because the path being demonstrated is undefined with the real fclose, even though this stand-in is safe.

Find the bugWhy does this PIMPL class fail to compile in main? (Imagine Engine in a header and Engine::Impl defined in another file that main.cpp never sees.)

#include <memory>

class Engine {
public:
    Engine() = default;
    ~Engine() = default;        // "I declared the destructor"
private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

int main() {
    Engine e;
}
Answer

Answer. ~Engine() = default; inside the class is a definition, so the destructor is still defined in the header, where Impl is incomplete. Destroying impl_ instantiates default_delete<Impl>::operator(), whose static assertion fails. Fix: write ~Engine(); in the header and Engine::~Engine() = default; in the .cpp after struct Engine::Impl { ... };.

Why. Defaulting on first declaration makes the function inline and defined at that point. Only an out-of-line definition moves the instantiation to the translation unit that has the complete type. GCC reports "invalid application of 'sizeof' to incomplete type"; Clang reports the same in default_delete.

Find the bugTwo smart pointers manage one buffer from malloc. What goes wrong?

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>

struct Free { void operator()(void* p) const noexcept { std::free(p); } };
using Buf = std::unique_ptr<char[], Free>;

static Buf duplicate(const char* s) {
    char* raw = static_cast<char*>(std::malloc(std::strlen(s) + 1));
    std::strcpy(raw, s);
    return Buf(raw);
}

int main() {
    Buf a = duplicate("hello");
    Buf b(a.get());          // "share" it
    std::printf("%s %s\n", a.get(), b.get());
}
Answer

Answer. b(a.get()) creates a second owner of the same pointer, so free runs twice: a double free, reported by ASan as "attempting double-free".

Why. Constructing a smart pointer from a raw pointer always means "take ownership". The deleter does not change that rule. To share, use shared_ptr<char[]>(a.release(), Free{}) and copy the shared_ptr; to lend, pass a.get() as a plain const char*. The same bug with shared_ptr (two control blocks for one pointer) is in Ch 11.

Write the code

Coding

CodeMake std::unique_ptr own a POSIX file descriptor. Write a deleter FdCloser with a nested pointer type so that using UniqueFd = std::unique_ptr<int, FdCloser>; stores an int fd (not an int*), treats -1 as null, treats 0 as a valid descriptor, and closes exactly once. Test it with pipe(): write through one end, read from the other, move a handle, and count the closes.

Answer

Approach. unique_ptr stores D::pointer when that type exists. Make it a tiny struct holding the fd, with a default constructor and a nullptr_t constructor that both give -1, an explicit operator bool that tests fd >= 0, and defaulted equality. That satisfies NullablePointer, so unique_ptr skips the deleter for -1 and moves set the source to -1.

#include <cstddef>
#include <cstdio>
#include <memory>
#include <unistd.h>

static int close_calls = 0;

struct FdCloser {
    struct pointer {
        int fd = -1;
        pointer() = default;
        pointer(std::nullptr_t) {}
        explicit pointer(int f) : fd(f) {}
        explicit operator bool() const { return fd >= 0; }
        friend bool operator==(pointer, pointer) = default;
    };
    void operator()(pointer p) const noexcept {
        ++close_calls;
        ::close(p.fd);    // once, result ignored: no retry on EINTR
    }
};
using UniqueFd = std::unique_ptr<int, FdCloser>;

static int fd_of(const UniqueFd& u) { return u.get().fd; }

int main() {
    int fds[2];
    if (::pipe(fds) != 0) return 1;
    UniqueFd rd(FdCloser::pointer{fds[0]}), wr(FdCloser::pointer{fds[1]});

    const char msg[] = "ping";
    if (::write(fd_of(wr), msg, sizeof msg) != static_cast<ssize_t>(sizeof msg)) return 1;
    char buf[8] = {};
    if (::read(fd_of(rd), buf, sizeof buf) <= 0) return 1;
    std::printf("read: %s\n", buf);

    UniqueFd moved = std::move(wr);
    std::printf("after move: wr %s, moved %s\n", wr ? "valid" : "null", moved ? "valid" : "null");

    UniqueFd zero(FdCloser::pointer{0});              // fd 0 is valid: must not count as null
    std::printf("fd 0 is %s\n", zero ? "valid" : "null");
    (void)zero.release();                               // do not close our stdin

    UniqueFd none;                                      // null: deleter never called
    moved.reset();
    rd.reset();
    std::printf("close calls: %d\n", close_calls);
    std::printf("sizeof(UniqueFd) = %zu\n", sizeof(UniqueFd));
}
read: ping
after move: wr null, moved valid
fd 0 is valid
close calls: 2
sizeof(UniqueFd) = 4

Complexity and pitfalls. Every operation is O(1) and the handle is the size of an int. Pitfalls: using int* or casting the fd to a pointer (fd 0 becomes "null"); testing fd != 0 instead of fd >= 0; retrying close on EINTR; forgetting the nullptr_t constructor, which unique_ptr's default constructor and reset() need. release() returns the pointer struct, so give the class a helper if callers need the raw fd.

Estimated rating: ~1600 · knowing the D::pointer hook and NullablePointer requirements is the whole problem

CodeImplement ScopeGuard: auto g = make_scope_exit([&] { ... }); runs the callable when g leaves scope, unless g.dismiss() was called. It must be movable (the moved-from guard does nothing), not copyable, and must not add overhead for a captureless or reference-capturing lambda beyond the lambda and one bool. Show the classic use: roll back a two-step update if the second step throws.

Answer

Approach. A class template over the callable type F, storing F and an active flag. The destructor calls f_() if active. The move constructor steals the callable and dismisses the source. A deleter-based version (unique_ptr<void, Lambda> with a dummy pointer) also works, but it abuses the pointer and cannot be dismissed without release; the dedicated class is clearer. This is the shape of std::experimental::scope_exit from the Library Fundamentals TS v3.

#include <cstdio>
#include <stdexcept>
#include <utility>
#include <vector>

template <class F>
class ScopeGuard {
public:
    explicit ScopeGuard(F f) noexcept(std::is_nothrow_move_constructible_v<F>) : f_(std::move(f)) {}
    ScopeGuard(ScopeGuard&& o) noexcept(std::is_nothrow_move_constructible_v<F>)
        : f_(std::move(o.f_)), active_(std::exchange(o.active_, false)) {}
    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;
    ScopeGuard& operator=(ScopeGuard&&) = delete;
    ~ScopeGuard() { if (active_) f_(); }      // f_ must not throw: we may be unwinding
    void dismiss() noexcept { active_ = false; }
private:
    F f_;
    bool active_ = true;
};

template <class F>
ScopeGuard<F> make_scope_exit(F f) { return ScopeGuard<F>(std::move(f)); }

static void step_two(int value) {
    if (value < 0) throw std::invalid_argument("negative");
}

static void add_both(std::vector<int>& a, std::vector<int>& b, int value) {
    a.push_back(value);
    auto undo = make_scope_exit([&] { a.pop_back(); std::puts("rolled back"); });
    step_two(value);
    b.push_back(value);
    undo.dismiss();               // commit
}

int main() {
    std::vector<int> a, b;
    add_both(a, b, 1);
    try { add_both(a, b, -5); } catch (const std::exception& e) { std::printf("caught %s\n", e.what()); }
    std::printf("a=%zu b=%zu\n", a.size(), b.size());

    int n = 0;
    {
        auto g1 = make_scope_exit([&n] { ++n; });
        auto g2 = std::move(g1);  // only g2 fires
    }
    std::printf("fired %d time(s), sizeof %zu\n", n, sizeof(ScopeGuard<void (*)()>));
}
rolled back
caught negative
a=1 b=1
fired 1 time(s), sizeof 16

Complexity and pitfalls. Zero allocation; the size is the closure plus a bool plus padding (a function pointer callable gives 16). Pitfalls: a copyable guard runs the action twice; forgetting to dismiss the moved-from guard; throwing from the action during unwinding calls std::terminate; writing make_scope_exit(...) without naming the result destroys the temporary immediately (mark the class [[nodiscard]] in production). Guaranteed copy elision in C++17 makes returning the guard from make_scope_exit free.

Estimated rating: ~1400 · small class, but move semantics and unwinding rules must be right

CodeWrite a fixed-capacity ObjectPool<T, N> whose acquire(args...) returns std::unique_ptr<T, Returner>. When the pointer dies, the object is destroyed and its slot goes back to the pool. acquire returns a null pointer when the pool is full. The pool must not be moved while handles exist (make it non-movable). Report how many slots are free.

Answer

Approach. The pool owns an aligned byte array of N slots and a free list of slot indices. acquire pops an index and constructs T there with placement new (Ch 14). The deleter holds a Pool* (stateful, so the handle is 16 bytes) and calls pool->give_back(p), which runs ~T and pushes the index. If the constructor throws, the slot must be returned before propagating.

#include <cstddef>
#include <cstdio>
#include <memory>
#include <new>
#include <string>
#include <utility>

template <class T, std::size_t N>
class ObjectPool {
public:
    struct Returner {
        ObjectPool* pool = nullptr;
        void operator()(T* p) const noexcept { pool->give_back(p); }
    };
    using Handle = std::unique_ptr<T, Returner>;

    ObjectPool() { for (std::size_t i = 0; i < N; ++i) free_[i] = N - 1 - i; }
    ObjectPool(const ObjectPool&) = delete;
    ObjectPool& operator=(const ObjectPool&) = delete;

    template <class... Args>
    Handle acquire(Args&&... args) {
        if (nfree_ == 0) return Handle(nullptr, Returner{this});
        std::size_t idx = free_[--nfree_];
        T* p;
        try {
            p = ::new (slot(idx)) T(std::forward<Args>(args)...);
        } catch (...) {
            free_[nfree_++] = idx;
            throw;
        }
        return Handle(p, Returner{this});
    }
    std::size_t available() const { return nfree_; }

private:
    void* slot(std::size_t i) { return storage_ + i * sizeof(T); }
    void give_back(T* p) noexcept {
        std::size_t idx = static_cast<std::size_t>(reinterpret_cast<std::byte*>(p) - storage_) / sizeof(T);
        p->~T();
        free_[nfree_++] = idx;
    }
    alignas(T) std::byte storage_[N * sizeof(T)];
    std::size_t free_[N];
    std::size_t nfree_ = N;
};

int main() {
    ObjectPool<std::string, 2> pool;
    {
        auto a = pool.acquire("first, long enough to need a heap buffer");
        auto b = pool.acquire(3, 'x');
        auto c = pool.acquire("no room");
        std::printf("%s | %s | c is %s | free %zu\n", a->substr(0, 5).c_str(), b->c_str(),
                    c ? "set" : "null", pool.available());
        a.reset();
        std::printf("after reset free %zu\n", pool.available());
        auto d = pool.acquire("reused");
        std::printf("%s free %zu\n", d->c_str(), pool.available());
    }
    std::printf("all returned, free %zu, handle size %zu\n", pool.available(),
                sizeof(ObjectPool<int, 1>::Handle));
}
first | xxx | c is null | free 0
after reset free 1
reused free 0
all returned, free 2, handle size 16

Complexity and pitfalls. O(1) acquire and release, no heap allocation by the pool. Pitfalls: the pool must outlive every handle (declare it first; see the reading drill); the slot must be returned if the constructor throws; the storage must be aligned for T; destroying T in the deleter, not only marking the slot free, or std::string buffers leak. If there is one global pool per type, a stateless deleter brings the handle back to 8 bytes.

Estimated rating: ~1800 · combines a stateful deleter, placement new, alignment and exception safety

CodeBuild a Registry of named resources: open(name) returns a std::shared_ptr<Resource>; opening a name that is already open returns the same object; when the last user drops it, a custom deleter closes it and removes the entry. The registry must not keep resources alive by itself.

Answer

Approach. Store weak_ptr<Resource> values in a map, so the registry observes without owning. open tries lock(); on a miss it creates the resource with shared_ptr(new Resource, Closer{this, name}). The deleter erases the entry and deletes the resource. Because the deleter runs when the strong count is zero, the map entry is already expired and safe to erase; check that the entry is still the expired one, in case a new resource with the same name was opened in between. A single-threaded version is shown; with threads, guard the map with a mutex taken in both open and the deleter.

#include <cstdio>
#include <map>
#include <memory>
#include <string>

struct Resource {
    std::string name;
    explicit Resource(std::string n) : name(std::move(n)) { std::printf("open %s\n", name.c_str()); }
    ~Resource() { std::printf("close %s\n", name.c_str()); }
};

class Registry {
public:
    std::shared_ptr<Resource> open(const std::string& name) {
        if (auto it = live_.find(name); it != live_.end())
            if (auto sp = it->second.lock()) return sp;
        std::shared_ptr<Resource> sp(new Resource(name), Closer{this, name});
        live_[name] = sp;
        return sp;
    }
    std::size_t size() const { return live_.size(); }

private:
    struct Closer {
        Registry* reg;
        std::string name;
        void operator()(Resource* r) const noexcept {
            auto it = reg->live_.find(name);
            if (it != reg->live_.end() && it->second.expired()) reg->live_.erase(it);
            delete r;
        }
    };
    std::map<std::string, std::weak_ptr<Resource>> live_;
};

int main() {
    Registry reg;
    auto a = reg.open("db");
    auto b = reg.open("db");
    auto c = reg.open("cache");
    std::printf("same object: %d, entries %zu, db users %ld\n", a == b, reg.size(), a.use_count());
    a.reset();
    b.reset();
    std::printf("entries %zu\n", reg.size());
    auto d = reg.open("db");
    std::printf("entries %zu\n", reg.size());
}
open db
open cache
same object: 1, entries 2, db users 2
close db
entries 1
open db
entries 2
close db
close cache

Complexity and pitfalls. O(log n) per open and close. Pitfalls: storing shared_ptr in the map (nothing ever closes); Closer holding a raw Registry*, so the registry must outlive all handles (here reg is declared first, so it is destroyed last); erasing an entry that already belongs to a newer resource; locking order with threads (the deleter can run on any thread that drops the last reference, so it must take the same mutex, and open must not hold that mutex while a shared_ptr it obtained might be destroyed). Destruction order at the end: d is declared last and destroyed first, then c.

Estimated rating: ~1900 · weak_ptr cache plus a deleter that reaches back into its owner

CodeWrite your own Unique<T, D = std::default_delete<T>> with move-only semantics, get, release, reset, get_deleter and operator->. It must be 8 bytes with an empty deleter, use the store-then-delete order in reset, never call the deleter on null, and prove its size with static_assert.

Answer

Approach. Store T* p_ and [[no_unique_address]] D d_. Moves use std::exchange on the pointer and move the deleter. reset(q) exchanges the stored pointer with q and then deletes the old value if it was not null. Move assignment is reset(other.release()) plus moving the deleter.

#include <cstdio>
#include <memory>
#include <utility>

template <class T, class D = std::default_delete<T>>
class Unique {
public:
    Unique() noexcept = default;
    explicit Unique(T* p) noexcept : p_(p) {}
    Unique(T* p, D d) noexcept : p_(p), d_(std::move(d)) {}
    Unique(Unique&& o) noexcept : p_(std::exchange(o.p_, nullptr)), d_(std::move(o.d_)) {}
    Unique& operator=(Unique&& o) noexcept {
        if (this != &o) {
            reset(o.release());
            d_ = std::move(o.d_);
        }
        return *this;
    }
    Unique(const Unique&) = delete;
    Unique& operator=(const Unique&) = delete;
    ~Unique() { if (p_) d_(p_); }

    T* get() const noexcept { return p_; }
    T* operator->() const noexcept { return p_; }
    T& operator*() const noexcept { return *p_; }
    explicit operator bool() const noexcept { return p_ != nullptr; }
    D& get_deleter() noexcept { return d_; }
    [[nodiscard]] T* release() noexcept { return std::exchange(p_, nullptr); }
    void reset(T* q = nullptr) noexcept {
        T* old = std::exchange(p_, q);   // store first
        if (old) d_(old);                // then delete
    }

private:
    T* p_ = nullptr;
    [[no_unique_address]] D d_{};
};

struct Loud {
    int id;
    ~Loud() { std::printf("~Loud %d\n", id); }
};
struct CountingDelete {
    int* count;
    void operator()(Loud* p) const noexcept { ++*count; delete p; }
};

static_assert(sizeof(Unique<Loud>) == sizeof(Loud*));
static_assert(sizeof(Unique<Loud, CountingDelete>) == 2 * sizeof(void*));

int main() {
    Unique<Loud> a(new Loud{1});
    Unique<Loud> b = std::move(a);
    std::printf("a %s, b holds %d\n", a ? "set" : "null", b->id);
    b.reset(new Loud{2});

    int deletes = 0;
    {
        Unique<Loud, CountingDelete> c(new Loud{3}, CountingDelete{&deletes});
        Unique<Loud, CountingDelete> d(nullptr, CountingDelete{&deletes});
        d = std::move(c);
    }
    std::printf("counted deletes: %d\n", deletes);
}
a null, b holds 1
~Loud 1
~Loud 3
counted deletes: 1
~Loud 2

Complexity and pitfalls. All operations O(1). Pitfalls: self-move-assignment (guarded); deleting before storing in reset; calling the deleter on null in the destructor (the counter shows only one delete, for the object that existed); forgetting noexcept on moves: std::vector<Unique> still compiles and still moves (move_if_noexcept uses a throwing move when there is no copy constructor), but it loses the strong exception guarantee on reallocation. The real unique_ptr also supports converting moves from Unique<Derived>, array specialisation and D::pointer.

Estimated rating: ~1700 · standard interview implementation, the EBO detail and reset order separate good answers

CodeMap an anonymous memory region with mmap and own it with std::unique_ptr<std::byte[], Unmap>, where Unmap remembers the length. Handle MAP_FAILED correctly, write and read the region, and expose the size through get_deleter().

Answer

Approach. munmap needs the address and the length, so the deleter is stateful and stores the length. Call mmap first and check for MAP_FAILED (which is (void*)-1, not null) before constructing the smart pointer; on failure return an empty pointer. Using the array form gives operator[].

#include <cstddef>
#include <cstdio>
#include <cstring>
#include <memory>
#include <sys/mman.h>

static int unmaps = 0;

struct Unmap {
    std::size_t len = 0;
    void operator()(std::byte* p) const noexcept {
        ++unmaps;
        ::munmap(p, len);
    }
};
using Mapping = std::unique_ptr<std::byte[], Unmap>;

static Mapping map_anonymous(std::size_t len) {
    void* raw = ::mmap(nullptr, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
    if (raw == MAP_FAILED) return Mapping(nullptr, Unmap{0});
    return Mapping(static_cast<std::byte*>(raw), Unmap{len});
}

int main() {
    Mapping m = map_anonymous(1 << 16);
    if (!m) return 1;
    std::printf("mapped %zu bytes, zero-filled: %d\n", m.get_deleter().len, m[100] == std::byte{0});
    std::memcpy(m.get(), "mapped", 7);
    std::printf("%s\n", reinterpret_cast<const char*>(m.get()));
    m[65535] = std::byte{42};
    std::printf("last byte %d\n", static_cast<int>(m[65535]));

    Mapping huge = map_anonymous(static_cast<std::size_t>(1) << 62);   // fails: too big
    std::printf("huge %s\n", huge ? "mapped" : "failed");
    m.reset();
    std::printf("unmaps %d\n", unmaps);
}
mapped 65536 bytes, zero-filled: 1
mapped
last byte 42
huge failed
unmaps 1

Complexity and pitfalls. The mapping is lazy: pages are faulted in on first touch, so "allocating" 64 KiB costs almost nothing until written. Pitfalls: passing MAP_FAILED to the smart pointer (it is non-null, so munmap((void*)-1, len) would be called); losing the length (a stateless deleter cannot unmap); MAP_ANON vs MAP_ANONYMOUS spelling (Linux has both, macOS prefers MAP_ANON); the handle is 16 bytes because of the length.

Estimated rating: ~1500 · stateful deleter plus the MAP_FAILED sentinel trap

Cheat sheet

Theory
  • unique_ptr<T, D>: deleter in the type, stored inline. Empty D (function object, captureless lambda) costs 0 bytes through EBO; a function pointer or captured state costs 8 or more.
  • Prefer struct Closer { void operator()(T*) const noexcept; };. Function pointer deleters must be passed at every construction and cannot be default-constructed.
  • shared_ptr<T>: deleter type-erased into the control block, captured at construction with the real pointer type, so shared_ptr<void> and non-virtual bases still destroy correctly. Inspect with std::get_deleter<D>.
  • Null: unique_ptr never calls the deleter on null; shared_ptr built with a deleter calls it even for nullptr. Check sentinels like MAP_FAILED and -1 yourself.
  • No deleter overloads for make_unique/make_shared; use the constructor, or allocate_shared to control the single allocation.
  • Deleters must not throw (destructors are noexcept). Close explicitly with fclose(f.release()) when you need the error.
  • PIMPL with unique_ptr<Impl>: declare the destructor and moves in the header, define them = default in the .cpp. shared_ptr does not need this.
  • Stateful deleters (pool, mmap length) add a lifetime rule: whatever they point to must outlive every handle. C++23 std::out_ptr adapts smart pointers to T** out parameters.

Part E · Memory management · Chapter 13

Cyclic reference issues

Reference counting frees an object when nobody points to it, so two objects that point to each other are never freed. This chapter shows exactly how shared_ptr cycles form (including the hidden ones inside callbacks), how to prove a leak, and the standard ways to break a cycle: weak_ptr, raw back pointers and an ownership graph with no cycles. Interviewers ask about it because it separates people who use shared_ptr everywhere from people who design ownership.

TheoryDrillsCoding· about 3 hours· prerequisites: Ch 11 smart pointers, Ch 12 custom deleters

How a shared_ptr cycle leaks

Theory

Mental model. A shared_ptr answers one local question: "how many owners point at this object?". It never asks whether those owners are themselves reachable from the program. When object A owns B and B owns A, each count includes the other's pointer, so neither count can reach zero once the outside world lets go. The objects are unreachable, yet every count is 1. That is a leak, and their destructors never run, so any resource they hold (sockets, files, threads) leaks too.

Under the hood. Each make_shared creates a control block with a strong count (owners) and a weak count. Copying a shared_ptr increments the strong count atomically, and destroying one decrements it. The destructor of the managed object runs only when the strong count goes from 1 to 0 (Ch 11 has the full control block).

#include <cstdio>
#include <memory>
#include <string>

static int alive = 0;

struct Person {
    std::string name;
    std::shared_ptr<Person> partner;
    explicit Person(std::string n) : name(std::move(n)) { ++alive; std::printf("hello %s\n", name.c_str()); }
    ~Person() { --alive; std::printf("bye %s\n", name.c_str()); }
};

int main() {
    {
        auto alice = std::make_shared<Person>("alice");
        auto bob = std::make_shared<Person>("bob");
        alice->partner = bob;
        bob->partner = alice;
        std::printf("counts: alice %ld, bob %ld\n", alice.use_count(), bob.use_count());
    }
    std::printf("after scope: %d alive\n", alive);
}
hello alice
hello bob
counts: alice 2, bob 2
after scope: 2 alive

No "bye" line is ever printed. At the closing brace the local bob is destroyed first (strong count 2 to 1), then alice (2 to 1). Both counts stop at 1, held by the other object's partner member.

Inside the scope After the scope: unreachable, counts still 1 alice (stack) bob (stack) Person alicestrong = 2partner → bob Person bobstrong = 2partner → alice Person alicestrong = 1partner → bob Person bobstrong = 1partner → alice no stack pointers Each count is held up by the other object. Neither reaches 0, so neither destructor runs.
A cycle keeps every count at least 1. On the left, each object has one owner on the stack and one in the other object. When the stack pointers die, each count drops by exactly one and stops at 1. Reference counting has no way to notice that the two objects are reachable only from each other.

Where cycles come from in real code

  • Bidirectional relationships: parent and child in a tree, prev and next in a doubly linked list, a session and its connection, a document and its views.
  • Callbacks: an object stores a std::function (a handler, a timer, a completion callback) whose lambda captured a shared_ptr to the same object. The cycle is invisible in the class definition.
  • Registries: a manager owns a list of shared_ptr<Client> and each client owns a shared_ptr<Manager> to talk back.
  • Self-ownership: an object stores shared_from_this() in one of its own members, a cycle of length one.
  • Longer rings: A to B to C to A, often created by three different teams, which is why ownership rules must be global (see the rules).

Detecting cycles

Theory

A cycle does not crash. It shows up as memory growth, files that stay open, or destructors whose side effects never happen. You need tools that make "never destroyed" visible.

Destructor printouts and instance counters

The cheapest proof is a static counter incremented in every constructor and decremented in the destructor, checked at the end of a test. It works on every platform and in release builds. Destructor printouts are the same idea for quick experiments, and they are what most interview answers use.

The weak_ptr probe

In a unit test, keep a weak_ptr to the object, drop all the strong pointers you know about, and assert that the weak_ptr has expired. If it has not, something still owns the object, and probe.use_count() tells you how many owners are left. This needs no change to the class under test.

#include <cstdio>
#include <functional>
#include <memory>

struct Widget {
    std::function<void()> on_click;
    int clicks = 0;
};

static void check(const char* label, const std::weak_ptr<Widget>& probe) {
    if (probe.expired()) std::printf("%s: freed\n", label);
    else std::printf("%s: LEAK, %ld owner(s) left\n", label, probe.use_count());
}

int main() {
    std::weak_ptr<Widget> probe;
    {
        auto w = std::make_shared<Widget>();
        w->on_click = [w] { ++w->clicks; };          // the lambda owns w, and w owns the lambda
        probe = w;
    }
    check("capture shared_ptr", probe);
    probe.lock()->on_click = nullptr;               // break the cycle by hand, so the test cleans up
    check("after clearing", probe);

    {
        auto w = std::make_shared<Widget>();
        Widget* raw = w.get();
        w->on_click = [raw] { ++raw->clicks; };      // the object owns the lambda, the lambda only observes
        probe = w;
    }
    check("capture raw pointer", probe);
}
capture shared_ptr: LEAK, 1 owner(s) left
after clearing: freed
capture raw pointer: freed

Leak detectors

  • LeakSanitizer (-fsanitize=address on Linux, or -fsanitize=leak alone) scans memory at exit and reports heap blocks that no live pointer reaches, with allocation stacks. A cycle is exactly that, so it is reported. On Linux it is on by default with ASan. On macOS, Apple's Xcode clang does not support it, but upstream LLVM clang (for example Homebrew's) does with ASAN_OPTIONS=detect_leaks=1. The harness for this guide leaves it off, which is why the drills prove leaks with counters.
  • Valgrind memcheck reports cycles as "definitely lost" or "indirectly lost" blocks, more slowly but without recompiling.
  • Heap profilers (heaptrack, jemalloc or tcmalloc profiling) show steady growth by allocation site, which is how cycles are usually found in long-running servers where the process never exits.

Tip

A leak checker only reports at exit. In a server, a cycle that forms per request is found by a test that runs the request 1000 times and checks the instance counter is back to its starting value.

Breaking cycles with weak_ptr

Theory

Mental model. A weak_ptr is a non-owning reference that knows whether its target is still alive. It increments the weak count, not the strong count, so it does not keep the object alive. To use the object you call lock(), which atomically returns a shared_ptr (owning, for as long as you hold it) or an empty one if the object is gone. Breaking a cycle means choosing one edge in each cycle and making it weak.

Which edge? Make weak the edge that points back toward the owner, that is, from the part to the whole, from the child to the parent, from the observer target to the observer, from the cache to the cached value. The rule of thumb: the weak edge must point at something that has another owner. If you make the wrong edge weak, the object you still need is destroyed immediately.

root (stack) rootstrong 1 usrstrong 1 etcstrong 1 bin shared_ptr child weak_ptr parent Counts only include solid edges. Dropping root frees the whole tree, top down.
Own downward, observe upward. Children are owned by their parent through shared_ptr; each child refers to its parent through weak_ptr. The ownership graph is a tree, so dropping the root's last owner destroys the root, whose children vector then destroys the children.
#include <cstdio>
#include <memory>
#include <string>
#include <vector>

struct Node {
    std::string name;
    std::weak_ptr<Node> parent;                       // observes
    std::vector<std::shared_ptr<Node>> children;      // owns
    explicit Node(std::string n) : name(std::move(n)) {}
    ~Node() { std::printf("~%s\n", name.c_str()); }
};

static std::shared_ptr<Node> add_child(const std::shared_ptr<Node>& parent, std::string name) {
    auto c = std::make_shared<Node>(std::move(name));
    c->parent = parent;
    parent->children.push_back(c);
    return c;
}

int main() {
    auto root = std::make_shared<Node>("root");
    auto usr = add_child(root, "usr");
    auto bin = add_child(usr, "bin");

    std::string path;
    for (auto n = bin; n; n = n->parent.lock()) path = "/" + n->name + path;
    std::printf("%s\n", path.c_str());
    std::printf("root %ld owner(s), usr %ld owner(s)\n", root.use_count(), usr.use_count());

    usr.reset();
    bin.reset();
    std::puts("dropping root");
    root.reset();
}
/root/usr/bin
root 1 owner(s), usr 2 owner(s)
dropping root
~root
~usr
~bin

The destructor body of root runs first, then its members are destroyed, which destroys usr, whose members destroy bin. The parent links never contribute to a count.

Doubly linked lists

Same rule: next owns, prev observes. The list owns head, every node owns its successor, and tail is a weak_ptr or raw pointer.

template <class T>
struct DNode {
    T value;
    std::shared_ptr<DNode> next;   // owning
    std::weak_ptr<DNode> prev;     // breaks the next/prev cycle
};

Two practical notes. First, a long owning chain is destroyed recursively (each node's destructor destroys next), so a list of a million nodes can overflow the stack; write an iterative clear(). Second, weak_ptr::lock() on every backward step costs two atomic operations; if the list owns all nodes, a raw prev pointer is just as safe and much cheaper (next section).

What weak_ptr costs

  • Size: 16 bytes, the same as shared_ptr (object pointer and control block pointer).
  • lock(): an atomic compare-and-swap loop on the strong count (it must not resurrect an object whose count already hit 0), then an atomic decrement when the returned shared_ptr dies.
  • Memory: the control block stays allocated until the weak count also reaches 0. With make_shared the object and control block are one allocation, so a long-lived weak_ptr keeps the whole block (including the dead object's bytes) allocated, although the destructor has already run. With shared_ptr<T>(new T) only the small control block stays.

Raw non-owning back pointers

Theory

Mental model. A back pointer needs to be a weak_ptr only if the target can die while the pointer still exists. If the structure guarantees that a child never outlives its parent, the parent link can be a plain T* (or T&): it is 8 bytes, needs no atomics and no lock(). The guarantee usually comes from unique_ptr ownership: the parent owns its children exclusively, nobody else holds an owning pointer to a child, so a child exists only inside its parent.

struct Node {
    Node* parent = nullptr;                          // valid for the child's whole life
    std::vector<std::unique_ptr<Node>> children;     // exclusive ownership

    Node& add_child() {
        auto c = std::make_unique<Node>();
        c->parent = this;
        children.push_back(std::move(c));
        return *children.back();
    }
};
Back edgeSafe whenCost per accessFailure mode
weak_ptr<T>always; target may die firstlock(): 2 atomic opsreturns null, which you must handle
T* / T&target is guaranteed to outlive the pointernonedangling pointer, use-after-free if the guarantee breaks
shared_ptr<T>never for a back edgecopy: 1 atomic opcycle, leak
index or id into a containertarget stored in a stable containera lookupstale id, detectable with generation counters

Interview trap

The raw back pointer breaks silently when ownership changes. If someone later hands out shared_ptr<Node> to children (for example to return them from an API), a child can outlive its parent, and child->parent dangles. Write the lifetime assumption down in a comment, or use weak_ptr whenever ownership is shared.

Moving the parent object is another trap. If Node objects live in a std::vector<Node> and the vector reallocates, every parent pointer to them dangles. Owning children through unique_ptr (stable heap addresses) avoids that.

Observers and caches

Theory

The observer pattern

A subject that stores shared_ptr<Observer> keeps every observer alive forever, and if the observer also holds the subject, you have a cycle. Storing raw Observer* instead means an observer that dies without unsubscribing leaves a dangling pointer, a classic crash. Storing weak_ptr<Observer> fixes both: the subject does not own its observers, and on each notification it locks each entry, calls live observers and erases expired ones.

void notify(const Event& e) {
    std::erase_if(observers_, [&](const std::weak_ptr<Observer>& w) {
        if (auto o = w.lock()) { o->on_event(e); return false; }
        return true;                                    // expired: drop it
    });
}

Locking before the call matters: the observer cannot be destroyed in the middle of on_event, even if another thread drops the last external owner. Be aware that an observer that unsubscribes (modifies observers_) from inside its own callback invalidates the iteration; real implementations copy the list, or mark entries and erase after the loop.

Caches of shared objects

A cache that stores shared_ptr<Texture> never frees anything. A cache that stores weak_ptr<Texture> returns the existing object while someone uses it and lets it die when the last user drops it. This "weak cache" or flyweight is common for fonts, compiled shaders, DNS results and parsed configs. Expired entries remain in the map until you purge them (on lookup, periodically, or from a custom deleter as in Ch 12), otherwise the map itself grows without bound.

Callbacks, enable_shared_from_this and capturing self

Theory

Mental model. A lambda's captures are members of its closure object. A std::function that holds the closure owns those members. So obj->handler = [self = obj] { ... }; stores, inside obj, an object that owns obj. The cycle runs through a type that does not mention shared_ptr at all.

Connection (strong = 1) std::function on_data owns its closure closure object (heap) shared_ptr self the capture is a member owning edge back to the object Fix capture weak_from_this() and lock() inside, or capture this if the object owns the callback
A captured shared_ptr is an owning edge. The connection owns the std::function, which owns the closure, which owns the connection. The strong count never drops below 1. Capturing a weak_ptr makes the back edge non-owning.

enable_shared_from_this

A member function sometimes needs a shared_ptr to its own object, for example to register itself with an event loop. shared_ptr<T>(this) would create a second control block and a double delete. Instead, inherit publicly from std::enable_shared_from_this<T>. The base contains a mutable weak_ptr<T>; when a shared_ptr constructor (or make_shared) takes ownership of an object with an unambiguous, accessible enable_shared_from_this base, it stores a weak reference to itself there. shared_from_this() then just locks that member.

  • Since C++17, shared_from_this() throws std::bad_weak_ptr if the object is not owned by any shared_ptr (a stack object, a unique_ptr, or inside the constructor, before ownership exists). Before C++17 it was undefined behaviour.
  • C++17 weak_from_this() returns the stored weak_ptr, which is empty in those cases, without throwing.
  • The base must be public. With private inheritance the shared_ptr constructor cannot see it, the weak member stays empty and shared_from_this() throws.
  • Inside the destructor the strong count is already 0, so shared_from_this() throws and weak_from_this().lock() is empty.

Which capture to use in a callback

Who stores the callbackCaptureWhy
The object itself (a member std::function)thisthe callback cannot outlive the object that owns it; capturing shared_from_this() is a cycle
Someone else, and the object may die first (timer queue, event bus, UI)w = weak_from_this(), then if (auto self = w.lock())no ownership, no dangling; a dead object's callback becomes a no-op
Someone else, and the operation must keep the object alive until it completes (async read, Asio handler)self = shared_from_this()intentional, temporary ownership; the cycle ends when the one-shot handler runs and is destroyed
Someone else, forever (periodic timer owned by the object)never shared_from_this()the handler is never destroyed, so the object is never destroyed
#include <cstdio>
#include <functional>
#include <memory>
#include <vector>

static std::vector<std::function<void()>> timer_queue;   // stands in for an event loop

struct Session : std::enable_shared_from_this<Session> {
    int id;
    explicit Session(int i) : id(i) {}
    ~Session() { std::printf("~Session %d\n", id); }
    void schedule_ping() {
        timer_queue.push_back([w = weak_from_this()] {
            if (auto self = w.lock()) std::printf("ping %d\n", self->id);
            else std::puts("ping skipped: session gone");
        });
    }
};

int main() {
    auto a = std::make_shared<Session>(1);
    auto b = std::make_shared<Session>(2);
    a->schedule_ping();
    b->schedule_ping();
    b.reset();                       // b dies now: the queue does not own it
    for (auto& f : timer_queue) f();
    timer_queue.clear();
    std::puts("end of main");
}
~Session 2
ping 1
ping skipped: session gone
end of main
~Session 1

Capturing this here would be a use-after-free for session 2, and capturing shared_from_this() would have kept session 2 alive until the queue ran. Neither is a cycle, but only the weak capture matches the intent "ping if still connected".

Why reference counting cannot collect cycles

Theory

Mental model. Garbage is defined globally: an object is garbage if no chain of pointers leads to it from a root (stack slots, registers, globals). A reference count is purely local: the number of pointers into the object. Every object in an unreachable cycle has a non-zero count that comes from inside the cycle, so local information cannot distinguish "kept alive by the program" from "kept alive by garbage". Detecting cycles needs a global traversal from the roots, which is what a tracing collector does.

C++ cannot bolt a tracing collector onto shared_ptr, because the runtime does not know where the roots are (any word on the stack might be a pointer, pointers can be hidden in integers or unions) and because destructors must run deterministically. C++11 even had minimal "garbage collection support" APIs (std::declare_reachable and friends), which no implementation used, and C++23 removed them.

Language or runtimeStrategyCyclesDestruction timing
C++ shared_ptratomic reference countingleak; break with weak_ptr or designdeterministic, at the last release
Rust Rc/Arcreference countingleak (safe, but a leak); break with Weakdeterministic
Swift, Objective-C ARCcompiler-inserted reference countingleak; weak and unowned references, [weak self] in closuresdeterministic
CPythonreference counting plus a generational cycle detector (gc module)collected by the detector, which finds groups whose counts are fully explained by internal referencesimmediate for acyclic objects, delayed for cycles
Java, Go, C#, JavaScripttracing garbage collection from rootscollectednondeterministic; finalisers are unreliable, so resources use try-with-resources, defer, using

The trade-off is the interview point. Reference counting gives deterministic destruction, which is what makes RAII work for files, locks and sockets, and it spreads cost evenly (no pauses). The price is that cycles are the programmer's job. Tracing GC handles cycles and is often faster in throughput, but it cannot tell you when a resource is released.

weak_ptr and threads: lock races

Theory

Mental model. The control block's counts are atomic, so different shared_ptr and weak_ptr objects that share one control block can be copied, locked and destroyed from different threads safely. What is not safe is a check followed by a separate use.

if (!w.expired()) w.lock()->refresh();   // BUG: may dereference null
if (auto sp = w.lock()) sp->refresh();   // correct: one atomic step, then you own it

Between expired() returning false and lock(), another thread can drop the last owner, so lock() returns an empty pointer. lock() itself is atomic: it increments the strong count only if it is still non-zero, so it never resurrects a dying object. For the same reason, use_count() is only a hint in multithreaded code; C++20 removed shared_ptr::unique() because it suggested otherwise.

#include <atomic>
#include <cstdio>
#include <memory>
#include <thread>

struct Config { int version; };

int main() {
    auto owner = std::make_shared<Config>(Config{1});
    std::weak_ptr<Config> w = owner;
    std::atomic<bool> go{false};
    int alive_hits = 0, null_hits = 0;

    std::thread reader([w, &go, &alive_hits, &null_hits] {   // the thread has its own copy of w
        while (!go.load()) {}
        for (int i = 0; i < 100000; ++i) {
            if (auto sp = w.lock()) alive_hits += (sp->version == 1);
            else ++null_hits;
        }
    });
    go = true;
    owner.reset();                    // races with lock(), and that is fine
    reader.join();
    std::printf("every lock() saw a live object or null: %d\n", alive_hits + null_hits == 100000);
}
every lock() saw a live object or null: 1

The split between alive_hits and null_hits depends on timing, so the program prints only the invariant. What would be a data race is two threads using the same shared_ptr or weak_ptr object while one of them modifies it (assigns, resets). For that, protect the pointer object with a mutex or use C++20 std::atomic<std::shared_ptr<T>> and std::atomic<std::weak_ptr<T>> (available in libstdc++ since GCC 12; libc++ as of LLVM 22 does not provide it yet).

Know this cold

Locking a weak_ptr is the only correct way to use it: if (auto sp = w.lock()). Never test expired() and then use. expired() is useful only as a cheap "definitely dead" check, for example to purge cache entries.

Ownership design rules

Theory

Mental model. Draw every owning pointer as an arrow. If the arrows form a directed acyclic graph (DAG), reference counting frees everything: when the roots go, counts cascade to zero in topological order. A cycle anywhere in the owning graph is a leak waiting to happen. All the techniques in this chapter are ways to keep that graph acyclic.

  1. Give every object one clear owner by default. Use values and unique_ptr. Reach for shared_ptr only when the lifetime is genuinely shared and not known statically (an object used by several asynchronous operations, a cache entry). Exclusive ownership cannot form a shared_ptr cycle.
  2. Keep the owning graph a DAG. Owners point down, from the whole to the parts. Anything pointing up or sideways is non-owning: weak_ptr if the target can die first, a raw pointer or reference if it cannot.
  3. Treat captures as edges. A lambda stored in an object or in something the object owns must not capture a shared_ptr to the object. Review [self = shared_from_this()] in code review the way you review new.
  4. Prefer explicit teardown for graphs that really are cyclic. Some domains (graph editors, object graphs loaded from files) have arbitrary cycles. Store all nodes in an owning container (a vector<unique_ptr<Node>> or an arena, Ch 17), and let edges be raw pointers or indices. The container frees everything at once.
  5. Test for leaks. A weak probe or an instance counter in unit tests catches the cycle that someone adds next year.
Key facts
Destructor runs whenstrong count reaches 0 (weak count irrelevant)
Control block freed whenstrong and weak counts both reach 0
make_shared memory with a live weak_ptrwhole block stays allocated until the last weak_ptr goes
weak_ptr::lock()atomic; returns empty if the object is dead; never resurrects
shared_from_this() without an ownerthrows std::bad_weak_ptr (C++17), UB before
weak_from_this()C++17; empty weak_ptr instead of a throw
Can unique_ptr leak through a cycle?yes, if you build an ownership ring by hand (A owns B owns A)
LeakSanitizerfinds cycles at exit; default with ASan on Linux; on macOS only with upstream clang and detect_leaks=1

Concept and tricky questions

Drills

ConceptExplain precisely why two objects that hold shared_ptrs to each other are never destroyed.

Answer

Answer. Each object's strong count includes the pointer stored in the other object. When all outside pointers are gone, both counts are still 1, and the destructor runs only when a count reaches 0. Since the destructor of A is what would release A's pointer to B (and vice versa), neither can start.

Why. Reference counting decides liveness locally, from the number of incoming owning pointers, not from reachability. An unreachable cycle has non-zero counts that are all internal. "The memory is freed at program exit" is not an answer: destructors still never run, so files are not flushed and sockets are not closed in an orderly way, and in a long-running server the leak grows per request.

TrickyA Parent owns a shared_ptr<Child> and the child owns a shared_ptr<Parent>. Your colleague breaks the cycle by making the Parent::child member a weak_ptr. Does it leak? Is it correct?

Answer

Answer. It no longer leaks, but it is wrong: if nothing else owns the child, the child is destroyed as soon as it is created, and parent->child.lock() returns null. Make the child-to-parent edge weak instead.

Why. Any weak edge in the cycle stops the leak, but a weak_ptr only works if the target has another owner. The child's only owner is the parent, so that edge must be owning. The parent has another owner (whoever created the tree), so the child can observe it weakly. The rule: weak edges point back toward the owner.

TrickyCan you leak memory through a cycle with only unique_ptr?

Answer

Answer. Yes. If A's unique_ptr member owns B and you then move ownership of A into B's member, A and B own each other and no outside pointer owns either. Both leak. A circular singly linked list built with unique_ptr<Node> next is the usual way it happens.

Why. unique_ptr guarantees one owner per object, not an acyclic ownership graph. "Unique ownership cannot cycle" is the common wrong answer. It is harder to do by accident than with shared_ptr, because you must explicitly move the last outside owner into the ring, and the reading drills include it.

ConceptWhat does weak_ptr::lock() do, and why can't you dereference a weak_ptr directly?

Answer

Answer. lock() atomically increments the strong count if it is non-zero and returns a shared_ptr that owns the object; if the count is already 0, it returns an empty shared_ptr. weak_ptr has no operator* or operator->.

Why. A direct dereference could not guarantee the object stays alive during the access: another thread might drop the last owner in the middle. Forcing you through lock() makes you hold an owner for exactly as long as you use the object. Internally lock() is a CAS loop, since a plain increment could revive an object whose count already hit zero.

TrickyAn object is created with make_shared. Its last shared_ptr is gone, but a weak_ptr to it lives on in a cache for hours. Has the object's memory been freed? Has its destructor run?

Answer

Answer. The destructor has run, but the memory has not been freed. The object's storage is inside the same allocation as the control block, and the control block must live until the weak count reaches 0.

Why. make_shared does one allocation for counts plus object. The strong count reaching 0 calls the destructor; the weak count reaching 0 frees the block. For a large object (say a 1 MB buffer member stored inline) a lingering weak_ptr keeps the whole 1 MB allocated. shared_ptr<T>(new T) frees the object at strong count 0 and keeps only the small control block. Many candidates think a weak_ptr holds nothing at all.

ConceptHow does enable_shared_from_this work internally?

Answer

Answer. The base class holds a mutable weak_ptr<T>. When a shared_ptr takes ownership of a raw pointer (or make_shared creates the object), its constructor checks at compile time whether T has an unambiguous, accessible enable_shared_from_this base, and if so assigns itself to that weak_ptr. shared_from_this() returns shared_ptr<T>(weak_this), which throws if expired.

Why. This lets a member function get a shared_ptr that shares the existing control block, instead of creating a second one with shared_ptr<T>(this), which would delete the object twice. It also explains its limits: the weak member is filled only after the constructor finishes and only if inheritance is public.

TrickyTo fix the alice/bob cycle, a colleague adds ~Person() { partner.reset(); }. Does that stop the leak?

Answer

Answer. No. The destructor is exactly the code that never runs. It would run only when a strong count reaches 0, and the cycle is what keeps both counts at 1. Adding a reset to it changes nothing, and the program still prints no "bye" lines.

Why. Breaking a cycle must happen either from outside while an external owner still exists (an explicit close() or unlink() that resets partner before the last outside shared_ptr goes), or structurally, by making one edge a weak_ptr. The explicit teardown works but relies on every caller remembering it on every path, including exceptions, so the structural fix is preferred. The same mistake appears as "reset the callbacks in the destructor" for a std::function that captured shared_from_this(). Many candidates suggest the destructor reset because it is how you would release resources in ordinary RAII code.

TrickyA Parent owns its Child through unique_ptr. The child needs the parent's settings, so the parent gives it std::shared_ptr<const Settings>(self, &self->settings), where self is the parent's own shared_ptr. No type in Child mentions Parent. Is there a cycle?

#include <cstdio>
#include <memory>

struct Settings { int level = 3; };
struct Child { std::shared_ptr<const Settings> settings; };

struct Parent {
    Settings settings;
    std::unique_ptr<Child> child;
    ~Parent() { std::puts("~Parent"); }
};

int main() {
    std::weak_ptr<Parent> probe;
    {
        auto p = std::make_shared<Parent>();
        p->child = std::make_unique<Child>();
        p->child->settings = std::shared_ptr<const Settings>(p, &p->settings);   // aliasing constructor
        probe = p;
        std::printf("parent owners %ld\n", p.use_count());
    }
    std::printf("after scope: expired %d, owners %ld\n", probe.expired(), probe.use_count());
}
Answer
parent owners 2
after scope: expired 0, owners 1

Answer. Yes, and the parent leaks. The aliasing constructor creates a shared_ptr that points to the Settings member but shares ownership of the whole Parent (same control block). So the parent owns the child, and the child owns the parent. ~Parent never prints.

Why. An owning edge is defined by the control block a pointer shares, not by its element type. That makes aliasing pointers the hardest cycles to spot in review. Fixes: give the child a std::weak_ptr<const Settings> made from the aliasing pointer (it observes the parent's block), a plain const Settings* (the parent outlives its exclusively owned child), or a copy of the settings. The common wrong answer is "no cycle, it is only a pointer to Settings".

ConceptWhen is a raw pointer a better back pointer than a weak_ptr?

Answer

Answer. When the structure guarantees that the target outlives the pointer, typically when the parent owns its children exclusively with unique_ptr (or by value in a node container with stable addresses). Then child->parent can never dangle, and a raw pointer is cheaper: 8 bytes, no atomics, no null check after lock().

Why. weak_ptr exists to handle "the target might be gone". If that is impossible by construction, it adds cost and suggests a lifetime uncertainty that does not exist. The risk is future changes: as soon as children can be shared outside the parent, the guarantee is gone and the raw pointer becomes a use-after-free.

TrickyA class stores a std::function<void()> on_timeout and sets it to a lambda that captures this. Is that a cycle? What if it captures shared_from_this()?

Answer

Answer. Capturing this is not a cycle and is safe as long as only the object itself invokes on_timeout: the closure cannot outlive the object that owns it. Capturing shared_from_this() is a cycle: object owns function owns closure owns object, so it leaks.

Why. Raw this is a non-owning edge, so it adds nothing to any count. The danger with this appears only when a copy of the callback is handed to someone else (a timer queue, another thread) that may call it after the object died; then use weak_from_this(). Many candidates say "always capture shared_from_this() to be safe", which is the leak.

ConceptWhy can a tracing garbage collector free a cycle when reference counting cannot? How does CPython deal with cycles?

Answer

Answer. A tracing collector marks everything reachable from the roots (stacks, registers, globals) and frees the rest, so an unreachable cycle is simply never marked. Reference counting only knows incoming pointer counts. CPython uses reference counting plus a periodic cycle detector: for container objects it subtracts references that come from other tracked objects, and any group whose count is fully explained by internal references is unreachable garbage.

Why. Reachability is a global property and counts are local. C++ chooses local counting because it gives deterministic destruction (RAII) and needs no knowledge of roots, which C++ cannot provide precisely. The price is that cycle avoidance is a design responsibility, handled with weak_ptr, raw back pointers or owning containers.

ConceptIn the observer pattern, why store weak_ptr<Observer> in the subject rather than shared_ptr or a raw pointer?

Answer

Answer. shared_ptr makes the subject keep every observer alive (and forms a cycle if the observer holds the subject). A raw pointer dangles when an observer is destroyed without unsubscribing. A weak_ptr does neither: the subject locks each observer during notify, calls the live ones and erases the expired ones.

Why. Subscription is not ownership. Locking also protects the call: while on_event runs, the subject holds a temporary owner, so the observer cannot be destroyed mid-call by another thread. The cost is two atomic operations per observer per notification, which is usually negligible next to the callback.

TrickyIn an Asio-style server every handler captures self = shared_from_this(). Isn't that a cycle? Why doesn't it leak?

Answer

Answer. It is a temporary ownership edge, not a permanent cycle. The handler is owned by the I/O service (not by the session), runs once and is then destroyed, which releases self. When the last pending operation completes without starting a new one, the session's count drops to 0 and it is destroyed. That is the intended "keep alive while an operation is in flight" pattern.

Why. A leak needs the closure to be owned, directly or indirectly, by the object it captures, and never released. If the session stored a copy of the handler in a member (or a periodic timer it owns re-arms forever with self), the edge would become permanent and leak. The usual way to stop such a session is to close its socket, so the pending operations complete with an error and do not re-arm.

Predict the output and find the bug

Drills

OutputFollow the counts. What does this print?

#include <cstdio>
#include <memory>

struct B;
struct A { std::shared_ptr<B> b; ~A() { std::puts("~A"); } };
struct B { std::shared_ptr<A> a; ~B() { std::puts("~B"); } };

int main() {
    std::weak_ptr<A> wa;
    std::weak_ptr<B> wb;
    {
        auto a = std::make_shared<A>();
        auto b = std::make_shared<B>();
        wa = a;
        wb = b;
        a->b = b;
        std::printf("%ld %ld\n", a.use_count(), b.use_count());
        b->a = a;
        std::printf("%ld %ld\n", a.use_count(), b.use_count());
    }
    std::printf("%ld %ld\n", wa.use_count(), wb.use_count());
    wa.lock()->b.reset();
    std::printf("%ld %ld\n", wa.use_count(), wb.use_count());
}
Answer
1 2
2 2
1 1
~B
~A
0 0

Answer. After the scope both counts are 1 (the cycle). wa.lock() creates a temporary owner of A (count 2). Resetting A::b drops B to 0, so ~B runs, and destroying B's member a drops A to 1. At the end of the full expression the temporary from lock() dies, A drops to 0 and ~A runs.

Why. weak_ptr::use_count() reports the strong count, not the weak count. The temporary shared_ptr returned by lock() lives until the semicolon, which is why ~A comes after ~B and not in the middle. Breaking any one edge of a cycle lets the whole cycle cascade.

Find the bugA doubly linked list reports that its nodes are still alive after the list is destroyed. Why?

#include <cstdio>
#include <memory>

static int live_nodes = 0;

struct Node {
    int value;
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;
    explicit Node(int v) : value(v) { ++live_nodes; }
    ~Node() { --live_nodes; }
};

struct List {
    std::shared_ptr<Node> head, tail;
    void push_back(int v) {
        auto n = std::make_shared<Node>(v);
        if (!head) { head = tail = n; return; }
        n->prev = tail;
        tail->next = n;
        tail = n;
    }
};

int main() {
    {
        List l;
        for (int i = 0; i < 5; ++i) l.push_back(i);
        std::printf("live inside: %d\n", live_nodes);
    }
    std::printf("live after the list is gone: %d\n", live_nodes);
}
Answer
live inside: 5
live after the list is gone: 5

Answer. Every adjacent pair of nodes owns each other through next and prev, so the list is a chain of cycles. When head and tail are released, every node still has count at least 1. Fix: make prev (and the list's tail) a weak_ptr or a raw Node*.

Why. With prev weak, the owning graph is a single chain from head, and dropping head cascades down the list. Note that the cascade is recursive (each destructor destroys next), so for very long lists add an iterative clear(). LeakSanitizer on Linux would report all five nodes; the counter proves it here.

Find the bugThis timer callback crashes intermittently in production. What is wrong?

#include <cstdio>
#include <functional>
#include <memory>
#include <vector>

struct EventLoop {
    std::vector<std::function<void()>> pending;
    void post(std::function<void()> f) { pending.push_back(std::move(f)); }
    void run() { for (auto& f : pending) f(); pending.clear(); }
};

struct Session {
    int bytes_sent = 0;
    void start(EventLoop& loop) {
        loop.post([this] { bytes_sent += 100; std::printf("sent %d\n", bytes_sent); });
    }
};

int main() {
    EventLoop loop;
    auto s = std::make_unique<Session>();
    s->start(loop);
    s.reset();          // client disconnected before the loop ran
    loop.run();
}
Answer

Answer. The callback captured raw this, and the event loop (not the session) owns the callback. The session is destroyed before the loop runs, so the callback writes to freed memory: heap-use-after-free, which ASan reports.

Why. A raw capture is safe only when the object owns the callback. Here the callback is stored by someone else and may run later. Fix: own the session with shared_ptr, inherit enable_shared_from_this, capture w = weak_from_this() and lock() inside, or capture shared_from_this() if the operation must keep the session alive until it runs. Capturing shared_from_this() is not a cycle here because the loop, not the session, owns the closure.

OutputWhat does each shared_from_this call do?

#include <cstdio>
#include <memory>

struct W : std::enable_shared_from_this<W> {
    W() {
        try {
            auto self = shared_from_this();
            (void)self;
            std::puts("ctor: got a shared_ptr");
        } catch (const std::bad_weak_ptr&) {
            std::puts("ctor: bad_weak_ptr");
        }
    }
};

int main() {
    auto p = std::make_shared<W>();
    auto q = p->shared_from_this();
    std::printf("use_count %ld\n", p.use_count());

    W on_stack;
    try {
        auto r = on_stack.shared_from_this();
        (void)r;
    } catch (const std::bad_weak_ptr&) {
        std::puts("stack: bad_weak_ptr");
    }
    std::printf("weak_from_this expired: %d\n", on_stack.weak_from_this().expired());
}
Answer
ctor: bad_weak_ptr
use_count 2
ctor: bad_weak_ptr
stack: bad_weak_ptr
weak_from_this expired: 1

Answer. Inside the constructor nothing owns the object yet, so both constructions print bad_weak_ptr. After make_shared returns, shared_from_this() shares the existing control block (count 2). The stack object is never owned, so it throws, and its weak_from_this() is empty.

Why. The internal weak_ptr in enable_shared_from_this is assigned by the owning shared_ptr after the object has been fully constructed. C++17 specifies the throw; before C++17 these calls were undefined behaviour.

Find the bugA developer needed a shared_ptr to the current object inside a member function and did not want to change the base classes. What happens?

#include <cstdio>
#include <memory>
#include <vector>

struct Job;
static std::vector<std::shared_ptr<Job>> registry;

struct Job {
    int id;
    explicit Job(int i) : id(i) {}
    void enqueue() { registry.push_back(std::shared_ptr<Job>(this)); }
};

int main() {
    auto job = std::make_shared<Job>(7);
    job->enqueue();
    std::printf("registered %zu job(s)\n", registry.size());
    registry.clear();
    std::printf("job id %d\n", job->id);
}
Answer

Answer. shared_ptr<Job>(this) creates a second, independent control block for an object that is already owned. When registry is cleared, that control block's count reaches 0 and it deletes the job, while job still points to it. The next line reads freed memory, and at the end the original owner destroys the object again. Under ASan the first error is an invalid free ("attempting free on address which was not malloc()-ed"): the object lives inside make_shared's combined block, so its address was never returned by new.

Why. Constructing a shared_ptr from a raw pointer always means "I am the first owner". Inherit from enable_shared_from_this<Job> and use shared_from_this(), which shares the existing control block. If the registry should not keep jobs alive, store weak_from_this() instead.

Find the bugA tree uses raw parent pointers. The code that prints a node's parent crashes. Why?

#include <cstdio>
#include <memory>
#include <string>
#include <vector>

struct Node {
    std::string name;
    Node* parent = nullptr;
    std::vector<std::shared_ptr<Node>> children;
    explicit Node(std::string n) : name(std::move(n)) {}
};

static std::shared_ptr<Node> add(Node& parent, std::string name) {
    auto c = std::make_shared<Node>(std::move(name));
    c->parent = &parent;
    parent.children.push_back(c);
    return c;
}

int main() {
    std::shared_ptr<Node> keep;
    {
        auto root = std::make_shared<Node>("root, with a name long enough to live on the heap");
        keep = add(*root, "leaf");
    }
    std::printf("leaf's parent is %s\n", keep->parent->name.c_str());
}
Answer

Answer. The children are shared, so keep can (and does) outlive the root. When root goes out of scope the root node is destroyed, and keep->parent dangles: heap-use-after-free.

Why. A raw back pointer is valid only if the parent is guaranteed to outlive the child. That holds when the parent owns its children exclusively (unique_ptr), and fails once children are handed out as shared_ptr. Fix: make parent a weak_ptr<Node> and check lock(), or keep exclusive ownership.

OutputA ring built from unique_ptr. How many nodes are alive at the end?

#include <cstdio>
#include <memory>

static int alive = 0;

struct Node {
    std::unique_ptr<Node> next;
    Node() { ++alive; }
    ~Node() { --alive; }
};

int main() {
    {
        auto a = std::make_unique<Node>();
        a->next = std::make_unique<Node>();
        a->next->next = std::make_unique<Node>();
        Node* last = a->next->next.get();
        last->next = std::move(a);      // close the ring
        std::printf("a is %s\n", a ? "set" : "null");
    }
    std::printf("alive: %d\n", alive);
}
Answer
a is null
alive: 3

Answer. All three nodes leak. After the move, the first node is owned by the last node, which is owned by the second, which is owned by the first. The local a is null, so nothing outside the ring owns any of them.

Why. unique_ptr guarantees a single owner per object, which the ring satisfies. It does not guarantee the ownership graph is acyclic. The same structure with shared_ptr would also leak. Circular lists need a non-owning link somewhere, or an owning container for all nodes.

Find the bugA widget registers a handler with itself. After the scope ends, the widget is never destroyed. Find the owning edge.

#include <cstdio>
#include <functional>
#include <memory>
#include <vector>

static int widgets = 0;

struct Widget : std::enable_shared_from_this<Widget> {
    std::vector<std::function<void(int)>> handlers;
    int total = 0;
    Widget() { ++widgets; }
    ~Widget() { --widgets; std::puts("~Widget"); }
    void add_logging() {
        auto self = shared_from_this();
        handlers.push_back([self](int v) { self->total += v; });
    }
    void fire(int v) { for (auto& h : handlers) h(v); }
};

int main() {
    {
        auto w = std::make_shared<Widget>();
        w->add_logging();
        w->fire(5);
        std::printf("total %d, use_count %ld\n", w->total, w.use_count());
    }
    std::printf("widgets alive: %d\n", widgets);
}
Answer
total 5, use_count 2
widgets alive: 1

Answer. The handler lambda captures self, a shared_ptr to the widget, and the widget stores the handler in its own handlers vector. Widget owns closure owns widget, so the count never drops below 1 and ~Widget never prints. Fix: capture this (the widget owns the handler, so it cannot outlive the widget), or weak_from_this() if handlers can be copied out.

Why. use_count 2 is the giveaway: one owner is w, the other is hidden inside a std::function. Cycles through captures do not show up in the class definition, which is why reviewers should treat [self = shared_from_this()] in a stored callback as a red flag.

OutputWhen does the destructor run relative to the weak pointer's lifetime?

#include <cstdio>
#include <memory>

struct Big {
    char payload[4096];
    ~Big() { std::puts("~Big"); }
};

int main() {
    std::weak_ptr<Big> w;
    {
        auto sp = std::make_shared<Big>();
        w = sp;
        std::printf("inside: expired %d, use_count %ld\n", w.expired(), w.use_count());
    }
    std::printf("outside: expired %d, use_count %ld\n", w.expired(), w.use_count());
    auto again = w.lock();
    std::printf("lock gave %s\n", again ? "an object" : "null");
    w.reset();
    std::puts("weak reset: now the 4 KiB block is freed");
}
Answer
inside: expired 0, use_count 1
~Big
outside: expired 1, use_count 0
lock gave null
weak reset: now the 4 KiB block is freed

Answer. ~Big runs at the end of the inner scope, when the strong count reaches 0, even though w still exists. After that w is expired and lock() returns null. The allocation (control block plus the 4 KiB payload, since it came from make_shared) is freed only when w is reset.

Why. Destruction is driven by the strong count, deallocation by both counts. A weak_ptr cannot keep an object alive and cannot revive it. The last line is a comment about memory, not something the program can observe without a custom allocator.

Find the bugThis weak cache never leaks objects, yet a long-running service using it grows without bound. Why?

#include <cstdio>
#include <map>
#include <memory>
#include <string>

struct Glyph { int code; };

struct GlyphCache {
    std::map<int, std::weak_ptr<Glyph>> entries;
    std::shared_ptr<Glyph> get(int code) {
        auto& slot = entries[code];
        if (auto sp = slot.lock()) return sp;
        auto sp = std::make_shared<Glyph>(Glyph{code});
        slot = sp;
        return sp;
    }
};

int main() {
    GlyphCache cache;
    long used = 0;
    for (int code = 0; code < 1000; ++code) used += cache.get(code)->code;   // each glyph used once
    std::size_t expired = 0;
    for (auto& [code, w] : cache.entries) expired += w.expired();
    std::printf("entries %zu, expired %zu, sum %ld\n", cache.entries.size(), expired, used);
}
Answer
entries 1000, expired 1000, sum 499500

Answer. The glyphs themselves are destroyed, but every expired weak_ptr stays in the map, and each one pins its control block, which (because of make_shared) also contains the dead glyph's storage. The map grows with every distinct key. Fix: purge expired entries (on lookup misses, periodically, or from a custom deleter that erases the key).

Why. weak_ptr solves ownership, not eviction. A cache keyed by an unbounded set of keys needs a removal policy regardless of how the values are held.

Write the code

Coding

CodeImplement a tree of named nodes with shared ownership of children and weak_ptr parents. Provide add_child(parent, name), path(node) (from the root, separated by /), depth(node) and detach(node), which removes a node (and its subtree) from its parent. Prove with an instance counter that detached subtrees are freed once no one else holds them, and that dropping the root frees everything.

Answer

Approach. Each node owns vector<shared_ptr<Node>> children and observes weak_ptr<Node> parent. path and depth walk up with lock(). detach locks the parent, erases the child from the parent's vector (comparing raw addresses) and resets the child's parent link. Freeing then follows automatically from the counts.

#include <algorithm>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>

static int live = 0;

struct Node {
    std::string name;
    std::weak_ptr<Node> parent;
    std::vector<std::shared_ptr<Node>> children;
    explicit Node(std::string n) : name(std::move(n)) { ++live; }
    ~Node() { --live; }
};
using NodePtr = std::shared_ptr<Node>;

static NodePtr add_child(const NodePtr& parent, std::string name) {
    auto c = std::make_shared<Node>(std::move(name));
    c->parent = parent;
    parent->children.push_back(c);
    return c;
}

static std::string path(NodePtr n) {
    std::string out;
    for (; n; n = n->parent.lock()) out = "/" + n->name + out;
    return out;
}

static int depth(const NodePtr& n) {
    int d = 0;
    for (auto p = n->parent.lock(); p; p = p->parent.lock()) ++d;
    return d;
}

static void detach(const NodePtr& n) {
    if (auto p = n->parent.lock()) {
        auto& v = p->children;
        v.erase(std::remove_if(v.begin(), v.end(), [&](const NodePtr& c) { return c.get() == n.get(); }), v.end());
    }
    n->parent.reset();
}

int main() {
    auto root = std::make_shared<Node>("root");
    auto home = add_child(root, "home");
    auto ana = add_child(home, "ana");
    add_child(ana, "notes");
    add_child(root, "tmp");
    std::printf("%s depth %d, live %d\n", path(ana).c_str(), depth(ana), live);

    detach(home);
    std::printf("after detach: %s, root children %zu, live %d\n", path(ana).c_str(), root->children.size(), live);
    home.reset();
    std::printf("ana still held: live %d\n", live);
    ana.reset();
    std::printf("subtree released: live %d\n", live);
    root.reset();
    std::printf("all released: live %d\n", live);
}
/root/home/ana depth 2, live 5
after detach: /home/ana, root children 1, live 5
ana still held: live 4
subtree released: live 2
all released: live 0

Complexity and pitfalls. path and depth are O(depth) with two atomics per step; detach is O(siblings). After home.reset() the home node is freed (live 4), but ana survives because the local variable owns it, and its parent link is now expired, which is exactly what weak_ptr is for. Pitfalls: a shared_ptr parent (cycle), forgetting to reset the parent link on detach, recursion depth on destruction of very deep trees.

Estimated rating: ~1400 · straightforward once the owning direction is chosen correctly

CodeWrite DList<T>, a doubly linked list with shared_ptr forward links and weak_ptr backward links. Support push_back, push_front, pop_back, forward and backward traversal, and a destructor that frees 200000 nodes without recursing (no stack overflow). Count live nodes to prove nothing leaks.

Answer

Approach. head is the only outside owner; next owns and prev observes, and tail is a weak_ptr too. pop_back locks the tail, finds its predecessor through prev, and resets that node's next (which destroys the old tail). The destructor detaches nodes one at a time: move head->next out before releasing head, so each node dies with a null next and destruction never recurses.

#include <cstdio>
#include <memory>

static long live = 0;

template <class T>
class DList {
    struct Node {
        T value;
        std::shared_ptr<Node> next;
        std::weak_ptr<Node> prev;
        explicit Node(T v) : value(std::move(v)) { ++live; }
        ~Node() { --live; }
    };
    std::shared_ptr<Node> head_;
    std::weak_ptr<Node> tail_;

public:
    DList() = default;
    DList(const DList&) = delete;
    DList& operator=(const DList&) = delete;
    ~DList() { clear(); }

    void clear() {
        while (head_) head_ = std::move(head_->next);   // old head dies with next already empty
        tail_.reset();
    }
    void push_back(T v) {
        auto n = std::make_shared<Node>(std::move(v));
        if (auto t = tail_.lock()) { n->prev = t; t->next = n; }
        else head_ = n;
        tail_ = n;
    }
    void push_front(T v) {
        auto n = std::make_shared<Node>(std::move(v));
        n->next = head_;
        if (head_) head_->prev = n; else tail_ = n;
        head_ = std::move(n);
    }
    bool pop_back() {
        auto t = tail_.lock();
        if (!t) return false;
        if (auto p = t->prev.lock()) { p->next.reset(); tail_ = p; }
        else { head_.reset(); tail_.reset(); }
        return true;
    }
    template <class F> void for_each(F f) const {
        for (auto n = head_; n; n = n->next) f(n->value);
    }
    template <class F> void for_each_reverse(F f) const {
        for (auto n = tail_.lock(); n; n = n->prev.lock()) f(n->value);
    }
};

int main() {
    {
        DList<int> l;
        for (int i = 1; i <= 3; ++i) l.push_back(i);
        l.push_front(0);
        l.pop_back();
        l.for_each([](int v) { std::printf("%d ", v); });
        std::printf("| ");
        l.for_each_reverse([](int v) { std::printf("%d ", v); });
        std::printf("| live %ld\n", live);
    }
    std::printf("after small list: live %ld\n", live);
    {
        DList<int> big;
        for (int i = 0; i < 200000; ++i) big.push_back(i);
        std::printf("big list: live %ld\n", live);
    }
    std::printf("after big list: live %ld\n", live);
}
0 1 2 | 2 1 0 | live 3
after small list: live 0
big list: live 200000
after big list: live 0

Complexity and pitfalls. Pushes and pop_back are O(1); every backward step costs a lock(). The non-recursive clear works because head_ = std::move(head_->next) empties the old head's next before the old head is released, so each node is destroyed with nothing left to cascade into. Without it, destroying 200000 nodes recurses 200000 levels deep, which can overflow the stack (and does under ASan, whose frames are larger). Pitfalls: a shared_ptr prev (every adjacent pair becomes a cycle); forgetting to update tail_ in push_front on an empty list; pop_back on a one-element list must reset head_. If the list owns all nodes and never hands them out, raw prev pointers are cheaper and equally safe.

Estimated rating: ~1600 · pointer bookkeeping in both directions plus the non-recursive teardown

CodeImplement Subject with subscribe(std::weak_ptr<Observer>) and notify(int event), where Observer has a virtual on_event(int). notify must skip and erase observers that have died, return how many observers it delivered to, and remain correct if an observer subscribes another observer from inside on_event.

Answer

Approach. Store vector<weak_ptr<Observer>>. In notify, first take a snapshot: lock every entry into a local vector<shared_ptr<Observer>> and erase expired entries from the member list. Then call the observers from the snapshot. Holding strong pointers during the calls means no observer dies mid-notification, and iterating the snapshot means subscriptions made during a callback do not invalidate the loop (they take effect on the next notification).

#include <algorithm>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>

struct Observer {
    virtual ~Observer() = default;
    virtual void on_event(int e) = 0;
};

class Subject {
public:
    void subscribe(std::weak_ptr<Observer> o) { observers_.push_back(std::move(o)); }
    int notify(int event) {
        std::vector<std::shared_ptr<Observer>> live;
        live.reserve(observers_.size());
        std::erase_if(observers_, [&](const std::weak_ptr<Observer>& w) {
            if (auto sp = w.lock()) { live.push_back(std::move(sp)); return false; }
            return true;
        });
        for (auto& o : live) o->on_event(event);
        return static_cast<int>(live.size());
    }
    std::size_t size() const { return observers_.size(); }
private:
    std::vector<std::weak_ptr<Observer>> observers_;
};

struct Printer : Observer {
    std::string name;
    explicit Printer(std::string n) : name(std::move(n)) {}
    void on_event(int e) override { std::printf("%s got %d\n", name.c_str(), e); }
};

struct Recruiter : Observer {
    Subject* subject;
    std::shared_ptr<Observer> recruit = std::make_shared<Printer>("recruit");
    bool done = false;
    explicit Recruiter(Subject* s) : subject(s) {}
    void on_event(int e) override {
        std::printf("recruiter got %d\n", e);
        if (!done) { subject->subscribe(recruit); done = true; }   // subscribe during notify
    }
};

int main() {
    Subject s;
    auto a = std::make_shared<Printer>("a");
    auto r = std::make_shared<Recruiter>(&s);
    s.subscribe(a);
    s.subscribe(r);
    {
        auto temp = std::make_shared<Printer>("temp");
        s.subscribe(temp);
        std::printf("delivered %d\n", s.notify(1));
    }
    std::printf("delivered %d\n", s.notify(2));
    std::printf("registered %zu\n", s.size());
}
a got 1
recruiter got 1
temp got 1
delivered 3
a got 2
recruiter got 2
recruit got 2
delivered 3
registered 3

Complexity and pitfalls. O(n) per notification plus two atomics per observer; the snapshot costs one allocation, which a reused member buffer can avoid. Pitfalls: calling on_event while iterating observers_ (a subscribe inside the callback reallocates the vector and invalidates the iterator); expired() followed by lock(); storing shared_ptr, which keeps observers alive forever. With threads, guard observers_ with a mutex during the snapshot only, and never hold it while calling observers (a callback that subscribes would deadlock).

Estimated rating: ~1600 · weak_ptr purge plus re-entrancy during notification

CodeWrite an EventLoop with post(std::function<void()>) and run(), and a Connection class owned by shared_ptr with two methods: ping(), which posts a callback that runs only if the connection is still alive, and send(msg), which posts a callback that must keep the connection alive until the message has been sent. Show that a connection dropped by its owner is destroyed immediately unless a send is pending, and destroyed right after its last pending send runs.

Answer

Approach. Connection inherits enable_shared_from_this and has a private constructor plus a static create factory, so it can only be owned by shared_ptr (otherwise shared_from_this would throw). ping captures weak_from_this() and locks inside. send captures shared_from_this(): the loop owns that closure, so this is temporary ownership, not a cycle. run moves the queue out before executing it so that callbacks can post safely and so that each closure is destroyed right after it runs.

#include <cstdio>
#include <deque>
#include <functional>
#include <memory>
#include <string>

class EventLoop {
public:
    void post(std::function<void()> f) { q_.push_back(std::move(f)); }
    void run() {
        while (!q_.empty()) {
            auto f = std::move(q_.front());
            q_.pop_front();
            f();
        }                                   // f, and anything it captured, dies here
    }
private:
    std::deque<std::function<void()>> q_;
};

class Connection : public std::enable_shared_from_this<Connection> {
    struct Key {};
public:
    Connection(Key, EventLoop& loop, int id) : loop_(loop), id_(id) {}
    static std::shared_ptr<Connection> create(EventLoop& loop, int id) {
        return std::make_shared<Connection>(Key{}, loop, id);
    }
    ~Connection() { std::printf("~Connection %d\n", id_); }

    void ping() {
        loop_.post([w = weak_from_this()] {
            if (auto self = w.lock()) std::printf("ping %d\n", self->id_);
            else std::puts("ping dropped");
        });
    }
    void send(std::string msg) {
        loop_.post([self = shared_from_this(), msg = std::move(msg)] {
            std::printf("conn %d sent '%s'\n", self->id_, msg.c_str());
        });
    }
private:
    EventLoop& loop_;
    int id_;
};

int main() {
    EventLoop loop;
    auto c1 = Connection::create(loop, 1);
    auto c2 = Connection::create(loop, 2);
    c1->ping();
    c2->ping();
    c2->send("bye");
    c1.reset();                 // no send pending: destroyed now
    c2.reset();                 // a send is pending: kept alive by the loop
    std::puts("running loop");
    loop.run();
    std::puts("loop done");
}
~Connection 1
running loop
ping dropped
ping 2
conn 2 sent 'bye'
~Connection 2
loop done

Complexity and pitfalls. O(1) per post and per callback. Connection 1 has no pending strong capture, so c1.reset() destroys it at once and its ping is dropped. Connection 2 is owned only by the queued send closure after c2.reset(); its ping still succeeds, because lock() only needs some owner to exist. When the send closure has run and is destroyed, the count reaches 0. Pitfalls: running callbacks while iterating the queue (a callback that posts invalidates the iteration); keeping executed closures in the queue, which would keep connection 2 alive forever; capturing this in ping (use-after-free for connection 1); a public constructor, which lets someone create a stack Connection whose send throws bad_weak_ptr.

Estimated rating: ~1700 · choosing weak vs strong capture per callback and keeping closures short-lived

CodeSimulate both memory managers on an ownership graph. Input: n objects, a list of owning edges u → v and a set of roots (objects owned by the stack). When the program drops all roots, which objects does reference counting free and which does it leak? Which would a tracing collector free? Print the leaked objects for each strategy.

Answer

Approach. Reference counting: once the stack references are gone, an object's count is its number of incoming owning edges. Put every object with count 0 on a worklist; freeing one decrements each object it owns, and any that reach 0 join the worklist (Kahn's algorithm). Whatever is never freed is the refcount leak: exactly the objects on a cycle or reachable from one. A tracing collector with no roots left frees everything, so the interesting comparison is while the roots are alive: tracing frees everything unreachable from the roots right away, including unreachable cycles. The program reports both.

#include <cstdio>
#include <vector>

struct Graph {
    int n;
    std::vector<std::vector<int>> owns;
    std::vector<int> roots;
};

// Objects that reference counting never frees after every root is dropped.
static std::vector<int> refcount_leaks(const Graph& g) {
    std::vector<int> count(g.n, 0);
    for (int u = 0; u < g.n; ++u) for (int v : g.owns[u]) ++count[v];
    // Each root adds one stack reference, and dropping the roots removes it again,
    // so after the drop the count is the in-degree. Zero means "freed".
    std::vector<int> work;
    for (int u = 0; u < g.n; ++u) if (count[u] == 0) work.push_back(u);
    std::vector<bool> freed(g.n, false);
    while (!work.empty()) {
        int u = work.back(); work.pop_back();
        freed[u] = true;
        for (int v : g.owns[u]) if (--count[v] == 0) work.push_back(v);
    }
    std::vector<int> leaked;
    for (int u = 0; u < g.n; ++u) if (!freed[u]) leaked.push_back(u);
    return leaked;
}

// Objects unreachable from the roots while the roots are still alive: tracing frees them now.
static std::vector<int> unreachable_now(const Graph& g) {
    std::vector<bool> seen(g.n, false);
    std::vector<int> st(g.roots.begin(), g.roots.end());
    for (int r : g.roots) seen[r] = true;
    while (!st.empty()) {
        int u = st.back(); st.pop_back();
        for (int v : g.owns[u]) if (!seen[v]) { seen[v] = true; st.push_back(v); }
    }
    std::vector<int> out;
    for (int u = 0; u < g.n; ++u) if (!seen[u]) out.push_back(u);
    return out;
}

static void print(const char* label, const std::vector<int>& v) {
    std::printf("%s:", label);
    for (int x : v) std::printf(" %d", x);
    std::printf("%s\n", v.empty() ? " none" : "");
}

int main() {
    // 0 -> 1 -> 2 -> 1 (cycle 1,2), 2 -> 3, 0 -> 4, 5 <-> 6 never reachable, 7 isolated
    Graph g{8, {{1, 4}, {2}, {1, 3}, {}, {}, {6}, {5}, {}}, {0}};
    print("refcount leaks after dropping roots", refcount_leaks(g));
    print("tracing frees while roots alive", unreachable_now(g));
}
refcount leaks after dropping roots: 1 2 3 5 6
tracing frees while roots alive: 5 6 7

Complexity and pitfalls. Both passes are O(n + e). Object 7 has no owner at all, so reference counting frees it immediately (in C++ it would have died with its last owner), and tracing also lists it as unreachable. Objects 5 and 6 are the unreachable cycle that only tracing can free. Object 3 is not on a cycle, but it leaks under reference counting because a cycle owns it. Pitfalls: forgetting that objects reachable from a cycle leak too; treating a root that is also owned by another object as freed when the stack drops it (its count stays above 0).

Estimated rating: ~1500 · topological worklist plus DFS, framed as a memory-management question

CodeSome graphs really are cyclic (A → B → A, self-loops). Store one without any shared_ptr: a Graph owns every node, and edges are non-owning. Provide add_node(name), which returns a stable NodeId; add_edge(a, b); remove_node(id), which also removes every edge into the node; neighbours(id); and detection of stale ids, so a removed node's id does not silently refer to a newer node that reuses its slot. Prove with an instance counter that destroying the graph frees every node, cycles included.

Answer

Approach. This is ownership rule 4: one owning container, non-owning edges. Slots hold a unique_ptr<Node> and a generation counter. A NodeId is (slot index, generation), and it is valid only while the generation matches. Removing a node destroys it, bumps the slot's generation (so every old id goes stale), puts the slot on a free list and erases the edges that point to it. Edges are ids, so they can form any cycle, and destroying the graph destroys the slot vector, which frees every node whatever the edges look like.

#include <cstdint>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>

static int live = 0;

struct NodeId { std::uint32_t index, gen; };

class Graph {
    struct Node {
        std::string name;
        std::vector<NodeId> out;                 // non-owning edges
        explicit Node(std::string n) : name(std::move(n)) { ++live; }
        ~Node() { --live; }
    };
    struct Slot { std::unique_ptr<Node> node; std::uint32_t gen = 0; };
    std::vector<Slot> slots_;                   // the only owner of nodes
    std::vector<std::uint32_t> free_;

    Node* get(NodeId id) const {
        if (id.index >= slots_.size()) return nullptr;
        const Slot& s = slots_[id.index];
        return s.gen == id.gen ? s.node.get() : nullptr;
    }

public:
    NodeId add_node(std::string name) {
        std::uint32_t i;
        if (!free_.empty()) { i = free_.back(); free_.pop_back(); }
        else { i = static_cast<std::uint32_t>(slots_.size()); slots_.emplace_back(); }
        slots_[i].node = std::make_unique<Node>(std::move(name));
        return {i, slots_[i].gen};
    }
    bool add_edge(NodeId from, NodeId to) {
        Node* f = get(from);
        if (!f || !get(to)) return false;
        f->out.push_back(to);
        return true;
    }
    bool remove_node(NodeId id) {
        if (!get(id)) return false;
        Slot& s = slots_[id.index];
        s.node.reset();
        ++s.gen;                                  // every old id for this slot is now stale
        free_.push_back(id.index);
        for (Slot& other : slots_)
            if (other.node)
                std::erase_if(other.node->out, [&](NodeId e) { return e.index == id.index && e.gen == id.gen; });
        return true;
    }
    bool alive(NodeId id) const { return get(id) != nullptr; }
    std::string neighbours(NodeId id) const {
        std::string out;
        if (Node* n = get(id))
            for (NodeId e : n->out)
                if (Node* t = get(e)) out += t->name + " ";
        return out;
    }
};

int main() {
    {
        Graph g;
        NodeId a = g.add_node("a"), b = g.add_node("b"), c = g.add_node("c");
        g.add_edge(a, b);
        g.add_edge(b, a);                         // a 2-cycle
        g.add_edge(b, c);
        g.add_edge(c, c);                         // a self-loop
        std::printf("b -> %s| live %d\n", g.neighbours(b).c_str(), live);
        g.remove_node(a);
        std::printf("after removing a: b -> %s| live %d\n", g.neighbours(b).c_str(), live);
        NodeId d = g.add_node("d");               // reuses a's slot
        bool stale_edge = g.add_edge(a, d);
        std::printf("d in slot %u, old id a alive %d, edge from a accepted %d\n", d.index, g.alive(a), stale_edge);
    }
    std::printf("graph destroyed: live %d\n", live);
}
b -> a c | live 3
after removing a: b -> c | live 2
d in slot 0, old id a alive 0, edge from a accepted 0
graph destroyed: live 0

Complexity and pitfalls. add_node, add_edge and lookups are O(1); remove_node is O(V + E) because it scans for incoming edges (keep reverse edge lists if removals are frequent). Pitfalls: storing raw Node* edges without removing them (dangling after a removal); reusing a slot without a generation, so an old id silently names the new node (the ABA problem); storing nodes by value in the vector and handing out Node*, which dangle when the vector reallocates. With shared_ptr edges, the 2-cycle and the self-loop would both leak.

Estimated rating: ~1600 · generation-checked handles plus edge cleanup, the standard answer for genuinely cyclic data

Cheat sheet

Theory
  • Reference counting frees an object when its strong count hits 0. Objects in a cycle keep each other at 1 or more, so their destructors never run.
  • Break each cycle with one non-owning edge, pointing back toward the owner: weak_ptr if the target may die first, a raw pointer if structure guarantees it outlives the pointer.
  • Use a weak_ptr only through if (auto sp = w.lock()). Never expired() then lock(); use_count() is a hint.
  • Captured shared_ptrs are owning edges. A callback stored in the object captures this; a callback stored elsewhere captures weak_from_this(); a one-shot async handler may capture shared_from_this().
  • shared_from_this() needs public enable_shared_from_this and an existing owner; it throws bad_weak_ptr in constructors, destructors and on unowned objects (C++17).
  • The destructor runs at strong count 0, the block is freed at weak count 0; with make_shared, lingering weak_ptrs pin the object's memory.
  • Prove leaks with instance counters or a weak_ptr probe in tests; LeakSanitizer (default with ASan on Linux; Apple's clang lacks it on macOS).
  • Design rule: one clear owner, owning graph is a DAG, arbitrary graphs live in an owning container with non-owning edges.

Part E · Memory management · Chapter 14

Placement new

Placement new separates the two jobs that a normal new does: getting memory and constructing an object in it. With it you construct objects in storage you already own, which is how std::vector, std::optional, std::variant, pools and arenas work. Interviewers ask about it to check that you understand object lifetime as distinct from memory: alignment, explicit destruction, std::launder and the undefined behaviour waiting at each step.

TheoryDrillsCoding· about 3.5 hours· prerequisites: Ch 9 new/delete vs malloc/free, Ch 1 object lifetime

What placement new does

Theory

Mental model. An ordinary new T(args) is two steps: call an allocation function, operator new(sizeof(T)), to get raw bytes, then run T's constructor on those bytes. Placement new is the same expression with extra arguments in parentheses, new (place) T(args). The extra arguments are passed to the allocation function, and the standard one, operator new(std::size_t, void* p), simply returns p. So no memory is allocated: you supply the storage, and the expression only constructs.

// What <new> declares (the non-allocating forms; they cannot be replaced by the program):
void* operator new  (std::size_t, void* p) noexcept { return p; }
void* operator new[](std::size_t, void* p) noexcept { return p; }
void  operator delete  (void*, void*) noexcept {}   // matching "placement delete": does nothing
void  operator delete[](void*, void*) noexcept {}

Under the hood. The result is a T* to a new object whose lifetime has begun at that address. The storage is still owned by whoever owned it before (a stack buffer, a pool, a malloc block), and placement new has no record of it. That means you, not the runtime, are responsible for ending the object's lifetime with an explicit destructor call, and for releasing the storage in whatever way it was obtained.

#include <cstdio>
#include <new>
#include <string>

struct Widget {
    std::string name;
    explicit Widget(std::string n) : name(std::move(n)) { std::printf("construct %s\n", name.c_str()); }
    ~Widget() { std::printf("destroy %s\n", name.c_str()); }
};

int main() {
    alignas(Widget) unsigned char buf[sizeof(Widget)];
    Widget* w = new (buf) Widget("gear");      // no allocation: buf is the storage
    std::printf("same address: %d\n", static_cast<void*>(w) == static_cast<void*>(buf));
    w->~Widget();                              // ends the lifetime; the bytes stay
    w = new (buf) Widget("spring");            // reuse the same bytes for a new object
    w->~Widget();
}
construct gear
same address: 1
destroy gear
construct spring
destroy spring

The new expression versus operator new

Keep three names apart. The new expression (new T, new (args) T) is language syntax: it calls an allocation function and then a constructor, and if the constructor throws, it calls the matching deallocation function. operator new is an ordinary function that only returns memory; you can call it directly, void* p = ::operator new(64);. Placement new is the informal name for a new expression with extra arguments, most often the void* form. Any extra argument types work: new (arena) T calls a user-declared operator new(std::size_t, Arena&).

  • Write ::new (p) T in generic code. The leading :: skips class-specific operator new overloads that T might declare.
  • Cast the address to void*: ::new (static_cast<void*>(p)) T. Otherwise a user overload like operator new(std::size_t, Foo*) could be a better match. std::construct_at does both for you.
  • new (p) T default-initialises (an int or a POD member is left indeterminate); new (p) T() and new (p) T{} value-initialise (zero for scalars). Same rules as ordinary new.

Storage: size and alignment

Theory

Mental model. The storage must be large enough (sizeof(T) bytes) and its address must be a multiple of alignof(T). Placement new checks neither. A plain unsigned char buf[sizeof(T)] on the stack has alignment 1, so it may start at an odd address; constructing a double or a std::string there is undefined behaviour. On x86 a misaligned ordinary load usually just works (slowly, across cache lines), but SSE aligned loads, atomics and many ARM instructions fault, and the optimiser is allowed to assume alignment, so "it worked on my laptop" proves nothing.

48 bytes of storage, 16-byte alignment boundaries (struct alignas(16) V, sizeof 16) 0163248 unsigned char buf[48] (alignment 1: may itself start anywhere) new (buf + 4) V : UB offset 4: straddles the boundary at 16 new (buf + 16) V : OK only if buf itself is 16-aligned Fix the base: alignas(V) std::byte buf[N]; then every offset that is a multiple of alignof(V) is valid.
Alignment is a property of the address. An offset is only meaningful relative to the buffer's own alignment. Declare the buffer with alignas(T), and place objects at offsets that are multiples of alignof(T), or use std::align to find the next valid address.

Getting correctly aligned storage

  • A typed byte array. alignas(T) std::byte buf[sizeof(T)]; (or unsigned char). This is the modern idiom. Arrays of std::byte, unsigned char or char are special: they can provide storage for other objects without ending their own lifetime ([intro.object]).
  • std::aligned_storage is deprecated in C++23. std::aligned_storage_t<sizeof(T), alignof(T)> did the same job, but P1413 deprecated it (and aligned_union) because it is easy to misuse: forgetting _t or ::type gives you a tiny struct with the wrong size, the default alignment argument is implementation-defined, and every access needs a reinterpret_cast plus std::launder anyway. Replace it with the alignas byte array.
  • Heap storage. ::operator new(n) and malloc return memory aligned for any fundamental type (__STDCPP_DEFAULT_NEW_ALIGNMENT__, 16 on x86-64 Linux). For over-aligned types use C++17 ::operator new(n, std::align_val_t{64}), which must be paired with the matching aligned operator delete.
  • Inside a larger buffer. std::align(alignment, size, ptr, space) advances ptr to the next aligned address and decreases space, or returns nullptr if the object does not fit. Arenas use it on every allocation (Ch 17).
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <new>

struct alignas(32) Block { float v[8]; };

static bool aligned(const void* p, std::size_t a) {
    return reinterpret_cast<std::uintptr_t>(p) % a == 0;
}

int main() {
    std::printf("alignof(Block) %zu, sizeof(Block) %zu\n", alignof(Block), sizeof(Block));
    alignas(Block) std::byte good[sizeof(Block)];
    std::printf("alignas buffer aligned: %d\n", aligned(good, alignof(Block)));

    alignas(Block) std::byte pool[128];
    void* p = pool + 1;                          // a deliberately misaligned cursor
    std::size_t space = sizeof pool - 1;
    void* q = std::align(alignof(Block), sizeof(Block), p, space);
    std::printf("std::align: found %d, aligned %d, skipped %zu bytes\n",
                q != nullptr, aligned(q, alignof(Block)), (sizeof pool - 1) - space);
    Block* b = ::new (q) Block{};
    b->v[7] = 1.5f;
    std::printf("%.1f\n", b->v[7]);              // Block is trivially destructible: no destructor call needed
}
alignof(Block) 32, sizeof(Block) 32
alignas buffer aligned: 1
std::align: found 1, aligned 1, skipped 31 bytes
1.5

Ending the lifetime: explicit destructor calls, destroy_at and construct_at

Theory

Mental model. An object built with placement new is destroyed by calling its destructor explicitly, p->~T(). That ends the object's lifetime and releases whatever the object owns (a string's heap buffer, a mutex), but it does not touch the storage. Never write delete p: that would call operator delete on memory that did not come from operator new (a stack buffer, the middle of a pool), which is undefined behaviour and usually a crash in the allocator.

  • std::destroy_at(p) (C++17) calls p->~T(). Unlike the raw syntax, it also works for arrays (destroying each element, C++20) and reads better in generic code. For scalars, p->~T() with T = int is a legal "pseudo-destructor call" that does nothing, so generic code needs no special case.
  • std::construct_at(p, args...) (C++20) is ::new (static_cast<void*>(p)) T(std::forward<Args>(args)...) and returns T*. It is constexpr, which placement new is not until C++26 (P2747), so it is how std::vector and std::optional became usable in constant expressions in C++20. It takes a typed pointer T*, not void*.
  • Range helpers in <memory>: std::destroy, destroy_n, uninitialized_copy, uninitialized_move, uninitialized_fill, uninitialized_value_construct_n, uninitialized_default_construct_n. The uninitialized_* algorithms destroy what they already built if a constructor throws.
  • Order. Built-in arrays and class members are destroyed in reverse order of construction. std::destroy(first, last) destroys forward. If the order matters, destroy in a reverse loop yourself.
#include <cstddef>
#include <cstdio>
#include <memory>

struct Tracer {
    int id;
    explicit Tracer(int i) : id(i) { std::printf("+%d ", id); }
    ~Tracer() { std::printf("-%d ", id); }
};

int main() {
    alignas(Tracer) std::byte raw[3 * sizeof(Tracer)];
    Tracer* first = reinterpret_cast<Tracer*>(raw);
    for (int i = 0; i < 3; ++i) std::construct_at(first + i, i);
    std::puts("");
    std::destroy(first, first + 3);              // forward order
    std::puts("");

    for (int i = 0; i < 3; ++i) std::construct_at(first + i, i + 10);
    std::puts("");
    for (int i = 3; i-- > 0;) std::destroy_at(first + i);   // reverse, like a built-in array
    std::puts("");
}
+0 +1 +2
-0 -1 -2
+10 +11 +12
-12 -11 -10

Interview trap

Calling the destructor does not make the memory reusable "for free": if you skip it, the object's resources leak, and if you call it twice, the second call runs on a dead object (double free of the string's buffer, for example). Every placement new needs exactly one matching destruction, on every path, including exceptions.

Placement delete: called only when a constructor throws

Theory

Mental model. There is no "placement delete expression". You cannot write delete (arena) p. A placement deallocation function, operator delete(void*, Args...) with the same extra parameters as a placement operator new, is called in exactly one situation: when the constructor in a placement new expression throws. The new expression then has memory it obtained but no object, and it calls the matching deallocation function with the same extra arguments so that the memory is not lost ([expr.new]).

  • For the standard void* form, the matching operator delete(void*, void*) does nothing, which is correct: the storage belongs to you.
  • For your own forms (operator new(std::size_t, Arena&)), declare the matching operator delete(void*, Arena&). If there is no match, nothing is called when the constructor throws, and the memory leaks (or the arena space is wasted).
  • For normal destruction you call the destructor and then release the memory by hand (p->~T(); arena.release(p);), because no expression can pass the extra arguments.
#include <cstddef>
#include <cstdio>
#include <new>
#include <stdexcept>

struct Arena {
    alignas(16) std::byte buf[256];
    std::size_t used = 0, last = 0;
    void* take(std::size_t n) {
        last = used;
        used += (n + 15) & ~std::size_t{15};
        return buf + last;
    }
    void undo_last() { used = last; }
};

void* operator new(std::size_t n, Arena& a) {
    std::puts("arena operator new");
    return a.take(n);
}
void operator delete(void*, Arena& a) noexcept {   // the matching placement delete
    std::puts("placement delete: the constructor threw");
    a.undo_last();
}

struct Fragile {
    int v;
    explicit Fragile(int x) : v(x) { if (x < 0) throw std::invalid_argument("negative"); }
};

int main() {
    Arena arena;
    Fragile* ok = new (arena) Fragile(1);
    std::printf("ok->v %d, used %zu\n", ok->v, arena.used);
    ok->~Fragile();                                    // trivial here, but write it anyway
    try {
        new (arena) Fragile(-1);
    } catch (const std::exception& e) {
        std::printf("caught %s, used %zu\n", e.what(), arena.used);
    }
}
arena operator new
ok->v 1, used 16
arena operator new
placement delete: the constructor threw
caught negative, used 16

Reusing storage and std::launder

Theory

Mental model. A pointer in C++ is more than an address: it points to a particular object. When you destroy an object and construct a new one in the same storage, the question is whether old pointers (and the old name) refer to the new object. The rule is [basic.life]/8: they do if the new object transparently replaces the old one, which in C++20 means it occupies exactly the same storage, has the same type (ignoring top-level cv), the old object was not a const complete object, and neither is a base class subobject or a [[no_unique_address]] member.

When those conditions fail, the compiler may assume the old pointer still sees the old object. For example, it may keep a const member's value in a register across the replacement. std::launder(p) (C++17, <new>) is the escape hatch: given a pointer that represents the right address, it returns a pointer to the object that currently lives there, and it is an optimisation barrier for that purpose. It generates no code.

When you actually need std::launder

  • Pointers derived from the raw storage. The main case today. reinterpret_cast<T*>(buf) where buf is a std::byte array gives a pointer whose value still points to the byte, because the array and the T nested in it are not pointer-interconvertible. Writing std::launder(reinterpret_cast<T*>(buf)) gets you the T. Containers that keep only a byte buffer (no stored T*) do this on every access. Keeping the pointer returned by placement new avoids the need.
  • Different type. The new object has a different type from the old one; old pointers do not refer to it at all, and launder on the old pointer (cast to the new type) is how you reach it.
  • const complete objects and base class subobjects, which are never transparently replaceable.
  • Before C++20, const and reference members. In C++17 a class with a const or reference non-static member was not transparently replaceable either, which is why early std::optional implementations needed launder after re-emplacing such a type (P0532 discussed the problem). C++20 relaxed the rule (P1971R0, resolving national body comments RU 007 and US 047), so replacing a struct { const int v; } through the old pointer is now well-defined.
#include <cstdio>
#include <new>

struct Config {
    const int version;      // const member: Config cannot be assigned, only rebuilt
    int flags;
};

int main() {
    alignas(Config) unsigned char storage[sizeof(Config)];
    Config* p = new (storage) Config{1, 0};
    std::printf("v%d\n", p->version);

    p->~Config();
    new (storage) Config{2, 0};               // result discarded on purpose
    std::printf("v%d\n", p->version);          // C++20: fine (transparent replacement); C++17: UB

    Config* q = std::launder(reinterpret_cast<Config*>(storage));   // from raw storage: launder
    std::printf("v%d\n", q->version);
}
v1
v2
v2

Know this cold

The simplest correct rule: use the pointer that placement new (or construct_at) returned. Use std::launder when you have to recompute a T* from the raw storage, or when the type changed. launder does not create an object; there must already be a live T at that address.

Implicit lifetime types and implicit object creation (C++20)

Theory

Mental model. Before C++20, the code every C programmer writes, Point* p = (Point*)malloc(sizeof(Point)); p->x = 3;, was technically undefined in C++: malloc returns storage, not a Point, and nothing started a Point's lifetime. P0593 fixed this in C++20 (and as a defect report against earlier standards). Certain operations now implicitly create objects of implicit-lifetime types in the storage they touch, if doing so gives the program defined behaviour.

  • Implicit-lifetime types: scalars, arrays (of anything), and class types that are aggregates without a user-provided destructor, or that have at least one trivial eligible constructor and a trivial, non-deleted destructor. struct Point { int x, y; }; qualifies; std::string does not.
  • Operations that create objects implicitly: malloc, calloc, realloc, operator new, operator new[], std::allocator<T>::allocate, memcpy and memmove (into the destination), and starting the lifetime of an array of char, unsigned char or std::byte.
  • For anything with a non-trivial constructor or destructor you still need placement new or construct_at. Implicit creation does not run constructors.
  • C++23 adds std::start_lifetime_as<T>(p) for "these bytes I received from the network are a T now", without copying. libstdc++ ships it from GCC 16, but libc++ (LLVM 22) does not yet, so memcpy into a local T remains the portable choice.
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <type_traits>

struct Point { int x, y; };                   // implicit-lifetime: aggregate, trivial destructor

int main() {
    static_assert(std::is_trivially_copyable_v<Point>);
    auto* p = static_cast<Point*>(std::malloc(sizeof(Point)));   // C++20: a Point is implicitly created
    if (!p) return 1;
    p->x = 3;
    p->y = 4;

    unsigned char wire[sizeof(Point)];
    std::memcpy(wire, p, sizeof wire);        // "send" the bytes
    Point q;
    std::memcpy(&q, wire, sizeof q);          // "receive": memcpy creates the object representation
    std::printf("%d %d\n", q.x, q.y);
    std::free(p);
}
3 4

This is also why a std::byte buffer can serve as storage for an array of T: arrays are implicit-lifetime types, so the buffer can implicitly contain a T[N] array object whose elements you then construct one by one. That makes pointer arithmetic across the slots of a container's buffer well-defined.

How vector, optional and variant use placement new

Theory

Mental model. Every standard container that can hold "room for a T but no T yet" is built from three pieces: raw storage of the right size and alignment, placement construction into it, and explicit destruction out of it. The container tracks which slots are live, because the language does not.

std::vector<std::string> v; v.reserve(6); 3 × push_back beginendcap_end "a" "b" "c" raw raw raw live objects: constructed with placement new, destroyed by pop_back/clear raw slots: allocated by reserve, no constructor has run, no destructor will run push_back constructs here
Capacity is storage, size is objects. reserve allocates bytes for six strings but constructs none. push_back placement-constructs at end; pop_back destroys the last element and leaves its bytes as raw capacity. Only [begin, end) holds live objects.
  • std::vector allocates with its allocator, constructs through allocator_traits::construct (placement new or construct_at for std::allocator) and destroys through allocator_traits::destroy. On reallocation it builds the elements in the new buffer with uninitialized_move if the move constructor is noexcept (otherwise it copies, to keep the strong guarantee), then destroys the old elements and frees the old buffer.
  • std::optional<T> stores the value in an anonymous union next to a bool. A union member is not constructed automatically, so the union acts as raw storage with the right size and alignment. emplace destroys any current value, constructs the new one with construct_at on the union member and sets the flag; reset destroys and clears it. If the constructor throws, the optional is left empty.
  • std::variant<Ts...> is the same idea with storage for the largest alternative and an index instead of a bool. Assigning a different alternative destroys the old one first; if the new constructor then throws, the variant can become valueless_by_exception().
  • Small-buffer types (std::function, std::any, SSO strings, Ch 18) placement-construct small objects in an internal buffer and fall back to the heap for large ones.
template <class T>
class TinyOptional {                 // the core of std::optional, without the API
    union { T value_; };             // no T constructed by default
    bool engaged_ = false;
public:
    TinyOptional() {}
    ~TinyOptional() { reset(); }
    template <class... A> T& emplace(A&&... a) {
        reset();
        std::construct_at(&value_, std::forward<A>(a)...);   // may throw: engaged_ is still false
        engaged_ = true;
        return value_;
    }
    void reset() noexcept { if (engaged_) { std::destroy_at(&value_); engaged_ = false; } }
};

A union member is an object of type T at a fixed address, so &value_ is already a T* and no launder is needed. That is one reason modern implementations prefer the union over a byte array.

Placement new with arrays: the overhead problem

Theory

Mental model. new T[n] for a type with a non-trivial destructor stores the element count in a hidden array cookie in front of the elements, so that delete[] knows how many destructors to run (Ch 9). The array new expression therefore asks the allocation function for n * sizeof(T) + overhead bytes and places the elements after the cookie. For placement array new, new (buf) T[n], older standards left the overhead unspecified, so a buffer of exactly n * sizeof(T) bytes could overflow, and the pointer returned was not necessarily buf.

CWG issue 2382 (accepted as a defect report in November 2019, so it applies to C++20 and to earlier modes) says the overhead is zero for the non-allocating form operator new[](std::size_t, void*). Any other placement array form, such as your own operator new[](std::size_t, Arena&), still gets the cookie when T is not trivially destructible, and its size is ABI-specific: 8 bytes on x86-64 (Itanium ABI); on Apple arm64, Clang uses a 16-byte ARM-style cookie while GCC uses 8.

#include <cstddef>
#include <cstdio>
#include <new>

struct Arena {};
static std::size_t requested = 0;
void* operator new[](std::size_t n, Arena&) { requested = n; return ::operator new(n); }
void operator delete[](void* p, Arena&) noexcept { ::operator delete(p); }

struct Trivial { int x; };
struct NonTrivial { int x; ~NonTrivial() {} };

int main() {
    Arena a;
    Trivial* t = new (a) Trivial[4];
    std::printf("trivial: extra bytes %zu\n", requested - 4 * sizeof(Trivial));
    NonTrivial* n = new (a) NonTrivial[4];
    std::printf("non-trivial: extra bytes > 0: %d\n", requested > 4 * sizeof(NonTrivial));

    alignas(NonTrivial) std::byte buf[4 * sizeof(NonTrivial)];
    NonTrivial* in_place = new (buf) NonTrivial[4];
    std::printf("void* form starts at buf: %d\n", static_cast<void*>(in_place) == static_cast<void*>(buf));
    for (int i = 4; i-- > 0;) in_place[i].~NonTrivial();

    ::operator delete(t);               // trivial: the elements start at the allocation
    for (int i = 4; i-- > 0;) n[i].~NonTrivial();
    ::operator delete(reinterpret_cast<std::byte*>(n) - (requested - 4 * sizeof(NonTrivial)));
}
trivial: extra bytes 0
non-trivial: extra bytes > 0: 1
void* form starts at buf: 1

The last two lines of main show why custom array forms are painful: to free the block you must undo the cookie offset yourself. The practical advice has not changed: do not use placement array new. Construct elements one at a time in a loop, or with std::uninitialized_value_construct_n, and destroy them in a reverse loop. You keep full control of the layout and of the exception cleanup.

Undefined behaviour pitfalls

Theory
MistakeWhat happensHow to catch or avoid it
Misaligned storageUB; slow or faulting loads, wrong code from the optimiseralignas(T) buffers, std::align; UBSan reports "misaligned address"
Buffer smaller than sizeof(T)the constructor writes past the endstatic_assert(sizeof(T) <= N); GCC -Wplacement-new; ASan buffer-overflow
Forgetting the destructorthe object's resources leak; the storage is fineRAII owner for the slot, instance counters
Destroying twicethe destructor runs on a dead object: double free insidetrack liveness (a flag, a size counter)
Constructing over a live objectthe old object's lifetime ends without its destructor; its resources leakdestroy first, or assign instead of reconstructing
delete p on a placement objectfrees memory that operator new never returneddestroy explicitly, release storage the way it was obtained; ASan "free on address which was not malloc()-ed"
Using a pointer from the raw buffer without launderUB; the compiler may see the old objectkeep the returned pointer, or std::launder
Old pointer after reuse with a different typeUB; strict aliasing lets the compiler use stale valuesuse the new pointer only
Placement array new into an exact-size buffer with a custom formthe cookie overflows the bufferconstruct elements in a loop
No cleanup when the k-th construction throwsthe first k objects leakstd::uninitialized_* algorithms, or try/catch with reverse destruction
Key facts
new (p) T(args)calls operator new(sizeof(T), p), which returns p; then constructs; no allocation
Header<new> (the void* forms); construct_at/destroy_at in <memory>
Destroyp->~T() or std::destroy_at(p); never delete p
Placement delete is calledonly if the constructor throws during that placement new expression
std::aligned_storagedeprecated in C++23; use alignas(T) std::byte buf[sizeof(T)]
std::construct_atC++20, constexpr, returns T*, uses ::new with a void* cast
std::launderC++17; recover a T* to an object living at an address you reached another way
Placement array new overheadzero for the void* form since CWG 2382; nonzero for custom forms and non-trivially destructible T
Implicit object creationC++20: malloc, memcpy, byte arrays create implicit-lifetime objects automatically

Concept and tricky questions

Drills

ConceptWhat does new (buf) T(args) do, and does it allocate memory?

Answer

Answer. It calls the allocation function operator new(sizeof(T), buf), which returns buf unchanged, and then runs T's constructor at that address, returning a T*. It does not allocate.

Why. The new expression is always "allocation function, then constructor"; the extra arguments in parentheses select which allocation function. The standard void* overload is a no-op, so only the construction remains. People sometimes say "placement new allocates on the stack"; the storage can be anywhere, and it is whatever you passed in. Typical uses, the follow-up interviewers ask for: containers that separate capacity from size (vector, optional, variant, inplace_vector), object pools and arenas, small-buffer optimisation, constructing objects in shared memory or a memory-mapped file, and wrappers such as NoDestructor.

TrickyAn object was created with placement new in a stack buffer. Can you destroy it with delete p? What if the buffer came from ::operator new?

Answer

Answer. Not for the stack buffer: delete p runs the destructor and then calls operator delete(p) on memory the heap never handed out, which is undefined behaviour (ASan: "attempting free on address which was not malloc()-ed"). For a buffer from ::operator new(sizeof(T)) it happens to match what new T would have done, but it is still poor style; write p->~T(); ::operator delete(buf); so that destruction and deallocation are visibly paired with how they were obtained.

Why. A delete expression always does both halves. With placement new you did the halves separately, so you undo them separately. For arrays or class-specific allocators, delete would even pick the wrong deallocation function.

TrickyWhen is a placement operator delete called? Can you call it with a delete expression?

Answer

Answer. Only when the constructor throws inside a placement new expression; then the runtime calls the operator delete whose extra parameters match the operator new that was used, with the same arguments. There is no delete expression syntax that passes extra arguments, so you never trigger it by writing delete.

Why. At that moment the memory has been obtained but no object exists, so the program has nothing to call delete on; the language cleans up for you ([expr.new]). If no matching placement delete is declared, nothing is called and the memory leaks. The standard operator delete(void*, void*) is a no-op, which is correct for caller-owned storage.

ConceptWhy must the buffer be declared alignas(T)? What can go wrong on x86, which tolerates misaligned loads?

Answer

Answer. Every object must live at an address that is a multiple of alignof(T); a char array only guarantees alignment 1. Constructing at a misaligned address is undefined behaviour regardless of the CPU.

Why. On x86 ordinary integer loads still work (possibly split across cache lines), but aligned SSE/AVX instructions (movaps) fault, lock-prefixed atomics that cross a cache line are extremely slow or trap with split-lock detection, and the compiler is allowed to emit those instructions because it assumes alignment. On ARM many instructions (exclusive loads, some SIMD) fault. UBSan reports misaligned accesses at run time.

TrickyWhy was std::aligned_storage deprecated in C++23, and what replaces it?

Answer

Answer. P1413 deprecated aligned_storage and aligned_union because they are easy to misuse and give no real benefit. Replace them with alignas(T) std::byte buf[sizeof(T)];.

Why. Classic bugs: writing std::aligned_storage<sizeof(T)> without ::type or _t (you get the trait class itself, a 1-byte empty struct); relying on the default alignment argument, which is implementation-defined and may be smaller than alignof(T) for over-aligned types; and the type gives no access to the object anyway, so you need reinterpret_cast and std::launder. A byte array with alignas states size and alignment explicitly and can legally provide storage.

TrickyIs alignas(T) std::byte buf[n * sizeof(T)]; T* a = new (buf) T[n]; safe?

Answer

Answer. Since CWG 2382 (C++20, also treated as a defect fix), yes for the standard void* form: the array allocation overhead is required to be zero, so the elements start at buf. Before that resolution it was unspecified, and implementations were allowed to add a cookie and overrun the buffer. With any custom placement form (an arena overload), a non-trivially destructible T gets a cookie (8 bytes on x86-64 Linux; 16 with Clang on Apple arm64, 8 with GCC there).

Why. The cookie exists so that delete[] knows how many destructors to run. You never use delete[] on placement arrays, but the expression rules applied anyway. Even now, the idiomatic answer is "construct the elements in a loop" or std::uninitialized_value_construct_n, which also handles exceptions. Interviewers often expect "unsafe, unspecified overhead", so mention both the history and the fix.

ConceptWhat is std::launder for? Give a case that needs it in C++20.

Answer

Answer. It takes a pointer that holds the address of a live object of type T but does not formally point to it, and returns a pointer that does. The common C++20 case is a container that stores only a byte buffer and recomputes element pointers with reinterpret_cast<T*>(buf): that pointer still points to the byte, so you write std::launder(reinterpret_cast<T*>(buf)).

Why. The object model tracks which object a pointer points to, and optimisers use it (for example to cache const values or vtable pointers). launder tells the compiler to forget those assumptions for this pointer. It does not create an object and generates no instructions. If you keep the pointer that placement new returned, you never need it.

TrickyAfter p->~T(); new (p) T(2); can you keep using the old pointer p? Does the answer change if T has a const int member, and does it differ between C++17 and C++20?

Answer

Answer. Yes, as long as the new object transparently replaces the old one: same storage, same type, the old object is not a const complete object and not a base subobject. In C++17 a const or reference member also disqualified the replacement, so p could not be used and you needed std::launder(p). C++20 (P1971R0, from the RU 007 and US 047 national body comments) removed that member condition, so the code is fine.

Why. The C++17 rule let compilers assume a const member never changes through the same pointer, which made std::optional<S> with a const member hard to implement correctly. Many answers still quote the C++17 rule; mention the change. A const complete object (const T obj;) still cannot be replaced transparently.

ConceptWhat do std::construct_at and std::destroy_at add over placement new and an explicit destructor call?

Answer

Answer. construct_at(p, args...) (C++20) is constexpr, always uses the global non-allocating form through ::new (static_cast<void*>(p)), and returns the typed pointer. destroy_at(p) (C++17) is the explicit destructor call as a function, and in C++20 it also destroys arrays element by element and is constexpr.

Why. A placement new expression is not allowed in constant evaluation before C++26 (P2747 makes it constexpr), so constexpr std::vector and std::optional in C++20 depend on construct_at. The :: and the void* cast protect generic code from class-specific or oddly-typed operator new overloads. As free functions they also work with algorithms and allocators.

TrickyWhat happens if you placement-new a second object into storage that still holds a live std::string, without destroying the first?

Answer

Answer. The first string's lifetime ends when its storage is reused, but its destructor never runs, so its heap buffer leaks. That alone is not undefined behaviour: [basic.life]/5 allows reusing storage without calling a non-trivial destructor, as long as the program does not depend on the destructor's side effects.

Why. "It is UB to construct over a live object" is the common wrong answer. The real consequences are the leak and any broken invariants (an unlocked mutex that never unlocks, a registration never removed). Containers avoid this by destroying before reconstructing, or by assigning to the live object.

ConceptWhat are implicit-lifetime types, and what did C++20 change about malloc?

Answer

Answer. Implicit-lifetime types are scalars, arrays, and classes that are aggregates without user-provided destructors or that have a trivial eligible constructor and a trivial non-deleted destructor. Since C++20 (P0593, applied as a defect report), operations like malloc, operator new, memcpy and creating a byte array implicitly create objects of such types when that is needed to give the program defined behaviour.

Why. Before that, (Point*)malloc(sizeof(Point)) followed by p->x = 1 was formally undefined, because nothing began the Point's lifetime, even though every C-compatible program relied on it. It does not help types with real constructors: a std::string in malloc memory still needs placement new.

ConceptHow do vector::reserve and push_back use placement new? Why doesn't reserve construct anything?

Answer

Answer. reserve(n) allocates raw storage for n elements through the allocator and moves existing elements into it. push_back constructs the new element at end() with allocator_traits::construct, which is placement new (or construct_at) for std::allocator. reserve constructs nothing because capacity is storage, not elements: size() does not change.

Why. If reserve default-constructed elements, it would require T to be default constructible and would waste work that push_back then overwrites. The separation between allocation and construction is the reason placement new exists. resize(n), in contrast, constructs elements.

Trickyalignas(int) std::byte b[sizeof(int)]; What is the difference between new (b) int and new (b) int()?

Answer

Answer. new (b) int default-initialises, which for int means no initialisation: the value is indeterminate and reading it is undefined behaviour. C++26 "erroneous behaviour" (P2795) covers uninitialised automatic variables, not objects created by a new expression, so this read stays undefined. new (b) int() (or int{}) value-initialises it to 0.

Why. Placement new follows exactly the same initialisation rules as ordinary new. People assume the bytes already in the buffer become the value, or that new always zeroes. For class types with a user-provided default constructor both forms call it; for aggregates, T() zeroes members that have no initialiser and T leaves them indeterminate.

Predict the output and find the bug

Drills

OutputIn what order are the objects constructed and destroyed?

#include <cstddef>
#include <cstdio>
#include <new>

struct T {
    char c;
    explicit T(char x) : c(x) { std::printf("+%c", c); }
    ~T() { std::printf("-%c", c); }
};

int main() {
    alignas(T) std::byte buf[3 * sizeof(T)];
    T* p[3];
    for (int i = 0; i < 3; ++i) p[i] = ::new (buf + i * sizeof(T)) T(static_cast<char>('a' + i));
    std::printf("\n");
    {
        T local[2] = {T('x'), T('y')};
    }
    std::printf("\n");
    for (int i = 0; i < 3; ++i) p[i]->~T();
    std::printf("\n");
}
Answer
+a+b+c
+x+y-y-x
-a-b-c

Answer. The built-in array is destroyed in reverse order of construction (-y-x). The placement objects are destroyed in whatever order you write, here forward (-a-b-c).

Why. The language guarantees reverse destruction for arrays, members and automatic objects. For placement-constructed objects there is no automatic destruction at all, so the order is the programmer's choice; mimic the reverse order when objects may depend on earlier ones. T local[2] = {T('x'), T('y')} constructs each element directly (guaranteed copy elision since C++17), so no extra copies print.

Find the bugA market data decoder places a payload struct right after a 4-byte header. It works on the developer's machine. What is wrong?

#include <cstddef>
#include <cstdio>
#include <new>

struct Payload {
    double price;
    long qty;
};

int main() {
    alignas(8) std::byte packet[64];
    // bytes 0..3 hold a header
    Payload* p = ::new (packet + 4) Payload{101.25, 300};
    std::printf("%.2f x %ld\n", p->price, p->qty);
}
Answer

Answer. Payload requires 8-byte alignment, and packet + 4 is only 4-byte aligned, so the object is created at a misaligned address: undefined behaviour. UBSan reports "member access within misaligned address ... which requires 8 byte alignment".

Why. alignas on the buffer aligns its start, not every offset. For wire formats with packed fields, do not place structs into the buffer at all: memcpy the bytes into a properly aligned local Payload (the compiler turns it into plain loads), or use std::align to pick a valid offset when you control the layout. x86 hides the bug for plain loads, while ARM and vectorised code expose it.

Find the bugA message slot API constructs messages into caller-provided memory. What goes wrong here?

#include <cstddef>
#include <cstdio>
#include <new>

struct Order {
    long id;
    double px;
    long qty;
    char side;
};

static Order* make_order(void* where, std::size_t capacity, long id) {
    (void)capacity;                        // "we always pass enough"
    return ::new (where) Order{id, 99.5, 10, 'B'};
}

int main() {
    alignas(Order) std::byte slot[16];     // sized for an old version of Order
    Order* o = make_order(slot, sizeof slot, 42);
    std::printf("%ld %c\n", o->id, o->side);
}
Answer

Answer. Order grew to 32 bytes, but the slot has 16, so the constructor writes past the end of slot: a stack buffer overflow. With -fsanitize=address,undefined the first report comes from UBSan's object-size check ("store to address ... with insufficient space for an object of type 'long'"); ASan alone reports a stack-buffer-overflow.

Why. Placement new trusts the caller completely; it neither knows nor checks the size. Validate capacity >= sizeof(Order) and alignment at run time, and when the buffer type is known at compile time use static_assert(sizeof(Order) <= sizeof slot). GCC's -Wplacement-new catches the case where it can see both sizes in one expression, which is exactly what the indirection here hides.

OutputA class has a custom placement operator new with a tag argument but only the usual operator delete. What is printed when the constructor throws?

#include <cstddef>
#include <cstdio>
#include <new>
#include <stdexcept>

static int allocs = 0, frees = 0;

struct Tagged {
    int v;
    explicit Tagged(int x) : v(x) { if (x < 0) throw std::runtime_error("bad"); }
    static void* operator new(std::size_t n, int /*tag*/) { ++allocs; return ::operator new(n); }
    static void operator delete(void* p) noexcept { ++frees; ::operator delete(p); }
};

int main() {
    Tagged* ok = new (1) Tagged(5);
    delete ok;                                   // the usual delete: fine
    try {
        new (2) Tagged(-1);
    } catch (const std::exception&) {
        std::puts("caught");
    }
    std::printf("allocs %d frees %d\n", allocs, frees);
}
Answer
caught
allocs 2 frees 1

Answer. The second allocation is never freed. When the constructor throws, the new expression looks for a deallocation function whose parameters after the first match the placement arguments, operator delete(void*, int). There is none, so no deallocation function is called and the block leaks.

Why. The usual operator delete(void*) is only used for the non-placement new Tagged(5) form and for delete expressions. Whenever you declare a placement operator new, declare the matching placement operator delete. (LeakSanitizer would flag the leak on Linux; the counters prove it here.)

Find the bugA developer cleans up a placement-constructed object the "normal" way. What happens?

#include <cstdio>
#include <new>
#include <string>

int main() {
    alignas(std::string) unsigned char buf[sizeof(std::string)];
    auto* s = new (buf) std::string("constructed in a stack buffer");
    std::printf("%s\n", s->c_str());
    delete s;
}
Answer

Answer. delete s runs ~basic_string correctly and then calls operator delete on a stack address, which is undefined behaviour. ASan reports "attempting free on address which was not malloc()-ed"; without ASan the allocator usually aborts or corrupts its metadata.

Why. A delete expression always pairs the destructor with deallocation. The storage did not come from operator new, so only the destructor may be called: s->~basic_string(); or, more readably, std::destroy_at(s);.

Find the bugReusing a buffer for a different type. What does the last line print, and why is the question meaningless?

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <new>

int main() {
    alignas(8) std::byte buf[8];
    auto* i = ::new (buf) std::int64_t(42);
    auto* d = ::new (buf) double(1.0);     // ends the int64's lifetime
    std::printf("%.1f\n", *d);
    std::printf("%lld\n", static_cast<long long>(*i));
}
Answer

Answer. Reading *i is undefined behaviour: the int64_t it pointed to no longer exists, and a double cannot transparently replace an int64_t because the types differ. In practice it prints either the bit pattern of 1.0 (4607182418800017408) or 42, depending on whether the optimiser forwarded the earlier store through the type-based aliasing rules.

Why. Old pointers survive storage reuse only for transparent replacement (same type). To reinterpret bits, use std::bit_cast<std::int64_t>(1.0) or memcpy, never a stale pointer. This is the same rule that makes type punning through pointer casts unsafe.

OutputCount the constructions. What does this print?

#include <cstdio>
#include <vector>

struct C {
    static inline int ctor = 0, copy = 0, move = 0, dtor = 0;
    C() { ++ctor; }
    C(const C&) { ++copy; }
    C(C&&) noexcept { ++move; }
    ~C() { ++dtor; }
};

int main() {
    std::vector<C> v;
    v.reserve(4);
    std::printf("after reserve: ctor %d dtor %d size %zu\n", C::ctor, C::dtor, v.size());
    v.emplace_back();
    v.emplace_back();
    C c;
    v.push_back(c);
    std::printf("ctor %d copy %d move %d\n", C::ctor, C::copy, C::move);
    v.pop_back();
    std::printf("after pop: dtor %d size %zu capacity >= 4: %d\n", C::dtor, v.size(), v.capacity() >= 4);
}
Answer
after reserve: ctor 0 dtor 0 size 0
ctor 3 copy 1 move 0
after pop: dtor 1 size 2 capacity >= 4: 1

Answer. reserve constructs nothing. Two emplace_back() calls default-construct in place, C c is the third default construction, and push_back(c) copy-constructs into the next raw slot. No reallocation happens, so there are no moves. pop_back runs one destructor and keeps the capacity.

Why. The vector separates storage (capacity) from objects (size) and uses placement construction for every element. A frequent wrong answer is "reserve(4) constructs 4 objects", which is what resize(4) would do.

Find the bugA hand-written "copy into raw storage" leaks when a copy throws. Where?

#include <cstddef>
#include <cstdio>
#include <memory>
#include <stdexcept>

static int live = 0;

struct Item {
    int v;
    explicit Item(int x) : v(x) { ++live; }
    Item(const Item& o) : v(o.v) {
        if (o.v == 3) throw std::runtime_error("copy failed");
        ++live;
    }
    ~Item() { --live; }
};

template <class T>
static void copy_into(const T* src, std::size_t n, T* dst) {
    for (std::size_t i = 0; i < n; ++i) std::construct_at(dst + i, src[i]);
}

int main() {
    Item src[5] = {Item(0), Item(1), Item(2), Item(3), Item(4)};
    alignas(Item) std::byte raw[5 * sizeof(Item)];
    try {
        copy_into(src, 5, reinterpret_cast<Item*>(raw));
    } catch (const std::exception& e) {
        std::printf("%s, live objects: %d (expected 5)\n", e.what(), live);
    }
}
Answer
copy failed, live objects: 8 (expected 5)

Answer. When the fourth copy throws, the three copies already constructed in raw are never destroyed, so their resources leak (here, the counter stays 3 too high). Fix: wrap the loop in try, and in catch (...) destroy dst[i-1] ... dst[0] in reverse order and rethrow. Or call std::uninitialized_copy_n, which does exactly that.

Why. Raw storage has no destructor to fall back on: no container owns those objects yet, so stack unwinding cannot clean them up. This is the core of the strong exception guarantee in vector's copy constructor and reallocation.

Find the bugA reusable connection slot is re-initialised with placement new. Why do the counts not balance?

#include <cstdio>
#include <new>

static int opened = 0, closed = 0;

struct Conn {
    int id;
    explicit Conn(int i) : id(i) { ++opened; }
    ~Conn() { ++closed; }
};

class Slot {
    alignas(Conn) unsigned char buf_[sizeof(Conn)];
    Conn* c_ = nullptr;
public:
    void open(int id) { c_ = new (buf_) Conn(id); }
    void close() { if (c_) { c_->~Conn(); c_ = nullptr; } }
    ~Slot() { close(); }
};

int main() {
    {
        Slot s;
        s.open(1);
        s.open(2);      // reconnect
        s.open(3);      // reconnect again
    }
    std::printf("opened %d closed %d\n", opened, closed);
}
Answer
opened 3 closed 1

Answer. open constructs a new Conn over a live one without destroying it first, so connections 1 and 2 are never closed. Only connection 3 is destroyed, by ~Slot. Fix: call close() at the start of open, then construct.

Why. Reusing the storage ends the old object's lifetime silently, skipping its destructor (allowed by [basic.life]/5, but its side effects are lost). Any type that wraps placement-constructed storage must track whether the slot is live, exactly like std::optional::emplace does.

OutputWhat state is the std::optional in after emplace throws?

#include <cstdio>
#include <optional>
#include <stdexcept>

struct Picky {
    int v;
    explicit Picky(int x) : v(x) {
        if (x < 0) throw std::invalid_argument("negative");
        std::printf("+%d\n", v);
    }
    ~Picky() { std::printf("-%d\n", v); }
};

int main() {
    std::optional<Picky> o;
    o.emplace(1);
    try {
        o.emplace(-1);
    } catch (const std::exception& e) {
        std::printf("caught %s, has_value %d\n", e.what(), o.has_value());
    }
    o.emplace(2);
}
Answer
+1
-1
caught negative, has_value 0
+2
-2

Answer. emplace first destroys the current value (-1 prints), then tries to construct the new one in the same storage. The constructor throws, so the optional is left empty, not holding the old value. The final emplace(2) succeeds and is destroyed at the end of main.

Why. In-place construction cannot offer the strong guarantee: the old object had to die to free the storage. The standard specifies "if an exception is thrown during the call to T's constructor, *this does not contain a value". The flag is set only after the constructor succeeds, which is the pattern your own implementation should copy.

Write the code

Coding

CodeImplement Optional<T> using an aligned byte buffer and placement new: default constructor (empty), construction from T, copy and move construction and assignment, emplace(args...), reset(), has_value(), operator*, operator-> and value() (throws when empty). If the constructor in emplace throws, the optional must end up empty. Test with a type that counts live instances.

Answer

Approach. Store alignas(T) std::byte buf_[sizeof(T)] and bool engaged_. emplace calls reset(), constructs with ::new, and sets engaged_ only afterwards. Access goes through std::launder(reinterpret_cast<T*>(buf_)), since the pointer is recomputed from raw storage. Copy and move assignment have four cases: both engaged (assign the value), only the source engaged (construct), only this engaged (reset), neither (nothing).

#include <cstddef>
#include <cstdio>
#include <new>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <utility>

template <class T>
class Optional {
public:
    Optional() noexcept {}
    Optional(const T& v) { emplace(v); }
    Optional(T&& v) { emplace(std::move(v)); }
    Optional(const Optional& o) { if (o.engaged_) emplace(*o); }
    Optional(Optional&& o) noexcept(std::is_nothrow_move_constructible_v<T>) {
        if (o.engaged_) emplace(std::move(*o));
    }
    Optional& operator=(const Optional& o) {
        if (this == &o) return *this;
        if (engaged_ && o.engaged_) **this = *o;
        else if (o.engaged_) emplace(*o);
        else reset();
        return *this;
    }
    Optional& operator=(Optional&& o) noexcept(std::is_nothrow_move_constructible_v<T> &&
                                               std::is_nothrow_move_assignable_v<T>) {
        if (this == &o) return *this;
        if (engaged_ && o.engaged_) **this = std::move(*o);
        else if (o.engaged_) emplace(std::move(*o));
        else reset();
        return *this;
    }
    ~Optional() { reset(); }

    template <class... A>
    T& emplace(A&&... a) {
        reset();
        T* p = ::new (static_cast<void*>(buf_)) T(std::forward<A>(a)...);   // may throw: still empty
        engaged_ = true;
        return *p;
    }
    void reset() noexcept {
        if (engaged_) { ptr()->~T(); engaged_ = false; }
    }
    bool has_value() const noexcept { return engaged_; }
    explicit operator bool() const noexcept { return engaged_; }
    T& operator*() noexcept { return *ptr(); }
    const T& operator*() const noexcept { return *ptr(); }
    T* operator->() noexcept { return ptr(); }
    T& value() {
        if (!engaged_) throw std::logic_error("Optional is empty");
        return *ptr();
    }

private:
    T* ptr() noexcept { return std::launder(reinterpret_cast<T*>(buf_)); }
    const T* ptr() const noexcept { return std::launder(reinterpret_cast<const T*>(buf_)); }
    alignas(T) std::byte buf_[sizeof(T)];
    bool engaged_ = false;
};

static int live = 0;
struct Tracked {
    std::string s;
    explicit Tracked(std::string v) : s(std::move(v)) {
        if (s.empty()) throw std::invalid_argument("empty");
        ++live;
    }
    Tracked(const Tracked& o) : s(o.s) { ++live; }
    Tracked(Tracked&& o) noexcept : s(std::move(o.s)) { ++live; }
    Tracked& operator=(const Tracked&) = default;
    Tracked& operator=(Tracked&&) noexcept = default;
    ~Tracked() { --live; }
};

int main() {
    {
        Optional<Tracked> a;
        std::printf("a empty: %d, live %d\n", !a, live);
        a.emplace("alpha, long enough to allocate on the heap");
        Optional<Tracked> b = a;                     // copy construct
        Optional<Tracked> c = std::move(a);          // move construct (a stays engaged, moved-from)
        std::printf("b %.5s, c %.5s, live %d\n", b->s.c_str(), c->s.c_str(), live);
        b = Optional<Tracked>();                     // assign empty: destroys b's value
        std::printf("b empty: %d, live %d\n", !b, live);
        try {
            c.emplace("");                           // constructor throws
        } catch (const std::exception& e) {
            std::printf("emplace threw %s, c empty: %d, live %d\n", e.what(), !c, live);
        }
        try {
            c.value();
        } catch (const std::logic_error& e) {
            std::printf("value(): %s\n", e.what());
        }
    }
    std::printf("after scope: live %d\n", live);
    std::printf("sizeof Optional<int> %zu, Optional<double> %zu\n", sizeof(Optional<int>), sizeof(Optional<double>));
}
a empty: 1, live 0
b alpha, c alpha, live 3
b empty: 1, live 2
emplace threw empty, c empty: 1, live 1
value(): Optional is empty
after scope: live 0
sizeof Optional<int> 8, Optional<double> 16

Complexity and pitfalls. O(1) apart from T's own operations, no heap use by Optional. Pitfalls: setting engaged_ before construction (a throwing constructor leaves a "full" optional with garbage); forgetting reset in emplace (leaks the old value); using the moved-from source as if empty (a moved-from optional is still engaged, as with std::optional); missing std::launder when recomputing the pointer. The real std::optional uses a union so that it can be constexpr and trivially copyable when T is.

Estimated rating: ~1800 · four-way assignment logic, exception safety and lifetime rules in one class

CodeImplement StaticVector<T, N>: a vector with inline storage for at most N elements and no heap allocation. Provide push_back (copy and move), emplace_back, pop_back, operator[], size, clear, begin/end, a copy constructor that cleans up if an element copy throws, and a destructor. emplace_back on a full vector throws std::length_error. (This is C++26 std::inplace_vector.)

Answer

Approach. An aligned byte array of N * sizeof(T) and a size counter. Elements [0, size) are live. emplace_back checks capacity, constructs at slot size with construct_at, then increments. pop_back decrements and destroys. clear destroys in reverse. The copy constructor appends element by element inside try; on failure it clears what it built and rethrows, because the destructor of a partially constructed object never runs.

#include <cstddef>
#include <cstdio>
#include <memory>
#include <new>
#include <stdexcept>
#include <string>
#include <utility>

template <class T, std::size_t N>
class StaticVector {
public:
    StaticVector() = default;
    StaticVector(const StaticVector& o) {
        try {
            for (const T& x : o) push_back(x);
        } catch (...) {
            clear();
            throw;
        }
    }
    StaticVector& operator=(const StaticVector&) = delete;   // kept short; see pitfalls
    ~StaticVector() { clear(); }

    template <class... A>
    T& emplace_back(A&&... a) {
        if (size_ == N) throw std::length_error("StaticVector is full");
        T* p = std::construct_at(slot(size_), std::forward<A>(a)...);
        ++size_;                          // only after the constructor succeeded
        return *p;
    }
    void push_back(const T& v) { emplace_back(v); }
    void push_back(T&& v) { emplace_back(std::move(v)); }
    void pop_back() noexcept { std::destroy_at(&(*this)[--size_]); }
    void clear() noexcept { while (size_) pop_back(); }   // reverse order

    T& operator[](std::size_t i) noexcept { return *std::launder(slot(i)); }
    const T& operator[](std::size_t i) const noexcept { return *std::launder(cslot(i)); }
    std::size_t size() const noexcept { return size_; }
    static constexpr std::size_t capacity() noexcept { return N; }
    T* begin() noexcept { return size_ ? &(*this)[0] : slot(0); }
    T* end() noexcept { return begin() + size_; }
    const T* begin() const noexcept { return size_ ? &(*this)[0] : cslot(0); }
    const T* end() const noexcept { return begin() + size_; }

private:
    T* slot(std::size_t i) noexcept { return reinterpret_cast<T*>(buf_ + i * sizeof(T)); }
    const T* cslot(std::size_t i) const noexcept { return reinterpret_cast<const T*>(buf_ + i * sizeof(T)); }
    alignas(T) std::byte buf_[N * sizeof(T)];
    std::size_t size_ = 0;
};

static int live = 0;
struct Name {
    std::string s;
    Name(const char* v) : s(v) { ++live; }
    Name(const Name& o) : s(o.s) {
        if (s == "boom") throw std::runtime_error("copy failed");
        ++live;
    }
    Name(Name&& o) noexcept : s(std::move(o.s)) { ++live; }
    ~Name() { --live; }
};

int main() {
    {
        StaticVector<Name, 4> v;
        v.emplace_back("ada");
        v.push_back(Name("grace"));
        v.emplace_back("linus");
        for (const Name& n : v) std::printf("%s ", n.s.c_str());
        std::printf("| size %zu live %d\n", v.size(), live);
        v.pop_back();
        std::printf("after pop: size %zu live %d\n", v.size(), live);
        v.emplace_back("boom");
        v.emplace_back("dennis");
        try {
            v.emplace_back("overflow");
        } catch (const std::length_error& e) {
            std::printf("%s\n", e.what());
        }
        try {
            StaticVector<Name, 4> copy = v;      // the third copy throws
        } catch (const std::exception& e) {
            std::printf("copy: %s, live %d\n", e.what(), live);
        }
    }
    std::printf("after scope: live %d\n", live);
    std::printf("sizeof StaticVector<int, 8> = %zu\n", sizeof(StaticVector<int, 8>));
}
ada grace linus | size 3 live 3
after pop: size 2 live 2
StaticVector is full
copy: copy failed, live 4
after scope: live 0
sizeof StaticVector<int, 8> = 40

Complexity and pitfalls. All operations O(1) except clear and copying, which are O(n); no allocation. The size is N * sizeof(T) plus the counter (32 + 8 for int, 8). Pitfalls: incrementing size_ before construction; destroying forward instead of backward (fine for independent elements, wrong for elements that refer to earlier ones); relying on ~StaticVector to clean up a throwing copy constructor (it does not run); a defaulted copy constructor, which would copy raw bytes of non-trivial objects. Copy assignment is deleted to keep the solution short; a correct one is clear() then append with the same rollback (basic guarantee), or copy-and-swap via a temporary and element-wise moves.

Estimated rating: ~1900 · placement construction with exact liveness tracking and exception-safe copying

CodeWrite RingBuffer<T, N>, a fixed-capacity FIFO queue with inline raw storage: bool push(T) (false when full), std::optional<T> pop() (empty when the queue is empty), size(), and a destructor that destroys exactly the live elements. Elements must be constructed only when pushed and destroyed when popped.

Answer

Approach. Keep head_ (next to pop), count_, and the raw slots. push constructs at (head_ + count_) % N. pop moves the front element into the returned optional, destroys the slot, and advances head_. The destructor pops (destroys) the remaining count_ elements starting at head_, wrapping around.

#include <cstddef>
#include <cstdio>
#include <memory>
#include <new>
#include <optional>
#include <string>
#include <utility>

template <class T, std::size_t N>
class RingBuffer {
public:
    RingBuffer() = default;
    RingBuffer(const RingBuffer&) = delete;
    RingBuffer& operator=(const RingBuffer&) = delete;
    ~RingBuffer() { while (count_) destroy_front(); }

    bool push(T v) {
        if (count_ == N) return false;
        std::construct_at(slot((head_ + count_) % N), std::move(v));
        ++count_;
        return true;
    }
    std::optional<T> pop() {
        if (count_ == 0) return std::nullopt;
        std::optional<T> out(std::move(front()));
        destroy_front();
        return out;
    }
    std::size_t size() const noexcept { return count_; }

private:
    T& front() noexcept { return *std::launder(slot(head_)); }
    void destroy_front() noexcept {
        std::destroy_at(&front());
        head_ = (head_ + 1) % N;
        --count_;
    }
    T* slot(std::size_t i) noexcept { return reinterpret_cast<T*>(buf_ + i * sizeof(T)); }
    alignas(T) std::byte buf_[N * sizeof(T)];
    std::size_t head_ = 0, count_ = 0;
};

static int live = 0;
struct Msg {
    std::string text;
    explicit Msg(std::string t) : text(std::move(t)) { ++live; }
    Msg(Msg&& o) noexcept : text(std::move(o.text)) { ++live; }
    ~Msg() { --live; }
};

int main() {
    {
        RingBuffer<Msg, 3> q;
        bool a = q.push(Msg("a"));
        bool b = q.push(Msg("b"));
        bool c = q.push(Msg("c"));
        bool d = q.push(Msg("d"));                // full
        std::printf("push: %d %d %d %d\n", a, b, c, d);
        std::printf("live after pushes: %d\n", live);
        auto first = q.pop();
        std::printf("popped %s, size %zu\n", first->text.c_str(), q.size());
        q.push(Msg("e"));                          // wraps around to slot 0
        while (auto m = q.pop()) std::printf("%s ", m->text.c_str());
        std::printf("| empty pop: %d\n", !q.pop().has_value());
        q.push(Msg("left in the queue"));
        std::printf("live before destruction: %d\n", live);
    }
    std::printf("after scope: live %d\n", live);
}
push: 1 1 1 0
live after pushes: 3
popped a, size 2
b c e | empty pop: 1
live before destruction: 2
after scope: live 0

Complexity and pitfalls. O(1) push and pop. "live before destruction: 2" counts the queued message plus first, which still holds "a". Pitfalls: destroying all N slots in the destructor instead of only the live ones (destructors on raw memory); forgetting to wrap indices; putting the four push calls directly into one printf call would make the test nondeterministic, because the evaluation order of function arguments is unspecified (GCC and Clang really do differ). Using a power-of-two N lets the modulo become a mask.

Estimated rating: ~1600 · raw slots with wrap-around and exact destruction of live elements

CodeImplement copy_construct_n(src, n, dst), which copy-constructs n objects into uninitialised storage at dst with the strong guarantee: if any copy throws, every object already constructed is destroyed (in reverse order) and the exception propagates. Show it next to std::uninitialized_copy_n.

Answer

Approach. Count constructed elements in i. Construct in a try; in the handler destroy dst[i-1] down to dst[0], then rethrow. The function owns the partially built range only during the call; on success the caller owns [dst, dst+n).

#include <cstddef>
#include <cstdio>
#include <memory>
#include <new>
#include <stdexcept>
#include <string>

template <class T>
T* copy_construct_n(const T* src, std::size_t n, T* dst) {
    std::size_t i = 0;
    try {
        for (; i < n; ++i) std::construct_at(dst + i, src[i]);
    } catch (...) {
        while (i > 0) std::destroy_at(dst + --i);   // reverse order
        throw;
    }
    return dst + n;
}

static int live = 0;
struct Cell {
    std::string s;
    int fail_on_copy;
    Cell(std::string v, int f) : s(std::move(v)), fail_on_copy(f) { ++live; }
    Cell(const Cell& o) : s(o.s), fail_on_copy(o.fail_on_copy) {
        if (fail_on_copy) throw std::runtime_error("copy of " + s + " failed");
        ++live;
    }
    ~Cell() { --live; }
};

int main() {
    const Cell src[4] = {{"w", 0}, {"x", 0}, {"y", 1}, {"z", 0}};
    alignas(Cell) std::byte raw[4 * sizeof(Cell)];
    Cell* dst = reinterpret_cast<Cell*>(raw);
    std::printf("live before: %d\n", live);
    try {
        copy_construct_n(src, 4, dst);
    } catch (const std::exception& e) {
        std::printf("mine: %s, live %d\n", e.what(), live);
    }
    try {
        std::uninitialized_copy_n(src, 4, dst);
    } catch (const std::exception& e) {
        std::printf("std: %s, live %d\n", e.what(), live);
    }
    Cell* end = copy_construct_n(src, 2, dst);      // succeeds
    std::printf("copied %td, live %d\n", end - dst, live);
    std::destroy(dst, end);
    std::printf("after destroy: live %d\n", live);
}
live before: 4
mine: copy of y failed, live 4
std: copy of y failed, live 4
copied 2, live 6
after destroy: live 4

Complexity and pitfalls. O(n) copies. Pitfalls: destroying dst[i] (the one that threw was never constructed); destroying all n; swallowing the exception; catching only std::exception (any type can be thrown). This loop is the building block for vector's copy constructor and for reallocation when the move constructor is not noexcept.

Estimated rating: ~1300 · short, but the off-by-one in the rollback is exactly what interviewers look for

CodeBuild an Arena with create<T>(args...) that bump-allocates correctly aligned storage from one heap block and placement-constructs a T. reset() must run the destructors of all non-trivially destructible objects in reverse creation order and make the memory reusable. Trivially destructible objects must not cost any bookkeeping. Throw std::bad_alloc when full. Test with strings and an alignas(64) type.

Answer

Approach. allocate(size, align) uses std::align on the cursor. For non-trivially destructible T, also allocate a small Finalizer node (function pointer, object pointer, next) and push it on a linked list; LIFO order gives reverse destruction for free. Allocate the node before constructing the object, so that a failure to get the node cannot leave a constructed object with no destructor registered. If the constructor throws, the space is simply wasted until reset.

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <new>
#include <string>
#include <type_traits>
#include <utility>

class Arena {
public:
    explicit Arena(std::size_t cap) : buf_(static_cast<std::byte*>(::operator new(cap))), cap_(cap) {}
    Arena(const Arena&) = delete;
    Arena& operator=(const Arena&) = delete;
    ~Arena() { reset(); ::operator delete(buf_); }

    void* allocate(std::size_t n, std::size_t align) {
        void* p = buf_ + used_;
        std::size_t space = cap_ - used_;
        if (!std::align(align, n, p, space)) throw std::bad_alloc();
        used_ = (cap_ - space) + n;
        return p;
    }

    template <class T, class... A>
    T* create(A&&... a) {
        if constexpr (std::is_trivially_destructible_v<T>) {
            return ::new (allocate(sizeof(T), alignof(T))) T(std::forward<A>(a)...);
        } else {
            void* node = allocate(sizeof(Finalizer), alignof(Finalizer));
            void* mem = allocate(sizeof(T), alignof(T));
            T* obj = ::new (mem) T(std::forward<A>(a)...);
            fins_ = ::new (node) Finalizer{[](void* p) noexcept { static_cast<T*>(p)->~T(); }, obj, fins_};
            return obj;
        }
    }

    void reset() noexcept {
        for (Finalizer* f = fins_; f; f = f->next) f->destroy(f->obj);   // newest first
        fins_ = nullptr;
        used_ = 0;
    }
    std::size_t used() const noexcept { return used_; }

private:
    struct Finalizer {
        void (*destroy)(void*) noexcept;
        void* obj;
        Finalizer* next;
    };
    std::byte* buf_;
    std::size_t cap_, used_ = 0;
    Finalizer* fins_ = nullptr;
};

struct Loud {
    std::string name;
    explicit Loud(std::string n) : name(std::move(n)) {}
    ~Loud() { std::printf("~%s\n", name.c_str()); }
};
struct alignas(64) CacheLine { int v[16]; };

int main() {
    Arena arena(1024);
    int* n = arena.create<int>(7);
    auto* a = arena.create<Loud>("first loud object, long enough to own heap memory");
    auto* line = arena.create<CacheLine>();
    auto* b = arena.create<Loud>("second");
    line->v[15] = *n;
    std::printf("cache line aligned: %d, value %d\n",
                reinterpret_cast<std::uintptr_t>(line) % 64 == 0, line->v[15]);
    std::printf("%.5s %s\n", a->name.c_str(), b->name.c_str());

    std::puts("reset:");
    arena.reset();
    std::printf("used after reset %zu\n", arena.used());
    try {
        for (;;) arena.create<CacheLine>();
    } catch (const std::bad_alloc&) {
        std::puts("arena full");
    }
}
cache line aligned: 1, value 7
first second
reset:
~second
~first loud object, long enough to own heap memory
used after reset 0
arena full

Complexity and pitfalls. O(1) per create, O(k) reset for k non-trivial objects. The heap block from ::operator new is only guaranteed 16-byte aligned, but std::align handles larger alignments by skipping bytes. Pitfalls: forgetting destructors (leaks the strings' buffers); destroying in creation order; registering the finaliser after construction with an allocation that can throw (the object would never be destroyed); handing out pointers that outlive reset. Ch 17 covers arenas and std::pmr::monotonic_buffer_resource in depth.

Estimated rating: ~2000 · alignment arithmetic, type-erased destructors and ordering under exceptions

CodeWrite NoDestructor<T>: a wrapper that constructs a T in inline storage and deliberately never destroys it. Use it for a function-local static registry, and show why it matters: a global object's destructor uses the registry during program shutdown, after an ordinary function-local static would already have been destroyed.

Answer

Approach. Construct T with placement new into alignas(T) std::byte storage in the constructor, and give NoDestructor a trivial (defaulted) destructor that does not touch T. Function-local statics are constructed on first use (thread-safely since C++11) and destroyed in reverse order of construction completion at exit; a NoDestructor registry is never destroyed, so it stays usable by any destructor that runs during shutdown. The leaked memory is reclaimed by the OS at exit and is still reachable through the static, so leak checkers do not report it.

#include <cstddef>
#include <cstdio>
#include <map>
#include <new>
#include <string>
#include <utility>

template <class T>
class NoDestructor {
public:
    template <class... A>
    explicit NoDestructor(A&&... a) { ::new (static_cast<void*>(storage_)) T(std::forward<A>(a)...); }
    NoDestructor(const NoDestructor&) = delete;
    NoDestructor& operator=(const NoDestructor&) = delete;
    ~NoDestructor() = default;                      // T is intentionally never destroyed

    T& get() noexcept { return *std::launder(reinterpret_cast<T*>(storage_)); }
    T* operator->() noexcept { return &get(); }
private:
    alignas(T) std::byte storage_[sizeof(T)];
};

static std::map<std::string, int>& registry() {
    static NoDestructor<std::map<std::string, int>> r;   // constructed on first use, never destroyed
    return r.get();
}

struct Plugin {
    std::string name;
    explicit Plugin(std::string n) : name(std::move(n)) { registry()[name] = 1; }
    ~Plugin() {
        registry().erase(name);                       // runs during static destruction
        std::printf("unregistered %s, %zu left\n", name.c_str(), registry().size());
    }
};

static Plugin audio("audio");    // constructed before main; the registry is created inside its constructor
static Plugin video("video");

int main() {
    std::printf("registered: %zu\n", registry().size());
    std::printf("sizeof(NoDestructor<std::string>) == sizeof(std::string): %d\n",
                sizeof(NoDestructor<std::string>) == sizeof(std::string));
}
registered: 2
sizeof(NoDestructor<std::string>) == sizeof(std::string): 1
unregistered video, 1 left
unregistered audio, 0 left

Complexity and pitfalls. Zero overhead: same size as T, one placement construction. Without NoDestructor this particular program would also work, because the registry's static finishes construction during audio's constructor and is therefore destroyed after audio; but as soon as some other static touches registry() first, or plugins live in another translation unit with a different initialisation order, a destructor can run against a destroyed map. NoDestructor removes the order question entirely. Pitfalls: using it for objects whose destructor must run (flushing a log file); destroying it manually; forgetting launder when recomputing the pointer. Chromium's base::NoDestructor and Abseil's absl::NoDestructor are production versions.

Estimated rating: ~1500 · small class, but explaining static destruction order is the real test

Cheat sheet

Theory
  • new (p) T(args) constructs at p without allocating. In generic code use std::construct_at(p, args...) (C++20, constexpr) or ::new (static_cast<void*>(p)) T(...).
  • Storage must be big enough and aligned: alignas(T) std::byte buf[sizeof(T)];. std::aligned_storage is deprecated in C++23. Use std::align inside larger buffers.
  • Destroy with p->~T() or std::destroy_at(p), exactly once, on every path. Never delete a placement object. Destroy in reverse order to mimic the language.
  • Placement operator delete(void*, Args...) is called only when the constructor throws in the matching placement new expression; declare it for every custom placement operator new.
  • Keep the pointer placement new returned. Use std::launder when you recompute a T* from raw storage or the type changed. C++20 lets old pointers see a same-type replacement even with const members.
  • C++20 implicit object creation makes malloc/memcpy of implicit-lifetime types (aggregates, scalars, arrays) well-defined; non-trivial types still need construction.
  • vector, optional, variant and SBO types are raw storage plus placement construction plus a liveness record (size, bool, index). Set the record only after the constructor succeeds.
  • Avoid placement array new: construct elements in a loop or with std::uninitialized_*, which roll back on exceptions.

Part E · Memory management · Chapter 15

Memory pools

A memory pool hands out fixed-size blocks from memory it already owns, so allocation becomes a pointer pop and freeing becomes a pointer push. This chapter builds pools from the free list up, covers size classes, thread safety, std::pmr and STL allocators, and shows how to debug and measure them. Low-latency firms and game studios ask about pools because they show whether you understand what malloc really costs.

TheoryDrillsCoding· about 5 hours· prerequisites: Ch 9, Ch 10, Ch 14

Why pools exist

Theory

Mental model. malloc is a general-purpose service: any size, any thread, any order of frees, and memory can go back to the OS. A pool gives up that generality. It serves one block size (or a few), usually from one thread, and keeps its memory until it is destroyed. In exchange, every allocation costs a handful of instructions and takes the same time every time.

What malloc actually does

A modern allocator (glibc ptmalloc, jemalloc, tcmalloc, mimalloc, macOS libmalloc) works roughly like this on each call:

  • Map the request size to a size class and look in a per-thread cache for a free block of that class.
  • If the cache is empty, refill it from a shared arena, which may take a lock or an atomic operation.
  • If the arena is empty, grow the heap with brk or mmap. That is a system call, and the first touch of each new page is a page fault (a few hundred nanoseconds to microseconds).
  • On free, find the block's metadata (a header before the block, or a page map lookup), handle frees that come from a different thread, and sometimes coalesce neighbours or return pages with madvise.

The fast path is good, often 10 to 30 ns. The problem is the variance: slow paths happen at moments you do not control, and a trading system that answers a tick in 2 microseconds cannot afford a 20 microsecond page fault on one order in ten thousand.

What a pool buys you

  • Constant, small cost. Allocation pops the head of a singly linked list. Freeing pushes onto it. No size lookup, no lock, no system call.
  • No external fragmentation inside the pool. Every block has the same size, so any free block fits any request. See Ch 10 for the general fragmentation story.
  • Predictable memory. You can allocate and touch all of it at start-up (and mlock it), so no page faults happen on the hot path.
  • Locality. Blocks sit next to each other in a few large chunks, and a LIFO free list returns the block that was freed most recently, which is probably still in cache.
  • Bulk teardown. Destroying the pool frees everything at once, which is useful when the objects die together.

The costs: memory is held at its peak until the pool dies, a block cannot be bigger than the block size, and a pool is one more piece of code that can corrupt memory in ways tools find hard to see (debugging pools covers this).

Know this cold

Interviewers want the trade-off in one sentence: a pool trades generality (any size, any order, any thread, memory returned to the OS) for constant-time allocation, no external fragmentation and no latency spikes. If you only say "pools are faster", you have missed the point that the tail latency is what matters on hot paths.

Fixed-size pools and the intrusive free list

Theory

Mental model. Take one big buffer and cut it into equal blocks. A free block is not being used for anything, so it can store the pointer to the next free block in its own first bytes. The pool keeps one pointer, head, to the first free block. That chain of pointers threaded through the free blocks is the intrusive free list: the list nodes are the free blocks themselves, so it costs no extra memory.

one buffer of 8 blocks, 64 bytes each head in use[0] next[1] in use[2] next[3] next[4] in use[5] next[6] in use[7] null free list order: head → [3] → [1] → [6] → [4] → null. allocate() pops [3]; deallocate(p) pushes p in front of head.
The free list lives inside the free blocks. Each free block stores the address of the next free block in its first 8 bytes. In-use blocks hold user data and are not on the list. The order is whatever the frees produced, not address order.

The core code

This is the whole idea in 30 lines. The pool computes a block size that can hold both the user's object and a pointer, carves the buffer, and pushes every block onto the list.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <new>

class FixedPool {
    struct Node { Node* next; };
    static constexpr std::size_t kAlign = __STDCPP_DEFAULT_NEW_ALIGNMENT__;   // what operator new guarantees
    static std::size_t round_up(std::size_t n, std::size_t a) { return (n + a - 1) / a * a; }

    std::size_t block_;
    std::byte* buf_;
    Node* head_ = nullptr;

public:
    FixedPool(std::size_t object_size, std::size_t count)
        : block_(round_up(std::max(object_size, sizeof(Node)), kAlign)),
          buf_(static_cast<std::byte*>(::operator new(block_ * count))) {
        for (std::size_t i = count; i-- > 0;)          // push in reverse so block 0 comes out first
            head_ = ::new (buf_ + i * block_) Node{head_};
    }
    ~FixedPool() { ::operator delete(buf_); }
    FixedPool(const FixedPool&) = delete;
    FixedPool& operator=(const FixedPool&) = delete;

    void* allocate() {
        if (!head_) return nullptr;                    // exhausted
        Node* n = head_;
        head_ = n->next;
        return n;
    }
    void deallocate(void* p) { head_ = ::new (p) Node{head_}; }
    std::size_t block_size() const { return block_; }
};

int main() {
    FixedPool pool(24, 4);
    std::printf("block size %zu\n", pool.block_size());
    void* a = pool.allocate();
    void* b = pool.allocate();
    pool.deallocate(a);
    void* c = pool.allocate();
    std::printf("c reuses a: %s\n", c == a ? "yes" : "no");
    std::printf("b - a = %td bytes\n", static_cast<std::byte*>(b) - static_cast<std::byte*>(a));
    pool.deallocate(b);
    pool.deallocate(c);
}
block size 32
c reuses a: yes
b - a = 32 bytes

Points to notice:

  • allocate and deallocate are a few loads and stores each: O(1), independent of how many blocks are in use.
  • The free list is LIFO: the next allocation gets the block just freed, which is probably still in L1. Good for speed, bad for catching use-after-free (the stale pointer now aliases a live object).
  • Placement new (Ch 14) starts a Node's lifetime in freed storage, and the caller placement-news its own object into the block it gets, so no object is read through the wrong type.
  • ::operator new returns memory aligned to at least __STDCPP_DEFAULT_NEW_ALIGNMENT__ (16 on mainstream 64-bit platforms), and the block size is a multiple of that, so every block is suitably aligned for any type that is not over-aligned. Do not assume alignof(std::max_align_t) is the same number: on Apple arm64 it is 16 with GCC but 8 with Clang, so a pool that rounds to max_align_t gets different block sizes from the two compilers.

Alignment and the minimum block size

Two rules decide the block size:

  1. It must hold a free-list pointer, so it is at least sizeof(void*) (8 bytes on 64-bit). A pool of char or int16_t still uses 8-byte blocks, unless you store 16-bit or 32-bit indices instead of pointers.
  2. It must be a multiple of the strictest alignment you serve. If block 0 is aligned to 16 and the block size is 24, block 1 starts at offset 24, which is only 8-aligned. A type with alignas(16) placed there is misaligned, which is undefined behaviour and can fault with SIMD loads.

The rounding formula for a power-of-two alignment a is (n + a - 1) & ~(a - 1), which equals (n + a - 1) / a * a. For a pool dedicated to one type T, use block = round_up(max(sizeof(T), sizeof(void*)), max(alignof(T), alignof(void*))), and make the buffer itself at least that aligned (::operator new(size, std::align_val_t{alignof(T)}) for over-aligned types, paired with the matching aligned operator delete).

A neat way to express a typed block is a union:

template <class T>
union Slot {
    Slot* next;                                  // valid while free
    alignas(T) unsigned char storage[sizeof(T)]; // valid while in use
};
// sizeof(Slot<T>) and alignof(Slot<T>) are already the max of both members, rounded correctly.

Why both operations are O(1), and what "O(1)" hides

Neither operation loops or searches, so both are constant time in the strict sense. The constant still includes a possible cache miss: popping the head reads head->next, which lives in the block itself. If that block has not been touched for a while, the pop costs a trip to L2 or DRAM. Prefetching the next head on each pop (__builtin_prefetch(head_)) is a known trick in very hot pools.

Key factsFixed-size poolGeneral-purpose malloc
AllocatePop the free-list head: O(1), a few nsFast path 10 to 30 ns; slow path takes locks, system calls, page faults
FreePush onto the head: O(1)Metadata lookup, cross-thread handling, sometimes coalescing
Sizes servedOne block size (or a few classes)Any size
Per-block overheadNone: the link lives inside free blocksA header or page-map metadata, plus size-class rounding
External fragmentationNone inside the poolPossible over long runs
Internal fragmentationBlock size minus object sizeSize-class rounding
Thread safetyNone unless you add itYes
Memory back to the OSWhen the pool is destroyedSometimes (trim, madvise)
Minimum blocksizeof(void*), rounded to the alignmentUsually 16 bytes

Growing by chunks vs a fixed capacity

Theory

A pool needs a policy for the moment the free list is empty.

  • Fixed capacity. Allocate all blocks up front and return nullptr (or throw, or fall back to malloc) when they run out. This is the HFT choice: size the pool for the worst day, pre-fault the pages at start-up, and treat exhaustion as an alarm. Memory use is known before the market opens.
  • Grow by chunks. When the list is empty, get a new chunk of N blocks from the upstream allocator, thread its blocks onto the free list and continue. Chunks are often doubled (64, 128, 256 blocks) to keep the number of upstream calls logarithmic. The chunks are linked together so the destructor can free them.

Growing never moves existing blocks, so pointers into the pool stay valid (unlike a std::vector of objects). Freed blocks are not returned upstream: a chunk can go back only when all its blocks are free, which needs per-chunk counters, so most pools keep every chunk until destruction.

Pool chunks free_head chunk 2 (newest, 8 blocks) chunk 1 (4 blocks) chunk.next The free list (key colour) can cross chunks. Chunks are linked only so the destructor can free them. Growth adds a chunk and pushes its blocks; old blocks never move. A chunk can return to the OS only when all of its blocks are free.
Growing pools chain chunks. The pool keeps a list of chunks for cleanup and one free list that threads through free blocks in any chunk. Filled boxes in the accent colour are in use, soft boxes are free.

The chunk header can live at the start of each chunk (one allocation per chunk), or the pool can keep a std::vector<std::unique_ptr<std::byte[]>> of chunks. The second is simpler and the cost is irrelevant because it runs once per chunk.

Tip

Pre-faulting matters as much as pre-allocating. operator new for a large buffer usually maps fresh pages that the kernel has not backed yet, so the first write to each 4 KiB (16 KiB on Apple Silicon) page still faults. Touch every page at start-up (write one byte per page, or memset the buffer) and, on Linux, consider mlock or MAP_POPULATE.

Multiple size classes

Theory

One pool serves one size. A small-object allocator serves many sizes by keeping an array of pools, one per size class, and routing each request to the smallest class that fits. This is exactly how jemalloc, tcmalloc and mimalloc work internally, and it is how std::pmr::unsynchronized_pool_resource is built.

  • Mapping. A common scheme is 16-byte steps up to 128 bytes, then powers of two up to a limit such as 4 KiB. The index is (n + 15) / 16 - 1 in the small range, or std::bit_width(n - 1) for powers of two.
  • Internal fragmentation. A 65-byte request in a power-of-two scheme takes 128 bytes, wasting 63 (49 percent). Finer classes waste less but need more pools, each with partly empty chunks.
  • Large requests above the biggest class go straight to the upstream allocator.
  • Freeing needs the size. The pool must know which class a pointer belongs to. Either the caller passes the size back (C++ sized deallocation, std::allocator::deallocate(p, n) and memory_resource::deallocate(p, bytes, align) all do), or the allocator looks it up from the address (page-to-class maps in jemalloc and tcmalloc).
// 16-byte classes: 1..16 -> 0, 17..32 -> 1, ..., 113..128 -> 7
constexpr std::size_t size_class(std::size_t n) { return (n + 15) / 16 - 1; }
static_assert(size_class(1) == 0 && size_class(16) == 0 && size_class(17) == 1 && size_class(128) == 7);

The coding section builds a complete size-class allocator with a fallback to malloc.

Thread safety options

Theory

The basic pool is a single-threaded data structure: two threads popping the head at the same time can both get the same block. There are three standard fixes, in increasing order of difficulty.

1. A mutex around the pool

Simple and correct. An uncontended std::mutex lock and unlock costs roughly 10 to 25 ns (two atomic read-modify-write operations on the lock word), which is often more than the pool operation itself, and under contention threads queue on the lock (Ch 5 explains the futex path). Fine when allocation is not the bottleneck.

2. Thread-local pools

Give each thread its own pool (thread_local, or one pool per worker passed explicitly). No synchronisation at all on the fast path. The hard case is a cross-thread free: thread A allocates an order object, hands it to thread B, and B frees it. Options:

  • Forbid it by design: the object goes back to the thread that owns it (via a queue) and is freed there.
  • Each pool has a lock-free "remote free" stack that other threads push to, and the owner drains it into its local list when the local list runs dry. mimalloc and snmalloc use this design.
  • Let the block join the freeing thread's pool. This works only if all pools share one block size and pools never free their chunks, and memory can drift between threads.

3. A lock-free free list and the ABA problem

The free list is a stack, so the classic lock-free version is a Treiber stack: pop reads head and head->next, then uses compare-and-swap to move head from the old value to next. It fails in a specific way called ABA.

Thread 1 Thread 2 old = head (A) next = A→next (B) … preempted … pop A pop B (B now in use) push A head = A again CAS(head, A → B) succeeds: head = B The CAS only compares the address. Head is A again, so it succeeds and installs B, a block thread 2 is still using. The next pop hands B out a second time. Fix: compare a (pointer, version) pair, or use hazard pointers or epochs so A cannot be reused mid-pop.
ABA. The head went from A to something else and back to A, so a pointer-only compare-and-swap cannot tell that the list changed.

Standard fixes, at the level interviews expect:

  • Tagged pointers / version counters. Store a counter next to the head and increment it on every change. CAS compares both, so A-with-version-5 is different from A-with-version-7. Needs a double-width CAS (cmpxchg16b on x86-64, CASP on arm64 with LSE), or pack a 32-bit block index with a 32-bit tag into one 64-bit word. The coding section uses the index trick.
  • Hazard pointers or epoch-based reclamation. A thread announces which node it is reading, and nobody reuses that node until the announcement is cleared. C++26 adds <hazard_pointer> (P2530) and <rcu> (P2545), but neither GCC 16's libstdc++ nor libc++ ships those headers yet, so today you use a library such as Folly or write your own.
  • LL/SC hardware. ARM's load-exclusive / store-exclusive fails if the location was written at all, which avoids ABA in principle, but C++ only exposes CAS, so you cannot rely on it from portable code.

There is a second, quieter problem: reading A->next while another thread may be writing the user's data into A is a data race in the C++ memory model. Lock-free free lists make next an atomic or use indices into an array of atomics. Pools never return memory to the OS, which at least means the read cannot fault.

Interview trap

"Just use std::atomic<Node*> and CAS" is the answer that gets the follow-up "what about ABA?". Have the timeline ready (pop A, pop B, push A, stale CAS succeeds) and name one fix. Also say that a mutex or thread-local pools are usually the better engineering choice: lock-free is not automatically faster under contention, because the cache line holding head still bounces between cores.

std::pmr: polymorphic memory resources

Theory

Mental model. C++17's <memory_resource> separates what a container is from where its memory comes from. A std::pmr::memory_resource is an abstract class with a virtual allocate and deallocate. A std::pmr::polymorphic_allocator<T> is a small allocator that holds a pointer to a resource and forwards to it. Because the allocator type is always the same, std::pmr::vector<int> backed by a pool and one backed by new have the same type, and you can pass both to the same function.

memory_resource and the standard resources

class memory_resource {
public:
    void* allocate(std::size_t bytes, std::size_t align = alignof(std::max_align_t));
    void  deallocate(void* p, std::size_t bytes, std::size_t align = alignof(std::max_align_t));
    bool  is_equal(const memory_resource& other) const noexcept;
private:   // you override these
    virtual void* do_allocate(std::size_t bytes, std::size_t align) = 0;
    virtual void  do_deallocate(void* p, std::size_t bytes, std::size_t align) = 0;
    virtual bool  do_is_equal(const memory_resource& other) const noexcept = 0;
};
ResourceBehaviourThread safe
new_delete_resource()Calls global operator new / delete (with alignment). The initial default resource.Yes
null_memory_resource()Every allocate throws std::bad_alloc. Used as an upstream to prove nothing spills to the heap.Yes
unsynchronized_pool_resourceSize-class pools carved from chunks obtained upstream. Requests larger than largest_required_pool_block go straight upstream.No
synchronized_pool_resourceSame interface, safe to share between threads. libc++ wraps the unsynchronised pool in a mutex; libstdc++ keeps per-thread pools behind a shared mutex.Yes
monotonic_buffer_resourceA bump-pointer arena: deallocate is a no-op. Covered in Ch 17.No

Facts that come up:

  • std::pmr::pool_options has two fields, max_blocks_per_chunk and largest_required_pool_block; implementations may adjust them, and chunks grow geometrically up to the maximum.
  • release() (also called by the destructor) returns everything upstream, even blocks never deallocated. A plain deallocate puts the block back in the pool, not upstream.
  • get_default_resource() and set_default_resource() manage a process-wide default. A default-constructed polymorphic_allocator captures the default at construction time.
  • Pool resources are not copyable and must outlive every container that uses them.

polymorphic_allocator and pmr containers

Every standard container has a std::pmr alias, for example std::pmr::vector<T> is std::vector<T, std::pmr::polymorphic_allocator<T>>. The allocator is scoped: when a pmr::vector<pmr::string> constructs an element, uses-allocator construction passes the vector's allocator to the string, so the characters come from the same resource. That is the main reason to prefer pmr over a hand-written allocator for nested containers.

Propagation rules are the classic source of surprises:

  • Copy construction calls select_on_container_copy_construction(), which for polymorphic_allocator returns a default-constructed allocator. The copy uses the default resource, not the source's resource, so a hot path that copies pmr containers quietly calls operator new. Pass the resource explicitly: std::pmr::vector<T> copy(src, &pool);.
  • Move construction moves the allocator, so the new container keeps the same resource and steals the buffer.
  • Copy and move assignment do not propagate the allocator (propagate_on_container_*_assignment are false). Move-assigning between containers with unequal resources moves element by element, O(n), and may allocate.
  • swap between containers with unequal resources is undefined behaviour.

C++20 made polymorphic_allocator<> (defaulting to std::byte) a general-purpose "vocabulary" allocator with allocate_bytes, allocate_object<T>, new_object<T>(args...) and delete_object(p).

#include <cstddef>
#include <cstdio>
#include <list>
#include <memory_resource>
#include <string>
#include <vector>

// Counts what reaches the upstream: shows that the pool batches requests into chunks.
class CountingResource : public std::pmr::memory_resource {
    std::pmr::memory_resource* up_ = std::pmr::new_delete_resource();
public:
    std::size_t calls = 0, live = 0;
private:
    void* do_allocate(std::size_t n, std::size_t a) override { ++calls; live += n; return up_->allocate(n, a); }
    void do_deallocate(void* p, std::size_t n, std::size_t a) override { live -= n; up_->deallocate(p, n, a); }
    bool do_is_equal(const memory_resource& o) const noexcept override { return this == &o; }
};

int main() {
    CountingResource upstream;
    {
        std::pmr::unsynchronized_pool_resource pool(
            std::pmr::pool_options{.max_blocks_per_chunk = 256, .largest_required_pool_block = 512}, &upstream);

        std::pmr::list<int> nodes(&pool);
        for (int i = 0; i < 10000; ++i) nodes.push_back(i);
        std::printf("10000 list nodes took fewer than 100 upstream calls: %s\n", upstream.calls < 100 ? "yes" : "no");

        std::pmr::vector<std::pmr::string> names(&pool);
        names.emplace_back("a string long enough to need a heap buffer");
        std::printf("element uses the pool: %s\n", names[0].get_allocator().resource() == &pool ? "yes" : "no");

        auto copy = names;   // copy construction: default resource
        std::printf("copy uses the pool: %s\n", copy.get_allocator().resource() == &pool ? "yes" : "no");
        auto moved = std::move(names);   // move construction: same resource
        std::printf("moved-to uses the pool: %s\n", moved.get_allocator().resource() == &pool ? "yes" : "no");
    }
    std::printf("bytes still held upstream after the pool died: %zu\n", upstream.live);
}
10000 list nodes took fewer than 100 upstream calls: yes
element uses the pool: yes
copy uses the pool: no
moved-to uses the pool: yes
bytes still held upstream after the pool died: 0

Writing an STL-compatible allocator

Theory

Before pmr, the way to put a container on a pool was a custom allocator type. It is still common in trading code because it avoids the virtual call and lets the compiler inline the pool. Since C++11, std::allocator_traits fills in almost everything, so the minimal allocator is short.

The minimal requirements

  • using value_type = T;
  • T* allocate(std::size_t n): room for n objects, uninitialised. Throws on failure (do not return null).
  • void deallocate(T* p, std::size_t n): n is the same value passed to allocate.
  • A converting constructor template <class U> A(const A<U>&) noexcept, so the container can make an allocator for a different type from yours.
  • operator==: two allocators compare equal when memory from one can be freed through the other. (!= is generated from == in C++20.)
  • Copies must compare equal to the original and share its memory source. In practice an allocator is a handle (a pointer to the real pool), not the pool itself.

Everything else (construct, destroy, max_size, pointer, the propagate_on_* traits, is_always_equal) has a default in allocator_traits. is_always_equal defaults to std::is_empty, so a stateless allocator is always equal.

rebind: why std::list never allocates a T

std::list<int, A<int>> never allocates an int. It allocates nodes (two pointers and an int), using std::allocator_traits<A<int>>::rebind_alloc<Node>, which for a class template A<T> without a rebind member is simply A<Node>, built from your allocator through the converting constructor. Maps, sets and unordered_map (nodes plus a bucket array) do the same, and deque rebinds for its block map.

Consequences:

  • A pool sized for sizeof(T) is the wrong size for a list. Size the pool from the type the allocator is instantiated with, inside allocate, not from the type the user wrote.
  • Several rebound copies can exist at once (node allocator, bucket allocator), so they must share state through a pointer, and that state must handle different sizes or keep one pool per size.
  • Since C++20 std::allocator no longer has a rebind member. Code that writes A::rebind<U>::other by hand should use allocator_traits instead.
#include <cstddef>
#include <cstdio>
#include <list>
#include <new>
#include <vector>

template <class T>
struct Tracing {
    using value_type = T;
    Tracing() = default;
    template <class U> Tracing(const Tracing<U>&) noexcept {}
    T* allocate(std::size_t n) {
        std::printf("  allocate %zu x %zu bytes%s\n", n, sizeof(T), sizeof(T) == sizeof(int) ? " (an int)" : " (not an int)");
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    void deallocate(T* p, std::size_t) noexcept { ::operator delete(p); }
    template <class U> bool operator==(const Tracing<U>&) const noexcept { return true; }
};

int main() {
    std::puts("vector<int>:");
    std::vector<int, Tracing<int>> v;
    v.reserve(4);
    std::puts("list<int>:");
    std::list<int, Tracing<int>> l;
    l.push_back(1);
    l.push_back(2);
}

Expected output (the node size is 24 bytes on both libstdc++ and libc++ for 64-bit targets, but it is printed indirectly so the test does not depend on it):

vector<int>:
  allocate 4 x 4 bytes (an int)
list<int>:
  allocate 1 x 24 bytes (not an int)
  allocate 1 x 24 bytes (not an int)

Debugging pools

Theory

A pool hides memory bugs. Freed blocks stay inside a buffer that the system allocator considers live, so AddressSanitizer and Valgrind see every access as valid. A use-after-free reads another live object, a double free hands one block out twice, and both surface far from their cause.

Defences, from cheapest to strongest:

  • Poisoning. Fill freed blocks (after the link) with 0xDD and new ones with 0xCD. Stale reads show obvious values, and checking the pattern on allocation catches writes after free.
  • Canaries (guard words) before and after each payload, checked on free, catch overruns.
  • An in-use bit per block turns a double free into an immediate report instead of a corrupted list.
  • Pointer checks. On free, assert the pointer is inside a chunk and on a block boundary: (p - base) % block == 0.
  • ASan manual poisoning. <sanitizer/asan_interface.h> provides ASAN_POISON_MEMORY_REGION(addr, size) and ASAN_UNPOISON_MEMORY_REGION. Poison a block in deallocate (except while the pool itself touches the next pointer) and unpoison it in allocate. ASan then reports use-after-poison at the exact faulty access. The macros compile to nothing when ASan is off. The reading section has a pool that does this.
  • A pass-through switch. A build flag that sends every block to malloc/free lets ASan, Valgrind and heap profilers see everything.
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <new>

// Debug pool: poisons freed blocks and verifies the poison on reuse.
class DebugPool {
    struct Node { Node* next; };
    static constexpr std::size_t kBlock = 32, kCount = 4;
    static constexpr unsigned char kFreed = 0xDD;
    alignas(std::max_align_t) std::byte buf_[kBlock * kCount];
    Node* head_ = nullptr;

    static void poison(void* p) { std::memset(static_cast<std::byte*>(p) + sizeof(Node), kFreed, kBlock - sizeof(Node)); }
    static bool poison_intact(const void* p) {
        auto* b = static_cast<const unsigned char*>(p) + sizeof(Node);
        for (std::size_t i = 0; i < kBlock - sizeof(Node); ++i) if (b[i] != kFreed) return false;
        return true;
    }
public:
    DebugPool() { for (std::size_t i = kCount; i-- > 0;) deallocate(buf_ + i * kBlock); }
    void* allocate() {
        Node* n = head_;
        if (!n) return nullptr;
        head_ = n->next;
        if (!poison_intact(n)) std::printf("pool: block %td was written after it was freed\n", (reinterpret_cast<std::byte*>(n) - buf_) / std::ptrdiff_t{kBlock});
        return n;
    }
    void deallocate(void* p) { poison(p); head_ = ::new (p) Node{head_}; }
};

int main() {
    DebugPool pool;
    auto* counters = static_cast<int*>(pool.allocate());
    counters[0] = 0;
    counters[3] = 0;
    pool.deallocate(counters);
    counters[3] = 42;              // bug: stale pointer written after free
    void* again = pool.allocate(); // LIFO: the same block comes back and the check fires
    pool.deallocate(again);
}
pool: block 0 was written after it was freed

The program is tagged ub because it writes to freed storage on purpose. The pool found the bug at the block's next allocation, not at the write; only ASan poisoning or the pass-through switch catches the write itself.

Measuring the benefit

Theory

Claims like "pools are 10 times faster" are only useful with the workload and the method attached. A sound allocator benchmark:

  1. Uses a realistic pattern. Allocate-then-free in a tight loop flatters every allocator (one block bounces in a thread cache); random churn over a live set of a few thousand objects is closer to real use.
  2. Touches the memory, so cache misses count and the compiler cannot elide the allocation ([expr.new] allows removing new/delete pairs).
  3. Repeats and reports the median plus the spread, as ratios against a baseline measured in the same process.
  4. Looks at the tail (p99, p99.9 of per-operation or per-batch times) when latency matters, since the median hides the spikes that pools remove.
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <memory_resource>
#include <new>
#include <vector>

class FixedPool {
    union Block { Block* next; alignas(std::max_align_t) unsigned char data[64]; };
    std::vector<Block> storage_;
    Block* head_ = nullptr;
public:
    explicit FixedPool(std::size_t n) : storage_(n) {
        for (auto& b : storage_) { b.next = head_; head_ = &b; }
    }
    void* allocate() { Block* b = head_; head_ = b->next; return b; }
    void deallocate(void* p) { auto* b = static_cast<Block*>(p); b->next = head_; head_ = b; }
};

constexpr int kSlots = 4096, kOps = 2'000'000, kReps = 7;

// Random churn: each step frees the object in a random slot, or allocates one if the slot is empty.
template <class Alloc, class Free>
static double churn(Alloc alloc, Free release) {
    std::vector<void*> live(kSlots, nullptr);
    std::uint32_t x = 12345;
    auto t0 = std::chrono::steady_clock::now();
    for (int i = 0; i < kOps; ++i) {
        x = x * 1664525u + 1013904223u;
        void*& s = live[x >> 20];
        if (s) { release(s); s = nullptr; }
        else { s = alloc(); static_cast<char*>(s)[0] = 1; }
    }
    auto t1 = std::chrono::steady_clock::now();
    for (void* p : live) if (p) release(p);
    return std::chrono::duration<double, std::nano>(t1 - t0).count() / kOps;
}

template <class F>
static double median_of(F f) {
    std::vector<double> v;
    for (int r = 0; r < kReps; ++r) v.push_back(f());
    std::sort(v.begin(), v.end());
    std::printf("    runs: min %.2f  max %.2f ns/op\n", v.front(), v.back());
    return v[v.size() / 2];
}

int main() {
    std::puts("malloc/free:");
    double m = median_of([] { return churn([] { return std::malloc(64); }, [](void* p) { std::free(p); }); });
    std::puts("fixed pool:");
    FixedPool pool(kSlots);
    double p = median_of([&] { return churn([&] { return pool.allocate(); }, [&](void* q) { pool.deallocate(q); }); });
    std::puts("pmr::unsynchronized_pool_resource:");
    std::pmr::unsynchronized_pool_resource upr;
    double u = median_of([&] { return churn([&] { return upr.allocate(64); }, [&](void* q) { upr.deallocate(q, 64); }); });
    std::puts("pmr::synchronized_pool_resource:");
    std::pmr::synchronized_pool_resource spr;
    double s = median_of([&] { return churn([&] { return spr.allocate(64); }, [&](void* q) { spr.deallocate(q, 64); }); });
    std::printf("median ns/op: malloc %.2f, pool %.2f, unsync pmr %.2f, sync pmr %.2f\n", m, p, u, s);
    std::printf("speed-up vs malloc: pool %.1fx, unsync pmr %.1fx, sync pmr %.1fx\n", m / p, m / u, m / s);
}

Representative results on an Apple M1 (macOS libmalloc, -O2, median of 7 runs of 2 million operations, 64-byte blocks). The program is tagged ub so timings are never compared.

Allocatorclang + libc++: ns/opratio vs mallocGCC + libstdc++: ns/opratio vs malloc
malloc/free28.21.0×28.41.0×
Fixed pool (free list)6.84.1× faster6.94.1× faster
unsynchronized_pool_resource12.42.3× faster36.60.8× (slower)
synchronized_pool_resource20.61.4× faster46.20.6× (slower)

What to take from it:

  • The hand-written pool is about 4 times faster than malloc with both compilers, and part of its 7 ns is the loop itself, so the pool operation is cheaper still.
  • The two standard libraries' pmr pools differ by about 3 times. libc++ keeps a plain free list per size class, so deallocate is a push. libstdc++ tracks blocks with a bitmap per chunk, so deallocate must first find the chunk that owns the pointer (a binary search over chunks sorted by address). A separate test on the same machine timed shuffled deallocations at about 5 ns on libc++ and 32 to 41 ns on libstdc++, rising with the number of chunks. "Use pmr" is not automatically a speed-up: measure it with your library.
  • Variance is real: the first GCC run after the build had malloc anywhere from 39.6 to 61.4 ns/op. Run the benchmark several times, and quote ratios with the platform (glibc's tcache is very quick for this pattern).

Concept and tricky questions

Drills

ConceptWhy is allocation from a fixed-size pool O(1), and why is it more predictable than malloc, not just faster on average?

Answer

Answer. Allocation pops the head of a singly linked free list and freeing pushes onto it: no search, no size lookup, no lock and no system call. The cost is the same on every call because there is no slow path, apart from a possible cache miss on the block itself.

Why. malloc's fast path is also tens of nanoseconds, so "faster" is only part of the answer. Its slow paths (refilling a thread cache, a lock, mmap or brk, first-touch page faults) cost microseconds at times the caller cannot control, and a pre-faulted pool has none. The common weak answer is "because the pool is in cache"; locality helps, but the structural reason is a constant number of loads and stores.

ConceptWhat does "intrusive" mean in "intrusive free list", and what is the per-block memory overhead of such a pool?

Answer

Answer. The list links are stored inside the elements themselves: each free block holds the pointer to the next free block in its own first bytes. The overhead per block is zero beyond rounding the block up to at least pointer size and to the alignment.

Why. A free block holds no user data, so its bytes can be the list node; once handed out, the user's object overwrites the link, which is no longer needed. malloc keeps a size header or page metadata so free(p) can find the size; a pool does not, because all blocks have one size. Wrong answer: "the list costs one pointer per block", which describes a non-intrusive list stored beside the blocks.

TrickyYou build a pool for char objects (or std::uint16_t). What is the smallest block size it can use on a 64-bit machine, and how would you get closer to the object size?

Answer

Answer. 8 bytes, because each free block must hold a void* for the free list. To approach 1 or 2 bytes per object, store the link as a small index (for example a 16-bit block number) instead of a pointer, or keep a separate bitmap of free blocks.

Why. The intrusive design reuses the block as a list node, so the block must be at least as big as the node and aligned for it. People answer "1 byte" and then write an 8-byte pointer into a 1-byte block, which overwrites the neighbouring blocks and runs off the end of the buffer (the reading section has this bug). With 16-bit indices the block must still be 2 bytes and the pool can hold at most 65,535 blocks. A bitmap costs one bit per block and makes allocation a "find first set bit" scan, which is O(n/64) words in the worst case.

TrickyA colleague writes a pool allocator whose pool is sized for sizeof(T) and uses it as std::list<int, PoolAlloc<int>>. The pool is built for 4-byte blocks. What actually happens?

Answer

Answer. std::list never allocates an int. It rebinds the allocator to its node type (two pointers plus the int, 24 bytes on 64-bit) through allocator_traits::rebind_alloc, which constructs PoolAlloc<Node> from PoolAlloc<int>. If the allocator's pool is keyed on the user's T instead of the type it is instantiated with, it hands out 4-byte blocks for 24-byte nodes and the list writes over neighbouring blocks.

Why. Node-based containers (list, map, set, unordered_*) all allocate internal types. The correct design picks the pool inside allocate from sizeof(value_type) of the rebound allocator, or keeps pools for several sizes behind a shared pointer. unordered_map also allocates a bucket array with n > 1, so multi-object requests must be handled too (usually by forwarding to operator new). The wrong answer is "the pool sees 4-byte requests"; it never sees one.

ConceptHow does a pool affect internal and external fragmentation?

Answer

Answer. Inside one pool there is no external fragmentation, because every free block fits every request. Internal fragmentation is the gap between the block size and the object size (plus rounding to alignment). Across the whole program, pools can increase memory use, because each pool keeps its peak footprint and free blocks of one size cannot serve another size.

Why. External fragmentation means "enough free bytes in total but no hole large enough", which needs holes of different sizes. Equal blocks remove that. Size-class designs trade internal fragmentation (a 65-byte request in a 128-byte class wastes 63 bytes) for the number of pools. The memory held by idle pools is sometimes called "stranded" memory. The wrong answer is "pools eliminate fragmentation": they remove the external kind inside one pool and add internal and cross-pool waste. Ch 10 covers the general theory.

Concept"Design a memory pool." What do you ask before writing code, and which decisions does the design come down to?

Answer

Answer. Ask: one object size or many; is the peak live count known; which threads allocate and which free (and can a block be freed by a different thread); is tail latency or throughput the goal; must memory go back to the OS; what happens on exhaustion. Then decide: block size round_up(max(size, sizeof(void*)), align); an intrusive LIFO free list; one pre-faulted buffer (fixed capacity) or a chain of chunks (growth that never moves blocks); the exhaustion policy (null, throw, or a counted fallback to malloc); thread safety (one pool per thread, a mutex, or a tagged lock-free list); the API (raw blocks, typed create/destroy, an STL or pmr adapter); and a debug mode (poisoning, in-use bits, ASan poisoning, a malloc pass-through).

Why. The interviewer grades the trade-offs more than the free list, which takes five minutes. Weak answers jump straight to code, forget the minimum block size and alignment, or use a std::vector<void*> free stack that itself allocates. Say the memory consequence too: freed blocks go back on the free list, not to the system, so the process's RSS stays at the peak until the pool is destroyed (a chunk can be returned only when all its blocks are free, which needs per-chunk counts).

ConceptExplain how std::pmr works. What does a std::pmr::vector<int> store, what happens when it grows, and why can one function accept vectors backed by different resources?

Answer

Answer. std::pmr::vector<int> is std::vector<int, std::pmr::polymorphic_allocator<int>>, and that allocator is one pointer to a memory_resource. Growing calls allocator_traits::allocate, which calls resource->allocate(n * sizeof(int), alignof(int)), a non-virtual function that calls the private virtual do_allocate. The allocator type is the same whatever the resource, so the container type is too, and the choice of memory is made at run time by the pointer.

Why. A custom allocator type makes vector<int, PoolAlloc<int>> a different type from vector<int>, which spreads through every interface. pmr trades that for a virtual call per allocation, one extra pointer per container, and the propagation rules (a copy uses the default resource, assignment does not propagate). Nested pmr elements receive the same resource through uses-allocator construction. Two wrong answers: "pmr containers are a separate, faster implementation" (it is the same container code with a different allocator), and "the container owns its resource" (it holds only a pointer, so the resource must outlive it).

ConceptWhen would you choose std::pmr::unsynchronized_pool_resource over synchronized_pool_resource, and what happens if two threads share the unsynchronised one?

Answer

Answer. Use the unsynchronised one when a single thread owns the resource, for example a per-thread or per-request resource, or a single-threaded game loop. It avoids locking. Sharing it between threads without external locking is a data race, which is undefined behaviour and in practice corrupts the free lists (two threads get the same block).

Why. The synchronised version pays for thread safety: libc++ wraps an unsynchronised pool in one mutex, and libstdc++ keeps per-thread pools behind a shared_mutex. In the benchmark it cost about 1.7 times the unsynchronised one on libc++, with no contention at all. The wrong answer is "the race is harmless because blocks are all the same size": two threads popping the same free-list head get the same block.

Trickystd::pmr::vector<int> v(&pool); then auto w = v;. Which memory resource does w use? And auto m = std::move(v);?

Answer

Answer. w uses the default resource (get_default_resource(), normally new_delete_resource()), not pool. m uses pool, and it takes over v's buffer.

Why. Copy construction obtains the new allocator from allocator_traits::select_on_container_copy_construction, and polymorphic_allocator defines that to return a default-constructed allocator. The design reason is that a resource is often scoped to a request or a frame, and a copy may outlive it. Move construction always takes the source's allocator. To copy into the same pool, say so: std::pmr::vector<int> w(v, &pool);. Most candidates assume the copy shares the resource, which leads to silent heap allocation on a hot path.

TrickyExplain the ABA problem in a lock-free free list and give two ways to fix it.

Answer

Answer. Thread 1 reads head = A and next = B, then stalls. Thread 2 pops A, pops B, and pushes A back. Thread 1 resumes and its compare_exchange(head, A, B) succeeds because head is A again, so it installs B as the head even though B is in use. B is then handed out twice. Fixes: a version counter compared together with the pointer (tagged pointer or double-width CAS), or safe memory reclamation (hazard pointers, epochs) so A cannot be recycled while a thread is still reading it.

Why. CAS compares values, not history. Pools make ABA very likely because LIFO reuse returns the same address immediately. A packed 32-bit index plus 32-bit tag in one 64-bit atomic avoids needing a 128-bit CAS. The tag can wrap after 232 changes, which is accepted as practically impossible during one stall. Wrong answers: "use memory_order_seq_cst" (ordering does not detect reuse) and "ABA cannot happen because we never free memory" (never freeing prevents a crash on reading A->next, but not the stale CAS).

ConceptThread-local pools avoid locking. What is the hard case, and how do production allocators handle it?

Answer

Answer. A cross-thread free: an object allocated from thread A's pool is freed by thread B. Common designs either send the object back to its owner through a queue, or give each pool a lock-free "remote free" stack that other threads push onto and the owner drains in batches when its local list is empty.

Why. The tempting wrong answer is "B pushes it onto A's list", which is a data race on A's head. Putting the block on B's own list works only if all pools share one block size and never release chunks, and memory then migrates until one thread holds everything. mimalloc and snmalloc use remote free lists. Trading systems usually solve it architecturally: the thread that allocates an order also frees it.

TrickyYour code reads an object after returning it to a pool, but AddressSanitizer reports nothing. Why, and how do you make ASan see it?

Answer

Answer. ASan tracks allocations made through malloc/new. The pool's chunk was allocated once and is still live, so every byte in it is addressable as far as ASan knows. Use ASan's manual poisoning API (ASAN_POISON_MEMORY_REGION in deallocate, ASAN_UNPOISON_MEMORY_REGION in allocate) or a debug switch that sends every block to malloc.

Why. ASan poisons a heap chunk's shadow memory at free, and a pool never calls free for one block. Worse, LIFO reuse means the stale pointer usually reaches a new, live object; if that object has the same type and was created directly in the old storage, [basic.life] even makes the old pointer refer to it, so the access is defined and only logically wrong. The wrong answer is "ASan catches every use-after-free". Valgrind has the same blind spot (its mempool client requests fix it).

ConceptLIFO free lists are the default. What do they buy, and when would you deliberately make the free list FIFO?

Answer

Answer. LIFO returns the most recently freed block, which is probably still in cache, and it needs only a head pointer. FIFO (a queue with head and tail) delays reuse, which turns use-after-free bugs into reads of poisoned, unused blocks instead of reads of a live object, so debug builds sometimes use it.

Why. The cache argument is the main reason allocators default to LIFO: a hot loop that frees and allocates touches the same few cache lines. FIFO costs a tail pointer and spreads accesses over the whole pool. A "quarantine" (hold freed blocks for N frees before reuse) is the same idea, and ASan's own heap uses one. The wrong answer is that FIFO is "fairer" or reduces fragmentation; with equal-size blocks the order changes only cache behaviour and bug visibility.

TrickyTwo std::pmr::vector<int> objects use different pool resources. What does a = std::move(b); cost, and what about std::swap(a, b)?

Answer

Answer. The move assignment is O(n): the allocator does not propagate, the resources are unequal, so a cannot steal b's buffer and instead move-assigns the elements into storage from its own resource. swap with unequal allocators that do not propagate is undefined behaviour.

Why. propagate_on_container_move_assignment is false_type for polymorphic_allocator. The container may only steal the buffer when the allocators compare equal, which for pmr means a.resource()->is_equal(*b.resource()). The usual wrong answer is "moves are always O(1) pointer swaps". That is true for std::allocator (always equal), not for stateful allocators. Because it may allocate, it can throw, so vector's move assignment is noexcept only when the allocator propagates or is always equal.

ConceptWhen is a hand-written pool worse than plain malloc?

Answer

Answer. When sizes vary widely (one pool per size strands memory), when objects are freed by other threads (a shared pool needs a lock, while malloc already has per-thread caches), when memory must go back to the system after a burst, and when the allocation was never the bottleneck. A pool shared behind one mutex is often slower than a modern malloc's lock-free thread-cache fast path.

Why. jemalloc, tcmalloc, mimalloc and glibc's tcache are already size-class pools with per-thread caches, so a pool beats them only by removing what they must keep: the size lookup, the cross-thread handling and the slow paths. The pool also costs debuggability (ASan sees one big allocation). The wrong answer is "a pool is always faster": profile first, and the benchmark in this chapter shows a standard pool resource losing to malloc.

TrickyA class gives itself pooled allocation by defining static void* operator new(std::size_t) that returns a block from a pool sized for sizeof(Base). What breaks when someone derives from it?

Answer

Answer. Class-specific operator new is inherited, so new Derived calls it with sizeof(Derived), which may be larger than the block size. The derived object then overruns its block. The operator must check the size and forward other sizes to ::operator new, and the matching operator delete(void*, std::size_t) must do the same.

Why. The size parameter exists precisely for this case. Deleting through a base pointer is fine only if the base has a virtual destructor, because then the sized operator delete receives the dynamic type's size. Without a virtual destructor, delete base_ptr is undefined anyway. The common wrong answer is "nothing, operator new is not inherited"; it is, like any static member function. A safer design is to make the pooled class final, or to keep pooling outside the class (Ch 16).

Predict the output and find the bug

Drills

OutputWhat does this print?

#include <cstddef>
#include <cstdio>
#include <new>

class Pool {
    struct Node { Node* next; };
    alignas(16) std::byte buf_[16 * 4];
    Node* head_ = nullptr;
public:
    Pool() { for (int i = 3; i >= 0; --i) deallocate(buf_ + i * 16); }
    void* allocate() { if (!head_) return nullptr; Node* n = head_; head_ = n->next; return n; }
    void deallocate(void* p) { head_ = ::new (p) Node{head_}; }
    int index(const void* p) const { return static_cast<int>((static_cast<const std::byte*>(p) - buf_) / 16); }
};

int main() {
    Pool pool;
    void* a = pool.allocate();
    void* b = pool.allocate();
    void* c = pool.allocate();
    std::printf("%d %d %d\n", pool.index(a), pool.index(b), pool.index(c));
    pool.deallocate(a);
    pool.deallocate(b);
    void* x = pool.allocate();
    void* y = pool.allocate();
    void* z = pool.allocate();
    void* w = pool.allocate();
    std::printf("%d %d %d %s\n", pool.index(x), pool.index(y), pool.index(z), w ? "more" : "empty");
}
Answer
0 1 2
1 0 3 empty

Answer. The constructor pushes blocks 3, 2, 1, 0, so 0 is on top and the first three allocations get 0, 1, 2. Freeing a then b pushes 0 then 1, so the list is 1 → 0 → 3. The next three allocations return 1, 0, 3, and the fourth finds the list empty.

Why. The free list is a stack: last freed, first reused. Many people predict "0 1 3" because they expect blocks to come back in address order or in the order they were freed. That would need a FIFO queue or a sorted list, which a pool does not keep.

Find the bugA pool for small integers. The author used memcpy for the link to avoid alignment problems. What is wrong?

#include <cstddef>
#include <cstdio>
#include <cstring>

class IntPool {
    std::size_t n_;
    unsigned char* buf_;
    unsigned char* head_ = nullptr;
public:
    explicit IntPool(std::size_t n) : n_(n), buf_(new unsigned char[n * sizeof(int)]) {
        for (std::size_t i = n; i-- > 0;) {
            unsigned char* block = buf_ + i * sizeof(int);
            std::memcpy(block, &head_, sizeof head_);   // store the link
            head_ = block;
        }
    }
    ~IntPool() { delete[] buf_; }
    int* allocate() {
        if (!head_) return nullptr;
        unsigned char* b = head_;
        std::memcpy(&head_, b, sizeof head_);
        return reinterpret_cast<int*>(b);
    }
};

int main() {
    IntPool pool(8);
    int* p = pool.allocate();
    *p = 1;
    std::printf("%d\n", *p);
}
Answer

Answer. The block size is sizeof(int) (4 bytes), but each link is a pointer (8 bytes). Writing the link for the last block writes 4 bytes past the end of the buffer, and every other link overwrites half of the next block's link. ASan reports a heap-buffer-overflow in memcpy during construction.

Why. The minimum block size of an intrusive pool is max(sizeof(T), sizeof(void*)), rounded to alignment. memcpy fixes alignment, not size. Without ASan the program may even appear to work, because the first allocation reads the link written last, before anything clobbered it, and the overflow lands in malloc's padding; the corruption shows up later as a garbage pointer from a later allocation. Fix: block = std::max(sizeof(int), sizeof(void*)), or 16-bit indices as links.

Find the bugEach session gets its own counting resource. The author reasoned that "they all end up in new and delete anyway" when writing do_is_equal. What does this print, and what goes wrong with real pools?

#include <cstddef>
#include <cstdio>
#include <memory_resource>
#include <utility>
#include <vector>

class SessionResource : public std::pmr::memory_resource {
public:
    long live = 0;                       // bytes this session has outstanding
private:
    void* do_allocate(std::size_t n, std::size_t a) override {
        live += static_cast<long>(n);
        return std::pmr::new_delete_resource()->allocate(n, a);
    }
    void do_deallocate(void* p, std::size_t n, std::size_t a) override {
        live -= static_cast<long>(n);
        std::pmr::new_delete_resource()->deallocate(p, n, a);
    }
    bool do_is_equal(const memory_resource&) const noexcept override { return true; }
};

int main() {
    SessionResource a, b;
    {
        std::pmr::vector<int> va(&a), vb(&b);
        vb.assign(100, 7);
        va = std::move(vb);
        std::printf("va size %zu, vb size %zu\n", va.size(), vb.size());
    }
    std::printf("a live %ld bytes, b live %ld bytes\n", a.live, b.live);
}
Answer
va size 100, vb size 0
a live -400 bytes, b live 400 bytes

Answer. Because the allocators compare equal, the move assignment steals vb's 400-byte buffer, which came from b. When va dies it returns that buffer to a. Session a's books show minus 400 bytes and session b's show a 400-byte leak that never goes away. With two real pool resources, the block would be pushed onto the wrong pool's free list, and destroying b would free a chunk that a still hands out: a use-after-free.

Why. is_equal is a correctness contract ([mem.res.private]): return true only if memory allocated by one resource can be deallocated by the other. polymorphic_allocator's == calls it, and containers use that answer to decide whether a move assignment, a swap or a splice may hand a buffer from one allocator to the other. The usual wrong belief is that it is an optimisation hint. Fix: return this == &other;. The move assignment then becomes an element-wise move into memory from a (O(n)), and both counters end at 0.

Find the bugA block is read after release, in a pool that uses ASan's manual poisoning API. What does the sanitizer build report, and where?

#include <sanitizer/asan_interface.h>
#include <cstddef>
#include <cstdio>
#include <new>

class Pool {
    struct Node { Node* next; };
    static constexpr std::size_t kBlock = 16, kCount = 8;
    std::byte* buf_ = static_cast<std::byte*>(::operator new(kBlock * kCount));
    Node* head_ = nullptr;
public:
    Pool() { for (std::size_t i = kCount; i-- > 0;) deallocate(buf_ + i * kBlock); }
    ~Pool() { ASAN_UNPOISON_MEMORY_REGION(buf_, kBlock * kCount); ::operator delete(buf_); }
    void* allocate() {
        Node* n = head_;
        if (!n) return nullptr;
        ASAN_UNPOISON_MEMORY_REGION(n, kBlock);
        head_ = n->next;
        return n;
    }
    void deallocate(void* p) {
        head_ = ::new (p) Node{head_};
        ASAN_POISON_MEMORY_REGION(p, kBlock);   // nobody may touch a free block
    }
};

struct Order { int id; int qty; };

int main() {
    Pool pool;
    auto* o = ::new (pool.allocate()) Order{7, 100};
    pool.deallocate(o);
    std::printf("qty %d\n", o->qty);   // read after release
}
Answer

Answer. Under ASan the read o->qty is reported as use-after-poison at that exact line, with the poisoned region shown as f7 ("poisoned by user") in the shadow map. Without ASan the macros expand to nothing and the program prints whatever is in the block, which is undefined behaviour (the harness tags it ub).

Why. Poisoning in deallocate and unpoisoning in allocate gives the pool the heap's error detection. The pool must unpoison before reading n->next, and the destructor before handing the buffer back. ASan poisons in 8-byte granules, so blocks should be multiples of 8 and 8-aligned. The common wrong answer is "ASan is blind to pools", which is true only until the pool tells it. With LIFO reuse the next allocate unpoisons the block again, so debug builds pair this with a FIFO quarantine.

OutputThe default resource is set to null_memory_resource() to catch hidden heap use. What does this print?

#include <cstdio>
#include <memory_resource>
#include <new>
#include <vector>

int main() {
    std::pmr::unsynchronized_pool_resource pool(std::pmr::new_delete_resource());
    std::pmr::set_default_resource(std::pmr::null_memory_resource());

    std::pmr::vector<int> prices(&pool);
    for (int i = 0; i < 100; ++i) prices.push_back(i);
    std::puts("filled from the pool");

    try {
        std::pmr::vector<int> copy = prices;
        std::printf("copy has %zu\n", copy.size());
    } catch (const std::bad_alloc&) {
        std::puts("copy threw bad_alloc");
    }

    std::pmr::vector<int> copy2(prices, &pool);
    std::printf("copy2 has %zu\n", copy2.size());
    std::pmr::set_default_resource(nullptr);   // restore new_delete_resource
}
Answer
filled from the pool
copy threw bad_alloc
copy2 has 100

Answer. Filling works because prices uses the pool, whose upstream was passed explicitly. The plain copy uses the default resource, which is now the null resource, so its allocation throws std::bad_alloc. The copy that names &pool succeeds.

Why. select_on_container_copy_construction for polymorphic_allocator returns a default-constructed allocator, which captures get_default_resource(). Setting the default to the null resource in tests is a cheap way to find every place that silently falls back to the heap. Two details: the pool was built with an explicit upstream before the default changed (a pool built with no argument would capture the default at construction), and set_default_resource(nullptr) restores new_delete_resource(). Most people predict "copy has 100", assuming the copy inherits the pool.

Find the bugWhy does this allocator fail to compile with std::list?

#include <cstddef>
#include <list>
#include <new>

template <class T>
struct PoolAlloc {
    using value_type = T;
    PoolAlloc() = default;
    T* allocate(std::size_t n) { return static_cast<T*>(::operator new(n * sizeof(T))); }
    void deallocate(T* p, std::size_t) noexcept { ::operator delete(p); }
    bool operator==(const PoolAlloc&) const noexcept { return true; }
};

int main() {
    PoolAlloc<int> alloc;
    std::list<int, PoolAlloc<int>> l(alloc);   // the list must build PoolAlloc<Node> from alloc
    l.push_back(1);
}
Answer

Answer. It has no converting constructor template <class U> PoolAlloc(const PoolAlloc<U>&). The list stores an allocator for its node type, PoolAlloc<Node>, and must construct it from the PoolAlloc<int> it was given. Both GCC and Clang reject the program. Interestingly, std::list<int, PoolAlloc<int>> l; alone compiles, because then the list default-constructs its node allocator and never converts; the error appears as soon as an allocator object is passed in, which is exactly what a stateful pool allocator needs.

Why. The Allocator requirements say A a(b) must work where b is an allocator for another type, and that the two must compare equal afterwards. allocator_traits::rebind_alloc names the type, but only the converting constructor can create the object. A defaulted constructor does not help, because the rebound allocator must share the original's state. The operator== here also only compares same-type allocators; making it a template over U is the usual, more general form.

Find the bugAn error path frees a block twice. What does the program print, and does ASan notice?

#include <cstddef>
#include <cstdio>
#include <new>

class Pool {
    struct Node { Node* next; };
    alignas(16) std::byte buf_[32 * 8];
    Node* head_ = nullptr;
public:
    Pool() { for (int i = 7; i >= 0; --i) deallocate(buf_ + i * 32); }
    void* allocate() { if (!head_) return nullptr; Node* n = head_; head_ = n->next; return n; }
    void deallocate(void* p) { head_ = ::new (p) Node{head_}; }
};

int main() {
    Pool pool;
    void* msg = pool.allocate();
    pool.deallocate(msg);   // normal release
    pool.deallocate(msg);   // error path releases it again
    void* a = pool.allocate();
    void* b = pool.allocate();
    std::printf("a == b: %s\n", a == b ? "yes" : "no");
    void* c = pool.allocate();
    std::printf("c == a: %s\n", c == a ? "yes" : "no");
}
Answer
a == b: yes
c == a: yes

Answer. The second deallocate pushes the block again, and its link now points to itself: the list is msg → msg → msg …. Every later allocation returns the same block, so a, b and c are all equal. Two live objects share memory, and the rest of the pool is lost. ASan says nothing: the harness runs this program under ASan and it is clean.

Why. After the first free, head = msg. The second free writes msg->next = head, which is msg itself, creating a cycle of length one. With malloc, ASan would report attempting double-free. A debug pool should keep an in-use bit per block and check it in deallocate, or check that the block being freed is not the current head (cheap, catches the immediate case only). The usual prediction is "a == b: yes, c == a: no", which assumes the list still continues past the duplicate; the self-loop means it never does.

OutputA power-of-two size-class table. What does it print?

#include <algorithm>
#include <bit>
#include <cstddef>
#include <cstdio>

constexpr std::size_t kMinClass = 8, kMaxClass = 4096;

static std::size_t class_size(std::size_t n) {
    if (n > kMaxClass) return 0;                 // 0 means: go upstream
    return std::max(kMinClass, std::bit_ceil(n));
}

int main() {
    for (std::size_t n : {1, 8, 9, 64, 65, 200, 4096, 5000}) {
        std::size_t c = class_size(n);
        if (c) std::printf("%4zu -> %4zu (waste %zu)\n", n, c, c - n);
        else std::printf("%4zu -> upstream\n", n);
    }
}
Answer
   1 ->    8 (waste 7)
   8 ->    8 (waste 0)
   9 ->   16 (waste 7)
  64 ->   64 (waste 0)
  65 ->  128 (waste 63)
 200 ->  256 (waste 56)
4096 -> 4096 (waste 0)
5000 -> upstream

Answer. Each request rounds up to the next power of two, with 8 as the smallest class (the free-list link needs 8 bytes). 65 bytes wastes 63 and 200 wastes 56, while exact powers of two waste nothing. Requests above 4096 bypass the pools.

Why. Power-of-two classes make the class index a bit operation (std::bit_width(n - 1)), but the worst-case internal fragmentation is just under 50 percent. That is why jemalloc and tcmalloc use four or more classes per power of two (for example 80, 96, 112, 128) above the small range. A common slip is mapping 64 to 128 (rounding n instead of n - 1), or 1 to 1, forgetting the 8-byte minimum.

Find the bugTwo worker threads share one pool. What goes wrong, and what does ThreadSanitizer say?

#include <cstddef>
#include <cstdio>
#include <new>
#include <thread>

class Pool {
    struct Node { Node* next; };
    alignas(16) std::byte buf_[64 * 256];
    Node* head_ = nullptr;
public:
    Pool() { for (int i = 255; i >= 0; --i) deallocate(buf_ + i * 64); }
    void* allocate() { if (!head_) return nullptr; Node* n = head_; head_ = n->next; return n; }
    void deallocate(void* p) { head_ = ::new (p) Node{head_}; }
};

static Pool pool;

static void worker() {
    for (int i = 0; i < 20000; ++i)
        if (void* p = pool.allocate()) pool.deallocate(p);
}

int main() {
    std::thread t1(worker), t2(worker);
    t1.join();
    t2.join();
    std::puts("done");
}
Answer

Answer. head_ is read and written by both threads with no synchronisation: a data race, so the behaviour is undefined. TSan reports WARNING: ThreadSanitizer: data race on head_ (and on the next fields). In practice both threads can pop the same block, or a push can be lost, so blocks are handed out twice or leak.

Why. Pop is a read-modify-write of head_ in three steps. If both threads read the same head before either writes, both return the same block. The fixes are a std::mutex around both operations, one pool per thread, or a lock-free list with ABA protection (the coding section builds one). The program may print "done" and look fine in a normal run, which is why it is tagged ub and checked with TSan.

Find the bugA pool of 24-byte blocks is reused for a SIMD-friendly vector type. What does the sanitizer build report?

#include <cstddef>
#include <cstdio>
#include <new>

struct alignas(16) Vec4 { float x, y, z, w; };

class Pool24 {
    struct Node { Node* next; };
    alignas(16) std::byte buf_[24 * 4];
    Node* head_ = nullptr;
public:
    Pool24() { for (int i = 3; i >= 0; --i) head_ = ::new (buf_ + i * 24) Node{head_}; }
    void* allocate() { Node* n = head_; head_ = n->next; return n; }
};

int main() {
    Pool24 pool;
    auto* a = ::new (pool.allocate()) Vec4{1, 2, 3, 4};
    auto* b = ::new (pool.allocate()) Vec4{5, 6, 7, 8};
    std::printf("%g %g\n", a->w, b->w);
}
Answer

Answer. Block 1 starts at offset 24, which is 8-aligned but not 16-aligned, so constructing a Vec4 there is undefined behaviour. UBSan (part of the harness's ASan build) reports constructor call on misaligned address … which requires 16 byte alignment (or "member access within misaligned address").

Why. The block size must be a multiple of the alignment of every type stored in it: round_up(24, 16) = 32. Sizing blocks by sizeof alone works only when sizeof is already a multiple of the alignment, which is true for complete types (a type's size is always a multiple of its alignment) but not for an arbitrary byte count like 24 chosen for a different type. On x86 a misaligned movaps faults; on ARM64 ordinary loads tolerate it, which is how this bug survives testing on one platform.

Write the code

Coding

CodeImplement a fixed-capacity pool FixedPool(object_size, object_align, capacity) with allocate() (returns nullptr when exhausted), deallocate(p), owns(p) and in_use(). Both operations must be O(1), every block must be aligned to object_align (which may exceed 16), and the pool must not be copyable.

Answer

Approach. Compute the block alignment as the larger of the requested alignment and a pointer's, and the block size as the object size (at least one pointer) rounded up to that alignment. Allocate one buffer with aligned operator new, then thread an intrusive free list through it in address order. owns is a range check plus a block-boundary check, which also makes a good assertion in deallocate.

#include <algorithm>
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <new>

class FixedPool {
    struct Node { Node* next; };
    std::size_t align_, block_, cap_, used_ = 0;
    std::byte* buf_;
    Node* head_ = nullptr;

    static std::size_t round_up(std::size_t n, std::size_t a) { return (n + a - 1) & ~(a - 1); }

public:
    FixedPool(std::size_t object_size, std::size_t object_align, std::size_t capacity)
        : align_(std::max(object_align, alignof(Node))),
          block_(round_up(std::max(object_size, sizeof(Node)), align_)),
          cap_(capacity),
          buf_(static_cast<std::byte*>(::operator new(block_ * cap_, std::align_val_t{align_}))) {
        for (std::size_t i = cap_; i-- > 0;) head_ = ::new (buf_ + i * block_) Node{head_};
    }
    ~FixedPool() { ::operator delete(buf_, std::align_val_t{align_}); }
    FixedPool(const FixedPool&) = delete;
    FixedPool& operator=(const FixedPool&) = delete;

    void* allocate() noexcept {
        if (!head_) return nullptr;
        Node* n = head_;
        head_ = n->next;
        ++used_;
        return n;
    }
    void deallocate(void* p) noexcept {
        assert(owns(p));
        head_ = ::new (p) Node{head_};
        --used_;
    }
    bool owns(const void* p) const noexcept {
        auto* b = static_cast<const std::byte*>(p);
        std::less<const std::byte*> lt;          // total order even for unrelated pointers
        if (lt(b, buf_) || !lt(b, buf_ + block_ * cap_)) return false;
        return static_cast<std::size_t>(b - buf_) % block_ == 0;
    }
    std::size_t in_use() const noexcept { return used_; }
    std::size_t block_size() const noexcept { return block_; }
};

struct alignas(32) Tile { float v[5]; };

int main() {
    FixedPool pool(sizeof(Tile), alignof(Tile), 3);
    std::printf("block %zu for a %zu-byte, %zu-aligned type\n", pool.block_size(), sizeof(Tile), alignof(Tile));
    void* p[3];
    bool aligned = true;
    for (auto& q : p) {
        q = pool.allocate();
        aligned = aligned && reinterpret_cast<std::uintptr_t>(q) % alignof(Tile) == 0;
    }
    std::printf("all aligned: %s, in use %zu, fourth: %s\n", aligned ? "yes" : "no", pool.in_use(),
                pool.allocate() ? "got one" : "nullptr");
    pool.deallocate(p[1]);
    std::printf("reuse: %s\n", pool.allocate() == p[1] ? "same block" : "other block");
    int local = 0;
    std::printf("owns a stack address: %s\n", pool.owns(&local) ? "yes" : "no");
    for (auto q : p) pool.deallocate(q);
    std::printf("in use at end: %zu\n", pool.in_use());
}
block 32 for a 32-byte, 32-aligned type
all aligned: yes, in use 3, fourth: nullptr
reuse: same block
owns a stack address: no
in use at end: 0

Complexity and pitfalls. Both operations are O(1); construction is O(capacity). The pitfalls are the ones this chapter keeps returning to: a block smaller than a pointer, a block size that is not a multiple of the alignment, and freeing the buffer with the unaligned operator delete (it must match the aligned new). Comparing unrelated pointers with < is unspecified, so owns uses std::less, which guarantees a total order. The pool does not run destructors: it serves raw memory.

Estimated rating: ~1400 · short code, but alignment rounding and the aligned new/delete pairing must be exact

CodeWrite TypedPool<T> with T* create(Args&&...) and void destroy(T*). It grows by chunks that double in size (16, 32, 64, ... blocks), never moves existing objects, and stays consistent if T's constructor throws. Report the number of chunks.

Answer

Approach. A block is a union of a free-list link and raw storage for a T, so its size and alignment are right by construction. When the list is empty, allocate a new chunk with std::make_unique<Slot[]>, keep it in a vector for cleanup, and push its blocks. create pops the block before constructing (the constructor will overwrite the link) and pushes it back if the constructor throws. destroy runs the destructor and re-creates a link object in the storage.

#include <cstddef>
#include <cstdio>
#include <memory>
#include <new>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

template <class T>
class TypedPool {
    union Slot {
        Slot* next;
        alignas(T) unsigned char storage[sizeof(T)];
    };
    std::vector<std::unique_ptr<Slot[]>> chunks_;
    std::size_t next_chunk_ = 16;
    Slot* head_ = nullptr;
    std::size_t live_ = 0;

    void grow() {
        auto chunk = std::make_unique<Slot[]>(next_chunk_);
        for (std::size_t i = next_chunk_; i-- > 0;) { chunk[i].next = head_; head_ = &chunk[i]; }
        chunks_.push_back(std::move(chunk));
        next_chunk_ *= 2;
    }

public:
    TypedPool() = default;
    TypedPool(const TypedPool&) = delete;
    TypedPool& operator=(const TypedPool&) = delete;
    ~TypedPool() { if (live_) std::fprintf(stderr, "TypedPool: %zu objects leaked\n", live_); }

    template <class... Args>
    T* create(Args&&... args) {
        if (!head_) grow();
        Slot* s = head_;
        head_ = s->next;
        try {
            T* p = ::new (static_cast<void*>(s->storage)) T(std::forward<Args>(args)...);
            ++live_;
            return p;
        } catch (...) {
            s->next = head_;        // give the block back
            head_ = s;
            throw;
        }
    }
    void destroy(T* p) noexcept {
        p->~T();
        head_ = ::new (static_cast<void*>(p)) Slot{.next = head_};
        --live_;
    }
    std::size_t chunks() const noexcept { return chunks_.size(); }
    std::size_t live() const noexcept { return live_; }
};

struct Message {
    static inline int alive = 0;
    std::string text;
    explicit Message(std::string t, bool fail = false) : text(std::move(t)) {
        if (fail) throw std::runtime_error("bad message");
        ++alive;
    }
    ~Message() { --alive; }
};

int main() {
    TypedPool<Message> pool;
    std::vector<Message*> msgs;
    for (int i = 0; i < 100; ++i) msgs.push_back(pool.create("message number " + std::to_string(i)));
    Message* first = msgs.front();
    std::printf("100 live, %zu chunks, first: %s\n", pool.chunks(), first->text.c_str());

    try { pool.create("broken", true); } catch (const std::exception& e) { std::printf("caught: %s\n", e.what()); }
    std::printf("live after the throw: %zu, alive count %d\n", pool.live(), Message::alive);

    for (Message* m : msgs) pool.destroy(m);
    for (int i = 0; i < 1000; ++i) pool.destroy(pool.create("x"));
    std::printf("after churn: live %zu, chunks %zu, alive %d\n", pool.live(), pool.chunks(), Message::alive);
}
100 live, 3 chunks, first: message number 0
caught: bad message
live after the throw: 100, alive count 100
after churn: live 0, chunks 3, alive 0

Complexity and pitfalls. create and destroy are O(1) amortised; growth is O(chunk size) but happens O(log n) times. 100 objects need chunks of 16 + 32 + 64 = 112 blocks, so 3 chunks, and the churn afterwards never grows. Pitfalls: reading s->next after constructing T (the constructor has already overwritten it), losing the block when the constructor throws, and destroying the pool while objects are alive (their destructors never run; this version reports it). destroy must only receive pointers from this pool; the next exercise in debug builds is to assert that with a chunk range check.

Estimated rating: ~1600 · union slot, exception safety in create and chunk growth are each easy to get slightly wrong

CodeWrite an allocator PoolAllocator<T> that makes std::list and std::map take their nodes from fixed-size pools. Single-object requests come from a pool for sizeof(T) and alignof(T) of the type the allocator is instantiated with; multi-object requests go to operator new. Show how many allocations the pools served.

Answer

Approach. Make the allocator stateless and keep one pool per (size, alignment) pair as a function-local static, so every rebound allocator for the same node type shares a pool and all instances compare equal. The rebind happens automatically through allocator_traits because PoolAllocator is a class template; we only supply the converting constructor. The pools grow by chunks so the containers have no size limit.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <list>
#include <map>
#include <memory>
#include <new>
#include <vector>

static std::size_t g_pool_allocs = 0;

template <std::size_t Size, std::size_t Align>
class SizedPool {
    struct Node { Node* next; };
    static constexpr std::size_t kAlign = std::max(Align, alignof(Node));
    static constexpr std::size_t kBlock = (std::max(Size, sizeof(Node)) + kAlign - 1) / kAlign * kAlign;
    static constexpr std::size_t kChunkBlocks = 256;
    std::vector<std::byte*> chunks_;
    Node* head_ = nullptr;

    SizedPool() = default;
    ~SizedPool() { for (auto* c : chunks_) ::operator delete(c, std::align_val_t{kAlign}); }
    void grow() {
        auto* c = static_cast<std::byte*>(::operator new(kBlock * kChunkBlocks, std::align_val_t{kAlign}));
        chunks_.push_back(c);
        for (std::size_t i = kChunkBlocks; i-- > 0;) head_ = ::new (c + i * kBlock) Node{head_};
    }
public:
    static SizedPool& instance() { static SizedPool pool; return pool; }
    void* allocate() {
        if (!head_) grow();
        Node* n = head_;
        head_ = n->next;
        ++g_pool_allocs;
        return n;
    }
    void deallocate(void* p) noexcept { head_ = ::new (p) Node{head_}; }
};

template <class T>
struct PoolAllocator {
    using value_type = T;
    using Pool = SizedPool<sizeof(T), alignof(T)>;

    PoolAllocator() noexcept = default;
    template <class U> PoolAllocator(const PoolAllocator<U>&) noexcept {}

    T* allocate(std::size_t n) {
        if (n == 1) return static_cast<T*>(Pool::instance().allocate());
        return static_cast<T*>(::operator new(n * sizeof(T), std::align_val_t{alignof(T)}));
    }
    void deallocate(T* p, std::size_t n) noexcept {
        if (n == 1) Pool::instance().deallocate(p);
        else ::operator delete(p, std::align_val_t{alignof(T)});
    }
    template <class U> bool operator==(const PoolAllocator<U>&) const noexcept { return true; }
};

int main() {
    std::list<int, PoolAllocator<int>> l;
    for (int i = 0; i < 1000; ++i) l.push_back(i);
    std::printf("list: %zu pool allocations\n", g_pool_allocs);
    l.remove_if([](int x) { return x % 2 == 0; });
    long sum = 0;
    for (int x : l) sum += x;
    std::printf("sum of odd values: %ld\n", sum);
    for (int i = 0; i < 500; ++i) l.push_front(i);   // reuses the freed nodes
    std::printf("after refill: %zu pool allocations, size %zu\n", g_pool_allocs, l.size());

    g_pool_allocs = 0;
    std::map<int, int, std::less<int>, PoolAllocator<std::pair<const int, int>>> m;
    for (int i = 0; i < 300; ++i) m[i * 7 % 300] = i;
    std::printf("map: %zu entries, %zu pool allocations\n", m.size(), g_pool_allocs);
}
list: 1000 pool allocations
sum of odd values: 250000
after refill: 1500 pool allocations, size 1000
map: 300 entries, 300 pool allocations

Complexity and pitfalls. O(1) per node. The list and map nodes get different SizedPool instantiations because their sizes differ. Pitfalls: keying the pool on the user's T instead of the rebound one; forgetting n > 1 requests (vector, unordered_map buckets); and static lifetime: a static container constructed before the pool's first use is destroyed after the pool, so its nodes are freed into a dead pool. The pools are process-wide, so this is not thread safe; a thread_local pool fixes that if nodes never cross threads. Boost's fast_pool_allocator uses this design.

Estimated rating: ~1800 · needs a working model of rebind, allocator equality and static lifetime, not just a free list

CodeBuild a small-object allocator with 16 size classes (16, 32, ..., 256 bytes). allocate(size) routes to the smallest class that fits, larger requests go to operator new, and deallocate(p, size) uses the size to find the class. Test it with many random sizes and verify that no two live allocations overlap.

Answer

Approach. Keep an array of 16 free-list heads and a list of chunks. The class index is (size + 15) / 16 - 1. An empty class takes a 4 KiB chunk and slices it into blocks of that class's size. Sized deallocation means no header is needed. The test fills every allocation with a byte unique to it and checks the pattern before freeing, which detects any overlap.

#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <new>
#include <vector>

class SmallObjectAllocator {
    struct Node { Node* next; };
    static constexpr std::size_t kStep = 16, kClasses = 16, kMax = kStep * kClasses, kChunk = 4096;
    std::array<Node*, kClasses> heads_{};
    std::vector<void*> chunks_;

    static std::size_t class_of(std::size_t n) { return (n + kStep - 1) / kStep - 1; }
    void refill(std::size_t c) {
        std::size_t block = (c + 1) * kStep;
        auto* chunk = static_cast<std::byte*>(::operator new(kChunk));
        chunks_.push_back(chunk);
        for (std::size_t off = kChunk / block * block; off >= block; off -= block)
            heads_[c] = ::new (chunk + off - block) Node{heads_[c]};
    }

public:
    SmallObjectAllocator() = default;
    SmallObjectAllocator(const SmallObjectAllocator&) = delete;
    SmallObjectAllocator& operator=(const SmallObjectAllocator&) = delete;
    ~SmallObjectAllocator() { for (void* c : chunks_) ::operator delete(c); }

    void* allocate(std::size_t n) {
        if (n == 0) n = 1;
        if (n > kMax) return ::operator new(n);
        std::size_t c = class_of(n);
        if (!heads_[c]) refill(c);
        Node* b = heads_[c];
        heads_[c] = b->next;
        return b;
    }
    void deallocate(void* p, std::size_t n) noexcept {
        if (n == 0) n = 1;
        if (n > kMax) { ::operator delete(p); return; }
        std::size_t c = class_of(n);
        heads_[c] = ::new (p) Node{heads_[c]};
    }
    std::size_t chunks() const { return chunks_.size(); }
};

struct Live { unsigned char* p; std::size_t n; unsigned char tag; };

int main() {
    SmallObjectAllocator alloc;
    std::vector<Live> live;
    std::uint32_t x = 7;
    auto rnd = [&] { x = x * 1103515245u + 12345u; return x >> 8; };
    int corrupt = 0;
    for (int step = 0; step < 20000; ++step) {
        if (live.size() < 500 && (live.empty() || rnd() % 3 != 0)) {
            std::size_t n = 1 + rnd() % 300;                  // some go above 256
            auto tag = static_cast<unsigned char>(rnd());
            auto* p = static_cast<unsigned char*>(alloc.allocate(n));
            std::memset(p, tag, n);
            live.push_back({p, n, tag});
        } else {
            std::size_t i = rnd() % live.size();
            Live v = live[i];
            for (std::size_t k = 0; k < v.n; ++k) corrupt += v.p[k] != v.tag;
            alloc.deallocate(v.p, v.n);
            live[i] = live.back();
            live.pop_back();
        }
    }
    for (Live v : live) alloc.deallocate(v.p, v.n);
    std::printf("corrupted bytes: %d\n", corrupt);
    std::printf("chunks used: %s\n", alloc.chunks() < 64 ? "fewer than 64" : "too many");
}
corrupted bytes: 0
chunks used: fewer than 64

Complexity and pitfalls. O(1) per call, plus O(chunk / block) when a class refills. Worst-case internal fragmentation is 15 bytes per block. The classic bugs: computing the class differently in allocate and deallocate (for example, if the size passed back is not the size requested), not handling n == 0, and alignment: blocks of 48 bytes are only 16-aligned, which is fine for max_align_t but not for over-aligned types, so a real allocator also takes an alignment argument. Chunks are never returned until destruction; returning them needs a per-chunk count of live blocks.

Estimated rating: ~1700 · several free lists and an off-by-one-prone slicing loop, plus a randomised self-check

CodeWrite a lock-free fixed-size pool for 48-byte blocks that is safe for many threads and immune to ABA. Use only 64-bit atomics (no double-width CAS). Test it with 4 threads and prove that no block is ever handed to two threads at once.

Answer

Approach. Blocks live in an array, so a block can be named by a 32-bit index. The head is one std::atomic<std::uint64_t> that packs a 32-bit version tag with the 32-bit index of the top block, and every successful CAS increments the tag, so a stale head never matches. Each block's link is a separate std::atomic<std::uint32_t>, so a thread that reads a link while another thread uses the block's data is not a data race. Pop uses acquire and push uses release, which orders the data written by the previous owner before the next owner's writes.

#include <atomic>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <thread>
#include <vector>

class LockFreePool {
    static constexpr std::uint32_t kNil = 0xFFFFFFFFu;
    struct Block {
        alignas(16) unsigned char data[48];
        std::atomic<std::uint32_t> next{kNil};
    };
    std::unique_ptr<Block[]> blocks_;
    std::uint32_t count_;
    std::atomic<std::uint64_t> head_;

    static std::uint64_t pack(std::uint64_t tag, std::uint32_t idx) { return tag << 32 | idx; }
    std::uint32_t index_of(void* p) const {
        auto* b = static_cast<unsigned char*>(p);
        return static_cast<std::uint32_t>((b - reinterpret_cast<unsigned char*>(blocks_.get())) / sizeof(Block));
    }

public:
    explicit LockFreePool(std::uint32_t n) : blocks_(std::make_unique<Block[]>(n)), count_(n), head_(pack(0, kNil)) {
        for (std::uint32_t i = 0; i < n; ++i) blocks_[i].next.store(i + 1 < n ? i + 1 : kNil, std::memory_order_relaxed);
        head_.store(pack(0, 0), std::memory_order_release);
    }
    void* allocate() noexcept {
        std::uint64_t old = head_.load(std::memory_order_acquire);
        for (;;) {
            auto idx = static_cast<std::uint32_t>(old);
            if (idx == kNil) return nullptr;
            std::uint32_t next = blocks_[idx].next.load(std::memory_order_relaxed);
            if (head_.compare_exchange_weak(old, pack((old >> 32) + 1, next),
                                            std::memory_order_acquire, std::memory_order_acquire))
                return blocks_[idx].data;
        }
    }
    void deallocate(void* p) noexcept {
        std::uint32_t idx = index_of(p);
        std::uint64_t old = head_.load(std::memory_order_relaxed);
        for (;;) {
            blocks_[idx].next.store(static_cast<std::uint32_t>(old), std::memory_order_relaxed);
            if (head_.compare_exchange_weak(old, pack((old >> 32) + 1, idx),
                                            std::memory_order_release, std::memory_order_relaxed))
                return;
        }
    }
    std::uint32_t capacity() const noexcept { return count_; }
};

int main() {
    constexpr std::uint32_t kBlocks = 64;
    LockFreePool pool(kBlocks);
    std::vector<std::atomic<int>> owner(kBlocks);
    std::atomic<int> clashes{0};
    auto base = static_cast<unsigned char*>(pool.allocate());   // block 0: learn the layout, then return it
    pool.deallocate(base);
    auto slot_of = [&](void* p) { return (static_cast<unsigned char*>(p) - base) / 64; };   // sizeof(Block) == 64

    auto worker = [&](int id) {
        void* held[4] = {};
        for (int i = 0; i < 20000; ++i) {
            void*& h = held[i % 4];
            if (h) {
                owner[slot_of(h)].store(0, std::memory_order_relaxed);
                pool.deallocate(h);
                h = nullptr;
            } else if ((h = pool.allocate())) {
                if (owner[slot_of(h)].exchange(1, std::memory_order_relaxed) != 0) ++clashes;
                static_cast<unsigned char*>(h)[0] = static_cast<unsigned char>(id);   // use the block
            }
        }
        for (void* h : held) if (h) { owner[slot_of(h)].store(0, std::memory_order_relaxed); pool.deallocate(h); }
    };
    std::vector<std::thread> ts;
    for (int t = 0; t < 4; ++t) ts.emplace_back(worker, t);
    for (auto& t : ts) t.join();

    std::uint32_t back = 0;
    while (pool.allocate()) ++back;
    std::printf("blocks handed to two threads at once: %d\n", clashes.load());
    std::printf("blocks recovered: %u of %u\n", back, pool.capacity());
}
blocks handed to two threads at once: 0
blocks recovered: 64 of 64

Complexity and pitfalls. O(1) without contention, with retries under contention (lock-free, not wait-free). Pitfalls: a plain uint32_t link would race when a slow popper reads a link the block's new owner is writing, so the link is atomic and outside the user data; the tag must change on push as well as pop; and the tag wraps after 232 updates, which is accepted. A 128-bit pointer-plus-tag CAS also works, but GCC may route it through libatomic and report it as not lock-free. The test's owner checks are relaxed on purpose, so the happens-before edges TSan verifies come from the pool itself.

Estimated rating: ~2200 · correct memory orders, ABA tagging and a meaningful concurrent test are all required

CodeWrite a debugging std::pmr::memory_resource that wraps an upstream resource and detects buffer overruns (canaries before and after each block), size mismatches on deallocate, and leaks (live blocks when it is destroyed). It must work as the resource of ordinary pmr containers.

Answer

Approach. For each request, ask upstream for a 16-byte header, the user's bytes and an 8-byte tail. The header stores the size and a front canary; the tail canary is written with memcpy because it may be unaligned. On deallocate, check both canaries and compare the stored size with the one passed in, then free with the stored size. Alignments above 16 are forwarded unchecked to keep the layout simple.

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <memory_resource>
#include <vector>

class CheckingResource : public std::pmr::memory_resource {
    static constexpr std::uint64_t kFront = 0xF00DFACEF00DFACEull, kBack = 0xDEADC0DEDEADC0DEull;
    static constexpr std::size_t kHeader = 16, kTail = sizeof(std::uint64_t);
    struct Header { std::size_t size; std::uint64_t canary; };
    std::pmr::memory_resource* up_;
    std::size_t live_ = 0;

public:
    int errors = 0;
    explicit CheckingResource(std::pmr::memory_resource* up = std::pmr::new_delete_resource()) : up_(up) {}
    ~CheckingResource() override { if (live_) std::printf("leak: %zu block(s) never freed\n", live_); }

private:
    void* do_allocate(std::size_t n, std::size_t align) override {
        if (align > kHeader) return up_->allocate(n, align);
        auto* raw = static_cast<std::byte*>(up_->allocate(kHeader + n + kTail, kHeader));
        ::new (raw) Header{n, kFront};
        std::memcpy(raw + kHeader + n, &kBack, kTail);
        ++live_;
        return raw + kHeader;
    }
    void do_deallocate(void* p, std::size_t n, std::size_t align) override {
        if (align > kHeader) { up_->deallocate(p, n, align); return; }
        auto* raw = static_cast<std::byte*>(p) - kHeader;
        auto* h = reinterpret_cast<Header*>(raw);
        std::uint64_t back;
        std::memcpy(&back, raw + kHeader + h->size, kTail);
        if (h->canary != kFront) { ++errors; std::puts("error: front canary overwritten (underrun)"); }
        if (back != kBack) { ++errors; std::puts("error: back canary overwritten (overrun)"); }
        if (h->size != n) { ++errors; std::printf("error: freed with size %zu, allocated %zu\n", n, h->size); }
        --live_;
        up_->deallocate(raw, kHeader + h->size + kTail, kHeader);
    }
    bool do_is_equal(const memory_resource& o) const noexcept override { return this == &o; }
};

int main() {
    CheckingResource checker;
    {
        std::pmr::vector<int> v(&checker);
        for (int i = 0; i < 1000; ++i) v.push_back(i);
    }
    std::printf("after a clean vector: %d errors\n", checker.errors);

    auto* buf = static_cast<char*>(checker.allocate(10, 1));
    std::memset(buf, 'x', 11);                   // one byte too many
    checker.deallocate(buf, 10, 1);

    void* q = checker.allocate(32);
    checker.deallocate(q, 16);                   // wrong size

    {
        CheckingResource scoped;
        [[maybe_unused]] void* forgotten = scoped.allocate(64);
    }
    std::printf("total errors: %d\n", checker.errors);
}
after a clean vector: 0 errors
error: back canary overwritten (overrun)
error: freed with size 16, allocated 32
leak: 1 block(s) never freed
total errors: 2

Complexity and pitfalls. O(1) extra work and 24 extra bytes per allocation. The inner scope's block is deliberately leaked (the harness turns off LeakSanitizer). The off-by-one write lands in the tail canary, which is inside this resource's own allocation, so ASan sees nothing: exactly the class of bug canaries exist for. Pitfalls: keeping the user pointer aligned (the 16-byte header preserves 16-byte alignment), an unaligned load of the tail canary (use memcpy), and freeing upstream with the caller's size instead of the stored one.

Estimated rating: ~1700 · memory_resource plumbing plus careful header and tail layout arithmetic

Cheat sheet

Theory
  • A pool trades generality for O(1), constant-time allocation with no slow path, no external fragmentation and pre-faultable memory. The win that matters on hot paths is the tail latency.
  • Intrusive free list: free blocks store the next pointer. Block size = round_up(max(size, sizeof(void*)), align). LIFO reuse is cache-warm and hides use-after-free.
  • Fixed capacity (pre-sized, alarm on exhaustion) for HFT; chunked growth (doubling, never moving blocks) elsewhere. Freed blocks do not go back to the OS.
  • Size classes: an array of pools; deallocation needs the size (sized delete, deallocate(p, n)) or an address lookup. Power-of-two classes waste up to about 50 percent.
  • Thread safety: mutex (simple), thread-local pools (fast, cross-thread frees need a remote list), lock-free Treiber stack (needs ABA protection: tag counter or hazard pointers).
  • std::pmr: memory_resource, (un)synchronized_pool_resource, polymorphic_allocator; copies use the default resource, moves keep it, move-assign across resources is O(n), swap across resources is UB.
  • STL allocator: value_type, allocate, deallocate, converting constructor, ==. Node containers rebind to their node type.
  • Debug pools with poisoning, canaries, in-use bits, ASan manual poisoning or a malloc pass-through switch. Benchmark with realistic churn, medians plus spread, ratios, and the platform named (pmr pools were 2.3× faster than malloc on libc++ but slower on libstdc++ on an Apple M1).

Part E · Memory management · Chapter 16

Object pools

An object pool keeps fully constructed objects and lends them out, so the expensive part of creating an object (its buffers, its connection, its thread) happens once. This chapter covers acquire and release, reset semantics, RAII handles, exhaustion policies, the bugs pools invite (stale state, double release, use after release) and the generation-counter handles that catch them. Interviewers ask about object pools because they test lifetime reasoning more than allocation tricks.

TheoryDrillsCoding· about 4 hours· prerequisites: Ch 11, Ch 12, Ch 15

Object pools vs memory pools

Theory

Mental model. A memory pool (Ch 15) recycles raw blocks: you construct an object in a block, destroy it, and the block goes back. An object pool recycles live objects: they are constructed once, handed out, reset to a clean state when returned, and destroyed only when the pool dies. The destructor and constructor do not run per use.

That distinction decides when each one helps:

  • A memory pool helps when the allocation is the cost: many small objects of one size, created and destroyed often.
  • An object pool helps when construction is the cost: an object that owns a 64 KiB buffer, a TCP connection with a completed TLS handshake, a thread, a database statement handle, a game entity with components wired up. Resetting such an object (clear the buffer but keep its capacity, keep the socket open) is far cheaper than building a new one.
Object pool constructed once (pool start-up) idle, clean in the free list in use destroyed pool dies acquire release + reset only at pool destruction Memory pool raw block live object allocate + construct destroy + deallocate Constructor and destructor run on every use. The pool only saves the allocation.
An object pool skips construction, a memory pool skips allocation. Objects in an object pool cycle between idle and in use, and the reset on release replaces the destructor and constructor.
Key factsMemory poolObject pool
RecyclesRaw storageConstructed objects
Per-use cost savedmalloc/freeConstruction, destruction and their allocations
Per-use cost addedPlacement new and destructor callreset() on release (or on acquire)
Typical APIvoid* allocate(), deallocate(p)Handle acquire(), release via the handle's destructor
Main bug riskUse after free, wrong sizeStale state from the previous user, use after release
Objects areOne type or one size classOne type, often heavy (buffers, sockets, threads)
Memory heldPeak block countPeak object count, plus every buffer each object grew

The two combine: an object pool can store its objects in memory from a memory pool or an arena, and many systems call both of them "pools". In an interview, define which one you mean first.

Acquire, release and reset semantics

Theory

The interface has two operations. acquire() removes an idle object from the pool and gives the caller exclusive use. release(obj) gives it back. Between the two, the pool must not hand that object to anyone else, and after release the caller must not touch it.

What reset means

Reset returns the object to a state that is indistinguishable, for the next user, from a freshly constructed one, while keeping whatever made construction expensive. Examples:

  • A buffer: clear() (size 0) but keep the capacity. Do not shrink_to_fit(), or you lose the point of pooling.
  • A request object: clear every field, including the optional ones (authentication token, user id, error flags). Missing one field is the classic stale-state bug.
  • A connection: roll back any open transaction, reset session variables, keep the socket and TLS session.
  • A game entity: reset position, health and timers, unsubscribe it from events, keep its allocated component storage.

Reset on release or on acquire?

Both are used, and the trade-offs are real:

  • On release (most common): the previous user's data and resources are dropped as soon as they are done. Secrets do not linger in idle objects, references to other objects are cut early so they can be freed, and the acquire path stays fast. The releasing thread pays the cost.
  • On acquire: lazy, so objects that are never reused are never reset, and the cost lands on the thread that needs the object. Idle objects still hold the previous user's data, which is a security and memory concern.

Whichever you choose, also validate: a pool of connections typically checks on acquire that the connection is still alive (the server may have closed it while it sat idle) and discards it if not.

A basic object pool

This version owns every object through a std::vector<std::unique_ptr<T>> and keeps a stack of idle ones. The free stack is reserved up front, so release never allocates and can be noexcept.

#include <cstddef>
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <vector>

template <class T>
class ObjectPool {
    std::vector<std::unique_ptr<T>> all_;   // owns every object
    std::vector<T*> idle_;                  // objects ready to hand out
    std::function<void(T&)> reset_;
public:
    ObjectPool(std::size_t n, std::function<void(T&)> reset) : reset_(std::move(reset)) {
        all_.reserve(n);
        idle_.reserve(n);
        for (std::size_t i = 0; i < n; ++i) {
            all_.push_back(std::make_unique<T>());
            idle_.push_back(all_.back().get());
        }
    }
    T* acquire() {
        if (idle_.empty()) return nullptr;
        T* obj = idle_.back();
        idle_.pop_back();
        return obj;
    }
    void release(T* obj) noexcept {
        reset_(*obj);
        idle_.push_back(obj);   // never reallocates: capacity is all_.size()
    }
    std::size_t idle() const { return idle_.size(); }
};

struct Packet { std::string payload; int retries = 0; };

int main() {
    ObjectPool<Packet> pool(2, [](Packet& p) { p.payload.clear(); p.retries = 0; });
    Packet* p = pool.acquire();
    p->payload.assign(1000, 'x');
    p->retries = 3;
    auto cap = p->payload.capacity();
    pool.release(p);
    Packet* q = pool.acquire();
    std::printf("same object: %s, size %zu, retries %d, capacity kept: %s\n",
                q == p ? "yes" : "no", q->payload.size(), q->retries, q->payload.capacity() == cap ? "yes" : "no");
    pool.release(q);
    std::printf("idle: %zu\n", pool.idle());
}
same object: yes, size 0, retries 0, capacity kept: yes
idle: 2

The raw T* interface is the weak point: nothing stops a caller from forgetting to release, releasing twice, or keeping the pointer after release. The next section fixes the first two with RAII, and generation handles fix the third.

RAII handles

Theory

A pooled object should be returned exactly once, on every path, including exceptions and early returns. That is what RAII is for (Ch 1). The standard tool is std::unique_ptr with a custom deleter (Ch 12) whose call operator returns the object to the pool instead of deleting it.

#include <cstdio>
#include <memory>
#include <stdexcept>
#include <vector>

struct Connection {
    int id;
    int queries = 0;
};

class ConnectionPool {
public:
    struct Return {
        ConnectionPool* pool;
        void operator()(Connection* c) const noexcept { pool->give_back(c); }
    };
    using Handle = std::unique_ptr<Connection, Return>;

    explicit ConnectionPool(int n) {
        for (int i = 0; i < n; ++i) all_.push_back(std::make_unique<Connection>(Connection{i}));
        for (auto& c : all_) idle_.push_back(c.get());
    }
    Handle acquire() {
        if (idle_.empty()) throw std::runtime_error("no connection available");
        Connection* c = idle_.back();
        idle_.pop_back();
        return Handle(c, Return{this});
    }
    std::size_t idle() const { return idle_.size(); }

private:
    void give_back(Connection* c) noexcept {
        c->queries = 0;            // reset
        idle_.push_back(c);
    }
    std::vector<std::unique_ptr<Connection>> all_;
    std::vector<Connection*> idle_;
};

static void run_query(ConnectionPool& pool, bool fail) {
    auto conn = pool.acquire();
    ++conn->queries;
    if (fail) throw std::runtime_error("query failed");
}   // conn returns to the pool here, on both paths

int main() {
    ConnectionPool pool(2);
    run_query(pool, false);
    try { run_query(pool, true); } catch (const std::exception& e) { std::printf("caught: %s\n", e.what()); }
    std::printf("idle after both calls: %zu\n", pool.idle());
    {
        auto a = pool.acquire();
        auto b = std::move(a);     // ownership moves; still exactly one release
        std::printf("while held: idle %zu, a empty: %s\n", pool.idle(), a ? "no" : "yes");
    }
    std::printf("idle at the end: %zu\n", pool.idle());
}
caught: query failed
idle after both calls: 2
while held: idle 1, a empty: yes
idle at the end: 2

Design points:

  • Move-only handles make double release impossible by construction: copying is a compile error, and moving leaves the source empty, so its destructor does nothing.
  • Size. A deleter holding a pool pointer makes the handle 16 bytes on 64-bit instead of 8. A stateless deleter (for a global or thread_local pool) keeps it at 8, thanks to the empty-member optimisation inside unique_ptr.
  • The pool must outlive every handle. If the pool is destroyed first, the deleter calls into a dead object. Either guarantee the order (declare the pool before its users, destroy users first), or make handles hold a std::weak_ptr to shared pool state and delete the object if the pool is gone. The coding section builds the second design.
  • std::shared_ptr handles work too (std::shared_ptr<T>(raw, deleter)), and are thread-safe to copy, but each one allocates a control block, which puts an allocation back on the path the pool was meant to clear. Use std::allocate_shared with a pool allocator, or an intrusive reference count, if you need sharing.

Pool exhaustion policies

Theory

When every object is out, acquire has three choices. Pick one deliberately and document it.

PolicyHowGood forDanger
GrowConstruct a new object (maybe up to a hard limit)Buffers, general services where memory is cheapUnbounded memory under a load spike; the expensive construction happens on the hot path at the worst time
BlockWait on a condition variable until a release, usually with a timeoutConnection pools, worker pools: the limit protects a downstream resourceDeadlock if a thread holding one object waits for a second one; latency spikes
FailReturn an empty handle, std::optional, an error code, or throwTrading and real-time systems: shed load, reject the order, raise an alarmCallers that do not check the result

Blocking pools have one classic trap: a thread that already holds an object and asks for another can deadlock when every thread does the same (each holds one, all wait for a second). Database connection pools hit this when a request handler opens a nested transaction on a second connection. The fix is to never acquire a second object while holding one, or to size the pool for the nesting depth times the number of threads.

Always expose a high-water mark (the most objects ever in use) and a counter of exhaustion events. They tell you how to size the pool, and in a grow policy they show whether growth is a rare event or the normal case.

Stale state, double release and use after release

Theory

Object pools turn three kinds of bugs from crashes into silent data corruption, because the memory involved is always valid.

  • Stale state. reset() forgets a field, so the next user inherits it: a previous user's authentication token, a "cancelled" flag, a half-filled vector, a pending timer. The object looks valid, so nothing crashes. This is the most common object pool bug in production, and it can be a security hole (one customer's data in another customer's response).
  • Double release. The same object is pushed onto the idle list twice, so two later acquire calls return it to two different users, who then overwrite each other's data.
  • Use after release. A caller keeps a raw pointer or reference after releasing. The object is reset and handed to someone else, and the old caller now reads or writes the new user's object. No tool sees a problem: the pointer is to a live object of the right type.

Interview trap

"ASan will catch use after release." It will not. The object was never freed: it is a live T inside a live allocation. ASan, Valgrind and the debug heap only see allocator-level frees. The defences have to be in the pool's design: RAII handles for release, generation-checked handles for access, and a debug mode that poisons or cross-checks idle objects.

Defences that work:

  • Make reset() assign a freshly constructed object's state where possible (obj = T{}), but move the buffers you want to keep out first and back afterwards, because the assignment also throws away their capacity (the reading section shows it). Test that a reset object compares equal to a new one.
  • Keep an in_pool flag (or a state enum) per object and check it in both acquire and release. A double release becomes an assertion failure at the second call.
  • In debug builds, fill idle objects with a recognisable pattern after reset, or keep idle objects in a FIFO quarantine so a stale pointer does not immediately alias a new user.
  • Hand out handles rather than pointers, and check them on every access. That is the next section.

Generation counters and slot maps

Theory

Mental model. Instead of a pointer, the user holds a handle: a slot index plus a generation number. Each slot also stores a generation, and the pool increments it every time the slot is released. A lookup succeeds only when the handle's generation equals the slot's. A stale handle (one taken before the release) carries an old generation, so it fails cleanly instead of reaching the new occupant.

slots [0]gen 3liveGoblin [1]gen 1freenext free: 3 [2]gen 7liveArcher [3]gen 2freenext free: none handle {2, gen 7} handle {2, gen 6} 7 == 7: returns the Archer 6 ≠ 7: stale, returns null user code holds handles, never pointers
A handle is an index plus a generation. Slot 2 was released when it held generation 6 and then reused for an Archer at generation 7, so a handle kept from the earlier occupant no longer matches.

A generational pool

#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>

struct Handle { std::uint32_t index; std::uint32_t gen; };

template <class T>
class GenPool {
    static constexpr std::uint32_t kNone = UINT32_MAX;
    struct Slot { T value{}; std::uint32_t gen = 0; bool live = false; std::uint32_t next_free = kNone; };
    std::vector<Slot> slots_;
    std::uint32_t free_head_ = kNone;
public:
    Handle acquire() {
        if (free_head_ == kNone) {
            slots_.push_back({});
            free_head_ = static_cast<std::uint32_t>(slots_.size() - 1);
        }
        std::uint32_t i = free_head_;
        Slot& s = slots_[i];
        free_head_ = s.next_free;
        s.live = true;
        return {i, s.gen};
    }
    T* get(Handle h) {
        if (h.index >= slots_.size()) return nullptr;
        Slot& s = slots_[h.index];
        return s.live && s.gen == h.gen ? &s.value : nullptr;
    }
    bool release(Handle h) {
        if (!get(h)) return false;          // stale or double release: refuse
        Slot& s = slots_[h.index];
        s.value = T{};                      // reset
        s.live = false;
        ++s.gen;                            // invalidates every outstanding handle
        s.next_free = free_head_;
        free_head_ = h.index;
        return true;
    }
};

int main() {
    GenPool<std::string> names;
    Handle player = names.acquire();
    *names.get(player) = "player";
    names.release(player);
    Handle enemy = names.acquire();         // reuses slot 0
    *names.get(enemy) = "enemy";
    std::printf("slot reused: %s\n", enemy.index == player.index ? "yes" : "no");
    std::printf("old handle: %s\n", names.get(player) ? names.get(player)->c_str() : "stale (null)");
    std::printf("new handle: %s\n", names.get(enemy)->c_str());
    std::printf("double release accepted: %s\n", names.release(player) ? "yes" : "no");
}
slot reused: yes
old handle: stale (null)
new handle: enemy
double release accepted: no

Slot maps

A slot map (the name comes from game engines; SG14's proposal for a std::slot_map, P0661, was never adopted, so you write or borrow one) adds one more idea: the values are kept in a dense array with no holes, and each slot stores the index of its value in that array. Erase moves the last value into the hole and patches the moved value's slot. Lookups cost one extra indirection, but iterating over all live values is a straight walk over contiguous memory, which is what a game loop that updates every entity each frame wants (see Ch 19 on cache-friendly layouts). The coding section builds one.

  • Handle size. 32-bit index plus 32-bit generation fits in 8 bytes, the same as a pointer, and can be serialised or sent between threads, which a pointer cannot do safely.
  • Wrap-around. After 232 reuses of one slot, the generation repeats and a very old handle would match again. At one reuse per millisecond of a single slot that takes about 50 days, at one per microsecond about 72 minutes. Either accept the risk because stale handles never live that long, or retire a slot permanently when its generation reaches the maximum. With 16-bit generations, wrap-around is a real risk and retiring is required.
  • Pointer stability. In the version above, get() returns a pointer that is valid only until the next acquire, because slots_ can reallocate. Users must re-resolve handles instead of caching pointers. A fixed-capacity pool, or chunked storage, keeps addresses stable.

Thread-safe object pools

Theory

Object pools are shared more often than memory pools, because the things they hold (connections, buffers) are used by many request threads. The options mirror Ch 15:

  • A mutex around the idle list. Acquire and release are short (a vector push or pop), so the critical section is tiny. Do the reset outside the lock, before taking it in release, so a slow reset does not serialise every thread.
  • A mutex plus a condition variable for the blocking policy: acquire waits with a predicate (!idle_.empty()) and a timeout; release notifies one waiter after pushing. Always use the predicate form of wait to handle spurious wake-ups (Ch 6).
  • Per-thread caches in front of a shared pool. Each thread keeps a few idle objects locally and moves them to or from the shared pool in batches, which takes the lock once per batch. This is how thread-caching allocators and high-throughput buffer pools work.
  • Lock-free stacks with ABA protection, as in Ch 15. Rarely needed for object pools: the work done with each object usually dwarfs the cost of a short lock.

RAII handles keep working across threads: a handle moved to another thread releases from that thread, so release must be thread safe even if acquire is always called from one thread. The deleter's call into the pool is the only shared access.

Tip

Releasing must never throw and should not allocate. Reserve the idle list's capacity for the pool's maximum size, so push_back in release never reallocates, and mark the deleter noexcept. A throwing deleter inside a destructor during stack unwinding calls std::terminate.

Typical uses

Theory
Pooled objectExpensive part keptReset doesUsual exhaustion policy
Database / HTTP connectionsTCP connect, TLS handshake, authenticationRoll back transaction, reset session state, health checkBlock with timeout (the limit protects the server)
Threads (a thread pool)Thread creation, stack, TLS setupNothing: the thread waits for the next taskQueue tasks instead of creating threads
I/O and message buffersLarge heap buffers, registered or pinned memory for DMA and kernel bypassclear(), keep capacity; drop buffers that grew too largeGrow, with a cap
Game entities, particles, bulletsComponent storage, loaded assets, registrationReset transforms and timers, deactivateFail (skip spawning) or recycle the oldest
Orders and messages in tradingPre-faulted memory, pre-sized fields, no allocation on the hot pathClear fields, bump a sequence numberFail and alarm; pool is sized for the worst day
Parsers, regex or compression contextsInternal tables and work buffersLibrary reset call (for example deflateReset)Grow

A thread pool is the object pool everybody already knows, with one difference: threads are not handed to the caller. The caller submits work and the pool's threads acquire tasks, so the pool, not the user, controls the lifetime.

When pools hurt

Theory

An object pool is a cache of objects, and it has the costs of any cache.

  • Memory held forever. Every idle object keeps whatever it grew. One request with a 50 MB payload leaves a 50 MB buffer in the pool for the life of the process. Cap the capacity an object may keep (shrink or discard on release if it exceeds the cap), and cap the number of idle objects.
  • Cache effects. Pooled objects allocated one by one are scattered on the heap, and a pool that cycles through many idle objects touches memory that has gone cold. A fresh allocation from a thread-caching malloc can be hotter. LIFO reuse (always hand out the most recently released object) mitigates this.
  • Complexity and bugs. Reset functions must be kept in sync with every field added to the class, and lifetime bugs become silent. A pool is a manual memory manager with all the risks that implies.
  • Hiding problems from tools. Leak checkers, ASan and heap profilers see one long-lived allocation instead of the real usage pattern.
  • Small objects gain little. For an object that is just a few fields with no owned resources, modern allocators already serve the memory in 10 to 30 ns. Pooling it adds code and saves almost nothing; a memory pool or an arena is a better fit.

What a pool saves, measured

This benchmark builds a message object with a 2 KiB payload and four heap-allocated header strings, either fresh each time or from a pool that resets with clear().

#include <algorithm>
#include <chrono>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>

struct Message {
    std::vector<char> payload;
    std::vector<std::string> headers;
    void reset() { payload.clear(); headers.clear(); }
};

class MessagePool {
    std::vector<std::unique_ptr<Message>> free_;
public:
    std::unique_ptr<Message> acquire() {
        if (free_.empty()) return std::make_unique<Message>();
        auto m = std::move(free_.back());
        free_.pop_back();
        return m;
    }
    void release(std::unique_ptr<Message> m) { m->reset(); free_.push_back(std::move(m)); }
};

static void fill(Message& m, int i) {
    m.payload.resize(2048, static_cast<char>(i));
    for (int h = 0; h < 4; ++h) m.headers.emplace_back("x-header-with-a-long-name-" + std::to_string(h));
}

constexpr int kIters = 200000, kReps = 7;

template <class F>
static double median_ns(F f) {
    std::vector<double> v;
    for (int r = 0; r < kReps; ++r) {
        auto t0 = std::chrono::steady_clock::now();
        f();
        auto t1 = std::chrono::steady_clock::now();
        v.push_back(std::chrono::duration<double, std::nano>(t1 - t0).count() / kIters);
    }
    std::sort(v.begin(), v.end());
    std::printf("    min %.1f  max %.1f ns per message\n", v.front(), v.back());
    return v[v.size() / 2];
}

int main() {
    long sink = 0;
    std::puts("fresh object per message:");
    double fresh = median_ns([&] {
        for (int i = 0; i < kIters; ++i) { auto m = std::make_unique<Message>(); fill(*m, i); sink += m->payload[7]; }
    });
    std::puts("pooled object, reset on release:");
    MessagePool pool;
    double pooled = median_ns([&] {
        for (int i = 0; i < kIters; ++i) { auto m = pool.acquire(); fill(*m, i); sink += m->payload[7]; pool.release(std::move(m)); }
    });
    std::printf("median: fresh %.1f ns, pooled %.1f ns, ratio %.1fx (sink %ld)\n", fresh, pooled, fresh / pooled, sink & 1);
}

Representative numbers on an Apple M1 (macOS, -O2, median of 7 runs of 200,000 messages). The program is tagged ub so timings are never compared.

BuildFresh objectPooled objectRatio
GCC 16 + libstdc++336 ns158 ns2.1× faster pooled
Clang 22 + libc++484 to 548 ns294 to 322 ns1.6 to 1.7× faster pooled

The pool removes the payload allocation and the header vector's allocation, but not the four string allocations: headers.clear() destroys the strings, so their buffers are freed and reallocated on every message. Keeping them would need a reset that reuses the existing strings (assign into them instead of clearing the vector). The lesson for the interview: measure what the reset really keeps, and quote ratios with the platform; the two standard libraries differ by 1.5 times on the same machine.

Concept and tricky questions

Drills

ConceptWhat is the difference between an object pool and a memory pool, and how do you decide which one a problem needs?

Answer

Answer. A memory pool recycles raw storage, so the constructor and destructor still run on every use; it saves the malloc/free. An object pool recycles constructed objects and resets them instead, so it saves construction and destruction and everything they allocate or acquire. Profile first: if the time is in the allocator, use a memory pool or arena; if it is in constructors (buffer allocation, connecting, thread start), use an object pool.

Why. A pool of 32-byte trivially constructible structs gains nothing from an object pool, since the constructor is free. A pool of TLS connections gains nothing from a memory pool, since the cost is the handshake, not the 200 bytes of memory. Candidates who use the terms interchangeably usually get asked to clarify, so state the distinction first.

TrickyShould a pool reset an object when it is released or when it is next acquired? Give a case where the choice matters.

Answer

Answer. Usually on release. A request object that held a customer's authentication token or personal data should be wiped as soon as the request ends, not when some later request happens to pick it up. Release-time reset also drops references to other objects early (so they can be freed) and keeps acquire fast.

Why. Acquire-time reset is lazy and avoids resetting objects that are never reused, and it charges the cost to the thread that needs the object, which some designs prefer. But idle objects then hold the previous user's data and resources indefinitely: memory dumps, debugging tools and heap-inspection bugs can expose it, and an idle object may keep a large buffer or a lock alive. A common compromise is a cheap reset on release (drop secrets and references) and a validation on acquire (is the connection still alive?).

ConceptWhy return pooled objects through a std::unique_ptr with a custom deleter instead of a raw pointer and a release() function?

Answer

Answer. The handle releases the object exactly once on every path out of its scope, including exceptions and early returns, and its move-only type makes double release a compile error. Ownership transfer is explicit (std::move), and the moved-from handle is empty.

Why. A manual release() is skipped by any exception thrown between acquire and release, which leaks the object from the pool until it runs dry. The deleter's operator() receives the raw pointer and calls the pool instead of delete, which is a direct use of Ch 12's custom deleters. What the handle does not prevent is a raw pointer taken with get() and kept after the handle dies.

TrickyA ConnectionPool is a member of a server object, and handles are stored in a session cache that is also a member. The server's destructor crashes. What is the likely cause and the fix?

Answer

Answer. Members are destroyed in reverse declaration order. If the pool is declared after the session cache, the pool is destroyed first, and then the cache's handles run their deleters, which call give_back on a destroyed pool: use after free. Declare the pool first (so it dies last), or make handles hold a std::weak_ptr to the pool's shared state so a handle that outlives the pool just deletes its object.

Why. A deleter that stores Pool* silently assumes the pool outlives every handle. That holds for a pool in main and breaks for pools inside objects, static pools destroyed at exit while some other static still holds a handle, and pools destroyed during shutdown while another thread is still working. The weak-pointer design costs an atomic operation per release, which is usually acceptable. A debug-build check (the pool asserts that all objects are idle in its destructor) catches the order bug early.

TrickySomeone makes the pool return std::shared_ptr<T>(raw, [this](T* p) { release(p); }) so handles can be shared. What did that cost on the hot path?

Answer

Answer. Every acquire now allocates a control block on the heap (it holds the counts and the deleter), and every copy of the handle does an atomic increment and decrement. The pool was supposed to remove allocation from this path, and it has added one back.

Why. The shared_ptr(ptr, deleter) constructor must store the deleter somewhere, and for a type-erased deleter that is a separately allocated control block (make_shared cannot be used because the object already exists). Options: pass a pool allocator as the third argument so control blocks come from a pool (std::shared_ptr<T>(raw, deleter, alloc)), use an intrusive reference count inside T, or keep unique_ptr handles and share only borrowed references within a scope.

ConceptList the three exhaustion policies and choose one for a database connection pool and one for an order pool in a trading engine.

Answer

Answer. Grow (create another object), block (wait for a release, with a timeout), or fail (return an empty handle or error). A connection pool usually blocks with a timeout, because its size limit exists to protect the database from too many connections. An order pool fails: it rejects or throttles the new order and raises an alarm, because blocking or constructing objects on the hot path would add unpredictable latency.

Why. The policy follows from what the pool's limit protects. Grow is right when the limit is only a performance hint (buffer pools), and it should still have a hard cap. In every case expose a high-water mark and an exhaustion counter, so the pool can be sized from data rather than guesses.

TrickyA blocking connection pool has 10 connections and 10 worker threads. Under load the service hangs with every thread waiting in acquire(). No connection is leaked. What happened?

Answer

Answer. Each request acquires a connection and then, somewhere deeper (an audit log, a nested transaction), acquires a second one. With all 10 threads holding one connection and waiting for another, nobody can release: a resource deadlock with the hold-and-wait condition.

Why. This is the dining philosophers problem with a pool as the shared resource (Ch 6). It does not show up in light load, because a second connection is usually free. Fixes: pass the already-held connection down instead of acquiring another, size the pool as threads × maximum nesting depth, or use a timeout on acquire so the request fails instead of hanging forever. Many connection pool libraries detect it by logging when a thread that already holds a connection asks for another.

TrickyA slot map uses 16-bit generation counters to keep handles at 6 bytes. What can go wrong, and how do you prevent it?

Answer

Answer. After 65,536 reuses of one slot the generation wraps, and a stale handle from 65,536 reuses ago matches again and reaches the wrong object. A busy slot (a bullet in a shooter, an order slot in a trading system) can be reused that often within minutes. Prevent it by retiring a slot whose generation reaches the maximum (never put it back on the free list), or by using 32-bit generations.

Why. Generation checking is a probabilistic defence that is exact only until the counter wraps. With 32 bits, a slot reused once per microsecond wraps after about 72 minutes, so even 32 bits is not enough for the busiest slots in a long-running system if stale handles can live that long; retirement removes the problem entirely at the cost of a slowly shrinking pool. Spreading reuse across slots (FIFO free list) makes wrap-around much less likely for any single slot.

ConceptWhy do game engines identify entities with slot-map handles instead of raw pointers or an std::unordered_map<EntityId, Entity>?

Answer

Answer. Handles detect use of a destroyed entity (generation mismatch) instead of silently reaching a recycled one; the lookup is an array index, O(1) with no hashing; and the dense value array makes "update every entity" a linear, cache-friendly loop. Handles are also plain integers, so they can be saved, sent over the network and stored in other components safely.

Why. A raw pointer cannot tell a dead entity from a new one at the same address, and it is invalidated if storage moves. An unordered_map detects dead ids (lookup fails) but costs a hash and a pointer chase per lookup, scatters entities across nodes, and iterates slowly. The slot map costs one extra indirection per lookup and a swap on erase, which reorders the dense array.

TrickyA buffer pool resets buffers with clear(). After a week in production, the process uses 6 GB although typical messages are 4 KiB. Why?

Answer

Answer. clear() sets the size to zero and keeps the capacity. Every buffer that ever held a rare large message (say 50 MB) keeps that capacity forever, and over time most pooled buffers have seen one. The pool's memory converges to "number of buffers × largest message ever seen".

Why. Keeping capacity is the reason to pool buffers, so the reset is doing what it was asked. The fix is a cap: on release, if capacity() > limit, replace the buffer with a fresh one (std::vector<char>().swap(buf) or shrink_to_fit after clear, which is a non-binding request) or discard the object entirely. Separate pools by size class also help. This is the "memory held forever" cost of pools.

ConceptIn a mutex-protected object pool, should reset() run inside the lock in release?

Answer

Answer. No. Reset the object first, while the releasing thread still owns it exclusively, then take the lock only to push it onto the idle list. The critical section stays a few instructions long.

Why. The object is not shared until it is back on the list, so resetting it needs no synchronisation. Doing it inside the lock makes every acquire and release in the process wait for the slowest reset (clearing a large map, closing a cursor). The same reasoning applies to construction when a pool grows: construct outside the lock, then publish. If release notifies a condition variable, notifying after unlocking avoids waking a thread that immediately blocks on the mutex.

TrickyIs a thread pool an object pool? Explain what is the same and what is different.

Answer

Answer. Yes in purpose, no in interface. It keeps expensive objects (threads: kernel task, stack, thread-local state) alive and reuses them, exactly like an object pool. But callers do not acquire a thread; they submit tasks to a queue, and the pool's threads take tasks. Ownership and lifetime stay inside the pool.

Why. Handing out threads would let callers hold them indefinitely and would need the same exhaustion policies. Inverting the control (tasks go to threads) gives queueing for free and makes "block when exhausted" the natural behaviour. The reset step exists too, implicitly: a task must not leave thread-local state behind for the next task (a classic bug with thread_local caches in pooled threads).

TrickyA pooled Session holds a std::unique_lock<std::mutex> and a std::ofstream as members. The pool resets Session by clearing its string fields. What leaks while sessions are idle?

Answer

Answer. The lock stays held and the file stays open, possibly for hours, because the destructor (which would release them) does not run when an object returns to the pool. Other threads block on that mutex, and the process can run out of file descriptors.

Why. RAII members release resources in the destructor, and an object pool deliberately skips the destructor. Reset must therefore release every resource that should not outlive a use: lock.unlock() (or lock = {}), file.close(). A good habit is to write reset as "assign a default-constructed object", which runs the members' assignment operators and releases what they own, then add back only what you want to keep.

ConceptWould you put a 24-byte Point3 struct in an object pool? When is an object pool the wrong tool?

Answer

Answer. No. Its constructor costs nothing and the allocator serves 24 bytes in tens of nanoseconds; better still, keep such values in a std::vector or on the stack. Object pools are wrong when construction is cheap, when objects are small values, when the peak population is much larger than the steady state (the pool holds the peak forever), and when the extra lifetime bugs outweigh the measured gain.

Why. The benefit of an object pool is proportional to what reset keeps and construction would rebuild. For a value type that is nothing. A memory pool or an arena (Ch 17) helps with allocation cost, and value semantics in contiguous containers avoid the allocation entirely.

Predict the output and find the bug

Drills

Find the bugTwo HTTP requests reuse a pooled Request. What does the second request see?

#include <cstdio>
#include <memory>
#include <optional>
#include <string>
#include <vector>

struct Request {
    std::string path;
    std::vector<std::string> headers;
    std::optional<std::string> auth_user;
    void reset() { path.clear(); headers.clear(); }
};

class RequestPool {
    std::vector<std::unique_ptr<Request>> idle_;
public:
    std::unique_ptr<Request> acquire() {
        if (idle_.empty()) return std::make_unique<Request>();
        auto r = std::move(idle_.back());
        idle_.pop_back();
        return r;
    }
    void release(std::unique_ptr<Request> r) { r->reset(); idle_.push_back(std::move(r)); }
};

static void handle(const Request& r) {
    std::printf("%s as %s\n", r.path.c_str(), r.auth_user ? r.auth_user->c_str() : "anonymous");
}

int main() {
    RequestPool pool;
    auto a = pool.acquire();
    a->path = "/account";
    a->auth_user = "alice";
    handle(*a);
    pool.release(std::move(a));

    auto b = pool.acquire();     // an unauthenticated request
    b->path = "/account";
    handle(*b);
    pool.release(std::move(b));
}
Answer
/account as alice
/account as alice

Answer. The anonymous second request is treated as Alice. reset() clears path and headers but forgets auth_user, and the pool hands the same object back.

Why. Stale state is the signature bug of object pools, and here it is a security hole. Nothing crashes and no sanitizer can help: every access is to a live, valid object. Fix: reset every field (auth_user.reset()), or better, write reset() so that new fields cannot be forgotten, for example *this = Request{} followed by restoring the capacities you want to keep, and add a test that a reset object compares equal to a new one.

Find the bugThis pool stores its objects directly in a std::vector and grows it on demand. What does the sanitizer build report?

#include <cstdio>
#include <vector>

struct Bullet { float x = 0, y = 0; bool active = false; };

class BulletPool {
    std::vector<Bullet> bullets_;
    std::vector<std::size_t> idle_;
public:
    Bullet* acquire() {
        if (idle_.empty()) {
            bullets_.push_back({});            // grow
            idle_.push_back(bullets_.size() - 1);
        }
        std::size_t i = idle_.back();
        idle_.pop_back();
        bullets_[i].active = true;
        return &bullets_[i];
    }
};

int main() {
    BulletPool pool;
    Bullet* first = pool.acquire();
    for (int i = 0; i < 10; ++i) pool.acquire();   // more bullets
    first->x = 5.0f;                               // move the first bullet
    std::printf("%g\n", first->x);
}
Answer

Answer. push_back reallocates the vector when it grows, so first points into the old, freed buffer. ASan reports heap-use-after-free on the write first->x = 5.0f.

Why. A pool that hands out pointers must never move its objects. A std::vector moves all elements on reallocation, invalidating every pointer and reference. Fixes: reserve the full capacity up front and never grow, store objects in chunks that are never moved (std::deque keeps element addresses stable on push_back, or a vector of unique_ptr<Bullet[]> chunks), or hand out indices / generation handles instead of pointers. This is one of the few object pool bugs ASan does catch, because the old buffer really was freed.

OutputWhat does this print on a 64-bit platform?

#include <cstdio>
#include <memory>

struct Widget { int v; };
struct Pool;                                         // only pointers to it are needed

struct EmptyReturn { void operator()(Widget*) const noexcept {} };
struct PoolReturn { Pool* pool; void operator()(Widget*) const noexcept {} };
using FnReturn = void (*)(Widget*);

int main() {
    std::printf("%zu %zu %zu %zu\n",
                sizeof(std::unique_ptr<Widget>) / sizeof(void*),
                sizeof(std::unique_ptr<Widget, EmptyReturn>) / sizeof(void*),
                sizeof(std::unique_ptr<Widget, PoolReturn>) / sizeof(void*),
                sizeof(std::unique_ptr<Widget, FnReturn>) / sizeof(void*));
}
Answer
1 1 2 2

Answer. In pointer-sized words: the default deleter and an empty deleter add nothing (1 word), while a deleter that stores the pool pointer and a function-pointer deleter both double the handle to 2 words (16 bytes).

Why. unique_ptr stores its deleter as a member, using the empty base optimisation or [[no_unique_address]], so a stateless deleter takes no space. A function pointer is state, even if every handle stores the same one. For a global or thread_local pool, a stateless deleter that finds the pool itself keeps handles pointer-sized, which matters when millions of handles are stored. Ch 12 covers deleter costs in detail.

Find the bugA handle outlives its pool. What does the sanitizer build report?

#include <cstdio>
#include <memory>
#include <vector>

struct Buffer { std::vector<char> bytes; };

class BufferPool {
public:
    struct Return { BufferPool* pool; void operator()(Buffer* b) const noexcept { pool->give_back(b); } };
    using Handle = std::unique_ptr<Buffer, Return>;
    BufferPool() { for (int i = 0; i < 4; ++i) idle_.push_back(new Buffer); }
    ~BufferPool() { for (Buffer* b : idle_) delete b; }
    Handle acquire() { Buffer* b = idle_.back(); idle_.pop_back(); return Handle(b, Return{this}); }
private:
    void give_back(Buffer* b) noexcept { b->bytes.clear(); idle_.push_back(b); }
    std::vector<Buffer*> idle_;
};

int main() {
    auto pool = std::make_unique<BufferPool>();
    BufferPool::Handle h = pool->acquire();
    h->bytes.assign(64, 'x');
    pool.reset();                 // shutdown order bug: pool first
    std::puts("pool destroyed");
}                                 // h's deleter runs here
Answer

Answer. When h is destroyed at the end of main, its deleter calls give_back on the deleted pool, which reads and writes the freed idle_ vector. ASan reports heap-use-after-free inside give_back. (The buffer that was out also leaks, since the pool's destructor only deletes idle buffers.)

Why. A deleter holding a raw Pool* silently requires the pool to outlive every handle. Fixes: destroy users before the pool (declaration order), have the pool's destructor assert that nothing is checked out, or let handles hold a std::weak_ptr to shared pool state and simply delete the object when the pool is gone. The coding section implements the last one.

Find the bugAfter the stale-state incident, the team rewrote reset() so that no field can ever be forgotten. What does this print, and what did the pool stop doing?

#include <cstdio>
#include <memory>
#include <vector>

struct Message {
    std::vector<char> payload;
    int retries = 0;
    void reset() { *this = Message{}; }   // every field back to its default
};

class MessagePool {
    std::vector<std::unique_ptr<Message>> idle_;
public:
    std::unique_ptr<Message> acquire() {
        if (idle_.empty()) return std::make_unique<Message>();
        auto m = std::move(idle_.back());
        idle_.pop_back();
        return m;
    }
    void release(std::unique_ptr<Message> m) { m->reset(); idle_.push_back(std::move(m)); }
};

int main() {
    MessagePool pool;
    const Message* first = nullptr;
    for (int i = 0; i < 3; ++i) {
        auto m = pool.acquire();
        if (!first) first = m.get();
        std::printf("request %d: same object %s, capacity %zu\n", i, m.get() == first ? "yes" : "no", m->payload.capacity());
        m->payload.resize(4096);
        m->retries = 1;
        pool.release(std::move(m));
    }
}
Answer
request 0: same object yes, capacity 0
request 1: same object yes, capacity 0
request 2: same object yes, capacity 0

Answer. The same Message comes back every time, but its buffer does not. *this = Message{} move-assigns from a temporary, and the vector's move assignment frees the 4 KiB buffer and adopts the temporary's empty one. Every request allocates the payload again, so the pool now saves one small new and nothing else. Most people predict capacity 4096 for requests 1 and 2, because "the pool keeps the object".

Why. Assignment from a fresh object gives fresh state, and fresh state includes capacity 0: with std::allocator (which propagates on move assignment) both standard libraries free the old buffer and take the source's. The safety idea is right and only needs to keep what the pool exists for: auto keep = std::move(payload); *this = Message{}; payload = std::move(keep); payload.clear(); resets every other field, including ones added later, and keeps the buffer. Test both properties: a reset object compares equal to a new one, and its capacity survives.

Find the bugWhy does this not compile, and is the compiler right?

#include <memory>
#include <vector>

struct Conn { int fd = -1; };
struct Return { void operator()(Conn* c) const noexcept { delete c; } };
using Handle = std::unique_ptr<Conn, Return>;

static Handle acquire() { return Handle(new Conn{3}); }

int main() {
    Handle h = acquire();
    std::vector<Handle> in_flight;
    in_flight.push_back(h);       // keep a copy for the retry logic
}
Answer

Answer. push_back(h) needs to copy the handle, and unique_ptr's copy constructor is deleted. Both compilers reject it. The compiler is right: two copies of the handle would both return the connection to the pool, which is a double release.

Why. Move-only handles turn the double-release bug into a compile error. If the vector should own the handle, write in_flight.push_back(std::move(h)) and stop using h. If the retry logic only needs to look at the connection, store a reference or the generation handle, not a second owner. Returning Handle by value from acquire compiles because a returned local is moved (or elided, Ch 23).

OutputTrace the generations. What does this print?

#include <cstdint>
#include <cstdio>
#include <vector>

struct Handle { std::uint32_t index, gen; };

class Slots {
    struct Slot { std::uint32_t gen = 0; bool live = false; };
    std::vector<Slot> slots_ = std::vector<Slot>(2);
public:
    Handle acquire() {
        for (std::uint32_t i = 0; i < slots_.size(); ++i)
            if (!slots_[i].live) { slots_[i].live = true; return {i, slots_[i].gen}; }
        return {UINT32_MAX, 0};
    }
    bool valid(Handle h) const { return h.index < slots_.size() && slots_[h.index].live && slots_[h.index].gen == h.gen; }
    void release(Handle h) { if (valid(h)) { slots_[h.index].live = false; ++slots_[h.index].gen; } }
};

int main() {
    Slots s;
    Handle a = s.acquire();
    Handle b = s.acquire();
    s.release(a);
    Handle c = s.acquire();
    s.release(c);
    Handle d = s.acquire();
    std::printf("a{%u,%u} b{%u,%u} c{%u,%u} d{%u,%u}\n", a.index, a.gen, b.index, b.gen, c.index, c.gen, d.index, d.gen);
    std::printf("valid: a=%d b=%d c=%d d=%d\n", s.valid(a), s.valid(b), s.valid(c), s.valid(d));
    s.release(a);   // stale: ignored
    std::printf("d still valid: %d\n", s.valid(d));
}
Answer
a{0,0} b{1,0} c{0,1} d{0,2}
valid: a=0 b=1 c=0 d=1
d still valid: 1

Answer. a and b take slots 0 and 1 at generation 0. Releasing a bumps slot 0 to generation 1, so c is {0, 1}; releasing c bumps it to 2, so d is {0, 2}. Only b and d are valid, and the stale release through a is rejected, so d survives.

Why. The generation is the number of times the slot has been released. Every handle created before a release carries a smaller number and can no longer read or release the slot. Without generations, s.release(a) would have freed d's slot: the classic use-after-release turned into a double use.

Find the bugA thread-safe pool locks in acquire. What does ThreadSanitizer report?

#include <cstdio>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>

struct Job { int payload = 0; };

class JobPool {
    std::mutex m_;
    std::vector<std::unique_ptr<Job>> all_;
    std::vector<Job*> idle_;
public:
    JobPool() {
        for (int i = 0; i < 64; ++i) { all_.push_back(std::make_unique<Job>()); idle_.push_back(all_.back().get()); }
        idle_.reserve(64);
    }
    Job* acquire() {
        std::lock_guard lock(m_);
        if (idle_.empty()) return nullptr;
        Job* j = idle_.back();
        idle_.pop_back();
        return j;
    }
    void release(Job* j) {        // "release is just a push, no lock needed"
        j->payload = 0;
        idle_.push_back(j);
    }
};

int main() {
    JobPool pool;
    auto work = [&] {
        for (int i = 0; i < 10000; ++i)
            if (Job* j = pool.acquire()) { j->payload = i; pool.release(j); }
    };
    std::thread t1(work), t2(work);
    t1.join();
    t2.join();
    std::puts("done");
}
Answer

Answer. TSan reports a data race on idle_: release modifies the vector without the mutex while the other thread reads and modifies it inside acquire. Even a push that never reallocates writes the vector's end pointer, and the pop in the other thread reads it.

Why. A mutex protects data only if every access to that data takes it. Locking one side of a producer/consumer pair is the most common way to get this wrong. The fix is std::lock_guard lock(m_); around the push, with the reset (j->payload = 0) left outside the lock because the releasing thread still owns the job at that point. The program usually prints "done" in a normal build, which is why it is tagged ub and checked with TSan.

OutputA pool hands out std::shared_ptr handles. When does the object return to the pool?

#include <cstdio>
#include <memory>
#include <vector>

struct Frame { int seq = 0; };

class FramePool {
    std::vector<std::unique_ptr<Frame>> all_;
    std::vector<Frame*> idle_;
public:
    FramePool() { all_.push_back(std::make_unique<Frame>()); idle_.push_back(all_.back().get()); }
    std::shared_ptr<Frame> acquire() {
        Frame* f = idle_.back();
        idle_.pop_back();
        return std::shared_ptr<Frame>(f, [this](Frame* p) { std::printf("  frame %d back in pool\n", p->seq); idle_.push_back(p); });
    }
    std::size_t idle() const { return idle_.size(); }
};

int main() {
    FramePool pool;
    auto f = pool.acquire();
    f->seq = 42;
    {
        auto encoder = f;
        auto network = f;
        std::printf("use_count %ld, idle %zu\n", f.use_count(), pool.idle());
        f.reset();
        std::puts("main dropped its copy");
    }
    std::printf("idle %zu\n", pool.idle());
}
Answer
use_count 3, idle 0
main dropped its copy
  frame 42 back in pool
idle 1

Answer. Three owners exist inside the block, so use_count is 3 and nothing is idle. Dropping f does not return the frame; the deleter runs when the last copy (network or encoder, whichever is destroyed last) goes away at the closing brace.

Why. A shared_ptr deleter runs when the strong count reaches zero, whichever copy that happens in. That is convenient for frames shared by several consumers, but remember the cost: constructing this shared_ptr allocated a control block, and copies do atomic count updates. The deleter captures this, so the same "pool must outlive handles" rule applies.

Find the bugA logger keeps a raw pointer obtained from a handle. What does it log?

#include <cstdio>
#include <memory>
#include <string>
#include <vector>

struct Quote { std::string symbol; double px = 0; };

class QuotePool {
public:
    struct Return { QuotePool* p; void operator()(Quote* q) const noexcept { q->symbol.clear(); q->px = 0; p->idle_.push_back(q); } };
    using Handle = std::unique_ptr<Quote, Return>;
    QuotePool() { for (int i = 0; i < 2; ++i) { all_.push_back(std::make_unique<Quote>()); idle_.push_back(all_.back().get()); } }
    Handle acquire() { Quote* q = idle_.back(); idle_.pop_back(); return Handle(q, Return{this}); }
private:
    std::vector<std::unique_ptr<Quote>> all_;
    std::vector<Quote*> idle_;
};

int main() {
    QuotePool pool;
    const Quote* last_logged = nullptr;
    {
        auto q = pool.acquire();
        q->symbol = "AAPL";
        q->px = 227.5;
        last_logged = q.get();             // keep it for the end-of-day report
    }
    auto next = pool.acquire();
    next->symbol = "MSFT";
    next->px = 512.25;
    std::printf("last logged: %s %.2f\n", last_logged->symbol.c_str(), last_logged->px);
}
Answer
last logged: MSFT 512.25

Answer. It logs MSFT at 512.25. The AAPL quote went back to the pool at the closing brace, the next acquire got the same object (LIFO), and last_logged now looks at the new quote. ASan is clean, and this program has no undefined behaviour at all: the object was never destroyed, only reset and reused.

Why. RAII handles fix release, not borrowing. A raw pointer from get() is a non-owning view and must not outlive the handle. If something needs the data later, copy the data (Quote copy = *q;), or keep a generation-checked handle that will report the reuse. This is the purest form of use-after-release: no tool can find it, only the pool's design.

OutputA fail-fast pool with capacity 2. What does this print?

#include <algorithm>
#include <cstdio>
#include <optional>
#include <vector>

class TicketPool {
    std::vector<int> idle_{1, 2};
    int high_water_ = 0, out_ = 0, rejected_ = 0;
public:
    std::optional<int> acquire() {
        if (idle_.empty()) { ++rejected_; return std::nullopt; }
        int t = idle_.back();
        idle_.pop_back();
        high_water_ = std::max(high_water_, ++out_);
        return t;
    }
    void release(int t) { idle_.push_back(t); --out_; }
    void stats() const { std::printf("high water %d, rejected %d\n", high_water_, rejected_); }
};

int main() {
    TicketPool pool;
    auto a = pool.acquire();
    auto b = pool.acquire();
    auto c = pool.acquire();
    std::printf("a=%d b=%d c=%s\n", *a, *b, c ? "got one" : "none");
    pool.release(*a);
    auto d = pool.acquire();
    std::printf("d=%d\n", d.value_or(-1));
    pool.stats();
}
Answer
a=2 b=1 c=none
d=2
high water 2, rejected 1

Answer. The idle list is a stack with 2 on top, so a gets 2 and b gets 1. The third request is rejected. Releasing ticket 2 puts it back on top, so d gets 2. At most two were ever out, and one request was rejected.

Why. The fail policy returns an empty std::optional and counts the event, which is what you want on a latency-critical path: the caller decides what to do (reject, retry later) and the counters tell operations how to size the pool. Note that *c on an empty optional would be undefined, which is why the code tests c first; value() would throw std::bad_optional_access instead.

Write the code

Coding

CodeImplement ObjectPool<T> with RAII handles. Requirements: ObjectPool(initial, max, reset) pre-constructs initial objects; acquire() returns a std::unique_ptr<T, Return>, growing up to max and returning an empty handle beyond that; release (through the handle) resets the object, never allocates and never throws; the pool reports its high-water mark and the number of failed acquires.

Answer

Approach. The pool owns every object in all_ and lists the idle ones in idle_; both vectors reserve max slots at construction, so a release is a reset plus a push_back that cannot reallocate. The deleter holds a pool pointer and calls a private release. Growth constructs a new object only when the idle list is empty and the limit allows it.

#include <algorithm>
#include <cassert>
#include <cstddef>
#include <cstdio>
#include <functional>
#include <memory>
#include <vector>

template <class T>
class ObjectPool {
public:
    class Return {
        ObjectPool* pool_ = nullptr;
    public:
        Return() = default;
        explicit Return(ObjectPool* p) noexcept : pool_(p) {}
        void operator()(T* obj) const noexcept { pool_->release(obj); }
    };
    using Handle = std::unique_ptr<T, Return>;

    ObjectPool(std::size_t initial, std::size_t max, std::function<void(T&)> reset)
        : max_(max), reset_(std::move(reset)) {
        all_.reserve(max_);
        idle_.reserve(max_);
        for (std::size_t i = 0; i < initial && i < max_; ++i) {
            all_.push_back(std::make_unique<T>());
            idle_.push_back(all_.back().get());
        }
    }
    ~ObjectPool() { assert(idle_.size() == all_.size() && "objects still checked out"); }
    ObjectPool(const ObjectPool&) = delete;
    ObjectPool& operator=(const ObjectPool&) = delete;

    Handle acquire() {
        if (idle_.empty()) {
            if (all_.size() == max_) { ++failed_; return Handle(nullptr, Return(this)); }
            all_.push_back(std::make_unique<T>());          // grow (may throw: nothing changed yet)
            idle_.push_back(all_.back().get());
        }
        T* obj = idle_.back();
        idle_.pop_back();
        high_water_ = std::max(high_water_, all_.size() - idle_.size());
        return Handle(obj, Return(this));
    }
    std::size_t high_water() const { return high_water_; }
    std::size_t failed() const { return failed_; }
    std::size_t size() const { return all_.size(); }
    std::size_t idle() const { return idle_.size(); }

private:
    void release(T* obj) noexcept {
        reset_(*obj);
        idle_.push_back(obj);                               // capacity is max_: no reallocation
    }
    std::size_t max_, high_water_ = 0, failed_ = 0;
    std::function<void(T&)> reset_;
    std::vector<std::unique_ptr<T>> all_;
    std::vector<T*> idle_;
};

struct Buffer { std::vector<char> data; };

int main() {
    ObjectPool<Buffer> pool(2, 3, [](Buffer& b) { b.data.clear(); });
    {
        auto a = pool.acquire();
        auto b = pool.acquire();
        auto c = pool.acquire();          // grows to 3
        auto d = pool.acquire();          // over the limit
        a->data.assign(4096, 'a');
        std::printf("size %zu, d is %s\n", pool.size(), d ? "valid" : "empty");
    }
    auto e = pool.acquire();
    std::printf("reused buffer: size %zu, capacity kept %s\n", e->data.size(), e->data.capacity() >= 4096 ? "yes" : "no");
    e.reset();
    std::printf("idle %zu, high water %zu, failed %zu\n", pool.idle(), pool.high_water(), pool.failed());
}
size 3, d is empty
reused buffer: size 0, capacity kept yes
idle 3, high water 3, failed 1

Complexity and pitfalls. Acquire and release are O(1); growth is one construction. The LIFO idle list hands the most recently released buffer (the one with 4 KiB of capacity) back first, which is why e gets it. Pitfalls: an empty handle still carries a deleter, but unique_ptr never calls the deleter on a null pointer, so that is safe; a reset_ that throws inside the noexcept release calls std::terminate; and the pool must outlive its handles (the destructor asserts in debug builds).

Estimated rating: ~1500 · straightforward structure, but no-allocation release, growth limits and the deleter design must all be right

CodeWrite a blocking pool: acquire_for(timeout) waits until an object is free or the timeout expires (then returns an empty handle). Test with 6 threads sharing 2 objects and check that at most 2 are ever in use at once.

Answer

Approach. A mutex protects the idle list and a condition variable signals releases. acquire_for uses wait_for with a predicate, which handles spurious wake-ups and returns false on timeout. release pushes under the lock and notifies one waiter after unlocking.

#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>

struct Conn { int id = 0; int uses = 0; };

class BlockingPool {
public:
    struct Return { BlockingPool* p; void operator()(Conn* c) const noexcept { p->release(c); } };
    using Handle = std::unique_ptr<Conn, Return>;

    explicit BlockingPool(int n) {
        for (int i = 0; i < n; ++i) all_.push_back(std::make_unique<Conn>(Conn{i}));
        for (auto& c : all_) idle_.push_back(c.get());
    }
    Handle acquire_for(std::chrono::milliseconds timeout) {
        std::unique_lock lock(m_);
        if (!cv_.wait_for(lock, timeout, [&] { return !idle_.empty(); }))
            return Handle(nullptr, Return{this});
        Conn* c = idle_.back();
        idle_.pop_back();
        return Handle(c, Return{this});
    }
    std::size_t idle() { std::lock_guard lock(m_); return idle_.size(); }

private:
    void release(Conn* c) noexcept {
        {
            std::lock_guard lock(m_);
            idle_.push_back(c);           // capacity never exceeds all_.size(): reserved by construction
        }
        cv_.notify_one();
    }
    std::mutex m_;
    std::condition_variable cv_;
    std::vector<std::unique_ptr<Conn>> all_;
    std::vector<Conn*> idle_;
};

int main() {
    BlockingPool pool(2);
    std::atomic<int> in_use{0}, max_in_use{0}, timeouts{0}, done{0};
    auto client = [&] {
        for (int i = 0; i < 20; ++i) {
            auto c = pool.acquire_for(std::chrono::milliseconds(500));
            if (!c) { ++timeouts; continue; }
            int now = ++in_use;
            int seen = max_in_use.load();
            while (now > seen && !max_in_use.compare_exchange_weak(seen, now)) {}
            ++c->uses;                                  // exclusive use: no race
            std::this_thread::sleep_for(std::chrono::microseconds(200));
            --in_use;
            ++done;
        }
    };
    std::vector<std::thread> ts;
    for (int t = 0; t < 6; ++t) ts.emplace_back(client);
    for (auto& t : ts) t.join();
    std::printf("completed %d, timeouts %d, max in use %d, idle %zu\n", done.load(), timeouts.load(), max_in_use.load(), pool.idle());
}
completed 120, timeouts 0, max in use 2, idle 2

Complexity and pitfalls. O(1) per operation plus waiting time. The idle_ vector was filled to its full size at construction, so its capacity covers every release. Pitfalls: wait_for without a predicate (spurious wake-ups, and a missed notification if the release happened before the wait), notifying while holding the lock (correct, but the woken thread immediately blocks on the mutex), and the nested-acquire deadlock from the concept drills. The 500 ms timeout is generous so the test never times out under TSan's slowdown.

Estimated rating: ~1600 · standard condition-variable pattern, plus a concurrent test that actually checks the invariant

CodeImplement a slot map: Key insert(T), bool erase(Key), T* get(Key), size() and iteration over all live values in a dense, contiguous array. Stale keys must return null, and erase must be O(1).

Answer

Approach. Three arrays: slots_ (generation, live flag, and either the value's dense index or the next free slot), values_ (dense values) and owner_ (for each dense value, the slot that points to it). Erase moves the last value into the hole, updates the moved value's slot through owner_, bumps the erased slot's generation and pushes it on the free list.

#include <cstdint>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>

template <class T>
class SlotMap {
public:
    struct Key { std::uint32_t index, gen; };

    Key insert(T value) {
        std::uint32_t s;
        if (free_head_ != kNone) { s = free_head_; free_head_ = slots_[s].link; }
        else { s = static_cast<std::uint32_t>(slots_.size()); slots_.push_back({}); }
        slots_[s].link = static_cast<std::uint32_t>(values_.size());
        slots_[s].live = true;
        values_.push_back(std::move(value));
        owner_.push_back(s);
        return {s, slots_[s].gen};
    }
    T* get(Key k) {
        if (k.index >= slots_.size()) return nullptr;
        const Slot& s = slots_[k.index];
        return s.live && s.gen == k.gen ? &values_[s.link] : nullptr;
    }
    bool erase(Key k) {
        if (!get(k)) return false;
        Slot& s = slots_[k.index];
        std::uint32_t hole = s.link, last = static_cast<std::uint32_t>(values_.size() - 1);
        if (hole != last) {
            values_[hole] = std::move(values_[last]);
            owner_[hole] = owner_[last];
            slots_[owner_[hole]].link = hole;
        }
        values_.pop_back();
        owner_.pop_back();
        s.live = false;
        ++s.gen;
        s.link = free_head_;
        free_head_ = k.index;
        return true;
    }
    std::size_t size() const { return values_.size(); }
    auto begin() { return values_.begin(); }
    auto end() { return values_.end(); }

private:
    static constexpr std::uint32_t kNone = UINT32_MAX;
    struct Slot { std::uint32_t gen = 0; std::uint32_t link = kNone; bool live = false; };
    std::vector<Slot> slots_;
    std::vector<T> values_;
    std::vector<std::uint32_t> owner_;
    std::uint32_t free_head_ = kNone;
};

struct Entity { std::string name; int hp; };

int main() {
    SlotMap<Entity> world;
    auto hero = world.insert({"hero", 100});
    auto orc = world.insert({"orc", 30});
    auto bat = world.insert({"bat", 5});
    auto elf = world.insert({"elf", 60});

    world.erase(orc);                                  // elf moves into orc's dense position
    std::printf("orc: %s, elf: %s %d\n", world.get(orc) ? "alive" : "gone", world.get(elf)->name.c_str(), world.get(elf)->hp);

    auto troll = world.insert({"troll", 80});          // reuses orc's slot with a new generation
    std::printf("troll slot %u gen %u, orc slot %u gen %u, stale orc: %s\n",
                troll.index, troll.gen, orc.index, orc.gen, world.get(orc) ? "found" : "null");

    int total = 0;
    for (Entity& e : world) total += e.hp;             // dense iteration
    std::printf("%zu entities, total hp %d, double erase: %s\n", world.size(), total, world.erase(orc) ? "accepted" : "refused");
    world.erase(hero);
    world.erase(bat);
    std::printf("left: %zu, troll is %s\n", world.size(), world.get(troll)->name.c_str());
}
orc: gone, elf: elf 60
troll slot 1 gen 1, orc slot 1 gen 0, stale orc: null
4 entities, total hp 245, double erase: refused
left: 2, troll is troll

Complexity and pitfalls. Insert, get and erase are O(1) (insert amortised); iteration is a linear walk over contiguous values. Pitfalls: forgetting to update the moved element's slot (its key then points at the wrong value or out of range), erasing the last element (the move must be skipped, or the self-move leaves a moved-from value), and keeping T* across inserts, because values_ can reallocate and erase reorders it. Dense order is not insertion order. The generations can wrap after 232 reuses of a slot; production slot maps retire such slots.

Estimated rating: ~1800 · three arrays that must stay consistent through swap-and-pop, plus generation handling

CodeDesign an object pool whose handles may safely outlive the pool. If the pool still exists, releasing a handle returns the object; if the pool has been destroyed, releasing deletes the object. It must be safe to release from any thread.

Answer

Approach. Put the pool's state (mutex, idle list, reset function) in a std::shared_ptr<State> owned by the pool, and give each handle's deleter a std::weak_ptr<State>. On release, lock() the weak pointer: if it succeeds, the state is kept alive for the duration of the release and the object goes back; if it fails, the pool is gone and the deleter deletes the object. Checked-out objects are owned by their handle, idle ones by the state.

#include <cstdio>
#include <functional>
#include <memory>
#include <mutex>
#include <vector>

template <class T>
class SafePool {
    struct State {
        std::mutex m;
        std::vector<std::unique_ptr<T>> idle;
        std::function<void(T&)> reset;
    };
    std::shared_ptr<State> state_;

public:
    struct Return {
        std::weak_ptr<State> state;
        void operator()(T* obj) const noexcept {
            std::unique_ptr<T> owned(obj);              // deletes obj unless handed back
            if (auto s = state.lock()) {
                s->reset(*owned);
                std::lock_guard lock(s->m);
                s->idle.push_back(std::move(owned));
            }
        }
    };
    using Handle = std::unique_ptr<T, Return>;

    SafePool(std::size_t n, std::function<void(T&)> reset) : state_(std::make_shared<State>()) {
        state_->reset = std::move(reset);
        state_->idle.reserve(n);
        for (std::size_t i = 0; i < n; ++i) state_->idle.push_back(std::make_unique<T>());
    }
    Handle acquire() {
        std::lock_guard lock(state_->m);
        if (state_->idle.empty()) return Handle(nullptr, Return{state_});
        T* obj = state_->idle.back().release();
        state_->idle.pop_back();
        return Handle(obj, Return{state_});
    }
    std::size_t idle() const { std::lock_guard lock(state_->m); return state_->idle.size(); }
};

struct Tracked {
    static inline int alive = 0;
    int value = 0;
    Tracked() { ++alive; }
    ~Tracked() { --alive; }
};

int main() {
    SafePool<Tracked>::Handle survivor;
    {
        SafePool<Tracked> pool(3, [](Tracked& t) { t.value = 0; });
        {
            auto a = pool.acquire();
            a->value = 7;
        }
        std::printf("after normal release: idle %zu, alive %d\n", pool.idle(), Tracked::alive);
        survivor = pool.acquire();
        survivor->value = 9;
    }   // pool destroyed while survivor is checked out
    std::printf("pool gone: alive %d, survivor value %d\n", Tracked::alive, survivor->value);
    survivor.reset();   // deleter finds no pool and deletes the object
    std::printf("after release: alive %d\n", Tracked::alive);
}
after normal release: idle 3, alive 3
pool gone: alive 1, survivor value 9
after release: alive 0

Complexity and pitfalls. Each handle stores a weak_ptr (16 bytes), so handles are 24 bytes, and each release does an atomic lock() plus the mutex. The object is wrapped in a unique_ptr at the top of the deleter so that every path either hands it back or deletes it. Pitfalls: push_back inside a noexcept deleter could throw bad_alloc if the idle vector had to grow (it cannot here, because only objects created by this pool come back and the vector reserved room for all of them); and calling reset outside the lock is deliberate, because the releasing thread still owns the object exclusively.

Estimated rating: ~1800 · ownership transfer between handle and pool and weak_ptr lifetime reasoning, with no leak on either destruction order

CodeBuild a buffer pool with size buckets (4 KiB, 16 KiB, 64 KiB). acquire(n) returns a handle to a std::vector<char> with capacity of at least n, taken from the smallest bucket that fits; larger requests get an unpooled buffer. On release, buffers that grew beyond twice their bucket size are discarded instead of kept, and each bucket keeps at most 4 idle buffers.

Answer

Approach. Each bucket is a stack of idle buffers. The handle's deleter knows the bucket index (or a sentinel for unpooled buffers). Release clears the buffer and keeps it only if its capacity is within the cap and the bucket has room; otherwise the buffer is freed. Counters show reuse and discards so the policy is observable.

#include <array>
#include <cstddef>
#include <cstdio>
#include <memory>
#include <vector>

class BufferPool {
public:
    using Buffer = std::vector<char>;
    static constexpr std::array<std::size_t, 3> kSizes{4096, 16384, 65536};
    static constexpr std::size_t kMaxIdle = 4, kUnpooled = kSizes.size();

    struct Return {
        BufferPool* pool;
        std::size_t bucket;
        void operator()(Buffer* b) const noexcept { pool->release(b, bucket); }
    };
    using Handle = std::unique_ptr<Buffer, Return>;

    BufferPool() { for (auto& b : idle_) b.reserve(kMaxIdle); }

    Handle acquire(std::size_t n) {
        std::size_t k = 0;
        while (k < kSizes.size() && kSizes[k] < n) ++k;
        if (k == kUnpooled) {                                   // too large to pool
            auto* b = new Buffer;
            b->reserve(n);
            return Handle(b, Return{this, kUnpooled});
        }
        auto& stack = idle_[k];
        if (!stack.empty()) {
            ++reused_;
            Buffer* b = stack.back().release();
            stack.pop_back();
            return Handle(b, Return{this, k});
        }
        auto* b = new Buffer;
        b->reserve(kSizes[k]);
        return Handle(b, Return{this, k});
    }
    void stats() const {
        std::printf("reused %d, discarded %d, idle per bucket: %zu %zu %zu\n",
                    reused_, discarded_, idle_[0].size(), idle_[1].size(), idle_[2].size());
    }

private:
    void release(Buffer* b, std::size_t k) noexcept {
        std::unique_ptr<Buffer> owned(b);
        if (k == kUnpooled) return;                             // freed
        if (b->capacity() > 2 * kSizes[k] || idle_[k].size() == kMaxIdle) { ++discarded_; return; }
        b->clear();                                             // keep the capacity
        idle_[k].push_back(std::move(owned));
    }
    std::array<std::vector<std::unique_ptr<Buffer>>, 3> idle_;
    int reused_ = 0, discarded_ = 0;
};

int main() {
    BufferPool pool;
    {
        auto a = pool.acquire(1000);
        auto b = pool.acquire(10000);
        a->assign(1000, 'a');
        b->assign(200000, 'b');                // grows far past its 16 KiB bucket
        auto big = pool.acquire(1 << 20);      // unpooled
        std::printf("a cap >= 4096: %s, big cap >= 1 MiB: %s\n",
                    a->capacity() >= 4096 ? "yes" : "no", big->capacity() >= (1u << 20) ? "yes" : "no");
    }
    pool.stats();
    auto c = pool.acquire(3000);               // reuses a's buffer
    std::printf("c is empty: %s, cap >= 4096: %s\n", c->empty() ? "yes" : "no", c->capacity() >= 4096 ? "yes" : "no");
    c.reset();
    std::vector<BufferPool::Handle> many;
    for (int i = 0; i < 6; ++i) many.push_back(pool.acquire(100));
    many.clear();                              // only 4 may stay idle
    pool.stats();
}
a cap >= 4096: yes, big cap >= 1 MiB: yes
reused 0, discarded 1, idle per bucket: 1 0 0
c is empty: yes, cap >= 4096: yes
reused 2, discarded 3, idle per bucket: 4 0 0

Complexity and pitfalls. O(number of buckets) to pick a bucket, O(1) otherwise. The 200,000-byte buffer is discarded on release instead of pinning 200 KB in the 16 KiB bucket forever, which is the "memory held forever" fix from the theory. Pitfalls: the deleter's bucket must be the one the buffer came from, not recomputed from its current capacity (it may have grown); clear() keeps capacity but shrink_to_fit() would throw it away; and the idle stacks reserve kMaxIdle so release never allocates.

Estimated rating: ~1600 · a policy-heavy pool: bucket choice, capacity caps and idle limits must agree between acquire and release

CodePut a per-thread cache in front of a shared, mutex-protected pool. Each thread keeps up to 8 idle objects locally; when its cache is empty it takes a batch of 4 from the shared pool (constructing new objects if needed), and when the cache is over 8 it returns a batch of 4. A thread's cache must flush back when the thread exits. Test with 4 threads.

Answer

Approach. A global SharedPool holds idle objects under a mutex. A thread_local cache object serves acquire and release without locking and moves objects to and from the shared pool in batches of 4, so the lock is taken about once per four operations at most. The cache's destructor runs at thread exit and returns everything. Each object carries an atomic "owned" flag so the test can prove no object is ever held by two threads.

#include <atomic>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>

struct Msg {
    std::atomic<bool> owned{false};
    int payload = 0;
};

class SharedPool {
    std::mutex m_;
    std::vector<Msg*> idle_;
    std::vector<Msg*> all_;
public:
    ~SharedPool() { for (Msg* p : all_) delete p; }
    void take(std::vector<Msg*>& out, std::size_t n) {
        std::lock_guard lock(m_);
        while (n-- > 0) {
            if (idle_.empty()) { all_.push_back(new Msg); idle_.push_back(all_.back()); }
            out.push_back(idle_.back());
            idle_.pop_back();
        }
    }
    void give(std::vector<Msg*>& from, std::size_t n) {
        std::lock_guard lock(m_);
        while (n-- > 0 && !from.empty()) { idle_.push_back(from.back()); from.pop_back(); }
    }
    std::size_t total() { std::lock_guard lock(m_); return all_.size(); }
    std::size_t idle() { std::lock_guard lock(m_); return idle_.size(); }
};

static SharedPool g_pool;

class LocalCache {
    static constexpr std::size_t kHigh = 8, kBatch = 4;
    std::vector<Msg*> cache_;
public:
    ~LocalCache() { g_pool.give(cache_, cache_.size()); }   // thread exit
    Msg* acquire() {
        if (cache_.empty()) g_pool.take(cache_, kBatch);
        Msg* m = cache_.back();
        cache_.pop_back();
        return m;
    }
    void release(Msg* m) {
        m->payload = 0;                                       // reset
        cache_.push_back(m);
        if (cache_.size() > kHigh) g_pool.give(cache_, kBatch);
    }
};

static thread_local LocalCache t_cache;

int main() {
    std::atomic<int> clashes{0};
    auto worker = [&](int id) {
        std::vector<Msg*> held;
        for (int i = 0; i < 20000; ++i) {
            if (held.size() < 12 && (i % 3 != 2)) {
                Msg* m = t_cache.acquire();
                if (m->owned.exchange(true)) ++clashes;
                m->payload = id;
                held.push_back(m);
            } else if (!held.empty()) {
                Msg* m = held.back();
                held.pop_back();
                m->owned.store(false);
                t_cache.release(m);
            }
        }
        for (Msg* m : held) { m->owned.store(false); t_cache.release(m); }
    };
    std::vector<std::thread> ts;
    for (int t = 0; t < 4; ++t) ts.emplace_back(worker, t);
    for (auto& t : ts) t.join();
    std::printf("clashes: %d\n", clashes.load());
    std::printf("all objects back in the shared pool: %s\n", g_pool.idle() == g_pool.total() ? "yes" : "no");
    std::printf("objects created: %s\n", g_pool.total() <= 4 * 24 ? "bounded" : "too many");
}
clashes: 0
all objects back in the shared pool: yes
objects created: bounded

Complexity and pitfalls. Most operations touch only the thread's own vector; the mutex is taken once per batch. Each thread holds at most 12 objects plus a cache of at most 8 plus a batch of 4, so the total stays bounded. Pitfalls: a thread that exits without flushing its cache strands objects (the thread_local destructor prevents it); static destruction order (the global pool must outlive every thread-local cache, which holds because thread-local objects of a thread are destroyed before static objects, and worker threads are joined before main returns); and objects migrating between threads, which is fine here because the shared pool is the only meeting point and it is locked. The main thread's own cache is never used, so it is empty at exit.

Estimated rating: ~2000 · two-level design with batching, thread-exit flushing and destruction-order reasoning, verified under TSan

Cheat sheet

Theory
  • Object pool = reuse constructed objects (saves construction); memory pool = reuse raw blocks (saves allocation). Say which one you mean.
  • Reset must make the object indistinguishable from a new one while keeping the expensive parts (capacity, sockets). Reset on release by default; validate on acquire.
  • Hand out std::unique_ptr<T, Return>: exactly-once release on every path, move-only so no double release. Pool-pointer deleters make handles 16 bytes; the pool must outlive handles (or use weak_ptr state).
  • shared_ptr handles allocate a control block per acquire unless you supply an allocator.
  • Exhaustion: grow (buffers), block with timeout (connections, beware nested acquires), fail and alarm (trading). Track a high-water mark.
  • Stale state, double release and use after release are silent: ASan cannot see them. Use in-pool flags, debug poisoning and generation-checked handles.
  • Slot map: handle = (index, generation), dense values, O(1) insert/erase/get; watch generation wrap-around and pointer invalidation.
  • Pools hurt when objects are cheap to build, when peak population is far above normal (memory held forever), and when reset logic drifts from the class. Measure: a pooled message was 1.6 to 2.1 times faster on an Apple M1, not 10 times.

Part E · Memory management · Chapter 17

Arena allocation

An arena hands out memory by moving a pointer forward and frees everything at once by moving it back. It is the fastest allocator there is, and it works whenever a group of objects shares one lifetime: a request, a frame, a parse. This chapter covers the bump pointer and its alignment arithmetic, the lifetime discipline arenas demand, destructors, std::pmr::monotonic_buffer_resource, growing and scratch arenas, and the bugs that come with them.

TheoryDrillsCoding· about 4 hours· prerequisites: Ch 14, Ch 15

Bump-pointer arenas

Theory

Mental model. An arena (also called a region, a linear allocator or a bump allocator) owns one large buffer and an offset into it. Everything before the offset is in use, everything after is free. To allocate n bytes, round the offset up to the required alignment, return that address, and add n. There is no per-object bookkeeping and no way to free one object: the whole arena is freed, or reset, at once.

one buffer, capacity 256 bytes Token0..23 pad Node (align 16)32..63 name64..76 free offset = 77 allocate(n, a): offset = round_up(offset, a) + n reset(): offset = 0. Every object is gone at once; no per-object free exists. 8 bytes of padding so the Node starts at a multiple of 16
Allocation is pointer arithmetic. The only state is the buffer and an offset. Padding appears where a request needs stricter alignment than the current offset provides.
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <new>
#include <type_traits>

class Arena {
    std::byte* base_;
    std::size_t cap_, offset_ = 0;
public:
    Arena(std::byte* buf, std::size_t cap) : base_(buf), cap_(cap) {}

    void* allocate(std::size_t n, std::size_t align) {
        auto addr = reinterpret_cast<std::uintptr_t>(base_) + offset_;
        auto aligned = (addr + align - 1) & ~(std::uintptr_t{align} - 1);   // align must be a power of two
        std::size_t start = aligned - reinterpret_cast<std::uintptr_t>(base_);
        if (start + n > cap_) throw std::bad_alloc();
        offset_ = start + n;
        return base_ + start;
    }
    template <class T, class... A> T* create(A&&... a) {
        static_assert(std::is_trivially_destructible_v<T>, "the arena never runs destructors");
        return ::new (allocate(sizeof(T), alignof(T))) T{static_cast<A&&>(a)...};
    }
    void reset() noexcept { offset_ = 0; }
    std::size_t used() const noexcept { return offset_; }
};

struct Token { int kind; int line; char text[16]; };          // 24 bytes, align 4
struct alignas(16) Node { Node* lhs; Node* rhs; int op; };    // 32 bytes, align 16

int main() {
    alignas(16) std::byte buffer[256];
    Arena arena(buffer, sizeof buffer);
    arena.create<Token>(1, 10);
    std::printf("after Token: %zu\n", arena.used());
    Node* n = arena.create<Node>(nullptr, nullptr, '+');
    std::printf("Node at offset %td, after Node: %zu\n", reinterpret_cast<std::byte*>(n) - buffer, arena.used());
    arena.allocate(13, 1);
    std::printf("after 13 chars: %zu\n", arena.used());
    arena.reset();
    std::printf("after reset: %zu\n", arena.used());
}
after Token: 24
Node at offset 32, after Node: 64
after 13 chars: 77
after reset: 0

Why this is fast: an allocation is an add, a mask, a compare and a store, all in registers, and the next allocation lands right after the previous one, so consecutive objects share cache lines and pages. There is no header before each object, so a 13-byte string costs 13 bytes, not 32.

Alignment rounding arithmetic

Theory

For a power-of-two alignment a, the next multiple of a at or above x is

round_up(x, a) = (x + a - 1) & ~(a - 1)

Adding a - 1 pushes x past the next boundary unless it is already on one, and the mask clears the low bits. For x = 24, a = 16: 24 + 15 = 39 = 0b100111, masked with ~0b1111 gives 32. For x = 32 it gives 32 again. The padding is round_up(x, a) - x, which is at most a - 1.

  • Align the address, not the offset. Rounding the offset only works if the buffer's base is itself aligned to the strictest alignment you serve. A char buf[1024] on the stack, or a sub-range of another buffer, may start at an odd address. The arena above adds the base before rounding, which is always correct. The reading section has the bug where it does not.
  • Check the alignment is a power of two (std::has_single_bit(a)). The mask trick gives garbage for 12 or 24. All alignments the language produces (alignof) are powers of two.
  • Check for overflow before adding: start + n > cap can wrap for a huge n. A robust check is n > cap - start after verifying start <= cap.
  • std::align(alignment, size, ptr, space) in <memory> does the same job: it adjusts ptr and space and returns null if the request does not fit.

Know this cold

Be ready to derive (x + a - 1) & ~(a - 1) on a whiteboard, explain why it needs a power of two, and compute a few cases. Interviewers at trading firms often ask for it directly, then ask what goes wrong if the arena's base buffer is not aligned.

Bulk free, reset, and why individual free is a no-op

Theory

An arena has no record of where one object ends and the next begins, and no free list. That is exactly why it is fast, and it means freeing a single object cannot be done in general: the hole in the middle could not be found again. So an arena's deallocate does nothing, and memory comes back only in bulk:

  • Reset sets the offset back to zero. The buffer is kept and reused, so a per-frame or per-request arena reaches a steady state with no allocation at all.
  • Destroying the arena returns its buffers to wherever they came from.
  • Rewinding to a mark frees everything allocated after the mark (see scratch arenas).

One optimisation is common: if the object being freed is the last one allocated, the arena can move the offset back by its size (an "undo last" free). It helps temporaries that are freed in reverse order of allocation, such as a buffer allocated and released inside one function. It does not help std::vector growth, because the vector allocates its new buffer before it frees the old one, so the old one is never on top. std::pmr::monotonic_buffer_resource does not do this; its deallocate is specified to have no effect.

The cost model follows: allocation is O(1), free is free, reset is O(1) (or O(number of chained blocks) if extra blocks are released). Memory use is the sum of everything allocated since the last reset, live or dead. An arena is the wrong tool when objects die at different times and the total over the arena's life is much bigger than what is live at any moment.

The lifetime discipline

Theory

An arena is safe when one rule holds: every object in the arena dies at or before the reset, and nothing outside the arena keeps a pointer into it past that point. Arena-friendly designs make that rule structural by tying each arena to a unit of work.

  • Per-request arenas. A server creates (or takes from a pool) an arena when a request arrives, puts the parsed request, temporary strings and the response in it, and resets it when the response is sent. Apache's APR pools and nginx's ngx_pool_t work like this. Nothing in the request may be stored in a cache that outlives it, unless it is copied out.
  • Per-frame arenas. A game allocates the frame's temporary data (visible object lists, draw commands, particle updates) from a frame arena and resets it at the start of the next frame. With double buffering, two arenas alternate, so data from the previous frame (for example for the renderer running one frame behind) stays valid for one extra frame.
  • Per-parse arenas. A parser allocates tokens and the AST in one arena; the AST lives until compilation finishes, then the whole tree is dropped in one step instead of walking it to delete each node.
time → frame 1 frame 2 frame 3 frame 4 A B game writes renderer reads reset, game writes renderer reads game writes renderer reads reset, game writes A pointer into arena A taken in frame 1 is valid through frame 2 and dangles from frame 3.
Two frame arenas alternate. Each one is reset only after the consumer one frame behind has finished with it, so the lifetime rule is "valid for two frames" and is enforced by the schedule, not by bookkeeping.

Compilers and parsers as typical users

Compilers are the textbook arena users. An abstract syntax tree has millions of small nodes (expressions, declarations, types) that are created during parsing, linked by raw pointers in every direction, and all freed when the translation unit is done. Per-node delete would need a tree walk and an ownership structure for a graph that is not a tree. Clang allocates its AST from ASTContext, which uses LLVM's BumpPtrAllocator, and many AST node destructors are never run. Protocol Buffers offers google::protobuf::Arena for messages parsed per request. JSON parsers such as simdjson's DOM and RapidJSON's MemoryPoolAllocator use arenas for the same reason: the parse result is one unit with one lifetime.

Destructors in an arena

Theory

Resetting an arena reuses its bytes without running any destructor. Two policies keep that sound.

Policy 1: trivially destructible types only

Restrict the arena to types where skipping the destructor changes nothing: static_assert(std::is_trivially_destructible_v<T>) in create, as in the arena above. AST nodes and tokens are designed this way on purpose: they hold raw pointers into the same arena and string views of the source, never owning containers. The standard allows reusing storage without calling a destructor ([basic.life]/5), and for a trivially destructible type there is nothing to call anyway.

Policy 2: a destructor list

To allow types like std::string, the arena keeps a singly linked list of "finalisers": for every non-trivially-destructible object it allocates a small record (a pointer to the object, a function pointer that destroys it, and the link) from the arena itself. Reset walks the list and calls each destructor, newest first, then rewinds. Trivially destructible types skip the record, so they still cost nothing.

struct Finalizer { void (*destroy)(void*); void* object; Finalizer* next; };

template <class T, class... A>
T* create(A&&... args) {
    void* mem = allocate(sizeof(T), alignof(T));
    T* obj = ::new (mem) T(std::forward<A>(args)...);
    if constexpr (!std::is_trivially_destructible_v<T>) {
        auto* f = ::new (allocate(sizeof(Finalizer), alignof(Finalizer)))
            Finalizer{[](void* p) { static_cast<T*>(p)->~T(); }, obj, finalizers_};
        finalizers_ = f;                               // reset() runs them newest first
    }
    return obj;
}

Order matters in that code: the object is constructed before its finaliser record exists, so if the constructor throws, no record points at a half-built object (the arena bytes are simply wasted until reset). If allocating the record throws, the object is already constructed and must be destroyed before rethrowing; the coding section avoids the problem by reserving the record first. Protobuf's arena and LLVM's SpecificBumpPtrAllocator<T> (which destroys all objects of one type on reset) are real versions of this idea.

Interview trap

An arena-allocated std::vector<int> or std::string with the default allocator puts only its 24 or 32-byte header in the arena. Its elements are on the global heap, and if the destructor never runs, that heap memory leaks on every reset. Either run destructors (a finaliser list), or give the container an allocator that also uses the arena (a std::pmr container on the arena's memory resource), so its elements vanish with the arena and skipping the destructor is harmless.

std::pmr::monotonic_buffer_resource

Theory

The standard library's arena is std::pmr::monotonic_buffer_resource (C++17, <memory_resource>). It is a memory_resource (Ch 15), so any pmr container can use it.

  • Construction. monotonic_buffer_resource(void* buffer, size_t size, memory_resource* upstream) starts by bumping through buffer (often a stack array). Other constructors take only an initial size hint, or nothing. The upstream defaults to get_default_resource() at construction.
  • Growth. When the current buffer is full, it asks the upstream for a new one, each larger than the last by an implementation-defined geometric factor (libstdc++ multiplies by 1.5; libc++ doubles). The initial buffer is never freed by it, because it does not own it.
  • deallocate has no effect. Memory only grows until release() or the destructor, which return every upstream buffer. After release(), the resource starts over from the initial buffer.
  • Not thread safe, not copyable, and it must outlive every container using it.
  • Pair it with null_memory_resource() as upstream to guarantee that a hot path never touches the heap: if the stack buffer is too small, you get std::bad_alloc in testing instead of a silent heap allocation in production.
  • Stack it under a pool. unsynchronized_pool_resource pool(&monotonic) reuses freed blocks within the request while the monotonic resource supplies chunks and frees everything at the end.
#include <array>
#include <cstddef>
#include <cstdio>
#include <functional>
#include <memory_resource>
#include <new>
#include <string>
#include <vector>

class CountingResource : public std::pmr::memory_resource {
    std::pmr::memory_resource* up_ = std::pmr::new_delete_resource();
public:
    std::size_t calls = 0, live = 0;
private:
    void* do_allocate(std::size_t n, std::size_t a) override { ++calls; live += n; return up_->allocate(n, a); }
    void do_deallocate(void* p, std::size_t n, std::size_t a) override { live -= n; up_->deallocate(p, n, a); }
    bool do_is_equal(const memory_resource& o) const noexcept override { return this == &o; }
};

int main() {
    alignas(std::max_align_t) std::array<std::byte, 2048> stack_buf;
    auto inside = [&](const void* p) {
        std::less<const void*> lt;
        return !lt(p, stack_buf.data()) && lt(p, stack_buf.data() + stack_buf.size());
    };

    {   // 1. a stack buffer with no heap fallback
        std::pmr::monotonic_buffer_resource arena(stack_buf.data(), stack_buf.size(), std::pmr::null_memory_resource());
        std::pmr::vector<std::pmr::string> words(&arena);
        words.reserve(4);
        for (const char* w : {"bid", "ask", "a symbol name long enough to need its own buffer", "fill"}) words.emplace_back(w);
        bool all_inside = true;
        for (auto& w : words) all_inside = all_inside && inside(w.data());
        std::printf("all strings in the stack buffer: %s\n", all_inside ? "yes" : "no");
        try {
            arena.allocate(4096);
        } catch (const std::bad_alloc&) {
            std::puts("over the stack buffer: bad_alloc");
        }
    }
    {   // 2. a small initial buffer with a counting upstream
        CountingResource upstream;
        std::pmr::monotonic_buffer_resource arena(stack_buf.data(), 256, &upstream);
        for (int i = 0; i < 100; ++i) {
            void* p = arena.allocate(64);
            arena.deallocate(p, 64);                 // no effect
        }
        std::printf("went upstream: %s, bytes held upstream >= 6400: %s\n",
                    upstream.calls > 0 ? "yes" : "no", upstream.live >= 6400 - 256 ? "yes" : "no");
        arena.release();
        std::printf("after release: %zu bytes held upstream\n", upstream.live);
    }
}
all strings in the stack buffer: yes
over the stack buffer: bad_alloc
went upstream: yes, bytes held upstream >= 6400: yes
after release: 0 bytes held upstream

The second block shows the defining behaviour: 100 allocate/deallocate pairs of 64 bytes consumed about 6.4 KB, because nothing freed was reused. A pool resource would have needed one block.

Growing arenas with chained blocks

Theory

A fixed buffer is ideal when the maximum size is known. Otherwise the arena grows by allocating a new block when the current one is full and linking it to the previous ones, so destruction can free them all. Each new block is usually twice the size of the last, so the number of blocks stays logarithmic in the total size.

Arena head, ptr, end next_size 4 KiB full 8 KiB full 16 KiB: usedfree ptr end head = newest block (links point to older blocks) dedicated block: one 100 KiB request Requests bigger than half a block get their own block, so they do not waste the current one. reset(): free every block except the largest (or the first), set ptr to its start. Pointers into freed blocks now dangle.
Chained blocks. Each block is twice the previous one, the newest is current, and oversized requests get a dedicated block. Reset keeps one block so the next cycle starts without an allocation.

Design decisions in a growing arena:

  • Where the block header lives. Putting {next, size} at the start of each block costs 16 bytes per block and no separate allocation. The usable region starts after the header.
  • Oversized requests. If a request is larger than the next block size, allocate a block of exactly that size (plus header). Some arenas put such blocks behind the current block in the chain so the current block keeps serving small requests.
  • What reset keeps. Freeing everything returns memory but makes the next cycle allocate again. Keeping the largest block means the steady state needs one block and no allocation. LLVM's BumpPtrAllocator::Reset keeps the first slab and frees the rest.
  • Wasted tail. When a request does not fit, the rest of the current block is abandoned. With doubling blocks this waste is bounded by the request size, and it is part of the trade.

Scratch arenas and save/restore marks

Theory

A mark is a saved position in the arena: the current block and offset. Rewinding to the mark frees everything allocated since, in O(1), without touching what was allocated before. This turns an arena into a stack-like allocator for temporary work:

struct Mark { std::size_t offset; };

class ScratchScope {                 // RAII: rewinds on every exit path
    Arena& arena_;
    Arena::Mark mark_;
public:
    explicit ScratchScope(Arena& a) : arena_(a), mark_(a.mark()) {}
    ~ScratchScope() { arena_.rewind(mark_); }
    ScratchScope(const ScratchScope&) = delete;
    ScratchScope& operator=(const ScratchScope&) = delete;
};

std::string_view longest_word(Arena& scratch, std::string_view text) {
    ScratchScope scope(scratch);               // temporaries below vanish at return
    auto* words = split_into(scratch, text);   // arena-allocated array of views
    return pick_longest(words);                // fine: the view points into text, not the arena
}

Rules that come with marks:

  • Marks nest like a stack. Rewinding to an outer mark invalidates inner marks. Rewinding to an inner mark after the outer one was restored is a bug (it moves the offset forward over freed memory).
  • Nothing allocated inside the scope may escape it. Returning a pointer to scratch memory from inside a ScratchScope is a dangling pointer the moment the scope ends. Results must be copied to a longer-lived arena or into the caller's memory.
  • Two scratch arenas solve the "result and temporaries in the same arena" problem: a function takes a persistent arena for its result and uses a separate scratch arena for its temporaries. Game engines and some compilers keep a pair of thread-local scratch arenas and pass the "other" one to callees so that callers' and callees' scratch data never collide.
  • In a growing arena, the mark must record the block as well as the offset, and rewinding frees blocks allocated after the mark (or keeps them for reuse).

Arena vs pool vs malloc

Theory
Key factsArena (bump)Pool (free list)malloc / new
Sizes servedAny size, any alignmentOne size (or a few classes)Any
AllocateBump a pointer: a few instructionsPop a list: a few instructionsFast path 10 to 30 ns, slow paths much longer
Free one objectNot possible (no-op)O(1) pushO(1) typical
Free everythingO(1) resetWalk or destroy the poolOne free per object
Per-object overheadAlignment padding onlyRounding to the block sizeHeader or size-class rounding
Memory reuseOnly after reset or rewindImmediately, per blockImmediately
DestructorsNot run unless a finaliser list existsRun by the user before freeingRun by delete
Fits whenObjects share one lifetime (request, frame, parse)Many same-size objects with individual lifetimesGeneral use, unknown lifetimes
Fails whenLifetimes differ, so dead objects pile upSizes vary widelyHot paths needing predictable latency
Standard versionmonotonic_buffer_resource(un)synchronized_pool_resourcenew_delete_resource

Measured

The benchmark builds a chain of 100,000 32-byte nodes (an AST-like workload), walks it, and drops it, with malloc per node and free per node, with a pre-sized bump arena and one reset, and with a default-constructed monotonic_buffer_resource and one release().

#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <memory_resource>
#include <new>
#include <vector>

struct Node { Node* left; Node* right; long key; long pad; };   // 32 bytes, like an AST node

class Arena {
    std::byte* buf_;
    std::size_t cap_, used_ = 0;
public:
    explicit Arena(std::size_t cap) : buf_(static_cast<std::byte*>(::operator new(cap))), cap_(cap) {}
    ~Arena() { ::operator delete(buf_); }
    void* allocate(std::size_t n, std::size_t a) {
        std::size_t p = (used_ + a - 1) & ~(a - 1);
        if (p + n > cap_) throw std::bad_alloc();
        used_ = p + n;
        return buf_ + p;
    }
    void reset() { used_ = 0; }
};

constexpr int kNodes = 100000, kReps = 7;

// Build a chain of nodes (each points to the previous), sum the keys, then drop everything.
template <class Alloc, class FreeAll>
static double build(Alloc alloc, FreeAll free_all, long& sink) {
    auto t0 = std::chrono::steady_clock::now();
    Node* prev = nullptr;
    for (int i = 0; i < kNodes; ++i) prev = ::new (alloc()) Node{prev, nullptr, i, 0};
    for (Node* n = prev; n; n = n->left) sink += n->key;
    free_all(prev);
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration<double, std::nano>(t1 - t0).count() / kNodes;
}

template <class F>
static double median_of(F f) {
    std::vector<double> v;
    for (int r = 0; r < kReps; ++r) v.push_back(f());
    std::sort(v.begin(), v.end());
    std::printf("    min %.2f  max %.2f ns/node\n", v.front(), v.back());
    return v[v.size() / 2];
}

int main() {
    long sink = 0;
    std::puts("malloc per node, free each:");
    double m = median_of([&] {
        return build([] { return std::malloc(sizeof(Node)); },
                     [](Node* n) { while (n) { Node* l = n->left; std::free(n); n = l; } }, sink);
    });
    std::puts("bump arena, one reset:");
    Arena arena(kNodes * sizeof(Node));
    double a = median_of([&] {
        return build([&] { return arena.allocate(sizeof(Node), alignof(Node)); }, [&](Node*) { arena.reset(); }, sink);
    });
    std::puts("pmr::monotonic_buffer_resource, one release:");
    std::pmr::monotonic_buffer_resource mono;
    double p = median_of([&] {
        return build([&] { return mono.allocate(sizeof(Node), alignof(Node)); }, [&](Node*) { mono.release(); }, sink);
    });
    std::printf("median ns/node: malloc %.2f, arena %.2f, monotonic %.2f\n", m, a, p);
    std::printf("speed-up vs malloc: arena %.1fx, monotonic %.1fx (sink %ld)\n", m / a, m / p, sink & 1);
}

Representative results on an Apple M1 (macOS, -O2, median of 7 repetitions, two runs each). The program is tagged ub because its output is timing.

AllocatorGCC 16 + libstdc++Clang 22 + libc++
malloc + free per node32.2 ns/node (1.0×)32.4 ns/node (1.0×)
Pre-sized bump arena, one reset2.9 ns/node (about 11× faster)3.4 ns/node (about 9.5× faster)
monotonic_buffer_resource, one release7.1 ns/node (about 4.6× faster)8.1 ns/node (4.0 to 5.2× faster)

The arena's advantage is bigger here than a pool's was in Ch 15 (about 4×), because the arena also deletes the 100,000 free calls and its nodes are contiguous, so the walk streams through memory. The monotonic resource pays for a virtual call per allocation and for re-acquiring its growing buffers from the heap after every release(); giving it an initial buffer of the right size would close most of the gap. Individual runs varied by up to 2× (the max column), so trust medians, not single runs.

Pitfalls

Theory

Dangling pointers after reset

After a reset, every pointer into the arena dangles, but the memory is usually still mapped and owned by the arena, so reads return plausible stale data or a newer object's data. ASan does not see it (the buffer is one live allocation), unless the arena poisons its memory on reset with ASAN_POISON_MEMORY_REGION and unpoisons on allocation. Arenas that free extra blocks on reset turn some of these bugs into detectable heap-use-after-free; the reading section shows both cases.

Unbounded growth

An arena that is never reset is a memory leak with good performance. Typical causes: a per-connection arena on a WebSocket or database connection that lives for hours; a loop that reallocates a growing pmr::vector in a monotonic resource (each regrowth abandons the old buffer, so the arena holds about twice the final size); a "temporary" scratch arena used by a long-running background task. Cap arena size in debug builds and log the high-water mark.

Mixing arena objects with long-lived ones

The lifetime rule breaks the moment a long-lived structure points into the arena: a global cache storing a std::string_view of an arena string, a long-lived object holding an arena-allocated child, a callback registered with a pointer to per-request data. The opposite direction also bites: an arena object that owns heap memory (a std::vector with the default allocator) leaks it at reset. Keep a clear boundary: arena objects point only into the same arena or to longer-lived data, and anything that must survive is copied out.

Alignment bugs

Rounding the offset instead of the address with an unaligned base, a non-power-of-two alignment fed to the mask formula, ignoring alignof(T) for over-aligned types (alignas(64) cache-line-aligned structs, SIMD types), and assuming the arena's buffer from operator new is more than __STDCPP_DEFAULT_NEW_ALIGNMENT__-aligned. x86 tolerates most misaligned scalar accesses, so these bugs often appear only on ARM or with SIMD and under UBSan's alignment check.

Threads

A bump pointer is a shared mutable offset, so a shared arena needs a lock or an atomic fetch_add on the offset (which works for a fixed buffer but not for growth). The usual design is one arena per thread or per task. Reading arena data from many threads after it is built is fine, provided the hand-over is synchronised.

Concept and tricky questions

Drills

ConceptWhy is an arena faster than malloc, and what does it give up to get there?

Answer

Answer. Allocation is a round-up, an add and a bounds check on one offset, with no size classes, headers, free lists, locks or system calls, and freeing costs nothing because all objects go together at reset. It gives up individual frees: memory is only reclaimed in bulk, so it suits objects that share one lifetime.

Why. malloc must support any free order, so it keeps metadata to find, merge and reuse holes. An arena has no holes by design. Consecutive allocations are also adjacent, which improves cache and TLB locality. The measured gap in this chapter was about 10× for an AST-like build-and-drop workload on an Apple M1. Saying only "it avoids system calls" misses the point: malloc's fast path avoids them too.

TrickyAn arena's deallocate(p, n) is a no-op. Is there any case where an arena can usefully reclaim a single allocation?

Answer

Answer. Yes: if p is the most recent allocation, the arena can move the offset back to p (an "undo last" free). A temporary freed right after use, or a group of temporaries freed in reverse order, then costs nothing. Any other single free cannot be honoured, and that includes the old buffer of a growing std::vector: the vector allocates the new buffer before freeing the old one, so the old one is never the most recent.

Why. The arena knows only the current offset, so it can recognise "the block that ends exactly at the offset" and nothing else. LLVM's bump allocator and many game-engine linear allocators do this. std::pmr::monotonic_buffer_resource deliberately does not: the standard says its do_deallocate has no effect, so code can rely on the simpler behaviour. A related trick is a mark and rewind, which frees a whole stack-like group of recent allocations.

TrickyA monotonic_buffer_resource is built over a 4 KiB stack buffer with the default upstream. Request 1 uses 100 KiB, then the server calls release(); request 2 uses 3 KiB. Where does request 2's memory come from, and does it start with a large upstream buffer because request 1 needed one?

Answer

Answer. Request 2 is served entirely from the 4 KiB stack buffer, with no upstream call. release() frees every upstream buffer and returns the resource to its state at construction: the current buffer is the user's buffer again, and the size of the next upstream buffer is back to its initial value. So the next large request grows from small buffers again; nothing is remembered from request 1.

Why. The standard specifies that release() resets current_buffer and next_buffer_size to their initial values ([mem.res.monotonic.buffer.mem]), and the growth factor is implementation-defined. With an initial size of 1000 bytes and a counting upstream, libstdc++ here asked for 1024, 1536, 2304, 3392, 5120 bytes (factor 1.5) and libc++ for 2032, 4032, 8032 (about 2), and both started from the first size again after release(). Wrong answers: "release() frees the stack buffer" (the resource never owned it), and "it keeps the biggest block for next time" (that is what a hand-written arena that keeps blocks on reset does; the standard resource pays to re-acquire them instead). If every request is large, construct the resource with a bigger initial buffer or size hint.

ConceptDescribe the lifetime discipline that makes a per-frame arena safe in a game engine, and how double buffering changes it.

Answer

Answer. Everything allocated during frame N is used only during frame N, and nothing outside the arena keeps a pointer to it; the arena is reset at the start of frame N + 1. With two arenas alternating, data from frame N stays valid during frame N + 1 as well (for a renderer or a job system running one frame behind), and its arena is reset at the start of frame N + 2.

Why. The schedule enforces the lifetime, so no reference counting or per-object frees are needed. The rule is broken by caches and long-lived systems that remember pointers into frame data; anything that must persist (a spawned entity, a loaded asset) has to be allocated from a longer-lived allocator or copied out before the reset.

TrickyYou place a std::string in an arena with placement new and never call its destructor. Is that undefined behaviour? What leaks, and what changes if it is a std::pmr::string using the arena?

Answer

Answer. Not calling the destructor is not undefined by itself: [basic.life]/5 lets a program end an object's lifetime by reusing or releasing its storage. Up to C++20 the same paragraph also said that a program which depends on the destructor's side effects has undefined behaviour; CWG2523 removed that sentence as a defect report against C++23, so now the destructor simply does not run. But a std::string longer than its small-string buffer owns a heap block, and that block leaks on every reset. A std::pmr::string whose resource is the arena allocates its characters from the arena too, so skipping its destructor leaks nothing: the characters vanish with the arena.

Why. The skipped side effect here is operator delete on the character buffer, which is exactly what the program needs to avoid leaking. Common wrong answers: "it is always UB" (it is not), and "short strings are fine, so all strings are fine" (it depends on the length and the library's SSO capacity, 15 characters in libstdc++ and 22 in libc++ on 64-bit). The robust options are trivially destructible types, a finaliser list, or containers that allocate from the same arena.

ConceptWhat do deallocate, release() and the destructor of std::pmr::monotonic_buffer_resource each do?

Answer

Answer. deallocate does nothing. release() returns every buffer obtained from the upstream resource and resets the resource to its initial state (bumping through the user-supplied initial buffer again, if there was one). The destructor calls release().

Why. It is a pure arena, specified as such so that its behaviour does not depend on the implementation. The initial buffer is not owned, so it is never freed. Pmr containers still call deallocate (when they grow or are destroyed), which is why their destructors are cheap but not free: they still walk their elements. If the containers live in the arena too, you can skip their destruction entirely and just release(), provided nothing else they own lives elsewhere.

TrickyA std::pmr::vector<int> on a monotonic_buffer_resource gets 1,000,000 push_back calls without reserve. Roughly how much memory does the arena hold at the end, and why?

Answer

Answer. About 8 MiB, twice what the data needs. With the doubling growth of libstdc++ and libc++, the final capacity is 220 ints (4 MiB), and the abandoned buffers of 1, 2, 4, ..., 219 ints add up to almost another 4 MiB. With reserve(1'000'000) the arena holds 4 MB.

Why. Each time the vector grows it allocates a bigger buffer, moves the elements and deallocates the old one, which the monotonic resource ignores. The geometric series of old buffers sums to about the size of the last one. In a pool or malloc the old buffers would be reused; in an arena they are dead weight until release(). The reading section measures it: reserve exactly when the size is known, or build into a scratch structure and copy the final result into the arena once.

TrickyWhy would anyone construct a monotonic_buffer_resource over a stack buffer with std::pmr::null_memory_resource() as upstream?

Answer

Answer. To prove that a code path never touches the heap. All allocations come from the stack buffer, and if it ever runs out, the null upstream throws std::bad_alloc instead of silently calling operator new. In a latency-critical path, that turns a hidden heap allocation into a test failure.

Why. With the default upstream, overflowing the buffer works but is invisible, and the heap allocation it causes is exactly what a trading or audio thread must avoid. The same trick works for the default resource (set_default_resource(null_memory_resource()) in tests) to find containers that were not given a resource. The buffer must be big enough for the worst case, and the code must be tested with worst-case input.

ConceptWhen would you choose an arena over a pool, and when a pool over an arena?

Answer

Answer. Choose an arena when many objects of mixed sizes share one lifetime (a request, a frame, a parse result) and are dropped together. Choose a pool when objects of one size come and go individually over a long period (orders, connections, nodes in a long-lived structure), because a pool reuses each freed block immediately and an arena would grow without bound.

Why. An arena's memory is the sum of all allocations since reset; a pool's memory is the peak number of live blocks. They also compose: a pmr::unsynchronized_pool_resource on top of a monotonic_buffer_resource reuses blocks within a request (pool) and frees everything at the end (arena).

TrickyTwo threads allocate from one monotonic_buffer_resource. What goes wrong, and what are the fixes?

Answer

Answer. The resource is not thread safe: both threads read and update the same current pointer and remaining-space fields without synchronisation, which is a data race (undefined behaviour) and in practice hands out overlapping memory. Fixes: one arena per thread (the usual answer), a mutex around the shared resource, or, for a fixed buffer only, an atomic fetch_add on the offset.

Why. fetch_add works for a fixed-size buffer (each thread gets a disjoint range, alignment can be handled by over-allocating and rounding), but growing a chained arena needs a lock because installing a new block is a multi-step update. Reading arena-built data from many threads afterwards is fine once its construction happens-before the reads (for example, the builder joins or publishes with a release store).

ConceptWhat is a scratch arena with save/restore marks, and why do some codebases keep two scratch arenas per thread?

Answer

Answer. A scratch arena is a per-thread arena for temporaries. A function records a mark (the current offset), allocates freely, and rewinds to the mark when it returns, usually through an RAII guard, freeing all its temporaries in O(1). Two arenas are kept so that a function can put its result in one and its temporaries in the other: if both were in the same arena, rewinding the temporaries would also free the result allocated after the mark.

Why. Marks behave like a stack, so they need LIFO discipline. When a function calls another function that also uses scratch memory, passing the "other" arena to the callee guarantees the callee's rewind cannot destroy the caller's live temporaries. This gives stack-like allocation for variable-sized data without alloca (Ch 18).

TrickyA function opens a ScratchScope, builds a std::string_view into scratch memory, and returns the view. Tests pass. Why is it still a bug, and why do the tests pass?

Answer

Answer. The scope's destructor rewinds the arena at return, so the view points to freed scratch memory. Tests pass because rewinding does not erase anything: the bytes stay there until the next scratch allocation overwrites them, and a simple test reads the result before anything else allocates.

Why. It is a use-after-free that no tool sees, since the arena's buffer remains a live allocation. It fails later, when the caller allocates scratch memory before reading the view, and then the "string" contains someone else's data. Fixes: allocate the result in a longer-lived arena passed in by the caller, return an owning std::string, or make the arena poison rewound memory in debug builds (fill with a pattern or use ASan manual poisoning).

ConceptWhy do compilers allocate their AST nodes from arenas?

Answer

Answer. An AST has a huge number of small nodes created during parsing, connected by raw pointers in many directions (parents, types, declarations referenced from uses), and all of them are needed until the translation unit is done. An arena makes each allocation a bump, keeps related nodes adjacent in memory, and frees the whole graph in one step without walking it.

Why. Per-node ownership would need a tree of unique_ptrs (and the AST is a graph, not a tree) or reference counting, and freeing would walk millions of nodes. Clang's ASTContext uses LLVM's BumpPtrAllocator and designs most node types to be trivially destructible or to not need destruction. Many compilers even skip freeing the arena at exit, letting the OS reclaim the memory.

TrickyA per-request arena stores parsed headers as std::string_views into arena memory. A developer adds a global cache of "recently seen user agents" that stores those views. What happens, and how do you fix the design?

Answer

Answer. After the request ends and the arena is reset, the cached views dangle. The next request overwrites that memory, so the cache returns other requests' bytes, or garbage. The fix is to copy the data into storage owned by the cache (a std::string, or a separate long-lived arena for interned strings) at the moment it crosses from request lifetime to process lifetime.

Why. Mixing arena objects with long-lived ones breaks the arena's only safety rule. It is easy to miss because string_view looks like a value. Code review rule of thumb: any pointer, reference or view stored in something that outlives the arena is a bug unless it was copied.

TrickyA server resets its request arena after each request, but the process's RSS keeps the peak from a single huge request forever. Why, and what can you do?

Answer

Answer. Reset only moves the offset; the arena keeps its blocks (often deliberately, to avoid reallocating next time), so one 500 MB request leaves 500 MB of blocks attached to that arena. Options: on reset, free every block beyond a size limit and keep only a modest first block; cap the arena and reject oversized requests; or madvise(MADV_DONTNEED) the pages of a kept block so the kernel drops them while the address range stays reserved.

Why. "Reset is O(1)" and "reset returns memory" are different properties. Keeping blocks makes the steady state allocation-free, which is the point, but a high-water mark set by an outlier then becomes permanent. monotonic_buffer_resource::release() does free upstream buffers, so it does not have this problem, at the price of re-acquiring them on the next request.

Predict the output and find the bug

Drills

OutputEach allocation is placed at the next offset that satisfies its alignment. What does this print?

#include <cstddef>
#include <cstdint>
#include <cstdio>

class Arena {
    std::byte* base_;
    std::size_t cap_, off_ = 0;
public:
    Arena(std::byte* b, std::size_t c) : base_(b), cap_(c) {}
    std::size_t alloc(std::size_t n, std::size_t a) {   // returns the offset, for the drill
        std::size_t start = (off_ + a - 1) & ~(a - 1);
        if (start + n > cap_) return SIZE_MAX;
        off_ = start + n;
        return start;
    }
};

int main() {
    alignas(64) std::byte buf[128];
    Arena a(buf, sizeof buf);
    std::size_t c = a.alloc(1, 1);
    std::size_t d = a.alloc(8, 8);
    std::size_t s = a.alloc(2, 2);
    std::size_t v = a.alloc(32, 32);
    std::size_t big = a.alloc(64, 16);
    std::printf("char %zu, double %zu, short %zu, vec %zu, big %s\n", c, d, s, v, big == SIZE_MAX ? "does not fit" : "fits");
}
Answer
char 0, double 8, short 16, vec 32, big fits

Answer. The char takes offset 0. The double needs a multiple of 8, so it starts at 8 (7 bytes of padding) and ends at 16. The short fits at 16. The 32-aligned block skips to 32 and ends at 64. The last request would start at 64 and end at 128, which is exactly the capacity, so it fits.

Why. Each start is round_up(previous end, alignment). People often add the padding to the wrong side (after the object) or forget that a request ending exactly at the capacity fits (start + n > cap is the failure test, not >=). The base is declared alignas(64), so aligning offsets is the same as aligning addresses here; see the bug drill below for when it is not.

Find the bugA request handler keeps a pointer across a reset. What does it print, and does ASan (the harness runs it under ASan) report it?

#include <cstddef>
#include <cstdio>
#include <cstring>
#include <new>

class Arena {
    std::byte* buf_ = static_cast<std::byte*>(::operator new(4096));
    std::size_t off_ = 0;
public:
    ~Arena() { ::operator delete(buf_); }
    char* copy(const char* s) {
        std::size_t n = std::strlen(s) + 1;
        char* p = reinterpret_cast<char*>(buf_ + off_);
        std::memcpy(p, s, n);
        off_ += n;
        return p;
    }
    void reset() { off_ = 0; }
};

int main() {
    Arena arena;
    const char* user = arena.copy("alice");      // request 1
    arena.reset();                                // request 1 done
    arena.copy("mallory");                        // request 2
    std::printf("audit log: request 1 was made by %s\n", user);
}
Answer
audit log: request 1 was made by mallory

Answer. In practice it logs "mallory": after the reset, request 2's string was copied to offset 0, exactly where user points. ASan reports nothing (the harness confirms a clean run), because the 4 KiB buffer is one live heap allocation.

Why. Reset invalidates every pointer into the arena, but the bytes stay mapped and owned by the arena, so stale reads see whatever was written next. The program is tagged ub because reading through a pointer to an object whose storage has been reused is undefined. Defences: copy anything that outlives the request out of the arena; in debug builds, poison the arena on reset (fill it with a pattern, or call ASAN_POISON_MEMORY_REGION(buf_, 4096) and unpoison on allocation) so the stale read fails loudly.

Find the bugThis growing arena frees its extra blocks on reset. What does the sanitizer build report?

#include <cstddef>
#include <cstdio>
#include <new>
#include <vector>

class GrowingArena {
    std::vector<std::byte*> blocks_;
    std::size_t block_size_ = 64, off_ = 0;
public:
    GrowingArena() { blocks_.push_back(static_cast<std::byte*>(::operator new(block_size_))); }
    ~GrowingArena() { for (auto* b : blocks_) ::operator delete(b); }
    void* allocate(std::size_t n) {
        n = (n + 15) & ~std::size_t{15};
        if (off_ + n > block_size_) {                     // new block (requests assumed smaller than a block)
            blocks_.push_back(static_cast<std::byte*>(::operator new(block_size_)));
            off_ = 0;
        }
        void* p = blocks_.back() + off_;
        off_ += n;
        return p;
    }
    void reset() {                                        // keep only the first block
        for (std::size_t i = 1; i < blocks_.size(); ++i) ::operator delete(blocks_[i]);
        blocks_.resize(1);
        off_ = 0;
    }
};

int main() {
    GrowingArena arena;
    int* counters[6];
    for (auto& c : counters) { c = static_cast<int*>(arena.allocate(32)); *c = 1; }
    arena.reset();
    std::printf("last counter: %d\n", *counters[5]);      // lives in the third block
}
Answer

Answer. Six 32-byte allocations fill three 64-byte blocks. Reset frees blocks 2 and 3, so reading *counters[5] touches freed heap memory: ASan reports heap-use-after-free on that read.

Why. This is the same bug as the previous drill (a pointer kept across reset), but here part of the arena really went back to the heap, so ASan can see it. Pointers into the first, kept block would still be silent. This asymmetry is worth saying in an interview: whether a dangling arena pointer is detectable depends on an implementation detail (which blocks reset keeps), so arena bugs need arena-aware tooling (poisoning on reset), not luck.

Find the bugAn arena is built on a sub-range of a bigger buffer. The alignment rounding looks correct. What does the sanitizer build report?

#include <cstddef>
#include <cstdio>
#include <new>

class Arena {
    unsigned char* base_;
    std::size_t cap_, off_ = 0;
public:
    Arena(unsigned char* b, std::size_t c) : base_(b), cap_(c) {}
    void* allocate(std::size_t n, std::size_t a) {
        std::size_t start = (off_ + a - 1) & ~(a - 1);     // aligns the offset only
        if (start + n > cap_) throw std::bad_alloc();
        off_ = start + n;
        return base_ + start;
    }
};

struct alignas(16) Quote { double bid, ask; };

int main() {
    alignas(16) static unsigned char storage[1 + 1024];
    storage[0] = 0x7F;                                       // a one-byte message header
    Arena arena(storage + 1, 1024);                          // payload area starts after it
    auto* q = ::new (arena.allocate(sizeof(Quote), alignof(Quote))) Quote{101.25, 101.5};
    std::printf("%.2f\n", q->ask - q->bid);
}
Answer

Answer. The arena's base is storage + 1, an odd address. Offset 0 is "aligned", so the Quote is constructed at an address that is 1 mod 16. UBSan (part of the harness's sanitizer build) reports a misaligned address for type Quote, which requires 16-byte alignment.

Why. Alignment is a property of the absolute address. Fix: round reinterpret_cast<std::uintptr_t>(base_) + off_ and convert back to an offset, as the arena in the theory section does, or align the base once in the constructor. On ARM64 and x86 a misaligned scalar double load usually works (slowly), which hides the bug; aligned SIMD loads (movaps) and atomic operations fault.

OutputAn arena with a destructor list. In what order do the destructors run?

#include <cstddef>
#include <cstdio>
#include <new>
#include <string>
#include <type_traits>
#include <utility>

class Arena {
    struct Finalizer { void (*destroy)(void*); void* obj; Finalizer* next; };
    alignas(std::max_align_t) std::byte buf_[1024];
    std::size_t off_ = 0;
    Finalizer* fin_ = nullptr;
    void* raw(std::size_t n, std::size_t a) {
        std::size_t s = (off_ + a - 1) & ~(a - 1);
        off_ = s + n;
        return buf_ + s;
    }
public:
    template <class T, class... A> T* make(A&&... args) {
        T* obj = ::new (raw(sizeof(T), alignof(T))) T(std::forward<A>(args)...);
        if constexpr (!std::is_trivially_destructible_v<T>)
            fin_ = ::new (raw(sizeof(Finalizer), alignof(Finalizer)))
                Finalizer{[](void* p) { static_cast<T*>(p)->~T(); }, obj, fin_};
        return obj;
    }
    void reset() {
        for (Finalizer* f = fin_; f; f = f->next) f->destroy(f->obj);
        fin_ = nullptr;
        off_ = 0;
    }
    ~Arena() { reset(); }
};

struct Noisy {
    std::string name;
    explicit Noisy(std::string n) : name(std::move(n)) { std::printf("make %s\n", name.c_str()); }
    ~Noisy() { std::printf("drop %s\n", name.c_str()); }
};

int main() {
    Arena arena;
    arena.make<Noisy>("lexer");
    arena.make<int>(42);                     // trivially destructible: no finalizer
    arena.make<Noisy>("parser");
    arena.make<Noisy>("ast");
    std::puts("reset:");
    arena.reset();
    arena.make<Noisy>("next request");
    std::puts("end of main");
}
Answer
make lexer
make parser
make ast
reset:
drop ast
drop parser
drop lexer
make next request
end of main
drop next request

Answer. Each finaliser is pushed on the front of a list, so reset runs them newest first: ast, parser, lexer. The int has no finaliser. The object made after the reset is destroyed by the arena's destructor at the end of main.

Why. Reverse order of construction matches what C++ does for locals and members, which matters when later objects refer to earlier ones (the parser holds a reference to the lexer). A queue would give construction order, which is usually wrong for exactly that reason.

Find the bugA parse tree is placed in a trivial arena, and each node owns a std::vector of children. What does this print, and what is the real damage?

#include <cstddef>
#include <cstdio>
#include <new>
#include <vector>

static int live_vectors = 0;

struct Children {
    std::vector<void*> items;
    Children() { ++live_vectors; }
    ~Children() { --live_vectors; }
};

struct Node { int kind; Children kids; };

class Arena {
    alignas(std::max_align_t) std::byte buf_[4096];
    std::size_t off_ = 0;
public:
    template <class T> T* make() {
        std::size_t s = (off_ + alignof(T) - 1) & ~(alignof(T) - 1);
        off_ = s + sizeof(T);
        return ::new (buf_ + s) T{};
    }
    void reset() { off_ = 0; }
};

int main() {
    Arena arena;
    for (int request = 0; request < 3; ++request) {
        Node* root = arena.make<Node>();
        for (int i = 0; i < 10; ++i) root->kids.items.push_back(arena.make<Node>());
        arena.reset();
    }
    std::printf("vectors never destroyed: %d\n", live_vectors);
}
Answer
vectors never destroyed: 33

Answer. 33 node objects were created (11 per request) and none was destroyed, so every Children constructor ran without its destructor. The real damage is the heap buffer of each root's std::vector: it was allocated with the global allocator and is leaked at every reset. (The harness disables LeakSanitizer, so this run is clean; with leak detection on, ASan reports the three buffers.)

Why. The arena only owns the bytes of the Node objects themselves. Anything a node owns elsewhere must be released by its destructor, which an arena reset does not run. Fixes: make nodes trivially destructible (children as an arena-allocated array plus a count, or an intrusive linked list of siblings), use std::pmr::vector on the arena's resource, or add a finaliser list. A static_assert(std::is_trivially_destructible_v<T>) in make would have rejected this code at compile time.

OutputMarks and rewinds. What does this print?

#include <cstddef>
#include <cstdio>

class Scratch {
    alignas(16) std::byte buf_[256];
    std::size_t off_ = 0;
public:
    struct Mark { std::size_t off; };
    void* alloc(std::size_t n) { std::size_t s = (off_ + 15) & ~std::size_t{15}; off_ = s + n; return buf_ + s; }
    Mark mark() const { return {off_}; }
    void rewind(Mark m) { off_ = m.off; }
    std::size_t used() const { return off_; }
};

int main() {
    Scratch s;
    void* keep = s.alloc(10);
    auto outer = s.mark();
    void* a = s.alloc(40);
    auto inner = s.mark();
    s.alloc(100);
    std::printf("used %zu\n", s.used());
    s.rewind(inner);
    std::printf("after inner rewind %zu\n", s.used());
    s.rewind(outer);
    void* b = s.alloc(24);
    std::printf("after outer rewind and alloc %zu, b reuses a: %s, keep intact: %s\n",
                s.used(), b == a ? "yes" : "no", keep != b ? "yes" : "no");
}
Answer
used 164
after inner rewind 56
after outer rewind and alloc 40, b reuses a: yes, keep intact: yes

Answer. keep occupies 0..10; a starts at 16 and ends at 56; the 100-byte block starts at 64 and ends at 164. Rewinding to inner returns to 56. Rewinding to outer returns to 10, and the next 24-byte allocation starts at 16, the address a had, ending at 40. keep is untouched because it was allocated before the outer mark.

Why. A mark is just a saved offset, so rewinding is O(1) and frees everything allocated after it, in stack order. Note the alignment: the mark stores the unaligned end (10, 56), and the next allocation re-rounds it. Rewinding to inner after rewinding to outer would have jumped the offset forward to 56, over memory that b now uses, which is the nesting bug.

Find the bugWhy does this not compile? Is the arena right to reject it?

#include <cstddef>
#include <new>
#include <string>
#include <type_traits>
#include <utility>

class Arena {
    alignas(std::max_align_t) std::byte buf_[1024];
    std::size_t off_ = 0;
public:
    template <class T, class... A> T* create(A&&... args) {
        static_assert(std::is_trivially_destructible_v<T>, "arena objects are never destroyed");
        std::size_t s = (off_ + alignof(T) - 1) & ~(alignof(T) - 1);
        off_ = s + sizeof(T);
        return ::new (buf_ + s) T(std::forward<A>(args)...);
    }
};

int main() {
    Arena arena;
    auto* symbol = arena.create<std::string>("a_rather_long_symbol_name_from_the_parser");
    (void)symbol;
}
Answer

Answer. std::string has a non-trivial destructor, so the static_assert fires and both compilers reject the program. The arena is right: it never runs destructors, and this 41-character string owns a heap buffer that would leak on every reset.

Why. Turning the lifetime rule into a compile-time check is the cheapest arena safety measure there is. Alternatives that compile and are correct: store a std::string_view into arena-copied characters, use std::pmr::string on the arena's memory resource, or give the arena a finaliser list. Note that the check must use is_trivially_destructible, not is_trivial: a type with a non-trivial constructor but a trivial destructor (most AST nodes) is perfectly fine.

Find the bugA parser function owns its arena and returns the result. What does the sanitizer build report?

#include <cstdio>
#include <memory>
#include <memory_resource>
#include <vector>

static std::pmr::vector<int> parse_numbers() {
    auto arena = std::make_unique<std::pmr::monotonic_buffer_resource>();
    std::pmr::vector<int> out(arena.get());
    for (int i = 1; i <= 100; ++i) out.push_back(i * 10);
    return out;                    // arena is destroyed here
}

int main() {
    auto nums = parse_numbers();
    std::printf("first %d, last %d\n", nums.front(), nums.back());
}
Answer

Answer. The vector is moved out, but its elements live in buffers owned by the arena, and the arena is destroyed when the function returns. Reading nums.front() is a heap-use-after-free, which ASan reports; later, nums's destructor would also call deallocate on the destroyed resource.

Why. A pmr container stores a raw pointer to its memory resource and never owns it. Moving the container moves the pointer, not the resource. The arena must outlive every container that uses it: create it in the caller and pass it in (parse_numbers(std::pmr::memory_resource& arena)), or return a struct that owns both the arena and the data, declared in that order so the data is destroyed first.

OutputHow many bytes does a pmr::vector request from its arena while growing to 1000 ints, with and without reserve?

#include <cstddef>
#include <cstdio>
#include <memory_resource>
#include <vector>

class Tally : public std::pmr::memory_resource {
    std::pmr::memory_resource* up_;
public:
    std::size_t requested = 0;
    explicit Tally(std::pmr::memory_resource* up) : up_(up) {}
private:
    void* do_allocate(std::size_t n, std::size_t a) override { requested += n; return up_->allocate(n, a); }
    void do_deallocate(void* p, std::size_t n, std::size_t a) override { up_->deallocate(p, n, a); }
    bool do_is_equal(const memory_resource& o) const noexcept override { return this == &o; }
};

int main() {
    std::pmr::monotonic_buffer_resource arena;
    Tally grow(&arena), reserved(&arena);
    {
        std::pmr::vector<int> v(&grow);
        for (int i = 0; i < 1000; ++i) v.push_back(i);
        std::pmr::vector<int> w(&reserved);
        w.reserve(1000);
        for (int i = 0; i < 1000; ++i) w.push_back(i);
    }
    std::printf("without reserve: %zu bytes, with reserve: %zu bytes\n", grow.requested, reserved.requested);
}
Answer
without reserve: 8188 bytes, with reserve: 4000 bytes

Answer. Without reserve, the vector requests capacities 1, 2, 4, ..., 1024 ints: 2047 ints, 8188 bytes, of which only the last 4096 are in use. With reserve(1000) it requests exactly 4000 bytes once.

Why. Both libstdc++ and libc++ double the capacity on growth (MSVC uses 1.5), so the abandoned buffers sum to just under the final one. With malloc or a pool those old buffers are reused; in a monotonic arena, deallocate is a no-op and they stay dead until release(). The output is the same for both libraries because both double; a library with a different growth factor would print a different first number.

Write the code

Coding

CodeImplement a fixed-buffer arena over caller-supplied memory: void* try_allocate(n, align) (null when full), allocate (throws std::bad_alloc), T* create<T>(args...) for trivially destructible types only, T* create_array<T>(count), reset() and used(). The base may be misaligned, alignments may be up to 4096, and huge sizes must not overflow the bounds check.

Answer

Approach. Work on absolute addresses: round base + offset up to the alignment, convert back to an offset, then check n <= cap - start (which cannot overflow once start <= cap is known). create enforces trivial destructibility with a static_assert, and create_array checks the multiplication for overflow.

#include <bit>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <limits>
#include <memory>
#include <new>
#include <type_traits>
#include <utility>

class FixedArena {
    std::byte* base_;
    std::size_t cap_, off_ = 0;
public:
    FixedArena(void* buf, std::size_t cap) : base_(static_cast<std::byte*>(buf)), cap_(cap) {}
    FixedArena(const FixedArena&) = delete;
    FixedArena& operator=(const FixedArena&) = delete;

    void* try_allocate(std::size_t n, std::size_t align = alignof(std::max_align_t)) noexcept {
        if (!std::has_single_bit(align)) return nullptr;
        auto b = reinterpret_cast<std::uintptr_t>(base_);
        auto aligned = (b + off_ + align - 1) & ~(std::uintptr_t{align} - 1);
        std::size_t start = aligned - b;
        if (start > cap_ || n > cap_ - start) return nullptr;
        off_ = start + n;
        return base_ + start;
    }
    void* allocate(std::size_t n, std::size_t align = alignof(std::max_align_t)) {
        if (void* p = try_allocate(n, align)) return p;
        throw std::bad_alloc();
    }
    template <class T, class... A> T* create(A&&... args) {
        static_assert(std::is_trivially_destructible_v<T>, "the arena never runs destructors");
        return ::new (allocate(sizeof(T), alignof(T))) T{std::forward<A>(args)...};
    }
    template <class T> T* create_array(std::size_t count) {
        static_assert(std::is_trivially_destructible_v<T>);
        if (count > std::numeric_limits<std::size_t>::max() / sizeof(T)) throw std::bad_alloc();
        T* p = static_cast<T*>(allocate(sizeof(T) * count, alignof(T)));
        std::uninitialized_value_construct_n(p, count);
        return p;
    }
    void reset() noexcept { off_ = 0; }
    std::size_t used() const noexcept { return off_; }
};

struct alignas(64) CacheLine { long counters[8]; };
struct Token { int kind; int start; int len; };

int main() {
    alignas(64) static std::byte storage[1 + 8192];
    FixedArena arena(storage + 1, 8192);                    // deliberately misaligned base

    auto* t = arena.create<Token>(1, 0, 5);
    auto* line = arena.create<CacheLine>();
    auto* page = arena.allocate(16, 4096);
    std::printf("token kind %d, cache line aligned: %s, page aligned: %s\n", t->kind,
                reinterpret_cast<std::uintptr_t>(line) % 64 == 0 ? "yes" : "no",
                reinterpret_cast<std::uintptr_t>(page) % 4096 == 0 ? "yes" : "no");

    int* zeros = arena.create_array<int>(100);
    int sum = 0;
    for (int i = 0; i < 100; ++i) sum += zeros[i];
    std::printf("array of 100 ints sums to %d\n", sum);

    std::printf("huge request: %s\n", arena.try_allocate(std::numeric_limits<std::size_t>::max() - 8) ? "granted" : "refused");
    std::printf("bad alignment: %s\n", arena.try_allocate(8, 24) ? "granted" : "refused");
    try { arena.create_array<double>(std::numeric_limits<std::size_t>::max() / 4); }
    catch (const std::bad_alloc&) { std::puts("overflowing array: bad_alloc"); }

    arena.reset();
    std::printf("used after reset: %zu\n", arena.used());
}
token kind 1, cache line aligned: yes, page aligned: yes
array of 100 ints sums to 0
huge request: refused
bad alignment: refused
overflowing array: bad_alloc
used after reset: 0

Complexity and pitfalls. O(1) per allocation, plus O(count) to value-initialise arrays. The tests cover each classic bug: the misaligned base (the 64- and 4096-aligned requests would fail with offset-only rounding), the overflow in start + n for a huge n, a non-power-of-two alignment, and the overflow in sizeof(T) * count. The arena does not own its buffer, so destroying it frees nothing; that is the caller's job.

Estimated rating: ~1500 · short, but every line has an alignment or overflow edge case that interviewers probe

CodeExtend an arena with a destructor list so it accepts any type. make<T>(args...) registers a finaliser only for types that need one; reset() and the destructor run finalisers newest first. The arena must stay consistent if T's constructor throws or if the arena runs out of space while registering the finaliser.

Answer

Approach. Reserve the finaliser record before constructing the object, so that the only thing that can fail after construction is nothing. If the constructor throws, rewind the offset to where it was before both allocations, so the arena is exactly as before the call. The record stores a type-erased destroy function (a captureless lambda converted to a function pointer) and the object's address.

#include <cstddef>
#include <cstdio>
#include <new>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

class Arena {
    struct Finalizer { void (*destroy)(void*); void* obj; Finalizer* next; };
    std::byte* buf_;
    std::size_t cap_, off_ = 0;
    Finalizer* fin_ = nullptr;

    void* raw(std::size_t n, std::size_t a) {
        std::size_t s = (off_ + a - 1) & ~(a - 1);
        if (s > cap_ || n > cap_ - s) throw std::bad_alloc();
        off_ = s + n;
        return buf_ + s;
    }
public:
    explicit Arena(std::size_t cap) : buf_(static_cast<std::byte*>(::operator new(cap))), cap_(cap) {}
    ~Arena() { reset(); ::operator delete(buf_); }
    Arena(const Arena&) = delete;
    Arena& operator=(const Arena&) = delete;

    template <class T, class... A> T* make(A&&... args) {
        static_assert(alignof(T) <= __STDCPP_DEFAULT_NEW_ALIGNMENT__, "buffer is only new-aligned");
        const std::size_t saved = off_;
        Finalizer* rec = nullptr;
        if constexpr (!std::is_trivially_destructible_v<T>)
            rec = static_cast<Finalizer*>(raw(sizeof(Finalizer), alignof(Finalizer)));   // may throw: nothing to undo
        void* mem;
        try {
            mem = raw(sizeof(T), alignof(T));
            T* obj = ::new (mem) T(std::forward<A>(args)...);
            if constexpr (!std::is_trivially_destructible_v<T>)
                fin_ = ::new (rec) Finalizer{[](void* p) { static_cast<T*>(p)->~T(); }, obj, fin_};
            return obj;
        } catch (...) {
            off_ = saved;                     // undo both allocations
            throw;
        }
    }
    void reset() noexcept {
        while (fin_) { Finalizer* f = fin_; fin_ = f->next; f->destroy(f->obj); }
        off_ = 0;
    }
    std::size_t used() const noexcept { return off_; }
};

struct Symbol {
    static inline int live = 0;
    std::string name;
    std::vector<int> uses;
    explicit Symbol(std::string n, bool fail = false) : name(std::move(n)) {
        if (fail) throw std::runtime_error("bad symbol");
        ++live;
    }
    ~Symbol() { --live; }
};

int main() {
    Arena arena(4096);
    arena.make<Symbol>("a_symbol_name_long_enough_for_the_heap")->uses.assign(50, 7);
    arena.make<double>(3.5);
    arena.make<Symbol>("another_long_symbol_name_on_the_heap");
    std::size_t before = arena.used();
    try { arena.make<Symbol>("broken", true); } catch (const std::exception& e) { std::printf("caught: %s\n", e.what()); }
    std::printf("live %d, offset restored: %s\n", Symbol::live, arena.used() == before ? "yes" : "no");
    try { for (;;) arena.make<Symbol>("filler"); } catch (const std::bad_alloc&) { std::puts("arena full: bad_alloc"); }
    std::printf("live before reset: %s\n", Symbol::live > 2 ? "many" : "few");
    arena.reset();
    std::printf("live after reset: %d, used %zu\n", Symbol::live, arena.used());
}
caught: bad symbol
live 2, offset restored: yes
arena full: bad_alloc
live before reset: many
live after reset: 0, used 0

Complexity and pitfalls. O(1) per make, O(number of non-trivial objects) per reset. ASan runs this clean, which proves the strings' and vectors' heap buffers are freed by the finalisers. Pitfalls: registering the finaliser after construction (if that allocation throws, the constructed object is never destroyed); pushing a finaliser before the constructor succeeds (reset would destroy a half-built object); running finalisers oldest first; and finalisers that throw, which is why reset is noexcept and destructors must not throw. Rewinding on failure is only valid because nothing else allocated in between.

Estimated rating: ~1800 · type erasure with function pointers plus careful exception-safety ordering

CodeWrite a growing arena that chains blocks: the first block is 1 KiB, each new block doubles, requests larger than half the next block size get a dedicated block, and reset() keeps only the first block. Each block stores its header at its start. Report the number of blocks and the bytes reserved.

Answer

Approach. Each block begins with {next, size} and the usable area follows, so the arena needs one allocation per block. The arena tracks the current block's bump pointer and end. A request that does not fit allocates either a normal doubled block (which becomes current) or, for large requests, a dedicated block inserted behind the current one so the current block keeps serving small requests.

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <new>

class GrowingArena {
    struct Block { Block* next; std::size_t size; };            // header; data follows
    static constexpr std::size_t kHeader = (sizeof(Block) + 15) & ~std::size_t{15};
    static constexpr std::size_t kFirst = 1024;

    Block* head_ = nullptr;          // current block, chain points to older blocks
    Block* first_ = nullptr;         // the 1 KiB block that reset() keeps
    std::byte* ptr_ = nullptr;
    std::byte* end_ = nullptr;
    std::size_t next_size_ = kFirst;

    static Block* new_block(std::size_t data_size, Block* next) {
        void* mem = ::operator new(kHeader + data_size);
        return ::new (mem) Block{next, data_size};
    }
    static std::byte* data(Block* b) { return reinterpret_cast<std::byte*>(b) + kHeader; }
    static std::byte* align_up(std::byte* p, std::size_t a) {
        auto v = reinterpret_cast<std::uintptr_t>(p);
        return p + (((v + a - 1) & ~(std::uintptr_t{a} - 1)) - v);
    }

public:
    GrowingArena() { first_ = head_ = new_block(kFirst, nullptr); ptr_ = data(head_); end_ = ptr_ + kFirst; next_size_ = 2 * kFirst; }
    ~GrowingArena() { release_all(head_); }
    GrowingArena(const GrowingArena&) = delete;
    GrowingArena& operator=(const GrowingArena&) = delete;

    void* allocate(std::size_t n, std::size_t align = 16) {     // align <= 16
        std::byte* p = align_up(ptr_, align);
        if (p <= end_ && n <= static_cast<std::size_t>(end_ - p)) { ptr_ = p + n; return p; }
        if (n > next_size_ / 2) {                                // dedicated block behind the current one
            head_->next = new_block(n, head_->next);
            return data(head_->next);
        }
        head_ = new_block(next_size_, head_);
        ptr_ = data(head_) + n;
        end_ = data(head_) + next_size_;
        next_size_ *= 2;
        return data(head_);
    }
    void reset() {
        for (Block* b = head_; b;) {                             // a dedicated block may sit behind first_
            Block* n = b->next;
            if (b != first_) ::operator delete(b);
            b = n;
        }
        first_->next = nullptr;
        head_ = first_;
        ptr_ = data(first_);
        end_ = ptr_ + first_->size;
        next_size_ = 2 * kFirst;
    }
    void stats(const char* when) const {
        std::size_t blocks = 0, bytes = 0;
        for (Block* b = head_; b; b = b->next) { ++blocks; bytes += b->size; }
        std::printf("%-14s %zu blocks, %zu bytes reserved\n", when, blocks, bytes);
    }

private:
    static void release_all(Block* b) { while (b) { Block* n = b->next; ::operator delete(b); b = n; } }
};

int main() {
    GrowingArena arena;
    arena.stats("start:");
    for (int i = 0; i < 100; ++i) static_cast<char*>(arena.allocate(40))[0] = 'x';   // 4000 bytes in 48-byte steps
    arena.stats("100 x 40 B:");
    auto* big = static_cast<char*>(arena.allocate(100000));
    big[99999] = 'y';
    void* small = arena.allocate(16);
    arena.stats("after 100 KB:");
    std::printf("small request still served from the current block: %s\n", small != nullptr ? "yes" : "no");
    arena.reset();
    arena.stats("after reset:");
    arena.allocate(5000);                                       // dedicated block behind the first one
    arena.reset();
    arena.stats("reset again:");
}
start:         1 blocks, 1024 bytes reserved
100 x 40 B:    3 blocks, 7168 bytes reserved
after 100 KB:  4 blocks, 107168 bytes reserved
small request still served from the current block: yes
after reset:   1 blocks, 1024 bytes reserved
reset again:   1 blocks, 1024 bytes reserved

Complexity and pitfalls. Allocation is O(1) amortised; reset is O(number of blocks). The header is rounded to 16 bytes so that block data stays 16-aligned (the header itself is 16 bytes on 64-bit, so this is free). Pitfalls: a large request that becomes the current block wastes the rest of the previous block and makes the next small request start a new block, which is why it is linked behind; reset must keep the first block, which is not always the tail of the chain (a large request made while the first block is current is linked behind it), so the arena remembers it; and the bounds test compares n with the remaining space rather than computing p + n, which could overflow past the end of the block.

Estimated rating: ~1800 · pointer arithmetic across a linked list of blocks, with a dedicated-block path and a selective reset

CodeImplement a scratch arena with mark(), rewind(mark) and an RAII ScratchScope. Use it in a recursive function that needs a temporary array at every level (a merge sort), and show that the peak memory stays far below the total requested. In debug builds, rewinding should fill freed memory with a pattern.

Answer

Approach. A mark is the current offset. The scope saves it in its constructor and rewinds in its destructor, so every exit path, including exceptions, frees the temporaries. Rewinding fills the released range with 0xCD so a stale pointer reads garbage at once. A merge sort that takes its merge buffer from the arena at every level shows the effect: the total requested is n log n per sort, but the high-water mark is a single buffer of n elements.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <new>

class ScratchArena {
    alignas(16) std::byte buf_[64 * 1024];
    std::size_t off_ = 0, high_ = 0;
public:
    struct Mark { std::size_t off; };
    void* allocate(std::size_t n) {
        std::size_t s = (off_ + 15) & ~std::size_t{15};
        if (s > sizeof buf_ || n > sizeof buf_ - s) throw std::bad_alloc();
        off_ = s + n;
        high_ = std::max(high_, off_);
        return buf_ + s;
    }
    template <class T> T* array(std::size_t n) { return ::new (allocate(n * sizeof(T))) T[n]{}; }
    Mark mark() const noexcept { return {off_}; }
    void rewind(Mark m) noexcept {
        std::memset(buf_ + m.off, 0xCD, off_ - m.off);      // debug poison
        off_ = m.off;
    }
    std::size_t used() const noexcept { return off_; }
    std::size_t high_water() const noexcept { return high_; }
};

class ScratchScope {
    ScratchArena& a_;
    ScratchArena::Mark m_;
public:
    explicit ScratchScope(ScratchArena& a) : a_(a), m_(a.mark()) {}
    ~ScratchScope() { a_.rewind(m_); }
    ScratchScope(const ScratchScope&) = delete;
    ScratchScope& operator=(const ScratchScope&) = delete;
};

// Merge sort that takes its merge buffer from the scratch arena at every level.
static void merge_sort(ScratchArena& s, int* a, std::size_t n, std::size_t& total_bytes) {
    if (n < 2) return;
    std::size_t half = n / 2;
    merge_sort(s, a, half, total_bytes);
    merge_sort(s, a + half, n - half, total_bytes);
    ScratchScope scope(s);                          // the buffer below is freed when this level returns
    int* tmp = s.array<int>(n);
    total_bytes += n * sizeof(int);
    std::merge(a, a + half, a + half, a + n, tmp);
    std::copy(tmp, tmp + n, a);
}

int main() {
    ScratchArena scratch;
    int data[1000];
    std::size_t total = 0;
    bool sorted = true;
    for (int round = 0; round < 100; ++round) {
        for (int i = 0; i < 1000; ++i) data[i] = (i * 7919 + round * 31) % 1000;
        merge_sort(scratch, data, 1000, total);
        sorted = sorted && std::is_sorted(data, data + 1000);
    }
    std::printf("100 sorts correct: %s\n", sorted ? "yes" : "no");
    std::printf("scratch requested in total: %zu bytes\n", total);
    std::printf("high-water mark: %zu bytes, used now: %zu\n", scratch.high_water(), scratch.used());

    auto outer = scratch.mark();
    int* stale = scratch.array<int>(4);
    stale[0] = 42;
    scratch.rewind(outer);
    std::printf("stale read after rewind: 0x%X\n", static_cast<unsigned>(stale[0]));
}
100 sorts correct: yes
scratch requested in total: 3990400 bytes
high-water mark: 4000 bytes, used now: 0
stale read after rewind: 0xCDCDCDCD

Complexity and pitfalls. Allocation and rewind are O(1) (the debug fill is O(bytes freed)). 100 sorts of 1000 ints requested almost 4 MB of scratch in total, yet the arena never held more than one 4000-byte buffer: each level opens its scope after its recursive calls have returned and rewound, so buffers never stack up. Opening the scope at the top of the function instead would still be correct but would hold buffers along the whole recursion path. Pitfalls: returning a pointer allocated inside the scope, rewinding marks out of order, and forgetting that the fill is a debugging aid only. The last lines read freed scratch memory on purpose to show the poison; it is safe here only because the bytes are still inside the arena's own buffer and are read as plain integers.

Estimated rating: ~1600 · RAII rewinding plus reasoning about high-water memory in recursion

CodeWrite a parser for integer expressions with +, *, parentheses and variable names (for example 2 * (price + 3) * qty) that builds an AST in a std::pmr::monotonic_buffer_resource over a stack buffer, with null_memory_resource() as upstream so it provably never touches the heap. Variable names are std::pmr::string. Evaluate the AST with a variable table.

Answer

Approach. A recursive-descent parser (expr := term ('+' term)*, term := factor ('*' factor)*, factor := number | name | '(' expr ')') creates nodes with std::pmr::polymorphic_allocator<>::new_object, so every node and every name comes from the arena. Nodes are never destroyed individually: the whole tree disappears when the arena does. That is safe because everything a node owns (the name's characters) is in the same arena.

#include <array>
#include <cctype>
#include <cstddef>
#include <cstdio>
#include <map>
#include <memory_resource>
#include <new>
#include <stdexcept>
#include <string>
#include <string_view>

struct Node {
    char op;                   // '+', '*', 'n' (number) or 'v' (variable)
    long value = 0;
    std::pmr::string name;
    Node* lhs = nullptr;
    Node* rhs = nullptr;
    Node(char o, std::pmr::polymorphic_allocator<> a) : op(o), name(a) {}
};

class Parser {
    std::string_view src_;
    std::size_t pos_ = 0;
    std::pmr::polymorphic_allocator<> alloc_;

    char peek() { while (pos_ < src_.size() && src_[pos_] == ' ') ++pos_; return pos_ < src_.size() ? src_[pos_] : '\0'; }
    Node* make(char op) { return alloc_.new_object<Node>(op, alloc_); }
    Node* binary(char op, Node* l, Node* r) { Node* n = make(op); n->lhs = l; n->rhs = r; return n; }

    Node* factor() {
        char c = peek();
        if (c == '(') { ++pos_; Node* e = expr(); if (peek() != ')') throw std::runtime_error("expected )"); ++pos_; return e; }
        if (std::isdigit(static_cast<unsigned char>(c))) {
            Node* n = make('n');
            while (pos_ < src_.size() && std::isdigit(static_cast<unsigned char>(src_[pos_]))) n->value = n->value * 10 + (src_[pos_++] - '0');
            return n;
        }
        if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') {
            Node* n = make('v');
            std::size_t start = pos_;
            while (pos_ < src_.size() && (std::isalnum(static_cast<unsigned char>(src_[pos_])) || src_[pos_] == '_')) ++pos_;
            n->name.assign(src_.substr(start, pos_ - start));
            return n;
        }
        throw std::runtime_error("unexpected character");
    }
    Node* term() { Node* l = factor(); while (peek() == '*') { ++pos_; l = binary('*', l, factor()); } return l; }
    Node* expr() { Node* l = term(); while (peek() == '+') { ++pos_; l = binary('+', l, term()); } return l; }

public:
    Parser(std::string_view s, std::pmr::memory_resource* r) : src_(s), alloc_(r) {}
    Node* parse() { Node* e = expr(); if (peek() != '\0') throw std::runtime_error("trailing input"); return e; }
};

static long eval(const Node* n, const std::map<std::string, long, std::less<>>& vars) {
    switch (n->op) {
        case 'n': return n->value;
        case 'v': return vars.at(std::string(n->name));
        case '+': return eval(n->lhs, vars) + eval(n->rhs, vars);
        default:  return eval(n->lhs, vars) * eval(n->rhs, vars);
    }
}

static int count(const Node* n) { return n ? 1 + count(n->lhs) + count(n->rhs) : 0; }

int main() {
    const std::map<std::string, long, std::less<>> vars{{"price", 7}, {"qty", 5}, {"a_very_long_variable_name_for_the_heap", 2}};
    alignas(std::max_align_t) std::array<std::byte, 4096> stack_buf;
    for (const char* text : {"2 * (price + 3) * qty", "1 + 2 * 3 + a_very_long_variable_name_for_the_heap", "(1 + 2"}) {
        std::pmr::monotonic_buffer_resource arena(stack_buf.data(), stack_buf.size(), std::pmr::null_memory_resource());
        try {
            Node* ast = Parser(text, &arena).parse();
            std::printf("%-50s = %ld (%d nodes)\n", text, eval(ast, vars), count(ast));
        } catch (const std::exception& e) {
            std::printf("%-50s : error: %s\n", text, e.what());
        }
    }   // each arena (and its tree) vanishes here; the stack buffer is reused

    std::pmr::monotonic_buffer_resource tiny(stack_buf.data(), 64, std::pmr::null_memory_resource());
    try { Parser("1 + 2 + 3 + 4 + 5 + 6", &tiny).parse(); }
    catch (const std::bad_alloc&) { std::puts("64-byte arena: bad_alloc, never the heap"); }
}
2 * (price + 3) * qty                              = 100 (7 nodes)
1 + 2 * 3 + a_very_long_variable_name_for_the_heap = 9 (7 nodes)
(1 + 2                                             : error: expected )
64-byte arena: bad_alloc, never the heap

Complexity and pitfalls. Parsing is O(length); each node is one bump allocation. Nodes are not trivially destructible (they hold a pmr::string), yet skipping their destructors is safe because the string's buffer is in the same arena, so nothing is leaked (skipping a destructor is allowed by [basic.life]/5); ASan confirms it. The polymorphic_allocator passed to each Node makes the long variable name allocate from the arena too. Pitfalls: a name stored as std::string would leak at every reset; a tree that escapes the scope of its arena dangles; and the null upstream means the buffer must be sized for the largest input you accept, which is a feature for hot paths and a limit you must test.

Estimated rating: ~1900 · a working recursive-descent parser plus correct pmr allocator propagation and arena lifetime reasoning

CodeWrite ArenaAllocator<T>, an STL allocator over a bump arena, so that std::vector, std::map and std::basic_string can live in the arena. deallocate must reclaim the memory when it is the most recent allocation (so a temporary that is freed right away costs nothing), and two allocators must compare equal exactly when they share an arena.

Answer

Approach. The allocator is a handle holding an Arena*; the converting constructor copies that pointer so rebound allocators share the arena. The arena implements "undo last": if the block being freed ends exactly at the current offset, the offset moves back to its start. Equality compares arena pointers. Copy and move assignment propagate the allocator so containers can be swapped and assigned freely within one arena.

#include <cstddef>
#include <cstdio>
#include <map>
#include <new>
#include <string>
#include <type_traits>
#include <vector>

class Arena {
    alignas(std::max_align_t) std::byte buf_[64 * 1024];
    std::size_t off_ = 0;
public:
    void* allocate(std::size_t n, std::size_t a) {
        std::size_t s = (off_ + a - 1) & ~(a - 1);
        if (s > sizeof buf_ || n > sizeof buf_ - s) throw std::bad_alloc();
        off_ = s + n;
        return buf_ + s;
    }
    void deallocate(void* p, std::size_t n) noexcept {
        if (static_cast<std::byte*>(p) + n == buf_ + off_) off_ = static_cast<std::size_t>(static_cast<std::byte*>(p) - buf_);
    }
    std::size_t used() const noexcept { return off_; }
};

template <class T>
class ArenaAllocator {
    Arena* arena_;
    template <class U> friend class ArenaAllocator;
public:
    using value_type = T;
    using propagate_on_container_copy_assignment = std::true_type;
    using propagate_on_container_move_assignment = std::true_type;
    using propagate_on_container_swap = std::true_type;

    explicit ArenaAllocator(Arena& a) noexcept : arena_(&a) {}
    template <class U> ArenaAllocator(const ArenaAllocator<U>& o) noexcept : arena_(o.arena_) {}

    T* allocate(std::size_t n) {
        if (n > static_cast<std::size_t>(-1) / sizeof(T)) throw std::bad_alloc();
        return static_cast<T*>(arena_->allocate(n * sizeof(T), alignof(T)));
    }
    void deallocate(T* p, std::size_t n) noexcept { arena_->deallocate(p, n * sizeof(T)); }
    template <class U> bool operator==(const ArenaAllocator<U>& o) const noexcept { return arena_ == o.arena_; }
};

using AString = std::basic_string<char, std::char_traits<char>, ArenaAllocator<char>>;

int main() {
    Arena arena;
    {
        std::vector<int, ArenaAllocator<int>> v{ArenaAllocator<int>(arena)};
        for (int i = 0; i < 1000; ++i) v.push_back(i);
        std::printf("vector of 1000 ints: arena holds %zu bytes\n", arena.used());
    }
    std::printf("after the vector died: %zu bytes\n", arena.used());
    {
        AString temp(200, 'x', ArenaAllocator<char>(arena));   // freed while still on top
    }
    std::printf("after a temporary string: %zu bytes\n", arena.used());

    ArenaAllocator<char> ca(arena);
    const std::size_t before_map = arena.used();
    std::map<AString, int, std::less<>, ArenaAllocator<std::pair<const AString, int>>> prices{ArenaAllocator<std::pair<const AString, int>>(arena)};
    for (const char* sym : {"AAPL", "MSFT", "a_long_instrument_identifier_on_the_arena"}) prices.emplace(AString(sym, ca), 100);
    std::printf("map entries %zu, nodes and keys in the arena: %s\n", prices.size(), arena.used() > before_map ? "yes" : "no");

    Arena other;
    ArenaAllocator<int> x(arena), y(arena), z(other);
    ArenaAllocator<long> rebound(x);
    std::printf("x == y: %d, x == z: %d, rebound == x: %d\n", x == y, x == z, rebound == x);
}
vector of 1000 ints: arena holds 8188 bytes
after the vector died: 4092 bytes
after a temporary string: 4092 bytes
map entries 3, nodes and keys in the arena: yes
x == y: 1, x == z: 0, rebound == x: 1

Complexity and pitfalls. O(1) per allocation. The output shows what undo-last can and cannot do. While the vector grows it allocates the new buffer before freeing the old one, so the old buffer is never on top and is never reclaimed: the arena holds 8188 bytes, the same as a monotonic resource. When the vector dies, its final 4096-byte buffer is on top and comes back, leaving 4092 bytes of dead buffers. The temporary string is allocated and freed while on top, so it costs nothing. (std::vector has no way to ask an allocator to grow a block in place, which is why reserve matters so much in arenas.) The propagate_* traits are set to true so that assigning or swapping two arena containers never falls back to element-wise copies. Swapping containers on different arenas then swaps arenas, which is what you want only if the containers' lifetimes match their new arenas. The map's nodes and its long key string are both in the arena; short keys fit in the string's inline buffer. The Arena object is 64 KiB, which is fine on a desktop stack but should be heap-allocated or static on small-stack threads.

Estimated rating: ~1800 · allocator requirements, rebind, propagation traits and an undo-last arena working together

Cheat sheet

Theory
  • Arena = buffer + offset. Allocate: start = round_up(addr, a), offset = start + n. Free one: no-op (or undo-last). Free all: reset in O(1).
  • round_up(x, a) = (x + a - 1) & ~(a - 1) for power-of-two a; align the address, not just the offset; check n <= cap - start to avoid overflow.
  • Safe only under a lifetime discipline: per request, per frame (double buffered), per parse. Nothing outside may point in after reset.
  • Destructors are not run: allow only trivially destructible types (static_assert), or keep a finaliser list run newest first. Default-allocator containers inside arena objects leak.
  • std::pmr::monotonic_buffer_resource: optional initial buffer, geometric growth from upstream, no-op deallocate, release() frees upstream buffers, not thread safe; upstream null_memory_resource() proves no heap use.
  • Growing arenas chain doubling blocks, give huge requests their own block, and keep one block on reset. Marks give O(1) rewind for scratch memory; use RAII scopes and two scratch arenas.
  • Arena vs pool: one shared lifetime and mixed sizes → arena; one size with individual lifetimes → pool. Measured on an Apple M1: arena about 10× malloc, monotonic resource 4 to 5×.
  • Pitfalls: dangling pointers after reset (ASan silent unless blocks are freed or memory is poisoned), unbounded growth (vector regrowth doubles memory), mixing with long-lived objects, alignment bugs, sharing across threads.

Part E · Memory management · Chapter 18

Stack allocation optimisations

The cheapest allocation is the one that never reaches the allocator. This chapter explains why the stack is so cheap, how big it really is, and the techniques that keep small objects off the heap: small string optimisation, small-buffer optimisation, inline-storage vectors, stack buffers for pmr, and the compiler's own heap elision. Interviewers ask about it because it separates people who know std::string has SSO from people who can say how many bytes fit, why std::vector cannot do the same, and what happens when a thread runs out of stack.

TheoryDrillsCoding· about 5 hours· prerequisites: Ch 9, Ch 14, Ch 17

Why the stack is cheap

Theory

Mental model. The stack is a bump allocator that the hardware and the ABI give you for free. Each thread owns one contiguous region and one register (rsp on x86-64, sp on AArch64) that marks its top. Allocating every local of a function is a single subtraction from that register in the prologue, and freeing all of them is a single addition in the epilogue. There is no search for a free block, no header, no lock and no bookkeeping.

Under the hood. The compiler knows the size of every local at compile time, so it adds them up (plus spills and alignment) and emits one adjustment. A heap allocation is a call into operator new, which calls malloc, which picks a size class, pops a block from a thread cache or a shared free list, and may fall back to a system call (Ch 10). The matching delete has to find the size class again and push the block back. The generated code shows the difference:

// int local_sum() { int a[64]; fill(a); return sum(a); }      (AArch64, abridged)
local_sum:
    sub   sp, sp, #272           // all locals allocated: one instruction
    stp   x29, x30, [sp, #256]   // save frame pointer and link register
    ...
    ldp   x29, x30, [sp, #256]
    add   sp, sp, #272           // all locals freed: one instruction
    ret

// int heap_sum() { int* a = new int[64]; fill(a); int s = sum(a); delete[] a; return s; }
heap_sum:
    ...
    mov   x0, #256
    bl    operator new[](unsigned long)    // tens of instructions in the fast path
    ...
    bl    operator delete[](void*)         // and again on the way out

The stack also wins on locality. The top of the stack is touched constantly, so it is almost always in L1 cache and its pages are already mapped. A fresh heap block can come from anywhere in the address space and may share nothing with the data you are using.

Key facts

OperationTypical costWhy
Allocate all locals of a frame1 instruction, about 0 nsOne sub sp, sizes known at compile time
Free all locals1 instructionOne add sp (or mov sp, fp)
malloc/new fast path (thread cache hit)about 10 to 50 nsSize-class lookup, free-list pop, function calls
malloc slow pathhundreds of ns to microsecondsCentral lists, locks, mmap/brk, page faults
First touch of a new heap pageabout 0.1 to 1 µsMinor page fault, kernel zeroes the page
Default stack, Linux main thread8 MB (ulimit -s = 8192)RLIMIT_STACK soft limit
Default stack, macOS secondary thread512 KBpthread_attr default

Know this cold

The stack is fast for three reasons: allocation is a pointer bump that the compiler fuses for the whole frame, deallocation is free and implicit, and the memory is hot in cache. Its limits are just as important: the size is fixed and small, the lifetime ends with the function, and the size of each object must be known at compile time (unless you use alloca or VLAs, which are their own trouble).

The rest of the chapter is about using the stack, or storage inside an object, for things that would normally go to the heap, without hitting those limits.

Stack frame layout

Theory

Each call pushes a frame. The exact layout depends on the ABI (System V x86-64 on Linux, AAPCS64 on Linux arm64, Apple's arm64 variant on macOS), but every frame has the same parts: a way back to the caller, saved registers, locals, and space for arguments to the next call.

high addresses low addresses grows down caller's frame its locals, stack-passed arguments return address saved frame pointer callee-saved registers locals and spills int a[64], small strings, std::array outgoing argument area red zone (128 B) SysV x86-64 and Apple arm64, leaf functions fp (rbp / x29) sp (rsp / sp) prologue: save fp, then sub sp, sp, #frame_size allocates the whole frame epilogue: one add frees it x86-64: pushed by call AArch64: in x30 (LR), saved with fp
A frame is carved out with one subtraction. The return address and saved frame pointer link frames into a chain that debuggers and profilers walk. Locals sit at fixed offsets from sp or fp. The red zone below sp may be used by leaf functions without moving sp at all.

Two details matter in interviews. First, on x86-64 the call instruction pushes the return address, so a buffer overflow on a local array can overwrite it (the classic stack smashing attack, mitigated by stack canaries from -fstack-protector). On AArch64 the return address arrives in the link register x30 and is saved next to the frame pointer by stp x29, x30. Second, the stack pointer must stay aligned: 16 bytes at every call on both x86-64 SysV and AArch64.

Red zones

The System V x86-64 ABI promises that the 128 bytes below rsp will not be clobbered by signal handlers. A leaf function (one that calls nothing) can keep its locals there and skip the sub rsp entirely. Apple's arm64 ABI also has a 128-byte red zone, while the generic AAPCS64 used by Linux on arm64 has none, and Windows x64 has none. Kernel code is compiled with -mno-red-zone, because an interrupt arriving on the same stack would overwrite it.

Stack probes

If a function allocates a frame bigger than a page in one step, sp can jump straight over the guard page below the stack and land in some other mapping (the "stack clash" class of exploits, 2017). Stack probes fix this: the compiler touches each page of a large frame in order, so the guard page is always hit first. GCC and Clang emit them with -fstack-clash-protection, which many Linux distributions enable by default. Windows does the same through __chkstk for any frame over 4 KB. Probes cost a few instructions per page, only in functions with large frames.

Stack size limits and stack overflow

Theory

The stack is small and fixed, and that is the price of its speed. Know the defaults, because "why does this recursion crash only in a worker thread?" is a common interview and debugging question.

PlatformMain threadOther threadsHow to change
Linux, glibc8 MB soft limit, grows on demandDefaults to RLIMIT_STACK at startup (usually 8 MB), fixed sizeulimit -s, setrlimit before exec, pthread_attr_setstacksize
Linux, musl (Alpine)8 MB128 KBpthread_attr_setstacksize, linker flag -Wl,-z,stack-size=
macOS8 MB512 KBpthread_attr_setstacksize, linker -stack_size for main
Windows1 MB1 MBLinker /STACK, CreateThread parameter

On Linux the main thread's stack is a mapping that the kernel grows downward on page faults until it reaches RLIMIT_STACK. Thread stacks are allocated once by the thread library with mmap, with a guard page (or more) at the low end that is mapped with no access. Touching the guard page raises SIGSEGV. A stack overflow is not a C++ exception: no std::bad_alloc is thrown, no destructors run, and the process dies unless you installed a handler on an alternate signal stack (sigaltstack), which can only log and exit.

This program prints the limits it runs with. The values are platform-specific, so its output is not checked.

#include <pthread.h>
#include <sys/resource.h>
#include <cstdio>

int main() {
    rlimit r{};
    getrlimit(RLIMIT_STACK, &r);
    std::printf("RLIMIT_STACK soft: %llu KB\n", (unsigned long long)r.rlim_cur / 1024);

    pthread_attr_t attr;
    pthread_attr_init(&attr);
    std::size_t sz = 0;
    pthread_attr_getstacksize(&attr, &sz);
    std::printf("default new-thread stack: %zu KB\n", sz / 1024);
    pthread_attr_destroy(&attr);
}

On the Apple M1 used to test this guide it prints 8176 KB for the soft limit (8 MB less one 16 KB page) and 512 KB for new threads. A typical Linux x86-64 box prints 8192 KB for both.

Giving a thread a bigger stack

std::thread has no way to set the stack size. Use pthread_attr_setstacksize with pthread_create, boost::thread::attributes, or move the deep work onto the main thread. Better still, remove the depth: turn recursion over user-controlled data (trees, graphs, JSON nesting) into a loop with an explicit stack in a std::vector, which lives on the heap and grows as needed.

Interview trap

A recursive destructor is still recursion. A linked list built from std::unique_ptr<Node> next destroys node 1, which destroys node 2, and so on: one frame per node. A list of a million nodes overflows an 8 MB stack. The fix is an iterative destructor that unlinks nodes in a loop (see the drills).

Rules of thumb that interviewers like to hear: keep frames under a few KB, never put a buffer whose size depends on input on the stack, and treat any recursion whose depth depends on input as a bug waiting for a deep input.

Small string optimisation

Theory

Mental model. Most strings in real programs are short: names, keys, tokens, numbers. A std::string already needs a pointer, a size and a capacity, which is 24 bytes on a 64-bit target. SSO reuses those bytes (and sometimes a few more) as an inline character buffer when the string is short, so a short string needs no heap allocation at all.

Under the hood. The two major implementations use different layouts, and the numbers differ.

libstdc++ (GCC): sizeof = 32, inline capacity 15 char* ptr (8) size_t size (8) union { char buf[16]; size_t cap; } 15 chars + NUL, or the heap capacity short: ptr points into its own buffer (self-referential) libc++ (Clang): sizeof = 24, inline capacity 22 short mode 1 char data[23]: up to 22 chars + NUL size + mode bit long mode cap | mode bit (8) size (8) char* data (8) heap one bit in the first byte tells the two modes apart
Same idea, different trade-offs. libstdc++ keeps the pointer valid in both modes, so data() is a plain load with no branch, but the object is 32 bytes and holds a pointer to itself. libc++ packs 22 characters into 24 bytes and branches on the mode bit in data() and size().

The program below measures it. It counts calls to operator new and assigns to a global string so the compiler cannot elide the allocation. The listed output is for GCC/libstdc++; the libc++ output follows it.

#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <string>

static std::atomic<int> g_allocs{0};   // atomic: see the elision section
void* operator new(std::size_t n) {
    g_allocs.fetch_add(1, std::memory_order_relaxed);
    if (void* p = std::malloc(n ? n : 1)) return p;
    throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

static std::string g;

int main() {
    std::printf("sizeof(std::string) = %zu, empty capacity = %zu\n", sizeof(std::string), g.capacity());
    for (std::size_t len : {15, 16, 22, 23}) {
        int before = g_allocs.load();
        g = std::string(len, 'x');
        std::printf("len %2zu: heap allocations %d\n", len, g_allocs.load() - before);
        g.clear();
        g.shrink_to_fit();
    }
}
sizeof(std::string) = 32, empty capacity = 15
len 15: heap allocations 0
len 16: heap allocations 1
len 22: heap allocations 1
len 23: heap allocations 1
// Clang with libc++ (same program)
sizeof(std::string) = 24, empty capacity = 22
len 15: heap allocations 0
len 16: heap allocations 0
len 22: heap allocations 0
len 23: heap allocations 1

MSVC's STL matches libstdc++ on these numbers (32 bytes, 15 characters) in x64 release builds; debug builds with iterator debugging add a pointer and make it 40 bytes. Everything here is implementation-defined: the standard does not require SSO at all. It does make it possible, because [string.require] allows swap and move to invalidate iterators and pointers into a string.

What SSO changes in practice

  • Moving a short string copies its characters. There is no heap pointer to steal. It is still cheap (a few dozen bytes), but a move is no longer "three pointer copies".
  • Pointers and views into a short string do not follow a move. After b = std::move(a), a string_view that pointed into a long a now points into b's heap buffer and still sees the text, while one that pointed into a short a still points at a's inline buffer, which is now empty. Code that "worked" with long strings breaks with short ones.
  • libstdc++ strings are not trivially relocatable. Because a short string points to itself, you cannot memcpy it to a new address. Containers must call the move constructor. libc++ strings have no self-pointer.
  • sizeof matters. A std::vector<std::string> of short keys is a contiguous array of 24 or 32 byte objects with the text inside, which is good for cache behaviour (Ch 19).
  • reserve and shrink_to_fit can move a string between modes. shrink_to_fit on a short string that has a heap buffer may bring it back inline.

Small-buffer optimisation in type-erased wrappers

Theory

The same trick generalises. Any wrapper that holds "some object of unknown type" (std::function, std::any, std::move_only_function, many Task or Callback classes) can keep a small inline buffer and place the object there with placement new (Ch 14) if it fits. Otherwise it allocates on the heap and stores a pointer.

// the shape of every SBO wrapper
class AnyCallable {
    alignas(std::max_align_t) unsigned char buf_[16];   // inline storage
    void* obj_;                                          // points into buf_ or to the heap
    const Ops* ops_;                                     // invoke, move, destroy for the real type
};

Whether an object goes inline depends on three conditions, all implementation-defined: it is small enough, its alignment fits the buffer, and it passes a type check. The check differs: std::any in both libraries requires a nothrow move constructor, libc++'s std::function requires a nothrow copy constructor, and libstdc++'s std::function requires the callable to be trivially copyable. The reason is the same in every case: moving the wrapper has to move or copy the inline object, and the wrapper's own move constructor is noexcept (heap-stored objects are moved by copying a pointer, which cannot throw).

Measured on GCC 16 with libstdc++ and Clang 22 with libc++ (escaping objects, so nothing is elided):

Wrappersizeof (libstdc++ / libc++)Inline if the stored object is at most
std::function<void()>32 / 3216 bytes on both (libstdc++ also requires it to be trivially copyable, libc++ requires a nothrow copy constructor)
std::any16 / 328 bytes (libstdc++) / 24 bytes (libc++), plus nothrow move
std::string32 / 2415 / 22 characters

A lambda that captures two pointers fits everywhere. A lambda that captures a std::string by value never fits libstdc++'s std::function (not trivially copyable) and always allocates. That is why hot callback paths often use a custom fixed-capacity function type (written in the coding drills) or std::function_ref (C++26), which never owns and never allocates.

Tip

To see whether a wrapper allocates, replace the global operator new with a counting version, as the programs in this chapter do. Make the object escape (store it in a global), and make the counter a std::atomic, or the optimiser may remove the allocation, or the count, and give you a false zero (details in the elision section below).

Inline-storage vectors and fixed arrays

Theory

Mental model. A small vector is a vector with SSO: it reserves room for N elements inside the object and uses the heap only when the size exceeds N. Real implementations are absl::InlinedVector<T, N>, llvm::SmallVector<T, N>, boost::container::small_vector<T, N> and folly::small_vector. A related type with no heap at all is the static or in-place vector: boost::container::static_vector, and std::inplace_vector<T, N> in C++26, which throws (or offers try_push_back) when full.

SmallVector<int, 4> with 3 elements: inline begin size 3 cap 4 10 20 30 free no allocation, data is inside the object after the 5th push_back: spilled to the heap begin size 5 cap 8 inline slots: unused, still paid for heap: 10 20 30 40 50 _ _ _
Inline until it overflows. While size ≤ N the elements live inside the object (on the stack if the object is a local). Past N it behaves like std::vector, and the inline bytes become dead weight.

Designing one

  • Storage. alignas(T) unsigned char buf[N * sizeof(T)], never T buf[N], because that would default-construct N elements and require T to be default constructible. Elements are created with placement new and destroyed by hand (Ch 14).
  • Header. A T* begin that points either at the buffer or at a heap block, plus size and capacity. "Am I inline?" is begin == buffer. LLVM uses 32-bit size and capacity to keep the header at 16 bytes.
  • Growth. When full, allocate 2 × cap, construct the new element first (the argument may refer to an element of the vector itself), then move the old elements across with std::move_if_noexcept semantics, destroy them, and free the old block if it was on the heap.
  • Move is O(N) when inline. You cannot steal a buffer that lives inside the source object. Moving an inline small vector moves every element, can throw if T's move can throw, and invalidates pointers into the source. A heap-backed one can steal the pointer as std::vector does.
  • Copy must not copy the pointer. A copied inline small vector must point at its own buffer. Copying begin verbatim is the most common bug in hand-written versions (see the drills).
  • Choosing N. Use profiling data: pick N so that 90 to 99 percent of instances never spill. A huge N bloats every object and every frame that holds one. LLVM's default SmallVector<T> picks N so that the whole object is about 64 bytes.

Interview trap

"Why does std::vector not have SSO like std::string?" Because the standard forbids it. Container move construction must be constant time, and swap must not invalidate iterators, pointers or references to elements ([container.reqmts]). With inline elements both promises break: moving would be O(N) and the elements would change address. std::string is exempt because [string.require] explicitly allows those invalidations.

std::array vs C arrays vs std::vector

PropertyT a[N]std::array<T, N>std::vector<T>
Storageinline (stack or inside the object)inline, same layout as T[N]heap, 24-byte header
Sizecompile timecompile time, size() is constexprrun time
Copy and assignno assignment, decays to T* when passedvalue semantics, comparablevalue semantics, allocates
Return from a functionnot allowedby valueby value (moves)
Zero sizeill-formed (T a[0] is a GCC extension)allowed, sizeof is still at least 1allowed
Overheadnonenone (an aggregate wrapping T[N])allocation, indirection

Prefer std::array over a C array in new code: it does not decay, it knows its size, and it can be copied, compared and returned. Use std::to_array (C++20) to deduce N, and std::span to pass either one to a function without a template.

alloca and variable-length arrays

Theory

alloca(n) moves sp down by n bytes at run time and returns the address. A VLA (int buf[n]; with a run-time n) does the same with array syntax. Both free the memory when the function returns, and both are attractive because a run-time sized scratch buffer costs a subtraction.

Neither is standard C++. VLAs are standard in C99 and optional in C11; in C++ they are a GCC and Clang extension (rejected by MSVC, flagged by -Wvla, and by Clang's -Wvla-cxx-extension). alloca is a non-standard function from <alloca.h> (_alloca on Windows). A proposed std::dynarray was dropped before C++14.

void parse(std::size_t n) {
    char* scratch = static_cast<char*>(alloca(n));   // no failure report: a big n just crashes
    int counts[n];                                    // VLA: GCC/Clang extension in C++
    // ...
}

The dangers:

  • No failure path. alloca cannot return null or throw. Too large a request silently runs past the guard page, which crashes or, without stack probes, corrupts another mapping. An attacker who controls n controls your stack pointer.
  • Lifetime is the function, not the scope. alloca inside a loop allocates again on every iteration and frees nothing until return, so a loop of a million iterations can exhaust the stack.
  • Inlining changes behaviour. If a function that calls alloca is inlined into a loop, the caller's frame grows on every iteration. Compilers refuse to inline such functions in many cases, which is its own cost.
  • VLAs are not ordinary types. sizeof is evaluated at run time, they cannot be members or template arguments, and they cannot be initialised with = {} in older compilers.

The safe pattern gives the speed in the common case and a bound in the bad case: a fixed-size stack buffer for small inputs and the heap for large ones. That is absl::FixedArray, LLVM's SmallString, and the small vector above.

void parse(std::size_t n) {
    std::array<char, 256> local;
    std::unique_ptr<char[]> big;
    char* scratch = n <= local.size() ? local.data() : (big = std::make_unique_for_overwrite<char[]>(n)).get();
    // use scratch[0 .. n)
}

Heap allocation elision and escape analysis

Theory

The rule. Since C++14 (N3664), [expr.new] allows an implementation to omit a call to a replaceable global allocation function made by a new-expression, or to merge several allocations into one. The storage is then "provided by the implementation", which in practice means a register or a stack slot. This holds even if you replaced operator new with a function that has side effects, such as a counter. The permission covers new-expressions only: a direct call such as ::operator new(16) is an ordinary function call and is not elided by the rule.

What compilers do. Clang and GCC (version 10 onwards) run an escape analysis: if the pointer never leaves the function (it is not stored to memory that outlives the call, not passed to an opaque function, not returned) and the new is paired with a delete on every path, the pair is removed and the object becomes a local. Clang applies the same reasoning to library code that uses __builtin_operator_new, which libc++ does, so even temporary std::vectors and std::strings sometimes vanish.

#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <new>

static std::atomic<int> g_allocs{0};   // atomic: see the elision section
void* operator new(std::size_t n) {
    g_allocs.fetch_add(1, std::memory_order_relaxed);
    if (void* p = std::malloc(n ? n : 1)) return p;
    throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

int main() {
    int* p = new int(41);
    *p += 1;
    std::printf("value %d\n", *p);
    delete p;
    std::printf("operator new calls: %d\n", g_allocs.load());
}

Built with -O1, both GCC 16 and Clang 22 print operator new calls: 0: the allocation was folded into a constant. At -O0 they print 1. Both outputs are conforming, which is why the output is not checked.

GCC goes one step further. By default (-fassume-sane-operators-new-delete, added and enabled by default in GCC 15) it assumes that a replaced operator new does not read or modify any other global state of the program. With a plain static int counter, GCC may therefore read the counter before and after a new and fold the difference to zero, even when the allocation really happens. Every counting program in this chapter uses std::atomic<int> for the counter, which GCC cannot reason away; with a plain int, the SSO program above prints 0 allocations for every length under GCC 16.

Interview trap

"My allocation counter says zero, so my code does not allocate." Not necessarily: it only proves that this build, with this inlining, elided it. One more call to a function the optimiser cannot see (logging, a virtual call) and the object escapes, and the allocation comes back. Do not rely on elision for performance; use a type with inline storage when you need a guarantee.

Java's JIT and Go's compiler run this kind of escape analysis on every allocation. C++ compilers do it only for code the optimiser can see (one function after inlining, or across translation units with LTO), and no compiler moves an object that escapes onto the stack. The guarantee you can rely on is the one you write: a local object, a std::array, or an inline buffer.

Stack buffers for pmr, returning and passing

Theory

A pmr arena on a stack buffer

std::pmr::monotonic_buffer_resource (Ch 17) can start from a buffer you give it. Put that buffer on the stack and every pmr container in the function allocates by bumping a pointer through stack memory, with no delete calls at all: everything is released when the resource goes out of scope.

#include <cstddef>
#include <iostream>
#include <memory_resource>
#include <string>
#include <vector>

int main() {
    alignas(std::max_align_t) std::byte buf[4096];
    std::pmr::monotonic_buffer_resource arena(buf, sizeof buf, std::pmr::null_memory_resource());

    std::pmr::vector<std::pmr::string> words(&arena);   // the strings inherit the arena
    words.reserve(4);
    for (const char* w : {"stack", "buffers", "are", "fast, until they run out of room"})
        words.emplace_back(w);

    std::size_t total = 0;
    for (const auto& w : words) total += w.size();
    std::cout << words.size() << " words, " << total << " chars, no heap\n";
}
4 words, 47 chars, no heap

Two rules. First, choose the upstream deliberately: the default upstream is new_delete_resource(), so an overflowing buffer silently falls back to the heap. Passing null_memory_resource() makes overflow throw std::bad_alloc instead, which is what you want in a latency-critical path where a hidden heap call is a bug. Second, a monotonic resource never reuses memory, so a growing vector leaves every old block behind. Reserve up front.

Returning large objects

Returning a big object by value does not copy it onto the caller's stack and back. The caller reserves space for the result in its own frame and passes a hidden pointer to it; the callee constructs the result directly there. Since C++17 this is guaranteed for prvalues (return T{...};) and allowed, and usually done, for named locals (NRVO). See Ch 23 and Ch 24. So std::array<double, 512> make() costs no copy, but it does put 4 KB on the caller's stack.

Passing small types by value

Both the System V x86-64 ABI and AAPCS64 pass a trivially copyable struct of up to 16 bytes in two registers. So int, double, a pointer, std::string_view, std::span, std::pair<int, int> and a small Point should be passed by value: a const& forces the caller to put the object in memory, adds an indirection, and makes the optimiser worry that the reference aliases something the function writes.

The catch is "trivially copyable". The Itanium C++ ABI (used by GCC and Clang on Linux and macOS) passes any type with a non-trivial copy constructor, move constructor or destructor by invisible reference: the caller builds a temporary in its own frame and passes its address. That includes std::unique_ptr, so passing a unique_ptr by value costs more than passing a raw pointer, even though both hold one pointer. Large or non-trivial types (strings, vectors) are passed by const& when you only read them, and by value only when you would copy them anyway (the sink pattern, Ch 20).

Concept and tricky questions

Drills

ConceptWhy is allocating a local array faster than new-ing the same array? Give at least three reasons.

Answer

Answer. The stack allocation is a compile-time-sized pointer bump fused with the rest of the frame (one sub sp), it is freed by one add sp with no bookkeeping, it needs no synchronisation because each thread owns its stack, and the memory is almost always hot in L1 cache and already mapped.

Why. new calls operator new, which calls malloc: a size-class lookup, a free-list pop from a thread cache, and on a miss a trip to a shared arena with locks or a system call. delete repeats the lookup. A fresh heap block may also be cold in cache. The common incomplete answer is "the heap is slow because of system calls"; most allocations never make a system call, and the fast path still costs tens of nanoseconds against roughly zero for the stack.

ConceptWhat is the small string optimisation, and how many characters fit inline in libstdc++ and in libc++ on a 64-bit target?

Answer

Answer. SSO stores short strings inside the std::string object instead of on the heap. libstdc++ (GCC): sizeof 32, up to 15 characters inline. libc++ (Clang): sizeof 24, up to 22 characters inline. MSVC: 32 bytes, 15 characters.

Why. libstdc++ keeps a pointer, a size and a 16-byte union that holds either the characters (15 plus the terminating NUL) or the heap capacity. libc++ overlays the whole 24 bytes: one byte holds the size and a mode bit, the other 23 hold up to 22 characters and the NUL. The figures are measured in this chapter with a counting operator new. A common wrong answer is "SSO is required by the standard"; it is permitted, not required, and every number is implementation-defined.

TrickyWhy does std::vector not use a small-buffer optimisation, when std::string does?

Answer

Answer. The standard's container requirements forbid it. Moving a container must be constant time, and swap must not invalidate references, pointers or iterators to elements ([container.reqmts]). Inline elements would have to be moved one by one (O(N)) and would change address.

Why. With a heap buffer, std::vector's move steals the pointer, so an iterator into the source is now an iterator into the destination. Code relies on that, for example keeping pointers into a vector that is then moved into a member. std::string has an explicit exemption in [string.require]: passing a string to a library function that takes a non-const reference may invalidate its iterators. People often answer "because elements may be big"; size is a reason not to choose a large N, but the real blocker is the standard's guarantees. Libraries that want SSO for vectors provide separate types (SmallVector, InlinedVector).

TrickyA std::string_view points into string a. You then write std::string b = std::move(a);. Is the view still valid, and does the answer depend on the string's length?

Answer

Answer. Formally, the view must be treated as invalid. In practice it depends on the length: for a long string the heap buffer moves to b, so the view still points at the text (now owned by b); for a short string the characters were copied into b's own inline buffer and the view still points into a, which is now empty.

Why. [string.require] lets a move invalidate pointers into the source, precisely so that SSO is possible. Code tested only with long strings "works", then breaks for short names. The wrong answer is "moves steal the buffer, so pointers follow". They follow only when there is a heap buffer to steal.

TrickyCan delete new int(5); run without calling operator new at all, even if you replaced operator new with a function that increments a counter?

Answer

Answer. Yes. Since C++14, [expr.new] permits an implementation to omit calls to replaceable global allocation functions from new-expressions, or to merge them, and to provide the storage another way. GCC and Clang both do this at -O1 when the pointer does not escape.

Why. The rule (from paper N3664) exists so that compilers can turn short-lived heap objects into locals. Because the permission is explicit, the side effects inside your replacement function are not "observable behaviour" that must be preserved. The common wrong answer is "a replaced operator new has side effects, so the compiler must call it". Direct calls such as ::operator new(4) are ordinary function calls and are not covered.

ConceptWhat are the default stack sizes on Linux and macOS, for the main thread and for other threads, and how do you give a std::thread a bigger stack?

Answer

Answer. Linux (glibc): main thread 8 MB (the RLIMIT_STACK soft limit, ulimit -s shows 8192), other threads default to the same value read at startup, usually 8 MB. musl uses 128 KB for threads. macOS: 8 MB main, 512 KB for secondary threads. std::thread cannot set a stack size; use pthread_attr_setstacksize with pthread_create or boost::thread::attributes.

Why. The main thread's stack is grown by the kernel on demand up to the rlimit, while each thread stack is one fixed mmap with a guard page. That is why code that recurses happily in main can crash in a worker on macOS or Alpine. The standard library deliberately has no portable stack-size knob. A common wrong answer is "the stack grows until memory runs out"; it is capped by the rlimit and, for threads, by the fixed mapping.

TrickyDoes a stack overflow throw an exception you can catch, such as std::bad_alloc?

Answer

Answer. No. On Linux and macOS the overflowing access hits a guard page and the kernel delivers SIGSEGV (sometimes SIGBUS on macOS). No C++ exception is thrown, no destructors run, and the default action kills the process.

Why. The C++ standard says nothing about stack exhaustion; it is outside the abstract machine. A signal handler cannot run on the stack that just overflowed, so you need sigaltstack plus SA_ONSTACK even to log the crash, and you cannot safely resume. Windows raises a structured exception (EXCEPTION_STACK_OVERFLOW), which is still not a C++ exception. The fix is to bound depth, not to catch.

TrickyIs int n = read(); int buf[n]; valid C++?

Answer

Answer. No. Array bounds in C++ must be constant expressions. Variable-length arrays are a C99 feature (optional since C11) that GCC and Clang accept in C++ as an extension; MSVC rejects them, and -pedantic, -Wvla or Clang's -Wvla-cxx-extension flag them.

Why. VLAs break the type system (their sizeof is a run-time value, they cannot be members or template arguments), they have no failure path when n is too large, and an attacker-controlled n moves your stack pointer. A proposed std::dynarray was dropped. People say "it compiles, so it is legal"; it compiles because of an extension. Use std::array with a bound and a heap fallback, or a small vector.

TrickyMoving a std::vector is O(1) and noexcept. Is moving an llvm::SmallVector<T, 8> or absl::InlinedVector<T, 8> the same?

Answer

Answer. Only when the source has spilled to the heap. When the elements are inline, moving has to move-construct each element into the destination's inline buffer: O(N), and it can throw if T's move constructor can throw.

Why. You cannot transfer ownership of bytes that are physically inside another object. This also means pointers into an inline source are not valid in the destination. As a result, putting small vectors inside a std::vector makes reallocation more expensive, and if the move is not noexcept, std::vector will copy them instead (move_if_noexcept). The wrong answer assumes every move is a pointer steal.

ConceptWhen does std::function allocate, and how would you avoid it on a hot path?

Answer

Answer. It allocates when the callable does not fit its small buffer or does not meet the implementation's conditions for inline storage. Measured here: both libstdc++ and libc++ store up to 16 bytes inline; libstdc++ also needs the callable to be trivially copyable (the __stored_locally test in std_function.h), libc++ needs a nothrow copy constructor. A lambda that captures one 8-byte object with a user-written copy constructor therefore allocates in libstdc++ even though it is small. A lambda capturing a std::string therefore always allocates in libstdc++.

Why. std::function is a type-erased owner that must be copyable, so it either places the callable in its buffer with placement new or holds a pointer to a heap copy. Avoid the allocation by keeping captures small (capture a pointer to a context struct), by using a template parameter instead of type erasure, by using std::function_ref (C++26) for non-owning callbacks, or by writing a fixed-capacity function type that refuses at compile time to hold anything larger (see the coding drills).

ConceptWhat is a red zone, which platforms have one, and why does the Linux kernel disable it?

Answer

Answer. A red zone is a region just below the stack pointer that the ABI guarantees asynchronous code will not overwrite, so leaf functions can use it for locals without adjusting sp. System V x86-64 has 128 bytes, and so does Apple's arm64 ABI. Generic AAPCS64 (Linux on arm64) and Windows x64 have none. The Linux kernel builds with -mno-red-zone because interrupts and exceptions in kernel mode push state onto the current stack and would trash it.

Why. In user space, signal handlers are delivered below the red zone, which is how the ABI keeps its promise. In the kernel there is no such adjustment. The saving is small (one sub and one add in a leaf), which is why some ABIs do without it.

ConceptWhat problem do stack probes and -fstack-clash-protection solve?

Answer

Answer. They stop a large frame, alloca or VLA from moving sp past the guard page in one jump. The compiler touches every page of a big allocation in order, so the guard page is always hit and the program faults safely.

Why. A guard page only works if something touches it. A 64 KB frame on a stack with a 4 KB guard can skip straight over it into the heap or another thread's stack, and later writes corrupt that memory silently (the 2017 "stack clash" exploits). Windows has always done this through __chkstk. The cost is a few instructions per page in functions with large frames. Mentioning the Linux kernel's enlarged stack guard gap (256 pages since 2017) earns extra credit.

TrickyA colleague changes void f(int x) to void f(const int& x) "to avoid a copy". Faster or slower? And is passing std::unique_ptr<T> by value as cheap as passing T*?

Answer

Answer. Slower or equal. An int by value travels in a register; by const& the caller must store it in memory, pass its address, and the callee must load it, and the optimiser must assume the reference may alias memory the function writes. And no: unique_ptr by value is more expensive than a raw pointer under the Itanium ABI.

Why. System V x86-64 and AAPCS64 pass trivially copyable objects of up to 16 bytes in registers, so int, string_view, span and small structs belong by value. A type with a non-trivial destructor or copy/move constructor, such as unique_ptr, is passed by invisible reference: the caller materialises a temporary in its frame and passes its address, and the caller (not the callee) destroys it. "References are always cheaper" is the wrong instinct carried over from large objects.

ConceptWhy is std::array<int, 4> faster than a std::vector<int> holding 4 elements, when indexing either one is the same pointer arithmetic? When does the vector win?

Answer

Answer. The array has no allocation, no deallocation and no indirection, and its size is a compile-time constant. The vector pays a call to operator new on construction and operator delete on destruction (tens of nanoseconds, against nothing), keeps its elements in a separate heap block reached through a pointer, and carries its size at run time. The vector wins when the size is large or unknown (the array would blow the stack or need a worst-case bound) and when you move it: moving a vector steals one pointer, moving an array copies every element.

Why. With std::array the 16 bytes live inside the enclosing object or frame, usually in an L1-hot cache line, and often entirely in registers after optimisation. Because N is in the type, the compiler can fully unroll and vectorise loops over it, and copying is a 16-byte memcpy. A std::vector<std::array<int, 4>> is one contiguous block; a std::vector<std::vector<int>> is one allocation per row and a pointer chase per row. The common wrong answer is "vector::operator[] is slower"; it is not, once the data pointer is loaded. The cost is in creating, destroying and reaching the storage.

ConceptWhy prefer std::array<T, N> over T[N]? Is there any run-time cost?

Answer

Answer. No run-time cost: std::array is an aggregate wrapping T[N] with the same layout. It gains value semantics (copy, assign, compare, return from functions), it never decays to a pointer, and it carries its size in the type (size() is constexpr).

Why. C arrays decay to T* when passed, so sizeof inside the callee gives the pointer size and bounds information is lost; they cannot be assigned or returned. std::array also supports at() with bounds checking, structured bindings, and zero size. Pass it as std::span<const T> to accept arrays of any length. Someone usually claims "std::array adds overhead like vector"; it has no heap, no header and no indirection.

Predict the output and find the bug

Drills

OutputWhat does this print on a 64-bit target?

#include <array>
#include <cstdio>

static void f(int a[10]) { std::printf("%zu\n", sizeof(a)); }

int main() {
    int a[10] = {};
    std::array<int, 10> b{};
    std::printf("%zu %zu\n", sizeof a, sizeof b);
    f(a);
    std::printf("%zu\n", sizeof a / sizeof a[0]);
}
Answer
40 40
8
10

Why. int a[10] and std::array<int, 10> have the same 40-byte layout: std::array is an aggregate around a C array with no extra members. Inside f, the parameter int a[10] is adjusted to int* a ([dcl.fct]), so sizeof gives the pointer size, 8, and the 10 is ignored. Both compilers warn (-Wsizeof-array-argument). The usual wrong answer is 40 for the call. The sizeof a / sizeof a[0] idiom only works where a is still an array; std::size(a) refuses to compile on a pointer, which is why it is safer.

OutputHow many heap allocations does each assignment to std::function make?

#include <atomic>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <new>

static std::atomic<int> g_allocs{0};   // atomic: see the elision section
void* operator new(std::size_t n) {
    g_allocs.fetch_add(1, std::memory_order_relaxed);
    if (void* p = std::malloc(n ? n : 1)) return p;
    throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

static std::function<int()> g_fn;   // global, so the callable escapes

template <std::size_t N>
static void store() {
    std::array<char, N> capture{};
    int before = g_allocs.load();
    g_fn = [capture] { return int(capture[0]); };
    std::printf("capture of %2zu bytes: %d\n", N, g_allocs.load() - before);
}

int main() {
    store<8>();
    store<16>();
    store<24>();
}
Answer
capture of  8 bytes: 0
capture of 16 bytes: 0
capture of 24 bytes: 1

Why. Both libstdc++ and libc++ have a 32-byte std::function whose small buffer holds a callable of up to 16 bytes. libstdc++ uses a 16-byte buffer next to two function pointers. libc++ uses a 24-byte buffer, but it stores a small object that has its own vtable pointer, which leaves 16 bytes for the lambda. A lambda capturing a std::array<char, N> by value is trivially copyable and nothrow copyable, so only size decides. The global target matters: with a local std::function the optimiser can remove the heap allocation entirely and print 0 for all three, which is legal under [expr.new]. The exact threshold is implementation-defined; MSVC's buffer is larger.

OutputThis uses a GCC/Clang extension. What does it print when run with no arguments?

#include <cstdio>

int main(int argc, char**) {
    int n = argc + 2;
    int v[n];                      // VLA: not standard C++
    for (int i = 0; i < n; ++i) v[i] = i * i;
    std::printf("%zu %d\n", sizeof v, v[n - 1]);
}
Answer
12 4

Why. With no arguments argc is 1, so n is 3. For a VLA, sizeof is evaluated at run time, giving 3 × 4 = 12, and the last element is 2 × 2 = 4. In standard C++ sizeof is always a compile-time constant, which is one reason VLAs do not fit the language. GCC accepts this silently unless you pass -Wvla or -pedantic; Clang warns by default with -Wvla-cxx-extension. MSVC refuses to compile it.

OutputWhich of these std::any assignments allocate?

#include <atomic>
#include <any>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <new>

static std::atomic<int> g_allocs{0};   // atomic: see the elision section
void* operator new(std::size_t n) {
    g_allocs.fetch_add(1, std::memory_order_relaxed);
    if (void* p = std::malloc(n ? n : 1)) return p;
    throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

static std::any g_any;

template <std::size_t N>
static void store() {
    int before = g_allocs.load();
    g_any = std::array<char, N>{};
    std::printf("any holding %2zu bytes: %d allocation(s)\n", N, g_allocs.load() - before);
}

int main() {
    store<8>();
    store<32>();
}
Answer
any holding  8 bytes: 0 allocation(s)
any holding 32 bytes: 1 allocation(s)

Why. std::any keeps small, nothrow-movable objects in an internal buffer. The buffer size is where the libraries differ: libstdc++'s std::any is 16 bytes and stores inline only objects of at most one pointer (8 bytes); libc++'s is 32 bytes and stores up to 24 bytes inline. The program prints only the cases where they agree. A 16-byte object would allocate on libstdc++ and not on libc++. The standard only "encourages" implementations to avoid allocation for small objects such as int ([any.class]).

Find the bugThis passes every test with long names and fails for short ones. Why?

#include <iostream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

struct Registry {
    std::vector<std::string> names;
    std::vector<std::string_view> index;
    void add(std::string name) {
        index.push_back(name);              // view into the parameter
        names.push_back(std::move(name));   // ...which is then moved from
    }
};

int main() {
    Registry r;
    r.names.reserve(8);
    r.add("a-rather-long-name-that-lives-on-the-heap");
    r.add("bob");
    for (std::size_t i = 0; i < r.index.size(); ++i)
        std::cout << "view " << i << (r.index[i] == r.names[i] ? " ok\n" : " broken\n");
}
Answer

Answer. The view is taken from name before it is moved. For the long name, the move transfers the heap buffer into the vector, so the view happens to point at live text owned by names.back(). For "bob", SSO copies the three characters into the vector element's inline buffer; the view still points into the parameter name, which is moved-from and then destroyed when add returns. Reading it is undefined behaviour. On the test machine the Clang build printed view 1 broken, while the GCC build printed ok because the dead parameter's bytes had not been overwritten yet: the worst kind of bug, one that passes tests.

Fix. Take the view from the stored string: names.push_back(std::move(name)); index.push_back(names.back());, and make sure names never reallocates (reserve, or use std::deque), or store indices instead of views. The lesson: a move does not promise that pointers into the source follow the data, and with SSO they visibly do not.

Find the bugA tiny inline-buffer name type. What is wrong, and which tool catches it?

#include <cstdio>
#include <cstring>

struct Name {
    char buf[16];
    char* data;   // points at buf
    explicit Name(const char* s) : data(buf) {
        std::strncpy(buf, s, sizeof buf - 1);
        buf[sizeof buf - 1] = '\0';
    }
};

int main() {
    Name kept("placeholder");
    {
        Name temp("alice");
        kept = temp;               // implicit copy assignment
    }
    std::puts(std::strcmp(kept.data, "alice") == 0 ? "alice" : "corrupted");
}
Answer

Answer. The implicit copy assignment copies data verbatim, so kept.data points into temp.buf. When the inner scope ends, temp is gone and the strcmp reads dead stack memory. AddressSanitizer reports stack-use-after-scope.

Why. Any type with inline storage and a pointer to it is self-referential, so the compiler-generated copy and move operations are wrong for it (rule of three/five, Ch 1). Fix it by writing copy operations that set data = buf and copy the characters, or better, by not storing the pointer at all: compute it (const char* c_str() const { return buf; }). This is exactly the bug libstdc++'s std::string has to avoid in its move constructor, and the classic bug in hand-written small vectors.

Find the bugWhy does this crash with GCC, while the Clang build seems to work?

#include <cstdio>

static const char* greeting(int id) {
    char buf[32];
    std::snprintf(buf, sizeof buf, "hello %d", id);
    return buf;
}

int main() {
    const char* g = greeting(7);
    std::printf("%c\n", g[0]);
}
Answer

Answer. buf lives in greeting's frame, which is released by the epilogue's add sp. The returned pointer dangles, and the next call (printf) reuses that stack. Both compilers warn (-Wreturn-local-addr in GCC, -Wreturn-stack-address in Clang). GCC goes further: because the result is undefined anyway, it returns a null pointer, so g[0] crashes immediately. Clang returns the stale address; on the test machine it even printed the expected h, because nothing had overwritten the dead frame yet. Both behaviours are allowed, since the program has undefined behaviour.

Fix. Return a value: std::string (likely within SSO, so no allocation), or std::array<char, 32>, which is returned in place by copy elision. Stack memory never outlives its function; the same applies to alloca.

Find the bugThis builds a list of a million nodes and crashes when it is destroyed. Why, given that there is no recursion in the source?

#include <cstdio>
#include <memory>
#include <utility>

struct Node {
    int value;
    std::unique_ptr<Node> next;
};

int main() {
    std::unique_ptr<Node> head;
    for (int i = 0; i < 1'000'000; ++i)
        head = std::make_unique<Node>(Node{i, std::move(head)});
    std::puts("built");
    head.reset();
    std::puts("destroyed");
}
Answer

Answer. The recursion is in the generated destructors. Destroying node 0 destroys its member next, whose deleter destroys node 1, and so on: one or more stack frames per node, a million deep. That exceeds the 8 MB main-thread stack, and the process dies with SIGSEGV; ASan reports stack-overflow in ~unique_ptr. It is not a tail call, because operator delete runs after the destructor returns.

Fix. Give the list an iterative destructor that detaches each node before it dies:

~List() {
    while (head) head = std::move(head->next);   // old head is destroyed with next already null
}

The same issue hits trees built from unique_ptr children, recursive JSON values and deep expression trees. Interviewers like it because the source contains no recursive call.

Find the bugWhy does this not compile, and what is the smallest fix?

#include <cstdio>

static int (make())[3] {
    int r[3] = {1, 2, 3};
    return r;
}

int main() {
    int a[3] = {1, 2, 3};
    int b[3];
    b = a;
    std::printf("%d\n", b[2]);
}
Answer

Answer. Two errors. A function cannot return an array type ([dcl.fct]), and arrays are not assignable: in b = a the left side is an array lvalue, which is not modifiable, and the right side decays to int*.

Fix. Use std::array<int, 3>: it is an aggregate class, so the implicit copy assignment copies all three elements, and it can be returned by value (constructed in the caller's frame by copy elision). C arrays inside a struct are copied with the struct, which is the only reason std::array works at all: it is a struct with an array member.

Find the bugThe author wanted parsing to stay off the heap entirely. Does it?

#include <cstddef>
#include <cstdio>
#include <memory_resource>
#include <vector>

// Counts what reaches the default resource, standing in for "the heap".
struct CountingResource : std::pmr::memory_resource {
    int calls = 0;
    void* do_allocate(std::size_t n, std::size_t a) override {
        ++calls;
        return std::pmr::new_delete_resource()->allocate(n, a);
    }
    void do_deallocate(void* p, std::size_t n, std::size_t a) override {
        std::pmr::new_delete_resource()->deallocate(p, n, a);
    }
    bool do_is_equal(const memory_resource& o) const noexcept override { return this == &o; }
};

static std::size_t parse(int n) {
    alignas(std::max_align_t) std::byte buf[256];
    std::pmr::monotonic_buffer_resource arena(buf, sizeof buf);
    std::pmr::vector<int> tokens(&arena);
    for (int i = 0; i < n; ++i) tokens.push_back(i);
    return tokens.size();
}

int main() {
    CountingResource heap;
    std::pmr::set_default_resource(&heap);
    std::size_t n = parse(100);
    std::printf("tokens %zu, heap used: %s\n", n, heap.calls > 0 ? "yes" : "no");
    std::pmr::set_default_resource(nullptr);
}
Answer
tokens 100, heap used: yes

Answer. No. 100 ints need a 512-byte block at the last growth, and the earlier blocks (4 + 8 + ... + 256 bytes) are never reused, so the 256-byte buffer runs out early. The resource then asks its upstream. The constructor without an upstream argument uses get_default_resource(), which is normally new_delete_resource() and calls operator new. The program installs a counting default resource to make that visible. (Counting with a replaced global operator new is not reliable for this one: on macOS, GCC's libstdc++ dylib calls its own operator new internally.)

Fix. Decide what overflow should mean. If the heap is forbidden, pass std::pmr::null_memory_resource() as the upstream so overflow throws bad_alloc and tests catch it. Either way, call tokens.reserve(n) so the monotonic arena is not wasted on abandoned blocks, and size the buffer from measurements.

Write the code

Coding

CodeImplement SmallVector<T, N>: a vector that stores up to N elements inside the object and spills to the heap beyond that. Support push_back (copy and move), emplace_back, pop_back, operator[], size, capacity, iteration, is_inline(), and correct copy, move and destruction. T need not be default constructible. v.push_back(v[0]) must work even when it triggers growth.

Answer

Approach. Keep raw, aligned storage for N elements (alignas(T) unsigned char[N * sizeof(T)]) and a T* that points either at it or at a heap block. Construct elements with placement new and destroy them with std::destroy. On growth, allocate double the capacity, construct the new element in the new block first (so an argument that refers to an existing element is still alive), then move the old elements across and free the old block if it was on the heap. Moving steals the pointer only when the source is on the heap; an inline source must have its elements moved one by one.

#include <cstddef>
#include <iostream>
#include <memory>
#include <new>
#include <string>
#include <type_traits>
#include <utility>

template <class T, std::size_t N>
class SmallVector {
    static_assert(N > 0, "use std::vector when N == 0");
    static_assert(alignof(T) <= __STDCPP_DEFAULT_NEW_ALIGNMENT__, "over-aligned T needs aligned new");

    alignas(T) unsigned char buf_[N * sizeof(T)];
    T* data_;
    std::size_t size_ = 0;
    std::size_t cap_ = N;

    T* inline_data() { return reinterpret_cast<T*>(buf_); }
    void release_heap() {
        if (!is_inline()) ::operator delete(data_);
        data_ = inline_data();
        cap_ = N;
    }
    void steal(SmallVector& o) {
        if (o.is_inline()) {   // elements live inside o: move them one by one
            std::uninitialized_move(o.data_, o.data_ + o.size_, data_);
            size_ = o.size_;
            o.clear();
        } else {               // heap block: take the pointer
            data_ = o.data_; size_ = o.size_; cap_ = o.cap_;
            o.data_ = o.inline_data(); o.size_ = 0; o.cap_ = N;
        }
    }

public:
    SmallVector() : data_(inline_data()) {}
    SmallVector(const SmallVector& o) : SmallVector() {
        reserve(o.size_);
        std::uninitialized_copy(o.data_, o.data_ + o.size_, data_);
        size_ = o.size_;
    }
    SmallVector(SmallVector&& o) noexcept(std::is_nothrow_move_constructible_v<T>) : SmallVector() { steal(o); }
    SmallVector& operator=(const SmallVector& o) {
        if (this != &o) {
            clear();
            reserve(o.size_);
            std::uninitialized_copy(o.data_, o.data_ + o.size_, data_);
            size_ = o.size_;
        }
        return *this;
    }
    SmallVector& operator=(SmallVector&& o) noexcept(std::is_nothrow_move_constructible_v<T>) {
        if (this != &o) { clear(); release_heap(); steal(o); }
        return *this;
    }
    ~SmallVector() { clear(); release_heap(); }

    bool is_inline() const { return static_cast<const void*>(data_) == static_cast<const void*>(buf_); }
    std::size_t size() const { return size_; }
    std::size_t capacity() const { return cap_; }
    T& operator[](std::size_t i) { return data_[i]; }
    const T& operator[](std::size_t i) const { return data_[i]; }
    T* begin() { return data_; }
    T* end() { return data_ + size_; }
    const T* begin() const { return data_; }
    const T* end() const { return data_ + size_; }

    void clear() { std::destroy(data_, data_ + size_); size_ = 0; }
    void pop_back() { std::destroy_at(data_ + --size_); }

    void reserve(std::size_t n) {
        if (n <= cap_) return;
        T* nd = static_cast<T*>(::operator new(n * sizeof(T)));
        std::uninitialized_move(data_, data_ + size_, nd);   // assumes T's move does not throw
        std::destroy(data_, data_ + size_);
        if (!is_inline()) ::operator delete(data_);
        data_ = nd;
        cap_ = n;
    }

    template <class... A>
    T& emplace_back(A&&... args) {
        if (size_ < cap_) {
            T* p = ::new (static_cast<void*>(data_ + size_)) T(std::forward<A>(args)...);
            ++size_;
            return *p;
        }
        std::size_t ncap = cap_ * 2;
        T* nd = static_cast<T*>(::operator new(ncap * sizeof(T)));
        T* p;
        try {   // build the new element first: args may refer to an element of *this
            p = ::new (static_cast<void*>(nd + size_)) T(std::forward<A>(args)...);
        } catch (...) {
            ::operator delete(nd);
            throw;
        }
        std::uninitialized_move(data_, data_ + size_, nd);
        std::destroy(data_, data_ + size_);
        if (!is_inline()) ::operator delete(data_);
        data_ = nd;
        cap_ = ncap;
        ++size_;
        return *p;
    }
    void push_back(const T& v) { emplace_back(v); }
    void push_back(T&& v) { emplace_back(std::move(v)); }
};

template <class V>
static void show(const char* label, const V& v) {
    std::cout << label << ':';
    for (const auto& x : v) std::cout << ' ' << x;
    std::cout << " (size " << v.size() << ", " << (v.is_inline() ? "inline" : "heap") << ")\n";
}

int main() {
    SmallVector<std::string, 2> v;
    v.push_back("a");
    v.push_back("b");
    show("two", v);
    v.push_back("c");
    v.push_back(v[0]);   // aliasing an element during growth
    show("four", v);

    SmallVector<std::string, 2> copy = v;
    show("copy", copy);
    SmallVector<std::string, 2> moved = std::move(v);
    show("moved", moved);
    std::cout << "source after move: size " << v.size() << '\n';

    SmallVector<std::string, 2> s1;
    s1.push_back("x");
    SmallVector<std::string, 2> s2 = std::move(s1);
    show("small move", s2);
    s2 = copy;
    s2.pop_back();
    show("assigned", s2);
}
two: a b (size 2, inline)
four: a b c a (size 4, heap)
copy: a b c a (size 4, heap)
moved: a b c a (size 4, heap)
source after move: size 0
small move: x (size 1, inline)
assigned: a b c (size 3, heap)

Complexity and pitfalls. Amortised O(1) push_back; move is O(1) for a heap-backed source and O(size) for an inline one. Pitfalls: copying data_ verbatim in the copy constructor (the copy would point into the source's buffer), declaring T buf[N] (constructs N elements), forgetting to reset a moved-from heap source to its own inline buffer, and growing before constructing the new element, which makes v.push_back(v[0]) read a destroyed object. A production version also uses std::move_if_noexcept during growth for the strong guarantee, supports over-aligned T with std::align_val_t, and adds insert, erase and resize.

Estimated rating: ~1900 · manual lifetimes, aliasing during growth and two different move paths

CodeWrite a string class with a small string optimisation in the libstdc++ style: a pointer, a size, and a 16-byte union holding either up to 15 characters plus NUL or the heap capacity. Provide construction from const char*, copy and move (constructor and assignment), append (which must work when the argument points into the string itself), c_str, size, capacity and is_small.

Answer

Approach. The pointer always points at the characters, so c_str() needs no branch; "small" means ptr_ == local_. The move constructor is where SSO bites: for a small source you must copy the characters and point at your own buffer, never copy o.ptr_. For a heap source you steal the pointer and reset the source to its empty inline state. append grows geometrically and copies the new characters into the new block before freeing the old one, because the argument may live in the old block.

#include <cstddef>
#include <cstring>
#include <iostream>
#include <utility>

class SsoString {
    static constexpr std::size_t kInline = 15;   // plus the NUL
    char* ptr_;                 // points at local_ or at a heap block
    std::size_t size_ = 0;
    union {
        char local_[kInline + 1];
        std::size_t cap_;       // heap capacity, valid only when !is_small()
    };

    void init(const char* s, std::size_t n) {
        if (n <= kInline) {
            ptr_ = local_;
        } else {
            ptr_ = new char[n + 1];
            cap_ = n;
        }
        std::memcpy(ptr_, s, n);
        ptr_[n] = '\0';
        size_ = n;
    }

    void take(SsoString& o) noexcept {
        size_ = o.size_;
        if (o.is_small()) {
            ptr_ = local_;                              // never copy o.ptr_ here
            std::memcpy(local_, o.local_, o.size_ + 1);
        } else {
            ptr_ = o.ptr_;
            cap_ = o.cap_;
            o.ptr_ = o.local_;
        }
        o.size_ = 0;
        o.local_[0] = '\0';
    }

public:
    SsoString() : ptr_(local_) { local_[0] = '\0'; }
    SsoString(const char* s) { init(s, std::strlen(s)); }
    SsoString(const SsoString& o) { init(o.ptr_, o.size_); }
    SsoString(SsoString&& o) noexcept { take(o); }
    SsoString& operator=(SsoString&& o) noexcept {
        if (this != &o) {
            if (!is_small()) delete[] ptr_;
            take(o);
        }
        return *this;
    }
    SsoString& operator=(const SsoString& o) {
        if (this != &o) *this = SsoString(o);   // copy, then move-assign
        return *this;
    }
    ~SsoString() { if (!is_small()) delete[] ptr_; }

    bool is_small() const { return ptr_ == local_; }
    std::size_t size() const { return size_; }
    std::size_t capacity() const { return is_small() ? kInline : cap_; }
    const char* c_str() const { return ptr_; }

    void append(const char* s, std::size_t n) {
        std::size_t need = size_ + n;
        if (need > capacity()) {
            std::size_t ncap = need > 2 * capacity() ? need : 2 * capacity();
            char* p = new char[ncap + 1];
            std::memcpy(p, ptr_, size_);
            std::memcpy(p + size_, s, n);   // s may point into the old buffer: copy before freeing
            if (!is_small()) delete[] ptr_;
            ptr_ = p;
            cap_ = ncap;                    // overwrites local_, which is no longer in use
        } else {
            std::memmove(ptr_ + size_, s, n);
        }
        size_ = need;
        ptr_[size_] = '\0';
    }
    SsoString& operator+=(const char* s) { append(s, std::strlen(s)); return *this; }
};

static void show(const SsoString& s) {
    std::cout << '"' << s.c_str() << "\" size " << s.size() << " cap " << s.capacity()
              << (s.is_small() ? " inline\n" : " heap\n");
}

int main() {
    SsoString a = "hello";
    show(a);
    a += ", world!!!";       // 15 chars: still inline
    show(a);
    a += " and more";        // spills to the heap
    show(a);

    SsoString small = "tiny";
    SsoString moved = std::move(small);
    show(moved);
    show(small);

    SsoString big = moved;
    big = a;                 // copy assignment
    show(big);
    a.append(a.c_str(), a.size());   // self-append that reallocates
    show(a);
}
"hello" size 5 cap 15 inline
"hello, world!!!" size 15 cap 15 inline
"hello, world!!! and more" size 24 cap 30 heap
"tiny" size 4 cap 15 inline
"" size 0 cap 15 inline
"hello, world!!! and more" size 24 cap 24 heap
"hello, world!!! and morehello, world!!! and more" size 48 cap 60 heap

Complexity and pitfalls. Amortised O(1) per appended character, O(n) copy, O(1) move (copying at most 16 bytes). Pitfalls: copying the self-pointer in a move or copy (the dangling-buffer bug from the reading drills), freeing the old buffer before copying a self-referencing argument, reading cap_ while the union holds characters (only read it when !is_small()), and forgetting the NUL. The copy assignment reuses the move assignment through a temporary, which gives the strong exception guarantee.

Estimated rating: ~1700 · union with an active-member rule, self-pointer and self-append aliasing

CodeImplement InplaceFunction<R(Args...), Cap>: a move-only, type-erased callable that never allocates. A callable that is larger than Cap, over-aligned, or not nothrow movable must be rejected at compile time. Calling an empty one throws std::bad_function_call. It must accept move-only lambdas.

Answer

Approach. Store the callable in an aligned byte buffer with placement new, and keep a pointer to a static table of three function pointers (invoke, move, destroy) instantiated once per callable type. That table is a hand-written vtable, so the object is Cap + 8 bytes with no heap and no virtual functions. All checks are static_asserts in the converting constructor, so an oversized capture is a compile error rather than a hidden allocation. Being move-only removes the need for a copy operation, which is what makes move-only captures possible (the same reason std::move_only_function exists in C++23).

#include <cstddef>
#include <cstdio>
#include <functional>
#include <memory>
#include <new>
#include <string>
#include <type_traits>
#include <utility>

template <class Sig, std::size_t Cap = 32>
class InplaceFunction;

template <class R, class... Args, std::size_t Cap>
class InplaceFunction<R(Args...), Cap> {
    struct Ops {
        R (*invoke)(void*, Args&&...);
        void (*move)(void* dst, void* src) noexcept;   // move-construct into dst, destroy src
        void (*destroy)(void*) noexcept;
    };
    template <class F>
    static constexpr Ops ops_for{
        [](void* p, Args&&... a) -> R { return (*static_cast<F*>(p))(std::forward<Args>(a)...); },
        [](void* dst, void* src) noexcept {
            ::new (dst) F(std::move(*static_cast<F*>(src)));
            static_cast<F*>(src)->~F();
        },
        [](void* p) noexcept { static_cast<F*>(p)->~F(); },
    };

    alignas(std::max_align_t) unsigned char buf_[Cap];
    const Ops* ops_ = nullptr;

public:
    InplaceFunction() = default;

    template <class F, class D = std::decay_t<F>,
              class = std::enable_if_t<!std::is_same_v<D, InplaceFunction>>>
    InplaceFunction(F&& f) {
        static_assert(sizeof(D) <= Cap, "callable too large for InplaceFunction");
        static_assert(alignof(D) <= alignof(std::max_align_t), "callable over-aligned");
        static_assert(std::is_nothrow_move_constructible_v<D>, "callable must be nothrow movable");
        static_assert(std::is_invocable_r_v<R, D&, Args...>, "wrong signature");
        ::new (static_cast<void*>(buf_)) D(std::forward<F>(f));
        ops_ = &ops_for<D>;
    }
    InplaceFunction(InplaceFunction&& o) noexcept : ops_(o.ops_) {
        if (ops_) { ops_->move(buf_, o.buf_); o.ops_ = nullptr; }
    }
    InplaceFunction& operator=(InplaceFunction&& o) noexcept {
        if (this != &o) {
            reset();
            if (o.ops_) { o.ops_->move(buf_, o.buf_); ops_ = o.ops_; o.ops_ = nullptr; }
        }
        return *this;
    }
    InplaceFunction(const InplaceFunction&) = delete;
    InplaceFunction& operator=(const InplaceFunction&) = delete;
    ~InplaceFunction() { reset(); }

    void reset() noexcept {
        if (ops_) { ops_->destroy(buf_); ops_ = nullptr; }
    }
    explicit operator bool() const noexcept { return ops_ != nullptr; }
    R operator()(Args... a) {
        if (!ops_) throw std::bad_function_call();
        return ops_->invoke(buf_, std::forward<Args>(a)...);
    }
};

int main() {
    int base = 10;
    InplaceFunction<int(int)> add = [base](int x) { return base + x; };
    std::printf("add(5) = %d\n", add(5));

    auto owner = std::make_unique<std::string>("owned by the lambda");
    InplaceFunction<std::size_t()> len = [p = std::move(owner)] { return p->size(); };   // move-only capture
    InplaceFunction<std::size_t()> len2 = std::move(len);
    std::printf("len2() = %zu, len empty: %s\n", len2(), len ? "no" : "yes");

    InplaceFunction<void(), 64> hello = [s = std::string("captured string")] { std::printf("%s\n", s.c_str()); };
    hello();
    hello = [] { std::puts("reassigned"); };
    hello();

    InplaceFunction<int(int)> empty;
    try { empty(1); } catch (const std::bad_function_call&) { std::puts("empty call throws"); }
    // InplaceFunction<void(), 8> tooBig = [s = std::string()] {};   // static_assert fires
}
add(5) = 15
len2() = 19, len empty: yes
captured string
reassigned
empty call throws

Complexity and pitfalls. One indirect call per invocation, like std::function. Pitfalls: the converting constructor template hijacking the move constructor (excluded with the is_same constraint), forgetting to destroy the source after moving it (the move op does both), and forgetting reset() before move-assigning into a non-empty object. Inline-only wrappers like this are common in trading systems, where a hidden allocation on the order path is a latency bug.

Estimated rating: ~2000 · type erasure by hand with a static ops table and compile-time capacity checks

CodeWrite StackFirstResource<N>, a std::pmr::memory_resource that serves allocations from an N-byte buffer inside the object (so it is on the stack when the resource is a local), falls back to an upstream resource when the buffer cannot satisfy a request, counts fallbacks, and reclaims space when the most recently allocated block is freed (LIFO).

Answer

Approach. Override the three virtuals. do_allocate rounds the bump offset up to the requested alignment (always a power of two in pmr) and returns the next slice if it fits, otherwise counts a fallback and forwards to the upstream. do_deallocate forwards pointers it does not own; for its own blocks it rolls the offset back only if the block is the last one handed out, which makes stack-like usage (scoped containers) reuse the buffer. Identity is the only meaningful equality.

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <memory_resource>
#include <string>
#include <vector>

template <std::size_t N>
class StackFirstResource : public std::pmr::memory_resource {
    alignas(std::max_align_t) std::byte buf_[N];
    std::size_t used_ = 0;
    std::pmr::memory_resource* upstream_;
    int fallbacks_ = 0;

    bool owns(const void* p) const {
        auto a = reinterpret_cast<std::uintptr_t>(p), b = reinterpret_cast<std::uintptr_t>(buf_);
        return a >= b && a < b + N;
    }
    void* do_allocate(std::size_t bytes, std::size_t align) override {
        std::size_t start = (used_ + align - 1) & ~(align - 1);   // align is a power of two
        if (align <= alignof(std::max_align_t) && start <= N && bytes <= N - start) {
            used_ = start + bytes;
            return buf_ + start;
        }
        ++fallbacks_;
        return upstream_->allocate(bytes, align);
    }
    void do_deallocate(void* p, std::size_t bytes, std::size_t align) override {
        if (!owns(p)) { upstream_->deallocate(p, bytes, align); return; }
        if (static_cast<std::byte*>(p) + bytes == buf_ + used_)   // most recent block: roll back
            used_ = static_cast<std::size_t>(static_cast<std::byte*>(p) - buf_);
    }
    bool do_is_equal(const std::pmr::memory_resource& o) const noexcept override { return this == &o; }

public:
    explicit StackFirstResource(std::pmr::memory_resource* up = std::pmr::new_delete_resource()) : upstream_(up) {}
    StackFirstResource(const StackFirstResource&) = delete;
    StackFirstResource& operator=(const StackFirstResource&) = delete;
    std::size_t used() const { return used_; }
    int fallbacks() const { return fallbacks_; }
};

int main() {
    StackFirstResource<1024> res;
    {
        std::pmr::vector<int> v(&res);
        v.reserve(64);                       // 256 bytes from the buffer
        for (int i = 0; i < 64; ++i) v.push_back(i);
        std::printf("after 64 ints: used %zu, fallbacks %d\n", res.used(), res.fallbacks());
    }
    std::printf("after vector freed: used %zu\n", res.used());   // LIFO free rolled back

    std::pmr::vector<std::pmr::string> words(&res);
    words.reserve(3);
    words.emplace_back("a long string that does not fit in the SSO buffer");
    words.emplace_back("short");
    std::printf("strings: fallbacks %d\n", res.fallbacks());

    std::pmr::vector<char> big(&res);
    big.resize(2000);                        // larger than the whole buffer: goes upstream
    std::printf("big: fallbacks %d, size %zu\n", res.fallbacks(), big.size());
}
after 64 ints: used 256, fallbacks 0
after vector freed: used 0
strings: fallbacks 0
big: fallbacks 1, size 2000

Complexity and pitfalls. O(1) allocate and deallocate. Pitfalls: alignment rounding that overflows (check bytes <= N - start rather than start + bytes <= N), forwarding a buffer pointer to the upstream (instant heap corruption), copying the resource (containers hold a pointer to it, so it must be non-copyable and outlive them), and expecting non-LIFO frees to reclaim space. Compared with monotonic_buffer_resource, this adds LIFO reuse and makes fallbacks observable, which is useful in tests that assert "no heap on the hot path".

Estimated rating: ~1600 · pmr virtual interface, alignment arithmetic and ownership checks

CodeA singly linked list built from std::unique_ptr<Node> and a binary tree with unique_ptr children both crash with a stack overflow on deep inputs (a million nodes), in their destructors and in a recursive max_depth. Rewrite them so that no operation uses stack space proportional to the input, and prove it on a thread with a 512 KB stack.

Answer

Approach. For the list, destroy nodes in a loop, detaching each node's next before the node dies: head_ = std::move(head_->next) releases the successor into head_ first and then deletes the old head, whose next is already null. For the tree, replace recursion with an explicit stack held in a std::vector (heap memory that grows as needed): push children with their depth for max_depth, and for destruction move each node's children onto the stack before the node goes out of scope.

#include <algorithm>
#include <cstdio>
#include <memory>
#include <pthread.h>
#include <utility>
#include <vector>

class List {
    struct Node {
        int value;
        std::unique_ptr<Node> next;
    };
    std::unique_ptr<Node> head_;
    std::size_t size_ = 0;

public:
    List() = default;
    List(List&&) noexcept = default;
    List& operator=(List&& o) noexcept {
        clear();
        head_ = std::move(o.head_);
        size_ = std::exchange(o.size_, 0);
        return *this;
    }
    ~List() { clear(); }
    void clear() {
        while (head_) head_ = std::move(head_->next);   // each node dies with next == nullptr
        size_ = 0;
    }
    void push_front(int v) {
        head_ = std::make_unique<Node>(Node{v, std::move(head_)});
        ++size_;
    }
    std::size_t size() const { return size_; }
};

struct TreeNode {
    int value = 0;
    std::unique_ptr<TreeNode> left, right;
};

static std::size_t max_depth(const TreeNode* root) {
    std::vector<std::pair<const TreeNode*, std::size_t>> stack;   // heap, grows as needed
    if (root) stack.push_back({root, 1});
    std::size_t best = 0;
    while (!stack.empty()) {
        auto [n, d] = stack.back();
        stack.pop_back();
        best = std::max(best, d);
        if (n->left) stack.push_back({n->left.get(), d + 1});
        if (n->right) stack.push_back({n->right.get(), d + 1});
    }
    return best;
}

static void destroy_tree(std::unique_ptr<TreeNode> root) {
    std::vector<std::unique_ptr<TreeNode>> stack;
    if (root) stack.push_back(std::move(root));
    while (!stack.empty()) {
        std::unique_ptr<TreeNode> n = std::move(stack.back());
        stack.pop_back();
        if (n->left) stack.push_back(std::move(n->left));
        if (n->right) stack.push_back(std::move(n->right));
    }   // n is destroyed here with both children already detached
}

static void* work(void*) {
    List list;
    for (int i = 0; i < 1'000'000; ++i) list.push_front(i);
    std::printf("list size %zu\n", list.size());
    list.clear();
    std::printf("list cleared\n");

    auto root = std::make_unique<TreeNode>();
    TreeNode* cur = root.get();
    for (int i = 0; i < 999'999; ++i) {           // degenerate tree: a left spine
        cur->left = std::make_unique<TreeNode>();
        cur = cur->left.get();
    }
    cur->right = std::make_unique<TreeNode>();
    std::printf("depth %zu\n", max_depth(root.get()));
    destroy_tree(std::move(root));
    std::printf("tree destroyed\n");
    return nullptr;
}

int main() {
    // std::thread cannot set a stack size, so use pthreads: 512 KB on every platform.
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setstacksize(&attr, 512 * 1024);
    pthread_t t;
    if (pthread_create(&t, &attr, work, nullptr) != 0) return 1;
    pthread_join(t, nullptr);
    pthread_attr_destroy(&attr);
}
list size 1000000
list cleared
depth 1000001
tree destroyed

Complexity and pitfalls. O(n) time for everything; O(1) extra space for the list and O(height) heap space (not stack) for the tree. Pitfalls: writing head_.reset(head_->next.release()) in the wrong order and leaking, forgetting that the defaulted move assignment would destroy the old list recursively (hence the custom one), and forgetting that the tree's own ~TreeNode is still recursive if a node is destroyed while it still owns children. Tail recursion does not save you: destructors run before operator delete, so the recursive call is not in tail position.

Estimated rating: ~1400 · standard recursion-to-iteration, with the destructor twist

CodeWrite a FixedString<N> that holds a string literal by value with no heap, can be used as a C++20 class-type template argument (Counter<"cache.miss">), compares equal across lengths, and supports compile-time concatenation.

Answer

Approach. A C++20 non-type template parameter may be a structural class type: a literal type whose bases and non-static members are all public and non-mutable. So the class is a public char data[N] plus a constexpr constructor from const char (&)[N], which lets class template argument deduction pick N from the literal (including the NUL). Each distinct literal makes a distinct Counter type with its own static. Concatenation is a constexpr function that computes the new size from the arguments' sizes.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <string_view>

template <std::size_t N>
struct FixedString {
    char data[N] {};                                 // includes the terminating NUL
    constexpr FixedString(const char (&s)[N]) { std::copy_n(s, N, data); }
    constexpr std::size_t size() const { return N - 1; }
    constexpr std::string_view view() const { return {data, N - 1}; }
    constexpr const char* c_str() const { return data; }
    template <std::size_t M>
    constexpr bool operator==(const FixedString<M>& o) const { return view() == o.view(); }
};

// C++20: a literal class type with public members can be a template argument.
template <FixedString Name>
struct Counter {
    static inline int hits = 0;
    static void hit() { ++hits; }
    static constexpr std::string_view name() { return Name.view(); }
};

template <FixedString A, FixedString B>
constexpr auto concat() {
    constexpr std::size_t n = A.size() + B.size() + 1;
    char buf[n] {};
    std::copy_n(A.data, A.size(), buf);
    std::copy_n(B.data, B.size() + 1, buf + A.size());
    return FixedString<n>(buf);
}

int main() {
    Counter<"cache.miss">::hit();
    Counter<"cache.miss">::hit();
    Counter<"cache.hit">::hit();
    std::printf("%s = %d\n", Counter<"cache.miss">::name().data(), Counter<"cache.miss">::hits);
    std::printf("%s = %d\n", Counter<"cache.hit">::name().data(), Counter<"cache.hit">::hits);

    constexpr auto full = concat<"order.", "filled">();
    static_assert(full.size() == 12 && full == FixedString("order.filled"));
    std::printf("%s (%zu chars, sizeof %zu)\n", full.c_str(), full.size(), sizeof full);
}
cache.miss = 2
cache.hit = 1
order.filled (12 chars, sizeof 13)

Complexity and pitfalls. Everything happens at compile time; at run time the object is exactly N bytes. Pitfalls: making data private (the type stops being structural and the template argument is rejected), forgetting that N counts the NUL, and comparing raw arrays with == (compares addresses, and is deprecated in C++20). This is the building block of compile-time format strings and string-keyed metrics registries.

Estimated rating: ~1600 · C++20 structural types and CTAD from array references

Cheat sheet

Theory
  • Stack allocation is one sub sp per frame and free on return; new is a function call into a size-class allocator, tens of nanoseconds on the fast path.
  • Stack sizes: Linux main 8 MB (ulimit -s), glibc threads the same by default, musl threads 128 KB, macOS threads 512 KB, Windows 1 MB. Overflow is SIGSEGV on the guard page, never an exception. std::thread cannot set the size.
  • SSO: libstdc++ 32 bytes and 15 chars, libc++ 24 bytes and 22 chars, MSVC 32 and 15. Moving a short string copies; views into a short string do not follow a move.
  • SBO: std::function keeps callables of up to 16 bytes inline on both libraries (libstdc++ also needs trivially copyable); std::any 8 bytes (libstdc++) or 24 (libc++). Count allocations with an escaping object and an atomic counter.
  • Small vectors (SmallVector, InlinedVector, C++26 inplace_vector): raw aligned storage, placement new, O(N) move when inline. std::vector cannot do SSO because moves and swaps must not invalidate element pointers.
  • alloca and VLAs are non-standard in C++, have no failure path and live until the function returns. Use a fixed stack buffer with a heap fallback instead.
  • [expr.new] lets compilers elide or merge new-expressions even with a replaced operator new; GCC and Clang do it at -O1 for non-escaping pointers. Never rely on it.
  • Pass by value trivially copyable types up to 16 bytes (int, string_view, span); non-trivial types such as unique_ptr go by invisible reference under the Itanium ABI. Return large objects by value: elision builds them in the caller's frame.

Part E · Memory management · Chapter 19

Cache-friendly allocation

A cache miss to DRAM costs as much as hundreds of simple instructions, so where your objects live in memory often matters more than how many instructions touch them. This chapter covers the memory hierarchy, cache lines, locality, data layout (AoS vs SoA, padding, hot/cold splitting), false sharing, prefetching and the allocation patterns that keep related data together, with benchmarks measured on an Apple M1. Low-latency and systems interviews ask about it because it is where "big-O equal" designs differ by 10x or more.

TheoryDrillsCoding· about 5 hours· prerequisites: Ch 10, Ch 15, Ch 17, Ch 18

The memory hierarchy and latency numbers

Theory

Mental model. The CPU can only work on data in registers. Everything else is a ladder of caches, each larger and slower than the one above: L1 (per core, tens of KB), L2 (per core or per cluster, hundreds of KB to a few MB), often a shared L3 or system-level cache (several MB to tens of MB), then DRAM (GB). A load that hits L1 completes in a few cycles. A load that has to go to DRAM stalls for hundreds of cycles, and if the next load depends on it (pointer chasing), nothing hides that stall.

Key facts

LevelTypical latencyTypical sizeMeasured here (M1, pointer chase)
Register0 cycles16 to 32 general registers
L1 data cache3 to 5 cycles, about 1 ns32 to 48 KB (x86), 128 KB (M1 performance core)about 2 ns at 16 to 64 KB (includes loop overhead)
L212 to 18 cycles, about 3 to 5 ns256 KB to 2 MB per core (x86), 12 MB per performance cluster (M1)6 to 11 ns at 256 KB to 4 MB
L3 / system cache30 to 70 cycles, about 10 to 20 ns8 to 100+ MB, sharedM1 has an 8 MB system level cache instead of an L3
DRAMabout 60 to 120 nsGBabout 115 ns at 64 MB (includes TLB misses)
TLB miss (page walk)tens of cycles if the page tables are cached, a DRAM access each if notabout 64 L1 and 1 to 3 K L2 TLB entries
NVMe SSD readabout 10 to 100 µsTB

These numbers vary by a factor of two or more between CPU generations, clock speeds and load, so quote ranges in interviews and say so. What does not vary is the shape: each step down costs roughly 3 to 10 times more, and DRAM is roughly 100 times slower than L1.

The program below measures load latency the standard way: it builds a random cyclic permutation inside an array of a given size and follows it, so every load depends on the previous one and the hardware cannot overlap or prefetch them. The output is a timing, so it is not checked.

#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdio>
#include <numeric>
#include <random>
#include <vector>

// Pointer chasing: each load depends on the previous one, so prefetchers cannot help
// and the time per step approximates the load latency of the level the array fits in.
static double ns_per_load(std::size_t bytes) {
    std::size_t n = bytes / sizeof(std::size_t);
    std::vector<std::size_t> order(n), next(n);
    std::iota(order.begin(), order.end(), 0);
    std::shuffle(order.begin() + 1, order.end(), std::mt19937_64{42});
    for (std::size_t i = 0; i < n; ++i) next[order[i]] = order[(i + 1) % n];   // one random cycle
    const std::size_t steps = 5'000'000;
    std::size_t p = 0;
    auto t0 = std::chrono::steady_clock::now();
    for (std::size_t i = 0; i < steps; ++i) p = next[p];
    auto t1 = std::chrono::steady_clock::now();
    if (p == n) std::puts("");   // keep p alive
    return std::chrono::duration<double, std::nano>(t1 - t0).count() / steps;
}

int main() {
    for (std::size_t kb : {16, 64, 256, 1024, 4096, 16384, 65536})
        std::printf("%7zu KB: %6.1f ns per dependent load\n", kb, ns_per_load(kb * 1024));
}

On the Apple M1 used for this guide (GCC 16 and Clang 22, -O1) it printed about 2 ns up to 64 KB, 6 to 11 ns from 256 KB to 4 MB (L2), between 28 and 66 ns at 16 MB depending on the run (the system cache catches part of it), and about 115 ns at 64 MB. The jump points are where the working set stops fitting in a cache level.

Know this cold

L1 about 1 ns, L2 about 4 ns, L3 about 15 ns, DRAM about 100 ns. A cache miss to DRAM costs as much as a few hundred simple instructions. Independent misses can overlap (a core can keep 10 to 20 or more misses in flight), dependent misses cannot. That is why arrays beat linked structures even when both touch "the same amount" of memory.

Cache lines and locality

Theory

Caches do not move bytes, they move cache lines: aligned blocks of 64 bytes on every current x86 core and on most Arm cores. Apple silicon reports 128 bytes (sysctl hw.cachelinesize prints 128 on this M1), and 128 is the value to pad for there. Reading one byte brings the whole line in; writing one byte requires the core to own the whole line.

The address splits into three fields: the low 6 bits (for 64-byte lines) are the offset inside the line, the next bits pick the set, and the rest is the tag that identifies which line occupies a slot of that set. A cache with 8 or 12 ways can hold only that many lines with the same set index. Addresses that differ by a large power of two (4 KB, 8 KB, 16 KB) land in the same set, so walking a matrix whose row length is a power of two can thrash a cache that is mostly empty (the transpose measurements below show this).

Spatial and temporal locality

  • Spatial locality: if you touch an address, you will soon touch its neighbours. Contiguous arrays and sequential scans exploit it: one miss brings 16 ints.
  • Temporal locality: if you touch something, you will soon touch it again. Tiling (blocking) a loop so that it reuses a small working set before moving on exploits it.

Cache-friendly design means maximising the fraction of each fetched line that you use, and the number of times you use it before it is evicted.

std::hardware_destructive_interference_size

C++17 added two constants in <new>: hardware_destructive_interference_size (the minimum distance between two objects to avoid false sharing) and hardware_constructive_interference_size (the maximum size of data that is guaranteed to share a line). Measured with both compilers in this guide's harness:

#include <cstdio>
#include <new>

int main() {
    std::printf("destructive %zu, constructive %zu\n",
                std::hardware_destructive_interference_size,
                std::hardware_constructive_interference_size);
}
destructive 256, constructive 64

That is the output for the arm64 target used here, with both GCC 16 and Clang 22. On x86-64 both compilers print 64 and 64. The arm64 value of 256 is deliberately conservative, chosen to cover Arm designs with large lines or pair-wise prefetching. Two warnings come with these constants. They are compile-time values for a target, not a property of the machine the program runs on. And they become part of any struct layout that uses them, so a header that uses them can produce an ABI mismatch between translation units compiled with different flags; GCC has -Winterference-size for exactly that: it is on by default and fires when the constant is used in a header (a use in the main source file, like the program above, is not flagged), and it goes quiet once you pin the value with --param destructive-interference-size=N. Many projects simply hard-code 64 (x86) or 128 (to also cover Apple silicon and Intel's adjacent-line prefetcher).

Contiguous containers vs node-based containers

Theory

A std::vector<int> of a million elements is 4 MB in one block: a scan touches 62,500 lines in order, and the hardware prefetcher sees the stream and fetches ahead. A std::list<int> of the same elements is a million separate nodes of 24 bytes plus allocator overhead, each found by loading the previous node's next pointer. Every step is a dependent load, and if the nodes are scattered, every step is a cache miss.

#include <algorithm>
#include <chrono>
#include <cstdio>
#include <list>
#include <numeric>
#include <random>
#include <vector>

template <class F>
static double best_ms(F f) {
    double best = 1e30;
    for (int r = 0; r < 3; ++r) {
        auto t0 = std::chrono::steady_clock::now();
        f();
        auto t1 = std::chrono::steady_clock::now();
        best = std::min(best, std::chrono::duration<double, std::milli>(t1 - t0).count());
    }
    return best;
}

int main() {
    const int n = 2'000'000;
    std::vector<int> v(n);
    std::iota(v.begin(), v.end(), 0);
    std::list<int> fresh(v.begin(), v.end());          // nodes allocated back to back

    std::vector<int> keys(n);
    std::iota(keys.begin(), keys.end(), 0);
    std::shuffle(keys.begin(), keys.end(), std::mt19937{1});
    std::list<std::pair<int, int>> scattered;
    for (int i = 0; i < n; ++i) scattered.push_back({keys[i], i});
    scattered.sort();   // relinks the nodes: list order no longer matches memory order
    long long s1 = 0, s2 = 0, s3 = 0;
    double tv = best_ms([&] { s1 = std::accumulate(v.begin(), v.end(), 0LL); });
    double tf = best_ms([&] { s2 = std::accumulate(fresh.begin(), fresh.end(), 0LL); });
    double ts = best_ms([&] { long long s = 0; for (auto& p : scattered) s += p.second; s3 = s; });
    std::printf("vector %.2f ms, list (allocation order) %.2f ms, list (scattered) %.2f ms\n", tv, tf, ts);
    std::printf("ratios: %.1fx and %.1fx  [%lld %lld %lld]\n", tf / tv, ts / tv, s1, s2, s3);
}

Measured on the M1: the list in allocation order is about 3 times slower than the vector (the allocator happened to place nodes nearly back to back, so this is the list's best case). After sorting by a random key, which relinks the nodes so that list order no longer matches memory order, the list is about 170 to 200 times slower than the vector for the same 2 million elements. Long-running programs look like the second case: nodes are allocated and freed over time, and neighbours in the list end up far apart in memory.

Interview trap

"Linked lists have O(1) insertion, so they are better for frequent inserts in the middle." Only if you already hold an iterator to the position. Finding the position is an O(n) walk of cache misses, while a vector finds it with a fast scan or binary search and shifts the tail with memmove, which runs at memory bandwidth. Bjarne Stroustrup's well-known benchmark showed vector beating list for "insert at a random sorted position" at every size he tried. Big-O hides the constant, and the constant is the memory system.

Row-major traversal

C and C++ arrays are row-major: m[r][c] and m[r][c + 1] are adjacent. Walking along a row uses every byte of every line. Walking down a column jumps a whole row each step, uses 4 bytes of each 64- or 128-byte line, and, for large matrices, evicts the line before the next column would have used the rest of it.

#include <chrono>
#include <cstdio>
#include <vector>

int main() {
    const int n = 4096;                        // 4096 × 4096 ints = 64 MB, far beyond every cache
    std::vector<int> m(static_cast<std::size_t>(n) * n, 1);
    auto time = [&](bool by_rows) {
        auto t0 = std::chrono::steady_clock::now();
        long long s = 0;
        for (int a = 0; a < n; ++a)
            for (int b = 0; b < n; ++b)
                s += by_rows ? m[static_cast<std::size_t>(a) * n + b] : m[static_cast<std::size_t>(b) * n + a];
        auto t1 = std::chrono::steady_clock::now();
        std::printf("%s: %.1f ms (sum %lld)\n", by_rows ? "row order   " : "column order",
                    std::chrono::duration<double, std::milli>(t1 - t0).count(), s);
        return std::chrono::duration<double>(t1 - t0).count();
    };
    double r = time(true), c = time(false);
    std::printf("column / row = %.1fx\n", c / r);
}

On the M1 the column-order walk of a 64 MB matrix was 5 times (GCC) to 9 times (Clang) slower than the row-order walk. On many x86 machines the gap is larger. Loop interchange (swapping the loops) is the cheapest optimisation in this chapter.

AoS vs SoA, and hot/cold splitting

Theory

Array of structures (AoS) stores whole objects one after another: std::vector<Particle>. Structure of arrays (SoA) stores one array per field: std::vector<float> x, y, z, vx, .... The data is the same; what changes is which bytes share a cache line.

AoS: one 64-byte Particle per line, loop reads p.x and p.vx x y z vx vy vz m q id fl created updated pad 8 / 64 B used the next particle is on the next line: same pattern, 87.5% of every fetched line is wasted bandwidth SoA: separate arrays, the same loop reads x[i] and vx[i] x line x0x1x2x15 vx line vx0vx15 64 / 64 B used two lines serve 16 particles instead of 16 lines, and the loop is a clean candidate for SIMD: x[i..i+3] += vx[i..i+3] in one vector instruction (unused fields of other particles never enter the cache at all)
Layout decides how much of each line is useful. A loop over one or two fields of a fat object drags the whole object through the cache. SoA keeps each field dense, so the same loop reads only what it uses and vectorises naturally.
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <vector>

struct ParticleAoS {            // 64 bytes: one particle per cache line on x86
    float x, y, z;
    float vx, vy, vz;
    float mass, charge;
    int id, flags;
    double created, updated;
    float pad[2];
};
static_assert(sizeof(ParticleAoS) == 64);

struct ParticlesSoA {
    std::vector<float> x, y, z, vx, vy, vz, mass, charge;
    std::vector<int> id, flags;
    std::vector<double> created, updated;
};

int main() {
    const std::size_t n = 4'000'000;
    std::vector<ParticleAoS> aos(n, ParticleAoS{1, 2, 3, 0.5f, 0, 0, 1, 0, 0, 0, 0, 0, {}});
    ParticlesSoA soa;
    soa.x.assign(n, 1); soa.vx.assign(n, 0.5f);
    soa.y.assign(n, 2); soa.z.assign(n, 3); soa.vy.assign(n, 0); soa.vz.assign(n, 0);
    auto ms = [](auto t0, auto t1) { return std::chrono::duration<double, std::milli>(t1 - t0).count(); };
    double ta = 1e30, ts = 1e30;
    for (int r = 0; r < 5; ++r) {
        auto t0 = std::chrono::steady_clock::now();
        for (auto& p : aos) p.x += p.vx;                       // touches 8 of every 64 bytes
        auto t1 = std::chrono::steady_clock::now();
        for (std::size_t i = 0; i < n; ++i) soa.x[i] += soa.vx[i];   // touches only what it uses
        auto t2 = std::chrono::steady_clock::now();
        ta = std::min(ta, ms(t0, t1));
        ts = std::min(ts, ms(t1, t2));
    }
    std::printf("AoS %.2f ms, SoA %.2f ms, AoS / SoA = %.1fx (x[7] %.1f %.1f)\n", ta, ts, ta / ts, aos[7].x, soa.x[7]);
}

Measured on the M1 with 4 million particles: SoA was 3.1 times faster with GCC and 2.4 times faster with Clang for x += vx. The theoretical ratio is 8 (8 bytes used out of 64 in AoS, all of them in SoA); the M1's very high memory bandwidth and the loop's arithmetic eat into it.

SoA is not always better. If the code usually works on one whole entity at a time (look up a particle by id and read all its fields), AoS fetches one or two lines while SoA fetches one line per field. Many engines use a hybrid, AoSoA: blocks of, say, 8 entities stored SoA-style inside each block, which keeps SIMD-friendly runs and entity locality. Entity-component systems in games are SoA at heart.

Hot/cold splitting

A gentler version of the same idea: keep the fields that the hot loop touches in a compact struct, and move rarely used fields (names, debug info, audit timestamps, large buffers) into a separate "cold" struct reached by index or pointer.

struct OrderHot {             // 32 bytes: two per 64-byte line, scanned by the matching engine
    std::int64_t price;
    std::int64_t qty;
    std::uint32_t id;
    std::uint32_t cold_index;  // where the rest lives
    std::uint8_t side, flags;
};
struct OrderCold {            // touched on fills, cancels and reports only
    std::string client_ref;
    std::chrono::system_clock::time_point created;
    std::array<char, 64> notes;
};
std::vector<OrderHot> hot;    // the loop streams through this
std::vector<OrderCold> cold;  // indexed by cold_index

Profile first: the split only pays off when the hot loop is memory bound and the cold fields are a large share of the object.

Alignment, padding and member order

Theory

Every type has an alignment (a power of two) and objects must start at a multiple of it. The compiler lays out members at increasing addresses in declaration order (required by the standard; before C++23 only among members with the same access control), inserts padding before any member that would be misaligned, and pads the end so that sizeof is a multiple of the struct's alignment, which keeps every element of an array aligned.

struct A { char a; int b; char c; }; sizeof 12, alignof 4 a b (4 bytes, offset 4) c 3 bytes padding 3 bytes tail padding struct B { int b; char a; char c; }; sizeof 8, alignof 4 b (offset 0) a c 2 bytes tail padding same data, one third smaller: 8 objects per 64-byte line instead of 5
Padding comes from order. Each square is one byte. The int must start at a multiple of 4, so a char in front of it costs 3 bytes, and the tail is padded to keep arrays aligned. Sorting members by decreasing alignment removes most padding.

The practical rule: declare members in decreasing order of alignment (8-byte types, then 4, 2, 1). It is optimal for the common case where all sizes are multiples of their alignment, and it is what the coding drill below automates. Group fields that are used together, too: reordering for size should not split the hot fields across two lines.

alignas, over-aligned types and packing

  • alignas(N) on a type raises its alignment and therefore rounds its size up to a multiple of N. struct alignas(64) Slot { long v; }; is 64 bytes, so an array of Slot puts each element on its own line.
  • alignas(N) on a variable aligns that variable only. alignas(64) long counters[4]; aligns the start of the array; the four elements are still 8 bytes apart (a reading drill below).
  • Since C++17, new honours over-alignment (it calls operator new(size_t, std::align_val_t)), and so does std::allocator, so std::vector<Slot> works. Before C++17, over-aligned heap objects were silently under-aligned.
  • [[no_unique_address]] (C++20) lets an empty member, such as a stateless allocator, comparator or deleter, take no space. GCC and Clang honour it; MSVC needs [[msvc::no_unique_address]].
  • #pragma pack(1) or __attribute__((packed)) removes padding to match a wire or file format. The members are then misaligned: accessing them by name is fine (the compiler emits safe loads), but taking their address and using the pointer is undefined behaviour, can be slow, and on some architectures traps. Atomics on packed members do not work. Prefer copying wire bytes with std::memcpy into a normal struct.

False sharing

Theory

Mental model. Coherence is tracked per cache line, not per variable. To write to a line, a core must hold it exclusively (the M state in MESI-style protocols), which invalidates every other core's copy. If two threads write to two different variables that happen to share a line, each write steals the line from the other core. Nothing is logically shared, but the hardware behaves as if everything were.

Shared line: counters A and B are 8 bytes apart core 0: ++A AB... line: M, then I core 1: ++B AB... line: I, then M invalidate, transfer every write: tens of ns ping-pong on every increment Padded: alignas(128) puts each counter on its own line core 0: ++A Apadding stays M: L1 hits core 1: ++B Bpadding stays M: L1 hits no traffic
Coherence works on lines. Two threads writing neighbouring variables force the line to move between cores on every write. Padding each hot, per-thread variable to its own line removes the traffic; reads alone never cause it, because many cores can hold a line in the shared state.
#include <atomic>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <thread>
#include <vector>

struct Packed {
    std::atomic<long> value{0};                 // 8 bytes: four counters share one line
};
struct alignas(128) Padded {                    // 128: the line size macOS reports on Apple silicon
    std::atomic<long> value{0};
};

template <class Counter>
static double run_ms(int threads, long iters) {
    std::vector<Counter> counters(threads);
    auto t0 = std::chrono::steady_clock::now();
    std::vector<std::thread> pool;
    for (int t = 0; t < threads; ++t)
        pool.emplace_back([&counters, t, iters] {
            for (long i = 0; i < iters; ++i) counters[t].value.fetch_add(1, std::memory_order_relaxed);
        });
    for (auto& th : pool) th.join();
    auto t1 = std::chrono::steady_clock::now();
    long total = 0;
    for (auto& c : counters) total += c.value.load();
    if (total != threads * iters) std::puts("lost updates?");
    return std::chrono::duration<double, std::milli>(t1 - t0).count();
}

int main() {
    const int threads = 4;
    const long iters = 2'000'000;
    double best_packed = 1e30, best_padded = 1e30;
    for (int r = 0; r < 3; ++r) {
        best_packed = std::min(best_packed, run_ms<Packed>(threads, iters));
        best_padded = std::min(best_padded, run_ms<Padded>(threads, iters));
    }
    std::printf("sizeof Packed %zu, Padded %zu\n", sizeof(Packed), sizeof(Padded));
    std::printf("adjacent counters %.1f ms, padded counters %.1f ms, ratio %.1fx\n",
                best_packed, best_padded, best_packed / best_padded);
}

Measured on the M1 with 4 threads: adjacent counters took about 155 ms, padded counters about 7 ms, a ratio of about 22 with both compilers. The program is race free (each thread owns one atomic), so it also runs clean under ThreadSanitizer; only the timings are unchecked.

Where false sharing hides: arrays of per-thread counters or statistics, a lock and the data it protects in the same line (waiters spinning on the lock slow down the owner), the head and tail indices of a single-producer single-consumer queue, and small heap objects allocated back to back for different threads. The fixes are padding and alignment, per-thread accumulation in locals with one write at the end, and per-thread arenas.

Tip

On Linux, perf c2c record followed by perf c2c report finds false sharing directly: it lists cache lines with many HITM events (loads that hit a line modified in another core's cache) and the offsets inside the line that each thread touches.

Prefetching and blocking

Theory

Hardware prefetchers watch the stream of misses and fetch ahead. Every modern core has a next-line prefetcher and a stride prefetcher that recognises constant strides (forwards or backwards, per instruction), and Intel cores also fetch the adjacent line of each 128-byte pair. They are why sequential and strided scans run near memory bandwidth. They cannot predict pointer chasing or random indices.

__builtin_prefetch(addr, rw, locality) (GCC and Clang) emits a prefetch instruction: a hint to start loading a line that you will need soon. It helps only when you know a future address well in advance, the access is not predictable by hardware, and the core is not already overlapping those misses. The benchmark below gathers from random indices, with and without a prefetch 16 iterations ahead.

#include <algorithm>
#include <chrono>
#include <cstdio>
#include <numeric>
#include <random>
#include <vector>

int main() {
    const std::size_t n = 1u << 24;                  // 16M ints (64 MB) of data
    std::vector<int> data(n, 1);
    std::vector<unsigned> seq(n), rnd(n);
    std::iota(seq.begin(), seq.end(), 0u);
    rnd = seq;
    std::shuffle(rnd.begin(), rnd.end(), std::mt19937{7});

    auto gather = [&](const std::vector<unsigned>& idx, int distance) {
        long long s = 0;
        auto t0 = std::chrono::steady_clock::now();
        for (std::size_t i = 0; i < n; ++i) {
            if (distance && i + distance < n) __builtin_prefetch(&data[idx[i + distance]]);
            s += data[idx[i]];
        }
        auto t1 = std::chrono::steady_clock::now();
        return std::pair{std::chrono::duration<double, std::milli>(t1 - t0).count(), s};
    };
    auto [ts, s1] = gather(seq, 0);
    auto [tr, s2] = gather(rnd, 0);
    auto [tp, s3] = gather(rnd, 16);
    std::printf("sequential %.0f ms, random %.0f ms, random + prefetch %.0f ms\n", ts, tr, tp);
    std::printf("random / sequential %.1fx, prefetch speedup %.1fx [%lld %lld %lld]\n", tr / ts, tr / tp, s1, s2, s3);
}

Measured on the M1: the random gather was 4.6 to 6.7 times slower than the sequential one, and the software prefetch gave no gain (0.9 to 1.0 times, so slightly worse). The loads in this loop are independent, so the out-of-order core already has many misses in flight, and the prefetch only adds instructions. Software prefetching pays off in loops with long dependency chains per element, such as hash-table probes or tree descents processed in batches. Measure every prefetch you add; a prefetch distance that is wrong for one machine is often wrong for the next.

Blocking (tiling)

When a loop nest touches one array along rows and another along columns, as in a matrix transpose or a naive matrix multiply, one of them is always strided. Blocking processes the matrix in tiles small enough that every line of the tile stays in L1 until it is fully used. The coding drill implements a blocked transpose. Timings on the M1 (milliseconds, best of 3, GCC -O1):

n × n floatsnaiveblocked, tile 16blocked, tile 64observation
20003.23.73.7no gain: the stride prefetcher handles the column writes
2048324.722power-of-two rows collide in the same cache sets; small tiles avoid it
40001022016.5about 6 times faster with blocking
4096167 to 200124121set conflicts and TLB misses dominate

The lesson is the interview answer: blocking is a large win when the working set exceeds the cache, but power-of-two strides can cause conflict misses that tiling alone does not cure. Padding each row by one line (allocate n + 16 floats per row) is the classic fix. And always measure on the target machine: the 2000 × 2000 case shows that a strong prefetcher can make the naive version good enough.

Allocation patterns that keep data together

Theory

Layout inside an object is half the story. The other half is where the allocator puts objects relative to each other. A general-purpose malloc serves requests of the same size class from the same pages in roughly allocation order, but after a while of mixed allocations and frees, related objects end up scattered (Ch 10).

  • Contiguous containers first. std::vector, std::array, std::deque (contiguous blocks), and sorted vectors in place of std::set/std::map when reads dominate (std::flat_map and std::flat_set in C++23, boost::container::flat_map before that).
  • reserve when the size is known or bounded. Besides avoiding reallocation copies, it keeps the final buffer in one place instead of leaving freed buffers of 1, 2, 4 ... elements scattered in the heap.
  • Open-addressing hash tables. std::unordered_map must keep references stable and expose buckets, so every mainstream implementation uses a bucket array of pointers to separately allocated nodes: a lookup is at least two dependent misses. absl::flat_hash_map, boost::unordered_flat_map and similar tables store entries in one array and probe neighbouring slots, so a lookup usually touches one or two lines. The cost: references are invalidated on rehash.
  • Pools and arenas (Ch 15, Ch 17). Allocate the nodes of one tree, graph or message from one arena and they sit next to each other in allocation order, which is usually traversal order. Freeing is one operation, and there is no per-node header.
  • Indices instead of pointers. Store nodes in a std::vector and link them with 32-bit indices. Nodes are contiguous, links are half the size of pointers, the structure can be copied or serialised with memcpy, and reallocation does not invalidate links.
  • Per-thread allocation. Objects used by one thread should come from that thread's arena or cache, which also prevents false sharing between neighbouring small objects owned by different threads.

TLB and huge pages

Every access also needs a virtual-to-physical translation. The TLB caches translations: typically about 64 entries in the first level and 1 to 3 thousand in the second. With 4 KB pages, 1,536 entries cover only 6 MB, so a random walk over a 1 GB working set misses the TLB on nearly every access and pays a page walk on top of the cache miss. Huge pages (2 MB or 1 GB on x86-64) make each entry cover 512 or 262,144 times more memory.

  • Linux: transparent huge pages (/sys/kernel/mm/transparent_hugepage/enabled set to always or madvise; opt in per region with madvise(addr, len, MADV_HUGEPAGE)), or explicit huge pages with mmap(MAP_HUGETLB) or hugetlbfs, reserved in advance. Low-latency systems often pre-fault and lock huge pages at startup to avoid page faults later.
  • Costs: THP can increase memory use (a 2 MB page for a few KB of data) and background compaction can cause latency spikes, which is why some databases recommend madvise mode rather than always.
  • Apple silicon uses 16 KB base pages (hw.pagesize is 16384 on this M1), which quadruples TLB reach compared with 4 KB pages. Linux on arm64 can be built with 4, 16 or 64 KB pages.

How to measure cache behaviour

Theory

Never optimise layout on intuition alone. Measure the miss rates, change one thing, and measure again.

# Linux: hardware counters for the whole run
perf stat -e cycles,instructions,cache-references,cache-misses,L1-dcache-load-misses,LLC-load-misses,dTLB-load-misses ./app

# where the misses happen, per function and per source line
perf record -e cache-misses -g ./app
perf report

# false sharing: lines with HITM events and the offsets each thread touches
perf c2c record ./app
perf c2c report

# simulated caches, deterministic and exact per line (slow: 20 to 100 times)
valgrind --tool=cachegrind --cache-sim=yes ./app
cg_annotate cachegrind.out.<pid>

# cache geometry
lscpu --caches
getconf LEVEL1_DCACHE_LINESIZE
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

# macOS
sysctl hw.cachelinesize hw.l1dcachesize hw.l2cachesize hw.pagesize

Read perf stat with care. Instructions per cycle (IPC) below about 1 on a modern core usually means the program is stalled on memory. The generic cache-misses event usually means last-level cache misses, which is what costs DRAM time. Cachegrind simulates a simple cache model rather than your real CPU, and since Valgrind 3.21 (2023) its cache simulation is off unless you pass --cache-sim=yes; it is still the best tool for exact, repeatable per-line counts.

On macOS there is no perf, and Valgrind does not support Apple silicon. Use Instruments: the CPU Counters template records hardware events such as L1D and L2 misses per function, and the Time Profiler finds hot loops. The stock template records little until you choose the events, so configure them in Instruments, save the result as your own template, and record from the command line with xcrun xctrace record --template 'My Counters' --launch -- ./app. Combine counters with micro-benchmarks like the ones in this chapter, run several times and on a quiet machine, and always report the machine and compiler next to the numbers.

Concept and tricky questions

Drills

ConceptGive rough latencies for an L1 hit, an L2 hit, an L3 hit and a DRAM access, and explain why the ratio matters more than the exact values.

Answer

Answer. L1 about 1 ns (3 to 5 cycles), L2 about 3 to 5 ns, L3 about 10 to 20 ns, DRAM about 60 to 120 ns. On the M1 used here, a pointer chase measured about 2 ns, 6 to 11 ns, and about 115 ns for the L1, L2 and DRAM ranges.

Why. Each level is roughly 3 to 10 times slower than the one above, and DRAM is roughly 100 times slower than L1, on every machine of the last two decades. A DRAM miss costs as much as a few hundred simple instructions, so an algorithm that does twice the arithmetic but misses half as often usually wins. Quoting a single exact number is a weak answer, because latencies differ by generation, clock and load; quoting the ladder and the ratios is the strong one.

ConceptWhat is a cache line, how big is it, and what happens when you read a single byte from memory that is not cached?

Answer

Answer. A cache line is the aligned block that caches move and track as a unit: 64 bytes on x86 and most Arm cores, 128 bytes on Apple silicon (sysctl hw.cachelinesize prints 128 on the M1). Reading one byte fetches the whole line containing it into L1 (and usually the levels below), so the next 63 bytes are free to read.

Why. DRAM delivers data in bursts, and tags cost silicon, so tracking larger blocks is cheaper per byte. The consequences: a value that straddles two lines costs two fetches, an access pattern that uses 4 bytes per line wastes 94 percent of the bandwidth, and coherence (and therefore false sharing) operates on whole lines. The common wrong answer is that the cache loads "the variable".

TrickyYou need to keep a sequence sorted while inserting 100,000 random values. A colleague picks std::list because "insertion is O(1)". Is that a good choice?

Answer

Answer. No. std::vector with lower_bound and insert is much faster in practice, even though the insert shifts O(n) elements.

Why. The list's O(1) insertion assumes you already have the position. Finding it is an O(n) walk of dependent loads, each likely a cache miss once the nodes are scattered (the scattered list in this chapter was about 200 times slower to traverse than a vector on the M1). The vector finds the position by binary search in O(log n) and shifts the tail with a memmove at memory bandwidth, which the prefetcher handles perfectly. This is Stroustrup's well-known "vector vs list" demonstration. Lists still win when you hold iterators and splice, or need iterator stability, but rarely for search-then-insert.

TrickyA colleague says: "Linked lists are only slow because the nodes are scattered. Allocate them from an arena and a list traversal is as fast as a vector." Is that right?

Answer

Answer. Only partly. An arena (or a freshly built list) puts nodes in allocation order, which removes the random misses, but the traversal is still a chain of dependent loads over fat nodes. In this chapter's benchmark the list in allocation order was still about 3 times slower than the vector, and the gap returns as soon as inserts, splices or a sort make list order differ from memory order (about 200 times after a sort).

Why. Three costs remain. Each step must load next before it knows the next address, so the core cannot run ahead and the loop cannot be vectorised. A std::list<int> node is 24 bytes for 4 bytes of payload, so the scan moves six times the data. And the arena only fixes the layout at creation: a list that is edited over time drifts back to random order. The common wrong answer is that locality is the whole story; the dependency chain and the per-node overhead are the rest. If the data is iterated far more often than it is spliced, a vector (or indices into one) is still the better structure.

TrickyTo avoid false sharing, a per-thread counter type is declared struct alignas(64) Counter { std::atomic<long> v; };. Is that enough on every machine?

Answer

Answer. Not everywhere. It is enough on most x86 and Arm cores with 64-byte lines, but Apple silicon uses 128-byte lines, so two 64-byte counters can still share one line there. Intel's L2 spatial prefetcher also pulls 64-byte lines in 128-byte pairs, which causes milder interference between neighbours. 128 is the safer portable choice.

Why. Correctness also needs two other things that this declaration does get right: the alignment is on the type, so sizeof(Counter) becomes 64 and array elements are 64 bytes apart (with alignas on an array variable they would not be), and since C++17 new and std::vector honour the over-alignment. std::hardware_destructive_interference_size is 256 on arm64 builds and 64 on x86-64 builds with GCC and Clang, which shows that the "right" value depends on the target.

TrickyEight threads read the same configuration struct in a tight loop, and nobody writes it. Is there false sharing, or any coherence traffic?

Answer

Answer. No. Every core can hold a copy of the line in the Shared state at the same time, and reads hit in each core's own cache after the first miss.

Why. False sharing needs at least one writer: a write requires exclusive ownership, which invalidates the other copies. The trap is a read-mostly struct that sits on the same line as a frequently written field (a hit counter, a lock, a sequence number); then every write invalidates all the readers' copies. Keep read-mostly data and written data on separate lines. Read sharing is how lock-free readers in RCU and seqlocks stay fast.

TrickyWhy do some engineers avoid std::hardware_destructive_interference_size in public headers, even though it is standard C++17?

Answer

Answer. Because it is a compile-time constant chosen by the compiler for a target, and anything that uses it in a layout bakes that value into the ABI. Two translation units, or a library and its user, compiled with different flags or compiler versions can disagree on sizeof of the same struct.

Why. GCC warns about this with -Winterference-size and lets you fix the value with --param destructive-interference-size=N. The value also need not match the machine at run time: one arm64 binary runs on cores with 64-byte and 128-byte lines, and GCC and Clang both choose a conservative 256 for arm64. Many codebases define their own constant (64 or 128) for that reason. The constant is still fine inside a single program built in one go.

TrickyIs structure of arrays always faster than array of structures?

Answer

Answer. No. SoA wins for loops that touch a few fields of many objects (it was 2.4 to 3.1 times faster for x += vx in this chapter); AoS wins when code accesses whole objects one at a time, especially at random.

Why. Fetching all fields of entity i from an SoA layout costs one line per field (eight lines for eight fields), against one or two lines for an AoS object. SoA also makes insertion and deletion touch many arrays, and it complicates passing "an object" around. The usual compromises are hot/cold splitting and AoSoA (small SoA blocks inside an array), which many game engines and SIMD libraries use. The right layout follows the dominant access pattern, which you find by profiling.

TrickyA teammate adds #pragma pack(1) to all structs "to make them smaller and more cache friendly". What can go wrong?

Answer

Answer. Members become misaligned. Taking a member's address and using the pointer is undefined behaviour (UBSan reports "misaligned address"), atomics on packed members are not atomic or not allowed, some architectures trap or split the access, and the ABI of every struct changes, so it breaks interoperation with code compiled without the pragma.

Why. Accessing a packed member by name is safe, because the compiler knows it is packed and emits byte-wise or unaligned loads. Once you pass &s.field as an ordinary T*, that knowledge is lost. Reordering members by decreasing alignment gets almost all of the size benefit with none of the risk. Packing belongs only on structs that mirror an external byte format, and even there std::memcpy into a normal struct is cleaner.

TrickyWhy is std::unordered_map often several times slower than absl::flat_hash_map or boost::unordered_flat_map, when both are O(1) on average?

Answer

Answer. The standard's interface forces a node-based design: references to elements must stay valid across rehashing, and the bucket API (bucket, begin(n), local_iterator) must be supported. So every mainstream implementation allocates each element in its own node and chains nodes from a bucket array, and a lookup costs at least two dependent cache misses.

Why. Open-addressing tables store elements (or small metadata bytes plus elements) in one contiguous array and probe neighbouring slots, so a lookup usually touches one or two lines, and SIMD can check 16 metadata bytes at once (Swiss tables). The price is that a rehash moves elements, invalidating references and pointers. The common wrong answer is "a better hash function"; the hash is rarely the bottleneck.

ConceptExplain spatial and temporal locality with one example of code that has each, and one that has neither.

Answer

Answer. Spatial: a sequential scan of a std::vector uses every byte of each fetched line. Temporal: a blocked matrix multiply reuses each tile many times while it is in L1. Neither: following random pointers through a large linked structure, where each node is used once and its neighbours are unrelated.

Why. Caches bet on both kinds of locality: they fetch whole lines (spatial) and keep recently used lines (temporal). Code that defeats both pays the full memory latency on almost every access. Improving locality is usually about layout (what is next to what) and loop order (when data is reused), not about doing less work.

ConceptWhat is hot/cold splitting, and when is it worth doing?

Answer

Answer. Moving the fields that hot loops use into a compact struct and the rarely used fields into a separate struct (reached by index or pointer), so each cache line carries more useful data in the hot path.

Why. If an order object is 200 bytes but the matching loop reads 24 of them, the loop streams 200 bytes per order through the cache. Splitting makes the hot struct small enough for several per line. It pays when the loop is memory bound (profile: low IPC, high cache-miss rate) and the cold part is large; it costs an extra indirection when cold data is needed, and more complex code. Compilers can do a version of this for code (hot/cold function splitting with profile-guided optimisation), but not for your data.

ConceptWhat do hardware prefetchers detect, and when does __builtin_prefetch actually help?

Answer

Answer. Hardware prefetchers detect sequential streams and constant strides (per load instruction, forwards and backwards), plus adjacent lines on some Intel cores. Software prefetch helps only when the future address is known early, is not predictable by the hardware, and the core is not already overlapping the misses, for example batched hash-table lookups or tree descents.

Why. In this chapter's random gather benchmark on the M1, a prefetch 16 iterations ahead gave no speedup (0.9 to 1.0 times), because the loads were independent and the out-of-order core already had many in flight. Too short a distance arrives late, too long evicts useful data, and the right distance changes with the machine. Treat software prefetch as a measured, last-step optimisation.

ConceptWhat is the TLB, why can a program with a large random-access working set be slow even when cache misses look normal, and what do huge pages change?

Answer

Answer. The TLB caches virtual-to-physical translations. With 4 KB pages and about 1,500 second-level entries, it covers about 6 MB. Random access over gigabytes misses the TLB on most accesses, and each miss needs a page-table walk (up to four or five dependent loads) before the data access can even start. Huge pages (2 MB or 1 GB on x86-64) cover 512 or 262,144 times more per entry.

Why. perf stat -e dTLB-load-misses shows the problem. On Linux, enable transparent huge pages for the region with madvise(MADV_HUGEPAGE) or use MAP_HUGETLB; low-latency systems often pre-fault huge pages at startup. Apple silicon's 16 KB base pages give four times the reach of 4 KB pages, one reason TLB effects show up later there.

ConceptHow do you measure cache behaviour on Linux and on macOS?

Answer

Answer. Linux: perf stat -e cycles,instructions,cache-misses,L1-dcache-load-misses,LLC-load-misses,dTLB-load-misses for totals, perf record/perf report to attribute misses to code, perf c2c for false sharing, and valgrind --tool=cachegrind --cache-sim=yes for exact simulated per-line counts. macOS: Instruments with the CPU Counters template (or xctrace), since perf does not exist there and Valgrind does not run on Apple silicon.

Why. Counters tell you whether a program is memory bound (low IPC, high LLC misses) before you change any layout. Micro-benchmarks need repeated runs, a quiet machine, a result that is used (so the optimiser cannot delete the loop), and the machine and compiler named next to every number, as in this chapter.

ConceptHow do pools, arenas and reserve improve locality, beyond saving allocation time?

Answer

Answer. They control where objects land. An arena places the nodes of one structure consecutively, in allocation order, which is usually the order they are traversed, with no headers in between. A per-type pool packs objects of one kind densely. reserve gives a vector its final buffer at once instead of leaving abandoned 1, 2, 4 ... element buffers scattered in the heap.

Why. A general-purpose allocator interleaves objects from unrelated code paths and, after churn, hands out blocks wherever there is space, so logically adjacent nodes end up on different lines and pages. Grouping related data improves both cache and TLB hit rates. Index-based links (32-bit indices into a vector) push this further by halving link size. See Ch 15 and Ch 17 for the allocators themselves.

Predict the output and find the bug

Drills

OutputSame three members, two orders. What are the sizes?

#include <cstdio>

struct A { char a; int b; char c; };
struct B { int b; char a; char c; };

int main() {
    std::printf("%zu %zu\n", sizeof(A), sizeof(B));
    std::printf("%zu %zu\n", alignof(A), sizeof(A[4]));
}
Answer
12 8
4 48

Why. In A, b must start at a multiple of 4, so 3 padding bytes follow a; c sits at offset 8, and 3 tail bytes round the size up to 12, a multiple of the struct's alignment (4). In B, a and c pack after b and only 2 tail bytes are needed: 8. An array of four A is exactly 4 × 12 = 48: arrays never add padding between elements, which is why the tail padding exists. The common wrong answer is 6 for A (summing the members).

OutputDoes putting the double last save space?

#include <cstddef>
#include <cstdio>

struct C { char c; double d; };
struct D { double d; char c; };

int main() {
    std::printf("%zu %zu\n", sizeof(C), sizeof(D));
    std::printf("%zu %zu\n", offsetof(C, d), offsetof(D, c));
}
Answer
16 16
8 8

Why. Both are 16. In C the padding is 7 bytes between c and d (so offsetof(C, d) is 8); in D the same 7 bytes move to the tail, after c at offset 8, because an array of D must keep every d 8-byte aligned. Reordering only helps when several small members can share the space that one of them would waste. People often expect D to be 9.

OutputHow big are an empty struct, a member of it, a [[no_unique_address]] member of it, and an empty base?

#include <cstdio>

struct Empty {};
struct F { int x; Empty e; };
struct G { int x; [[no_unique_address]] Empty e; };
struct H : Empty { int x; };

int main() {
    std::printf("%zu %zu %zu %zu\n", sizeof(Empty), sizeof(F), sizeof(G), sizeof(H));
}
Answer
1 8 4 4

Why. Every complete object has a size of at least 1 so that distinct objects have distinct addresses, so sizeof(Empty) is 1. As an ordinary member it occupies a byte, and alignment rounds F to 8. [[no_unique_address]] (C++20) lets the member overlap other members, so G is 4, and the empty base optimisation does the same for a base class in H. This is how std::unique_ptr<T> with the default deleter stays the size of a raw pointer (Ch 12). MSVC ignores the standard attribute and needs [[msvc::no_unique_address]].

OutputWhat do alignas on a type and alignas on a member do to size and offsets?

#include <cstddef>
#include <cstdio>

struct alignas(16) Vec3 { float x, y, z; };
struct Mixed {
    char tag;
    alignas(64) int counter;
};

int main() {
    std::printf("%zu %zu %zu\n", sizeof(Vec3), alignof(Vec3), sizeof(Vec3[3]));
    std::printf("%zu %zu\n", sizeof(Mixed), offsetof(Mixed, counter));
}
Answer
16 16 48
128 64

Why. Vec3 holds 12 bytes of data, but alignas(16) makes its alignment 16 and rounds its size to 16 (useful for SIMD loads), so three of them take 48 bytes. In Mixed, the member counter must start at a multiple of 64, which puts it at offset 64 after 63 bytes of padding, and the struct's alignment becomes 64, so its size rounds up from 68 to 128. Aligning a member is an easy way to waste a line by accident.

OutputBit-fields: what are the sizes with GCC and Clang on a 64-bit target?

#include <cstdio>

struct Flags { unsigned a : 3; unsigned b : 5; unsigned c : 24; };
struct Wide  { unsigned a : 3; unsigned long long b : 40; };
struct Split { char a; unsigned b : 4; char c; };

int main() {
    std::printf("%zu %zu %zu\n", sizeof(Flags), sizeof(Wide), sizeof(Split));
}
Answer
4 8 4

Why. Flags packs 3 + 5 + 24 = 32 bits into one unsigned. Wide packs 43 bits into one 8-byte unit. In Split, the Itanium ABI used by GCC and Clang places the 4-bit field in the byte after a, because it still fits within an aligned unsigned unit, then c, and rounds the size to the 4-byte alignment of unsigned. Bit-field layout is implementation-defined: MSVC allocates a whole unsigned for b and gives Split a size of 12. Never use bit-fields to match an external format across compilers.

OutputA market data quote, as written and after reordering. How many fit in a 64-byte line?

#include <cstdint>
#include <cstdio>

struct Quote {
    bool valid;
    std::int64_t price;
    bool is_bid;
    std::int32_t size;
    bool stale;
};

struct QuoteSorted {
    std::int64_t price;
    std::int32_t size;
    bool valid, is_bid, stale;
};

int main() {
    std::printf("%zu %zu\n", sizeof(Quote), sizeof(QuoteSorted));
    std::printf("quotes per 64-byte line: %zu vs %zu\n", 64 / sizeof(Quote), 64 / sizeof(QuoteSorted));
}
Answer
32 16
quotes per 64-byte line: 2 vs 4

Why. In Quote, each bool in front of a wider member wastes up to 7 bytes: valid (1 + 7 padding), price (8), is_bid (1 + 3), size (4), stale (1 + 7 tail) = 32. Sorted by decreasing alignment the data is 8 + 4 + 3 = 15 bytes, rounded to 16. Halving the struct doubles the quotes per line and halves the memory traffic of a scan over a book, with no code change.

OutputThis guide's harness targets arm64 (Apple M1). What does it print?

#include <cstdio>
#include <new>

struct Poly { virtual ~Poly() = default; char tag; };
struct alignas(std::hardware_destructive_interference_size) Slot { long value; };

int main() {
    std::printf("%zu\n", sizeof(Poly));
    std::printf("%zu %zu\n", std::hardware_destructive_interference_size, std::hardware_constructive_interference_size);
    std::printf("%zu\n", sizeof(Slot));
}
Answer
16
256 64
256

Why. Poly has a vptr (8 bytes) because of the virtual destructor, then tag, rounded to 16 by the pointer's alignment (Ch 2). GCC 16 and Clang 22 both report 256 and 64 for the interference sizes on arm64, so Slot becomes 256 bytes for one long. Compiled for x86-64, the same program prints 64 and 64, and Slot is 64. That difference is the reason the constants are risky in headers: the layout depends on the target the header was compiled for.

Find the bugThis parser works on x86 and on the M1. Which tool objects, and why?

#include <cstdint>
#include <cstdio>

#pragma pack(push, 1)
struct WireHeader {          // matches a network message byte for byte
    std::uint8_t type;
    std::uint32_t length;
    std::uint16_t flags;
};
#pragma pack(pop)

static std::uint32_t read_u32(const std::uint32_t* p) { return *p; }

int main() {
    WireHeader h[2] = {{1, 100, 0}, {2, 200, 0}};
    std::uint32_t total = 0;
    for (const WireHeader& w : h) total += read_u32(&w.length);
    std::printf("sizeof %zu, total %u\n", sizeof(WireHeader), total);
}
Answer
sizeof 7, total 300

Answer. &w.length is a uint32_t* to an address that is not a multiple of 4 (byte offset 1 inside the first element). Dereferencing it in read_u32 is undefined behaviour, and UBSan reports "load of misaligned address ... which requires 4 byte alignment".

Why. Inside the packed struct the compiler knows the member is misaligned and uses safe loads. Through a plain uint32_t* it assumes 4-byte alignment and may use instructions that fault on misaligned addresses (some Arm and older architectures, or SIMD loads on x86). It "works" here only because both test CPUs tolerate misaligned scalar loads. Fix: read the field by value (std::uint32_t len = w.length;) or std::memcpy the bytes into a normal, aligned struct. GCC's -Waddress-of-packed-member catches some of these at compile time, but not this one.

Find the bugThe result is correct and there is no data race. Why does this scale badly when the loop body is real work?

#include <cstdio>
#include <thread>
#include <vector>

int main() {
    const int threads = 4;
    long hits[threads] = {};                 // one slot per thread: no data race
    std::vector<std::thread> pool;
    for (int t = 0; t < threads; ++t)
        pool.emplace_back([&hits, t] {
            for (int i = 0; i < 1'000'000; ++i)
                if (i % 3 == 0) ++hits[t];
        });
    for (auto& th : pool) th.join();
    long total = 0;
    for (long h : hits) total += h;
    std::printf("total %ld\n", total);
}
Answer
total 1333336

Answer. False sharing. The four longs occupy 32 contiguous bytes, one cache line, so every increment by one thread invalidates the line in the other three cores. With real work in the loop (function calls, memory accesses the compiler cannot see through), each ++hits[t] is a store to that shared line.

Why. At -O1 and above the compiler may keep hits[t] in a register for this simple loop and store it once at the end, which hides the problem in a micro-test. Do that explicitly: accumulate into a local long local = 0; and write hits[t] = local; once, or give each slot its own line with struct alignas(128) Slot { long v; };. This chapter's benchmark measured about 22 times between packed and padded atomic counters on the M1.

Find the bugThe comment claims the counters are on separate cache lines. Are they?

#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>

alignas(64) std::atomic<long> counters[4];   // "aligned to a cache line, so no false sharing"

int main() {
    std::vector<std::thread> pool;
    for (int t = 0; t < 4; ++t)
        pool.emplace_back([t] { for (int i = 0; i < 100'000; ++i) counters[t].fetch_add(1, std::memory_order_relaxed); });
    for (auto& th : pool) th.join();
    std::printf("sum %ld, distance between counters %td bytes\n",
                counters[0] + counters[1] + counters[2] + counters[3],
                reinterpret_cast<char*>(&counters[1]) - reinterpret_cast<char*>(&counters[0]));
}
Answer
sum 400000, distance between counters 8 bytes

Answer. No. alignas(64) on an array variable aligns the array's first byte. The elements keep their natural size, 8 bytes, so all four counters share one line, exactly the false sharing the comment claims to prevent.

Fix. Put the alignment on the element type, which also rounds its size: struct alignas(128) Counter { std::atomic<long> v{0}; }; Counter counters[4];. Then &counters[1] - &counters[0] is 128 bytes. Add a static_assert(sizeof(Counter) == 128) so the intent is checked. This is one of the most common false-sharing "fixes" that does nothing.

Find the bugThe answer is right. What makes it several times slower than it needs to be, and what is a second, smaller problem with the data structure?

#include <cstdio>
#include <vector>

static long long sum_grid(const std::vector<std::vector<int>>& g) {
    long long s = 0;
    for (std::size_t col = 0; col < g[0].size(); ++col)
        for (std::size_t row = 0; row < g.size(); ++row)
            s += g[row][col];
    return s;
}

int main() {
    std::vector<std::vector<int>> grid(2000, std::vector<int>(2000, 1));
    std::printf("%lld\n", sum_grid(grid));
}
Answer
4000000

Answer. The loops are in column-major order over row-major data: the inner loop jumps to a different row (a different heap block) on every iteration, using 4 bytes of each fetched line. Swap the loops so the inner loop walks along g[row]. The second problem is vector<vector<int>> itself: 2,000 separate allocations, an extra indirection per row, and no guarantee that rows are adjacent.

Why. This chapter's row/column benchmark measured 5 to 9 times on the M1 for a 64 MB matrix. A single std::vector<int> of rows * cols elements indexed as v[r * cols + c] (or std::mdspan in C++23) is contiguous, has one allocation, and lets the compiler vectorise the row loop.

Write the code

Coding

CodeConvert an array-of-structures particle system to structure-of-arrays. Given std::vector<Particle> with position, velocity, mass and id, write a ParticleSystem class with one vector per field, a constructor from the AoS vector, an integrate(dt) that updates positions, total_mass(), and get(i) that reassembles one particle. Show that both layouts give identical results.

Answer

Approach. One std::vector per field, all with the same length, filled in one pass with reserve first. Write the update as separate tight loops over two arrays each, so every loop streams through exactly the bytes it needs and the compiler can vectorise it. total_mass reads one array. get(i) shows the cost of SoA: one load per field from a different array.

#include <cmath>
#include <cstddef>
#include <cstdio>
#include <vector>

struct Particle {            // AoS: what the code base had
    float x, y, z;
    float vx, vy, vz;
    float mass;
    int id;
};

class ParticleSystem {       // SoA: one array per field
public:
    std::vector<float> x, y, z, vx, vy, vz, mass;
    std::vector<int> id;

    explicit ParticleSystem(const std::vector<Particle>& in) {
        const std::size_t n = in.size();
        for (auto* v : {&x, &y, &z, &vx, &vy, &vz, &mass}) v->reserve(n);
        id.reserve(n);
        for (const Particle& p : in) {
            x.push_back(p.x); y.push_back(p.y); z.push_back(p.z);
            vx.push_back(p.vx); vy.push_back(p.vy); vz.push_back(p.vz);
            mass.push_back(p.mass); id.push_back(p.id);
        }
    }
    std::size_t size() const { return x.size(); }

    void integrate(float dt) {                       // reads 6 streams, writes 3: all sequential
        const std::size_t n = size();
        for (std::size_t i = 0; i < n; ++i) x[i] += vx[i] * dt;
        for (std::size_t i = 0; i < n; ++i) y[i] += vy[i] * dt;
        for (std::size_t i = 0; i < n; ++i) z[i] += vz[i] * dt;
    }
    double total_mass() const {                      // touches only one array
        double s = 0;
        for (float m : mass) s += m;
        return s;
    }
    Particle get(std::size_t i) const {              // gathering one entity costs 8 loads
        return {x[i], y[i], z[i], vx[i], vy[i], vz[i], mass[i], id[i]};
    }
};

static void integrate_aos(std::vector<Particle>& ps, float dt) {
    for (Particle& p : ps) { p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; }
}

int main() {
    std::vector<Particle> aos;
    for (int i = 0; i < 1000; ++i)
        aos.push_back({float(i), 0, 0, 1.0f, 0.5f, -0.25f, 1.0f + float(i % 3), i});
    ParticleSystem soa(aos);

    for (int step = 0; step < 10; ++step) { integrate_aos(aos, 0.1f); soa.integrate(0.1f); }

    bool same = true;
    for (std::size_t i = 0; i < aos.size(); ++i) {
        Particle s = soa.get(i);
        same = same && s.x == aos[i].x && s.y == aos[i].y && s.z == aos[i].z && s.id == aos[i].id;
    }
    std::printf("same results: %s\n", same ? "yes" : "no");
    std::printf("particle 3: (%.2f, %.2f, %.2f)\n", soa.get(3).x, soa.get(3).y, soa.get(3).z);
    std::printf("total mass %.0f\n", soa.total_mass());
}
same results: yes
particle 3: (4.00, 0.50, -0.25)
total mass 1999

Complexity and pitfalls. O(n) per step either way; the gain is bandwidth (only used bytes cross the memory bus) and SIMD. Pitfalls: letting the field vectors get out of sync in length (wrap them in one class and never expose push_back on a single field), erasing from the middle (every array must shift; use swap-with-last if order does not matter), and expecting SoA to help code that reads whole particles at random. Floating-point results match here because both versions perform the same operations in the same order.

Estimated rating: ~1200 · straightforward refactor whose value is in explaining the trade-off

CodeWrite a layout calculator: given a list of fields with size and alignment, compute each field's offset, the padding and the struct's sizeof under the usual C/Itanium rules, then compute the order that minimises the size. Check your numbers against the compiler's sizeof for the real structs.

Answer

Approach. Walk the fields keeping a running offset: round the offset up to the field's alignment, place the field, add its size. The struct's alignment is the maximum field alignment, and the final size is the offset rounded up to it. For fundamental types (size a multiple of alignment), sorting by decreasing alignment gives the minimum size, and a stable sort keeps related fields together within each alignment class.

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <vector>

struct Field { const char* name; std::size_t size, align; };

// The C/Itanium layout rule: each member goes at the next offset that is a multiple of
// its alignment; the struct's alignment is the largest member alignment, and its size
// is rounded up to that so that arrays keep every element aligned.
static std::size_t layout_size(const std::vector<Field>& fs, bool print) {
    std::size_t off = 0, max_align = 1;
    for (const Field& f : fs) {
        std::size_t at = (off + f.align - 1) / f.align * f.align;
        if (print && at != off) std::printf("  [%zu bytes padding]\n", at - off);
        if (print) std::printf("  %-8s offset %2zu size %zu\n", f.name, at, f.size);
        off = at + f.size;
        max_align = std::max(max_align, f.align);
    }
    std::size_t total = (off + max_align - 1) / max_align * max_align;
    if (print && total != off) std::printf("  [%zu bytes tail padding]\n", total - off);
    return total;
}

static std::vector<Field> best_order(std::vector<Field> fs) {
    std::stable_sort(fs.begin(), fs.end(), [](const Field& a, const Field& b) { return a.align > b.align; });
    return fs;
}

struct Order {                         // as written by the original author
    bool active;
    double price;
    std::int16_t venue;
    std::int64_t qty;
    char side;
    std::int32_t trader;
    bool hidden;
};
struct OrderSorted {                   // largest alignment first
    double price;
    std::int64_t qty;
    std::int32_t trader;
    std::int16_t venue;
    bool active;
    char side;
    bool hidden;
};

int main() {
    std::vector<Field> fs = {{"active", 1, 1}, {"price", 8, 8}, {"venue", 2, 2}, {"qty", 8, 8},
                             {"side", 1, 1}, {"trader", 4, 4}, {"hidden", 1, 1}};
    std::printf("declared order:\n");
    std::size_t a = layout_size(fs, true);
    std::printf("computed %zu, compiler says %zu\n", a, sizeof(Order));
    std::printf("sorted by alignment:\n");
    std::size_t b = layout_size(best_order(fs), true);
    std::printf("computed %zu, compiler says %zu\n", b, sizeof(OrderSorted));
    std::printf("saved per million orders: %zu KB\n", (a - b) * 1'000'000 / 1000);
}
declared order:
  active   offset  0 size 1
  [7 bytes padding]
  price    offset  8 size 8
  venue    offset 16 size 2
  [6 bytes padding]
  qty      offset 24 size 8
  side     offset 32 size 1
  [3 bytes padding]
  trader   offset 36 size 4
  hidden   offset 40 size 1
  [7 bytes tail padding]
computed 48, compiler says 48
sorted by alignment:
  price    offset  0 size 8
  qty      offset  8 size 8
  trader   offset 16 size 4
  venue    offset 20 size 2
  active   offset 22 size 1
  side     offset 23 size 1
  hidden   offset 24 size 1
  [7 bytes tail padding]
computed 32, compiler says 32
saved per million orders: 16000 KB

Complexity and pitfalls. O(n log n) for the sort. Pitfalls: forgetting tail padding (the most common error in interviews), rounding with % on non-power-of-two values incorrectly, and assuming the rule holds for bit-fields, empty members with [[no_unique_address]], virtual bases or #pragma pack, which all change it. The 25 bytes of data cannot fit in less than 32 with 8-byte alignment, so the sorted order is optimal. In real code, also check that the hot fields still share a line after reordering.

Estimated rating: ~1300 · simple simulation, but you must know the alignment and tail-padding rules exactly

CodeImplement a sharded counter for many threads: add(shard, v) must not suffer false sharing, total() may be called concurrently with writers, and the slots must be on separate cache lines on both x86 and Apple silicon, including when allocated on the heap. Verify the slot spacing and alignment at run time.

Answer

Approach. Put the alignment on the slot type, struct alignas(128) Slot, so its size rounds up to 128 and array elements are 128 bytes apart. Allocate the array with new Slot[n], which since C++17 calls the aligned operator new. Each thread updates only its own slot with a relaxed atomic add (no ordering is needed for a statistic), and total() sums relaxed loads, which gives a value that was true at some point during the summation for each slot.

#include <atomic>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <new>
#include <thread>
#include <vector>

// 128 covers Apple silicon's 128-byte lines and Intel's adjacent-line prefetcher, which
// pulls 64-byte lines in pairs. std::hardware_destructive_interference_size is an
// alternative, but it is a compile-time guess that can differ from the machine.
inline constexpr std::size_t kLine = 128;

class ShardedCounter {
    struct alignas(kLine) Slot {
        std::atomic<long> value{0};
    };
    static_assert(sizeof(Slot) == kLine && alignof(Slot) == kLine);
    std::unique_ptr<Slot[]> slots_;   // C++17 aligned new honours alignas(128)
    std::size_t n_;

public:
    explicit ShardedCounter(std::size_t shards) : slots_(new Slot[shards]), n_(shards) {}
    void add(std::size_t shard, long v) { slots_[shard % n_].value.fetch_add(v, std::memory_order_relaxed); }
    long total() const {                     // a racy-but-safe snapshot while writers run
        long s = 0;
        for (std::size_t i = 0; i < n_; ++i) s += slots_[i].value.load(std::memory_order_relaxed);
        return s;
    }
    std::size_t distance_bytes() const {
        return static_cast<std::size_t>(reinterpret_cast<const char*>(&slots_[1]) - reinterpret_cast<const char*>(&slots_[0]));
    }
    bool aligned() const { return reinterpret_cast<std::uintptr_t>(slots_.get()) % kLine == 0; }
};

int main() {
    const int threads = 4;
    const long per_thread = 200'000;
    ShardedCounter c(threads);
    std::vector<std::thread> pool;
    for (int t = 0; t < threads; ++t)
        pool.emplace_back([&c, t] { for (long i = 0; i < per_thread; ++i) c.add(t, 1); });
    for (auto& th : pool) th.join();
    std::printf("total %ld\n", c.total());
    std::printf("slot distance %zu bytes, base aligned: %s\n", c.distance_bytes(), c.aligned() ? "yes" : "no");
}
total 800000
slot distance 128 bytes, base aligned: yes

Complexity and pitfalls. O(1) add, O(shards) total; memory 128 bytes per shard. Pitfalls: alignas on an array variable (aligns only the start), 64-byte padding on Apple silicon, using a plain long instead of an atomic when total() runs concurrently (a data race that TSan reports), and choosing the shard by thread id hash with too few shards, which reintroduces contention on the atomic itself. Production versions (for example in Folly or the Linux kernel's per-CPU counters) shard by CPU rather than thread.

Estimated rating: ~1300 · small code, but it tests alignment rules, C++17 aligned new and memory orders

CodeWrite a cache-friendly (blocked) transpose of a row-major rows × cols float matrix into a cols × rows matrix. It must handle sizes that are not multiples of the tile size and non-square matrices, and you must test it against a naive version.

Answer

Approach. The naive transpose reads along rows but writes down columns, so one side is always strided. Process the matrix in B × B tiles: within a tile, the B source lines and B destination lines all fit in L1 (32 × 32 floats is 4 KB each side), so each fetched line is used fully before eviction. Clamp each tile's end with std::min for the edges.

#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <utility>
#include <vector>

// out = transpose(in), where in is rows × cols, row-major, and out is cols × rows.
static void transpose_naive(const float* in, float* out, std::size_t rows, std::size_t cols) {
    for (std::size_t r = 0; r < rows; ++r)
        for (std::size_t c = 0; c < cols; ++c)
            out[c * rows + r] = in[r * cols + c];          // writes stride through memory
}

template <std::size_t B = 32>
static void transpose_blocked(const float* in, float* out, std::size_t rows, std::size_t cols) {
    for (std::size_t r0 = 0; r0 < rows; r0 += B)
        for (std::size_t c0 = 0; c0 < cols; c0 += B) {
            const std::size_t r1 = std::min(r0 + B, rows), c1 = std::min(c0 + B, cols);
            for (std::size_t r = r0; r < r1; ++r)           // one B × B tile: B lines read,
                for (std::size_t c = c0; c < c1; ++c)       // B lines written, all stay in L1
                    out[c * rows + r] = in[r * cols + c];
        }
}

int main() {
    bool all_ok = true;
    for (auto [rows, cols] : {std::pair<std::size_t, std::size_t>{1000, 1000}, {37, 1029}, {1, 5}, {64, 64}}) {
        std::vector<float> in(rows * cols), a(rows * cols), b(rows * cols);
        for (std::size_t i = 0; i < in.size(); ++i) in[i] = float(i);
        transpose_naive(in.data(), a.data(), rows, cols);
        transpose_blocked(in.data(), b.data(), rows, cols);
        bool ok = a == b && (rows < 2 || b[1] == in[cols]);  // out[0][1] == in[1][0]
        std::printf("%zu x %zu: %s\n", rows, cols, ok ? "match" : "MISMATCH");
        all_ok = all_ok && ok;
    }
    std::printf("%s\n", all_ok ? "all tests passed" : "failure");
}
1000 x 1000: match
37 x 1029: match
1 x 5: match
64 x 64: match
all tests passed

Complexity and pitfalls. O(rows × cols) for both versions; the blocked one reduces cache misses from about one per element to about two per line. Measured on the M1 (see the prefetching section): about 6 times faster at 4000 × 4000, no gain at 2000 × 2000, and only modest gains at 4096 × 4096, where power-of-two rows map to the same cache sets. Pitfalls: integer overflow in r * cols with int indices on large matrices (use std::size_t), wrong edge handling, and tuning B on one machine only. Further steps are padding rows away from powers of two, transposing 4 × 4 or 8 × 8 micro-tiles in SIMD registers, and recursive (cache-oblivious) splitting.

Estimated rating: ~1400 · classic loop tiling; the edge cases and the explanation matter more than the code

CodeImplement an open-addressing hash map from uint64_t keys to values with linear probing, power-of-two capacity, a maximum load factor of 7/8, insert_or_assign, find and erase without tombstones. Test it against std::unordered_map with random operations.

Answer

Approach. Store entries in one contiguous array and keep occupancy in a dense byte array. Hash with a strong mixer (splitmix64) and mask with capacity - 1. Probe forwards until the key or an empty slot. For erase, use backward-shift deletion: after emptying slot i, scan forward through the cluster and move back any entry whose home position means its probe sequence passes through i; stop at the first empty slot. That keeps the invariant "no empty slot between an entry and its home", so lookups can stop at the first empty slot and no tombstones are needed.

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <random>
#include <unordered_map>
#include <utility>
#include <vector>

// Open addressing with linear probing: one contiguous array, so a lookup usually
// touches one or two cache lines instead of chasing a bucket's linked list.
template <class V>
class FlatHashMap {
    struct Slot { std::uint64_t key; V value; };
    std::vector<Slot> slots_;
    std::vector<std::uint8_t> full_;   // 1 = occupied: dense metadata, like a Swiss table's control bytes
    std::size_t size_ = 0;
    std::size_t mask_ = 0;

    static std::uint64_t mix(std::uint64_t k) {   // splitmix64 finaliser: spreads nearby keys
        k ^= k >> 30; k *= 0xbf58476d1ce4e5b9ULL;
        k ^= k >> 27; k *= 0x94d049bb133111ebULL;
        return k ^ (k >> 31);
    }
    std::size_t home(std::uint64_t k) const { return static_cast<std::size_t>(mix(k)) & mask_; }

    void rehash(std::size_t cap) {
        std::vector<Slot> old_slots = std::move(slots_);
        std::vector<std::uint8_t> old_full = std::move(full_);
        slots_.assign(cap, Slot{});
        full_.assign(cap, 0);
        mask_ = cap - 1;
        size_ = 0;
        for (std::size_t i = 0; i < old_slots.size(); ++i)
            if (old_full[i]) insert_or_assign(old_slots[i].key, std::move(old_slots[i].value));
    }

public:
    FlatHashMap() { rehash(16); }
    std::size_t size() const { return size_; }

    void insert_or_assign(std::uint64_t key, V value) {
        if ((size_ + 1) * 8 > slots_.size() * 7) rehash(slots_.size() * 2);   // max load 7/8
        for (std::size_t i = home(key);; i = (i + 1) & mask_) {
            if (!full_[i]) { slots_[i] = Slot{key, std::move(value)}; full_[i] = 1; ++size_; return; }
            if (slots_[i].key == key) { slots_[i].value = std::move(value); return; }
        }
    }
    V* find(std::uint64_t key) {
        for (std::size_t i = home(key); full_[i]; i = (i + 1) & mask_)
            if (slots_[i].key == key) return &slots_[i].value;
        return nullptr;
    }
    bool erase(std::uint64_t key) {
        std::size_t i = home(key);
        while (full_[i] && slots_[i].key != key) i = (i + 1) & mask_;
        if (!full_[i]) return false;
        // Backward-shift deletion: pull later members of the cluster into the hole, so no
        // tombstones are needed and lookups can stop at the first empty slot.
        for (std::size_t j = (i + 1) & mask_; full_[j]; j = (j + 1) & mask_) {
            std::size_t h = home(slots_[j].key);
            if (((j - h) & mask_) >= ((j - i) & mask_)) {   // slot j may legally move back to i
                slots_[i] = std::move(slots_[j]);
                i = j;
            }
        }
        full_[i] = 0;
        slots_[i] = Slot{};
        --size_;
        return true;
    }
};

int main() {
    FlatHashMap<int> m;
    std::unordered_map<std::uint64_t, int> ref;
    std::mt19937_64 rng(2026);
    int mismatches = 0;
    for (int step = 0; step < 200'000; ++step) {
        std::uint64_t k = rng() % 5000;                 // small key space forces collisions and erasures
        switch (rng() % 3) {
        case 0: m.insert_or_assign(k, step); ref[k] = step; break;
        case 1: mismatches += m.erase(k) != (ref.erase(k) == 1); break;
        default: {
            int* v = m.find(k);
            auto it = ref.find(k);
            mismatches += (v == nullptr) != (it == ref.end()) || (v && *v != it->second);
        }
        }
    }
    std::printf("size %zu vs %zu, mismatches %d\n", m.size(), ref.size(), mismatches);
}
size 2485 vs 2485, mismatches 0

Complexity and pitfalls. Expected O(1) for all operations at a bounded load factor; a lookup usually touches one or two cache lines, against at least two dependent misses in a node-based table. Pitfalls: a weak hash with power-of-two masking (sequential keys collide into long clusters; identity hashing is the classic mistake), the wrap-around arithmetic in the backward-shift condition (do it modulo the capacity), forgetting that rehash invalidates pointers returned by find, and letting the load factor approach 1, where linear probing degrades sharply. This version requires V to be default constructible; production tables (Swiss tables) keep control bytes separately, use SIMD to test 16 slots at once, and construct values in raw storage.

Estimated rating: ~1800 · backward-shift deletion and the probe-distance condition are easy to get subtly wrong

CodeImplement FlatMap<K, V>, an ordered map stored as a sorted std::vector<std::pair<K, V>>, with insert_or_assign, find, erase, ordered iteration, and a bulk assign_unsorted that sorts once and keeps the last value for duplicate keys. Test it against std::map.

Answer

Approach. Every operation starts with std::lower_bound on the key, a binary search over contiguous memory. Insert and erase use vector::insert and vector::erase, which shift the tail. For bulk loading, appending everything and sorting once is O(n log n), against O(n²) for repeated sorted inserts; a stable sort followed by an in-place deduplication pass gives "last write wins".

#include <algorithm>
#include <cstdio>
#include <map>
#include <random>
#include <string>
#include <utility>
#include <vector>

// A sorted vector of pairs: lookups are a binary search over contiguous memory,
// iteration is a linear scan. Insertion is O(n) (a memmove), so it suits
// "build once, read many" data such as configuration, symbol tables or order-book levels.
template <class K, class V>
class FlatMap {
    std::vector<std::pair<K, V>> data_;
    auto lower(const K& k) {
        return std::lower_bound(data_.begin(), data_.end(), k, [](const auto& p, const K& key) { return p.first < key; });
    }
    auto lower(const K& k) const {
        return std::lower_bound(data_.begin(), data_.end(), k, [](const auto& p, const K& key) { return p.first < key; });
    }

public:
    void reserve(std::size_t n) { data_.reserve(n); }
    std::size_t size() const { return data_.size(); }
    bool insert_or_assign(const K& k, V v) {           // true if a new key was inserted
        auto it = lower(k);
        if (it != data_.end() && it->first == k) { it->second = std::move(v); return false; }
        data_.insert(it, {k, std::move(v)});
        return true;
    }
    const V* find(const K& k) const {
        auto it = lower(k);
        return it != data_.end() && it->first == k ? &it->second : nullptr;
    }
    bool erase(const K& k) {
        auto it = lower(k);
        if (it == data_.end() || it->first != k) return false;
        data_.erase(it);
        return true;
    }
    // Bulk build: append unsorted, then sort and deduplicate once (last write wins).
    template <class It>
    void assign_unsorted(It first, It last) {
        data_.assign(first, last);
        std::stable_sort(data_.begin(), data_.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
        auto out = data_.begin();
        for (auto it = data_.begin(); it != data_.end(); ++it) {
            if (out != data_.begin() && std::prev(out)->first == it->first) *std::prev(out) = std::move(*it);
            else {
                if (out != it) *out = std::move(*it);   // never self-move-assign
                ++out;
            }
        }
        data_.erase(out, data_.end());
    }
    auto begin() const { return data_.begin(); }
    auto end() const { return data_.end(); }
};

int main() {
    FlatMap<int, std::string> fm;
    std::map<int, std::string> ref;
    std::mt19937 rng(19);
    int mismatches = 0;
    for (int i = 0; i < 20'000; ++i) {
        int k = static_cast<int>(rng() % 500);
        std::string v = "v" + std::to_string(i);
        if (rng() % 4 == 0) mismatches += fm.erase(k) != (ref.erase(k) == 1);
        else { fm.insert_or_assign(k, v); ref[k] = v; }
    }
    for (const auto& [k, v] : ref) {
        const std::string* got = fm.find(k);
        mismatches += !got || *got != v;
    }
    std::printf("size %zu vs %zu, mismatches %d, ordered: %s\n", fm.size(), ref.size(), mismatches,
                std::is_sorted(fm.begin(), fm.end()) ? "yes" : "no");

    std::vector<std::pair<int, std::string>> raw = {{3, "c"}, {1, "a"}, {3, "C"}, {2, "b"}};
    FlatMap<int, std::string> bulk;
    bulk.assign_unsorted(raw.begin(), raw.end());
    for (const auto& [k, v] : bulk) std::printf("%d=%s ", k, v.c_str());
    std::printf("\n");
}
size 374 vs 374, mismatches 0, ordered: yes
1=a 2=b 3=C 

Complexity and pitfalls. Lookup O(log n) with excellent constants (the last few probes of the binary search share lines), iteration at memory bandwidth, insert and erase O(n). It beats std::map for read-heavy maps up to large sizes, and C++23 standardises the idea as std::flat_map (with keys and values in separate containers, which is even more cache friendly for key searches). Pitfalls: insert and erase invalidate all iterators and references, unlike std::map, and a self-move-assignment in the deduplication loop (*out = std::move(*it) when out == it) can leave a std::string value empty (a self-moved object is only "valid but unspecified"), which is why the code checks out != it. The bulk-load test with a duplicate key is what catches it.

Estimated rating: ~1400 · simple container, with an in-place dedupe that hides a self-move bug

Cheat sheet

Theory
  • Latency ladder: L1 about 1 ns, L2 about 4 ns, L3 about 15 ns, DRAM about 100 ns; numbers vary by machine, ratios do not. Dependent misses serialise, independent ones overlap.
  • Lines: 64 bytes on x86 and most Arm cores, 128 on Apple silicon. hardware_destructive_interference_size is 64 on x86-64 and 256 on arm64 with GCC and Clang, and it is a compile-time guess.
  • Contiguous beats node-based: vector over list (3 times faster in allocation order, about 200 times when scattered, on the M1), flat or open-addressing maps over std::map/unordered_map for read-heavy work.
  • Layout: order members by decreasing alignment, split hot from cold fields, choose SoA for field-wise loops and AoS for entity-wise access.
  • False sharing: threads writing different variables on one line; fix with alignas(128) types (not alignas on an array) or thread-local accumulation. About 22 times on the M1 with 4 threads.
  • Prefetch: hardware handles sequential and strided access; software prefetch only for unpredictable addresses known early, and it gave nothing here. Block (tile) loops whose working set exceeds the cache, and avoid power-of-two strides.
  • TLB: 4 KB pages cover a few MB of TLB reach; huge pages (2 MB, THP or MAP_HUGETLB) fix random access over large heaps.
  • Measure: perf stat, perf c2c, cachegrind on Linux; Instruments CPU Counters on macOS. Name the machine with every number.

Part F · Move semantics and value passing · Chapter 20

Move semantics

Moves let an object hand its resources to another object instead of duplicating them. This chapter covers value categories, rvalue references, what std::move really does, how to write the move constructor and move assignment operator, and why noexcept decides whether std::vector moves or copies. Interviewers use this topic to separate people who memorised "move is faster" from people who can predict which special member runs.

TheoryDrillsCoding· about 4 hours· prerequisites: Ch 1 (rule of 3/5/0), Ch 11 (unique_ptr)

Why moves exist

Theory

Mental model. A resource-owning type (a string, a vector, a buffer, a file handle) is a small "header" object that points at something expensive: heap memory, a kernel descriptor, a thread. Copying such an object means duplicating the expensive part. When the source is about to die anyway (a temporary, or a local you will never touch again), that duplication is wasted work. A move copies only the header and leaves the source in an empty but valid state, so its destructor frees nothing.

Before C++11 the language had no way to tell "an object you may plunder" from "an object you must preserve". Functions returning a std::vector by value relied on the optimiser eliding copies, and code like v.push_back(std::string(1000, 'x')) allocated twice. C++11 added a new reference type, T&&, which binds only to expressions the program has marked as expendable, plus two new special members that overload on it.

Copy: Buffer b = a; Move: Buffer b = std::move(a); a size=4 data=• b size=4 data=• heap: a b c d heap: a b c d new allocation + memcpy: O(n) two owners, two frees a (moved-from) size=0 data=null b size=4 data=• heap: a b c d pointer stolen copy two words, null the source: O(1) one owner, one free
A move is a shallow copy plus "disarm the source". The copy duplicates the heap block. The move copies the pointer and size, then sets the source to an empty state so its destructor has nothing to free.

A move only pays off when the type owns something indirectly. For an int, a double, a plain struct of numbers or a std::array<int, 1000>, moving is exactly copying: there is no pointer to steal, so all the bytes must be transferred either way.

Know this cold

Move semantics is an optimisation of copying for the case where the source will not be used again. It is also the only way to transfer ownership of types that cannot be copied at all, such as std::unique_ptr, std::thread and std::fstream. (Some types, like std::mutex, can be neither copied nor moved.)

Value categories

Theory

Every expression in C++ has a type and a value category. The category decides which references can bind to it, and therefore which overload (copy or move) is chosen. C++11 defines three primary categories and two mixed ones, built from two properties:

  • Has identity: the expression refers to an object with an address you could take (a variable, *p, a[i]).
  • Can be moved from: the program has said the object's resources may be taken (a temporary, or the result of std::move).
expression glvalue has identity rvalue can be moved from lvalue x, *p, a[i], ++x "lit", r (named T&&) xvalue std::move(x) S{}.m, f() → T&& prvalue 42, x + 1, S{}, x++ f() → T, this an xvalue has identity AND may be moved from, so it is both a glvalue and an rvalue
Two properties, five names. lvalues have identity and are not movable; prvalues are pure values with no identity yet (C++17: they initialise their result object directly); xvalues ("expiring values") have identity but are marked movable.

Which category is this expression?

The rule that trips people up is the first one: the category is a property of the expression, not of the variable's declared type. A variable declared int&& r is an rvalue reference, but the expression r is an lvalue, because it has a name and you can take its address.

ExpressionCategoryWhy
x (any named variable, including T&& r)lvalueA name always denotes an object with identity
42, true, nullptrprvalueLiteral with no object behind it
"hello"lvalueString literals are arrays with static storage
x + 1, x++, &xprvalueBuilt-in operators producing a new value
++x, *p, a[i], x = ylvalueThey return the object itself
f() returning TprvalueReturn by value
f() returning T&lvalueReturn by lvalue reference
f() returning T&&, std::move(x), static_cast<T&&>(x)xvalueReturns an rvalue reference to an existing object
S{}.m (member of a prvalue)xvalueTemporary materialisation creates the object, then .m refers into it
[]{ }, S{}, thisprvaluePure values

You can make the compiler tell you. decltype((e)) with double parentheses yields T& for an lvalue, T&& for an xvalue and plain T for a prvalue:

#include <cstdio>
#include <utility>

template <class T> struct cat      { static constexpr const char* v = "prvalue"; };
template <class T> struct cat<T&>  { static constexpr const char* v = "lvalue"; };
template <class T> struct cat<T&&> { static constexpr const char* v = "xvalue"; };
#define CAT(...) std::printf("%s: %s\n", #__VA_ARGS__, cat<decltype((__VA_ARGS__))>::v)

struct S { int m; };
static int g = 0;
static int& ref() { return g; }
static int&& rref() { return std::move(g); }

int main() {
    int x = 1;
    int&& r = 5;
    CAT(x);
    CAT(42);
    CAT(x + 1);
    CAT(++x);
    CAT(x++);
    CAT("hi");
    CAT(r);
    CAT(std::move(x));
    CAT(S{});
    CAT(S{}.m);
    CAT(ref());
    CAT(rref());
}
x: lvalue
42: prvalue
x + 1: prvalue
++x: lvalue
x++: prvalue
"hi": lvalue
r: lvalue
std::move(x): xvalue
S{}: prvalue
S{}.m: xvalue
ref(): lvalue
rref(): xvalue

What binds to what

Parameterlvalueconst lvaluervalue (xvalue or prvalue)const rvalue
T&yesnonono
const T&yesyesyes (lifetime extended for prvalues)yes
T&&nonoyesno
const T&&nonoyesyes
T (by value)copycopymove (or elided for prvalues)copy

When both const T& and T&& overloads are viable for an rvalue, the rvalue reference wins (a better reference binding, [over.ics.rank]). That single rule is what makes moves happen automatically for temporaries.

#include <cstdio>
#include <utility>

static void f(int&)  { std::puts("f(int&)"); }
static void f(int&&) { std::puts("f(int&&)"); }

int main() {
    int x = 1;
    int&& r = std::move(x);   // r is an rvalue reference variable...
    f(x);
    f(r);                     // ...but the expression r is an lvalue
    f(std::move(r));
    f(2);
    f(static_cast<int&&>(x));
}
f(int&)
f(int&)
f(int&&)
f(int&&)
f(int&&)

Interview trap

"Inside void g(Widget&& w), is w an rvalue?" No. w has a name, so it is an lvalue. Passing it on as h(w) copies. You must write h(std::move(w)). The design is deliberate: a named object may be used several times in the body, and an implicit move on the first use would leave later uses looking at a gutted object.

std::move is only a cast

Theory

std::move moves nothing. It is an unconditional cast to an rvalue reference, which turns its argument into an xvalue. The actual move happens later, if and when overload resolution picks a function taking T&& (usually a move constructor or move assignment operator). If no such function exists, the cast is harmless and the copy runs.

#include <cstdio>
#include <string>
#include <type_traits>

namespace my {
template <class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}
}

int main() {
    std::string a = "payload";
    static_assert(std::is_same_v<decltype(my::move(a)), std::string&&>);
    std::string b = my::move(a);         // the move constructor does the work
    std::printf("b=%s a.size()=%zu\n", b.c_str(), a.size());

    const std::string c = "const";
    static_assert(std::is_same_v<decltype(my::move(c)), const std::string&&>);
    std::string d = my::move(c);         // const string&& binds to const string&: a copy
    std::printf("d=%s c=%s\n", d.c_str(), c.c_str());
}
b=payload a.size()=0
d=const c=const

The parameter is a forwarding reference (T&& in a deduced context, see Ch 21), so move accepts lvalues and rvalues. remove_reference_t is needed because for an lvalue argument T is deduced as std::string&, and T&& would collapse back to std::string&. The printed a.size()=0 is what libstdc++ and libc++ do; the standard only promises a valid, unspecified state.

Moving a const object silently copies

std::move(c) on a const object yields const T&&. The move constructor takes T&& and cannot bind to it, but the copy constructor's const T& can. The code compiles, runs and copies, with no warning from either compiler at -Wall -Wextra. The same happens for const data members (moving the enclosing object copies the member) and for const locals you return: const std::string s = ...; return s; can still use NRVO, but if NRVO does not apply, the implicit move becomes a copy.

#include <cstdio>
#include <utility>

struct T {
    T() = default;
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
};

int main() {
    const T a{};
    T b = std::move(a);   // const T&& -> picks T(const T&)
    T c = std::move(b);
}
copy
move

Writing the move constructor and move assignment

Theory

The move constructor has the signature T(T&& other) noexcept; move assignment is T& operator=(T&& other) noexcept. Each one does three things: take the source's resources, leave the source in a state its destructor (and assignment) can cope with, and for assignment, release what *this owned before.

A full Buffer with all five special members

#include <cstdio>
#include <cstring>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : size_(n), data_(new char[n]()) { std::printf("ctor(%zu)\n", n); }
    ~Buffer() { std::printf("dtor(%zu)\n", size_); delete[] data_; }

    Buffer(const Buffer& o) : size_(o.size_), data_(new char[o.size_]) {
        std::memcpy(data_, o.data_, size_);
        std::printf("copy(%zu)\n", size_);
    }
    Buffer(Buffer&& o) noexcept
        : size_(std::exchange(o.size_, 0)), data_(std::exchange(o.data_, nullptr)) {
        std::printf("move(%zu)\n", size_);
    }
    Buffer& operator=(const Buffer& o) {
        if (this != &o) {
            char* p = new char[o.size_];      // allocate first: strong guarantee
            std::memcpy(p, o.data_, o.size_);
            delete[] data_;
            data_ = p;
            size_ = o.size_;
        }
        std::printf("copy=(%zu)\n", size_);
        return *this;
    }
    Buffer& operator=(Buffer&& o) noexcept {
        if (this != &o) {
            delete[] data_;
            data_ = std::exchange(o.data_, nullptr);
            size_ = std::exchange(o.size_, 0);
        }
        std::printf("move=(%zu)\n", size_);
        return *this;
    }
    std::size_t size() const { return size_; }

private:
    std::size_t size_;
    char* data_;
};

int main() {
    Buffer a(100);
    Buffer b = a;
    Buffer c = std::move(a);
    std::printf("a=%zu b=%zu c=%zu\n", a.size(), b.size(), c.size());
    b = std::move(c);
    std::printf("b=%zu c=%zu\n", b.size(), c.size());
}
ctor(100)
copy(100)
move(100)
a=0 b=100 c=100
move=(100)
b=100 c=0
dtor(0)
dtor(100)
dtor(0)

Destruction runs in reverse order of construction: c (empty), b, then a (empty). Both moved-from objects print dtor(0) and call delete[] nullptr, which is a no-op.

std::exchange: "take the value and leave this behind"

std::exchange(obj, new_value) (header <utility>, C++14) assigns new_value to obj and returns the old value. It turns the two-step "copy the pointer, then null the source" into one expression, which is why it is idiomatic in move constructors' member initialiser lists. It is also constexpr since C++20 and conditionally noexcept since C++23.

Alternatives: swap-based move, copy-and-swap, unified assignment

Three common shapes of move assignment:

  • Release then steal (as above). Needs a self-check, otherwise x = std::move(x) deletes the buffer and then steals the dangling pointer from itself.
  • Swap: swap(*this, o); return *this;. Self-safe without a check. The old resources are released later, when o dies, which may surprise people who expect a lock or file to close at the assignment.
  • Unified assignment: T& operator=(T o) noexcept { swap(*this, o); return *this; }. One function serves both copy and move assignment (the parameter is copy- or move-constructed by the caller). It costs an extra move for rvalues and loses the chance to reuse the existing buffer on copy (std::vector's copy assignment reuses capacity, copy-and-swap never does).

Self-move-assignment

The standard library's rule ([res.on.arguments]) is that a function taking an rvalue reference may assume it is the only reference to that argument. Self-move-assignment of a standard type therefore only promises a valid but unspecified result. For your own types, either check this != &o or use swap so that x = std::move(x) leaves x usable. It happens in real code through aliasing, for example v[i] = std::move(v[j]) with i == j inside a generic algorithm. GCC's -Wself-move (in -Wall since GCC 13) catches the literal spelling.

The moved-from state

Theory

After a move, the source is a live object: its destructor will run, and you may assign to it. The standard's wording for library types ([lib.types.movedfrom]) is that a moved-from object is in a valid but unspecified state. Valid means every operation without preconditions works: size(), clear(), assignment, destruction. Unspecified means you cannot assume its value. Calling front() on a moved-from vector is wrong because front() has the precondition "not empty", which you cannot know holds.

TypeState after move construction from itGuaranteed?
std::unique_ptrnullyes, specified
std::shared_ptr, std::weak_ptremptyyes, specified
std::thread, std::jthreadnot joinableyes, specified
std::unique_lockno mutex, not owningyes, specified
std::vector, std::stringempty in libstdc++, libc++ and MSVCno, only "valid but unspecified"
std::optional<T>still engaged, holding a moved-from Tyes, has_value() is unchanged
int, raw pointers, trivially copyable typesunchanged (move is a copy)yes

For your own types, make the moved-from state the cheapest valid state, usually the same as default-constructed. Keep the class invariants: if the class promises "data_ is never null", the move constructor must either allocate (and then it cannot be noexcept) or you must weaken the invariant to allow a null state. This is why std::list in some implementations had a throwing move constructor (it allocated a sentinel node), and why "never empty" designs fight with moves.

Tip

The safe rule for any type: after std::move(x), only destroy x or assign a new value to it. Reusing a moved-from std::string after s.clear() or s = "new" is fine and common in loops.

noexcept on moves and vector reallocation

Theory

The problem. std::vector::push_back gives the strong exception guarantee: if it throws, the vector is unchanged. When the vector is full it allocates a bigger buffer and must transfer the old elements. If it moves them and the third move throws, the first two elements are already gutted in the old buffer, and the original state cannot be restored (moving them back could throw too). Copying keeps the old buffer intact, so a throw just discards the new buffer.

The rule. The library uses std::move_if_noexcept for relocation: it moves when the move constructor is noexcept (or the type is not copyable at all), and copies otherwise. A move constructor that is not marked noexcept, even one that never throws in practice, turns every reallocation into a full deep copy.

old buffer (capacity 3, full) e0 e1 e2 new buffer (capacity 6) e0 e1 e2 new 1. allocate new storage 2. construct the new element at index 3 3. relocate e0..e2 with move_if_noexcept: noexcept move → move (O(1) each) move may throw and T is copyable → copy 4. destroy old elements, free the old buffer If a copy throws in step 3, the new buffer is destroyed and the old one is untouched: strong guarantee. If a move threw half-way, e0 and e1 would already be gutted: no way back.
Why noexcept decides copy vs move. push_back promises "unchanged on exception". Copies preserve the old buffer; moves destroy it as they go, so vector only moves when the move cannot throw.
#include <cstdio>
#include <vector>

template <bool NoThrow>
struct Item {
    Item() = default;
    Item(const Item&) { ++copies; }
    Item(Item&&) noexcept(NoThrow) { ++moves; }
    static inline int copies = 0;
    static inline int moves = 0;
};

template <bool NoThrow>
static void run(const char* label) {
    std::vector<Item<NoThrow>> v;
    for (int i = 0; i < 1000; ++i) v.emplace_back();
    std::printf("%-18s copies=%d moves=%d\n", label, Item<NoThrow>::copies, Item<NoThrow>::moves);
}

int main() {
    run<true>("noexcept move:");
    run<false>("throwing move:");
}
noexcept move:     copies=0 moves=1023
throwing move:     copies=1023 moves=0

Both libstdc++ and libc++ grow capacity 1, 2, 4, ..., 1024 when appending one element at a time from empty, so 1 + 2 + 4 + ... + 512 = 1023 elements are relocated. MSVC grows by 1.5x and would print a different count, but the pattern is the same: all moves in the first line, all copies in the second.

std::move_if_noexcept

The helper (header <utility>) returns const T& when T has a potentially throwing move constructor and is copy constructible, and T&& otherwise. A move-only type with a throwing move (rare) still gets moved, and vector falls back to the basic guarantee for it.

#include <cstdio>
#include <memory>
#include <type_traits>
#include <utility>

struct Risky {
    Risky() = default;
    Risky(const Risky&) { std::puts("Risky copy"); }
    Risky(Risky&&) { std::puts("Risky move"); }      // not noexcept
};
struct Safe {
    Safe() = default;
    Safe(const Safe&) { std::puts("Safe copy"); }
    Safe(Safe&&) noexcept { std::puts("Safe move"); }
};

int main() {
    Risky r;
    Safe s;
    Risky r2 = std::move_if_noexcept(r);
    Safe s2 = std::move_if_noexcept(s);
    static_assert(std::is_same_v<decltype(std::move_if_noexcept(r)), const Risky&>);
    static_assert(std::is_same_v<decltype(std::move_if_noexcept(s)), Safe&&>);
    auto p = std::make_unique<int>(1);
    static_assert(std::is_same_v<decltype(std::move_if_noexcept(p)), std::unique_ptr<int>&&>);
}
Risky copy
Safe move

Know this cold

Mark move constructors, move assignment operators and swap noexcept whenever they cannot throw. Implicitly generated and = default moves are noexcept automatically when every member's move is. Check with static_assert(std::is_nothrow_move_constructible_v<T>).

Implicit moves, the rule of five and the rule of zero

Theory

The compiler declares an implicit move constructor and move assignment operator only if the class has no user-declared copy constructor, copy assignment operator, move operation (of the other kind) or destructor ([class.copy.ctor]/8). "User-declared" includes = default and = delete. The implicit move moves each base and member in declaration order (memberwise std::move).

You declaredefault ctordestructorcopy ctor / copy =move ctor / move =
nothingimplicitimplicitimplicitimplicit
any constructornot declaredimplicitimplicitimplicit
destructorimplicityoursimplicit (deprecated)not declared: moves copy
copy ctor or copy =(not declared if copy ctor)implicityours / implicit (deprecated)not declared: moves copy
move ctor or move =(not declared if move ctor)implicitdeletedyours / not declared

"Not declared" and "deleted" differ in an important way. A not-declared move constructor does not take part in overload resolution, so T b = std::move(a) quietly falls back to the copy constructor. A deleted function does take part, and choosing it is a compile error. A move constructor that is = default but would be ill-formed (for example a member that cannot be moved) is defined as deleted and then ignored by overload resolution ([class.copy.ctor]/10), again falling back to copy.

#include <cstdio>
#include <utility>

struct Tracer {
    Tracer() = default;
    Tracer(const Tracer&) { std::puts("  copy"); }
    Tracer(Tracer&&) noexcept { std::puts("  move"); }
};

struct Plain    { Tracer t; };                                // rule of zero
struct WithDtor { Tracer t; ~WithDtor() {} };                 // move not declared
struct Fixed    { Tracer t; ~Fixed() {}
                  Fixed() = default;
                  Fixed(Fixed&&) = default;                   // brings the move back
                  Fixed(const Fixed&) = default; };           // and keeps the copy

int main() {
    std::puts("Plain:");    Plain a;    Plain b = std::move(a);
    std::puts("WithDtor:"); WithDtor c; WithDtor d = std::move(c);
    std::puts("Fixed:");    Fixed e;    Fixed f = std::move(e);
}
Plain:
  move
WithDtor:
  copy
Fixed:
  move

Interview trap

Adding a destructor "just to log something" or virtual ~Base() = default; to a class silently disables its moves. Every std::move on it becomes a copy and every vector reallocation deep-copies. For a polymorphic base, declare all five: virtual ~Base() = default; plus defaulted copy and move operations (or delete them to prevent slicing).

Rule of zero, rule of five

  • Rule of zero: classes that do not manage a resource directly declare none of the five. They hold members that manage themselves (std::string, std::vector, std::unique_ptr), and the implicit members do the right thing, including noexcept moves.
  • Rule of five: a class that does manage a resource (a raw pointer, a descriptor) declares the destructor, both copy operations and both move operations, or explicitly deletes the ones it does not support. Declaring only some of them changes the others in the ways the table shows.
  • A move-only type is the rule of five with the copies deleted: T(const T&) = delete; T& operator=(const T&) = delete; plus real moves.

A template constructor is never a move constructor. template <class U> T(U&&) does not suppress the implicit copy or move constructors, and the reverse trap (it hijacks copies of non-const lvalues) is covered in Ch 21. A constructor T(const T&&) technically is a move constructor by the standard's definition, but it cannot modify the source and so it cannot steal anything.

Moves in interfaces: returning, sinks, move-only types and ref-qualifiers

Theory

Do not write return std::move(local)

Returning a local by name already does the best possible thing. The compiler may construct the local directly in the caller's slot (NRVO), and when it cannot, the return statement treats the local as an rvalue automatically (the "implicit move", widened in C++20 by P1825 and again in C++23 by P2266). Writing return std::move(local); changes the returned expression from a name to an xvalue, which disables NRVO, so you get a guaranteed move instead of a free elision. GCC and Clang both warn with -Wpessimizing-move. Ch 24 covers NRVO in depth and Ch 23 covers guaranteed elision for prvalues.

#include <cstdio>
#include <utility>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T good() { T t; return t; }
static T bad()  { T t; return std::move(t); }   // pessimising move

int main() {
    std::puts("-- good");
    T a = good();
    std::puts("-- bad");
    T b = bad();
    std::puts("-- end");
}
-- good
ctor
-- bad
ctor
move
dtor
-- end
dtor
dtor

NRVO in good() is not guaranteed by the standard, but GCC and Clang apply it to this simple shape at every optimisation level. When std::move on return is right: returning a data member from an rvalue-qualified function (return std::move(member_);), returning a member of a local (return std::move(local.field);, since local.field is not a plain name and gets no implicit move; structured bindings are a murkier case where GCC and Clang disagree, see Ch 24), or, in pre-C++20 code, returning an rvalue-reference parameter or a local whose type differs from the return type in ways the old rule did not cover (for example a Derived local returned as Base).

Sink arguments: pass by value and move

A "sink" function stores its argument (a constructor that keeps a string, a setter, push). Three ways to write it:

SignatureCaller passes lvalueCaller passes rvalueCost to write
void set(const std::string& s) { s_ = s; }1 copy1 copy (wasted)1 function
set(const std::string&) + set(std::string&&)1 copy1 move2 functions (2n for n params)
void set(std::string s) { s_ = std::move(s); }1 copy + 1 move2 moves (1 for a prvalue)1 function
template <class S> void set(S&& s) { s_ = std::forward<S>(s); }1 copy1 movetemplate, see Ch 21

Pass-by-value is the usual recommendation for constructors that always store the argument: one extra cheap move, one function. It is a poor choice for setters called repeatedly with lvalues on long strings, because s_ = s (const& version) reuses s_'s existing capacity, while by-value always allocates a fresh copy for the parameter. It is also wrong for types that are expensive to move (std::array, large aggregates).

#include <cstdio>
#include <utility>

struct Tracer {
    Tracer() = default;
    Tracer(const Tracer&) { std::puts("  copy"); }
    Tracer(Tracer&&) noexcept { std::puts("  move"); }
};

struct ByValue {
    Tracer t;
    explicit ByValue(Tracer x) : t(std::move(x)) {}
};
struct Overloads {
    Tracer t;
    explicit Overloads(const Tracer& x) : t(x) {}
    explicit Overloads(Tracer&& x) : t(std::move(x)) {}
};

int main() {
    Tracer a;
    std::puts("by value, lvalue:");   ByValue v1(a);
    std::puts("by value, xvalue:");   ByValue v2(std::move(a));
    std::puts("by value, prvalue:");  ByValue v3(Tracer{});
    std::puts("overloads, lvalue:");  Overloads o1(a);
    std::puts("overloads, xvalue:");  Overloads o2(std::move(a));
}
by value, lvalue:
  copy
  move
by value, xvalue:
  move
  move
by value, prvalue:
  move
overloads, lvalue:
  copy
overloads, xvalue:
  move

The prvalue case shows C++17 guaranteed elision: Tracer{} initialises the parameter directly, so only the move into the member remains.

Move-only types

std::unique_ptr, std::thread, std::jthread, the file and string streams (std::fstream, std::stringstream), std::unique_lock, std::promise, std::future and std::move_only_function (C++23) are movable but not copyable. They can live in containers, be returned from functions and be passed by value, but each transfer must be from an rvalue. std::mutex, std::atomic and std::condition_variable are neither copyable nor movable, because other threads may hold their address.

#include <cstdio>
#include <memory>
#include <thread>
#include <vector>

static std::unique_ptr<int> make(int v) { return std::make_unique<int>(v); }  // implicit move

int main() {
    std::vector<std::unique_ptr<int>> v;
    v.push_back(make(1));
    auto p = make(2);
    v.push_back(std::move(p));                 // v.push_back(p) does not compile
    std::printf("p is %s, v = {%d, %d}\n", p ? "set" : "null", *v[0], *v[1]);

    std::thread t([] {});
    std::thread u = std::move(t);
    std::printf("t joinable=%d u joinable=%d\n", t.joinable(), u.joinable());
    u.join();
}
p is null, v = {1, 2}
t joinable=0 u joinable=1
#include <memory>
#include <vector>

int main() {
    std::vector<std::unique_ptr<int>> v;
    auto p = std::make_unique<int>(1);
    v.push_back(p);                                 // error: copy constructor is deleted
    std::vector<std::unique_ptr<int>> w = v;        // error: copying the vector copies elements
}

Ref-qualified member functions

A member function can overload on the value category of *this by adding & or && after the parameter list (and after const). The && overload runs on temporaries and on std::move(obj), so it may steal from the object. std::optional::value(), std::move_only_function::operator() and std::basic_stringbuf::str() (C++20) use this. If one overload of a name is ref-qualified, all overloads with the same parameter list must be.

#include <cstdio>
#include <string>
#include <string_view>
#include <utility>

class Builder {
public:
    Builder& add(std::string_view x) & { s_ += x; return *this; }
    Builder&& add(std::string_view x) && { s_ += x; return std::move(*this); }
    std::string build() const& { std::puts("build: copy out"); return s_; }
    std::string build() && { std::puts("build: move out"); return std::move(s_); }
private:
    std::string s_;
};

int main() {
    Builder b;
    b.add("a").add("b");
    std::string s1 = b.build();                          // b is an lvalue
    std::string s2 = Builder{}.add("x").add("y").build(); // chain of xvalues
    std::string s3 = std::move(b).build();
    std::printf("%s %s %s\n", s1.c_str(), s2.c_str(), s3.c_str());
}
build: copy out
build: move out
build: move out
ab xy ab

The && overload of add returns Builder&&, so the chain stays an xvalue and the final build() also picks the && overload. Returning a reference into a temporary is safe here because the temporary lives until the end of the full expression.

Concept and tricky questions

Drills

ConceptWhat does std::move do at run time?

Answer

Answer. Nothing. It is static_cast<std::remove_reference_t<T>&&>(t), a compile-time cast that changes the expression's value category to xvalue.

Why. The cast only affects overload resolution: an xvalue binds to T&& parameters, so a move constructor or move assignment (if one exists and is accessible) wins over the copy. The resource transfer happens inside that function. The common wrong answer is "it moves the object" or "it makes the source empty"; std::move(x); as a statement on its own leaves x completely untouched. A better name would have been rvalue_cast.

TrickyInside void store(std::string&& s) { member_ = s; }, does the assignment move or copy?

Answer

Answer. It copies. Write member_ = std::move(s);.

Why. The parameter's type is rvalue reference, but the expression s is a named variable and therefore an lvalue. Value category belongs to expressions, not declarations. The rule exists so that a function can use the parameter several times safely; the move must be the explicit last use. People answer "it moves because s is an rvalue reference", which confuses the type with the category.

Trickyconst std::vector<int> big(1'000'000); auto v = std::move(big); How expensive is the second statement, and is big empty afterwards?

Answer

Answer. It is a full copy of a million ints (one allocation and a 4 MB memcpy), and big still holds all its elements.

Why. std::move(big) yields const std::vector<int>&&. The move constructor takes a non-const vector&& and cannot bind to it; the copy constructor's const vector& can. Overload resolution silently chooses the copy, and no compiler warns at -Wall -Wextra (clang-tidy has performance-move-const-arg). The same happens for const data members, which is why const members make a class effectively non-movable.

ConceptName the five value categories and give an example of each primary one.

Answer

Answer. Primary: lvalue (x, *p, "lit"), xvalue (std::move(x), S{}.m, a call returning T&&), prvalue (42, x + 1, a call returning T). Mixed: glvalue = lvalue or xvalue (has identity), rvalue = xvalue or prvalue (movable).

Why. The two properties "has identity" and "may be moved from" give three useful combinations, and the mixed categories name the overlaps. Since C++17, a prvalue is not an object at all: it is a recipe that initialises a result object, and it is turned into an xvalue only when it must be (temporary materialisation, [conv.rval]). A frequent mistake is to call string literals rvalues; they are lvalue arrays with static storage duration.

ConceptWhy does std::vector copy its elements on reallocation when the element's move constructor is not noexcept?

Answer

Answer. To keep the strong exception guarantee of push_back, reserve and friends: if relocation fails, the vector must be unchanged.

Why. With copies, a throw half-way leaves the old buffer intact, so the new buffer is destroyed and the exception propagates. With moves, the old elements before the failure point are already gutted, and moving them back could throw again. The library therefore uses std::move_if_noexcept, which moves only when the move is noexcept or the type is not copyable. "The compiler adds noexcept for you" is wrong for user-written moves; only implicit and defaulted ones get an inferred exception specification.

TrickyA class has a std::string member and a user-written ~Logger() { std::puts("bye"); }. Does Logger b = std::move(a); move the string?

Answer

Answer. No. It copies the string.

Why. A user-declared destructor prevents the implicit declaration of the move constructor and move assignment ([class.copy.ctor]/8). The implicit copy constructor is still generated (deprecated but legal), and const Logger& binds to the xvalue, so the copy runs. ~Logger() = default; also counts as user-declared. Fix it with Logger(Logger&&) = default; Logger& operator=(Logger&&) = default; plus defaulted copies, or remove the destructor. The wrong answer "only the copy operations suppress moves" misses the destructor rule.

TrickyAfter std::optional<std::string> a = "hi"; auto b = std::move(a);, what does a.has_value() return?

Answer

Answer. true. a still contains a string, which is itself in a moved-from state (empty in practice).

Why. optional's move constructor is specified to move-construct the contained value and leave the source's engaged flag unchanged ([optional.ctor]). Resetting it would cost an extra destructor call and branch. People assume it behaves like unique_ptr, which is specified to become null. The lesson: "moved-from" means whatever that type documents, and for most types only "valid but unspecified".

ConceptAfter std::vector<int> b = std::move(a);, which of these are allowed on a: a.size(), a.clear(), a.push_back(1), a = {1, 2}, a[0], a.front(), letting a go out of scope?

Answer

Answer. Everything except a[0] and a.front(). Those two have the precondition "not empty", which you cannot know holds. In practice a is empty in every mainstream library, but you should call a.clear() or assign before relying on its contents.

Why. [lib.types.movedfrom] says a moved-from library object is in a "valid but unspecified state": every operation without preconditions works, and the destructor runs normally. So a moved-from object is usable, and reusing it after clear() or assignment is idiomatic (a buffer reused across loop iterations). The two common wrong answers are opposite extremes: "a moved-from object must not be touched at all except to destroy it" (too strict: size() and assignment are fine) and "it is guaranteed empty" (too loose for std::vector and std::string, although std::unique_ptr really is guaranteed null).

ConceptState the rule of five and the rule of zero. Which should a typical class follow?

Answer

Answer. Rule of five: a class that manages a resource declares (or deletes) the destructor, copy constructor, copy assignment, move constructor and move assignment together. Rule of zero: every other class declares none of them and lets members like std::vector and std::unique_ptr manage resources. Most classes should follow the rule of zero.

Why. Declaring any one of the five changes what the compiler generates for the others (a destructor suppresses moves, a move deletes copies), so partial declarations cause silent copies or surprising compile errors. Pushing ownership into a single-purpose RAII member means only that member needs the five, and the outer class gets correct, noexcept moves for free. See Ch 1 for the copy side.

TrickyIs moving a std::array<int, 1000> cheaper than copying it? What about a std::string holding "hi"?

Answer

Answer. No for the array: move and copy both transfer all 4000 bytes. For a short string, a move is about as cheap as a copy too, because of the small string optimisation.

Why. A move is faster only when there is an indirection to steal. std::array stores its elements inline, so "moving" each int is copying it. A short std::string stores its characters inside the object (SSO, see Ch 18), so the move constructor copies the inline bytes and resets the source; there is no heap pointer to steal. Interviewers ask this to catch the belief that std::move always makes things O(1).

ConceptWhy is std::exchange popular in move constructors? Write the move constructor for a class holding int fd_ where -1 means "no descriptor".

Handle(Handle&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
Answer

Answer. It reads the old value and writes the empty value in one expression, so it fits in the member initialiser list and you cannot forget to reset the source.

Why. Forgetting to reset the source is the classic move bug: two objects then own the same descriptor or pointer and both release it (double close or double free). std::exchange(obj, v) is defined as T old = std::move(obj); obj = std::forward<U>(v); return old;. For int and pointers this compiles to two moves of registers. It also makes move assignment short: reset(); fd_ = std::exchange(o.fd_, -1);.

TrickyIs template <class U> Widget(U&& u); a move constructor? Does declaring it stop the compiler from generating one?

Answer

Answer. No and no. A constructor template is never a copy or move constructor, so the implicit copy and move constructors are still declared.

Why. [class.copy.ctor]/1 defines a copy or move constructor as a non-template constructor whose first parameter is X&, const X&, X&& and so on. The template still competes in overload resolution, and for a non-const lvalue Widget it deduces U = Widget&, which is a better match than the implicit const Widget& copy constructor. That greedy-template problem is the subject of Ch 21. Conversely, Widget(const Widget&&) is formally a move constructor, but useless.

ConceptWhen should a constructor take a std::string by value and move it, and when is const std::string& better?

Answer

Answer. By value plus std::move for constructors and other sinks that always keep a copy: callers pay one copy plus one move for lvalues and two moves for rvalues, with a single function. const& is better when the function only reads the argument, when the storage may not happen, or for assignment-style setters called with lvalues, where reusing the member's existing buffer avoids an allocation.

Why. The overload pair const T& + T&& is optimal but doubles per parameter (2n overloads). A forwarding-reference template is also optimal but must be constrained and lives in a header. The by-value version is the simplest good default for constructors, and it is what clang-tidy's modernize-pass-by-value check rewrites to. The C++ Core Guidelines are more conservative: F.16 says const& for "in" parameters, and F.15/F.18 add a T&& overload only when the optimisation matters. People who answer "always pass by value now" miss the setter case.

Predict the output and find the bug

Drills

OutputEvery special member prints its name. What is the output?

#include <cstdio>
#include <utility>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    T& operator=(const T&) { std::puts("copy="); return *this; }
    T& operator=(T&&) noexcept { std::puts("move="); return *this; }
    ~T() { std::puts("dtor"); }
};

int main() {
    T a;
    T b = a;
    T c = std::move(a);
    b = c;
    c = std::move(b);
    T d = T{};
}
Answer
ctor
copy
move
copy=
move=
ctor
dtor
dtor
dtor
dtor

Answer. As above: 2 constructions by default ctor, one copy, one move, one of each assignment, four destructors.

Why. T b = a is initialisation, not assignment, so it calls the copy constructor. b = c and c = std::move(b) assign to existing objects. T d = T{}; prints only ctor: since C++17 the prvalue T{} initialises d directly, with no temporary and no move (Ch 23). People often add a "move" and an extra "dtor" there, which was the possible C++14 behaviour without elision.

OutputTwo push_back calls on an empty vector with a noexcept move. What is printed?

#include <cstdio>
#include <vector>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

int main() {
    std::vector<T> v;
    std::puts("-- push 1");
    v.push_back(T{});
    std::puts("-- push 2");
    v.push_back(T{});
    std::puts("-- end");
}
Answer
-- push 1
ctor
move
dtor
-- push 2
ctor
move
move
dtor
dtor
-- end
dtor
dtor

Answer. Push 1: the temporary is built, moved into the buffer, then destroyed at the end of the statement. Push 2: capacity is 1, so the vector reallocates to capacity 2. It first move-constructs the new element from the temporary into the new buffer, then moves the old element across, destroys the old element, and finally the temporary is destroyed.

Why. Both libstdc++ and libc++ construct the new element before relocating the old ones, so that an argument referring into the vector itself (v.push_back(v[0])) is still valid when it is read. A temporary bound to push_back(T&&) cannot be elided into the buffer: it must exist so the reference can bind to it. That is the cost emplace_back avoids (Ch 22).

OutputThe same program, but the move constructor is no longer noexcept. What changes?

#include <cstdio>
#include <vector>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

int main() {
    std::vector<T> v;
    v.push_back(T{});
    std::puts("-- push 2");
    v.push_back(T{});
    std::puts("-- end");
}
Answer
ctor
move
dtor
-- push 2
ctor
move
copy
dtor
dtor
-- end
dtor
dtor

Answer. The new element is still moved in from the temporary, but the existing element is now copied into the new buffer.

Why. Placing the new element uses plain T&& overload resolution, because if it throws nothing old has been touched. Relocating the existing elements uses std::move_if_noexcept, which returns const T& for a copyable type whose move may throw. With real resource-owning types, this one missing keyword turns every growth step into deep copies.

OutputA function takes an rvalue reference and forwards it on. What is printed?

#include <cstdio>
#include <utility>

struct T {
    T() = default;
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
};

static void keep(T&& t) {
    T a = t;
    T b = std::move(t);
    T c = std::move(t);
}

int main() {
    keep(T{});
    const T k;
    T d = std::move(k);
}
Answer
copy
move
move
copy

Answer. copy, move, move, copy.

Why. t is a named rvalue reference, so T a = t copies. Each std::move(t) is just a cast, so moving twice from the same object compiles and calls the move constructor twice (with real resources, c would get an empty object, which is a logic bug the compiler will not catch). std::move(k) on a const object yields const T&&, which only the copy constructor accepts. A side note: const T k; compiles even though the default constructor is defaulted, because T has no data members that would be left uninitialised, which makes it const-default-constructible ([dcl.init.general]; the paragraph number differs between C++20, C++23 and the current draft).

OutputTwo wrapper classes differ only in a destructor. What is printed?

#include <cstdio>
#include <type_traits>
#include <utility>
#include <vector>

struct Res {
    Res() = default;
    Res(const Res&) { std::puts("Res copy"); }
    Res(Res&&) noexcept { std::puts("Res move"); }
};
struct A { Res r; };
struct B { Res r; virtual ~B() = default; };

int main() {
    A a1; A a2 = std::move(a1);
    B b1; B b2 = std::move(b1);
    std::printf("%d %d\n", std::is_nothrow_move_constructible_v<A>,
                           std::is_nothrow_move_constructible_v<B>);
}
Answer
Res move
Res copy
1 0

Answer. A moves; B copies; the traits print 1 0.

Why. virtual ~B() = default; is user-declared, so B gets no implicit move constructor. std::move(b1) binds to the implicit copy constructor, which copies Res. is_nothrow_move_constructible<B> asks whether B(B&&) is a valid non-throwing expression; it is valid (via the copy constructor), but Res's copy constructor is not noexcept, so the answer is false. A std::vector<B> would copy on every reallocation. The usual wrong answer is that = default on the destructor "doesn't count".

Find the bugThis string class crashes at the end of main. Why?

#include <cstdio>
#include <cstring>

class Str {
public:
    explicit Str(const char* s) : len_(std::strlen(s)), p_(new char[len_ + 1]) {
        std::memcpy(p_, s, len_ + 1);
    }
    ~Str() { delete[] p_; }
    Str(const Str&) = delete;
    Str& operator=(const Str&) = delete;
    Str(Str&& o) noexcept : len_(o.len_), p_(o.p_) {}
    const char* c_str() const { return p_; }
private:
    std::size_t len_;
    char* p_;
};

int main() {
    Str a("hello");
    Str b(static_cast<Str&&>(a));
    std::puts(b.c_str());
}
Answer

Answer. The move constructor copies the pointer but does not reset o.p_. Both a and b own the same buffer, and both destructors call delete[] on it: a double free, which ASan reports as attempting double-free.

Why. A move is "shallow copy plus disarm the source". Without the second half, it is just a shallow copy, the exact bug the rule of three was invented to prevent. Fix: Str(Str&& o) noexcept : len_(std::exchange(o.len_, 0)), p_(std::exchange(o.p_, nullptr)) {}. Then also make c_str() cope with a null p_ (return ""), because the moved-from state must keep the class usable. The output is marked as undefined behaviour: without a sanitizer, a double free may abort, corrupt the heap silently or appear to work.

Find the bugThe author expected this move constructor to be cheap. The trace says otherwise. Fix it.

#include <cstdio>
#include <utility>

struct Blob {
    Blob() = default;
    Blob(const Blob&) { std::puts("Blob copy"); }
    Blob(Blob&&) noexcept { std::puts("Blob move"); }
};

struct Doc {
    Blob body;
    Blob meta;
    Doc() = default;
    Doc(Doc&& o) noexcept : body(o.body), meta(std::move(o.meta)) {}
};

int main() {
    Doc a;
    Doc b = std::move(a);
}
Answer
Blob copy
Blob move

Answer. body(o.body) copies: o is a named rvalue reference, so o.body is an lvalue. Write body(std::move(o.body)), or better, delete the hand-written constructor and let the compiler generate Doc(Doc&&) = default;.

Why. Member access on an lvalue gives an lvalue. Only std::move(o).body or std::move(o.body) gives an xvalue. Hand-written move constructors are a common source of accidental copies, and a missed member compiles silently. The defaulted version moves every base and member in declaration order and gets the right noexcept automatically.

Find the bugWhy does moving a Person copy its name?

#include <cstdio>
#include <string>
#include <utility>

struct Name {
    std::string s;
    explicit Name(std::string v) : s(std::move(v)) {}
    Name(const Name& o) : s(o.s) { std::puts("Name copy"); }
    Name(Name&& o) noexcept : s(std::move(o.s)) { std::puts("Name move"); }
};

struct Person {
    const Name name;
    int age;
};

int main() {
    Person a{Name("Ada"), 36};
    Person b = std::move(a);
    std::printf("%s %d\n", b.name.s.c_str(), b.age);
}
Answer
Name copy
Ada 36

Answer. name is a const member, so Person's implicit move constructor initialises it from std::move(o).name, a const Name&&, which binds only to the copy constructor. That is the single Name copy line. (The aggregate initialisation prints nothing: the prvalue Name("Ada") initialises the member directly.)

Why. The wrong answer is "the implicit move constructor moves every member". It does, but moving a const member is a copy. Drop the const and enforce immutability through the interface (private member plus a getter). const data members also delete the implicit copy and move assignment, so Person cannot be assigned or sorted in a vector. This is one of the most common accidental-copy sources in real codebases.

Find the bugThe program crashes. Which line is wrong, and why does it compile?

#include <cstdio>
#include <memory>
#include <vector>

struct Job { int id; };

int main() {
    std::vector<std::unique_ptr<Job>> queue;
    auto job = std::make_unique<Job>(Job{7});
    queue.push_back(std::move(job));
    std::printf("queued job %d\n", job->id);
}
Answer

Answer. job->id after std::move(job). unique_ptr's move constructor is specified to leave the source null, so this dereferences a null pointer (UBSan/ASan report it; a normal build segfaults).

Why. The compiler does not track moved-from state, so use-after-move compiles cleanly. clang-tidy's bugprone-use-after-move catches it. Fix by reading what you need before the move (int id = job->id;) or through the container (queue.back()->id). The output is marked undefined behaviour because a null dereference has no defined result.

Write the code

Coding

CodeImplement Buffer, a heap byte buffer with the rule of five. Requirements: explicit Buffer(std::size_t n) zero-fills; size(), data(), operator[]; deep copy; noexcept moves that leave the source with size 0 and null data; copy assignment with the strong exception guarantee; self-assignment and self-move safe; a noexcept member and free swap. Show with static_assert that a vector of Buffers will move on reallocation.

Answer

Approach. Own a char* and a size. The copy constructor allocates and copies. Moves use std::exchange to steal and reset. Copy assignment uses copy-and-swap (construct a temporary copy, which may throw, then swap, which cannot), giving the strong guarantee and self-assignment safety. Move assignment uses swap, which is self-safe without a branch, and the old buffer is freed by the source's destructor.

#include <cassert>
#include <cstdio>
#include <cstring>
#include <type_traits>
#include <utility>
#include <vector>

class Buffer {
public:
    Buffer() noexcept = default;
    explicit Buffer(std::size_t n) : size_(n), data_(n ? new char[n]() : nullptr) {}
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer& o) : size_(o.size_), data_(o.size_ ? new char[o.size_] : nullptr) {
        if (size_) std::memcpy(data_, o.data_, size_);
    }
    Buffer(Buffer&& o) noexcept
        : size_(std::exchange(o.size_, 0)), data_(std::exchange(o.data_, nullptr)) {}

    Buffer& operator=(const Buffer& o) {
        Buffer tmp(o);          // may throw; *this untouched
        swap(tmp);
        return *this;
    }
    Buffer& operator=(Buffer&& o) noexcept {
        Buffer tmp(std::move(o));   // o is now empty, even when &o == this
        swap(tmp);                  // old contents die with tmp
        return *this;
    }

    void swap(Buffer& o) noexcept {
        std::swap(size_, o.size_);
        std::swap(data_, o.data_);
    }
    friend void swap(Buffer& a, Buffer& b) noexcept { a.swap(b); }

    std::size_t size() const noexcept { return size_; }
    char* data() noexcept { return data_; }
    const char* data() const noexcept { return data_; }
    char& operator[](std::size_t i) { return data_[i]; }
    const char& operator[](std::size_t i) const { return data_[i]; }

private:
    std::size_t size_ = 0;
    char* data_ = nullptr;
};

static_assert(std::is_nothrow_move_constructible_v<Buffer>);
static_assert(std::is_nothrow_move_assignable_v<Buffer>);

int main() {
    Buffer a(4);
    std::memcpy(a.data(), "abc", 4);
    Buffer b = a;                      // deep copy
    b[0] = 'X';
    assert(a[0] == 'a');
    Buffer c = std::move(a);
    assert(a.size() == 0 && a.data() == nullptr && c.size() == 4);
    b = b;                             // self copy-assign
    assert(std::strcmp(b.data(), "Xbc") == 0);
    Buffer& alias = c;
    c = std::move(alias);              // self move-assign keeps the value
    assert(std::strcmp(c.data(), "abc") == 0);
    a = c;                             // reuse a moved-from object
    std::vector<Buffer> v;
    for (int i = 0; i < 100; ++i) v.emplace_back(16);
    std::printf("a=%s b=%s c=%s v=%zu\n", a.data(), b.data(), c.data(), v.size());
}
a=abc b=Xbc c=abc v=100

Complexity and pitfalls. Copy O(n), move O(1), swap O(1). Pitfalls: forgetting to reset the source in the move constructor (double free); forgetting noexcept (vector copies); calling memcpy with a null pointer even for size 0 (undefined behaviour under the C rules that C++20 inherits, so guard it); declaring data_ before size_ and then reading size_ in data_'s initialiser (members initialise in declaration order). The b = b line triggers -Wself-assign-overloaded on Clang, which is expected in a test. The swap-based move assignment frees the old buffer immediately here because tmp dies at the end of the function.

Estimated rating: ~1500 · standard rule-of-five boilerplate, but four special members each have a distinct trap

CodeWrite a move-only UniqueHandle that owns an integer descriptor (-1 means empty) and closes it with a given function. Interface: explicit UniqueHandle(int fd), get(), release(), reset(int = -1), explicit operator bool. Copies must not compile. Use a fake close that records calls so the test can check that every descriptor is closed exactly once.

Answer

Approach. Delete both copy operations. The move constructor takes the descriptor with std::exchange(o.fd_, -1). Move assignment is reset(o.release()), which is self-safe: on self-move, release() first sets our own fd to -1 and returns it, so reset finds nothing old to close and simply takes the fd back. The old != fd check in reset covers the separate case h.reset(h.get()). The destructor calls reset().

#include <cassert>
#include <cstdio>
#include <type_traits>
#include <utility>
#include <vector>

static std::vector<int> closed;                 // fake ::close for the test
static void fake_close(int fd) { closed.push_back(fd); }

class UniqueHandle {
public:
    UniqueHandle() noexcept = default;
    explicit UniqueHandle(int fd) noexcept : fd_(fd) {}
    ~UniqueHandle() { reset(); }

    UniqueHandle(const UniqueHandle&) = delete;
    UniqueHandle& operator=(const UniqueHandle&) = delete;

    UniqueHandle(UniqueHandle&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
    UniqueHandle& operator=(UniqueHandle&& o) noexcept {
        reset(o.release());
        return *this;
    }

    int get() const noexcept { return fd_; }
    explicit operator bool() const noexcept { return fd_ != -1; }
    [[nodiscard]] int release() noexcept { return std::exchange(fd_, -1); }
    void reset(int fd = -1) noexcept {
        int old = std::exchange(fd_, fd);
        if (old != -1 && old != fd) fake_close(old);
    }

private:
    int fd_ = -1;
};

static_assert(!std::is_copy_constructible_v<UniqueHandle>);
static_assert(std::is_nothrow_move_constructible_v<UniqueHandle>);

static UniqueHandle open_fake(int fd) { return UniqueHandle(fd); }

int main() {
    {
        UniqueHandle a = open_fake(3);
        UniqueHandle b = std::move(a);
        assert(!a && b.get() == 3);
        UniqueHandle c(4);
        c = std::move(b);              // closes 4, takes 3
        UniqueHandle& alias = c;
        c = std::move(alias);          // self-move: no close
        assert(c.get() == 3);
        std::vector<UniqueHandle> v;
        v.push_back(UniqueHandle(5));
        v.push_back(open_fake(6));
        int raw = v[0].release();      // caller now owns 5
        fake_close(raw);
    }                                  // closes 6, then 3
    for (int fd : closed) std::printf("%d ", fd);
    std::printf("\n");
}
4 5 6 3

Complexity and pitfalls. Every operation is O(1). Destruction order at the closing brace is reverse declaration: v (closes 6, element 0 is empty), then c (closes 3), then the empty b and a. Pitfalls: a move assignment written as reset(); fd_ = o.fd_; o.fd_ = -1; closes our own descriptor on self-move; forgetting [[nodiscard]] on release() lets callers leak; using 0 as the empty value is wrong because descriptor 0 (stdin) is valid. In production, prefer std::unique_ptr with a custom deleter only if the handle is a pointer; for int descriptors a small class like this is the norm (Ch 12).

Estimated rating: ~1400 · move-only RAII is short but the self-move and release semantics are easy to get wrong

CodeImplement my::move and my::move_if_noexcept without using the standard versions. Prove with static_assert that they return the same types as std::move and std::move_if_noexcept for: a non-const lvalue, a const lvalue, an rvalue, a type with a throwing move, and a move-only type with a throwing move.

Answer

Approach. move takes a forwarding reference and casts to remove_reference_t<T>&&. move_if_noexcept picks its return type with std::conditional_t: const T& when the move may throw and a copy is possible, T&& otherwise. Both are constexpr and noexcept because they are pure casts.

#include <cstdio>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>

namespace my {
template <class T>
[[nodiscard]] constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}

template <class T>
using move_if_noexcept_t = std::conditional_t<
    !std::is_nothrow_move_constructible_v<T> && std::is_copy_constructible_v<T>,
    const T&, T&&>;

template <class T>
[[nodiscard]] constexpr move_if_noexcept_t<T> move_if_noexcept(T& x) noexcept {
    return static_cast<move_if_noexcept_t<T>>(x);
}
}

struct Throwing { Throwing() = default; Throwing(const Throwing&) {} Throwing(Throwing&&) {} };
struct ThrowingMoveOnly { ThrowingMoveOnly() = default; ThrowingMoveOnly(ThrowingMoveOnly&&) {} };

#define SAME(a, b) static_assert(std::is_same_v<decltype(a), decltype(b)>)

int main() {
    std::string s = "x";
    const std::string cs = "y";
    Throwing t;
    ThrowingMoveOnly m;
    auto u = std::make_unique<int>(1);

    SAME(my::move(s), std::move(s));
    SAME(my::move(cs), std::move(cs));
    SAME(my::move(std::string{}), std::move(std::string{}));
    SAME(my::move_if_noexcept(s), std::move_if_noexcept(s));
    SAME(my::move_if_noexcept(t), std::move_if_noexcept(t));
    SAME(my::move_if_noexcept(m), std::move_if_noexcept(m));
    SAME(my::move_if_noexcept(u), std::move_if_noexcept(u));
    static_assert(std::is_same_v<decltype(my::move_if_noexcept(t)), const Throwing&>);
    static_assert(std::is_same_v<decltype(my::move_if_noexcept(m)), ThrowingMoveOnly&&>);

    std::string moved = my::move(s);
    std::printf("moved=%s source_size=%zu\n", moved.c_str(), s.size());
}
moved=x source_size=0

Complexity and pitfalls. Zero run-time cost. Pitfalls: returning T&& instead of remove_reference_t<T>&& (for an lvalue argument, T = std::string& and T&& collapses to std::string&, so nothing would move); taking the parameter by T& only (then move(std::string{}) fails); forgetting is_copy_constructible in move_if_noexcept, which would try to copy a move-only type. The source size 0 is what both standard libraries do; it is not guaranteed.

Estimated rating: ~1300 · tiny code, but needs reference collapsing and the exact move_if_noexcept condition

CodeWrite a numeric Vec (heap array of doubles, rule of five with noexcept moves) and overload operator+ so that Vec s = a + b + c + d; performs exactly one heap allocation. Count allocations to prove it, and explain how many a single const Vec&, const Vec& overload would make.

Answer

Approach. a + b + c + d parses as ((a + b) + c) + d. Only the first addition sees two lvalues; every later one has a temporary on the left. Overload on the value category of the operands: when an operand is an rvalue, add into its buffer with += and move it out as the result. Four overloads are needed, because with only (Vec&&, const Vec&) and (const Vec&, Vec&&) the call with two temporaries would be ambiguous. This is the same trick std::string's operator+ uses.

#include <cstdio>
#include <cstddef>
#include <memory>
#include <utility>

class Vec {
public:
    explicit Vec(std::size_t n, double x = 0) : n_(n), d_(alloc(n)) {
        for (std::size_t i = 0; i < n_; ++i) d_[i] = x;
    }
    Vec(const Vec& o) : n_(o.n_), d_(alloc(o.n_)) {
        for (std::size_t i = 0; i < n_; ++i) d_[i] = o.d_[i];
    }
    Vec(Vec&&) noexcept = default;
    Vec& operator=(const Vec& o) { Vec t(o); return *this = std::move(t); }
    Vec& operator=(Vec&&) noexcept = default;
    ~Vec() = default;

    Vec& operator+=(const Vec& o) {
        for (std::size_t i = 0; i < n_; ++i) d_[i] += o.d_[i];
        return *this;
    }
    double operator[](std::size_t i) const { return d_[i]; }
    static inline int allocations = 0;

private:
    static std::unique_ptr<double[]> alloc(std::size_t n) {
        ++allocations;
        return std::make_unique<double[]>(n);
    }
    std::size_t n_;
    std::unique_ptr<double[]> d_;
};

// Both operands are lvalues: the result needs a new buffer.
inline Vec operator+(const Vec& a, const Vec& b) { Vec r(a); r += b; return r; }
// The left operand is a temporary: reuse its buffer.
inline Vec operator+(Vec&& a, const Vec& b) { a += b; return std::move(a); }
// The right operand is a temporary: addition commutes, so reuse it.
inline Vec operator+(const Vec& a, Vec&& b) { b += a; return std::move(b); }
// Both temporaries: needed, otherwise the two overloads above are ambiguous.
inline Vec operator+(Vec&& a, Vec&& b) { a += b; return std::move(a); }

int main() {
    Vec a(1000, 1), b(1000, 2), c(1000, 3), d(1000, 4);
    Vec::allocations = 0;
    Vec s = a + b + c + d;
    std::printf("a+b+c+d: allocations=%d s[0]=%g\n", Vec::allocations, s[0]);
    Vec::allocations = 0;
    Vec t = (a + b) + (c + d);
    std::printf("(a+b)+(c+d): allocations=%d t[0]=%g\n", Vec::allocations, t[0]);
    Vec::allocations = 0;
    Vec u = a + (b + (c + d));
    std::printf("a+(b+(c+d)): allocations=%d u[0]=%g\n", Vec::allocations, u[0]);
}
a+b+c+d: allocations=1 s[0]=10
(a+b)+(c+d): allocations=2 t[0]=10
a+(b+(c+d)): allocations=1 u[0]=10

Complexity and pitfalls. Each addition is O(n); the chain does one allocation instead of three, which is what a lone operator+(const Vec&, const Vec&) makes (each temporary result binds to const Vec& and a new buffer is allocated for the next result). (a+b)+(c+d) needs two, because two independent sums exist at the same time. Pitfalls: returning Vec&& instead of Vec from the rvalue overloads (const Vec& r = a + b + c; would then dangle, since the result refers to a temporary that dies at the semicolon); forgetting the (Vec&&, Vec&&) overload (ambiguity); reusing the right operand for a non-commutative operation such as -. In C++20, return a; would also move the rvalue-reference parameter implicitly (P1825); std::move keeps the code correct in C++17.

Estimated rating: ~1600 · needs value-category overloading of a free operator and the ambiguity case, not just a move constructor

CodeWrite a minimal Stack<T> over raw storage with push(const T&), push(T&&), pop(), top() and size(). When it grows (doubling), it must relocate elements with std::move_if_noexcept and keep the strong exception guarantee. Test with a type whose copy constructor throws on the third copy, and show the stack is unchanged after the failed push.

Answer

Approach. Allocate raw memory with std::allocator<T>. On growth, allocate the new block, construct the new element at index size first (the argument may alias an existing element), then relocate old elements with std::construct_at(dst, std::move_if_noexcept(src)). If any construction throws, destroy what was built in the new block, free it and rethrow; the old block is untouched. Only after full success destroy the old elements and swap in the new block.

#include <cstdio>
#include <memory>
#include <stdexcept>
#include <utility>

template <class T>
class Stack {
public:
    Stack() = default;
    Stack(const Stack&) = delete;
    Stack& operator=(const Stack&) = delete;
    ~Stack() { std::destroy_n(data_, size_); alloc_.deallocate(data_, cap_); }

    void push(const T& v) { emplace(v); }
    void push(T&& v) { emplace(std::move(v)); }
    void pop() { std::destroy_at(data_ + --size_); }
    T& top() { return data_[size_ - 1]; }
    std::size_t size() const { return size_; }

private:
    template <class U>
    void emplace(U&& v) {
        if (size_ < cap_) { std::construct_at(data_ + size_, std::forward<U>(v)); ++size_; return; }
        std::size_t ncap = cap_ ? 2 * cap_ : 1;
        T* nd = alloc_.allocate(ncap);
        std::size_t built = 0;
        bool placed = false;
        try {
            std::construct_at(nd + size_, std::forward<U>(v));   // new element first
            placed = true;
            for (; built < size_; ++built)
                std::construct_at(nd + built, std::move_if_noexcept(data_[built]));
        } catch (...) {
            std::destroy_n(nd, built);
            if (placed) std::destroy_at(nd + size_);
            alloc_.deallocate(nd, ncap);
            throw;
        }
        std::destroy_n(data_, size_);
        alloc_.deallocate(data_, cap_);
        data_ = nd; cap_ = ncap; ++size_;
    }

    std::allocator<T> alloc_;
    T* data_ = nullptr;
    std::size_t size_ = 0, cap_ = 0;
};

struct Fragile {
    int v;
    static inline int copies = 0;
    explicit Fragile(int x) : v(x) {}
    Fragile(const Fragile& o) : v(o.v) {
        if (++copies == 3) throw std::runtime_error("copy failed");
    }
    Fragile(Fragile&& o) : v(o.v) { o.v = -1; }   // may throw, so Stack copies on growth
};

int main() {
    Stack<Fragile> s;
    s.push(Fragile(1));                 // cap 1
    s.push(Fragile(2));                 // grow to 2: copy #1
    try {
        s.push(Fragile(3));             // grow to 4: copy #2 ok, copy #3 throws
    } catch (const std::exception& e) {
        std::printf("caught: %s\n", e.what());
    }
    std::printf("size=%zu top=%d\n", s.size(), s.top().v);
    s.pop();
    std::printf("size=%zu top=%d\n", s.size(), s.top().v);
}
caught: copy failed
size=2 top=2
size=1 top=1

Complexity and pitfalls. Amortised O(1) push. Pitfalls: relocating with std::move instead of move_if_noexcept (a throwing move half-way corrupts the old buffer); constructing the new element after relocation, which breaks s.push(s.top()) because the reference points into the old buffer only until it is destroyed; leaking the new block on exception; destroying the old elements before the new ones are complete. The values are intact after the failure (top=2, then 1), which proves no element was moved from.

Estimated rating: ~1900 · raw storage, exception safety and move_if_noexcept together, where most candidates leak or corrupt on the failure path

CodeImplement move_remove_if(first, last, pred) with the semantics of std::remove_if: keep the elements that do not satisfy pred, in order, at the front, and return the new logical end. It must move (never copy) elements, so it works for std::unique_ptr. Show what is left in the tail and why erase must follow.

Answer

Approach. Find the first element to remove; that slot becomes the write position. Walk the rest with a read iterator and move-assign every kept element into the write position, advancing it. Elements already in the right place are never touched, so the cost is one move assignment per kept element after the first removal. The range [result, last) then holds moved-from or leftover objects, which the caller erases (the erase-remove idiom, or C++20's std::erase_if).

#include <algorithm>
#include <cstdio>
#include <memory>
#include <string>
#include <utility>
#include <vector>

template <class It, class Pred>
It move_remove_if(It first, It last, Pred pred) {
    first = std::find_if(first, last, pred);
    if (first == last) return last;
    for (It it = std::next(first); it != last; ++it)
        if (!pred(*it)) *first++ = std::move(*it);
    return first;
}

struct Tr {
    int v;
    static inline int copies = 0, moves = 0;
    Tr(int x) : v(x) {}
    Tr(const Tr& o) : v(o.v) { ++copies; }
    Tr(Tr&& o) noexcept : v(o.v) { ++moves; }
    Tr& operator=(const Tr& o) { v = o.v; ++copies; return *this; }
    Tr& operator=(Tr&& o) noexcept { v = o.v; ++moves; return *this; }
};

int main() {
    std::vector<Tr> t{1, 2, 3, 4, 5, 6};
    Tr::copies = Tr::moves = 0;
    auto mid = move_remove_if(t.begin(), t.end(), [](const Tr& x) { return x.v % 2 == 0; });
    std::printf("kept=%td copies=%d moves=%d\n", mid - t.begin(), Tr::copies, Tr::moves);
    t.erase(mid, t.end());
    for (const Tr& x : t) std::printf("%d ", x.v);
    std::printf("\n");

    std::vector<std::unique_ptr<int>> p;
    for (int i = 0; i < 5; ++i) p.push_back(std::make_unique<int>(i));
    auto pend = move_remove_if(p.begin(), p.end(), [](const auto& q) { return *q < 2; });
    std::printf("tail after remove: %td elements, first tail is %s\n", p.end() - pend,
                *pend ? "non-null" : "null");
    p.erase(pend, p.end());
    for (const auto& q : p) std::printf("%d ", *q);
    std::printf("\n");
}
kept=3 copies=0 moves=2
1 3 5
tail after remove: 2 elements, first tail is null
2 3 4

Complexity and pitfalls. O(n) predicate calls and at most n move assignments, no allocation. The tail is the lesson: after std::remove_if the elements in [new_end, end) are valid but unspecified (here the first tail unique_ptr is null because it was moved from, but the standard algorithm promises nothing about which values remain), so reading them is a logic error and forgetting erase leaves them in the container. Pitfalls: writing *first++ = *it (copies, and does not compile for unique_ptr); swapping instead of moving (correct but slower, and it changes what the tail holds); calling pred on an element after it has been moved from.

Estimated rating: ~1300 · short loop, but it forces you to reason about moved-from elements left behind by a standard algorithm

Cheat sheet

Theory
  • Categories: lvalue (has a name or identity), prvalue (pure value), xvalue (std::move(x), S{}.m). A named T&& variable is an lvalue.
  • std::move is static_cast<remove_reference_t<T>&&>. It moves nothing; the move constructor or assignment does. On a const object it silently copies.
  • Move members: steal with std::exchange(o.p_, nullptr), leave the source destructible and assignable, mark them noexcept, handle self-move (check or swap).
  • noexcept: vector relocation uses std::move_if_noexcept; a throwing move means copies on every growth.
  • Suppression: a user-declared destructor or copy operation (even = default) stops implicit moves, and std::move then copies. Declaring a move deletes the copies.
  • Rule of zero for normal classes, rule of five for resource owners, copies deleted for move-only types.
  • Interfaces: never return std::move(local) (Ch 24); sinks take by value then move; ref-qualify members to steal from temporaries.
  • Moved-from: valid but unspecified; only unique_ptr, shared_ptr, thread and a few others specify empty. Moved-from optional stays engaged.

Part F · Move semantics and value passing · Chapter 21

Perfect forwarding

Perfect forwarding lets a template pass its arguments on to another function exactly as the caller supplied them: lvalues stay lvalues, rvalues stay rvalues, const stays const. It is how std::make_unique, emplace_back, std::invoke and every logging or timing wrapper avoid extra copies. Interviewers ask about it because it combines template deduction, reference collapsing and value categories, and because the failure cases are well known.

TheoryDrillsCoding· about 4 hours· prerequisites: Ch 20 (value categories, std::move), basic templates

The forwarding problem

Theory

Mental model. A wrapper sits between a caller and a target. Whatever the caller passes, the target should see the same thing it would have seen without the wrapper. If the caller passes a temporary, the target should be able to move from it; if the caller passes a named object, the target must copy (or modify it, if it takes a non-const reference).

Before C++11 there was no good way to write such a wrapper. Each obvious parameter type loses something:

  • template <class A> void wrap(A& a) rejects rvalues: wrap(T{}) does not compile.
  • template <class A> void wrap(const A& a) accepts everything but adds const, so the target can never move from a temporary and cannot take a non-const reference.
  • Overloading on both A& and const A& for each parameter needs 2n overloads for n parameters, and still cannot express "rvalue".

C++11 solved it with two pieces: a special deduction rule for T&& in a template, and std::forward, which restores the original value category inside the wrapper.

#include <cstdio>
#include <utility>

struct T {
    T() = default;
    T(const T&) { std::puts("  copy"); }
    T(T&&) noexcept { std::puts("  move"); }
};

static void target(T) {}

template <class A> void by_cref(const A& a) { target(a); }               // always copies
template <class A> void perfect(A&& a) { target(std::forward<A>(a)); }   // copies or moves

int main() {
    T t;
    std::puts("by_cref(lvalue):");  by_cref(t);
    std::puts("by_cref(rvalue):");  by_cref(T{});
    std::puts("perfect(lvalue):");  perfect(t);
    std::puts("perfect(rvalue):");  perfect(T{});
}
by_cref(lvalue):
  copy
by_cref(rvalue):
  copy
perfect(lvalue):
  copy
perfect(rvalue):
  move

Calling target(T{}) directly would print nothing at all (the prvalue initialises the parameter in place, Ch 23). Perfect forwarding gets as close as a reference parameter can: the temporary has to be materialised to bind to a, so one move remains. That residual move is the reason emplace_back takes constructor arguments rather than an object (Ch 22).

Forwarding references vs rvalue references

Theory

The syntax && means two different things. In void f(Widget&& w) it is an rvalue reference: only rvalues bind. In template <class T> void f(T&& x), where T is deduced from this very argument, it is a forwarding reference (Scott Meyers' older name: "universal reference"), and it binds to everything. The standard defines it in [temp.deduct.call]/3: "an rvalue reference to a cv-unqualified template parameter that does not represent a template parameter of a class template (during class template argument deduction)". The parenthesised clause only matters for CTAD. A member function of a class template such as V<T>::push(T&&) is excluded for a simpler reason: its T is already fixed, so no deduction happens at the call.

The conditions are strict. All of them must hold:

  1. the form is exactly T&&, with no const and no wrapping template;
  2. T is a template parameter of the function template itself (or auto);
  3. deduction happens for that call.
DeclarationForwarding reference?Reason
template <class T> void f(T&& x)yesthe canonical form
auto&& x = expr;yesauto uses template deduction rules
[](auto&& x) { }yesa generic lambda is a template operator()
template <class... A> void f(A&&... a)yes, each onea pack of forwarding references
void f(Widget&& w)nono deduction
template <class T> void f(const T&& x)noconst breaks the special rule: binds only rvalues
template <class T> void f(std::vector<T>&& v)nothe reference is to vector<T>, not to T
template <class T> struct V { void push(T&& x); };noT was fixed when V<int> was instantiated
template <class T> struct V { template <class U> void emplace(U&& x); };yesU is deduced per call

This is why std::vector<T>::push_back needs two overloads (const T& and T&&, the second being a real rvalue reference), while emplace_back is a single variadic template with forwarding references.

Interview trap

"template <class T> void f(const T&& x) is a universal reference that accepts const objects." It is not. It is an rvalue reference to const and rejects every lvalue: int i; f(i); does not compile. The forwarding rule applies only to the exact form T&&.

Deduction and reference collapsing

Theory

For a parameter T&& where T is deduced, the language adds one special rule ([temp.deduct.call]/3): if the argument is an lvalue of type U, T is deduced as U&. If the argument is an rvalue, T is deduced as plain U. The parameter type is then T&& with T substituted, which can produce a "reference to reference". C++ never lets you write that directly, but it can arise through templates and aliases, and the reference collapsing rules turn it back into a single reference.

T isT& becomesT&& becomes
UU&U&&
U&U& (& & → &)U& (& && → &)
U&&U& (&& & → &)U&& (&& && → &&)

One sentence covers it: if either reference is an lvalue reference, the result is an lvalue reference; only && && gives &&. Collapsing applies in four places: template instantiation, auto deduction, typedef/using aliases and decltype.

template <class T> void f(T&& x); f(i) int lvalue T = int& special lvalue rule int& && int& x binds the lvalue collapse f(42) int prvalue T = int ordinary deduction int && int&& x binds the temporary T remembers the caller's value category: a reference type for lvalues, a non-reference for rvalues. Inside f, the expression x is an lvalue in both cases. std::forward<T> reads T to undo that.
T encodes the category. The deduced T is the only place the original value category survives; the parameter x itself is always a named lvalue inside the body.
#include <cstdio>
#include <type_traits>

using L = int&;
using R = int&&;

template <class T> constexpr bool param_is_lref(T&&) { return std::is_lvalue_reference_v<T&&>; }

int main() {
    static_assert(std::is_same_v<L&, int&>);
    static_assert(std::is_same_v<L&&, int&>);
    static_assert(std::is_same_v<R&, int&>);
    static_assert(std::is_same_v<R&&, int&&>);
    static_assert(std::is_same_v<const L, int&>);      // const on a reference is ignored
    int i = 0;
    std::printf("lvalue arg -> lref param: %d\n", param_is_lref(i));
    std::printf("rvalue arg -> lref param: %d\n", param_is_lref(42));
}
lvalue arg -> lref param: 1
rvalue arg -> lref param: 0

The line const L shows a related rule: const applied to a reference type through an alias is silently dropped (references cannot be reseated anyway), so it stays int&, not const int&. This matters when people write const T& in a template where T may already be a reference.

A const lvalue deduces T = const U&, so constness is part of what is forwarded. The forwarding reference never strips const; it just carries it along, which is why a target can never move from a const argument.

std::forward and why it needs the template argument

Theory

Inside the wrapper the parameter has a name, so it is an lvalue, whatever was passed. std::forward<T>(x) is a conditional cast: it casts x to T&&. By reference collapsing, that is U& when T = U& (the caller passed an lvalue) and U&& when T = U (the caller passed an rvalue).

#include <cstdio>
#include <string>
#include <type_traits>
#include <utility>

namespace my {
template <class T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept {
    return static_cast<T&&>(t);
}
template <class T>
constexpr T&& forward(std::remove_reference_t<T>&& t) noexcept {
    static_assert(!std::is_lvalue_reference_v<T>, "cannot forward an rvalue as an lvalue");
    return static_cast<T&&>(t);
}
}

static void g(const std::string&) { std::puts("g(const string&)"); }
static void g(std::string&&) { std::puts("g(string&&)"); }

template <class T>
void wrap(T&& x) { g(my::forward<T>(x)); }

int main() {
    std::string s = "abc";
    wrap(s);
    wrap(std::string{"tmp"});
    wrap(std::as_const(s));
    wrap(std::move(s));
}
g(const string&)
g(string&&)
g(const string&)
g(string&&)

Why the explicit template argument. The parameter of forward is std::remove_reference_t<T>&. A T that appears only inside a nested type like remove_reference_t<T> (formally, to the left of ::type) is a non-deduced context ([temp.deduct.type]/5), so std::forward(x) fails to compile. That is deliberate: the argument x is always an lvalue, so deduction from it would always produce the "lvalue" answer and lose the information. The caller's category lives only in the wrapper's T, so you must pass it in by hand.

#include <utility>

template <class T>
void wrap(T&& x) {
    auto y = std::forward(x);   // error: couldn't deduce template parameter
    (void)y;
}

int main() { wrap(1); }

The second overload (taking an rvalue) exists for expressions like std::forward<T>(get_member(x)) where the inner expression is an rvalue. Its static_assert catches the nonsense request "forward this rvalue as an lvalue reference", which would create a dangling reference.

x (named) always an lvalue std::forward<T>(x) static_cast<T&&>(x) T = U& → U& lvalue: target copies T = U → U&& xvalue: target may move std::move(x) would always take the lower path, even for a caller's lvalue: that is the bug in "move a forwarding reference".
forward is a conditional move. The decision is made at compile time from T alone. std::move is the unconditional version.

std::move vs std::forward

std::move(x)std::forward<T>(x)
Castalways to remove_reference_t<T>&&to T&&: lvalue or rvalue depending on T
Use ona value you own and will not use again (locals, rvalue reference parameters, members in && functions)forwarding references only
Template argumentdeducedmust be written
Wrong useon a forwarding reference: steals from the caller's lvalueon a non-forwarding parameter: forward<int>(x) just moves, which works but misleads readers

Rule of thumb from Effective Modern C++ (Items 24 and 25): std::move on rvalue references, std::forward on forwarding references, each applied once, at the last use.

Variadic forwarding: make_unique and emplace

Theory

Forwarding references combine with parameter packs. Args&&... args declares one forwarding reference per argument, each with its own deduced type, and std::forward<Args>(args)... expands pairwise into std::forward<A1>(a1), std::forward<A2>(a2), .... This is the whole implementation of std::make_unique for single objects:

#include <cstdio>
#include <memory>
#include <utility>

namespace my {
template <class T, class... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
}

struct Tracer {
    Tracer() = default;
    Tracer(const Tracer&) { std::puts("  copy"); }
    Tracer(Tracer&&) noexcept { std::puts("  move"); }
};

struct Widget {
    Tracer a, b;
    int n;
    Widget(const Tracer& x, Tracer&& y, int k) : a(x), b(std::move(y)), n(k) {}
};

int main() {
    Tracer t;
    std::puts("make_unique<Widget>(t, Tracer{}, 3):");
    auto w = my::make_unique<Widget>(t, Tracer{}, 3);
    auto p = my::make_unique<std::unique_ptr<int>>(std::make_unique<int>(7));   // move-only argument
    std::printf("n=%d **p=%d\n", w->n, **p);
}
make_unique<Widget>(t, Tracer{}, 3):
  copy
  move
n=3 **p=7

Note the parentheses in new T(...). Before C++20 that meant aggregates could not be created with make_unique (no constructor to call); C++20's parenthesised aggregate initialisation (P0960) fixed it. Another detail: the order in which a constructor's by-value parameters are initialised is unspecified, so tracing programs that print from several by-value parameters can differ between GCC and Clang. The Widget above takes references and initialises members, which always happens in declaration order.

The container version is the same idea with placement construction into already allocated storage: std::allocator_traits<A>::construct(alloc, p, std::forward<Args>(args)...). Ch 22 traces it in detail.

Two other patterns appear often. sizeof...(Args) gives the pack size. And forwarding into several calls means each argument is forwarded at most once: if you pass the same pack to two functions, only the last use may forward; earlier uses must pass the plain names (lvalues).

When perfect forwarding fails

Theory

"Perfect" forwarding is not perfect. Forwarding fails when calling the target directly would work but going through a template does not. There are five classic cases (Effective Modern C++, Item 30), plus the logic error of forwarding twice.

ArgumentWhy it failsFix
Braced initialiser {1, 2, 3}A braced-init-list has no type, so T cannot be deduced ([temp.deduct.call]/1)auto il = {1, 2, 3}; fwd(il); or name the type: fwd(std::vector{1, 2, 3})
0 or NULL as a null pointerDeduced as int (or long); an int variable does not convert to a pointernullptr
Name of an overloaded function or a function templateNo single type to deducestatic_cast<void(*)(int)>(f) or a lambda
Bit-field h.ihlA non-const reference cannot bind to a bit-fieldCopy it first: fwd(static_cast<unsigned>(h.ihl))
static const int N = 28; declared but not definedBinding a reference odr-uses N, which then needs a definition; link errorstatic constexpr (implicitly inline since C++17) or an out-of-class definition
#include <utility>
#include <vector>

static void take(const std::vector<int>&) {}

template <class T>
void fwd(T&& x) { take(std::forward<T>(x)); }

int main() {
    take({1, 2, 3});   // fine: the parameter type is known
    fwd({1, 2, 3});    // error: cannot deduce T from a braced-init-list
}
#include <utility>

static void take(int*) {}

template <class T>
void fwd(T&& x) { take(std::forward<T>(x)); }

int main() {
    take(0);     // fine: the literal 0 is a null pointer constant
    fwd(0);      // error: T = int, and an int expression does not convert to int*
}

All five fixes in one program, using a generic forwarder that calls any callable:

#include <cstdio>
#include <utility>
#include <vector>

template <class F, class... A>
void fwd(F&& f, A&&... a) { std::forward<F>(f)(std::forward<A>(a)...); }

static void take_vec(const std::vector<int>& v) { std::printf("vector of %zu\n", v.size()); }
static void take_ptr(int* p) { std::printf("pointer is %s\n", p ? "set" : "null"); }
static void process(int x) { std::printf("process(int %d)\n", x); }
static void process(double x) { std::printf("process(double %g)\n", x); }
struct Header { unsigned version : 4, ihl : 4; };
static void take_u(unsigned u) { std::printf("ihl %u\n", u); }
struct Limits { static constexpr int N = 28; };   // implicitly inline: has a definition
static void take_n(const int& n) { std::printf("N %d\n", n); }

int main() {
    auto il = {1, 2, 3};                           // std::initializer_list<int>
    fwd(take_vec, il);
    fwd(take_ptr, nullptr);
    fwd(static_cast<void (*)(int)>(process), 1);
    fwd([](double d) { process(d); }, 2.5);
    Header h{4, 5};
    fwd(take_u, static_cast<unsigned>(h.ihl));
    fwd(take_n, Limits::N);
}
vector of 3
pointer is null
process(int 1)
process(double 2.5)
ihl 5
N 28

Interview trap

The "static const without definition" case often links fine at -O2 (the optimiser inlines the call, folds the value and never emits a reference to the symbol) and fails at -O0 with "undefined reference" / "Undefined symbols". On GCC 16 and Clang 22 the program in the table fails at -O0 and links at -O2; at -O1 GCC fails while Clang links. The standard calls it ill-formed, no diagnostic required, so either outcome is "allowed". A bug that depends on the optimisation level is the worst kind. Since C++17, prefer static constexpr data members, which are implicitly inline and so are always defined.

Forwarding the same argument twice

std::forward may hand the object to a function that moves from it. Any later use of the same parameter then sees a moved-from object. This compiles, and for lvalue arguments it even works, so tests that only pass lvalues never catch it. Forward only at the last use; earlier uses pass the plain name.

Overloading on forwarding references is greedy

Theory

A function template with a T&& parameter produces an exact match for almost any argument. Overload resolution prefers a non-template only when the two are otherwise equally good. The classic victim is a class with a forwarding constructor:

#include <cstdio>
#include <utility>

struct W {
    W() = default;
    template <class T> W(T&&) { std::puts("template ctor"); }
    W(const W&) { std::puts("copy ctor"); }
    W(W&&) noexcept { std::puts("move ctor"); }
};

int main() {
    W a;
    const W ca;
    W b(a);              // T = W&: exact match beats adding const
    W c(ca);             // T = const W&: tie, the non-template wins
    W d(std::move(a));   // T = W: tie, the non-template wins
    W e(std::move(ca));  // T = const W: const W&& beats const W&
}
template ctor
copy ctor
move ctor
template ctor

Copying a non-const W calls the template. If the template's body does something like name_(std::forward<T>(x)) with a std::string member, that is a compile error deep inside the class ("no conversion from W to string"), which confuses everyone. Derived classes hit the same problem: a hand-written derived copy constructor Derived(const Derived& o) : Base(o) calls Base with a const Derived&, and the template (with T = const Derived&) beats Base(const Base&), which needs a derived-to-base conversion. (The implicit copy constructor of Derived passes the Base subobject, so it is safe.)

Constraining the template

Remove the template from overload resolution for the types it should not handle. In C++20 use a requires clause; in C++11/14/17 use std::enable_if_t as a defaulted template parameter.

#include <concepts>
#include <cstdio>
#include <string>
#include <utility>

class Person {
public:
    template <class S>
        requires std::constructible_from<std::string, S&&>
    explicit Person(S&& s) : name_(std::forward<S>(s)) { std::puts("forwarding ctor"); }
    Person(const Person& o) : name_(o.name_) { std::puts("copy ctor"); }
    Person(Person&& o) noexcept : name_(std::move(o.name_)) { std::puts("move ctor"); }
    const std::string& name() const { return name_; }
private:
    std::string name_;
};

int main() {
    Person a("Ada");
    std::string bob = "Bob";
    Person b(bob);
    Person c(a);              // the template is no longer viable for Person&
    Person d(std::move(b));
    std::printf("%s %s %s %s\n", a.name().c_str(), bob.c_str(), c.name().c_str(), d.name().c_str());
}
forwarding ctor
forwarding ctor
copy ctor
move ctor
Ada Bob Ada Bob

The C++17 spelling of the same constraint:

template <class S,
          class = std::enable_if_t<!std::is_base_of_v<Person, std::decay_t<S>>>>
explicit Person(S&& s);

Use is_base_of (or std::derived_from) with decay_t/remove_cvref_t when the class itself might be convertible to the target, so that derived classes are excluded too. Tag dispatch (forward to an implementation function overloaded on std::true_type/std::false_type from a trait such as std::is_integral) is the other classic C++11 technique (Effective Modern C++, Item 27). It works for ordinary function templates but not for constructors, which is why constructors use constraints.

Perfect returning and forwarding lambdas

Theory

decltype(auto)

A wrapper should also return exactly what the target returns. auto as a return type uses template deduction and strips references, so a wrapper around a function returning int& would return a copy. decltype(auto) (C++14) deduces the return type with decltype rules instead, preserving references and value category.

#include <cstdio>
#include <functional>
#include <type_traits>
#include <utility>
#include <vector>

template <class F, class... A>
decltype(auto) call(F&& f, A&&... a) {
    return std::invoke(std::forward<F>(f), std::forward<A>(a)...);
}
template <class F, class... A>
auto call_auto(F&& f, A&&... a) {
    return std::invoke(std::forward<F>(f), std::forward<A>(a)...);
}

int main() {
    std::vector<int> v{1, 2, 3};
    auto at = [&v](std::size_t i) -> int& { return v[i]; };
    static_assert(std::is_same_v<decltype(call(at, 0)), int&>);
    static_assert(std::is_same_v<decltype(call_auto(at, 0)), int>);
    call(at, 0) = 10;             // writes through the returned reference
    // call_auto(at, 1) = 20;     // error: cannot assign to a prvalue int
    std::printf("v[0]=%d\n", v[0]);
}
v[0]=10

Interview trap

With decltype(auto), parentheses change the type. decltype(x) is the declared type of x, but decltype((x)) is int& for an lvalue x. So decltype(auto) f() { int x = 1; return (x); } returns a dangling int&. Never parenthesise a local in a decltype(auto) return.

Forwarding in generic lambdas

A generic lambda parameter declared auto&& x is a forwarding reference, but there is no named T to pass to std::forward. The idiom is std::forward<decltype(x)>(x). For an lvalue argument decltype(x) is U&, which forwards as an lvalue. For an rvalue it is U&& rather than U, but forward<U&&> returns U&& &&, which collapses to U&&, the same result as forward<U>. C++20 template lambdas let you write the usual form directly.

auto log_call = [](auto&& f, auto&&... args) -> decltype(auto) {
    return std::invoke(std::forward<decltype(f)>(f), std::forward<decltype(args)>(args)...);
};
auto log_call20 = []<class F, class... A>(F&& f, A&&... args) -> decltype(auto) {
    return std::invoke(std::forward<F>(f), std::forward<A>(args)...);
};

auto&& also appears outside templates. for (auto&& e : range) binds to whatever the iterator yields, including proxy prvalues like std::vector<bool>::reference, where auto& would not compile. auto&& r = f(); on a function returning by value binds to a temporary whose lifetime is extended to that of r, so it does not dangle.

C++23 adds std::forward_like<Self>(member), which forwards a member with the value category and constness of the enclosing object. It pairs with "deducing this" (this auto&& self) to write one function instead of four ref-qualified overloads.

Concept and tricky questions

Drills

ConceptWhat is the forwarding problem, and which two language features solve it?

Answer

Answer. Writing a wrapper that passes its arguments to another function with their original value category and constness, so the target copies when the caller passed an lvalue and can move when the caller passed an rvalue. C++11 solves it with forwarding references (T&& with the special deduction rule plus reference collapsing) and std::forward<T>.

Why. const A& loses "rvalue-ness" (and adds const), A& rejects rvalues, and overloading every combination needs 2n functions. Forwarding references record the category in the deduced T; std::forward reads it back. A common incomplete answer mentions only std::forward, but without the deduction rule there would be nothing for it to read.

TrickyWhich of these parameters are forwarding references?

template <class T> void a(T&& x);
template <class T> void b(const T&& x);
template <class T> void c(std::vector<T>&& v);
void d(std::string&& s);
template <class T> struct Box { void e(T&& x); template <class U> void f(U&& y); };
auto&& g = compute();
auto h = [](auto&& z) {};
Answer

Answer. a, Box::f, g and the lambda's z. Not b, c, d or Box::e.

Why. The rule needs the exact form T&& (or auto&&) where T is deduced from that argument in that call. b has const, c wraps T inside vector, d has no template, and in Box::e the T belongs to the class and was fixed at instantiation (Box<int>::e takes int&&). All four only accept rvalues. Candidates often say Box::e is forwarding because "it's in a template", which is the most common error.

ConceptState the reference collapsing rules. Where do they apply?

Answer

Answer. & &, & && and && & all collapse to &; only && && collapses to &&. In words: lvalue reference wins. They apply during template instantiation, auto deduction, typedef/using aliases and decltype.

Why. You cannot write int& && in source code, but substituting T = int& into T&& produces it, and the rules define the result. Combined with "an lvalue argument deduces T = U&", they make a T&& parameter become U& for lvalues and U&& for rvalues, which is the whole forwarding-reference mechanism.

TrickyWhy does std::forward require an explicit template argument while std::move does not?

Answer

Answer. forward takes remove_reference_t<T>&, a non-deduced context, on purpose: the information it needs (the caller's category) is in the wrapper's T, not in the argument expression, which is always an lvalue.

Why. If forward deduced from its argument, it would always see an lvalue and always deduce "lvalue", making it useless. std::move needs no outside information: it casts to rvalue unconditionally, so deducing from the argument is fine. People sometimes answer "because of overloading" or "for readability"; the real reason is that the needed information is not present in the argument.

ConceptWhat do T, the parameter type and the category of the expression x become for template <class T> void f(T&& x) called as f(42), f(i) and f(ci), where int i; const int ci = 0;?

Answer

Answer. f(42): T = int, parameter int&&. f(i): T = int&, parameter int&. f(ci): T = const int&, parameter const int&. In all three, the expression x inside the body is an lvalue.

Why. Rvalues deduce the plain type; lvalues trigger the special rule that adds & to T, and collapsing then makes the parameter an lvalue reference. Constness is kept as part of T. The body sees a named variable either way, which is why it must call std::forward<T>(x) to restore the category.

TrickyFor std::vector<int*> v;, which of these compile: v.push_back(0), v.push_back(NULL), v.emplace_back(0), v.emplace_back(NULL), v.emplace_back(nullptr)?

Answer

Answer. Both push_back calls and emplace_back(nullptr) compile. emplace_back(0) and emplace_back(NULL) do not, on GCC 16 or Clang 22.

Why. push_back takes int* const& or int*&&, so the literal 0 converts directly: an integer literal with value zero is a null pointer constant. emplace_back deduces Args = int (or long for NULL, which both compilers define as __null), and inside the library the argument is an int&& expression, which is no longer a literal and does not convert to a pointer. This is forwarding failure case 2 from the table, hiding inside a standard container. The common wrong answer is "emplace accepts everything push_back accepts"; it does not, because forwarding throws away the "this is a literal 0" property along with braced lists.

Trickyv.emplace_back({1, 2}) fails to compile for std::vector<std::pair<int, int>>, but v.push_back({1, 2}) works. Why?

Answer

Answer. emplace_back takes Args&&..., and a braced-init-list has no type, so Args cannot be deduced. push_back is not a template on its argument: its parameter is const value_type& or value_type&&, so the braces initialise a pair<int, int> directly.

Why. [temp.deduct.call]/1: a braced-init-list argument makes the parameter a non-deduced context unless the parameter is std::initializer_list<P> or an array. This is the first of the classic perfect-forwarding failures. Write v.emplace_back(1, 2) instead, which forwards the two ints to pair's constructor.

ConceptWhen would you use decltype(auto) as a return type instead of auto?

Answer

Answer. In forwarding wrappers that must return exactly what the wrapped call returns, including references: decltype(auto) call(F&& f, A&&... a) { return std::invoke(...); }. Also for accessors that should return a reference when the underlying expression yields one.

Why. auto deduces like a by-value template parameter and drops references and top-level const, so a wrapper around vector::operator[] would return a copy. decltype(auto) applies decltype to the return expression. The cost: return (local); or returning a member access of a local can deduce a reference to a local, which dangles.

TrickyInside template <class T> class Stack { void push(T&& x) { ... } };, a colleague writes data_.push_back(std::forward<T>(x));. Is it a bug?

Answer

Answer. It is not a bug in behaviour, but it is wrong in intent. x is an rvalue reference (not forwarding, because T belongs to the class), and for a non-reference T, std::forward<T>(x) is exactly std::move(x). Write std::move(x).

Why. The two differ only if someone instantiates the class with an lvalue reference type, say Stack<std::string&>: then T&& collapses to std::string&, forward copies, and move would steal from the caller's object. The convention (move for rvalue references, forward for forwarding references) exists so that readers can tell which kind of parameter they are looking at.

ConceptHow do you perfectly forward a parameter in a generic lambda [](auto&& x) { g(???); }, and why does it work?

Answer

Answer. g(std::forward<decltype(x)>(x));. In C++20 you can also write []<class T>(T&& x) { g(std::forward<T>(x)); }.

Why. decltype(x) is the declared type of the parameter: U& for an lvalue argument and U&& for an rvalue. For an rvalue, forward<U&&> returns U&& &&, which collapses to U&&, identical to forward<U>. So the "wrong" template argument for rvalues gives the right answer. Writing std::forward<decltype(x)> without understanding this is fine in practice, but interviewers like to ask why it works.

TrickyIs auto&& r = make_string(); (returning std::string by value) a dangling reference? What about auto&& c = make_string().c_str(); and auto&& f = Builder{}.self(); where self() returns Builder&?

Answer

Answer. The first is safe: r binds directly to the temporary, whose lifetime is extended to r's. The second is a const char* pointing into a temporary string that dies at the end of the statement: dangling. The third is a reference returned by a function: no extension, dangling.

Why. Lifetime extension ([class.temporary]/6) applies only when a reference binds directly to a temporary (or a subobject of it via member access). A reference or pointer returned from a function call is not a temporary, so it never extends anything. auto&& is not special here; it just picks the reference type. People assume "auto&& always keeps things alive".

ConceptWhat does a const lvalue argument do to a forwarding reference parameter? Can the target move from it?

Answer

Answer. T is deduced as const U&, the parameter is const U&, and std::forward<T>(x) yields const U&. The target receives a const lvalue and can only copy.

Why. Forwarding preserves every property, constness included. A const rvalue (std::move(ci)) deduces T = const U and forwards as const U&&, which still binds only to copy constructors. That is correct behaviour: forwarding must not grant permissions the caller did not have.

Predict the output and find the bug

Drills

OutputWhat does each call deduce?

#include <cstdio>
#include <type_traits>
#include <utility>

template <class T>
constexpr const char* tn() {
    using std::is_same_v;
    if constexpr (is_same_v<T, int>) return "int";
    else if constexpr (is_same_v<T, int&>) return "int&";
    else if constexpr (is_same_v<T, int&&>) return "int&&";
    else if constexpr (is_same_v<T, const int>) return "const int";
    else if constexpr (is_same_v<T, const int&>) return "const int&";
    else if constexpr (is_same_v<T, const int&&>) return "const int&&";
    else return "?";
}

template <class T>
void f(T&&) { std::printf("T=%s param=%s\n", tn<T>(), tn<T&&>()); }

int main() {
    int x = 0;
    const int cx = 0;
    int&& rr = 5;
    f(x);
    f(cx);
    f(rr);
    f(0);
    f(std::move(x));
    f(std::move(cx));
}
Answer
T=int& param=int&
T=const int& param=const int&
T=int& param=int&
T=int param=int&&
T=int param=int&&
T=const int param=const int&&

Answer. As shown. The interesting line is the third: rr is declared int&&, but as an expression it is an lvalue, so T = int&.

Why. Deduction looks at the argument expression's type and category, never at how a variable was declared. Lvalues deduce U&, rvalues deduce U, and const stays part of U. The last line shows that a const rvalue forwards as const int&&, which a move constructor cannot accept.

OutputThe author wanted relay to make a local copy of its argument. What is printed?

#include <cstdio>
#include <type_traits>
#include <utility>

struct Tr {
    Tr() = default;
    Tr(const Tr&) { std::puts("  copy"); }
    Tr(Tr&&) noexcept { std::puts("  move"); }
};

template <class T>
void relay(T&& x) {
    T local = std::forward<T>(x);
    (void)local;
    std::printf("  local is %s\n", std::is_reference_v<T> ? "a reference" : "an object");
}

int main() {
    Tr t;
    const Tr ct;
    std::puts("lvalue:");        relay(t);
    std::puts("const lvalue:");  relay(ct);
    std::puts("rvalue:");        relay(Tr{});
}
Answer
lvalue:
  local is a reference
const lvalue:
  local is a reference
rvalue:
  move
  local is an object

Answer. No copy is ever printed. For lvalues, T is a reference type, so T local declares a reference that aliases the caller's object. Only the rvalue case creates an object, by moving.

Why. T is Tr& or const Tr& for lvalue arguments. Code that uses T as "the value type" inside a forwarding function is a classic bug: modifying local would modify the caller's object. Use std::remove_cvref_t<T> local = std::forward<T>(x); or simply auto local = std::forward<T>(x); to get a real copy or move.

OutputThree lambdas, two arguments each. What is printed?

#include <cstdio>
#include <utility>

struct Tr {
    Tr() = default;
    Tr(const Tr&) { std::puts("  copy"); }
    Tr(Tr&&) noexcept { std::puts("  move"); }
};

static void sink(Tr) {}

int main() {
    auto plain = [](auto&& x) { sink(x); };
    auto fwd = [](auto&& x) { sink(std::forward<decltype(x)>(x)); };
    auto fwd20 = []<class U>(U&& x) { sink(std::forward<U>(x)); };
    Tr t;
    std::puts("plain:"); plain(t); plain(Tr{});
    std::puts("fwd:");   fwd(t);   fwd(Tr{});
    std::puts("fwd20:"); fwd20(t); fwd20(Tr{});
}
Answer
plain:
  copy
  copy
fwd:
  copy
  move
fwd20:
  copy
  move

Answer. Without std::forward both calls copy; with it, the lvalue copies and the temporary moves.

Why. auto&& x is a forwarding reference, but inside the body x is a named lvalue, so sink(x) always copies. decltype(x) is Tr& or Tr&&; forwarding with either gives the right category thanks to collapsing. The C++20 template-lambda form is identical in effect.

OutputA class with an unconstrained forwarding constructor. Which constructor does each line call?

#include <cstdio>
#include <utility>

struct Base {
    Base() = default;
    template <class T> Base(T&&) { std::puts("Base template"); }
    Base(const Base&) { std::puts("Base copy"); }
    Base(Base&&) noexcept { std::puts("Base move"); }
};

struct Derived : Base {
    Derived() = default;
    Derived(const Derived& o) : Base(o) {}               // passes a Derived, not a Base
    Derived(Derived&& o) noexcept : Base(std::move(o)) {}
};

int main() {
    Derived d;
    const Derived cd;
    Derived d2(d);
    Derived d3(cd);
    Derived d4(std::move(d));
    Base b(cd);
}
Answer
Base template
Base template
Base template
Base template

Answer. All four lines call the template.

Why. Derived's hand-written copy constructor calls Base(o) with o of type const Derived&. The template deduces T = const Derived&, an exact match, while Base(const Base&) needs a derived-to-base conversion, which ranks worse. The move constructor passes Derived&&, and again the template wins over Base(Base&&). Base b(cd) goes through the template for the same reason. Had Derived used the implicit copy and move constructors, they would pass the Base subobject itself and the real constructors would run; hand-written ones that pass the whole object are the trap. Constraining with requires (!std::derived_from<std::remove_cvref_t<T>, Base>) makes all four use the copy or move constructors.

Find the bugA helper stores a string. After calling it, the caller's variable is empty. Why?

#include <cstdio>
#include <string>
#include <utility>
#include <vector>

static std::vector<std::string> saved;

template <class S>
void remember(S&& s) {
    saved.push_back(std::move(s));
}

int main() {
    std::string name = "Ada Lovelace, first programmer";
    remember(name);
    std::printf("name='%s' saved='%s'\n", name.c_str(), saved[0].c_str());
}
Answer
name='' saved='Ada Lovelace, first programmer'

Answer. std::move on a forwarding reference moves unconditionally. For the lvalue name, S = std::string&, and std::move(s) steals the caller's string. Use std::forward<S>(s).

Why. The caller passed an lvalue and expects it to be untouched; only forward respects that. The printed empty string is what both libstdc++ and libc++ leave behind; the standard only says "valid but unspecified", which is worse: the program might print the old value on another library, hiding the bug in testing.

Find the bugThe second log line is wrong for rvalue arguments. Why?

#include <cstdio>
#include <string>
#include <utility>
#include <vector>

static std::vector<std::string> store, audit;

template <class S>
void record(S&& s) {
    store.push_back(std::forward<S>(s));
    audit.push_back(std::forward<S>(s));
}

int main() {
    std::string a = "alpha-alpha-alpha-alpha";
    record(a);
    record(std::string("bravo-bravo-bravo-bravo"));
    for (std::size_t i = 0; i < store.size(); ++i)
        std::printf("store='%s' audit='%s'\n", store[i].c_str(), audit[i].c_str());
}
Answer
store='alpha-alpha-alpha-alpha' audit='alpha-alpha-alpha-alpha'
store='bravo-bravo-bravo-bravo' audit=''

Answer. The argument is forwarded twice. For an rvalue, the first forward lets push_back move the string into store, and the second pushes the moved-from (empty) husk into audit.

Why. Forward only on the last use. Fix: audit.push_back(s); store.push_back(std::forward<S>(s));, copying first and forwarding last. The lvalue call works either way, so tests that only use named variables pass, which is why this bug survives code review. The empty output is the libstdc++ and libc++ behaviour for a moved-from string.

Find the bugThis factory takes forwarding references but still copies. Find the problem.

#include <cstdio>
#include <memory>
#include <utility>

struct Tr {
    Tr() = default;
    Tr(const Tr&) { std::puts("copy"); }
    Tr(Tr&&) noexcept { std::puts("move"); }
};

struct Job {
    Tr payload;
    explicit Job(Tr p) : payload(std::move(p)) {}
};

template <class... Args>
std::unique_ptr<Job> make_job(Args&&... args) {
    return std::make_unique<Job>(args...);
}

int main() {
    auto j = make_job(Tr{});
}
Answer
copy
move

Answer. args... expands to named lvalues, so make_unique receives lvalues and the Job parameter is copy-constructed. Write std::make_unique<Job>(std::forward<Args>(args)...); the output then becomes move, move.

Why. Declaring a forwarding reference only captures the category in Args; nothing uses it unless std::forward is called. With move-only arguments (a unique_ptr) the same bug becomes a compile error deep inside make_unique, which is easier to notice than a silent copy.

Find the bugWhy does this not compile, and what are two fixes?

#include <cstdio>
#include <utility>

static void handler(int x) { std::printf("int %d\n", x); }
static void handler(const char* s) { std::printf("str %s\n", s); }

template <class F, class... A>
void dispatch(F&& f, A&&... a) { std::forward<F>(f)(std::forward<A>(a)...); }

int main() {
    dispatch(handler, 42);
}
Answer

Answer. handler names an overload set, which has no single type, so F cannot be deduced. Fixes: dispatch(static_cast<void (*)(int)>(handler), 42); or wrap it: dispatch([](auto&& x) { handler(std::forward<decltype(x)>(x)); }, 42);.

Why. Calling handler(42) directly works because overload resolution sees the argument. Passing the name to a template asks the compiler to pick a type before any call happens. The same failure occurs for a function template name, even with explicit template arguments: dispatch(std::max<int>, 1, 2) does not compile on GCC or Clang, because std::max<int> still names several overloads (two arguments, two arguments plus a comparator, an initializer list). People often assume the <int> picks one function. The lambda fix is the robust one: it defers overload resolution to the call.

Find the bugA bit-field passed through a forwarding wrapper. Why the compile error?

#include <cstdint>
#include <cstdio>
#include <utility>

struct IPv4Header {
    std::uint32_t version : 4, ihl : 4, dscp : 6, ecn : 2, total_length : 16;
};

static void show(std::uint32_t v) { std::printf("%u\n", v); }

template <class T>
void log_and_show(T&& x) { show(std::forward<T>(x)); }

int main() {
    IPv4Header h{4, 5, 0, 0, 20};
    show(h.ihl);           // fine
    log_and_show(h.ihl);   // error
}
Answer

Answer. h.ihl is an lvalue, so T = std::uint32_t&, and a non-const reference cannot bind to a bit-field ([class.bit]/3): bit-fields are not addressable. Fix: pass a copy, log_and_show(static_cast<std::uint32_t>(h.ihl)) or auto v = h.ihl; log_and_show(v);.

Why. A function that takes the bit-field by value or by const& works, because the compiler can make a temporary copy. A forwarding reference deduced from an lvalue is a non-const lvalue reference, which must refer to the actual bits. This comes up in networking code that parses packed headers.

Find the bugA helper captures a call to run later. ASan reports a stack-use-after-scope. Why, and how do you fix it while keeping perfect forwarding?

#include <algorithm>
#include <cstdio>
#include <string>
#include <utility>

static long count_a(const std::string& s) { return std::count(s.begin(), s.end(), 'a'); }

template <class F, class... A>
auto defer(F f, A&&... a) {
    return [f, &a...] { return f(std::forward<A>(a)...); };
}

int main() {
    auto job = defer(count_a, std::string("banana, papaya and a long tail of text"));
    std::printf("a count: %ld\n", job());
}
Answer

Answer. [f, &a...] captures the forwarding references by reference. For the rvalue argument, a refers to the temporary std::string, which dies at the end of the statement that creates job. Calling job() then reads a dead object. Fix by capturing the pack by value with a C++20 init-capture pack: [f, ...a = std::forward<A>(a)]() mutable { return f(std::move(a)...); }. Lvalue arguments are then copied into the closure and rvalues are moved in.

Why. A forwarding reference is still a reference: perfect forwarding is only safe within the call that received the arguments. Anything that outlives the call (a closure, a stored task, a thread) must decay-copy the arguments, as std::thread, std::bind and std::async do. Before C++20, use std::make_tuple(std::forward<A>(a)...) captured by value and std::apply. A tempting half-fix is [=] with std::forward<A>(a) left in the body: the copies are then const members of a non-mutable closure, std::forward cannot bind them, and the code stops compiling. The captured copies belong to the closure, so the body should std::move them, which needs mutable. Without ASan this program often prints the right count, because the dead string's buffer has not been reused yet.

Write the code

Coding

CodeImplement my::move and my::forward (both overloads of forward) and show, with an overload probe, that a forwarding wrapper built on them preserves all four combinations of const and value category. Show that forwarding an rvalue as an lvalue is rejected at compile time.

Answer

Approach. move casts to remove_reference_t<T>&&. forward has two overloads taking remove_reference_t<T>& and &&, both returning static_cast<T&&>(t); the second asserts that T is not an lvalue reference. A probe with four overloads reports what the wrapper delivered.

#include <cstdio>
#include <type_traits>

namespace my {
template <class T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
    return static_cast<std::remove_reference_t<T>&&>(t);
}
template <class T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept {
    return static_cast<T&&>(t);
}
template <class T>
constexpr T&& forward(std::remove_reference_t<T>&& t) noexcept {
    static_assert(!std::is_lvalue_reference_v<T>, "cannot forward an rvalue as an lvalue");
    return static_cast<T&&>(t);
}
}

static const char* probe(int&) { return "int&"; }
static const char* probe(const int&) { return "const int&"; }
static const char* probe(int&&) { return "int&&"; }
static const char* probe(const int&&) { return "const int&&"; }

template <class T>
const char* relay(T&& x) { return probe(my::forward<T>(x)); }

static int value() { return 3; }

int main() {
    int x = 1;
    const int cx = 2;
    std::printf("%s\n", relay(x));
    std::printf("%s\n", relay(cx));
    std::printf("%s\n", relay(7));
    std::printf("%s\n", relay(my::move(cx)));
    std::printf("%s\n", probe(my::forward<int>(value())));   // the rvalue overload
    static_assert(std::is_same_v<decltype(my::forward<int&>(x)), int&>);
    static_assert(std::is_same_v<decltype(my::forward<int>(x)), int&&>);
    static_assert(std::is_same_v<decltype(my::move(x)), int&&>);
}
int&
const int&
int&&
const int&&
int&&
#include <type_traits>

namespace my {
template <class T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept { return static_cast<T&&>(t); }
template <class T>
constexpr T&& forward(std::remove_reference_t<T>&& t) noexcept {
    static_assert(!std::is_lvalue_reference_v<T>, "cannot forward an rvalue as an lvalue");
    return static_cast<T&&>(t);
}
}

int main() {
    int& r = my::forward<int&>(42);   // static_assert fires: would dangle
    (void)r;
}

Complexity and pitfalls. No run-time cost; both are casts. Pitfalls: writing forward(T&& t) with a deducible parameter (callers could omit the template argument and always get the lvalue answer); returning remove_reference_t<T>&& from forward (that is move); omitting the rvalue overload, which makes forward<int>(value()) fail to compile. The standard versions are also [[nodiscard]] in recent libraries.

Estimated rating: ~1500 · two lines each, but you must explain non-deduced contexts and collapsing to justify them

CodeImplement my::make_unique with the standard's three forms: make_unique<T>(args...) for single objects, make_unique<T[]>(n) for unbounded arrays (value-initialised), and a deleted overload for bounded arrays T[N]. Test with a move-only argument, an aggregate (C++20) and an array.

Answer

Approach. Three overloads selected by constraints on T. The single-object form forwards the pack into new T(...). The array form computes the element type with remove_extent_t and uses new E[n](), where the () value-initialises (zeroes ints). The bounded-array form is = delete so that make_unique<int[3]>() is a clear "use of deleted function" error instead of a confusing one.

#include <cstdio>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>

namespace my {
template <class T, class... Args>
    requires (!std::is_array_v<T>)
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
template <class T>
    requires std::is_unbounded_array_v<T>
std::unique_ptr<T> make_unique(std::size_t n) {
    return std::unique_ptr<T>(new std::remove_extent_t<T>[n]());
}
template <class T, class... Args>
    requires std::is_bounded_array_v<T>
void make_unique(Args&&...) = delete;
}

struct Point { int x, y; };                      // aggregate: no constructor
struct Owner {
    std::unique_ptr<int> p;
    explicit Owner(std::unique_ptr<int> q) : p(std::move(q)) {}
};

int main() {
    auto s = my::make_unique<std::string>(3, 'z');
    auto pt = my::make_unique<Point>(1, 2);       // C++20 parenthesised aggregate init
    auto o = my::make_unique<Owner>(std::make_unique<int>(42));
    auto arr = my::make_unique<int[]>(4);
    arr[2] = 9;
    std::printf("%s (%d,%d) %d [%d %d %d %d]\n", s->c_str(), pt->x, pt->y, *o->p,
                arr[0], arr[1], arr[2], arr[3]);
}
zzz (1,2) 42 [0 0 9 0]

Complexity and pitfalls. One allocation per call. Pitfalls: using braces new T{...}, which changes meaning for types with initializer-list constructors (make_unique<std::vector<int>>(3, 1) must make three ones, not the list {3, 1}); forgetting the () in the array form, which leaves ints uninitialised (C++20's make_unique_for_overwrite does that on purpose); not forwarding, which breaks move-only arguments. Why make_unique exists at all: before C++17, f(std::unique_ptr<A>(new A), g()) could leak if the compiler evaluated new A, then g() threw (see Ch 11).

Estimated rating: ~1600 · variadic forwarding plus array specialisation via constraints, with a few value-initialisation details

CodeWrite logged(name, f, args...), a wrapper that prints -> name(N args) before the call and <- name after it, perfectly forwards the callable and arguments through std::invoke, and perfectly returns the result (value, reference or void). It must work with member function pointers and move-only return types.

Answer

Approach. The return type is decltype(auto) and the body returns the std::invoke call directly, which preserves references and works for void (returning a void expression is allowed). The "after" message is printed by a local object's destructor, which runs after the return value has been constructed. That avoids storing the result in a variable, which would break perfect returning.

#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

template <class F, class... A>
decltype(auto) logged(const char* name, F&& f, A&&... a) {
    std::printf("-> %s(%zu args)\n", name, sizeof...(A));
    struct Exit {
        const char* n;
        ~Exit() { std::printf("<- %s\n", n); }
    } exit_log{name};
    return std::invoke(std::forward<F>(f), std::forward<A>(a)...);
}

struct Account {
    int balance = 0;
    void deposit(int x) { balance += x; }
    int& ref() { return balance; }
};

static std::unique_ptr<std::string> make_name(std::string s) {
    return std::make_unique<std::string>(std::move(s));
}

int main() {
    Account acc;
    logged("deposit", &Account::deposit, acc, 50);              // member pointer, void
    logged("ref", &Account::ref, acc) += 5;                      // returns int&
    static_assert(std::is_same_v<decltype(logged("r", &Account::ref, acc)), int&>);
    auto name = logged("make_name", make_name, std::string("Ada")); // move-only result
    std::vector<int> v{3, 1, 2};
    auto sum = logged("sum", [](const std::vector<int>& xs) { int s = 0; for (int x : xs) s += x; return s; }, v);
    std::printf("balance=%d name=%s sum=%d\n", acc.balance, name->c_str(), sum);
}
-> deposit(2 args)
<- deposit
-> ref(1 args)
<- ref
-> make_name(1 args)
<- make_name
-> sum(1 args)
<- sum
balance=55 name=Ada sum=6

Complexity and pitfalls. No overhead beyond the two prints. Pitfalls: writing auto result = std::invoke(...); log(); return result; loses references and adds a move (and needs a special case for void); writing decltype(auto) r = ...; return r; keeps references but returning a named T&& variable needs C++23's implicit-move rules; forgetting std::invoke breaks member pointers. Note that <- ref prints before the += 5 runs, because the destructor fires when logged returns.

Estimated rating: ~1800 · perfect returning with a post-call hook is a known trick that most candidates do not reach unaided

CodeWrite a class Label holding a std::string, with a forwarding constructor that accepts anything a std::string can be constructed from. Label also has an implicit operator std::string(). Copying and moving a Label, and copying a class derived from it, must use the real copy and move constructors. Prove it with a trace.

Answer

Approach. A positive constraint (constructible_from<std::string, S>) is not enough here, because Label converts to std::string, so a Label& argument would satisfy it and the template would hijack copies. Add a negative constraint that removes Label and anything derived from it: !std::derived_from<std::remove_cvref_t<S>, Label>.

#include <concepts>
#include <cstdio>
#include <string>
#include <string_view>
#include <type_traits>
#include <utility>

class Label {
public:
    template <class S>
        requires std::constructible_from<std::string, S&&> &&
                 (!std::derived_from<std::remove_cvref_t<S>, Label>)
    explicit Label(S&& s) : text_(std::forward<S>(s)) { std::puts("  forwarding"); }
    Label(const Label& o) : text_(o.text_) { std::puts("  copy"); }
    Label(Label&& o) noexcept : text_(std::move(o.text_)) { std::puts("  move"); }
    operator std::string() const { return text_; }
    const std::string& text() const { return text_; }
private:
    std::string text_;
};

struct Badge : Label {
    int level;
    Badge(std::string s, int l) : Label(std::move(s)), level(l) {}
    Badge(const Badge& o) : Label(o), level(o.level) {}   // passes a Badge to Label
};

int main() {
    std::puts("from literal, string, string_view:");
    Label a("alpha");
    std::string s = "beta";
    Label b(s);
    Label c(std::string_view("gamma"));
    std::puts("copy and move:");
    Label d(a);
    Label e(std::move(b));
    std::puts("derived:");
    Badge x("delta", 3);
    Badge y(x);
    std::printf("%s %s %s %s %d\n", a.text().c_str(), d.text().c_str(), e.text().c_str(),
                y.text().c_str(), y.level);
}
from literal, string, string_view:
  forwarding
  forwarding
  forwarding
copy and move:
  copy
  move
derived:
  forwarding
  copy
alpha alpha beta delta 3

Complexity and pitfalls. Construction is linear in the string length. Without the derived_from constraint, Label d(a) would run the template (Label& converts to std::string through the conversion operator, an extra copy) and so would Badge y(x), because Badge's copy constructor passes a whole const Badge& to Label. The constraint uses remove_cvref_t because S is a reference type for lvalues. Explicit is kept on the forwarding constructor so that strings do not silently convert to Label.

Estimated rating: ~1700 · requires seeing that a positive constraint is defeated by the conversion operator

CodeWrite make_scope_guard(f), returning a move-only ScopeGuard that runs f on destruction unless dismiss() was called. It must store the callable by value (decayed), copying lvalue callables and moving rvalue ones, and accept function names, lambdas and function objects. Trace the copies and moves of a function object.

Answer

Approach. ScopeGuard<F> holds an F and an active flag. The factory deduces the guard's type as ScopeGuard<std::decay_t<F>> (so a function name becomes a function pointer and a reference becomes a value) and forwards the callable into the guard's constructor. The guard is returned as a prvalue, so no move of the guard happens in C++17 and later; a move constructor is still provided for users who want to transfer it.

#include <cstdio>
#include <type_traits>
#include <utility>

template <class F>
class ScopeGuard {
public:
    template <class G>
    explicit ScopeGuard(G&& g) : f_(std::forward<G>(g)) {}
    ScopeGuard(ScopeGuard&& o) noexcept(std::is_nothrow_move_constructible_v<F>)
        : f_(std::move(o.f_)), active_(std::exchange(o.active_, false)) {}
    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;
    ScopeGuard& operator=(ScopeGuard&&) = delete;
    ~ScopeGuard() { if (active_) f_(); }
    void dismiss() noexcept { active_ = false; }
private:
    F f_;
    bool active_ = true;
};

template <class F>
[[nodiscard]] ScopeGuard<std::decay_t<F>> make_scope_guard(F&& f) {
    return ScopeGuard<std::decay_t<F>>(std::forward<F>(f));
}

struct Action {
    const char* msg;
    Action(const char* m) : msg(m) {}
    Action(const Action& o) : msg(o.msg) { std::puts("  Action copy"); }
    Action(Action&& o) noexcept : msg(o.msg) { std::puts("  Action move"); }
    void operator()() const { std::printf("  run: %s\n", msg); }
};

static void cleanup() { std::puts("  run: cleanup()"); }

int main() {
    Action named("named");
    {
        std::puts("lvalue functor:");
        auto g1 = make_scope_guard(named);
        std::puts("rvalue functor:");
        auto g2 = make_scope_guard(Action("temp"));
        std::puts("function name and lambda:");
        auto g3 = make_scope_guard(cleanup);
        auto g4 = make_scope_guard([] { std::puts("  run: lambda"); });
        g4.dismiss();
        static_assert(std::is_same_v<decltype(g3), ScopeGuard<void (*)()>>);
        std::puts("leaving scope:");
    }
}
lvalue functor:
  Action copy
rvalue functor:
  Action move
function name and lambda:
leaving scope:
  run: cleanup()
  run: temp
  run: named

Complexity and pitfalls. Zero overhead beyond the stored callable and a bool. Guards run in reverse order of creation (g4 is dismissed). Pitfalls: storing F instead of decay_t<F> makes the guard hold a reference to the caller's functor (dangling if the functor is a temporary) or fail to compile for function types; letting an exception escape the destructor terminates the program, so production guards wrap the call in try or require noexcept callables; the unconstrained template constructor is safe here only because copying is deleted and the class is never constructed from itself through it (the move constructor wins the tie for rvalues). C++ has std::experimental::scope_exit in the Library Fundamentals TS v3 with the same design.

Estimated rating: ~1600 · decay plus forwarding into a stored member, with move-only semantics and destructor ordering

CodeImplement my::forward_like<T>(x) (the C++23 utility): it returns x with the value category and constness of T, so that a function taking Self&& self can forward self.member correctly. Test all four combinations of const and value category on the owner.

Answer

Approach. Read two facts from T: is T&& an lvalue reference, and is remove_reference_t<T> const. Then return x as an lvalue or an xvalue, adding const with std::as_const when the owner was const. The return type is auto&&, so each if constexpr branch produces its own reference type.

#include <cstdio>
#include <string>
#include <type_traits>
#include <utility>

namespace my {
template <class T, class U>
constexpr auto&& forward_like(U&& x) noexcept {
    constexpr bool add_const = std::is_const_v<std::remove_reference_t<T>>;
    if constexpr (std::is_lvalue_reference_v<T&&>) {
        if constexpr (add_const) return std::as_const(x);
        else return static_cast<U&>(x);
    } else {
        if constexpr (add_const) return std::move(std::as_const(x));
        else return std::move(x);
    }
}
}

static const char* probe(std::string&) { return "string&"; }
static const char* probe(const std::string&) { return "const string&"; }
static const char* probe(std::string&&) { return "string&&"; }
static const char* probe(const std::string&&) { return "const string&&"; }

struct Holder { std::string s = "payload"; };

template <class Self>
const char* get(Self&& self) { return probe(my::forward_like<Self>(self.s)); }

int main() {
    Holder h;
    const Holder ch;
    std::printf("%s\n", get(h));
    std::printf("%s\n", get(ch));
    std::printf("%s\n", get(Holder{}));
    std::printf("%s\n", get(std::move(ch)));
    std::string taken = my::forward_like<Holder>(h.s);   // owner treated as rvalue: moves
    std::printf("taken=%s left=%zu\n", taken.c_str(), h.s.size());
}
string&
const string&
string&&
const string&&
taken=payload left=0

Complexity and pitfalls. Compile-time only. Why not just std::forward<Self>(self).s? That also works for data members (member access on an xvalue is an xvalue), but not for things reached through a pointer or a getter, such as *self.ptr_ in an optional-like class; forward_like handles those. Pitfall: for a const owner, the result must be const even if the member is not, otherwise a const object's internals could be moved from. The left=0 line reflects both standard libraries' moved-from strings.

Estimated rating: ~1900 · four-way category and constness propagation with if constexpr and auto&& returns

Cheat sheet

Theory
  • Forwarding reference: exactly T&& (or auto&&) with T deduced in this call. const T&&, vector<T>&& and a class template's T&& are plain rvalue references.
  • Deduction: lvalue argument gives T = U&, rvalue gives T = U; const is kept. Collapsing: any & wins; only && && gives &&.
  • std::forward<T>(x) = static_cast<T&&>(x); the template argument is mandatory because the argument is always an lvalue. Forward once, at the last use.
  • move vs forward: std::move for rvalue references and values you own; std::forward for forwarding references. Moving a forwarding reference steals from callers' lvalues.
  • Variadic: template <class... A> f(A&&... a) with std::forward<A>(a)...; that is all of make_unique and emplace.
  • Failures: braced lists, 0/NULL, overloaded names, bit-fields, undefined static const members.
  • Greedy templates: T&& constructors beat the copy constructor for non-const lvalues and derived classes; constrain with requires or enable_if.
  • Returning: decltype(auto) for perfect returning, never return (local);. In generic lambdas use std::forward<decltype(x)>(x).

Part F · Move semantics and value passing · Chapter 22

emplace vs push_back

push_back inserts an object you already have; emplace_back builds the object inside the container from constructor arguments. The difference is one move at most, but emplace also calls explicit constructors and allows narrowing, so it can turn a compile error into a leak or a silent bug. Interviewers like this topic because "always use emplace" is a popular wrong answer.

TheoryDrillsCoding· about 3 hours· prerequisites: Ch 20 (moves), Ch 21 (perfect forwarding), Ch 14 (placement new)

What each function does

Theory

Mental model. A vector's spare capacity is raw memory. Something has to construct a T in the next free slot. push_back receives a finished T (by reference) and copy- or move-constructs the slot from it. emplace_back receives the ingredients and runs T's constructor directly in the slot, so no intermediate T ever exists.

void push_back(const T& value);                 // copy-constructs the slot from value
void push_back(T&& value);                      // move-constructs the slot from value
template <class... Args>
T& emplace_back(Args&&... args);                // constructs the slot from args (returns T& since C++17)

Under the hood all three end in the same call: std::allocator_traits<A>::construct(alloc, slot, args...). For std::allocator that is std::construct_at(slot, std::forward<Args>(args)...), which is placement new with parentheses: ::new (static_cast<void*>(slot)) T(std::forward<Args>(args)...). push_back(x) is simply the case where the argument list is one T.

v.push_back(Widget("id", 3)); temporary Widget 1. ctor (caller's stack) w0 w1 slot 2. move ctor into slot 3. dtor of the temporary v.emplace_back("id", 3); args: const char(&)[3], int just references w0 w1 Widget forward → ::new(slot) Widget(args...) One constructor call and no temporary. For cheap-to-move types the saving is a single move and destructor.
The only thing emplace saves is the temporary. push_back needs an existing T to bind its reference to; emplace_back forwards the constructor arguments and constructs in the slot.

The same pairs exist across the library: push_front/emplace_front (deque, list, forward_list), insert/emplace (every standard container except std::array, at a position for sequences), push/emplace (stack, queue, priority_queue adaptors) and insert/emplace/try_emplace for associative containers.

Tracing every combination

Theory

A type that prints each special member call, a vector with enough capacity reserved so no reallocation interferes, and the eight interesting calls:

#include <cstdio>
#include <utility>
#include <vector>

static bool tracing = true;

struct T {
    int v;
    T(int x) : v(x) { if (tracing) std::printf("  ctor(%d)\n", v); }
    T(const T& o) : v(o.v) { if (tracing) std::printf("  copy(%d)\n", v); }
    T(T&& o) noexcept : v(o.v) { if (tracing) std::printf("  move(%d)\n", v); }
    ~T() { if (tracing) std::printf("  dtor(%d)\n", v); }
};

int main() {
    std::vector<T> v;
    v.reserve(16);              // no reallocation noise
    T t(0);
    std::puts("push_back(t):");               v.push_back(t);
    std::puts("push_back(std::move(t)):");    v.push_back(std::move(t));
    std::puts("push_back(T(3)):");            v.push_back(T(3));
    std::puts("push_back(4):");               v.push_back(4);
    std::puts("emplace_back(t):");            v.emplace_back(t);
    std::puts("emplace_back(std::move(t)):"); v.emplace_back(std::move(t));
    std::puts("emplace_back(T(7)):");         v.emplace_back(T(7));
    std::puts("emplace_back(8):");            v.emplace_back(8);
    tracing = false;            // element destruction order differs by library
}
  ctor(0)
push_back(t):
  copy(0)
push_back(std::move(t)):
  move(0)
push_back(T(3)):
  ctor(3)
  move(3)
  dtor(3)
push_back(4):
  ctor(4)
  move(4)
  dtor(4)
emplace_back(t):
  copy(0)
emplace_back(std::move(t)):
  move(0)
emplace_back(T(7)):
  ctor(7)
  move(7)
  dtor(7)
emplace_back(8):
  ctor(8)

Key facts

CallSpecial membersNotes
push_back(lvalue)copyidentical to emplace_back(lvalue)
push_back(std::move(x))moveidentical to emplace_back(std::move(x))
push_back(T(args))ctor + move + dtorthe prvalue must be materialised to bind to T&&
push_back(arg), implicit conversionctor + move + dtoronly works if the converting constructor is not explicit
emplace_back(T(args))ctor + move + dtorno better than push_back: you built the temporary yourself
emplace_back(args)ctorthe only case where emplace wins; calls explicit constructors too
Return typepush_back: voidemplace_back: T& since C++17, void in C++11/14
Braced argument {1, 2}push_back worksemplace_back does not compile (no type to deduce, Ch 21)

Know this cold

Copy elision cannot reach into a container. A prvalue argument to push_back or emplace_back is always materialised as a temporary, because a reference parameter has to bind to an object. Only emplace_back(ctor args) avoids that object.

emplace_back returns a reference (C++17)

Theory

Since C++17, emplace_back and emplace_front on vector, deque, list and forward_list (which has only emplace_front), and emplace on the queue and stack adaptors, return a reference to the inserted element (P0084, "Emplace Return Type"). priority_queue::emplace still returns void, because the new element is not at a known position. Before that you wrote v.emplace_back(...); auto& x = v.back();. The reference is valid until the next operation that invalidates references (any reallocation for vector).

#include <cstdio>
#include <string>
#include <vector>

struct Order { std::string sym; int qty; Order(std::string s, int q) : sym(std::move(s)), qty(q) {} };

int main() {
    std::vector<Order> book;
    Order& o = book.emplace_back("AAPL", 100);
    o.qty += 50;
    book.emplace_back("MSFT", 10).qty *= 3;
    for (const Order& x : book) std::printf("%s %d\n", x.sym.c_str(), x.qty);
}
AAPL 150
MSFT 30
#include <vector>

int main() {
    std::vector<int> v;
    int& r = v.emplace_back(1);   // C++14: emplace_back returns void
    (void)r;
}

The returned reference is the safe way to touch the new element. Taking o above and then emplacing more elements is the bug: o refers into the old buffer once the vector grows (see the reallocation section below).

When emplace is no better, and when it is worse

Theory

No better

  • Passing an existing object: emplace_back(x) copies and emplace_back(std::move(x)) moves, exactly like push_back.
  • Passing a temporary you built yourself: emplace_back(T{...}) is the same as push_back(T{...}).
  • Types that are cheap to move (int, small structs, std::string under SSO): the saved move costs nothing measurable.
  • Associative containers when the key is often already present: emplace may build a node and throw it away (see the maps section below).

Explicit constructors are bypassed

push_back(arg) uses copy-initialisation of a T parameter, which respects explicit. emplace_back(arg) uses direct-initialisation inside the container, which calls explicit constructors happily. That removes a safety net the class author put there on purpose.

#include <memory>
#include <vector>

int main() {
    std::vector<std::unique_ptr<int>> v;
    v.push_back(new int(1));     // error: unique_ptr(pointer) is explicit
}

The emplace version compiles, and leaks if the vector has to grow and the allocation fails. The raw pointer is created as an argument; if emplace_back throws before the unique_ptr is constructed in the slot, nothing owns it:

#include <cstdio>
#include <memory>
#include <new>
#include <vector>

static bool fail_next = false;

template <class T>
struct FlakyAlloc {
    using value_type = T;
    FlakyAlloc() = default;
    template <class U> FlakyAlloc(const FlakyAlloc<U>&) noexcept {}
    T* allocate(std::size_t n) {
        if (fail_next) throw std::bad_alloc();
        return std::allocator<T>{}.allocate(n);
    }
    void deallocate(T* p, std::size_t n) noexcept { std::allocator<T>{}.deallocate(p, n); }
    friend bool operator==(const FlakyAlloc&, const FlakyAlloc&) { return true; }
};

struct Obj {
    static inline int live = 0;
    Obj() { ++live; }
    ~Obj() { --live; }
};

int main() {
    std::vector<std::unique_ptr<Obj>, FlakyAlloc<std::unique_ptr<Obj>>> v;
    v.emplace_back(new Obj);                       // capacity 1
    fail_next = true;
    try { v.emplace_back(new Obj); }               // growth fails before any unique_ptr owns it
    catch (const std::bad_alloc&) { std::puts("emplace_back(new Obj): bad_alloc"); }
    try { v.push_back(std::make_unique<Obj>()); }  // the temporary unique_ptr cleans up
    catch (const std::bad_alloc&) { std::puts("push_back(make_unique): bad_alloc"); }
    fail_next = false;
    v.clear();
    std::printf("live objects after clear: %d\n", Obj::live);
}
emplace_back(new Obj): bad_alloc
push_back(make_unique): bad_alloc
live objects after clear: 1

The one surviving Obj is the leak. The rule: never pass a raw owning new as an argument; create the owner first with std::make_unique, then push or emplace it.

v.emplace_back(new Obj) 1. new Obj raw Obj*, no owner 2. enter emplace_back size == capacity 3. allocate throws bad_alloc 4. Obj* lost leak v.push_back(std::make_unique<Obj>()) 1. unique_ptr temp owns the Obj 2. enter push_back 3. allocate throws 4. temp dtor deletes no leak
Ownership must exist before the call. With emplace, the owner is constructed inside the container, after the step that can throw; with make_unique, the owner exists before the container does anything.

vector<vector<int>>: braces vs parentheses

push_back({10}) copy-list-initialises an std::vector<int> from {10}, which picks the initializer-list constructor: one element, value 10. emplace_back(10) direct-initialises with parentheses, std::vector<int>(10), which is the explicit size constructor: ten zeros. push_back(10) does not compile because that constructor is explicit.

#include <cstdio>
#include <vector>

int main() {
    std::vector<std::vector<int>> vv;
    vv.push_back({10});        // {10}
    vv.emplace_back(10);       // ten zeros
    vv.emplace_back(3, 7);     // {7, 7, 7}
    for (const auto& x : vv) std::printf("size %zu, first %d\n", x.size(), x[0]);
}
size 1, first 10
size 10, first 0
size 3, first 7

A nastier example: std::vector<std::regex> rs; rs.emplace_back(nullptr); compiles (it calls the explicit regex(const char*) with a null pointer, undefined behaviour), while rs.push_back(nullptr) is rejected.

Narrowing goes unnoticed

Placement new with parentheses allows narrowing conversions; a braced push_back does not.

#include <cstdint>
#include <cstdio>
#include <vector>

int main() {
    std::vector<std::uint8_t> bytes;
    bytes.emplace_back(300);           // compiles: 300 mod 256
    std::vector<int> ints;
    ints.emplace_back(3.99);           // compiles: truncates to 3
    std::printf("%d %d\n", bytes[0], ints[0]);
}
44 3
#include <cstdint>
#include <vector>

int main() {
    std::vector<std::uint8_t> bytes;
    bytes.push_back({300});            // error: narrowing conversion inside braces
}

Aggregates: emplace before and after C++20

For struct Point { int x, y; };, v.emplace_back(1, 2) becomes ::new (p) Point(1, 2). Before C++20 aggregates could only be initialised with braces, so this failed to compile and people wrote v.push_back({1, 2}). C++20 added parenthesised aggregate initialisation (P0960), so it now works. Parenthesised aggregate init allows narrowing and does not extend lifetimes of temporaries bound to reference members, unlike braces.

#include <vector>

struct Point { int x, y; };

int main() {
    std::vector<Point> v;
    v.push_back({1, 2});      // fine in every standard
    v.emplace_back(1, 2);     // C++17: no constructor Point(int, int)
}
#include <cstdio>
#include <vector>

struct Point { int x, y; };

int main() {
    std::vector<Point> v;
    v.emplace_back(1, 2);     // C++20: parenthesised aggregate initialisation
    v.emplace_back(3);        // y is value-initialised to 0
    std::printf("(%d,%d) (%d,%d)\n", v[0].x, v[0].y, v[1].x, v[1].y);
}
(1,2) (3,0)

Interview trap

"Always prefer emplace_back because it is never slower." Abseil's Tip of the Week #112 recommends the opposite for the common case: use push_back when it works (you already have a T, or a braced initialiser), and emplace_back only when you are passing constructor arguments that push_back cannot take. Effective Modern C++ Item 42 ("consider emplacement") lists the same dangers. Emplace trades type safety for one avoided move.

Associative containers: emplace, try_emplace, insert_or_assign

Theory

Map insertion functions differ in when they construct the value, and what happens if the key is already present.

FunctionKey absentKey presentRequirements
insert(value_type)moves or copies the pair into a nodenothing; you built the pair for nothinga whole pair
emplace(args...)constructs the pair in a nodenothing inserted, but the node may be built and destroyed, and rvalue arguments may be moved frompair constructible from args
try_emplace(k, args...) (C++17)constructs the value from argsguaranteed not to touch argskey separate from value args
insert_or_assign(k, v) (C++17)constructs the value from vassigns v to the existing valuevalue assignable
operator[](k)value-initialises the value (default constructor, or zero for int), returns a referencereturns a referencevalue default-constructible
#include <cstdio>
#include <map>
#include <tuple>
#include <utility>

struct V {
    int v;
    V() : v(0) { std::puts("  V()"); }
    V(int x) : v(x) { std::printf("  V(%d)\n", v); }
    V(int a, int b) : v(a * b) { std::printf("  V(%d,%d)\n", a, b); }
    V(const V& o) : v(o.v) { std::puts("  copy"); }
    V(V&& o) noexcept : v(o.v) { std::puts("  move"); }
    V& operator=(const V& o) { v = o.v; std::puts("  copy="); return *this; }
    V& operator=(V&& o) noexcept { v = o.v; std::puts("  move="); return *this; }
};

int main() {
    std::map<int, V> m;
    std::puts("insert({1, V(10)}):");      m.insert({1, V(10)});
    std::puts("emplace(2, 20):");          m.emplace(2, 20);
    std::puts("emplace(piecewise, 3, (4,5)):");
    m.emplace(std::piecewise_construct, std::forward_as_tuple(3), std::forward_as_tuple(4, 5));
    std::puts("try_emplace(4, 6, 7):");    m.try_emplace(4, 6, 7);
    std::puts("m[5] = 50:");               m[5] = 50;
    std::puts("insert_or_assign(5, 55):"); m.insert_or_assign(5, 55);
    std::puts("insert_or_assign(6, 60):"); m.insert_or_assign(6, 60);
    std::puts("emplace(1, 99) existing:"); auto [it1, ok1] = m.emplace(1, 99);
    std::puts("try_emplace(1, 99) existing:"); auto [it2, ok2] = m.try_emplace(1, 99);
    std::printf("ok1=%d ok2=%d m[1]=%d m[3]=%d m[5]=%d\n", ok1, ok2, it1->second.v, m.at(3).v, m.at(5).v);
}
insert({1, V(10)}):
  V(10)
  move
  move
emplace(2, 20):
  V(20)
emplace(piecewise, 3, (4,5)):
  V(4,5)
try_emplace(4, 6, 7):
  V(6,7)
m[5] = 50:
  V(50)
  V()
  move=
insert_or_assign(5, 55):
  V(55)
  move=
insert_or_assign(6, 60):
  V(60)
emplace(1, 99) existing:
try_emplace(1, 99) existing:
ok1=0 ok2=0 m[1]=10 m[3]=20 m[5]=55

Reading the trace:

  • insert({1, V(10)}) builds V(10), moves it into a temporary pair<const int, V>, then moves that pair's value into the node: two moves.
  • emplace(2, 20) and try_emplace construct the value once, in the node.
  • m[5] = 50 constructs V(50) first (C++17 sequences the right operand of = before the left), then operator[] default-constructs the value, then move-assigns.
  • insert_or_assign(5, 55) on an existing key converts 55 to a V (its parameter is M&& with M = int, assigned through V's converting constructor) and move-assigns.
  • emplace(1, 99) on an existing key built nothing here: both libstdc++ and libc++ look the key up first when the first argument already has the key type. That is an optimisation, not a promise.

emplace can steal your argument even when it inserts nothing

When the key argument is not exactly the key type, the libraries must construct the node to learn the key. If the key is already present the node is destroyed, and anything you moved into it is gone.

#include <cstdio>
#include <map>
#include <memory>
#include <string>

int main() {
    std::map<std::string, std::unique_ptr<int>> m;
    m.emplace("a", std::make_unique<int>(1));

    auto p = std::make_unique<int>(2);
    m.emplace("a", std::move(p));                  // const char* key: node built, then destroyed
    std::printf("emplace with \"a\": p is %s\n", p ? "intact" : "gone");

    auto q = std::make_unique<int>(3);
    m.try_emplace("a", std::move(q));              // guaranteed: q untouched when the key exists
    std::printf("try_emplace with \"a\": q is %s\n", q ? "intact" : "gone");
}
emplace with "a": p is gone
try_emplace with "a": q is intact

The standard says emplace inserts "if and only if there is no element with an equivalent key"; it does not say the arguments are left alone. try_emplace was added in C++17 (N4279) exactly to make that guarantee, and also to allow a value that is not movable at all, since the key and the value arguments are separate.

std::piecewise_construct

std::pair has a constructor pair(std::piecewise_construct_t, std::tuple<A...>, std::tuple<B...>) that constructs first from the first tuple's elements and second from the second's. It is how you emplace into a map when the key or the value needs several constructor arguments, or when the value is neither copyable nor movable. std::forward_as_tuple builds a tuple of references, so nothing is copied on the way. Since C++17, try_emplace(key, args...) covers the common case (single-argument key) with less noise.

emplace_hint and insert with a hint

m.emplace_hint(pos, args...) inserts the element as close as possible to the position just before pos. If the hint is right (the new key belongs immediately before pos), insertion is amortised O(1) instead of O(log n). Inserting already-sorted data with m.emplace_hint(m.end(), ...) is the standard idiom. A wrong hint costs nothing beyond the normal lookup. Unordered containers accept hints too, but implementations mostly ignore them except for equivalent keys in multi-containers.

#include <cstdio>
#include <map>

int main() {
    std::map<int, int> squares;
    for (int i = 0; i < 5; ++i)
        squares.emplace_hint(squares.end(), i, i * i);    // sorted input: O(1) each
    auto it = squares.emplace_hint(squares.find(3), 2, -1); // key exists: returns it
    std::printf("existing key 2 -> %d, size %zu\n", it->second, squares.size());
    for (auto [k, v] : squares) std::printf("%d:%d ", k, v);
    std::printf("\n");
}
existing key 2 -> 4, size 5
0:0 1:1 2:4 3:9 4:16

Note the return type: emplace_hint returns an iterator only (not a pair), so you cannot tell from it whether insertion happened; compare the size before and after. try_emplace(hint, k, args...) has the same limitation; use the non-hint try_emplace when you need the bool.

Reallocation, invalidation and reserve

Theory

When size() == capacity(), any vector insertion allocates a larger buffer (both libstdc++ and libc++ double it, starting from 1; MSVC grows by 1.5x), constructs the new element there, relocates the old elements with std::move_if_noexcept (Ch 20) and frees the old buffer. After that, every pointer, reference and iterator into the vector is invalid, including references you got from emplace_back earlier and v.back().

#include <cstdio>
#include <vector>

struct T {
    int v;
    T(int x) : v(x) { std::printf("  ctor(%d)\n", v); }
    T(const T& o) : v(o.v) { std::printf("  copy(%d)\n", v); }
    T(T&& o) noexcept : v(o.v) { std::printf("  move(%d)\n", v); }
    ~T() { std::puts("  dtor"); }
};

int main() {
    std::vector<T> v;
    for (int i = 1; i <= 3; ++i) {
        std::printf("emplace_back(%d), capacity %zu:\n", i, v.capacity());
        v.emplace_back(i);
    }
    std::puts("emplace_back(v[0]) with spare capacity:");
    v.emplace_back(v[0]);
    std::puts("end");
    v.clear();
}
emplace_back(1), capacity 0:
  ctor(1)
emplace_back(2), capacity 1:
  ctor(2)
  move(1)
  dtor
emplace_back(3), capacity 2:
  ctor(3)
  move(1)
  dtor
  move(2)
  dtor
emplace_back(v[0]) with spare capacity:
  copy(1)
end
  dtor
  dtor
  dtor
  dtor

This output is for GCC/libstdc++. Two things are the same in libc++: the new element is constructed before the old ones are relocated (so v.emplace_back(v[0]) is safe even when the vector grows, and the standard requires it), and the growth sequence is 1, 2, 4. One thing differs: libstdc++ relocates element by element (move, destroy, move, destroy), while libc++ moves all elements first and then destroys the old ones, so its third step prints move(1) move(2) dtor dtor.

before: capacity 3, size 3 1 2 3 after emplace_back(4): capacity 6 1 2 3 4 old buffer: freed int& first still points here: use-after-free relocate v.data(), v.begin(), v.back() now refer here. Anything computed before the call does not. reserve(n) first, or re-fetch by index.
Growth moves every element. References, pointers and iterators obtained before a reallocating insertion keep pointing into memory that has been freed.

reserve and the invalidation rules

  • v.reserve(n) with n > capacity() reallocates once and invalidates everything. With n <= capacity() it does nothing. It never shrinks. shrink_to_fit() is a non-binding request.
  • After reserve(n), insertions at the end do not invalidate references as long as size() <= n. Insertions in the middle still invalidate everything at and after the insertion point.
  • Calling reserve(size() + 1) before each push in a loop defeats geometric growth and makes the loop O(n²) on libraries that allocate exactly what you ask for. Reserve once, with the final size.
  • std::deque::emplace_back invalidates iterators but not references to existing elements, and it never relocates them. That is why a deque can hold non-movable types, and a vector cannot grow with them.
  • std::list and node-based maps never invalidate references on insertion.

Concept and tricky questions

Drills

ConceptWhat is the difference between push_back and emplace_back?

Answer

Answer. push_back takes a T (by const T& or T&&) and copy- or move-constructs the new element from it. emplace_back takes constructor arguments by forwarding reference and constructs the element in place with allocator_traits::construct, using direct-initialisation with parentheses.

Why. The practical difference is only visible when the argument is not already a T: emplace_back(args) skips the temporary and its move and destructor. Two side effects follow from direct-initialisation: explicit constructors are callable and narrowing is allowed. People often say "emplace avoids a copy"; it avoids a temporary, and when you pass an existing object it copies just the same.

TrickyIs v.emplace_back(std::move(s)) faster than v.push_back(std::move(s)) for a std::string s?

Answer

Answer. No. Both perform exactly one move construction into the slot.

Why. emplace_back forwards a std::string&& to std::string(std::string&&); push_back(T&&) calls the same constructor. After inlining the generated code is identical. Emplace only helps when the arguments differ from T itself, such as v.emplace_back(10, 'x') or v.emplace_back("literal").

Trickystd::vector<std::unique_ptr<Widget>> v; v.emplace_back(new Widget); compiles while push_back(new Widget) does not. What can go wrong with the emplace version?

Answer

Answer. A leak. If the vector is full, emplace_back allocates a new buffer before constructing the unique_ptr. If that allocation throws, the raw Widget* has no owner and is never deleted.

Why. unique_ptr's pointer constructor is explicit precisely to stop implicit ownership transfer from raw pointers; emplace's direct-initialisation bypasses it. With v.push_back(std::make_unique<Widget>()) (or emplace_back of that), the owner exists before the container does anything that can throw, so a failure destroys the temporary unique_ptr and the object. This is Item 42 of Effective Modern C++.

TrickyFor std::vector<std::vector<int>> vv;, what do vv.push_back({10}), vv.emplace_back(10) and vv.push_back(10) do?

Answer

Answer. push_back({10}) appends a vector holding the single value 10. emplace_back(10) appends a vector of ten zeros. push_back(10) does not compile.

Why. Braces initialise the parameter with list-initialisation, which strongly prefers the initializer_list constructor. Emplace constructs with parentheses, vector<int>(10), which is the size constructor. That constructor is explicit, so the implicit conversion push_back(10) would need is not allowed. Same tokens, three different outcomes.

ConceptWhat does emplace_back return, and since when?

Answer

Answer. A reference to the inserted element (T&) since C++17 for vector, deque and list (also emplace_front). In C++11 and C++14 it returned void.

Why. P0084 changed it so that auto& x = v.emplace_back(...); replaces the two-step emplace_back plus back(). push_back still returns void. The reference has the same lifetime rules as any element reference: the next reallocation invalidates it. Associative emplace returns pair<iterator, bool>, and emplace_hint returns just an iterator.

TrickyAfter std::vector<int> v; v.emplace_back();, what is v.back()? And for struct Raw { int x; }; std::vector<Raw> r; r.emplace_back();, what is r.back().x?

Answer

Answer. Both are 0. An empty argument list value-initialises the element.

Why. emplace_back() ends in std::construct_at(p), which is ::new (p) T(), with empty parentheses. T() is value-initialisation, which zero-initialises an int and an aggregate like Raw (a class with no user-provided default constructor is zeroed first). The usual wrong answer is "indeterminate, like int x;" or new int, which are default-initialisation. The same rule makes std::vector<int> v(n) and resize(n) write n zeros; avoiding that cost needs C++23's resize_and_overwrite (strings only) or an allocator whose construct default-initialises. A type with a user-provided constructor that leaves members uninitialised (Ctor() {}) is not zeroed: value-initialisation just calls that constructor.

ConceptWhat is std::piecewise_construct for? Give an example.

std::map<std::string, std::vector<int>> m;
m.emplace(std::piecewise_construct,
          std::forward_as_tuple("primes"),
          std::forward_as_tuple(5, 0));     // key "primes", value: five zeros
Answer

Answer. It selects pair's constructor that builds first and second each from their own argument tuple, so a map element can be constructed in place when the key or the value needs several constructor arguments (or none).

Why. Without it, m.emplace("primes", 5, 0) would try pair(const char*, int, int), which does not exist. forward_as_tuple makes tuples of references, so the arguments are forwarded, not copied. It is also how you insert a value that cannot be moved, such as a std::mutex: m.emplace(std::piecewise_construct, std::forward_as_tuple(k), std::tuple<>()). Since C++17 try_emplace(k, args...) does the same job more readably.

ConceptWhen does emplace_back give a real, measurable improvement over push_back?

Answer

Answer. When you pass constructor arguments for a type whose temporary is expensive to create, move or destroy, or for a type that cannot be moved at all (in a deque, list or map). Examples: v.emplace_back(1024, '\0') for strings, a type with a large inline array, a type holding a mutex.

Why. The saved work is one temporary construction's worth of move plus destructor. For a std::string built from a long literal, push_back("...") allocates once for the temporary and then moves the pointer, so allocations are the same; only the small move and destructor are saved. For types where a move equals a copy (large std::array members), the saving is a full copy. Measure before rewriting code for it.

TrickyIs v.emplace_back(v[0]) safe when the vector is full and must reallocate?

Answer

Answer. Yes. The standard library must handle arguments that refer into the container itself, and libstdc++ and libc++ do it by constructing the new element in the new buffer before relocating (and destroying) the old elements.

Why. The library may not assume that an argument is unrelated to the container. push_back(v[0]) has always had to work, and LWG 2164 (resolved in C++20) added the note to the sequence-container requirements that for a.emplace(p, args) "args may directly or indirectly refer to a value in a", so even v.emplace(v.begin(), v.back()), a middle insertion where the referenced element is about to be shifted, must work. Implementations handle that case by constructing the new value into a temporary first. Your own containers must use the same order for appends: allocate, construct the new element, relocate, free. The common wrong answer is "undefined, take a copy first"; a hand-rolled vector that grows first and then reads the argument does read freed memory, but the standard one does not.

ConceptWhat is the difference between m[k] = v and m.insert_or_assign(k, v)?

Answer

Answer. For a new key, m[k] = v default-constructs the value and then assigns v; insert_or_assign constructs the value directly from v. For an existing key, both assign. insert_or_assign also tells you which happened (it returns pair<iterator, bool>) and works for values without a default constructor.

Why. operator[] must return a reference before the assignment runs, so it has to put some value in the node first. That costs a default construction plus an assignment and fails to compile if the value type has no default constructor. insert_or_assign (C++17) was added to fix exactly this.

TrickyDoes v.reserve(100) invalidate iterators? Does a later push_back invalidate references if v.size() was 10?

Answer

Answer. reserve(100) invalidates all iterators, pointers and references if 100 exceeds the current capacity, because it reallocates. After it, push_backs up to size 100 invalidate only the end() iterator.

Why. [vector.capacity]: reallocation invalidates everything, and no reallocation happens during insertions until the size would exceed the reserved capacity. People think reserve is a pure hint that leaves existing references alone. Also, reserve with a smaller value never shrinks, and resize is a different operation that changes size() and constructs elements.

Trickystruct P { int x, y; }; std::vector<P> v; v.emplace_back(1, 2); Does it compile?

Answer

Answer. In C++20, yes. In C++17 and earlier, no.

Why. emplace_back uses ::new (p) P(1, 2), with parentheses. An aggregate has no constructor taking two ints, and before C++20 aggregates could only be initialised with braces. P0960 (C++20) allows parenthesised aggregate initialisation, which differs from braces in two ways: narrowing is allowed and temporaries bound to reference members are not lifetime-extended. In C++17 code use v.push_back({1, 2}) or give P a constructor.

ConceptWhat does emplace_hint do and when is it O(1)?

Answer

Answer. It inserts the element using the iterator as a starting point, placing it as close as possible to just before the hint. If the element belongs immediately before the hint, insertion is amortised constant time; otherwise it falls back to the normal O(log n) search.

Why. For tree-based maps, checking "does the key fit between prev(hint) and hint" takes two comparisons, then the node is linked in and the tree rebalanced (amortised O(1)). Bulk-loading sorted data with emplace_hint(m.end(), ...) is the common use. C++03 specified the fast case as "inserted right after the hint", C++11 (LWG 233) changed it to "just before". Old code that passes prev(end()) for appends is still correct, but the constant-time path is only guaranteed for the C++11 form (libstdc++ happens to check both sides).

Predict the output and find the bug

Drills

OutputThe vector has room for exactly one element. What is printed?

#include <cstdio>
#include <vector>

struct T {
    int v;
    T(int x) : v(x) { std::printf("ctor(%d)\n", v); }
    T(const T& o) : v(o.v) { std::printf("copy(%d)\n", v); }
    T(T&& o) noexcept : v(o.v) { std::printf("move(%d)\n", v); }
    ~T() { std::printf("dtor(%d)\n", v); }
};

int main() {
    std::vector<T> v;
    v.reserve(1);
    v.emplace_back(1);
    std::puts("--");
    v.emplace_back(2);
    std::puts("-- end");
    v.pop_back();
    v.pop_back();
}
Answer
ctor(1)
--
ctor(2)
move(1)
dtor(1)
-- end
dtor(2)
dtor(1)

Answer. The first emplace constructs in place. The second needs a bigger buffer: ctor(2) runs first, directly in the new buffer, then element 1 is moved across and the old copy destroyed. The two pop_back calls destroy the last element, then the first.

Why. Constructing the new element before relocation is what makes self-referencing arguments safe. The moved-from element still prints dtor(1) because this type's move copies the int. With a single old element the libstdc++ and libc++ traces agree; with more, their order of moves and destructors differs, which is why the program pops explicitly instead of relying on the vector's destructor order.

OutputA vector of strings, four ways to add an element. For each string the program prints its size and last character. What is the output?

#include <cstdio>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> v;
    v.push_back({3, 'a'});
    v.emplace_back(3, 'b');
    v.push_back(std::string(3, 'c'));
    v.emplace_back("dd", 1);
    for (const auto& s : v) std::printf("%zu%c ", s.size(), s.back());
    std::printf("\n");
}
Answer
2a 3b 3c 1d

Answer. push_back({3, 'a'}) makes the two-character string "\x03a"; emplace_back(3, 'b') makes "bbb"; push_back(std::string(3, 'c')) makes "ccc"; emplace_back("dd", 1) makes "d".

Why. The braces in push_back list-initialise a std::string, and list-initialisation picks the initializer_list<char> constructor whenever it is viable, so 3 becomes the character with code 3. Emplace uses parentheses, which selects string(size_type count, char ch). The last call selects string(const char* s, size_type n), which takes the first n characters. Same tokens, different constructors: the brace-vs-paren split is the root of most emplace surprises.

OutputInserting into a map with keys that already exist. What is printed?

#include <cstdio>
#include <map>
#include <string>

int main() {
    std::map<std::string, int> m{{"a", 1}};
    auto [i1, ok1] = m.insert({"a", 2});
    auto [i2, ok2] = m.emplace("a", 3);
    auto [i3, ok3] = m.try_emplace("a", 4);
    auto [i4, ok4] = m.insert_or_assign("a", 5);
    m["b"];
    std::printf("%d%d%d%d a=%d b=%d size=%zu\n", ok1, ok2, ok3, ok4, m["a"], m["b"], m.size());
}
Answer
0000 a=5 b=0 size=2

Answer. All four report "not inserted" (false), but insert_or_assign overwrote the value with 5. m["b"] as a bare statement inserts a value-initialised 0.

Why. insert, emplace and try_emplace never modify an existing element; the bool is the only sign they did nothing. insert_or_assign returns false to mean "assigned rather than inserted". The operator[] side effect is a classic: using m[k] to test for presence silently adds entries; use find, contains (C++20) or at instead.

OutputA non-copyable, non-movable type in three containers. What compiles and what is printed?

#include <cstdio>
#include <deque>
#include <list>
#include <map>
#include <mutex>

struct Pinned {
    int id;
    std::mutex m;
    explicit Pinned(int i) : id(i) {}
};

int main() {
    std::deque<Pinned> d;
    d.emplace_back(1);
    d.emplace_front(0);
    std::list<Pinned> l;
    l.emplace_back(2);
    std::map<int, Pinned> mp;
    mp.try_emplace(7, 3);
    std::printf("%d %d %d %d\n", d[0].id, d[1].id, l.front().id, mp.at(7).id);
}
Answer
0 1 2 3

Answer. All of it compiles and prints 0 1 2 3.

Why. std::mutex makes Pinned neither copyable nor movable. deque, list and maps never relocate existing elements, so emplace into them only needs the constructor. std::vector<Pinned>::emplace_back would not compile, because growing a vector must move or copy elements. push_back(Pinned(1)) would fail in every container: it needs a move constructor to get the temporary into the slot.

OutputWhat does this print, and why might it surprise a reviewer?

#include <cstdio>
#include <vector>

struct Price {
    long ticks;
    explicit Price(long t) : ticks(t) {}
};

int main() {
    std::vector<Price> book;
    double mid = 101.75;
    book.emplace_back(mid * 4);      // intended: quarter ticks
    book.emplace_back(-0.9);
    std::printf("%ld %ld\n", book[0].ticks, book[1].ticks);
}
Answer
407 0

Answer. 407 0. Both doubles are converted to long by truncation toward zero, and the explicit constructor is called without complaint.

Why. The double-to-long conversion happens inside the container's ::new (p) Price(std::forward<double>(d)), a parenthesised direct-initialisation, so narrowing is allowed and the explicit is ignored. book.push_back(Price{mid * 4}) is ill-formed (narrowing in braces: Clang rejects it, GCC only emits a -Wnarrowing warning for a non-constant value), and book.push_back(mid * 4) would fail because the constructor is explicit. In trading code, a silent truncation of a price is the kind of bug that costs money, so avoid emplace for types whose constructors take numeric arguments that could be the wrong type.

Find the bugThis program reads the first price after adding a fourth one. ASan reports a heap-use-after-free. Why?

#include <cstdio>
#include <vector>

int main() {
    std::vector<int> prices{100, 101, 102};
    int& best = prices[0];
    prices.emplace_back(103);
    std::printf("best=%d\n", best);
}
Answer

Answer. prices{100, 101, 102} has capacity 3 in both standard libraries, so emplace_back(103) reallocates, and best refers into the freed old buffer.

Why. Every reallocation invalidates all references, pointers and iterators. Fixes: keep an index (std::size_t best = 0;) and read prices[best] later, call reserve with the final size before taking references, or use a container that does not relocate (std::deque for appends). The output is marked undefined behaviour: without ASan this often prints 100 because the freed memory has not been reused yet, which is why such bugs survive testing.

Find the bugThe loop duplicates every even number. It crashes under ASan. What is wrong?

#include <cstdio>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3, 4};
    for (int& x : v)
        if (x % 2 == 0) v.push_back(x);
    std::printf("size=%zu\n", v.size());
}
Answer

Answer. Inserting into a vector while iterating it with a range-for. The loop's hidden begin and end iterators (and x) are invalidated by the first reallocation, so the next increment reads freed memory.

Why. Range-for caches auto __end = v.end() before the loop. Even with enough capacity it would be wrong, because the cached end would not include the new elements. Fix: iterate by index up to the original size, for (std::size_t i = 0, n = v.size(); i < n; ++i) if (v[i] % 2 == 0) v.push_back(v[i]);, which is safe because push_back(v[i]) handles self-reference. Or collect into a second vector and append afterwards.

Find the bugA session table stores entries with operator[]. Why does it not compile, and what is the fix?

#include <map>
#include <string>

struct Session {
    std::string user;
    explicit Session(std::string u) : user(std::move(u)) {}
};

int main() {
    std::map<int, Session> sessions;
    sessions[42] = Session("ada");
}
Answer

Answer. operator[] must default-construct a Session when the key is missing, and Session has no default constructor. Use sessions.try_emplace(42, "ada") (insert only) or sessions.insert_or_assign(42, Session("ada")) (insert or overwrite).

Why. operator[] is compiled as one function that handles both the "found" and "not found" paths, so the default constructor is required even if the key always exists at run time. Adding a default constructor just to satisfy it weakens the class invariant (a session without a user). try_emplace also avoids the extra assignment.

Find the bugA monitor keeps a list of sensors it tracks. ASan aborts at the end of main. Why does push_back catch this at compile time while emplace_back does not?

#include <cstdio>
#include <memory>
#include <vector>

struct Sensor { int id; };

int main() {
    Sensor primary{1};
    std::vector<std::unique_ptr<Sensor>> owned;
    owned.emplace_back(&primary);        // compiles: unique_ptr(Sensor*) is explicit
    std::printf("tracking sensor %d\n", owned[0]->id);
}
Answer

Answer. emplace_back(&primary) calls the explicit constructor unique_ptr(Sensor*), so the vector now "owns" a stack object. When owned is destroyed it calls delete on &primary, and ASan reports "attempting free on address which was not malloc()-ed". owned.push_back(&primary) would be rejected, because the implicit conversion from Sensor* to unique_ptr is exactly what explicit forbids. Fix: if the vector should own, write owned.push_back(std::make_unique<Sensor>(primary)); if it only observes, make it std::vector<Sensor*>.

Why. explicit on unique_ptr's pointer constructor exists to make every ownership transfer visible in the source. Emplace's direct-initialisation skips that check, so the leak in this chapter's theory and this bad delete are two faces of the same problem. People say "emplace and push_back are the same except for performance"; for types with explicit constructors they accept different programs. Without ASan the output is undefined: the bad delete may abort, corrupt the heap or appear to work.

Find the bugThe registry is meant to keep the first connection for each host. Sometimes the second connection object is destroyed even though it was never stored. Why?

#include <cstdio>
#include <map>
#include <memory>
#include <string>

struct Conn {
    std::string host;
    explicit Conn(std::string h) : host(std::move(h)) {}
    ~Conn() { std::printf("closing %s\n", host.c_str()); }
};

int main() {
    std::map<std::string, std::unique_ptr<Conn>> registry;
    registry.emplace("db", std::make_unique<Conn>("db#1"));
    auto spare = std::make_unique<Conn>("db#2");
    registry.emplace("db", std::move(spare));       // key already present
    std::printf("spare is %s\n", spare ? "still ours" : "gone");
    std::puts("end of main");
}
Answer
closing db#2
spare is gone
end of main
closing db#1

Answer. The key is passed as const char*, so emplace constructs the node (moving spare into it) before it can compare keys. The key exists, the node is destroyed, and db#2 is closed. Use registry.try_emplace("db", std::move(spare)), which leaves spare untouched when the key exists.

Why. The standard does not promise that a failed emplace leaves its arguments alone. Both standard libraries look the key up first only when the first argument already has the key type (std::string here), so the behaviour even depends on how the caller spells the key. try_emplace was introduced in C++17 for exactly this.

Write the code

Coding

CodeImplement TinyVector<T> with push_back(const T&), push_back(T&&), emplace_back(Args&&...) returning T&, reserve, size, capacity and operator[]. Growth doubles, relocates with std::move_if_noexcept, and must handle v.emplace_back(v[0]) when full. Trace it with a noisy type.

Answer

Approach. Both push_back overloads forward to emplace_back. On growth, allocate the new block, construct the new element at index size in it first (the arguments may refer to old elements), then relocate the old elements, destroy them and free the old block. If constructing the new element throws, free the new block; if relocation throws (only possible when copying), destroy what was built.

#include <cstdio>
#include <memory>
#include <utility>

template <class T>
class TinyVector {
public:
    TinyVector() = default;
    TinyVector(const TinyVector&) = delete;
    TinyVector& operator=(const TinyVector&) = delete;
    ~TinyVector() { std::destroy_n(data_, size_); alloc_.deallocate(data_, cap_); }

    void push_back(const T& v) { emplace_back(v); }
    void push_back(T&& v) { emplace_back(std::move(v)); }

    template <class... Args>
    T& emplace_back(Args&&... args) {
        if (size_ < cap_) {
            T& r = *std::construct_at(data_ + size_, std::forward<Args>(args)...);
            ++size_;                         // only after construction succeeded
            return r;
        }
        return grow_and_emplace(cap_ ? 2 * cap_ : 1, std::forward<Args>(args)...);
    }
    void reserve(std::size_t n) { if (n > cap_) relocate_to(n); }

    std::size_t size() const { return size_; }
    std::size_t capacity() const { return cap_; }
    T& operator[](std::size_t i) { return data_[i]; }

private:
    template <class... Args>
    T& grow_and_emplace(std::size_t ncap, Args&&... args) {
        T* nd = alloc_.allocate(ncap);
        try {
            std::construct_at(nd + size_, std::forward<Args>(args)...);   // before relocation
        } catch (...) {
            alloc_.deallocate(nd, ncap);
            throw;
        }
        try {
            move_elements(nd);
        } catch (...) {
            std::destroy_at(nd + size_);
            alloc_.deallocate(nd, ncap);
            throw;
        }
        adopt(nd, ncap);
        return data_[size_++];
    }
    void relocate_to(std::size_t ncap) {
        T* nd = alloc_.allocate(ncap);
        try { move_elements(nd); } catch (...) { alloc_.deallocate(nd, ncap); throw; }
        adopt(nd, ncap);
    }
    void move_elements(T* nd) {          // strong guarantee: on throw, nd holds nothing
        std::size_t i = 0;
        try {
            for (; i < size_; ++i) std::construct_at(nd + i, std::move_if_noexcept(data_[i]));
        } catch (...) {
            std::destroy_n(nd, i);
            throw;
        }
    }
    void adopt(T* nd, std::size_t ncap) {
        std::destroy_n(data_, size_);
        alloc_.deallocate(data_, cap_);
        data_ = nd;
        cap_ = ncap;
    }

    std::allocator<T> alloc_;
    T* data_ = nullptr;
    std::size_t size_ = 0, cap_ = 0;
};

struct Noisy {
    int v;
    Noisy(int x) : v(x) { std::printf("  ctor(%d)\n", v); }
    Noisy(int a, int b) : v(a * 10 + b) { std::printf("  ctor(%d,%d)\n", a, b); }
    Noisy(const Noisy& o) : v(o.v) { std::printf("  copy(%d)\n", v); }
    Noisy(Noisy&& o) noexcept : v(o.v) { std::printf("  move(%d)\n", v); }
    ~Noisy() { std::printf("  dtor(%d)\n", v); }
};

int main() {
    TinyVector<Noisy> v;
    std::puts("emplace_back(1):");      v.emplace_back(1);
    std::puts("emplace_back(v[0]):");   v.emplace_back(v[0]);       // full: grows, reads v[0] first
    std::puts("push_back(Noisy(3)):");  v.push_back(Noisy(3));
    std::puts("emplace_back(4, 2):");   Noisy& r = v.emplace_back(4, 2);
    r.v += 100;
    std::printf("size=%zu cap=%zu last=%d\n", v.size(), v.capacity(), v[3].v);
    std::puts("destroy:");
}
emplace_back(1):
  ctor(1)
emplace_back(v[0]):
  copy(1)
  move(1)
  dtor(1)
push_back(Noisy(3)):
  ctor(3)
  move(3)
  move(1)
  move(1)
  dtor(1)
  dtor(1)
  dtor(3)
emplace_back(4, 2):
  ctor(4,2)
size=4 cap=4 last=142
destroy:
  dtor(1)
  dtor(1)
  dtor(3)
  dtor(142)

Complexity and pitfalls. Amortised O(1) appends. In the second block the copy of v[0] is made into the new buffer before the old element is moved and destroyed, which is what keeps the self-reference valid. The third block shows the full cost of push_back of a temporary during growth: the temporary is built, moved into the new slot, both old elements are relocated and destroyed, and finally the temporary dies. The fourth block shows emplace_back(4, 2) constructing in place and returning a usable reference. Pitfalls: growing first and constructing second (reads freed memory for v.emplace_back(v[0])); using std::move instead of move_if_noexcept for relocation; incrementing size_ before construction succeeds; forgetting that the element destructor order of this class (front to back) is a choice, and libc++ chooses back to front.

Estimated rating: ~1900 · placement construction, exception-safe growth and the self-reference ordering in one class

CodeWrite my_try_emplace(map, key, args...) for a std::map using only lower_bound, key_comp and emplace_hint, with the same guarantee as try_emplace: when the key exists, the value arguments are not touched. Return std::pair<iterator, bool>.

Answer

Approach. lower_bound(key) returns the first element not less than key. If it is not end() and key is not less than its key, the key exists: return it with false without touching the arguments. Otherwise lower_bound is exactly the correct hint (the new key belongs just before it), so emplace_hint with piecewise_construct inserts in amortised O(1) after the O(log n) lookup.

#include <cstdio>
#include <map>
#include <memory>
#include <string>
#include <tuple>
#include <utility>

template <class Map, class K, class... Args>
std::pair<typename Map::iterator, bool> my_try_emplace(Map& m, K&& key, Args&&... args) {
    auto it = m.lower_bound(key);
    if (it != m.end() && !m.key_comp()(key, it->first)) return {it, false};
    it = m.emplace_hint(it, std::piecewise_construct,
                        std::forward_as_tuple(std::forward<K>(key)),
                        std::forward_as_tuple(std::forward<Args>(args)...));
    return {it, true};
}

int main() {
    std::map<std::string, std::unique_ptr<int>> m;
    auto [a, ok1] = my_try_emplace(m, std::string("x"), std::make_unique<int>(1));
    auto p = std::make_unique<int>(2);
    auto [b, ok2] = my_try_emplace(m, std::string("x"), std::move(p));
    auto [c, ok3] = my_try_emplace(m, std::string("w"), std::move(p));
    std::printf("ok=%d%d%d x=%d w=%d p=%s size=%zu\n", ok1, ok2, ok3, *a->second, *c->second,
                p ? "set" : "null", m.size());
    std::printf("first key=%s\n", m.begin()->first.c_str());
}
ok=101 x=1 w=2 p=null size=2
first key=w

Complexity and pitfalls. O(log n) lookup plus amortised O(1) linking. The second call leaves p owning 2, which the third call then moves into the new "w" entry; that is why p is null only at the end. Pitfalls: comparing with == instead of the map's comparator (wrong for custom orderings); calling emplace instead of emplace_hint after the lookup (correct, but it repeats the O(log n) search); passing the key as std::forward<K>(key) to lower_bound and then forwarding it again (forward once, at the last use, Ch 21).

Estimated rating: ~1600 · needs lower_bound equivalence via the comparator and the exact hint semantics

CodeWrite safe_emplace_back(v, args...), which behaves like v.emplace_back(args...) but refuses (at compile time) a single argument that is not implicitly convertible to the element type, and refuses narrowing for single arithmetic arguments. Multi-argument calls stay allowed. Show which calls compile using a requires check.

Answer

Approach. For one argument, require std::is_convertible_v<A, T> (rejects explicit constructors) and that T{std::declval<A>()} is well-formed (brace initialisation rejects narrowing). For zero or several arguments, require std::constructible_from<T, Args...> and forward to emplace_back.

#include <concepts>
#include <cstdio>
#include <memory>
#include <type_traits>
#include <utility>
#include <vector>

template <class T, class A>
concept SafeSingle = std::is_convertible_v<A, T> && requires(A&& a) { T{std::forward<A>(a)}; };

template <class T, class... Args>
concept SafeArgs = (sizeof...(Args) != 1 && std::constructible_from<T, Args...>) ||
                   (sizeof...(Args) == 1 && (SafeSingle<T, Args> && ...));

template <class T, class Alloc, class... Args>
    requires SafeArgs<T, Args...>
T& safe_emplace_back(std::vector<T, Alloc>& v, Args&&... args) {
    return v.emplace_back(std::forward<Args>(args)...);
}

template <class V, class... Args>
constexpr bool can_emplace = requires(V& v, Args&&... a) { safe_emplace_back(v, std::forward<Args>(a)...); };

int main() {
    using UP = std::vector<std::unique_ptr<int>>;
    using VV = std::vector<std::vector<int>>;
    using VI = std::vector<int>;
    static_assert(!can_emplace<UP, int*>);                 // explicit constructor
    static_assert(can_emplace<UP, std::unique_ptr<int>>);  // an owner is fine
    static_assert(!can_emplace<VV, int>);                  // explicit size constructor
    static_assert(can_emplace<VV, int, int>);              // multi-arg: allowed on purpose
    static_assert(!can_emplace<VI, double>);               // narrowing
    static_assert(can_emplace<VI, short>);                 // widening

    UP up;
    safe_emplace_back(up, std::make_unique<int>(5));
    VV vv;
    safe_emplace_back(vv, 3, 9);
    std::printf("%d %zu %d\n", *up[0], vv[0].size(), vv[0][0]);
}
5 3 9

Complexity and pitfalls. Compile-time only. The brace test also rejects types that are not brace-constructible from themselves in odd ways, and for aggregates T{a} means "first member from a", so the check is a heuristic rather than a proof. Multi-argument calls still go through explicit constructors; that is usually what the caller means (emplace_back(3, 9) for "three nines").

Estimated rating: ~1700 · concept composition with a pack-size switch and a narrowing probe through brace initialisation

CodeBuild a Registry that owns polymorphic plugins by name: template <class T, class... Args> T& create(std::string name, Args&&... args) constructs a T (derived from Plugin) with forwarded arguments, stores it in an unordered_map<std::string, std::unique_ptr<Plugin>> and returns a reference to the concrete type. Creating a duplicate name must throw std::invalid_argument without constructing anything.

Answer

Approach. Look the name up first; if present, throw before building anything. Otherwise std::make_unique<T>(std::forward<Args>(args)...), keep a raw T* to return, and move the owner into the map with try_emplace. The owner exists before the map allocates, so a failing insert cannot leak. The reference stays valid across rehashes because the map stores pointers and node-based maps never move their nodes.

#include <concepts>
#include <cstdio>
#include <memory>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>

struct Plugin {
    virtual ~Plugin() = default;
    virtual const char* kind() const = 0;
};

class Registry {
public:
    template <class T, class... Args>
        requires std::derived_from<T, Plugin>
    T& create(std::string name, Args&&... args) {
        if (items_.contains(name)) throw std::invalid_argument("duplicate plugin: " + name);
        auto obj = std::make_unique<T>(std::forward<Args>(args)...);
        T& ref = *obj;
        items_.try_emplace(std::move(name), std::move(obj));
        return ref;
    }
    Plugin* find(const std::string& name) const {
        auto it = items_.find(name);
        return it == items_.end() ? nullptr : it->second.get();
    }
    std::size_t size() const { return items_.size(); }
private:
    std::unordered_map<std::string, std::unique_ptr<Plugin>> items_;
};

struct Counter : Plugin {
    static inline int made = 0;
    int start;
    explicit Counter(int s) : start(s) { ++made; }
    const char* kind() const override { return "counter"; }
};
struct Echo : Plugin {
    std::string prefix;
    std::unique_ptr<int> level;
    Echo(std::string p, std::unique_ptr<int> l) : prefix(std::move(p)), level(std::move(l)) {}
    const char* kind() const override { return "echo"; }
};

int main() {
    Registry r;
    Counter& c = r.create<Counter>("hits", 10);
    Echo& e = r.create<Echo>("log", "> ", std::make_unique<int>(3));   // move-only argument
    for (int i = 0; i < 100; ++i) r.create<Counter>("c" + std::to_string(i), i);   // rehashes
    c.start += 1;                                                      // still valid
    try { r.create<Counter>("hits", 99); }
    catch (const std::invalid_argument& ex) { std::printf("%s\n", ex.what()); }
    std::printf("hits=%d echo=%s%d kinds=%s,%s made=%d size=%zu\n", c.start, e.prefix.c_str(),
                *e.level, r.find("hits")->kind(), r.find("log")->kind(), Counter::made, r.size());
}
duplicate plugin: hits
hits=11 echo=> 3 kinds=counter,echo made=101 size=102

Complexity and pitfalls. Average O(1) per operation plus the construction. made=101 proves the duplicate call constructed nothing. Pitfalls: checking existence after constructing (wasted work and side effects); items_.emplace(name, new T(...)) (the explicit-constructor leak from this chapter); returning *items_[name] as Plugin& and static_casting later; taking name as const std::string& and then copying it into the map (by value plus move is one copy fewer for rvalue names).

Estimated rating: ~1500 · combines forwarding, make_unique, try_emplace and reference stability of node containers

CodeImplement InplaceVector<T, N>, a fixed-capacity vector with no heap allocation (the shape of C++26's std::inplace_vector), with emplace_back returning T&, push_back overloads, pop_back, size, operator[] and a destructor. When full, emplace_back throws std::bad_alloc without constructing anything. Elements must never move, so non-movable types work.

Answer

Approach. Store alignas(T) std::byte buf_[N * sizeof(T)] and a count. emplace_back checks capacity first, then constructs with std::construct_at at slot size_. Access goes through std::launder(reinterpret_cast<T*>(buf_) + i) because the objects were created in raw storage (see Ch 14). Copying and moving the container itself are deleted to keep the example short.

#include <cstddef>
#include <cstdio>
#include <memory>
#include <mutex>
#include <new>
#include <utility>

template <class T, std::size_t N>
class InplaceVector {
public:
    InplaceVector() = default;
    InplaceVector(const InplaceVector&) = delete;
    InplaceVector& operator=(const InplaceVector&) = delete;
    ~InplaceVector() { while (size_) pop_back(); }

    template <class... Args>
    T& emplace_back(Args&&... args) {
        if (size_ == N) throw std::bad_alloc();
        T* p = std::construct_at(slot(size_), std::forward<Args>(args)...);
        ++size_;                                   // only after construction succeeded
        return *p;
    }
    void push_back(const T& v) { emplace_back(v); }
    void push_back(T&& v) { emplace_back(std::move(v)); }
    void pop_back() { std::destroy_at(&(*this)[--size_]); }

    T& operator[](std::size_t i) { return *std::launder(slot(i)); }
    std::size_t size() const { return size_; }
    static constexpr std::size_t capacity() { return N; }

private:
    T* slot(std::size_t i) { return reinterpret_cast<T*>(buf_) + i; }
    alignas(T) std::byte buf_[N * sizeof(T)];
    std::size_t size_ = 0;
};

struct Pinned {
    int id;
    std::mutex m;                                  // not copyable, not movable
    explicit Pinned(int i) : id(i) { std::printf("  make %d\n", id); }
    ~Pinned() { std::printf("  drop %d\n", id); }
};

int main() {
    InplaceVector<Pinned, 2> v;
    Pinned& first = v.emplace_back(1);
    v.emplace_back(2);
    try { v.emplace_back(3); }
    catch (const std::bad_alloc&) { std::puts("  full"); }
    std::printf("first=%d size=%zu sizeof ok=%d\n", first.id, v.size(),
                sizeof(v) >= 2 * sizeof(Pinned));
    v.pop_back();
    std::puts("end");
}
  make 1
  make 2
  full
first=1 size=2 sizeof ok=1
  drop 2
end
  drop 1

Complexity and pitfalls. O(1) for every operation and zero allocations, which is why low-latency code favours this shape for small bounded collections. References are never invalidated by insertion, since elements never move. Pitfalls: incrementing size_ before construction (a throwing constructor leaves a "live" garbage slot that the destructor will destroy); forgetting alignas(T); using T buf_[N] instead of raw bytes, which default-constructs N objects up front and requires a default constructor; omitting std::launder, which is technically required when reaching objects through a pointer to the byte array.

Estimated rating: ~1700 · raw aligned storage, construct_at, launder and the size-after-construction ordering

Cheat sheet

Theory
  • push_back(x) copies or moves an existing T; emplace_back(args) constructs in the slot via allocator_traits::construct, with parentheses.
  • Only emplace_back(ctor args) avoids work: the temporary, its move and its destructor. With an existing object or a T{...} argument the two are identical.
  • Dangers of emplace: calls explicit constructors (emplace_back(new T) into unique_ptrs leaks on growth failure; vector<vector<int>>::emplace_back(10) is ten zeros), allows narrowing, cannot take braced lists.
  • Aggregates: emplace_back(1, 2) needs C++20 parenthesised aggregate init.
  • emplace_back returns T& since C++17 (void before); emplace_hint returns an iterator; associative emplace returns pair<iterator, bool>.
  • Maps: try_emplace never touches arguments if the key exists; insert_or_assign overwrites; operator[] needs a default constructor; piecewise_construct + forward_as_tuple for multi-argument keys or values.
  • Growth: new element constructed first, then relocation with move_if_noexcept; every reference and iterator dies. reserve once with the final size; deque and node containers never relocate.

Part F · Move semantics and value passing · Chapter 23

Copy elision

Copy elision is the rule that lets (and since C++17, forces) the compiler to build an object directly where it will finally live, so the copy or move that the source code seems to ask for never runs. Interviewers ask about it because it decides how many constructors a line of code calls, why a factory can return a std::mutex by value in C++17 but not in C++14, and why std::move in the wrong place makes code slower.

TheoryDrillsCoding· about 3 hours· prerequisites: Ch 20 (value categories, move constructors)

What copy elision is

Theory

Mental model. Every object needs storage. When the source code says "make a temporary, then copy it into x", the cheapest implementation is to skip the temporary and construct straight into x. Copy elision is the language rule that permits this: two objects that the abstract machine would create are treated as one, and the copy or move constructor between them (and the matching destructor) is never called.

There are two quite different forms, and most interview confusion comes from mixing them up:

FormExampleStatus in C++17 and laterChapter
Initialising from a prvalue (unnamed temporary), often called RVO when it is a returnreturn T(args);, T x = T();, f(T())Guaranteed. No copy or move exists in the abstract machine, so none can run, and the constructor does not even need to exist.This chapter
Returning a named local (NRVO)T x; ...; return x;Still optional. The move or copy constructor must be usable even when the compiler elides it.Ch 24
throw of a local, catch by valuethrow local;, catch (T e)Optional. GCC and Clang elide neither in practice.This chapter

The rule lives in [class.copy.elision] for the optional cases and in [dcl.init] / [basic.lval] for the guaranteed one. The guaranteed case is not described as "elision" in the C++17 wording at all: it falls out of what a prvalue is, which the next sections explain.

Why it matters in practice

  • Counting. "How many constructor calls does this line make?" is a staple question, and the answer depends on knowing which steps are elided.
  • Types that cannot move. std::mutex, std::atomic, std::lock_guard and any class with deleted copy and move can be returned from a factory by value in C++17. In C++14 that code does not compile.
  • Performance. A move of a std::vector is cheap but not free (copy three pointers, null the source's three, and later run the destructor on the moved-from shell). For types with inline storage (std::array, small-buffer types) a move is as expensive as a copy, so elision is the only way to make returning by value free.
  • Observable behaviour. If a copy constructor prints or counts, elision changes the program's output. That is allowed, which makes elision the main exception to the as-if rule.

The pre-C++17 world: optional elision, required constructors

Theory

In C++11 and C++14, T x = make(); where make does return T(); describes three objects in the abstract machine: the temporary T() inside make, the temporary that is the function's return value, and x. Two moves connect them (copies for a type without a move constructor). The standard then allowed the implementation to merge all three into one.

Two consequences follow from "allowed but not required":

  1. The constructor still had to be accessible and not deleted. The program was checked as if the moves happened, then the compiler was permitted to skip them. A class with a deleted move constructor could not be returned this way even though no move would ever run in the optimised program.
  2. The count was compiler-dependent in principle. In practice every mainstream compiler elided these cases at every optimisation level, because elision is decided in the front end, not by the optimiser.
C++14 abstract machine: T x = make(); T() in make temporary 1: ctor return value temporary 2 x caller's variable move (may be elided) move (may be elided) both moves must be accessible even if skipped C++17: the prvalue T() is an initialiser, not an object return T(); "initialise the result object with T()" x the only object: one ctor, one dtor result object = x
C++14 elides; C++17 never creates the extra objects. In C++14 the two moves are part of the program and the compiler may delete them. In C++17 the prvalue carries no storage of its own, so its result object is x from the start.

You can still see the C++14 abstract machine today. GCC and Clang accept -fno-elide-constructors, which turns off the optional elisions. In C++14 mode it shows every step:

// static T make() { return T(); }   T x = make();
$ g++-16 -std=c++14 -fno-elide-constructors   (Clang 22 prints the same)
ctor move dtor move dtor dtor
//  T()   ->ret  ~T()  ->x  ~ret  ~x

Three constructions and three destructions for one line. With elision (the default) the same C++14 program prints ctor dtor.

Interview trap

"C++14 compilers did not elide, C++17 made them start" is wrong. GCC and Clang elided these cases in C++98 already. What C++17 changed is the rule: the elision is no longer an optimisation that needs a usable move constructor, so non-movable types become returnable and -fno-elide-constructors can no longer undo it.

C++17: prvalues and temporary materialisation

Theory

C++17 (paper P0135, "guaranteed copy elision through simplified value categories") redefined what a prvalue is. A prvalue is not an object. It is a recipe for initialising an object: "call T(args) on whatever storage I end up in". The object it initialises is called its result object, and the context decides where that is.

  • T x = T(1);: the result object of T(1) is x.
  • return T(1); in a function returning T: the result object is whatever the caller is initialising with the call, which may be the caller's variable, a by-value parameter, a member, or a temporary.
  • T x = T(T(T()));: the outer T(...) is a prvalue of the same type initialised from a prvalue of the same type, so the rule "a prvalue of the same type initialises the object directly" ([dcl.init]/17.6.1) applies three times. One default construction, into x.

Temporary materialisation

Sometimes a real object is needed: you cannot bind a reference to a recipe or read a member from it. The temporary materialisation conversion ([conv.rval]) turns a prvalue into an xvalue that denotes a fresh temporary object. It happens only when the context needs an object:

  • binding a reference: const T& r = T();, T&& r = make();, passing T() to a const T& or T&& parameter (including push_back(T()), see Ch 22);
  • member access: make().size(), T().v;
  • a discarded-value expression: the statement make(); on its own (the temporary is destroyed at the end of the full-expression);
  • array-to-pointer conversion on a prvalue array, and std::move(T()) (which binds to a reference parameter, so it materialises and then forces a move).

Unevaluated operands never materialise anything: sizeof(make()) and decltype(make()) do not call make.

prvalue T(...) a recipe, no storage Initialises an object of the same type T x = T(); return T(); f(T()) with T param built in place: no copy, no move, no temporary Context needs a real object: materialise const T& r = T(); T().v; make(); std::move(T()) a temporary (xvalue) is created; any copy or move into another object after that is a real call
Where a prvalue goes decides whether a temporary exists. Same-type initialisation consumes the recipe directly. Reference binding, member access and discarded expressions materialise a temporary, and from then on ordinary copy and move rules apply.

Key facts

Expression (T has a tracing ctor/copy/move)C++17/20/23 callsC++14, defaultC++14, -fno-elide-constructors
T x = T();ctorctorctor, move
T x = T(T(T()));ctorctorctor, move, move, move
T x = make(); with return T();ctorctorctor, move, move
take(T()); with void take(T)ctorctorctor, move
T x = std::move(T());ctor, movector, movector, move
T x = b ? T(1) : T(2);ctor (one branch)ctorGCC: ctor, move; Clang: ctor, move, move
throw T(); then catch (const T&)ctorctorctor, move
Non-movable T: T x = make();compileserror: deleted moveerror

All rows were checked on GCC 16 and Clang 22. The only disagreement is the conditional row in the last column: Clang materialises the chosen operand and then moves twice (operand into the conditional's result, result into x), while GCC moves once. Both are valid C++14 abstract machines with optional elision switched off. In C++17 mode -fno-elide-constructors leaves every prvalue row at a single ctor; only named returns (NRVO, Ch 24) change.

Where the object is built: the return slot and the calling convention

Theory

How can a callee construct an object inside the caller's variable? The ABI gives it the address. Under the Itanium C++ ABI (Linux, macOS, every non-Windows platform GCC and Clang target), a function that returns a class type "in memory" receives a hidden pointer argument to uninitialised storage, the return slot. LLVM calls this parameter sret (structure return).

  • x86-64 System V: the slot address arrives in rdi (so the visible arguments shift to rsi, rdx, ...). The callee returns the same address in rax.
  • AArch64 (AAPCS64): the slot address arrives in x8, a register reserved for this purpose, so the ordinary arguments keep x0 to x7.

The caller decides what the slot is. For T x = make(); it passes &x. For use(make()) with a by-value parameter it passes the address of the parameter's storage. For make().size() it passes the address of a temporary it allocated on its own frame. The callee does not know or care: it runs T's constructor with this equal to the slot.

caller: T x = make(42); caller stack frame x: sizeof(T) bytes uninitialised until make() runs lea rdi, [x] mov esi, 42 call make callee: T make(int v) { return T(v); } T::T(this = rdi, v = esi) constructor writes into x mov rax, rdi ; ret AArch64: slot pointer in x8 hidden ptr constructs here
The return slot is the caller's storage. The caller passes the address of the object being initialised as a hidden first argument (rdi on x86-64, x8 on AArch64). The callee constructs into it and returns the address in rax. No bytes are copied afterwards.

When the slot is not used

Small trivially copyable types are returned in registers instead. Compiling for x86-64 Linux with Clang 22 at -O1 gives:

struct Small { long a, b; };            Small small()  -> mov eax, 1 ; mov edx, 2 ; ret
struct Big   { long a[4]; };            Big big()      -> stores through rdi, mov rax, rdi
struct NonTriv { long a; NonTriv();
  NonTriv(const NonTriv&); ~NonTriv(); }; NonTriv nt()  -> call NonTrivC1 with this = rdi, mov rax, rdi

NonTriv is only 8 bytes, yet it goes through the hidden pointer. The Itanium ABI rule is that a class that is non-trivial for the purpose of calls (it has a non-trivial copy constructor, move constructor or destructor, or all of its copy and move constructors are deleted) must be passed and returned in memory, because its address is part of its identity: it may store this, and its destructor must run on the object that was constructed. For a trivially copyable type, a register return does physically copy bytes, but no constructor is called and the program cannot tell, so this is not a copy in the C++ sense.

Know this cold

"Return by value" for a class with a non-trivial constructor or destructor means "the caller lends the callee its storage". Elision is not a trick added on top of the ABI; the ABI was designed for it. The callee that does return T(v); literally runs the constructor on the caller's variable.

Returning non-movable and non-copyable types by value

Theory

Because a C++17 prvalue needs no copy or move constructor, a factory can return types that can never be copied or moved: std::mutex, std::atomic<int>, std::lock_guard, std::condition_variable, or your own class with both deleted. The caller can also initialise members, by-value parameters and aggregate elements from such a factory.

#include <atomic>
#include <cstdio>
#include <mutex>

struct Pinned {
    int id;
    explicit Pinned(int i) : id(i) {}
    Pinned(const Pinned&) = delete;
    Pinned(Pinned&&) = delete;
};

static Pinned make_pinned(int id) { return Pinned(id); }
static std::atomic<int> make_counter() { return std::atomic<int>(5); }
static std::mutex make_mutex() { return std::mutex(); }

struct Holder {
    Pinned p;
    Holder() : p(make_pinned(2)) {}   // member initialised from a prvalue
};

struct Agg { Pinned p; };

int main() {
    Pinned a = make_pinned(1);
    std::atomic<int> c = make_counter();
    std::mutex m = make_mutex();
    std::lock_guard<std::mutex> g(m);
    Holder h;
    Agg ag{make_pinned(3)};
    std::printf("%d %d %d %d\n", a.id, h.p.id, ag.p.id, c.load());
}
1 2 3 5

The same program in C++14 fails on every line that initialises an object from the factory, because the abstract machine still contains a move from the return value:

#include <cstdio>

struct Pinned {
    int id;
    explicit Pinned(int i) : id(i) {}
    Pinned(const Pinned&) = delete;
    Pinned(Pinned&&) = delete;
};

static Pinned make_pinned(int id) { return Pinned(id); }   // C++14: error, needs Pinned(Pinned&&)

int main() {
    Pinned a = make_pinned(1);                             // C++14: error again
    std::printf("%d\n", a.id);
}

GCC 16 reports use of deleted function 'Pinned::Pinned(Pinned&&)' and Clang 22 reports call to deleted constructor of 'Pinned', both in -std=c++14.

What already worked before C++17

  • return {}; or return {args}; is copy-list-initialisation of the return value directly from the braced list. No temporary of type T exists, so it compiled in C++11 even for std::mutex (as long as the constructor is not explicit). The caller's auto m = make(); still failed in C++14.
  • Binding the result to a reference, auto&& m = make(); or const auto& m = make();, uses lifetime extension instead of a move, so it compiled in C++14.

The exception: base-class subobjects

Guaranteed elision does not reach base-class subobjects. struct D : Pinned { D() : Pinned(make_pinned(3)) {} }; is rejected by both GCC 16 and Clang 22 in C++17 and C++20 with a "deleted move constructor" error. The reason (core issue CWG2403) is that a base subobject is potentially overlapping: the derived class may reuse the base's tail padding for its own members, and a function returning a complete Pinned would write sizeof(Pinned) bytes, clobbering them. Ordinary members are fine, as the Holder example shows. (For a [[no_unique_address]] member, which is also potentially overlapping, the two compilers currently disagree: Clang 22 accepts it and GCC 16 rejects it.)

Putting a factory result into a container

std::optional<Pinned>::emplace(make_pinned(7)) does not compile: emplace takes its arguments by forwarding reference, which materialises the prvalue, and then Pinned would have to be constructed from a Pinned&&. The fix is to pass an object that converts to Pinned by calling the factory. The container constructs Pinned(std::forward<Lazy>(l)), and GCC and Clang let the conversion function's prvalue result initialise the element in place. Strictly, the C++17 and C++20 wording only considers Pinned's constructors here, which would bind the converted prvalue to Pinned(Pinned&&) and fail; core issue CWG2327 asks for the elision that compilers already perform. Coding drill 3 builds this adaptor.

By-value parameters, conditional expressions, throw and catch

Theory

Passing prvalues to by-value parameters

A by-value parameter is an object initialised from the argument, so a prvalue argument of the same type initialises the parameter directly. take(T()) and take(make()) each call exactly one constructor; the parameter is the result object. Passing an lvalue copies, and passing std::move(lvalue) moves. This is why "take by value and move from it" is a good default for sink parameters: callers that pass temporaries pay nothing extra.

One detail that surprises people: who destroys the parameter. Under the Itanium ABI the caller allocates the parameter's storage and the standard leaves it implementation-defined whether the parameter is destroyed when the function returns or at the end of the caller's full-expression. GCC and Clang destroy it at the end of the full-expression in the caller. Do not write output-comparing code that depends on that order; count calls instead.

The conditional operator

b ? T(1) : T(2) is a prvalue when both operands are prvalues of the same class type, so T x = b ? T(1) : T(2); and return b ? T(1) : T(2); build directly into the result object. If both operands are lvalues (b ? x : y), the result is an lvalue, and initialising from it is a copy. That is the classic way to lose elision, discussed in Ch 24.

throw and catch

  • throw T(args); copy-initialises the exception object from a prvalue, so in C++17 the exception object is constructed directly. One constructor.
  • throw local; may elide (optional) if local is a non-volatile automatic object whose scope does not extend beyond the innermost enclosing try block. GCC and Clang do not elide it; they apply the implicit move instead (the object is moved into the exception object, since C++11 for locals).
  • catch (T e) may elide the copy from the exception object into e only if doing so changes nothing but the skipped constructor and destructor. In practice GCC and Clang always copy. Catch by const T&: no copy, no slicing.

-fno-elide-constructors in C++14 and C++17

Theory

-fno-elide-constructors (GCC and Clang) disables the optional elisions. It is a teaching and debugging tool; nobody ships with it. Its effect depends on the language mode, because the set of optional elisions shrank in C++17.

Code-std=c++14 -fno-elide-constructors-std=c++17 -fno-elide-constructors
T x = make(); where return T();ctor move dtor move dtor dtorctor dtor
T x = T(T(T()));ctor move move move, then 4 dtorsctor dtor
T x = nrvo(); where T t; return t;ctor move dtor move dtor dtorctor move dtor dtor
T x = param(T()); where return p;ctor move move move, then 4 dtorsctor move dtor dtor

Read the C++17 column carefully. The prvalue cases do not change: the flag cannot disable guaranteed elision because there is nothing to disable. The NRVO case gains exactly one move, from the local t into the return slot (implicit move, because t is a local being returned). The parameter case is a move in both modes because parameters are never eligible for elision; C++14 adds two more moves for the prvalue argument and the return temporary. Every row above was produced by both GCC 16 and Clang 22.

Tip

To see the C++14 abstract machine of a snippet, compile it with -std=c++14 -fno-elide-constructors. To check whether a type is returnable without a move in your codebase's language mode, delete its move constructor temporarily and compile.

When elision cannot happen

Theory

Elision needs the object being initialised and the source to be the "same object" in a way the compiler can arrange before the source is created. Each case below breaks that, and each was checked on both compilers (calls shown for C++20):

CaseExampleResultWhy
Returning a by-value parameterT f(T p) { return p; }moveThe caller built p in its own storage before the call; it cannot also be the return slot. Implicit move applies.
Returning a data memberreturn h.m;copyA subobject of another object, not an automatic variable; no implicit move either.
Returning a global or staticreturn g;copyIts lifetime is not the function's; it must survive.
Different object per branchif (b) return a; return c;moveBoth locals are alive at once, and only one can occupy the slot (NRVO, Ch 24).
Conditional of lvaluesreturn b ? a : c;copyThe expression is not a plain name, so neither NRVO nor implicit move applies.
Conversion to another typeW f() { T t; return t; }move, then W(T)The slot holds a W, not a T; t needs its own storage.
std::move of the sourcereturn std::move(t);, T x = std::move(T());moveThe expression is an xvalue (a reference), not a prvalue or a name, so the rules no longer match.
Base-class subobjectD() : Base(make_base()) {}move (error if deleted)Potentially overlapping subobject, CWG2403.

Notice the split between "move" and "copy". When elision fails for a local or a parameter, the implicit move on return still saves you from a copy. When the source is a member, a global or a non-name expression, you get a full copy. Ch 24 covers the implicit-move rules and their C++20 and C++23 extensions.

Side effects: an exception to the as-if rule

Theory

The as-if rule ([intro.abstract]) lets a compiler perform any transformation that does not change observable behaviour (I/O, volatile accesses). Copy elision is one of the very few places where the standard explicitly allows a change in observable behaviour; the other commonly cited one is omitting or merging allocations in new expressions ([expr.new]), so a replaced operator new may not be called. If a copy constructor writes to stdout, increments a global counter or registers this in a list, elision skips those effects, and the program is still correct.

Two consequences matter for real code:

  • Copy and move constructors must mean "make an equivalent object" and nothing else. Code that relies on a copy constructor running (reference counting by copy, logging that a value was "passed", assigning IDs on copy) breaks silently when elision kicks in, and behaves differently in C++14 with optional elision disabled.
  • Tests that count operations are fine if they target guaranteed elision (prvalues) or behaviour that GCC and Clang both implement consistently. Tests that depend on NRVO are testing the compiler, not the language.

Elision also changes which object's address you see. With elision, this in the constructor equals &x in the caller. Recording this in the constructor and comparing it with the final address is a portable way to prove that elision happened without printing pointers (reading drill 6).

Interview trap

"The compiler may only skip a copy constructor if it has no side effects" is wrong. Elision is permitted even when the constructor or destructor has side effects. This is the whole point of naming it in the standard instead of leaving it to the as-if rule.

Concept and tricky questions

Drills

ConceptWhat is copy elision, and what are its guaranteed and optional forms in C++17?

Answer

Answer. Copy elision is constructing an object directly in its final location so that a copy or move constructor (and the matching destructor) that the source code implies never runs. Guaranteed: initialising an object from a prvalue of the same type (return T();, T x = T();, passing T() by value, throw T();). Optional: NRVO (return local;), throw local; and catch (T e).

Why. The guaranteed form is a consequence of C++17's definition of a prvalue as an initialiser without its own storage ([basic.lval], [dcl.init]/17.6.1). The optional forms are in [class.copy.elision]/1 and still require an accessible copy or move constructor. A common wrong answer is "RVO and NRVO are both guaranteed since C++17"; only the unnamed case is.

ConceptWhy do some people say "guaranteed copy elision" is a misleading name?

Answer

Answer. Because in C++17 there is no copy to elide. A prvalue is not an object; it is a recipe that initialises its result object. return T(); does not create a temporary and then skip a move: it says "initialise the caller's object with T()".

Why. P0135 changed the value-category model rather than adding a new elision rule. That is why the move constructor need not exist, why -fno-elide-constructors cannot bring the moves back, and why the standard wording for it is in [dcl.init] rather than [class.copy.elision]. Saying "the compiler is now required to optimise" is the misconception; the language simply stopped describing the extra objects.

TrickyHow many constructors does T x = T(T(T())); call in C++17? In C++14? In C++14 with -fno-elide-constructors?

Answer

Answer. C++17: one default constructor, guaranteed. C++14: one default constructor in practice (GCC 16 and Clang 22 both elide), but the move constructor must be accessible. C++14 with -fno-elide-constructors: one default constructor and three moves, then four destructors.

Why. In C++17 each T(prvalue of T) is a prvalue of the same type initialised from a prvalue of the same type, so [dcl.init]/17.6.1 collapses them all into x. In C++14 each functional cast creates a temporary initialised by moving from the inner temporary, then x is moved from the outer one, giving three elidable moves. People often answer "three moves in C++17" (thinking of the casts as copies) or "the compiler might do three moves" (it is not allowed to in C++17).

TrickyIs copy elision an application of the as-if rule?

Answer

Answer. No. Elision is explicitly allowed to change observable behaviour, for example skipping a copy constructor that prints. It is the best-known such exception; the other commonly cited one is allocation elision for new expressions.

Why. The as-if rule only permits transformations that leave I/O and volatile accesses unchanged. A compiler could never prove that skipping a user-defined copy constructor with side effects is unobservable, so the standard grants the permission separately in [class.copy.elision]. The wrong answer "elision only happens when the copy constructor is trivial or side-effect free" confuses it with ordinary optimisation.

ConceptWhat is temporary materialisation, and name four contexts that trigger it.

Answer

Answer. The conversion that turns a prvalue into an xvalue denoting a newly created temporary object ([conv.rval]). Triggers: binding a reference (const T& r = T();, passing to a T&& or const T& parameter), member access on a prvalue (make().v), a discarded-value expression (make(); as a statement), and array-to-pointer conversion of a prvalue array. std::move(T()) is a reference binding too.

Why. A recipe has no address, so anything that needs an object forces one into existence. After materialisation the object is a normal temporary and any copy or move into another object is a real call; that is why v.push_back(T()) costs a move while v.emplace_back() does not (Ch 22). Unevaluated operands (sizeof, decltype) never materialise.

TrickyCan -fno-elide-constructors make a C++17 program call a move constructor for T x = make(); where make does return T();? What about T t; return t;?

Answer

Answer. No for return T();: the output stays ctor dtor on GCC 16 and Clang 22. Yes for return t;: it becomes ctor move dtor dtor, one move from the local into the result.

Why. The flag disables optional elision only. The prvalue case is not elision any more, so there is nothing to turn off. NRVO is still optional, so disabling it exposes the implicit move. Candidates who learned the flag in the C++11 era expect two moves in both cases; in C++14 mode that is what you get.

ConceptAt the machine level, how does a function return a std::string by value on x86-64 Linux?

Answer

Answer. The caller allocates storage for the result and passes its address as a hidden first argument in rdi. The callee constructs the string at that address and returns the same address in rax. On AArch64 the hidden pointer goes in x8.

Why. std::string has a non-trivial copy constructor and destructor, so under the Itanium C++ ABI it is "non-trivial for the purpose of calls" and must be returned in memory. LLVM marks this parameter sret. That hidden pointer is what makes elision possible: the callee's return std::string(...) constructs straight into the caller's variable. Saying "the string is built on the callee's stack and copied back" describes no real implementation.

TrickyAn 8-byte class with a user-defined destructor and a 16-byte plain struct { long a, b; }: which one goes through the hidden return pointer on x86-64?

Answer

Answer. The 8-byte class with the destructor goes through the hidden pointer. The 16-byte plain struct comes back in rax and rdx.

Why. Size alone does not decide it. The Itanium C++ ABI forces any type with a non-trivial copy constructor, move constructor or destructor into memory, because the object's address must stay stable between construction and destruction. Trivially copyable aggregates of up to 16 bytes follow the C rules (System V classifies them into registers). Checked with Clang 22 --target=x86_64-linux-gnu -O1. The common wrong answer is "anything up to 16 bytes is returned in registers".

TrickyA member Pinned p; can be initialised with p(make_pinned(2)) even though Pinned is not movable. Why does struct D : Pinned { D() : Pinned(make_pinned(3)) {} }; fail?

Answer

Answer. Base-class subobjects are potentially overlapping, and guaranteed elision does not apply to them. Both GCC 16 and Clang 22 reject the base initialiser with a deleted-move-constructor error.

Why. A derived class may place its own members in the base's tail padding. The factory writes a complete Pinned of sizeof(Pinned) bytes (and for a polymorphic base would set the wrong vptr), so building it in place could corrupt the derived object. Core issue CWG2403 records this; compilers materialise a temporary and then need the move constructor. A workaround is a Pinned constructor taking the factory's arguments, or holding Pinned as a member.

ConceptHow many objects of type T exist for throw T(); caught by catch (const T& e)? And by catch (T e)?

Answer

Answer. By reference: one, the exception object, constructed directly from the prvalue. By value: two, because e is copied from the exception object (GCC 16 and Clang 22 print ctor copy).

Why. The exception object is copy-initialised from the operand of throw; a prvalue operand initialises it directly in C++17. The copy into a by-value catch parameter is only optionally elidable, and implementations do not elide it because the exception object lives in a runtime-allocated area. Catching by value also slices derived exceptions, which is the stronger reason to catch by const&.

TrickyWhich of these compile in C++14 for std::mutex: (a) std::mutex f() { return {}; } (b) auto m = f(); (c) auto&& m = f(); (d) std::mutex m = std::mutex();

Answer

Answer. (a) and (c) compile in C++14; (b) and (d) do not. All four compile in C++17.

Why. (a) copy-list-initialises the return value from {} directly, with no intermediate std::mutex object, which was legal since C++11. (c) binds a reference to the returned temporary and extends its lifetime, so no move is needed. (b) and (d) copy-initialise from a temporary of the same type, which in C++14 requires a usable move or copy constructor, and std::mutex has both deleted. Verified on GCC 16 and Clang 22. Many people think return {}; needed C++17.

ConceptWhy can the compiler never elide the move in T f(T p) { return p; }?

Answer

Answer. The parameter p and the return slot are two distinct pieces of storage, both chosen by the caller before f starts. The callee cannot make them the same object, so it moves p into the slot. The standard excludes parameters from NRVO explicitly.

Why. Under the Itanium ABI the caller constructs the argument in its own frame and passes its address, and separately passes the return-slot address. Since C++11, return p; treats p as an rvalue first (implicit move), so you get a move rather than a copy. Writing return std::move(p); adds nothing; GCC warns with -Wredundant-move under -Wextra and Clang under -Wall.

TrickySomeone writes T x = std::move(T()); "to make sure it moves instead of copying". What does it cost?

Answer

Answer. It adds a move and a destructor call. Without std::move the line is one constructor; with it, a temporary is materialised, x is move-constructed from it, and the temporary is destroyed at the end of the statement.

Why. std::move takes a reference, so its argument is materialised, and its result is an xvalue, not a prvalue. Guaranteed elision only applies to prvalues, so the rule no longer matches. The prvalue was already the best case; there was never a copy to avoid. For a non-movable type this line fails to compile, while T x = T(); works.

Predict the output and find the bug

Drills

OutputCount the constructor, copy and move calls for each line.

#include <cstdio>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T make() { return T(); }
static T twice() { return make(); }

int main() {
    std::puts("-a"); { T x = make(); }
    std::puts("-b"); { T x = twice(); }
    std::puts("-c"); { T x = T(T(T())); }
    std::puts("-d"); { auto x = T{}; }
}
Answer
-a
ctor
dtor
-b
ctor
dtor
-c
ctor
dtor
-d
ctor
dtor

Answer. One constructor and one destructor per block, no copies, no moves.

Why. Every initialiser is a prvalue of type T. twice() returns make(), which is itself a prvalue, so the caller's return slot (x) is passed through two levels and T() constructs straight into it. In C++14 with -fno-elide-constructors, block -b would show three moves.

OutputBy-value parameters receive prvalues, lvalues and xvalues. Count the operations.

#include <cstdio>
#include <utility>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T make() { return T(); }
static void take(T) { std::puts("in take"); }

int main() {
    T a;
    std::puts("-prvalue"); take(T());
    std::puts("-call");    take(make());
    std::puts("-lvalue");  take(a);
    std::puts("-xvalue");  take(std::move(a));
    std::puts("-end");
}
Answer
ctor
-prvalue
ctor
in take
dtor
-call
ctor
in take
dtor
-lvalue
copy
in take
dtor
-xvalue
move
in take
dtor
-end
dtor

Answer. Prvalue and function-call arguments: one constructor each, built directly in the parameter. Lvalue: one copy. std::move(a): one move. The final dtor is a.

Why. A by-value parameter is copy-initialised from its argument, so a same-type prvalue becomes the parameter itself ([dcl.init]/17.6.1). The parameter is destroyed before the next statement, which is why each dtor follows in take. Whether the parameter dies at function exit or at the end of the caller's full-expression is implementation-defined; both are before the next line here.

OutputThe conditional operator with prvalue operands and with lvalue operands.

#include <cstdio>

struct T {
    int v;
    explicit T(int x) : v(x) { std::printf("ctor %d\n", v); }
    T(const T& o) : v(o.v) { std::printf("copy %d\n", v); }
    T(T&& o) noexcept : v(o.v) { std::printf("move %d\n", v); }
    ~T() { std::printf("dtor %d\n", v); }
};

static T pick_prvalue(bool b) { return b ? T(1) : T(2); }
static T pick_lvalue(bool b) {
    T a(3), c(4);
    return b ? a : c;
}

int main() {
    std::puts("-prvalue"); { T x = pick_prvalue(false); }
    std::puts("-lvalue");  { T x = pick_lvalue(true); }
}
Answer
-prvalue
ctor 2
dtor 2
-lvalue
ctor 3
ctor 4
copy 3
dtor 4
dtor 3
dtor 3

Answer. The prvalue version builds only T(2), directly in x. The lvalue version constructs both locals and then copies a into the result.

Why. b ? T(1) : T(2) is a prvalue of type T, and only the chosen operand is evaluated, so it initialises the return slot. b ? a : c is an lvalue that is neither a prvalue nor a plain name, so there is no elision and no implicit move; copy initialisation from an lvalue calls the copy constructor. Rewrite as if (b) return a; return c; to get moves (Ch 24). Many candidates expect a move because "it is a local being returned".

Find the bugThis compiles in C++17 but the team must also support C++14. Which lines break, and how would you make it compile in C++14 without making Lock movable?

#include <cstdio>
#include <mutex>

class Lock {
public:
    explicit Lock(int id) : id_(id) { std::puts("locked"); }
    Lock(const Lock&) = delete;
    Lock(Lock&&) = delete;
    ~Lock() { std::puts("unlocked"); }
    int id() const { return id_; }
private:
    int id_;
};

static Lock acquire(int id) { return Lock(id); }

int main() {
    Lock l = acquire(7);
    std::printf("holding %d\n", l.id());
}
Answer

Answer. In C++14 both return Lock(id); and Lock l = acquire(7); are errors: each copy-initialises a Lock from a temporary Lock, which requires the deleted move constructor even though the compiler would elide it. A C++14-compatible version returns with a braced list (the constructor must then be non-explicit) and binds the result with auto&&:

#include <cstdio>

class Lock {
public:
    Lock(int id) : id_(id) { std::puts("locked"); }   // not explicit, so return {id} works
    Lock(const Lock&) = delete;
    Lock(Lock&&) = delete;
    ~Lock() { std::puts("unlocked"); }
    int id() const { return id_; }
private:
    int id_;
};

static Lock acquire(int id) { return {id}; }   // list-initialises the return value directly

int main() {
    auto&& l = acquire(7);                     // lifetime extension, no move
    std::printf("holding %d\n", l.id());
}
locked
holding 7
unlocked

Why. Before C++17, elision was an optional optimisation applied to a program that had to be valid with the moves present ([class.copy]/31 in C++14). The braced return has no source object of type Lock, and reference binding uses lifetime extension instead of initialisation from a temporary. In C++17 the original program is valid as written.

OutputCount operations for a thrown prvalue, a thrown local, and catching by value.

#include <cstdio>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static void throw_local() { T t; throw t; }

int main() {
    std::puts("-1"); try { throw T(); } catch (const T&) { std::puts("caught"); }
    std::puts("-2"); try { throw_local(); } catch (const T&) { std::puts("caught"); }
    std::puts("-3"); try { throw T(); } catch (T) { std::puts("caught"); }
}
Answer
-1
ctor
caught
dtor
-2
ctor
move
dtor
caught
dtor
-3
ctor
copy
caught
dtor
dtor

Answer. (1) one constructor, the exception object built directly. (2) the local is constructed, moved into the exception object, then destroyed during unwinding before the handler runs. (3) one constructor plus a copy into the by-value catch parameter.

Why. throw T() copy-initialises the exception object from a prvalue, which is guaranteed elision. throw t could optionally be elided but GCC 16 and Clang 22 apply the implicit move instead. The copy into catch (T) is also optional to elide and neither compiler does it. Both copies vanish if you catch by const T&.

OutputThis program proves elision without printing addresses. What does it print?

#include <cstdio>

struct T {
    static const T* built_at;
    T() { built_at = this; }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
};
const T* T::built_at = nullptr;

static T make() { return T(); }
static T wrap() { return make(); }

struct Box { T member; Box() : member(wrap()) {} };

int main() {
    T x = wrap();
    std::printf("%d\n", T::built_at == &x);
    Box b;
    std::printf("%d\n", T::built_at == &b.member);
    const T& r = make();
    std::printf("%d\n", T::built_at == &r);
}
Answer
1
1
1

Answer. Three 1s and no copy or move lines.

Why. The constructor records this, the address it is building. For x the return slot passed down through wrap and make is &x. For the member, the slot is the member's storage inside b. For const T& r, the prvalue is materialised into a lifetime-extended temporary, and the slot is that temporary, which r refers to. Comparing pointers and printing a boolean keeps the output deterministic.

Find the bugThe author added std::move everywhere "to avoid copies" and the profiler shows more move constructor calls than before. Predict the output and fix it.

#include <cstdio>
#include <utility>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T make() { return std::move(T()); }

int main() {
    T x = std::move(make());
    std::puts("done");
}
Answer
ctor
move
dtor
move
dtor
done
dtor

Answer. Two extra moves and two extra destructions. Remove both std::move calls: return T(); and T x = make(); give ctor, done, dtor.

Why. Each std::move binds a prvalue to a reference parameter, which materialises a temporary, and returns an xvalue. Guaranteed elision only matches prvalues, so each step becomes a real move. GCC 16 and Clang 22 both flag each call under -Wall: "moving a temporary object prevents copy elision" (-Wpessimizing-move). The belief that std::move "makes things move" is the root error: it is a cast, and a prvalue was already better than any move.

OutputMaterialisation and unevaluated operands. Which lines construct objects?

#include <cstdio>

struct T {
    int v = 5;
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T make() { return T(); }

int main() {
    std::puts("-sizeof");  std::printf("%d\n", static_cast<int>(sizeof(make()) > 0));
    std::puts("-member");  std::printf("%d\n", make().v);
    std::puts("-discard"); make();
    std::puts("-ref");     const T& r = make();
    std::puts("-end");     (void)r;
}
Answer
-sizeof
1
-member
ctor
5
dtor
-discard
ctor
dtor
-ref
ctor
-end
dtor

Answer. sizeof calls nothing. Member access and the discarded call each materialise a temporary that dies at the end of the statement. The reference binding materialises a temporary whose lifetime is extended to r's scope, so its dtor prints after -end.

Why. The operand of sizeof is unevaluated. Member access and discarded-value expressions need an object, so [conv.rval] creates one; temporaries are destroyed at the end of the full-expression ([class.temporary]), which is why 5 prints before dtor. Binding to const T& extends the lifetime. No copies or moves appear anywhere.

Find the bugWhy does this fail on both compilers in C++20, when initialising a member the same way works?

#include <cstdio>

struct Pinned {
    int id;
    explicit Pinned(int i) : id(i) {}
    Pinned(const Pinned&) = delete;
    Pinned(Pinned&&) = delete;
};

static Pinned make_pinned(int id) { return Pinned(id); }

struct Derived : Pinned {
    int extra = 0;
    Derived() : Pinned(make_pinned(3)) {}
};

int main() {
    Derived d;
    std::printf("%d\n", d.id);
}
Answer

Answer. A base-class subobject is potentially overlapping, so it cannot be the result object of a prvalue from a factory. The compiler materialises a temporary and needs Pinned(Pinned&&), which is deleted. Fix: construct the base from the factory's arguments, or hold Pinned as a member.

#include <cstdio>

struct Pinned {
    int id;
    explicit Pinned(int i) : id(i) {}
    Pinned(const Pinned&) = delete;
    Pinned(Pinned&&) = delete;
};

static Pinned make_pinned(int id) { return Pinned(id); }

struct AsBase : Pinned {
    AsBase() : Pinned(3) {}                  // construct the base directly
};

struct AsMember {
    Pinned p;
    AsMember() : p(make_pinned(4)) {}        // members are fine
};

int main() {
    AsBase a;
    AsMember m;
    std::printf("%d %d\n", a.id, m.p.id);
}
3 4

Why. The derived class may store extra in Pinned's tail padding, and a function that returns a Pinned writes a full sizeof(Pinned). Core issue CWG2403 excludes potentially-overlapping subobjects from the in-place rule. GCC 16 and Clang 22 both report the deleted move constructor.

OutputA factory returns a Derived prvalue, and the caller stores it in a Base. Is anything elided?

#include <cstdio>

struct Base {
    Base() { std::puts("Base ctor"); }
    Base(const Base&) { std::puts("Base copy"); }
    Base(Base&&) noexcept { std::puts("Base move"); }
    ~Base() { std::puts("Base dtor"); }
};

struct Derived : Base {
    Derived() { std::puts("Derived ctor"); }
    ~Derived() { std::puts("Derived dtor"); }
};

static Derived make_derived() { return Derived(); }

int main() {
    {
        Base b = make_derived();
        std::puts("using b");
    }
    std::puts("end");
}
Answer
Base ctor
Derived ctor
Base move
Derived dtor
Base dtor
using b
Base dtor
end

Answer. The Derived object is built in place inside a temporary, but b is then move-constructed from that temporary's Base part (slicing), and the whole temporary is destroyed at the end of the statement.

Why. Guaranteed elision needs the prvalue to have the same type as the object it initialises ([dcl.init]/17.6.1). Here Base is initialised from a Derived prvalue, so overload resolution picks Base(Base&&), binding its reference parameter materialises a Derived temporary, and the move is a real call. The common wrong answer is "one construction, because a prvalue is always built in place": the slot passed to make_derived is the temporary, not b, because b is too small to hold a Derived. Return Base from the factory (or store a Derived) to get zero moves.

OutputThe copy constructor has a side effect. What does the counter say?

#include <cstdio>

static int copies = 0;
static int moves = 0;

struct T {
    T() = default;
    T(const T&) { ++copies; }
    T(T&&) noexcept { ++moves; }
};

static T make() { return T(); }
static T pass(T t) { return t; }

int main() {
    T a = T(T(make()));
    T b = pass(make());
    T c = pass(a);
    std::printf("copies=%d moves=%d\n", copies, moves);
}
Answer
copies=1 moves=2

Answer. One copy (the lvalue a into pass's parameter) and two moves (each return t; from a parameter into the return slot).

Why. T a = T(T(make())) is all prvalues: zero operations. pass(make()) builds the parameter in place, then moves it out because parameters cannot be NRVO'd. pass(a) copies into the parameter, then moves it out. The side effects of the skipped constructors simply never happen, which the standard permits. Answering "copies=3" (treating every T(...) as a copy) is the usual mistake.

Write the code

Coding

CodeWrite a factory make_registry(std::string name) that returns, by value, a Registry holding a std::mutex, a std::atomic<long> counter and the name. Registry must be neither copyable nor movable. Show it initialising a local, a class member and an aggregate element, and add a thread-safe add(long) used from two threads.

Answer

Approach. Delete both copy and move. Let std::mutex and std::atomic members make that implicit, and delete explicitly for clarity. The factory returns a prvalue (return Registry(std::move(name));), so C++17 builds it in the caller's storage. Members and aggregate elements are result objects too, so they can be initialised from the factory call. The mutex protects a non-atomic total to show the lock in use.

#include <atomic>
#include <cstdio>
#include <mutex>
#include <string>
#include <thread>
#include <utility>

class Registry {
public:
    explicit Registry(std::string name) : name_(std::move(name)) {}
    Registry(const Registry&) = delete;
    Registry(Registry&&) = delete;
    Registry& operator=(const Registry&) = delete;
    Registry& operator=(Registry&&) = delete;

    void add(long v) {
        hits_.fetch_add(1, std::memory_order_relaxed);
        std::lock_guard<std::mutex> g(m_);
        total_ += v;
    }
    long hits() const { return hits_.load(); }
    long total() { std::lock_guard<std::mutex> g(m_); return total_; }
    const std::string& name() const { return name_; }

private:
    std::string name_;
    std::mutex m_;
    std::atomic<long> hits_{0};
    long total_ = 0;
};

static Registry make_registry(std::string name) { return Registry(std::move(name)); }

struct Service {
    Registry reg;
    Service() : reg(make_registry("svc")) {}
};

struct Pair { Registry a; Registry b; };

int main() {
    Registry local = make_registry("local");
    std::thread t1([&] { for (int i = 0; i < 1000; ++i) local.add(1); });
    std::thread t2([&] { for (int i = 0; i < 1000; ++i) local.add(2); });
    t1.join();
    t2.join();
    Service s;
    Pair p{make_registry("a"), make_registry("b")};
    std::printf("%s hits=%ld total=%ld\n", local.name().c_str(), local.hits(), local.total());
    std::printf("%s %s %s\n", s.reg.name().c_str(), p.a.name().c_str(), p.b.name().c_str());
}
local hits=2000 total=3000
svc a b

Complexity and pitfalls. O(1) per call. Do not return a named local (Registry r(...); return r;): NRVO is optional, so it needs a move constructor and will not compile. Do not try to put it in a std::vector, which needs moves for reallocation. In C++14 none of the initialisations compile.

Estimated rating: ~1400 · straightforward once you know prvalue returns need no move constructor; the trap is returning a named local

CodeWrite a tracing type Probe that counts default constructions, copies, moves and destructions in a global struct, plus a helper that runs a callable and returns the counts it caused. Use it to assert that a chain of three factories feeding a by-value parameter makes zero copies and zero moves, and that a sink function that does return p; makes exactly one move.

Answer

Approach. Keep counters in a static struct. measure(f) resets the counters, runs f, and returns a snapshot taken after f returns (so destructors of temporaries inside f are included). Assertions use a small check function that prints PASS or FAIL, so the program's output is deterministic and verifiable.

#include <cstdio>
#include <utility>

struct Counts { int ctor = 0, copy = 0, move = 0, dtor = 0; };
static Counts g_counts;

struct Probe {
    int v = 0;
    Probe() { ++g_counts.ctor; }
    explicit Probe(int x) : v(x) { ++g_counts.ctor; }
    Probe(const Probe& o) : v(o.v) { ++g_counts.copy; }
    Probe(Probe&& o) noexcept : v(o.v) { ++g_counts.move; }
    Probe& operator=(const Probe&) = default;
    Probe& operator=(Probe&&) = default;
    ~Probe() { ++g_counts.dtor; }
};

template <class F>
static Counts measure(F f) {
    g_counts = {};
    f();
    return g_counts;
}

static void check(const char* what, bool ok) { std::printf("%s %s\n", ok ? "PASS" : "FAIL", what); }

static Probe make(int v) { return Probe(v); }
static Probe add_one(int v) { return make(v + 1); }
static Probe twice(int v) { return add_one(v * 2); }
static int consume(Probe p) { return p.v; }
static Probe sink(Probe p) { p.v += 100; return p; }

int main() {
    Counts c = measure([] { std::printf("value %d\n", consume(twice(3))); });
    check("factory chain: 1 ctor, 0 copies, 0 moves", c.ctor == 1 && c.copy == 0 && c.move == 0 && c.dtor == 1);

    c = measure([] { Probe r = sink(make(1)); std::printf("value %d\n", r.v); });
    check("sink: exactly one move", c.ctor == 1 && c.copy == 0 && c.move == 1 && c.dtor == 2);

    c = measure([] { Probe a(5); Probe r = sink(a); (void)r; });
    check("sink from lvalue: one copy, one move", c.copy == 1 && c.move == 1);
}
value 7
PASS factory chain: 1 ctor, 0 copies, 0 moves
value 101
PASS sink: exactly one move
PASS sink from lvalue: one copy, one move

Complexity and pitfalls. Constant overhead per operation. Keep the counters non-atomic only for single-threaded tests. Reset before each measurement, and check destructions too: a missing dtor count reveals a leaked or lifetime-extended temporary. Do not assert NRVO-dependent counts in portable tests; this test only relies on guaranteed elision and the parameter rule, which are the same on every conforming compiler.

Estimated rating: ~1300 · simple bookkeeping; the insight is predicting the counts correctly before asserting them

CodeWrite a generic adaptor Lazy (C++17) that wraps a callable returning a non-movable type N, so that std::optional<N>::emplace and std::map<K, N>::try_emplace can store the factory's result in place. Show that emplace(make()) does not compile and that your version does, with no move constructor on N.

Answer

Approach. emplace(args...) constructs N(std::forward<Args>(args)...) in the container's storage. If the single argument is an object with a conversion function operator N(), GCC and Clang use that conversion function and let its prvalue result initialise the element directly. This is compiler behaviour that goes slightly beyond the letter of the standard: the C++17 wording only considers N's constructors, so it would bind the converted prvalue to the deleted N(N&&). Core issue CWG2327 covers this gap, and both compilers already implement the elision. The adaptor holds the callable and calls it inside the conversion. A deduction guide lets callers write Lazy{lambda}.

#include <cstdio>
#include <map>
#include <optional>
#include <string>
#include <utility>

struct Conn {
    int fd;
    explicit Conn(int f) : fd(f) { std::printf("open %d\n", fd); }
    Conn(const Conn&) = delete;
    Conn(Conn&&) = delete;
    ~Conn() { std::printf("close %d\n", fd); }
};

static Conn connect(int fd) { return Conn(fd); }

template <class F>
struct Lazy {
    F f;
    operator decltype(std::declval<F&>()())() { return f(); }
};
template <class F> Lazy(F) -> Lazy<F>;

int main() {
    std::optional<Conn> slot;
    // slot.emplace(connect(3));   // error: Conn(Conn&&) is deleted
    slot.emplace(Lazy{[] { return connect(3); }});

    std::map<std::string, Conn> pool;
    pool.try_emplace("db", Lazy{[] { return connect(4); }});
    pool.try_emplace("cache", Lazy{[] { return connect(5); }});
    std::printf("%d %d %d\n", slot->fd, pool.at("db").fd, pool.at("cache").fd);
    pool.erase("db");
}
open 3
open 4
open 5
3 4 5
close 4
close 5
close 3

The commented-out line fails because emplace takes Args&&...: binding connect(3) to that reference materialises a temporary Conn, and constructing the element from it needs the move constructor.

Complexity and pitfalls. No overhead beyond the factory call. The conversion operator must return exactly N by value; returning a reference would reintroduce a copy. Do not make Conn constructible from arbitrary types through a template constructor, or that constructor may win over the conversion. The program erases "db" explicitly before printing ends, because the order in which a std::map destroys its remaining elements is an implementation detail (libstdc++ and libc++ differ), and output tests must not depend on it.

Estimated rating: ~1900 · needs the materialisation rule for forwarding references plus the conversion-function trick

CodeWrite ScopedTimer start_timer(bool verbose, const char* label) returning a non-copyable, non-movable RAII timer configured in one of two ways depending on verbose, with zero moves. The timer prints its label and mode on construction and on destruction. Do not use heap allocation or std::optional.

Answer

Approach. Returning one of two named locals needs a move. Instead, return a conditional expression whose operands are both prvalues of the same type: return verbose ? ScopedTimer(label, Mode::Verbose) : ScopedTimer(label, Mode::Quiet);. That expression is itself a prvalue, only the chosen operand is evaluated, and it initialises the caller's object directly. Two separate return statements of prvalues work just as well.

#include <cstdio>

enum class Mode { Quiet, Verbose };

class ScopedTimer {
public:
    ScopedTimer(const char* label, Mode m) : label_(label), mode_(m) {
        std::printf("start %s %s\n", label_, mode_ == Mode::Verbose ? "verbose" : "quiet");
    }
    ScopedTimer(const ScopedTimer&) = delete;
    ScopedTimer(ScopedTimer&&) = delete;
    ~ScopedTimer() { std::printf("stop %s\n", label_); }
private:
    const char* label_;
    Mode mode_;
};

static ScopedTimer start_timer(bool verbose, const char* label) {
    return verbose ? ScopedTimer(label, Mode::Verbose) : ScopedTimer(label, Mode::Quiet);
}

static ScopedTimer start_timer2(bool verbose, const char* label) {
    if (verbose) return ScopedTimer(label, Mode::Verbose);
    return ScopedTimer(label, Mode::Quiet);
}

int main() {
    {
        ScopedTimer a = start_timer(true, "parse");
        ScopedTimer b = start_timer2(false, "emit");
        std::puts("working");
    }
    std::puts("done");
}
start parse verbose
start emit quiet
working
stop emit
stop parse
done

Complexity and pitfalls. O(1). ScopedTimer t1(...), t2(...); return verbose ? t1 : t2; would need a copy constructor, and two named locals with two returns would need a move constructor, so both fail to compile. The deleted move constructor is a good guard: it turns any accidental non-elided path into a compile error.

Estimated rating: ~1400 · knowing that a conditional of two prvalues is still a prvalue

CodeWrite a non-copyable, non-movable Node that registers its own address in a global intrusive list on construction and unregisters on destruction, plus a factory make_node(int) returning it by value. Prove (without printing pointers) that the address registered at construction is the final address of the object in the caller, including when the factory result initialises a struct member.

Answer

Approach. Self-registering objects are a classic reason types cannot be moved: the registry stores this. With guaranteed elision the constructor runs on the final storage, so registering this in the constructor is safe even for a factory. The list is a doubly linked intrusive list (head pointer plus prev/next in each node). A contains(const Node*) check proves the final address is registered.

#include <cstdio>

class Node {
public:
    explicit Node(int id) : id_(id), next_(head_) {
        if (head_) head_->prev_ = this;
        head_ = this;
    }
    Node(const Node&) = delete;
    Node(Node&&) = delete;
    ~Node() {
        if (prev_) prev_->next_ = next_; else head_ = next_;
        if (next_) next_->prev_ = prev_;
    }
    static bool contains(const Node* n) {
        for (const Node* p = head_; p; p = p->next_) if (p == n) return true;
        return false;
    }
    static int size() { int k = 0; for (const Node* p = head_; p; p = p->next_) ++k; return k; }
    int id() const { return id_; }
private:
    int id_;
    Node* prev_ = nullptr;
    Node* next_;
    static Node* head_;
};
Node* Node::head_ = nullptr;

static Node make_node(int id) { return Node(id); }

struct Owner {
    int tag;
    Node node;
    Owner() : tag(1), node(make_node(20)) {}
};

int main() {
    Node a = make_node(10);
    Owner o;
    std::printf("%d %d size=%d\n", Node::contains(&a), Node::contains(&o.node), Node::size());
    {
        Node tmp = make_node(30);
        std::printf("inner size=%d id=%d\n", Node::size(), tmp.id());
    }
    std::printf("after size=%d ids=%d,%d\n", Node::size(), a.id(), o.node.id());
}
1 1 size=2
inner size=3 id=30
after size=2 ids=10,20

Complexity and pitfalls. Registration and removal are O(1); contains is O(n). ASan confirms no dangling pointers in the list. In C++14 this design was impossible to return by value; people used std::unique_ptr<Node> or out-parameters. Do not use it as a base subobject initialised from make_node (CWG2403), and add a mutex if nodes are created on several threads.

Estimated rating: ~1600 · intrusive list bookkeeping plus the guarantee that this in the constructor is the final address

Cheat sheet

Theory
  • C++17: a prvalue is an initialiser, not an object. return T();, T x = T(T(T()));, f(T()) and throw T(); construct once, directly in the result object, and need no copy or move constructor.
  • Temporaries appear only through materialisation: reference binding (including std::move and push_back), member access, discarded expressions. After that, copies and moves are real.
  • Non-movable types (std::mutex, std::atomic, your own) can be returned from factories and used to initialise locals, members and aggregate elements in C++17, but not base-class subobjects (CWG2403). In C++14 use return {...}; plus auto&&.
  • ABI: the caller passes the return slot's address (rdi on x86-64, x8 on AArch64, sret in LLVM) for any type that is non-trivial for calls; small trivially copyable types come back in registers.
  • No elision for: by-value parameters (move), members and globals (copy), b ? lvalue : lvalue (copy), conversions to another type, anything wrapped in std::move.
  • -fno-elide-constructors shows the C++14 abstract machine; in C++17 it only affects the optional elisions (in practice NRVO), never prvalue initialisation.
  • Elision may skip constructors with side effects: an explicit exception to the as-if rule. Never put logic in a copy constructor that must run.
  • Named returns (NRVO) are still optional and are covered in Ch 24.

Part F · Move semantics and value passing · Chapter 24

NRVO

Named return value optimisation lets a compiler build a function's named local directly in the caller's storage, so T result; ...; return result; costs nothing. Unlike the unnamed case in Ch 23, NRVO is still optional, and when it does not happen the implicit move on return decides whether you pay a move or a full copy. Interviewers use it to test whether you can predict constructor counts, know when return std::move(x) hurts, and can restructure a function so the optimisation applies.

TheoryDrillsCoding· about 3 hours· prerequisites: Ch 20 (move semantics), Ch 23 (copy elision)

RVO versus NRVO

Theory

Mental model. A function that returns a class by value is handed the address of the caller's storage, the return slot (Ch 23). If the function builds its answer in a local variable, the compiler can decide to place that local in the return slot from the start. Every write to result is then a write to the caller's object, and return result; has nothing left to do.

RVO (unnamed)NRVO (named)
Shapereturn T(args);T r; ...; return r;
C++17 statusGuaranteed (prvalue semantics, not really an optimisation)Optional ([class.copy.elision]/1.1)
Needs a usable copy or move constructor?NoYes, even when elided
Disabled by -fno-elide-constructors in C++17?NoYes
Fallback when not appliedNone neededImplicit move, else copy

The term "RVO" is used loosely. Some people use it for both forms and say "URVO" (unnamed RVO) for the first. In an interview, say which one you mean, and say that only the unnamed one is guaranteed.

NRVO applied caller frame x ≡ result callee writes here through the hidden pointer callee frame of build() no storage for result return result; → just ret NRVO not applied caller frame x initialised at the return statement callee frame of build() result destroyed after the move move (or copy)
NRVO makes the local and the caller's object one object. Without it, the local has its own storage in the callee's frame, and the return statement move-constructs the caller's object from it and then destroys the local.

The conditions that allow NRVO

Theory

[class.copy.elision]/1.1 permits eliding the copy or move in a return statement when all of these hold:

  1. The returned expression is the name of an object (possibly parenthesised: return (r); also qualifies).
  2. That object has automatic storage duration: a local variable, not a static, global, member or array element.
  3. It is not a function parameter and not a catch-clause parameter.
  4. It is not volatile.
  5. Its type is the same class type as the function's return type, ignoring cv-qualifiers. A const T local returned from a function returning T qualifies.

The standard says nothing about multiple return statements. That part is the compiler's problem: it has to decide, when it allocates the local, whether that local can live in the return slot. It can only do so if every return that executes while the local is alive returns that local. If another path returns a different object while this one is still alive, both cannot occupy the slot.

Know this cold

The portable recipe: declare one local of the return type near the top of the function and return that name on every path. GCC and Clang then apply NRVO at every optimisation level, including -O0. Anything else is compiler-dependent.

Same type, ignoring cv

A local of a different type never qualifies, even if it converts. std::optional<T> f() { T t; return t; } must construct the optional from t, so the best you get is a move (the implicit move on return, next section). The same holds for std::unique_ptr<Base> returned from a std::unique_ptr<Derived> local, and for a base class returned from a derived local (that one also slices).

What GCC and Clang actually do

Theory

Because NRVO is optional, the honest answer to "is it elided?" is "check". The table below was produced by a tracing type (constructor, copy and move print) on GCC 16 and Clang 22 at -O1, C++20 unless stated. The column shows the operations between the local's construction and the caller's object.

Function bodyGCC 16Clang 22Notes
T r; return r;elidedelidedThe canonical case. Also at -O0.
T r; if (b) return r; r.v = 1; return r;elidedelidedSeveral returns of the same name are fine.
T a, c; if (b) return a; return c;movemoveTwo locals alive together: neither can be the slot.
if (b) { T a; return a; } else { T c; return c; }a elided, c movedboth elidedDisjoint scopes. Clang gives each local the slot; GCC gives it to one local per function.
if (b) { T a; return a; } T c; return c;a elided, c movedboth elidedSame as above; a switch with one local per case behaves the same way.
T a; if (b) { T c; return c; } return a;c elided, a movedc elided, a movedThe inner local gets the slot; the outer one cannot.
T a; if (b) return T(); return a;a moveda movedA prvalue return on another path stops NRVO for a.
T f(T p) { return p; }movemoveParameters are excluded by the standard.
auto [a, n] = pair; return a;movecopyA structured binding is not a variable; see below.
return h.member;, return global;, return local_static;copycopyNot automatic objects, so neither NRVO nor implicit move.
T& r = a; return r;, return arr[0];copycopyA reference or a subobject is not the local itself.
return b ? a : c;copycopyNot a name, so no NRVO and no implicit move.
T t; return std::move(t);movemoveNot a name any more: NRVO blocked. Both warn.
return expr; expr is a prvalue of the return type? yes built in slot (always) no name of a non-param automatic local of the same type? NRVO allowed (optional) no, or not applied implicitly movable: a local or parameter by name, or a T&& local (C++20)? conversions allowed (C++20 P1825) move no: member, global, ref, ?:, std::move(x) copy (or move if an xvalue)
How a return statement initialises the result. A prvalue is always built in place. A plain local may additionally be elided. Failing that, an implicitly movable name is treated as an rvalue. Everything else is ordinary copy-initialisation, which copies from lvalues and moves from xvalues such as std::move(member).

Structured bindings

The names introduced by auto [a, n] = ...; are not variables: they are names for parts of a hidden variable. The C++20 and C++23 wording of "implicitly movable entity" talks about variables, so returning a does not qualify, and Clang 22 copies. GCC 16 moves in C++20 and C++23 mode (and copies in C++17 mode). Neither compiler applies NRVO. If you need to return a binding cheaply, write return std::move(a);: here it is the right call, because NRVO was never possible, and neither compiler warns about it.

The implicit move on return: C++11, C++20, C++23

Theory

If NRVO does not happen, the copy is still avoided in most cases by the rule that a returned local is first treated as an rvalue. The rule has grown in three steps.

StandardRuleNew cases that move
C++11 (plus CWG1579 for C++14)If the operand names a local or parameter, overload resolution first treats it as an rvalue. In the original wording, this was used only if the chosen constructor's first parameter was an rvalue reference to the returned object's type.return local;, return param;; CWG1579 added converting constructors taking U&&, such as unique_ptr<Base> from a unique_ptr<Derived> local.
C++20, P1825 ("more implicit moves")Removes the "rvalue reference to the returned object's type" restriction and adds rvalue-reference variables.Returning a T&& local or parameter by name; constructors taking the source by value; slicing to a base (Base f() { Derived d; return d; } moves the base part); throw of a parameter.
C++23, P2266 ("simpler implicit move")A returned implicitly movable entity is simply an xvalue, with no two-phase overload resolution.Functions returning a reference: T&& f(T&& x) { return x; } now compiles; T& f(T&& x) { return x; } now fails.

Compilers treat parts of these changes as defect reports and apply them in older modes. Verified on GCC 16 and Clang 22:

  • Slicing a derived local to a base return type, and a converting constructor that takes its argument by value: both compilers move in every mode from C++14 on.
  • Returning an rvalue-reference parameter or local by name (T f(T&& r) { return r; }): Clang moves in every mode; GCC copies in C++14 and C++17 and moves from C++20. This is a real compiler difference in C++17 code.
  • T&& f(T&& x) { return x; }: rejected by both in C++20, accepted by both in C++23.
#include <cstdio>
#include <memory>
#include <optional>
#include <string>

struct Base { virtual ~Base() = default; virtual const char* name() const { return "base"; } };
struct Derived : Base { const char* name() const override { return "derived"; } };

static std::unique_ptr<Base> make_base() {
    auto p = std::make_unique<Derived>();
    return p;                       // unique_ptr<Derived> -> unique_ptr<Base>: implicit move
}

static std::optional<std::string> lookup(bool found) {
    std::string s(40, 'x');         // longer than any small-string buffer
    if (!found) return std::nullopt;
    return s;                       // string -> optional<string>: implicit move
}

int main() {
    std::unique_ptr<Base> b = make_base();
    std::optional<std::string> o = lookup(true);
    std::printf("%s %zu %d\n", b->name(), o->size(), lookup(false).has_value());
}
derived 40 0

make_base would not compile if return p; tried to copy, because unique_ptr is move-only. That is the simplest proof that the implicit move happens through a converting constructor.

Why return std::move(local) is a pessimisation

Theory

return std::move(r); changes the operand from a name into an xvalue. NRVO requires a name, so it is now impossible: the local lives in the callee's frame and the caller's object is move-constructed from it. You traded zero operations for one move plus one destructor call, and for a type without a cheap move (a std::array, a struct of scalars, a small-buffer type) that move is a full copy of the bytes.

CodeEffectWarning
T r; return std::move(r);Blocks NRVO; forces a move-Wpessimizing-move (GCC: in -Wall; Clang: in -Wall)
T f(T p) { return std::move(p); }No effect: parameters move implicitly anyway-Wredundant-move (GCC: in -Wextra; Clang: in -Wall)
return std::move(T());Materialises a temporary, forces a move-Wpessimizing-move, "moving a temporary object"
return std::move(member_);Needed: members never move implicitlynone
return std::move(*this); in an &&-qualified memberNeedednone
T f(T&& r) { return std::move(r); }Needed for GCC in C++17 and earlier; redundant from C++20none from either compiler

Interview trap

"return std::move(x) is always safe, at worst redundant." Wrong for locals: it disables NRVO. The rule of thumb is: never std::move a local or a by-value parameter in a return; do std::move members, *this, structured bindings, and things you only hold by lvalue reference but own.

const locals and const return types

Theory

Two different uses of const interact with returns, and they fail in different ways.

A const local

T f() { const T r = build(); return r; } is still eligible for NRVO, because the types match ignoring cv. GCC and Clang elide it. But if NRVO fails (two const locals on different branches, or -fno-elide-constructors), the implicit move finds a const T rvalue, which cannot bind to T&&, so overload resolution picks the copy constructor. Making a local const silently turns the fallback from a move into a copy.

A const return type

const T f(); was once recommended (pre-C++11) to stop f() = x;. Today it is harmful:

  • T x = f(); is still fine: a prvalue of type const T initialises a T directly (same type ignoring cv), so nothing runs.
  • x = f(); calls the copy assignment operator, because a const T rvalue cannot bind to T&&.
  • v.push_back(f()); copies for the same reason, and std::move(f()) yields const T&&, which also copies.

GCC and Clang warn about const on scalar return types (-Wignored-qualifiers, in -Wextra), where it is meaningless, but neither warns for class types, where it does harm. Remove const from by-value return types.

-fno-elide-constructors, -O0 and debug builds

Theory

GCC and Clang decide NRVO in the front end (GCC in its C++ front end; Clang marks the NRVO candidate during semantic analysis and code generation then allocates it in the return slot), not in the optimiser. So the result does not depend on -O: every case in the table above prints the same at -O0 and -O1 on both compilers (checked). A debug build of a GCC or Clang program has the same constructor counts as the release build.

-fno-elide-constructors turns NRVO off. In C++17 and later it leaves prvalue initialisation alone, so its only visible effect on T r; return r; is one extra move:

$ g++-16 -std=c++17 -fno-elide-constructors nrvo.cpp   (Clang 22 identical)
ctor move dtor dtor           // T r; return r;  -> r constructed, moved into x
$ g++-16 -std=c++14 -fno-elide-constructors nrvo.cpp
ctor move dtor move dtor dtor // plus the C++14 move from the return temporary

MSVC behaves differently: it has historically applied NRVO only when optimising, and recent versions changed that behind a conformance switch. This guide's harness does not include MSVC, so treat any MSVC-specific claim as something to check against its current documentation (/Zc:nrvo) and on your own build. The portable conclusion is the same as before: never write code whose correctness depends on NRVO happening, only code whose speed benefits from it.

Tracing programs on both compilers

Theory

Interview answers about NRVO should come with a way to check them. Three techniques, from cheapest to most precise:

  1. A tracing type that prints or counts in its constructor, copy, move and destructor. Run it under both compilers. Count per call, because destructor placement of temporaries varies more than the counts do.
  2. Address identity. Record this in the constructor, then compare it with the caller's &x and print the boolean. If they are equal, the local was the caller's object. This avoids printing pointers.
  3. Delete the move constructor temporarily. If the function still compiles, every return path is a prvalue; if NRVO is what you rely on, it will not compile (NRVO needs a usable constructor). This proves which category you are in, not whether elision happened.
#include <cstdio>

struct T {
    static const T* last;
    int v = 0;
    T() { last = this; }
    T(const T& o) : v(o.v) { last = this; }
    T(T&& o) noexcept : v(o.v) { last = this; }
};
const T* T::last = nullptr;

static const T* inner = nullptr;

static T one_local(int n) {
    T r;
    inner = &r;
    for (int i = 0; i < n; ++i) r.v += i;
    return r;
}

static T two_locals(bool b) {
    T a, c;
    inner = b ? &a : &c;
    return b ? T(a) : T(c);    // explicit copies: a prvalue, but never the local
}

int main() {
    T x = one_local(4);
    std::printf("one_local: local is x: %d, v=%d\n", inner == &x, x.v);
    T y = two_locals(true);
    std::printf("two_locals: local is y: %d, y built in place: %d\n", inner == &y, T::last == &y);
}
one_local: local is x: 1, v=6
two_locals: local is y: 0, y built in place: 1

inner == &x is true only because NRVO placed r in x. Printing it is safe here because both GCC and Clang elide this canonical shape; in general such a program has compiler-dependent output, which is exactly the point being tested.

Concept and tricky questions

Drills

ConceptWhat is the difference between RVO and NRVO, and which is guaranteed in C++17?

Answer

Answer. RVO (unnamed) is return T(args);: a prvalue initialises the caller's object, guaranteed since C++17 and needing no copy or move constructor. NRVO is T r; return r;: the compiler may place r in the return slot, but it is still optional and the copy or move constructor must be usable.

Why. P0135 made prvalues initialisers without storage, which covers the unnamed case. A named local is an object with its own lifetime that the compiler would have to prove can share storage with the result, so the standard leaves it as permission in [class.copy.elision]/1.1. "Both are guaranteed since C++17" is the common wrong answer.

ConceptList the conditions under which the standard permits NRVO.

Answer

Answer. The return operand is the (possibly parenthesised) name of a non-volatile object with automatic storage duration, which is not a function or catch parameter, whose type is the same class as the return type ignoring cv-qualifiers.

Why. Each condition guarantees the compiler controls where the object lives: statics and members live elsewhere, parameters are allocated by the caller, and a different type cannot share storage with the result. Nothing in the standard mentions how many returns there are; that limitation is practical, because only one object can occupy the slot at a time.

TrickyT a, c; if (b) return a; return c;: is NRVO applied? Is it a copy? How do you fix it?

Answer

Answer. No NRVO on GCC 16 or Clang 22, but no copy either: each return moves. To get NRVO, restructure so only one object is alive at a return: declare one result and fill it according to b, or give each branch its own scope with its own local.

Why. When a and c are both alive, the compiler must choose their storage before it knows which will be returned, and only one can be the return slot. The implicit move on return still treats each name as an rvalue. Saying "it copies" confuses this with return b ? a : c;, which does copy.

Trickyif (b) { T a; return a; } else { T c; return c; }: the two locals are never alive together. Do GCC and Clang elide both returns?

Answer

Answer. Clang 22 elides both. GCC 16 elides a but moves c. The same split appears with a fall-through (if (b) { T a; return a; } T c; return c;) and with a switch that has one local per case.

Why. NRVO is possible for both locals because their lifetimes do not overlap. Clang's analysis works per scope and lets each local take the slot in turn. GCC's picks a single local per function to place in the return slot (in these tests, the one in the first return statement) and treats the others as ordinary locals, which are then moved by the implicit-move rule. Both outcomes conform because NRVO is optional. If you need NRVO on every compiler, use one result variable for the whole function. Verified at -O0, -O1 and -O2.

ConceptWhat is the implicit move on return, and what did C++20 (P1825) and C++23 (P2266) change?

Answer

Answer. When return x; names an automatic local or parameter, the compiler first tries to initialise the result as if x were an rvalue, so it moves instead of copying. C++20 removed the restriction that the selected constructor take an rvalue reference to x's own type and added rvalue-reference variables (T&& r). C++23 replaced the two-step lookup with "the name is an xvalue", which makes T&& f(T&& x) { return x; } legal and T& f(T&& x) { return x; } ill-formed.

Why. The aim each time was to move from anything that is about to die. The C++11 wording missed converting constructors with by-value parameters, base-class slicing, and rvalue references. Compilers back-port much of this: GCC 16 and Clang 22 move when slicing to a base even in C++14 mode, while for T&& parameters GCC waits for C++20 and Clang does not.

TrickyWhen is return std::move(x); correct, and when is it harmful?

Answer

Answer. Harmful for a local of the return type: it blocks NRVO and forces a move (-Wpessimizing-move). Redundant for a by-value parameter (-Wredundant-move). Correct for a data member, *this in an rvalue-qualified member function, a structured binding, an object held through an lvalue reference that you own, and (before C++20, for GCC) an rvalue-reference parameter.

Why. NRVO and the implicit move both only apply to a plain name of an implicitly movable entity. Members, references and bindings are not such names, so without std::move they are copied. Locals and parameters already move implicitly, so wrapping them gains nothing and for locals loses the elision.

TrickyTo stop accidental moves, a class keeps its copy constructor and declares C(C&&) = delete;. Does C f() { C c; return c; } compile? Does C g() { return C(); }?

Answer

Answer. f does not compile in any standard mode; GCC 16 and Clang 22 both report the deleted move constructor. g compiles from C++17 on (and fails in C++14).

Why. NRVO is optional, so return c; must be valid as if the move happened. The implicit move performs overload resolution with c as an rvalue, and C(C&&) is the best match. A deleted function still takes part in overload resolution, so it is selected, and selecting a deleted function makes the program ill-formed; the compiler does not fall back to the copy constructor, because the fallback only happens when overload resolution fails. return C(); is a prvalue and needs no constructor at all in C++17. The usual wrong answer is "it falls back to the copy". If you want a copyable type that never moves, leave the move constructor undeclared (a user-declared copy constructor already suppresses the implicit one) instead of deleting it: then the rvalue binds to C(const C&).

TrickyWhat do these return, and which one is a bug? decltype(auto) a() { std::string s = "x"; return s; } and decltype(auto) b() { std::string s = "x"; return (s); }

Answer

Answer. a returns std::string by value (eligible for NRVO). b returns a reference to the dead local s, a dangling reference: std::string& in C++20 and std::string&& in C++23. Both compilers warn (-Wreturn-local-addr in GCC, -Wreturn-stack-address in Clang).

Why. decltype(auto) deduces the type with decltype rules. decltype(s) is the declared type, std::string, but decltype((s)) is the type of an expression, which for an lvalue is T&. In C++23, P2266 makes a returned movable name an xvalue, so the parentheses deduce T&& instead, still dangling. With a plain return type std::string or auto, return (s); is harmless and still eligible for NRVO, which is why people think parentheses never matter.

ConceptDoes making the returned local const prevent NRVO?

Answer

Answer. No, NRVO still applies, because the type matches ignoring cv-qualifiers, and GCC and Clang elide it. But the fallback changes: if NRVO is not applied, the const object cannot be moved from, so it is copied.

Why. The implicit move treats the name as an rvalue of type const T; T(T&&) cannot bind to it, so T(const T&) wins. With two const locals returned on different branches, both compilers print a copy (reading drill 5). Make locals const only if they are not returned.

TrickyWhat does declaring a return type const std::vector<int> f(); cost?

Answer

Answer. Nothing for auto v = f(); (still constructed in place), but every assignment v = f(); and every push_back(f()) copies instead of moving.

Why. A const T prvalue initialising a T is still same-type initialisation. Once it is materialised to bind to a reference, it is an xvalue of type const T, which cannot bind to T&& and only binds to const T&, so copy assignment and copy construction are chosen. No warning catches this for class types. The old advice (Scott Meyers, pre-C++11) to return const values is obsolete.

TrickyA function does auto [key, value] = parse(line); return value;. Is value moved?

Answer

Answer. It depends on the compiler. Clang 22 copies it in every mode. GCC 16 moves it in C++20 and C++23 and copies in C++17. Neither applies NRVO. Write return std::move(value); to get a move everywhere.

Why. Structured binding names refer to subobjects of a hidden variable; they are not variables themselves, and the implicit-move rule speaks of variables. GCC chose to treat them as movable. This is one of the few places where std::move in a return is the right fix, and neither compiler warns about it.

ConceptDo GCC and Clang apply NRVO in a -O0 debug build? What switches it off?

Answer

Answer. Yes. Both decide NRVO in the front end, so -O0 and -O2 give the same constructor counts. -fno-elide-constructors switches NRVO off (and in C++14 mode also the prvalue elisions).

Why. Elision changes which object a name refers to, which must be known when the variable's storage is allocated, before any optimisation pass. Verified for every case in this chapter at -O0 and -O1. MSVC has a different history (NRVO tied to optimisation, later a conformance flag) that this guide has not tested.

TrickyWhy does std::unique_ptr<Base> f() { auto p = std::make_unique<Derived>(); return p; } compile, given that p is an lvalue and unique_ptr cannot be copied?

Answer

Answer. Because of the implicit move on return: p is treated as an rvalue, and unique_ptr<Base>'s converting constructor from unique_ptr<Derived>&& is selected.

Why. NRVO is impossible (different type). In the original C++11 wording the implicit move only counted if the constructor took an rvalue reference to the returned object's own type, so this was a copy attempt and failed; CWG1579 (applied to C++14 and by compilers as a fix) allowed converting constructors, and C++20 P1825 generalised it further. Writing return std::move(p); is not needed.

Predict the output and find the bug

Drills

OutputOne local, several returns of it, and a lambda. Count the operations.

#include <cstdio>

struct T {
    int v = 0;
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T build(bool early) {
    T r;
    if (early) return r;
    r.v = 42;
    return r;
}

int main() {
    std::puts("-early"); { T x = build(true); }
    std::puts("-late");  { T x = build(false); std::printf("%d\n", x.v); }
    auto lam = [] { T r; r.v = 7; return r; };
    std::puts("-lambda"); { T x = lam(); std::printf("%d\n", x.v); }
}
Answer
-early
ctor
dtor
-late
ctor
42
dtor
-lambda
ctor
7
dtor

Answer. One constructor per call, no copies or moves, on both GCC 16 and Clang 22.

Why. Every path returns the same named local, so the compiler places r in the caller's x. Multiple return statements are not a problem as long as they all name the same object. A lambda's body is an ordinary function body, so the same applies.

OutputTwo locals, one returned per branch, and a nested scope.

#include <cstdio>

struct T {
    int v;
    explicit T(int x) : v(x) { std::printf("ctor %d\n", v); }
    T(const T& o) : v(o.v) { std::printf("copy %d\n", v); }
    T(T&& o) noexcept : v(o.v) { std::printf("move %d\n", v); }
    ~T() { std::printf("dtor %d\n", v); }
};

static T either(bool b) {
    T a(1), c(2);
    if (b) return a;
    return c;
}

static T nested(bool b) {
    T outer(3);
    if (b) {
        T inner(4);
        return inner;
    }
    return outer;
}

int main() {
    std::puts("-either"); { T x = either(false); }
    std::puts("-nested true"); { T x = nested(true); }
    std::puts("-nested false"); { T x = nested(false); }
}
Answer
-either
ctor 1
ctor 2
move 2
dtor 2
dtor 1
dtor 2
-nested true
ctor 3
ctor 4
dtor 3
dtor 4
-nested false
ctor 3
move 3
dtor 3
dtor 3

Answer. either: no NRVO, c is moved. nested(true): inner is built directly in x, no move. nested(false): outer is moved.

Why. In either both locals are alive at the returns, so neither gets the slot, and the implicit move applies. In nested, inner's whole lifetime is inside the branch, and while it is alive the only return is return inner;, so both compilers give it the slot. outer cannot have it too, because it is alive while inner is using it. Note that dtor 3 prints before dtor 4 in the second block: outer dies when nested returns, while inner is x.

OutputDisjoint scopes written two ways, each called so that the second local is returned. This output is for GCC 16; what does Clang 22 print?

#include <cstdio>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T if_else(bool b) {
    if (b) { T a; return a; }
    else   { T c; return c; }
}

static T fall_through(bool b) {
    if (b) { T a; return a; }
    T c;
    return c;
}

int main() {
    std::puts("-if_else");      { T x = if_else(false); }
    std::puts("-fall_through"); { T x = fall_through(false); }
}
Answer
-if_else
ctor
move
dtor
dtor
-fall_through
ctor
move
dtor
dtor

Answer. GCC 16 moves c in both functions. Clang 22 elides both, printing only ctor and dtor for each block. Called with true, both compilers elide a.

Why. In both functions a and c are never alive at the same time, so NRVO is possible for each. Clang assigns the return slot per scope. GCC places only one local per function in the slot, the one in the first return statement, so c lives in the callee's frame and is moved out by the implicit move. Because NRVO is optional, both results conform. A single result variable gets NRVO on both compilers.

Find the bugThe author wanted to "help the compiler". Predict the output and fix it.

#include <array>
#include <cstdio>
#include <utility>

struct Frame {
    std::array<double, 512> samples{};
    Frame() { std::puts("ctor"); }
    Frame(const Frame&) { std::puts("copy"); }
    Frame(Frame&&) noexcept { std::puts("move (4 KB memcpy)"); }
    ~Frame() { std::puts("dtor"); }
};

static Frame capture() {
    Frame f;
    f.samples[0] = 1.5;
    return std::move(f);
}

int main() {
    Frame f = capture();
    std::puts("done");
}
Answer
ctor
move (4 KB memcpy)
dtor
done
dtor

Answer. std::move(f) blocks NRVO, so a 4 KB move (which for a std::array is a copy of every element) and an extra destructor run. Fix: return f;, which prints ctor, done, dtor.

Why. NRVO requires the operand to be the name of the local; std::move(f) is an xvalue expression. Both GCC 16 and Clang 22 warn at -Wall: "moving a local object in a return statement prevents copy elision" (-Wpessimizing-move). For types with inline storage, "move" is not cheaper than copy, so the only way to make returning free is elision.

OutputTwo const locals and a single const local. Watch the fallback.

#include <cstdio>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T one() { const T r; return r; }

static T two(bool b) {
    const T a;
    const T c;
    if (b) return a;
    return c;
}

int main() {
    std::puts("-one"); { T x = one(); }
    std::puts("-two"); { T x = two(true); }
}
Answer
-one
ctor
dtor
-two
ctor
ctor
copy
dtor
dtor
dtor

Answer. one: elided. two: a copy, not a move.

Why. A const T local is still eligible for NRVO (same type ignoring cv), so one is free. In two NRVO is impossible, and the implicit move produces an rvalue of type const T, which cannot bind to T&&, so the copy constructor is chosen. Remove the const and the copy becomes a move.

OutputReturning things that are not locals.

#include <cstdio>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

struct Holder { T member; };

static T g;

static T from_member() { Holder h; return h.member; }
static T from_global() { return g; }
static T from_ref()    { T a; T& r = a; return r; }
static T from_param(T p) { return p; }

int main() {
    std::puts("-member"); { T x = from_member(); }
    std::puts("-global"); { T x = from_global(); }
    std::puts("-ref");    { T x = from_ref(); }
    std::puts("-param");  { T x = from_param(T()); }
    std::puts("-end");
}
Answer
ctor
-member
ctor
copy
dtor
dtor
-global
copy
dtor
-ref
ctor
copy
dtor
dtor
-param
ctor
move
dtor
dtor
-end
dtor

Answer. Member, global and reference: copies. By-value parameter: a move. The first ctor is the global g, and the last dtor is g at program exit.

Why. NRVO and the implicit move both need the name of an automatic object (local or, for the move, parameter). h.member is a subobject, g has static storage, and r is a reference, so each is an lvalue and is copied. Use return std::move(h.member); when the holder is about to die. The parameter is moved implicitly but never elided.

Find the bugThis forwarding helper compiles in C++23 but the project builds with -std=c++20. Why does it fail there, and what is the portable fix?

#include <cstdio>
#include <string>
#include <utility>

static std::string&& pass_through(std::string&& s) {
    s += "!";
    return s;          // C++20: s is an lvalue here
}

int main() {
    std::string a = "hi";
    std::string b = pass_through(std::move(a));
    std::printf("%s\n", b.c_str());
}
Answer

Answer. In C++20 the name s is an lvalue, and an rvalue reference return type cannot bind to an lvalue. The implicit move only applied to initialising a returned object, not a returned reference. C++23 (P2266) makes a returned implicitly movable entity an xvalue, so the code compiles there. The portable fix is return std::move(s);.

#include <cstdio>
#include <string>
#include <utility>

static std::string&& pass_through(std::string&& s) {
    s += "!";
    return s;          // C++23: s is an xvalue in a return statement
}

int main() {
    std::string a = "hi";
    std::string b = pass_through(std::move(a));
    std::printf("%s\n", b.c_str());
}
hi!

Why. GCC 16 says "cannot bind rvalue reference of type 'std::string&&' to lvalue" and Clang 22 says "rvalue reference to type ... cannot bind to lvalue" in C++20 mode; both accept it in C++23. The flip side of P2266: std::string& f(std::string&& s) { return s; } compiles in C++20 and fails in C++23, because an xvalue cannot bind to a non-const lvalue reference.

OutputReturning a structured binding. This output is for Clang 22; what does GCC 16 print in C++20 mode, and how do you make them agree?

#include <cstdio>
#include <utility>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static std::pair<T, int> parse() { return {}; }

static T first() {
    auto [value, code] = parse();
    (void)code;
    return value;
}

int main() {
    T x = first();
    std::puts("done");
}
Answer
ctor
copy
dtor
done
dtor

Answer. Clang 22 copies. GCC 16 prints move instead of copy in C++20 and C++23 (and copy in C++17). Writing return std::move(value); gives a move on both, with no warning.

Why. value names a member of the hidden pair object, not a variable, so it is not an implicitly movable entity under the standard wording that Clang follows. NRVO is impossible either way, since the returned object is a subobject. The dtor after copy is the hidden pair being destroyed when first returns.

OutputA const return type, used for initialisation and for assignment.

#include <cstdio>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    T& operator=(const T&) { std::puts("copy="); return *this; }
    T& operator=(T&&) noexcept { std::puts("move="); return *this; }
    ~T() { std::puts("dtor"); }
};

static const T make() { T t; return t; }

int main() {
    std::puts("-init");   T x = make();
    std::puts("-assign"); x = make();
    std::puts("-end");
}
Answer
-init
ctor
-assign
ctor
copy=
dtor
-end
dtor

Answer. Initialisation is free (NRVO into x). The assignment calls copy=, not move=.

Why. make() is a prvalue of type const T. Initialising a T from it is same-type initialisation, and inside make NRVO places t in the slot. For the assignment, the prvalue is materialised as a const T temporary, which cannot bind to T&&, so copy assignment is selected. Drop the const from the return type to get move=.

OutputAn rvalue-reference parameter and an rvalue-reference local, returned by name in C++20.

#include <cstdio>

struct T {
    T() { std::puts("ctor"); }
    T(const T&) { std::puts("copy"); }
    T(T&&) noexcept { std::puts("move"); }
    ~T() { std::puts("dtor"); }
};

static T make() { return T(); }
static T from_param(T&& r) { return r; }
static T from_local() { T&& r = make(); return r; }

int main() {
    std::puts("-param"); { T x = from_param(T()); }
    std::puts("-local"); { T x = from_local(); }
}
Answer
-param
ctor
move
dtor
dtor
-local
ctor
move
dtor
dtor

Answer. Both move in C++20 on GCC 16 and Clang 22.

Why. C++20 (P1825) made rvalue-reference variables implicitly movable. In -std=c++17 the compilers disagree: GCC 16 prints copy for both functions, while Clang 22 already moves, applying the change as a fix to older modes. If your code must be portable to C++17, write return std::move(r); for rvalue references; it is not a pessimisation, because a reference is never eligible for NRVO.

Write the code

Coding

CodeThe function below returns one of two locals and is called in a hot loop. Rewrite it so NRVO applies on both GCC and Clang, and prove it with a counting type that asserts zero copies and zero moves for both values of the flag.

Report summarise(const std::vector<int>& xs, bool detailed) {
    Report brief;  brief.title = "brief";  brief.total = sum(xs);
    Report full;   full.title = "full";    full.total = sum(xs);  full.rows = xs;
    if (detailed) return full;
    return brief;
}
Answer

Approach. Two live locals rule out NRVO. Declare a single Report r, fill the common parts, branch only on what differs, and return r on every path. That also stops computing work for the branch you throw away. The test type counts copies and moves in static counters, and the test checks both flag values.

#include <cstdio>
#include <numeric>
#include <string>
#include <vector>

static int copies = 0, moves = 0;

struct Report {
    std::string title;
    long total = 0;
    std::vector<int> rows;
    Report() = default;
    Report(const Report& o) : title(o.title), total(o.total), rows(o.rows) { ++copies; }
    Report(Report&& o) noexcept : title(std::move(o.title)), total(o.total), rows(std::move(o.rows)) { ++moves; }
};

static Report summarise(const std::vector<int>& xs, bool detailed) {
    Report r;
    r.total = std::accumulate(xs.begin(), xs.end(), 0L);
    if (detailed) {
        r.title = "full";
        r.rows = xs;
        return r;
    }
    r.title = "brief";
    return r;
}

int main() {
    std::vector<int> xs{1, 2, 3, 4};
    for (bool detailed : {false, true}) {
        copies = moves = 0;
        Report r = summarise(xs, detailed);
        std::printf("%s total=%ld rows=%zu copies=%d moves=%d %s\n", r.title.c_str(), r.total,
                    r.rows.size(), copies, moves, copies == 0 && moves == 0 ? "PASS" : "FAIL");
    }
}
brief total=10 rows=0 copies=0 moves=0 PASS
full total=10 rows=4 copies=0 moves=0 PASS

Complexity and pitfalls. O(n) for the sum and the optional row copy (that copy is of the vector, not of the Report, so it is not counted). Keep exactly one Report object in the function; a return Report{} on an error path would block NRVO for r on GCC and Clang (both move r then), so return r in a cleared state instead.

Estimated rating: ~1300 · simple refactor once you know that one live local is the condition compilers check

CodeWrite a header-only tracing type Traced and an RAII checker ExpectOps that records the copy and move counts at construction and, at destruction, prints PASS or FAIL against expected numbers. Use it to document, as executable tests, five return patterns: single local, two locals, by-value parameter, data member, and return std::move(local).

Answer

Approach. Static counters in Traced; ExpectOps snapshots them in its constructor and compares the difference in its destructor. Each test is a block that creates an ExpectOps and then calls the function, so the check happens at the closing brace after all temporaries are gone. Only patterns that GCC and Clang handle identically are asserted.

#include <cstdio>
#include <utility>

struct Traced {
    static inline int copies = 0, moves = 0;
    Traced() = default;
    Traced(const Traced&) { ++copies; }
    Traced(Traced&&) noexcept { ++moves; }
};

class ExpectOps {
public:
    ExpectOps(const char* what, int c, int m)
        : what_(what), want_c_(c), want_m_(m), c0_(Traced::copies), m0_(Traced::moves) {}
    ~ExpectOps() {
        int c = Traced::copies - c0_, m = Traced::moves - m0_;
        std::printf("%s %-14s copies=%d moves=%d\n", c == want_c_ && m == want_m_ ? "PASS" : "FAIL", what_, c, m);
    }
private:
    const char* what_;
    int want_c_, want_m_, c0_, m0_;
};

struct Box { Traced t; };

static Traced single()               { Traced r; return r; }
static Traced two(bool b)            { Traced a, c; if (b) return a; return c; }
static Traced param(Traced p)        { return p; }
static Traced member()               { Box b; return b.t; }
#pragma GCC diagnostic ignored "-Wpessimizing-move"
static Traced moved()                { Traced r; return std::move(r); }

int main() {
    { ExpectOps e("single", 0, 0);       Traced x = single(); }
    { ExpectOps e("two locals", 0, 1);   Traced x = two(true); }
    { ExpectOps e("param", 0, 1);        Traced x = param(Traced()); }
    { ExpectOps e("member", 1, 0);       Traced x = member(); }
    { ExpectOps e("std::move(local)", 0, 1); Traced x = moved(); }
}
PASS single         copies=0 moves=0
PASS two locals     copies=0 moves=1
PASS param          copies=0 moves=1
PASS member         copies=1 moves=0
PASS std::move(local) copies=0 moves=1

Complexity and pitfalls. Constant overhead. Declare ExpectOps before the call in each block so that its destructor runs last (locals die in reverse order). The #pragma silences the deliberate pessimising move on both compilers (Clang honours GCC diagnostic pragmas). Do not add the disjoint-scope fall-through or structured-binding patterns to a portable suite: GCC and Clang differ there.

Estimated rating: ~1400 · small RAII test fixture; the hard part is predicting each count correctly

CodeWrite a fluent RequestBuilder that supports both RequestBuilder b; b.url(...).header(...); Request r = b.build(); (builder reusable, so build copies) and Request r = RequestBuilder().url(...).header(...).build(); (temporary builder, zero copies of the request). Prove the second chain does not copy the Request.

Answer

Approach. Overload on the builder's value category with ref-qualifiers. Setters on an lvalue builder return RequestBuilder&; setters on an rvalue builder return RequestBuilder&& so the chain stays an rvalue. build() const & returns a copy of the stored request; build() && returns std::move(req_), which is necessary because req_ is a member and would otherwise be copied. A counter in Request checks the result.

#include <cstdio>
#include <string>
#include <utility>
#include <vector>

static int req_copies = 0, req_moves = 0;

struct Request {
    std::string url;
    std::vector<std::string> headers;
    Request() = default;
    Request(const Request& o) : url(o.url), headers(o.headers) { ++req_copies; }
    Request(Request&& o) noexcept : url(std::move(o.url)), headers(std::move(o.headers)) { ++req_moves; }
};

class RequestBuilder {
public:
    RequestBuilder& url(std::string u) & { req_.url = std::move(u); return *this; }
    RequestBuilder&& url(std::string u) && { req_.url = std::move(u); return std::move(*this); }
    RequestBuilder& header(std::string h) & { req_.headers.push_back(std::move(h)); return *this; }
    RequestBuilder&& header(std::string h) && { req_.headers.push_back(std::move(h)); return std::move(*this); }

    Request build() const & { return req_; }              // copy: the builder stays usable
    Request build() && { return std::move(req_); }        // member: std::move is required
private:
    Request req_;
};

int main() {
    Request a = RequestBuilder().url("https://x.test").header("A: 1").header("B: 2").build();
    std::printf("temp builder: %s headers=%zu copies=%d moves=%d\n",
                a.url.c_str(), a.headers.size(), req_copies, req_moves);

    req_copies = req_moves = 0;
    RequestBuilder b;
    b.url("https://y.test").header("C: 3");
    Request r1 = b.build();
    Request r2 = b.header("D: 4").build();
    std::printf("reused builder: %zu %zu copies=%d moves=%d\n",
                r1.headers.size(), r2.headers.size(), req_copies, req_moves);
}
temp builder: https://x.test headers=2 copies=0 moves=1
reused builder: 1 2 copies=2 moves=0

Complexity and pitfalls. The temporary chain costs one Request move (a few pointer writes) and no copies; the move cannot be elided because the source is a member, not a local. Returning RequestBuilder&& from setters means auto&& b = RequestBuilder().url(...); dangles after the statement; document that the temporary chain must end in build(). Returning RequestBuilder by value instead is safer but moves the whole builder at each step.

Estimated rating: ~1700 · ref-qualified overloads and knowing that members need an explicit std::move

CodeWrite partition_by_sign(const std::vector<Item>&) that returns two lists, the negatives and the non-negatives, as one object, with zero copies of the result object and no copies of Item beyond the one per input element. Compare it with a version that builds two locals and returns {neg, pos}, and show the counts.

Answer

Approach. return {neg, pos}; list-initialises the result from two lvalues, which copies both vectors (and every Item in them). The implicit move does not apply because the operand is a braced list, not a name. Two good fixes: return {std::move(neg), std::move(pos)};, or (better) declare the result struct as the single local, fill its members directly, and return it by name so NRVO applies.

#include <cstdio>
#include <utility>
#include <vector>

static int item_copies = 0;

struct Item {
    int v;
    explicit Item(int x) : v(x) {}
    Item(const Item& o) : v(o.v) { ++item_copies; }
    Item(Item&&) noexcept = default;
    Item& operator=(const Item&) = default;
    Item& operator=(Item&&) noexcept = default;
};

struct Split {
    std::vector<Item> neg, pos;
};

static Split split_copying(const std::vector<Item>& in) {
    std::vector<Item> neg, pos;
    for (const Item& i : in) (i.v < 0 ? neg : pos).push_back(i);
    return {neg, pos};                         // copies both vectors
}

static Split split_nrvo(const std::vector<Item>& in) {
    Split out;
    for (const Item& i : in) (i.v < 0 ? out.neg : out.pos).push_back(i);
    return out;                                // single local: NRVO
}

int main() {
    std::vector<Item> in;
    for (int x : {-3, 5, -1, 0, 7}) in.emplace_back(x);

    item_copies = 0;
    Split a = split_copying(in);
    std::printf("copying: neg=%zu pos=%zu item copies=%d\n", a.neg.size(), a.pos.size(), item_copies);

    item_copies = 0;
    Split b = split_nrvo(in);
    std::printf("nrvo:    neg=%zu pos=%zu item copies=%d\n", b.neg.size(), b.pos.size(), item_copies);
}
copying: neg=2 pos=3 item copies=10
nrvo:    neg=2 pos=3 item copies=5

Complexity and pitfalls. O(n) both ways, but the copying version doubles the element copies (5 to fill the locals plus 5 to copy them into the result). Vector growth inside push_back moves items rather than copying them because the move constructor is noexcept; without noexcept, reallocation would copy and the counts would rise (Ch 20). Call reserve if you know the sizes.

Estimated rating: ~1500 · spotting that a braced return of lvalues copies, and fixing it with a single named result

CodeWrite choose(bool prefer_fast, ...) that computes two candidate plans of a move-only type Plan (it owns a std::unique_ptr) and returns the better one without copying. Then write a variant that returns std::unique_ptr<PlanBase> from a local std::unique_ptr<FastPlan>, with no std::move in any return statement.

Answer

Approach. Both candidates must exist before choosing, so NRVO is out. Because Plan is move-only, any copy would be a compile error, which proves there are none; the implicit move on return a; / return b; moves the winner. The second variant relies on the converting move constructor of unique_ptr being picked by the implicit move. A counter records the moves.

#include <cstdio>
#include <memory>
#include <string>
#include <utility>

static int plan_moves = 0;

class Plan {
public:
    Plan(std::string name, int cost) : name_(std::move(name)), cost_(std::make_unique<int>(cost)) {}
    Plan(Plan&& o) noexcept : name_(std::move(o.name_)), cost_(std::move(o.cost_)) { ++plan_moves; }
    Plan(const Plan&) = delete;
    int cost() const { return *cost_; }
    const std::string& name() const { return name_; }
private:
    std::string name_;
    std::unique_ptr<int> cost_;
};

static Plan choose(bool prefer_fast, int n) {
    Plan fast("fast", n * 2);
    Plan cheap("cheap", n + 10);
    if (prefer_fast || fast.cost() <= cheap.cost()) return fast;   // implicit move
    return cheap;                                                  // implicit move
}

struct PlanBase { virtual ~PlanBase() = default; virtual const char* kind() const = 0; };
struct FastPlan : PlanBase { const char* kind() const override { return "FastPlan"; } };

static std::unique_ptr<PlanBase> make_plan() {
    auto p = std::make_unique<FastPlan>();
    return p;                                                      // unique_ptr<FastPlan> -> unique_ptr<PlanBase>
}

int main() {
    Plan a = choose(false, 20);
    Plan b = choose(true, 20);
    std::printf("%s %d, %s %d, moves=%d\n", a.name().c_str(), a.cost(), b.name().c_str(), b.cost(), plan_moves);
    std::unique_ptr<PlanBase> p = make_plan();
    std::printf("%s\n", p->kind());
}
cheap 30, fast 40, moves=2
FastPlan

Complexity and pitfalls. One move per call, which for Plan is two pointer-sized moves. Writing return prefer_fast ? fast : cheap; would not compile: the conditional is an lvalue, so it needs the deleted copy constructor. Do not make the candidates const, or the implicit move cannot bind and the code again needs a copy.

Estimated rating: ~1300 · using a move-only type as a compile-time proof that the return path never copies

Cheat sheet

Theory
  • RVO (return T(...);) is guaranteed in C++17; NRVO (T r; return r;) is optional and needs a usable copy or move constructor.
  • NRVO needs the plain name of a non-parameter, non-volatile automatic local of the return type (cv ignored). Portable recipe: one result local, returned by name on every path.
  • Compilers differ: two live locals give a move on both; with locals in disjoint scopes Clang elides every one, GCC only the first returned; structured bindings are copied by Clang and moved by GCC (C++20 and later).
  • If NRVO fails, a returned local, parameter, or (C++20) rvalue-reference variable is moved, including through conversions (unique_ptr<Derived> to unique_ptr<Base>, T to optional<T>). Members, globals, references and ?: expressions are copied.
  • Never return std::move(local) (-Wpessimizing-move) or std::move(param) (-Wredundant-move). Do std::move members, *this and structured bindings.
  • const locals keep NRVO but turn the fallback into a copy; const return types make assignments copy. Avoid both for returned values.
  • C++23 (P2266): a returned movable name is an xvalue, so T&& f(T&& x) { return x; } compiles and T& f(T&& x) { return x; } breaks.
  • GCC and Clang apply NRVO at -O0; -fno-elide-constructors disables it. Check claims with a tracing type on both compilers.

Part G · Mixed practice · Chapter 25

Miscellaneous tricky questions

Real interviews rarely stay inside one topic. A question about a lambda turns into a question about thread lifetime, a framing bug turns into one about struct padding, and a constructor reads a parameter it has already moved from. This chapter is a bank of those cross-topic traps, grouped by theme, with every program compiled on GCC and Clang and every bug proven by a sanitizer where one can see it.

DrillsCoding· about 5 hours· prerequisites: chapters 1 to 24, or the confidence to be caught out

OOP and object model traps

Drills

These questions look like inheritance trivia, but each one hides a rule about object layout, construction phases or name lookup. Background is in Ch 1, Ch 2 and Ch 3.

Find the bugEach feed registers a tick callback in its constructor. After the second emplace_back, the first feed's callback misbehaves. Why?

#include <cstdio>
#include <functional>
#include <string>
#include <utility>
#include <vector>

class Feed {
public:
    explicit Feed(std::string name) : name_(std::move(name)) {
        on_tick = [this](double px) { std::printf("tick %.2f for a %zu-char feed\n", px, name_.size()); };
    }
    std::function<void(double)> on_tick;

private:
    std::string name_;
};

int main() {
    std::vector<Feed> feeds;
    feeds.emplace_back("XNAS-primary-equities-feed");
    feeds.emplace_back("XLON-primary-equities-feed");  // grows: feeds[0] is moved
    feeds[0].on_tick(101.25);
}
Answer

Answer. The lambda captures this, the address of the object under construction. When the vector grows from capacity 1 to 2, it move-constructs feeds[0] into the new buffer and frees the old one. The implicit move constructor moves on_tick as a value, so the new object's callback still holds the old address, and calling it reads name_ from freed memory. ASan reports heap-use-after-free. Without ASan it prints a plausible line, here a 0-character name, purely by luck.

Why. Implicit copy and move copy member values, and a value that points back into the object itself (a captured this, a self-pointer, an iterator into one of its own members) is stale in the copy. The same happens on any relocation: std::sort, std::swap, or returning the object without elision. The usual wrong answer blames std::function, which copied the lambda correctly. Fixes: pass the object to the callback instead of capturing it (std::function<void(Feed&, double)>); or make the class non-copyable and non-movable and keep it in a container that never relocates (std::deque, std::list, std::vector<std::unique_ptr<Feed>>); or rebind the callback in hand-written copy and move constructors. Capturing this in a thread or timer callback has the same lifetime problem (Ch 4).

OutputA non-virtual helper calls a virtual function. What does each phase print?

#include <cstdio>

struct Base {
    Base() { hello("ctor"); }
    virtual ~Base() { hello("dtor"); }
    void hello(const char* when) const { std::printf("%s: %s\n", when, name()); }
    virtual const char* name() const { return "Base"; }
};

struct Derived : Base {
    Derived() { hello("Derived ctor"); }
    ~Derived() override { hello("Derived dtor"); }
    const char* name() const override { return "Derived"; }
};

int main() {
    Derived d;
    d.hello("main");
}
Answer
ctor: Base
Derived ctor: Derived
main: Derived
Derived dtor: Derived
dtor: Base

Why. During Base's constructor and destructor the object's dynamic type is Base ([class.cdtor]). The implementation makes this real by storing Base's vptr while the base constructor runs, switching to Derived's vptr once Derived's constructor starts, and switching back when ~Derived finishes. Calling through a helper does not get around it, because the helper reads the same vptr.

The common wrong answer is "ctor: Derived", from candidates who know Java or C#, where the override runs before the derived fields are initialised. C++ refuses to call into a derived part that does not exist yet (or no longer exists). If a base needs derived-specific setup, pass the data to the base constructor or use a two-phase factory.

OutputPredict all four sizes on a 64-bit Linux or macOS target. Two of them surprise most candidates.

#include <cstdio>

struct Plain  { long long x; char a; };
struct PlainD : Plain { char c; };

struct Poly  { virtual void f() {} char a; };
struct PolyD : Poly { char c; };

int main() {
    std::printf("Plain=%zu PlainD=%zu Poly=%zu PolyD=%zu\n",
                sizeof(Plain), sizeof(PlainD), sizeof(Poly), sizeof(PolyD));
}
Answer
Plain=16 PlainD=24 Poly=16 PolyD=16

Why. Plain is 8 + 1 bytes padded to 16, and Poly is an 8-byte vptr plus one char, also padded to 16. Both have 7 bytes of tail padding. The difference is what a derived class may do with those bytes. The Itanium C++ ABI (used by GCC and Clang on Linux and macOS) never reuses the tail padding of a base that is a POD in the C++03 sense, so PlainD::c goes at offset 16 and the class grows to 24. Poly is polymorphic and therefore not a POD, so its tail padding is fair game: PolyD::c sits at offset 9 and the size stays 16.

The rule exists because C code may memcpy a whole POD, padding included, which would overwrite a derived member living in that padding. The practical lesson: never memcpy or memset a base subobject with sizeof(Base), because for non-POD bases the derived class may own those bytes. MSVC uses a different ABI and gives different numbers, so say "on Itanium" when you answer. See Ch 1 for basic padding rules.

PlainD24 bytes long long x a Plain tail pad (locked) c pad PolyD16 bytes vptr a c pad 081624 Each 24px cell is one byte. Non-POD bases let the derived class move into their tail padding.
Tail padding reuse depends on POD-ness. The same 7 spare bytes are off limits in Plain and available in Poly, so adding a char costs 8 bytes in one hierarchy and nothing in the other.

OutputA C library takes a callback and a void* context, and the strategy registers itself. What do the first two lines print on a 64-bit GCC or Clang target, and why would the callback crash without the second c_register?

#include <cstdio>

struct Logger {
    virtual ~Logger() = default;
    int level = 1;
};

struct Listener {
    virtual ~Listener() = default;
    virtual void on_event(int e) = 0;
};

struct Strategy : Logger, Listener {
    void on_event(int e) override { std::printf("event %d\n", e); }
};

// A C library stores a callback and an opaque context pointer.
static void (*g_cb)(void*, int);
static void* g_ctx;
static void c_register(void (*cb)(void*, int), void* ctx) { g_cb = cb; g_ctx = ctx; }

static void trampoline(void* ctx, int e) { static_cast<Listener*>(ctx)->on_event(e); }

int main() {
    Strategy s;
    c_register(trampoline, &s);  // Strategy* converted straight to void*
    Listener* good = &s;
    Listener* bad = static_cast<Listener*>(g_ctx);
    std::printf("offset of Listener: %td\n", reinterpret_cast<char*>(good) - reinterpret_cast<char*>(&s));
    std::printf("round trip correct: %d\n", bad == good);
    c_register(trampoline, static_cast<Listener*>(&s));  // the fix
    g_cb(g_ctx, 7);
}
Answer
offset of Listener: 16
round trip correct: 0
event 7

Why. Converting Strategy* to Listener* adjusts the address to the Listener subobject, which follows the Logger part (a vptr plus an int, padded to 16 bytes). Converting to void* does no adjustment: it is the address of the whole object, which is also the address of its Logger part. static_cast<Listener*>(void*) cannot know where the pointer came from, so bad points at the Logger subobject. Calling on_event through it loads Logger's vptr and calls whatever sits in that slot of Logger's vtable: undefined behaviour, in practice a crash or a call to the wrong function.

The rule: a round trip through void* is valid only when you cast back to exactly the type you converted from. So convert to the type the callback expects before erasing it, as the last c_register does. The wrong answer is "static_cast fixes up base offsets": it does so only between related class types, never from void*. With single inheritance the offset is 0 and the bug is invisible, so it appears the day someone adds a base class. The same applies to the arg of pthread_create, epoll_event.data.ptr and every C "user data" pointer.

TrickyTwo processes map the same shared memory region. The writer constructs a Quote there with placement new. Quote has a virtual price() and a std::string venue. The reader casts the region to Quote* and calls price(). What happens?

Answer

Answer. Undefined behaviour, and in practice a crash or garbage. The object contains pointers that only mean something in the writer: the vptr (the address of Quote's vtable in the writer's mapping of the executable) and the string's data pointer (into the writer's heap, or with SSO into the object at the writer's mapping address). With ASLR, the reader maps the executable and the shared region at different addresses, so the virtual call jumps through a wrong address and reading venue dereferences someone else's heap. It "works" when the reader is a forked child of the writer, or ASLR is off, which is exactly how such code passes its tests.

Why. std::is_trivially_copyable_v<Quote> is false: a class with virtual functions never has a trivial copy constructor, and that is the language telling you its bytes are not a self-contained value. Shared memory, files and network buffers need trivially copyable types without pointers: integers, fixed-size char arrays, offsets instead of pointers (as Boost.Interprocess's offset_ptr does), and an enum tag instead of a vptr, with each process dispatching on the tag itself. The reader also never created an object: for implicit-lifetime types, C++23's std::start_lifetime_as is the sanctioned way to say "treat these bytes as a T". A mutex or atomic placed in shared memory has its own rules (PTHREAD_PROCESS_SHARED, lock-free atomics only, Ch 5).

OutputA dispatch table stores pointers to member functions. Is a call through &Venue::name virtual? And how big is the pointer?

#include <cstdio>

struct Venue {
    virtual ~Venue() = default;
    virtual const char* name() const { return "venue"; }
};

struct Nasdaq : Venue {
    const char* name() const override { return "nasdaq"; }
};

int main() {
    Nasdaq n;
    const Venue& v = n;
    const char* (Venue::*pick)() const = &Venue::name;
    std::printf("%s\n", (v.*pick)());
    std::printf("%s\n", v.Venue::name());
    std::printf("%zu\n", sizeof(pick));
}
Answer
nasdaq
venue
16

Why. A pointer to a virtual member function does not store an address of Venue::name. It stores "call vtable slot k", so calling it through any object dispatches virtually and reaches Nasdaq::name. The qualified call v.Venue::name() is the one way to suppress virtual dispatch, and it is how an override calls the base version. On the Itanium ABI a member function pointer is two words: a function address or vtable offset, and a this adjustment needed for multiple inheritance. On x86-64 the low bit of the first word marks the virtual case; ARM64 cannot use that bit, so it moves the flag into the adjustment word. That is why it is 16 bytes, cannot be cast to void*, and why std::function or a lambda is usually a simpler table entry. MSVC uses sizes from 8 to 24 bytes depending on the inheritance model.

Find the bugThe program uses a smart pointer, so it must be safe. What does it print, and is it correct?

#include <cstdio>
#include <memory>
#include <string>

struct Message {
    ~Message() { std::puts("~Message"); }
};

struct Quote : Message {
    std::string symbol = "a symbol long enough to live on the heap";
    ~Quote() { std::puts("~Quote"); }
};

int main() {
    std::unique_ptr<Message> m = std::make_unique<Quote>();
}
Answer

Answer. It typically prints only ~Message, and the behaviour is undefined. unique_ptr<Message> uses default_delete<Message>, which runs delete on a Message*. Deleting a derived object through a base pointer whose destructor is not virtual is undefined ([expr.delete]). In practice ~Quote never runs and the heap buffer of symbol leaks.

Fix. Give Message a virtual destructor, or a protected non-virtual one so that deletion through the base does not compile. Two traps live here: the converting constructor from unique_ptr<Quote> compiles without complaint, and GCC's -Wdelete-non-virtual-dtor stays quiet because Message has no virtual functions at all. shared_ptr behaves differently, because it captures the deleter when the first owner is created (Ch 11).

Find the bugWhy does this clone() override fail to compile, and what is the idiomatic fix?

#include <memory>

struct Shape {
    virtual ~Shape() = default;
    virtual std::unique_ptr<Shape> clone() const = 0;
};

struct Circle : Shape {
    std::unique_ptr<Circle> clone() const override { return std::make_unique<Circle>(*this); }
};

int main() {
    Circle c;
    auto p = c.clone();
    return p ? 0 : 1;
}
Answer

Answer. Covariant return types only work for raw pointers and references to classes ([class.virtual]). unique_ptr<Circle> and unique_ptr<Shape> are unrelated class types, even though one converts to the other, so the override's return type is invalid.

Fix. Return std::unique_ptr<Shape> from every override, which is enough in most code. If callers need the precise type, keep a private virtual Circle* clone_impl() const (raw pointers are covariant) and give each class a public non-virtual clone() that wraps it in the right unique_ptr. Many candidates think templates or auto can make the override work. They cannot, because virtual functions cannot be templates and an override cannot have a deduced return type.

Memory and allocation traps

Drills

Lifetime, ownership and the gap between "allocated" and "constructed". Background is in Ch 9, Ch 11 and Ch 14.

OutputHow big is each closure on a 64-bit GCC or Clang target? What does the size of by_ref tell you?

#include <cstdio>
#include <string>

int main() {
    int qty = 100, side = 1;
    double px = 101.25;
    std::string venue = "XNAS";
    auto none = [] { return 0; };
    auto by_ref = [&qty, &side] { return qty * side; };
    auto by_val = [qty, side] { return qty * side; };
    auto mixed = [side, px] { return side * px; };
    auto owns = [venue] { return venue.size(); };
    std::printf("none=%zu by_ref=%zu by_val=%zu mixed=%zu owns_string=%d\n", sizeof none, sizeof by_ref,
                sizeof by_val, sizeof mixed, sizeof owns == sizeof(std::string));
    return none() + by_ref() + by_val() + static_cast<int>(mixed() + owns()) > 0 ? 0 : 1;
}
Answer
none=1 by_ref=16 by_val=8 mixed=16 owns_string=1

Why. A lambda is an unnamed class with one data member per captured entity ([expr.prim.lambda.capture]). With no captures it is an empty class of size 1, because every object needs a distinct address. Two ints take 8 bytes, and int plus double is padded to 16 by alignment. A by-value std::string capture holds a whole std::string (32 bytes in libstdc++ and 24 in libc++, which is why the program compares instead of printing). The layout of reference captures is unspecified, but GCC and Clang store one pointer per reference, so by_ref is two pointers. The wrong answers are "0, references are free" and "8, the size of two ints".

Two consequences. A by-reference closure is a bundle of pointers into the current stack frame, so storing it beyond the frame (in a std::function, a thread or a callback list) is the returned-pointer-to-a-local bug in disguise. And size decides allocation: libstdc++'s std::function stores a closure inline only if it is trivially copyable and at most 16 bytes, and libc++ allows somewhat larger nothrow-movable closures. Anything bigger is heap-allocated on construction and on every copy (Ch 18).

Find the bugA hot path duplicates quotes with memcpy "because it is faster than the copy constructor". It prints the right line. What happens next?

#include <cstdio>
#include <cstring>
#include <string>

struct Quote {
    std::string symbol;
    double px = 0;
};

int main() {
    Quote a{"a symbol name long enough to need the heap", 101.25};
    Quote b;
    std::memcpy(&b, &a, sizeof a);
    std::printf("%s %.2f\n", b.symbol.c_str(), b.px);
}
Answer

Answer. A double free when main returns. memcpy copied the string object's bytes, including the pointer to its heap buffer, so a.symbol and b.symbol both own the same buffer and both destructors free it. ASan reports attempting double-free. The print is correct, which is why the bug survives code review.

Why. memcpy and memset are valid only for trivially copyable types ([basic.types.general]). Quote has a std::string member, so its copy is a real function that allocates. With a short symbol the bug changes shape: libstdc++ strings in SSO mode point at their own inline buffer, so b.symbol would point into a and break as soon as a changes or dies. GCC warns with -Wclass-memaccess, and the warning is right. If the copy is genuinely hot, make the type trivially copyable: store the symbol as a fixed char[8] or a small integer id, then static_assert(std::is_trivially_copyable_v<Quote>), and plain assignment compiles to the same memcpy anyway.

OutputThe same three lines are written for vector<bool> and for vector<int>. What prints?

#include <cstdio>
#include <vector>

int main() {
    std::vector<bool> flags{true, true};
    auto f = flags[0];
    flags[0] = false;

    std::vector<int> nums{1, 1};
    auto n = nums[0];
    nums[0] = 0;

    std::printf("%d %d\n", static_cast<int>(f), n);
}
Answer
0 1

Why. vector<bool> is a specialisation that packs bits, so operator[] cannot return bool&. It returns a proxy object, vector<bool>::reference, that holds a word pointer and a bit mask. auto f deduces that proxy type, so f still refers to bit 0 and sees the later write. For vector<int>, auto n is a plain int copy.

Other consequences: bool* p = &flags[0]; does not compile, flags.data() does not exist, and two threads writing different elements race because they share a word (unlike every other vector<T>). Write bool f = flags[0]; to get a value. If you need real bools, use std::vector<char>, std::deque<bool> or std::unique_ptr<bool[]>.

OutputA buffer is passed to a function that "takes an array of 16 ints". What are the three sizes?

#include <cstddef>
#include <cstdio>

static void fill(int buf[16]) { std::printf("inside %zu\n", sizeof(buf)); }

template <std::size_t N>
static void fill_ref(int (&buf)[N]) { std::printf("ref %zu\n", sizeof(buf)); }

int main() {
    int data[16] = {};
    std::printf("outside %zu\n", sizeof(data));
    fill(data);
    fill_ref(data);
}
Answer
outside 64
inside 8
ref 64

Why. A function parameter declared as an array is adjusted to a pointer ([dcl.fct]), and the 16 is ignored completely. Inside fill, buf is an int*, so sizeof gives the pointer size. Passing a reference to an array keeps the type and lets a template deduce N. In networking code this bug looks like recv(fd, buf, sizeof buf, 0) inside a helper, which silently reads 8 bytes at a time. Both compilers warn (-Wsizeof-array-argument). Pass std::span<int> to carry the size explicitly.

TrickyA host loads a pricing plugin with dlopen, gets a std::unique_ptr<Pricer> from its factory, and unloads the plugin with dlclose during reconfiguration. The process crashes later, when the unique_ptr goes out of scope. Why there?

Answer

Answer. The object outlives the code that defines it. Its vptr points at a vtable inside the plugin, and its virtual destructor is code inside the plugin. Once dlclose unmaps the library, deleting the object jumps into unmapped memory (a SIGSEGV) or into whatever was mapped there since. Any function pointer, std::function target, string literal or type_info that came from the plugin dangles in the same way. dlclose has also run the plugin's static destructors, so plugin globals the object relies on are gone.

Why. Code must outlive every object that uses it, so the library handle needs an owner too. Fixes: return std::shared_ptr<Pricer> whose deleter captures a shared_ptr to the library handle (the library is closed by the last object's deleter); destroy every plugin object before calling dlclose; or never unload (RTLD_NODELETE, which many production systems choose). The crash can depend on the build, because glibc may decline to unmap a library at all, for example when it defines STB_GNU_UNIQUE symbols, which GCC emits for some inline and template statics. The wrong answer blames unique_ptr: any owner would crash, because the bug is the order of two lifetimes.

OutputOrder has a class-specific operator new that counts pool allocations. Which of the three factories go through it?

#include <cstdio>
#include <memory>
#include <new>

struct Order {
    static inline int pool_allocs = 0;
    long id = 0;
    static void* operator new(std::size_t n) {
        ++pool_allocs;
        return ::operator new(n);
    }
    static void operator delete(void* p) { ::operator delete(p); }
};

int main() {
    std::unique_ptr<Order> a(new Order);
    std::printf("new: %d\n", Order::pool_allocs);
    auto b = std::make_unique<Order>();
    std::printf("make_unique: %d\n", Order::pool_allocs);
    auto c = std::make_shared<Order>();
    std::printf("make_shared: %d\n", Order::pool_allocs);
}
Answer
new: 1
make_unique: 2
make_shared: 2

Why. make_unique<Order>() is defined as unique_ptr<Order>(new Order()), so the class-specific operator new is found by the new-expression. make_shared needs one block that holds the control block and the object, so it allocates that block through std::allocator (which calls ::operator new) and constructs Order inside it with placement new. The class's allocator is bypassed completely. In a trading system that relies on class-level pools to avoid the global heap on the hot path, a single make_shared quietly reintroduces malloc.

Fixes: use std::allocate_shared with an allocator that draws from the same pool (it must allocate the combined block, so its size is larger than sizeof(Order)), or use std::shared_ptr<Order>(new Order) and accept the separate control block allocation, which still goes to the global heap. Pool design is covered in Ch 15.

TrickyLegacy code does auto* p = static_cast<Order*>(std::malloc(sizeof(Order))); p->symbol = "AAPL"; where Order has a std::string member. It "works" in testing. What is actually happening?

Answer

Answer. Undefined behaviour. malloc returns raw bytes, and no Order or std::string object exists there. p->symbol = "AAPL" calls string::operator= on garbage: it reads an uninitialised data pointer and size, and may free or write through a random address. It seems to work when the bytes happen to look like an empty short string.

Why. Allocation and construction are separate steps. new does both, malloc does only the first. The fix is placement new, Order* p = new (mem) Order;, and later p->~Order(); std::free(mem); (Ch 14). C++20 lets malloc implicitly create objects of implicit-lifetime types (trivial types such as int arrays or plain structs, P0593), which is why the same code is fine for a struct of ints. A class with a std::string member is not implicit-lifetime.

Move semantics, forwarding and elision traps

Drills

Most of these programs compile cleanly and silently copy. Background is in Ch 20 to Ch 24.

Find the bugThe constructor moves the symbol into the member and records its width for column alignment. Every width prints as 0. Why, and why does swapping the two initialisers in the list not help?

#include <cstdio>
#include <string>
#include <utility>

class Instrument {
public:
    explicit Instrument(std::string symbol)
        : symbol_(std::move(symbol)), width_(symbol.size()) {}
    void show() const { std::printf("symbol=%s width=%zu\n", symbol_.c_str(), width_); }

private:
    std::string symbol_;
    std::size_t width_;
};

int main() {
    Instrument("VOD.L").show();
    Instrument("BRK.A-long-composite-symbol-name").show();
}
Answer
symbol=VOD.L width=0
symbol=BRK.A-long-composite-symbol-name width=0

Answer. Members are initialised in declaration order, so symbol_ comes first, and its initialiser moves from the parameter. When width_(symbol.size()) runs, symbol is a moved-from string. That is "valid but unspecified", and both libstdc++ and libc++ leave it empty, for short (SSO) and long strings alike. Reordering the mem-initialisers in the source changes nothing, because the order is fixed by the member declarations ([class.base.init]). GCC and Clang warn with -Wreorder when the two orders differ, which is the only hint you get.

Fix. Read from the member, which already exists: width_(symbol_.size()), correct only because symbol_ is declared first. Or compute from the parameter before any move, or move in the constructor body. The rule: once a parameter appears inside std::move in an initialiser list, it must not appear again later in that list. clang-tidy's bugprone-use-after-move catches it. The same declaration-order rule decides when a std::thread member starts running relative to the members it uses.

Trickystd::vector<std::atomic<int>> slots(8); compiles and works as a table of per-venue counters. Someone adds slots.resize(16) and the build breaks. Why does one compile and not the other, and what are the options?

#include <atomic>
#include <deque>
#include <vector>

int main() {
    std::vector<std::atomic<int>> slots(8);  // fine: elements built in place
    std::deque<std::atomic<int>> more;
    more.emplace_back(1);                     // fine: a deque never relocates
    slots.resize(16);                         // error: needs to move elements
    return slots[0] + more[0];
}
Answer

Answer. std::atomic can be neither copied nor moved. The size constructor only value-initialises elements in place, which atomic supports. resize, reserve, push_back, emplace_back and insert may reallocate, and reallocation relocates the existing elements by moving or copying them, so these members require the element type to be MoveInsertable. The check is at compile time, so the build fails even if the capacity would have sufficed. std::mutex, std::condition_variable and any class holding one behave the same way.

Why. It is also the right outcome for threads: relocating an atomic while other threads use it can never be made safe. Options: size the table once and never grow it; use std::deque<std::atomic<int>> with emplace_back, which only constructs in place because a deque never relocates existing elements; use std::unique_ptr<std::atomic<int>[]>; or wrap each atomic in a struct with a copy constructor that loads and stores, which is safe only while no other thread is touching the table. For per-thread counters, also pad each slot to a cache line (Ch 19). The wrong answer is "atomic has no default constructor": it has one, and since C++20 it value-initialises the value (P0883).

OutputWhich overload does each call pick?

#include <cstdio>
#include <string>
#include <utility>

static void take(const std::string&) { std::puts("lvalue"); }
static void take(std::string&&) { std::puts("rvalue"); }

int main() {
    std::string&& r = std::string("tmp");
    take(r);
    take(std::move(r));
    take(static_cast<decltype(r)>(r));
    auto&& fwd = r;
    take(std::forward<decltype(fwd)>(fwd));
}
Answer
lvalue
rvalue
rvalue
lvalue

Why. A named variable is an lvalue whatever its declared type, so take(r) picks the const& overload even though r is an rvalue reference. decltype(r) is std::string&&, so the cast produces an xvalue, which is all std::move does. auto&& fwd = r binds a forwarding reference to the lvalue r, so fwd's type is std::string& and std::forward returns an lvalue. The answer "rvalue" for the last line comes from forgetting that forwarding preserves the category of the initialiser, and r as an expression is an lvalue.

OutputOrders move from a pending std::set to a send queue, first with std::move on the element and then through extract. Count the copies and moves.

#include <cstdio>
#include <iterator>
#include <set>
#include <string>
#include <utility>
#include <vector>

struct Order {
    static inline int copies = 0, moves = 0;
    std::string id;
    explicit Order(const char* s) : id(s) {}
    Order(const Order& o) : id(o.id) { ++copies; }
    Order(Order&& o) noexcept : id(std::move(o.id)) { ++moves; }
    bool operator<(const Order& o) const { return id < o.id; }
};

int main() {
    std::set<Order> pending;
    pending.emplace("ord-0001-with-a-long-client-tag");
    pending.emplace("ord-0002-with-a-long-client-tag");
    std::vector<Order> sent;
    sent.reserve(2);

    sent.push_back(std::move(*pending.begin()));
    std::printf("move from set element: copies=%d moves=%d\n", Order::copies, Order::moves);

    auto node = pending.extract(std::next(pending.begin()));
    sent.push_back(std::move(node.value()));
    std::printf("move from extracted node: copies=%d moves=%d\n", Order::copies, Order::moves);
    std::printf("set size=%zu\n", pending.size());
}
Answer
move from set element: copies=1 moves=0
move from extracted node: copies=1 moves=1
set size=1

Why. Elements of a std::set (and keys of a std::map) are const, because changing one in place would break the tree's ordering. *pending.begin() is a const Order&, std::move turns it into const Order&&, and that binds only to the copy constructor. So the first "move" is a silent copy, and the element also stays in the set. C++17's extract unlinks the node and returns a node handle that owns it, and node.value() gives non-const access, so the second move really moves. The common wrong answer is "copies=0 moves=2": std::move never casts away const.

Node handles have two more uses interviewers like: other.insert(std::move(node)) transfers an element between sets or maps without allocating, and auto nh = m.extract(k); nh.key() = k2; m.insert(std::move(nh)); changes a map key in place. The same trap appears in for (auto& [k, v] : m) out.push_back(std::move(k));, which copies every key while really moving every value.

Find the bugThe lambda owns the order through an init-capture and should hand it back. Why does it not compile?

#include <memory>
#include <utility>

int main() {
    auto order = std::make_unique<int>(42);
    auto task = [o = std::move(order)]() { return std::move(o); };
    auto back = task();
    return *back == 42 ? 0 : 1;
}
Answer

Answer. A lambda's operator() is const unless the lambda is mutable, so inside the body o is a const std::unique_ptr<int>. std::move(o) yields const unique_ptr&&, which can only bind to the deleted copy constructor. Both compilers report a call to a deleted function.

Fix. Write [o = std::move(order)]() mutable { return std::move(o); }. This is the lambda version of the const-member trap, where a move silently becomes a copy (Ch 20). With a copyable type such as std::string it would compile and silently copy instead of failing, which is worse. A lambda holding a unique_ptr is move-only, so it cannot be stored in std::function. Use C++23 std::move_only_function or a template parameter.

ConceptWhat does std::swap(a, b) cost for a type with a copy constructor and no move constructor, and why do libraries provide a member swap or a hidden friend?

Answer

Answer. The generic std::swap is T tmp = std::move(a); a = std::move(b); b = std::move(tmp);. With no move operations, all three steps copy, so swapping two large buffers costs three deep copies and two allocations. A type-specific swap exchanges the internal pointers in O(1) and cannot throw.

Why. Generic code calls using std::swap; swap(a, b); so argument-dependent lookup finds a better overload in the type's namespace (a hidden friend is found only that way, which keeps overload sets small). A noexcept swap is the building block for copy-and-swap assignment and for the strong exception guarantee. With a proper noexcept move constructor and move assignment, the generic version is already cheap, and a custom swap only saves a few pointer writes.

Threads and locks traps

Drills

Ownership questions turn into concurrency questions as soon as a second thread can see the object. Background is in Ch 4, Ch 5 and Ch 6.

Find the bugEvery access to status_ holds the mutex, yet ThreadSanitizer reports a race. Where is the unprotected access?

#include <cstdio>
#include <mutex>
#include <string>
#include <thread>

class Session {
public:
    const std::string& status() const {
        std::lock_guard<std::mutex> g(m_);
        return status_;
    }
    void set_status(std::string s) {
        std::lock_guard<std::mutex> g(m_);
        status_ = std::move(s);
    }

private:
    mutable std::mutex m_;
    std::string status_ = "connecting";
};

int main() {
    Session s;
    std::thread writer([&] {
        for (int i = 0; i < 1000; ++i) s.set_status(i % 2 ? "logged-in-and-streaming-quotes" : "heartbeat-overdue-reconnecting");
    });
    std::size_t total = 0;
    for (int i = 0; i < 1000; ++i) total += s.status().size();
    writer.join();
    std::printf("%d\n", total > 0);
}
Answer

Answer. status() returns a reference, and its lock_guard is released when the function returns. The caller reads the string (here size(), in real code a copy or a log line) after the unlock, while the writer may be move-assigning a new value into it and freeing the old buffer. The read is unprotected: a data race, and with long strings a possible use after free. TSan reports the race between the move assignment and the read.

Fix. Return by value (std::string status() const copies under the lock), or offer with_status(f) that runs f under the lock, or publish immutable snapshots as std::shared_ptr<const std::string>. A lock protects the accesses made inside its scope, and references, pointers, iterators and string_views that escape the scope escape the protection with them. The wrong answer blames the mutable mutex, which is correct and necessary for a const getter. The Synchronized exercise below has the same pitfall.

TrickyThis function crashes rarely, and only in production. The shared_ptr is supposed to keep feed alive. What is wrong?

void start(std::shared_ptr<Feed> feed) {
    std::thread([&feed] { feed->run(); }).detach();
}
Answer

Answer. The lambda captures the parameter feed by reference. No copy of the shared_ptr is made, so the reference count never goes up. start returns, the parameter is destroyed, and the detached thread later reads a dangling reference to a dead shared_ptr object on a stack frame that has been reused. If that was the last owner, the Feed is gone as well.

Why. It works most of the time because the new thread usually reads feed before start returns. Capture by value, [feed] or [feed = std::move(feed)], so the thread owns a reference. The member-function version of this bug is [this] in a lambda given to a thread or a timer: capture self = shared_from_this() instead. Also question the detach(): a detached thread that touches globals can still be running while static destructors run after main returns.

Find the bugDouble-checked locking: the fast path avoids the lock and the slow path checks again under it. What is wrong?

#include <cstdio>
#include <mutex>
#include <thread>

struct Registry { int ready = 42; };

static Registry* instance = nullptr;
static std::mutex mtx;

static Registry* get() {
    if (!instance) {
        std::lock_guard<std::mutex> lock(mtx);
        if (!instance) instance = new Registry;
    }
    return instance;
}

int main() {
    int a = 0, b = 0;
    std::thread t1([&] { a = get()->ready; });
    std::thread t2([&] { b = get()->ready; });
    t1.join();
    t2.join();
    std::printf("%d %d\n", a, b);
}
Answer

Answer. The unlocked read of instance races with the locked write. A reader that sees a non-null pointer on the fast path never takes the mutex, so nothing orders its reads of *instance after the writer's construction of the Registry. The compiler or CPU may publish the pointer before the ready = 42 store, and on ARM or POWER the reader can see the pointer and still read a stale ready. TSan reports the race on instance.

Fix. The simplest correct singleton is a function-local static: static Registry r; return &r;. Since C++11 its initialisation is thread-safe, and compilers implement it with exactly this double-check, done correctly with an acquire load of a guard byte. If you need the pointer form, make instance a std::atomic<Registry*>, load it with memory_order_acquire and store it with memory_order_release, or use std::call_once. The coding section has the atomic version. Saying "x86 is strongly ordered, so it is fine" is wrong twice: the compiler may reorder the stores, and the race is undefined behaviour on every architecture.

TrickyA server keeps per-request state (the client id and a scratch buffer) in thread_local variables. It worked with one thread per connection. After a move to a fixed thread pool, logs sometimes show the wrong client id and memory keeps growing. What changed?

Answer

Answer. A thread_local lives as long as its thread, not as long as the task. With a thread per connection, the thread's exit reset everything. In a pool, each thread runs many unrelated requests, so a task that does not overwrite the id (an error path, an early return, an exception before the assignment) logs the previous client's id. Scratch buffers keep their peak capacity forever, in every pool thread, so one 100 MB request pins 100 MB per thread that ever served one.

Why. The variable is perfectly thread-safe, which is why the wrong answer "thread_local is not thread-safe" misses. The bug is scope. Tie request state to the request: pass a context object explicitly, or set it with an RAII guard at task start that restores it in its destructor, exceptions included. Shrink or cap scratch buffers after large requests. C++20 coroutines make thread_local request state actively wrong, because a coroutine may resume on a different thread after co_await. Pool threads usually live until shutdown, so their thread_local destructors run then, and leak checkers report the memory as still reachable rather than leaked.

TrickyA read-mostly cache uses std::shared_mutex. On a miss, the reader, still holding its std::shared_lock, calls lock() on the same mutex to insert the value. Under load the service hangs. Why, and what is the correct pattern?

Answer

Answer. std::shared_mutex has no upgrade operation. lock() waits until every shared owner has released, including the calling thread itself, which never will: a self-deadlock. The standard makes locking a mutex you already own undefined behaviour, and in practice it hangs. Even a real upgrade would deadlock two readers upgrading at the same time, each waiting for the other's shared hold, which is why Boost's upgrade_mutex allows only one upgradable owner at a time.

Why. The correct pattern: release the shared lock, take a std::unique_lock, check again (another thread may have inserted the value in the gap), then insert. Better still, compute the value with no lock held and insert it with try_emplace under the exclusive lock, keeping whichever copy arrived first. The wrong answer is "writer starvation": this hang has nothing to do with fairness and happens with a single thread. For very hot reads, remember that every lock_shared still writes the mutex's cache line, so snapshot publication (the Published<T> exercise in Ch 26) or RCU scales better.

OutputOrders are published to three venues "in parallel" with std::async, and the futures are discarded. Venue 0 is the slowest. In what order do the lines appear, and how long does the loop take?

#include <chrono>
#include <cstdio>
#include <future>
#include <thread>

static void publish(int venue) {
    std::this_thread::sleep_for(std::chrono::milliseconds(20 * (3 - venue)));
    std::printf("published to venue %d\n", venue);
}

int main() {
    for (int v = 0; v < 3; ++v)
        (void)std::async(std::launch::async, publish, v);  // "fire and forget"
    std::puts("all sent");
}
Answer
published to venue 0
published to venue 1
published to venue 2
all sent

Why. The future returned by std::async with launch::async blocks in its destructor until the task has finished ([futures.async]). The discarded temporary dies at the end of each full expression, so every iteration waits for its own task and the loop is sequential: 60 + 40 + 20 = 120 ms. If the tasks really ran in parallel, venue 2 (20 ms) would print first and the loop would take about 60 ms. The (void) cast silences the [[nodiscard]] warning that recent standard libraries attach to std::async, but not the blocking.

Fix: keep the futures in a vector and wait for them after the loop, or submit to a thread pool. Two more traps: with the default policy (launch::async | launch::deferred) the library may defer the task, which then runs only if someone calls get or wait; and only futures from std::async block in the destructor, while those from std::promise or std::packaged_task do not, so the same-looking code behaves differently.

OutputFour threads each do 100000 increments with memory_order_relaxed. Can increments be lost?

#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>

int main() {
    std::atomic<long> hits{0};
    std::vector<std::thread> ts;
    for (int i = 0; i < 4; ++i)
        ts.emplace_back([&hits] {
            for (int k = 0; k < 100000; ++k) hits.fetch_add(1, std::memory_order_relaxed);
        });
    for (auto& t : ts) t.join();
    std::printf("%ld\n", hits.load());
}
Answer
400000

Why. Atomicity and ordering are separate properties. Every atomic read-modify-write is indivisible and sees the latest value in the variable's modification order, whatever memory order you pass ([atomics.order]). relaxed only drops the guarantees about other memory locations, so it is the right choice for statistics counters. The final load() after join() is ordered by the joins. What relaxed cannot do is publish data: if a thread writes a buffer and then sets a relaxed flag, another thread can see the flag and stale buffer contents.

Networking traps

Drills

Socket bugs hide because loopback tests deliver everything at once. Background is in Ch 7 and Ch 8.

Find the bugFrames are a 4-byte big-endian length followed by the payload. The reader works on every local test. Here the sender's second segment is still in flight. What does the reader return?

#include <arpa/inet.h>
#include <cstdint>
#include <cstdio>
#include <string>
#include <sys/socket.h>
#include <unistd.h>

static std::string naive_read_frame(int fd) {
    uint32_t len_be = 0;
    recv(fd, &len_be, sizeof len_be, 0);
    uint32_t len = ntohl(len_be);
    std::string payload(len, '\0');
    ssize_t n = recv(fd, payload.data(), len, 0);
    std::printf("asked %u, got %zd\n", len, n);
    return payload;
}

int main() {
    int sv[2];
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) return 1;
    const char msg[] = "NEW AAPL 100@189.25";
    uint32_t len_be = htonl(sizeof msg - 1);
    send(sv[0], &len_be, sizeof len_be, 0);
    send(sv[0], msg, 8, 0);  // the rest has not arrived yet
    std::string got = naive_read_frame(sv[1]);
    std::printf("payload \"%.8s\", last byte %d\n", got.c_str(), got.back());
    close(sv[0]);
    close(sv[1]);
}
Answer
asked 19, got 8
payload "NEW AAPL", last byte 0

Answer. A blocking recv returns as soon as some data is available, up to the size you asked for. It got 8 of 19 bytes, the code ignored the count, and the frame was handed on with 11 zero bytes at the end. The next call would then parse " 100@189" as a length header, and the stream is desynchronised for good. The first recv of the header has the same bug: a 4-byte header can arrive as 1 + 3.

Why. TCP is a byte stream. Segment boundaries, the sender's send calls and the receiver's recv calls are unrelated, and loopback hides this because small writes arrive in one piece. Fix it with a read_exact loop that keeps calling recv until the requested count arrives, returns failure on 0 (peer closed mid-frame) and retries on EINTR. Ch 8 builds the full send_all/recv_exact pair and a framer on top of it. Also check len against a maximum before allocating, or a hostile peer can send 0xFFFFFFFF.

frames len 19 NEW AAPL 100@189.25 len 9 CXL 42 ok segments segment 1 segment 2 segment 3 recv calls recv 4 recv 19 → 8 frame cut here, 11 bytes still unread Boundaries in one row tell you nothing about the other rows. Only a length prefix (or a delimiter) defines a message.
TCP delivers bytes, not messages. The receiver must loop until it has the 4 header bytes, then loop again until it has exactly len payload bytes, whatever the segment and recv boundaries happen to be.

TrickyA gateway uses select and has run for months. A configuration change raises the number of client connections past about a thousand, and it starts crashing in unrelated code, or aborting with *** buffer overflow detected ***. Why?

Answer

Answer. An fd_set is a fixed bitmap of FD_SETSIZE bits, 1024 on glibc. FD_SET(fd, &set) with fd of 1024 or more writes past the end of the object, usually on the stack, and corrupts whatever lives next to it: an OS limit turning into memory corruption. When the program is built with _FORTIFY_SOURCE, glibc checks the descriptor inside FD_SET and FD_ISSET and aborts with that message, which is the kind outcome. Because descriptors are allocated lowest-free first, the first connection to get number 1024 triggers it, even if few of the others are still active.

Why. select cannot wait on a descriptor numbered 1024 or above at all, so the fix is poll or epoll, which take arrays or registrations with no limit on descriptor values (Ch 8). Raising ulimit -n is what exposes the bug, and redefining FD_SETSIZE is not supported by glibc. "select is slow with many descriptors" is true but does not explain a crash.

TrickyA client connects to a primary gateway whose address is firewalled, so packets are silently dropped. connect blocks for about two minutes and then fails with ETIMEDOUT. Connecting to a reachable host with nothing listening fails at once with ECONNREFUSED. Explain both, and how you bound the wait.

Answer

Answer. A reachable host with a closed port answers the SYN with RST, so connect fails within one round trip. A black-holed address answers nothing, so Linux retransmits the SYN with exponential backoff: an initial timeout of 1 s and net.ipv4.tcp_syn_retries = 6 retries give 1 + 2 + 4 + 8 + 16 + 32 + 64 = 127 s before ETIMEDOUT. An ICMP unreachable from a router produces EHOSTUNREACH or ENETUNREACH quickly instead.

Why. To bound the wait, use a non-blocking connect: set O_NONBLOCK, expect EINPROGRESS, poll for POLLOUT with your own timeout, then read getsockopt(SO_ERROR), because writability alone does not mean success. On Linux, TCP_SYNCNT lowers the retry count per socket, and SO_SNDTIMEO also limits a blocking connect. For failover, start the backup attempt after a short delay instead of waiting for the first to fail, which is what Happy Eyeballs (RFC 8305) does for IPv6 and IPv4. Wrong answers: "SO_RCVTIMEO limits it" (it does not apply to connect) and "connect times out after 75 s" (an old BSD figure).

OutputA connection log line prints the source and destination addresses. What do the two lines print?

#include <arpa/inet.h>
#include <cstdio>

int main() {
    in_addr a{}, b{};
    inet_pton(AF_INET, "10.0.0.1", &a);
    inet_pton(AF_INET, "192.168.1.20", &b);

    const char* src = inet_ntoa(a);
    const char* dst = inet_ntoa(b);
    std::printf("%s -> %s\n", src, dst);

    char s1[INET_ADDRSTRLEN], s2[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &a, s1, sizeof s1);
    inet_ntop(AF_INET, &b, s2, sizeof s2);
    std::printf("%s -> %s\n", s1, s2);
}
Answer
192.168.1.20 -> 192.168.1.20
10.0.0.1 -> 192.168.1.20

Why. inet_ntoa returns a pointer to a static buffer inside the C library, and every call overwrites it. src and dst are the same pointer, so both print the last address converted. Written as printf("%s -> %s", inet_ntoa(a), inet_ntoa(b)) it is even worse: both arguments still alias, and which address wins depends on the unspecified argument evaluation order. glibc makes the buffer thread-local, so threads do not corrupt each other, but POSIX does not require that. Use inet_ntop with your own buffer, which also supports IPv6. strerror, localtime, gethostbyname and strtok share the same static-buffer design, and each has a reentrant replacement (strerror_r, localtime_r, getaddrinfo, strtok_r).

TrickyA load-test client opens and closes 30000 short connections per minute to one server. After a while connect fails with EADDRNOTAVAIL, and ss shows thousands of sockets in TIME_WAIT on the client. Why the client, and what are the fixes?

Answer

Answer. TIME_WAIT is held by whichever side closes first (the active closer), and here that is the client. Each TIME_WAIT entry keeps its 4-tuple reserved for 2·MSL (60 seconds on Linux), and with a single server address and port the only free part of the tuple is the client's ephemeral port. Linux's default range is 32768 to 60999, about 28000 ports, so 30000 closes a minute exhausts it.

Why. TIME_WAIT exists so that a late duplicate segment from an old connection is not accepted by a new one, and so the final ACK can be resent if it is lost (RFC 9293). Fixes, best first: reuse connections (keep-alive, pooling); let the server close first so it holds TIME_WAIT (cheap for a server with one listening port); widen net.ipv4.ip_local_port_range; enable net.ipv4.tcp_tw_reuse, which lets new outgoing connections reuse TIME_WAIT ports safely using timestamps. SO_REUSEADDR is the server-side answer to "bind fails after restart" and does not help here. Never use SO_LINGER with a zero timeout to skip TIME_WAIT, because it sends RST and can drop data.

OutputA binary market data frame contains a zero byte. The handler turns the received buffer into a std::string in two ways. What are the sizes?

#include <cstdio>
#include <string>
#include <sys/socket.h>
#include <unistd.h>

int main() {
    int sv[2];
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) return 1;
    const char frame[] = {'H', 'B', '\0', '4', '2', '\n'};  // binary protocol: a 0 byte is data
    send(sv[0], frame, sizeof frame, 0);

    char buf[64];
    ssize_t n = recv(sv[1], buf, sizeof buf, 0);
    std::string as_cstr(buf);      // stops at the first 0 byte (and needs one to exist)
    std::string exact(buf, static_cast<std::size_t>(n));
    std::printf("recv=%zd as_cstr=%zu exact=%zu\n", n, as_cstr.size(), exact.size());
    close(sv[0]);
    close(sv[1]);
}
Answer
recv=6 as_cstr=2 exact=6

Why. std::string(const char*) treats its argument as a C string and stops at the first '\0', so it keeps "HB" and silently drops the rest. If the received bytes happen to contain no zero, the same constructor reads past the data and possibly past the buffer, which is undefined behaviour (ASan reports a stack-buffer-overflow). The (pointer, length) constructor copies exactly n bytes, embedded zeros included. A std::string holds zeros without trouble, and only APIs built on c_str() (printf("%s"), strlen, C libraries) stop at them.

Always carry the count that recv returned: std::string(buf, n), std::string_view(buf, n) or std::span. Related traps: std::string s = "a\0b"; has size 1, while "a\0b"s has size 3 because the literal operator receives the length; and strcpy or strlen in a framer truncate binary payloads. The wrong answer is "6 6".

OutputA protocol header is sent with send(fd, &hdr, sizeof hdr, 0). The spec says it is 7 bytes. What does the program print?

#include <cstddef>
#include <cstdint>
#include <cstdio>

struct Header {
    uint8_t type;
    uint32_t length;
    uint16_t seq;
};

struct [[gnu::packed]] PackedHeader {
    uint8_t type;
    uint32_t length;
    uint16_t seq;
};

int main() {
    std::printf("%zu %zu %zu %zu\n", sizeof(Header), offsetof(Header, length),
                sizeof(PackedHeader), offsetof(PackedHeader, length));
}
Answer
12 4 7 1

Why. length needs 4-byte alignment, so 3 padding bytes follow type, seq sits at offset 8, and the struct is padded to 12. Sending the struct sends 12 bytes, including padding with indeterminate contents (an information leak), in host byte order. [[gnu::packed]] removes the padding, but every access to length is now misaligned, taking a reference or pointer to it is dangerous (GCC warns with -Waddress-of-packed-member), and the byte order is still wrong. The portable way is to serialise field by field into a byte buffer: write type, then htonl(length) with memcpy, then htons(seq), and parse the same way. HFT feed handlers often do use packed structs for speed, but only on a known architecture and with explicit endian conversion of each field.

TrickyA server accepts a connection, forks a child to run a helper program, and then the parent closes the connection. The client never sees EOF and hangs. Why?

Answer

Answer. fork duplicates the file descriptor table, so the child holds its own descriptor for the same socket. The kernel only sends FIN when the last descriptor referring to the socket is closed. The parent's close just drops a reference, and the helper keeps the connection open for as long as it runs.

Why. Descriptors refer to shared open file descriptions with a reference count (Ch 4). Fixes: open sockets with SOCK_CLOEXEC (or accept4(..., SOCK_CLOEXEC)) so they close automatically on exec, close unneeded descriptors in the child, or call shutdown(fd, SHUT_WR) in the parent, which sends FIN regardless of how many descriptors exist. The same mechanism causes "port still in use" after a server restarts, when a forked child still holds the listening socket.

ConceptWhat do the return values of recv on a TCP socket mean: a positive count, 0, and -1 with errno set to EAGAIN, EINTR or ECONNRESET?

Answer

Answer. A positive value is the number of bytes copied, possibly fewer than requested. 0 means orderly shutdown: the peer sent FIN and no more data will arrive in that direction (it is not "no data right now"). -1 with EAGAIN/EWOULDBLOCK means a non-blocking socket has nothing to read yet, so go back to poll/epoll. EINTR means a signal interrupted the call before any data arrived, so retry. ECONNRESET means the peer sent RST (it crashed, or closed with unread data), so the connection is dead.

Why. A loop that treats 0 as "try again" spins at 100% CPU after the peer leaves, and a loop that treats EAGAIN as fatal drops healthy connections. Note that recv with a length of 0 also returns 0, so do not call it with an empty buffer and then mistake that for EOF.

OutputTimeouts are configured in milliseconds and passed to an API that takes whole seconds. What does each conversion produce?

#include <chrono>
#include <cstdio>

int main() {
    using namespace std::chrono;
    auto timeout = milliseconds(1999);
    seconds s = duration_cast<seconds>(timeout);
    microseconds us = timeout;                // widening: implicit and exact
    std::printf("cast=%lld floor=%lld ceil=%lld round=%lld us=%lld\n",
                static_cast<long long>(s.count()), static_cast<long long>(floor<seconds>(timeout).count()),
                static_cast<long long>(ceil<seconds>(timeout).count()), static_cast<long long>(round<seconds>(timeout).count()),
                static_cast<long long>(us.count()));
    auto ratio = milliseconds(1500) / seconds(1);
    std::printf("ratio=%lld neg=%lld\n", static_cast<long long>(ratio),
                static_cast<long long>(duration_cast<seconds>(milliseconds(-1500)).count()));
}
Answer
cast=1 floor=1 ceil=2 round=2 us=1999000
ratio=1 neg=-1

Why. duration_cast truncates toward zero, like integer division, so 1999 ms becomes 1 s and -1500 ms becomes -1 s (where floor would give -2). A conversion that can lose precision must be written explicitly, while a widening one (milliseconds to microseconds) is implicit and exact. C++17 adds floor, ceil and round (ties to even) so you can choose. Dividing two durations gives a plain count, also truncated: 1500 ms / 1 s is 1. The wrong answer is "it rounds to the nearest second".

For timeouts, round up. A 1999 ms deadline truncated to 1 s fires early, and a sub-millisecond timeout truncated to 0 makes poll (which takes an int of milliseconds) return immediately, so the event loop busy-spins at 100% CPU.

What the standard says vs what the compiler does

Drills

Here "it printed X on my machine" and "the language guarantees X" are different answers. Know which category each result falls into: defined, unspecified, implementation-defined or undefined.

OutputWhat does show(next(), next()) print?

#include <cstdio>

static int counter = 0;
static int next() { return ++counter; }
static void show(int a, int b) { std::printf("%d %d\n", a, b); }

int main() {
    show(next(), next());
}
Answer

Answer. Either 1 2 or 2 1, and both are correct. The order in which function arguments are evaluated is unspecified ([expr.call]). On this guide's ARM64 test machine both GCC and Clang print 1 2, but GCC on x86-64 has historically evaluated many argument lists right to left, and the order may change with the optimisation level, the argument types or the compiler version. The output is not compared for that reason.

Why. Unspecified is not undefined: since C++17 each argument is evaluated completely before the next one starts (they are indeterminately sequenced), so the program is valid and prints one of the two results. Before C++17 the sub-expressions of different arguments could interleave, which made f(std::unique_ptr<T>(new T), g()) leak if g() threw between the new and the unique_ptr constructor. That leak is gone in C++17. If the order matters, evaluate into named locals first.

OutputSame counter, now with stream insertion and with a map assignment. What does C++20 guarantee?

#include <iostream>
#include <map>

static int counter = 0;
static int next() { return ++counter; }

int main() {
    std::cout << next() << ' ' << next() << ' ' << next() << '\n';
    std::map<int, int> m;
    m[next()] = next();
    std::cout << m.begin()->first << ' ' << m.begin()->second << '\n';
}
Answer
1 2 3
5 4

Why. C++17 (P0145) added sequencing rules for a few operators. In E1 << E2 and E1 >> E2, E1 is sequenced before E2, so chained stream output now runs left to right even though each << is a function call. In assignment E1 = E2 (and compound assignment), the right operand is sequenced before the left, so the value next() runs first and returns 4, then the key next() returns 5. In C++14 both lines were unspecified, so a compiler could legally print 3 2 1 for the first one, and older compilers sometimes did. The same rule makes v[i] = i++; well defined since C++17, and explains why m[k] = m.size(); stores the size before the insertion.

OutputAn overflow guard: will_grow(INT_MAX) should return false. What does an optimising compiler make of it?

#include <climits>
#include <cstdio>

static bool will_grow(int x) { return x + 1 > x; }

int main(int argc, char**) {
    int big = INT_MAX - 1 + argc;  // INT_MAX when run with no arguments
    std::printf("%d\n", will_grow(big));
}
Answer

Answer. With optimisation, both GCC and Clang compile will_grow to return true and print 1. Without optimisation you may see 0, because the addition wraps on the hardware. The output is not compared here because the program has undefined behaviour.

Why. Signed overflow is undefined ([expr.pre]), so the compiler may assume it never happens, and then x + 1 > x is always true. This is how loops like for (int i = 0; i >= 0; ++i) become infinite and how overflow checks written after the fact disappear. Correct guards test before the operation: x < INT_MAX, or __builtin_add_overflow(x, 1, &out), or C++26 std::add_sat. Unsigned arithmetic wraps by definition, and -fwrapv makes signed wrap too, at some cost to loop optimisation. UBSan (-fsanitize=signed-integer-overflow) catches it at run time.

OutputAre these two lines undefined behaviour in C++20? If not, what do they print?

#include <cstdio>

int main() {
    int i = 5;
    i = i++ + 1;
    int k = 1;
    k += k++;
    std::printf("%d %d\n", i, k);
}
Answer
6 3

Why. Both were undefined before C++11 and still undefined in C++14, because the side effect of i++ and the assignment to i were unsequenced. Since C++17 the right operand of every assignment, including compound assignment, is sequenced before the left operand and before the assignment itself. So i++ + 1 is fully evaluated (value 6, and i becomes 6), then 6 is stored. For k += k++, the right side yields 1 and sets k to 2, then k = k + 1 gives 3. i++ + i++ and f(i++, i++) are still not defined the way people hope: the first is undefined, the second unspecified. Never write this in real code, but know why it is legal now.

Trickychar c = '\xFF'; if (c < 0) ... takes the branch on an x86-64 Linux box and not on an ARM64 Linux box. Which compiler is wrong?

Answer

Answer. Neither. Whether plain char is signed is implementation-defined, and the ABI decides. x86-64 Linux, Windows and Apple's ARM64 ABI use signed char. The standard ARM64 Linux (AAPCS64), 32-bit ARM Linux and POWER ABIs use unsigned char. char, signed char and unsigned char are three distinct types in every case.

Why. This bites parsers that index tables with a char (a negative index on x86, fine on ARM) and checksum code that adds bytes. Use unsigned char or std::uint8_t for raw bytes, or std::byte, and convert explicitly. -funsigned-char and -fsigned-char change the default and break ABI compatibility with other libraries. Other implementation-defined favourites: sizeof(long) (8 on Linux, 4 on Windows) and right shift of a negative number (arithmetic on every mainstream compiler, and defined that way since C++20).

OutputTwo ways to look at the bits of a float. What prints, and which commonly used third way is undefined?

#include <bit>
#include <cstdint>
#include <cstdio>
#include <cstring>

int main() {
    float f = 1.0f;
    auto viaBitCast = std::bit_cast<std::uint32_t>(f);
    std::uint32_t viaMemcpy;
    std::memcpy(&viaMemcpy, &f, sizeof f);
    std::printf("%08x %08x\n", viaBitCast, viaMemcpy);
}
Answer
3f800000 3f800000

Why. IEEE 754 single precision stores 1.0 as sign 0, biased exponent 127 (0x7f) and fraction 0, which is 0x3f800000. Both ways copy the object representation, which is well defined. The undefined way is *reinterpret_cast<std::uint32_t*>(&f): it reads a float object through a uint32_t glvalue, which breaks the strict aliasing rule ([basic.lval]). The optimiser assumes that an int* and a float* never refer to the same object, and may reorder or delete the accesses. Union type punning is allowed in C but is undefined in C++, although GCC documents it as an extension. memcpy of 4 bytes compiles to a single register move, so the safe versions cost nothing. std::bit_cast (C++20) is also constexpr.

OutputA feed parser stores small fields as std::int8_t and std::uint8_t. What do the stream insertions print?

#include <cstdint>
#include <iostream>
#include <string>

int main() {
    std::int8_t side = 66;
    std::uint8_t qty = 50;
    std::cout << side << ' ' << qty << '\n';
    std::cout << +side << ' ' << +qty << ' ' << static_cast<int>(qty) << '\n';
    std::cout << std::to_string(qty) << '\n';
}
Answer
B 2
66 50 50
50

Why. On every mainstream platform std::int8_t and std::uint8_t are typedefs for signed char and unsigned char, and iostreams have character overloads for both. So 66 prints as B and 50 as 2. Unary + applies integral promotion to int, as does the explicit cast, and std::to_string has no character overloads, so it prints the number. The standard would allow these typedefs to name distinct extended integer types, but no mainstream implementation does that. The wrong answer is "66 50".

Extraction is worse: std::cin >> qty reads one character, so the input 50 stores 53 (the code of '5'). std::format("{}", qty) prints 50, because std::format formats only char as a character and treats signed char and unsigned char as integers.

ConceptWhat does the inline keyword actually guarantee in modern C++? Does it make a function get inlined?

Answer

Answer. inline is about the one-definition rule, not about inlining. An inline function or (since C++17) inline variable may be defined in several translation units, as long as every definition is token-for-token identical, and the linker keeps one copy. Whether a call is expanded in place is the optimiser's choice, and it inlines non-inline functions and ignores the keyword freely.

Why. It is what lets you define functions in headers. Member functions defined inside a class body, constexpr functions and function templates are implicitly inline. The trap is the "identical" requirement: two TUs that see different definitions (different #defines, or two unrelated classes both called Node in the global namespace) cause an ODR violation, which is undefined behaviour with no diagnostic required. The linker silently keeps one version. Put file-local helpers in an anonymous namespace or mark them static. To force or block inlining, use [[gnu::always_inline]] or [[gnu::noinline]].

Find the bugThe destructor reports flush failures by throwing, and it is explicitly marked noexcept(false). What happens when an order fails?

#include <cstdio>
#include <stdexcept>

struct Flusher {
    ~Flusher() noexcept(false) {
        std::puts("flush failed, throwing");
        throw std::runtime_error("disk full");
    }
};

int main() {
    try {
        Flusher f;
        throw std::runtime_error("bad order");
    } catch (const std::exception& e) {
        std::printf("caught %s\n", e.what());
    }
}
Answer

Answer. std::terminate is called and the program aborts. The catch never runs. Even the puts line may not appear when stdout is a pipe, because abort does not flush stdio buffers.

Why. Throwing bad order starts stack unwinding, which destroys f. If a destructor called during unwinding exits with an exception, there would be two exceptions in flight, and C++ handles that by calling std::terminate ([except.terminate]). Without the noexcept(false) it is even worse: destructors are implicitly noexcept, so any throw out of them terminates, unwinding or not (GCC warns with -Wterminate). Fix: never let exceptions escape a destructor. Provide an explicit flush() or close() that can throw, and have the destructor call it inside try { } catch (...) { } as a best-effort fallback. std::uncaught_exceptions() lets a destructor know whether it is running because of unwinding, which the Transaction exercise below uses.

Rapid-fire one-liners

Drills

Screening rounds often open with a burst of these. Aim to answer each in one sentence and then give the reason in a second sentence if asked.

ConceptCan you overload a function on its return type alone?

Answer

Answer. No. Overloads must differ in their parameter lists (including const/ref qualifiers on member functions).

Why. A call expression has to be resolved without knowing what the result is used for, and the result may be discarded. Return types only enter the signature of function templates, and a conversion operator template can fake "overload on return type" when that is really needed.

TrickyWhat does delete p; do when p is null, and what does free(nullptr) do?

Answer

Answer. Nothing in both cases. They are defined no-ops, so if (p) delete p; is redundant.

Why. The real danger is the opposite case, a non-null pointer that was already deleted. Setting a pointer to null after delete turns a later double delete into a harmless no-op, but it also hides the logic error. Smart pointers remove the question entirely.

ConceptWhy is std::endl slower than '\n' in a loop that writes a million lines to a file?

Answer

Answer. std::endl writes a newline and flushes the stream, so each line becomes a separate write system call. '\n' just appends to the buffer.

Why. A million syscalls cost far more than the formatting. For a terminal, std::cout is often line buffered anyway, which hides the difference in tests. Use '\n' and flush explicitly where you need durability or interactivity.

ConceptWhat is the largest payload of a single UDP datagram over IPv4, and what actually limits it in practice?

Answer

Answer. 65507 bytes: the 16-bit IP total length (65535) minus the 20-byte IP header and the 8-byte UDP header. In practice anything above the path MTU (about 1472 bytes of payload on Ethernet) is fragmented, and losing one fragment loses the whole datagram.

Why. Market data protocols keep datagrams under the MTU for this reason. If the receive buffer is smaller than the datagram, recv truncates it and the rest is discarded (MSG_TRUNC tells you).

TrickyCan you re-seat a reference, for example inside a class's copy assignment operator?

Answer

Answer. No. Assigning to a reference assigns to the object it refers to. That is why a class with a reference member gets a deleted copy assignment operator.

Why. If you need a rebindable reference, store a pointer or std::reference_wrapper<T>, which is also what lets containers hold "references".

ConceptWhich ephemeral port range does Linux use for outgoing connections by default?

Answer

Answer. 32768 to 60999, set by net.ipv4.ip_local_port_range. IANA recommends 49152 to 65535, which Windows and macOS use.

Why. The range limits how many concurrent connections one client address can open to one server address and port, together with TIME_WAIT. See the TIME_WAIT drill in the networking section.

TrickyIs sizeof('a') the same in C and C++?

Answer

Answer. No. In C++ a character literal has type char, so the size is 1. In C it has type int, so the size is usually 4.

Why. C++ changed the type so that overloads such as print(char) and print(int) pick the right one for print('a'). It is a classic question about why C++ is not a superset of C, along with void* needing a cast and auto meaning something else.

Write the code: cross-topic exercises

Coding

Each exercise fixes one of the traps above properly. Interviewers grade the edge cases as much as the happy path, so each answer lists the pitfalls they look for.

CodeWrite BitVector, a packed bit array with a proxy reference, and get the proxy's assignment right.

Store bits in std::uint64_t words. Support push_back(bool), size(), count(), a const operator[] returning bool, and a non-const operator[] returning a proxy that supports b[i] = true, b[i] = b[j], conversion to bool and flip(). Explain what auto r = b[i]; means for your users.

Answer

Approach. The proxy holds a pointer to one word and a single-bit mask. Assigning a bool sets or clears the bit. The proxy's copy assignment is the subtle part: b[i] = b[j] must copy the bit value, so it is written as "convert the source to bool, then assign", whereas the defaulted version would re-point the proxy and leave the vector unchanged. count() uses std::popcount per word, which works because unused high bits of the last word are kept at zero.

#include <bit>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <vector>

class BitVector {
public:
    class reference {
    public:
        reference(std::uint64_t& word, std::uint64_t mask) : w_(&word), m_(mask) {}
        reference(const reference&) = default;
        reference& operator=(bool b) {
            if (b) *w_ |= m_; else *w_ &= ~m_;
            return *this;
        }
        reference& operator=(const reference& other) { return *this = static_cast<bool>(other); }
        operator bool() const { return (*w_ & m_) != 0; }
        void flip() { *w_ ^= m_; }

    private:
        std::uint64_t* w_;
        std::uint64_t m_;
    };

    explicit BitVector(std::size_t n = 0) : words_((n + 63) / 64), size_(n) {}

    void push_back(bool b) {
        if (size_ % 64 == 0) words_.push_back(0);
        ++size_;
        (*this)[size_ - 1] = b;
    }
    reference operator[](std::size_t i) { return reference(words_[i / 64], std::uint64_t{1} << (i % 64)); }
    bool operator[](std::size_t i) const { return (words_[i / 64] >> (i % 64)) & 1u; }
    std::size_t size() const { return size_; }
    std::size_t count() const {
        std::size_t c = 0;
        for (std::uint64_t w : words_) c += static_cast<std::size_t>(std::popcount(w));
        return c;
    }

private:
    std::vector<std::uint64_t> words_;
    std::size_t size_;
};

int main() {
    BitVector b(70);
    b[3] = true;
    b[69] = true;
    b[64] = b[3];   // copies the bit, not the proxy
    auto r = b[5];  // r is a proxy: writing through it changes b
    r = true;
    b[69].flip();
    b.push_back(true);
    const BitVector& cb = b;
    std::printf("size=%zu count=%zu b5=%d b64=%d b69=%d b70=%d\n",
                cb.size(), cb.count(), cb[5], cb[64], cb[69], cb[70]);
}
size=71 count=4 b5=1 b64=1 b69=0 b70=1

Complexity and pitfalls. Element access is O(1) with a shift and a mask, and count() is O(n/64). Pitfalls that interviewers probe: the defaulted proxy assignment (silently does nothing to the vector), auto r = b[i] holding a proxy that dangles once push_back reallocates words_, generic code that expects T& (bool& x = b[i] fails to compile), and concurrency: two threads writing different bits of the same word race, which is never true for distinct elements of an ordinary vector. These are exactly the reasons std::vector<bool> is considered a design mistake. A resize that shrinks must also clear the bits above the new size, or count() breaks.

Estimated rating: ~1600 · easy bit arithmetic, but the proxy's copy assignment and lifetime are a real test.

CodeImplement Lazy<T>: a correct double-checked lazy initialiser.

T& get(F make) builds the value with make() on first use and returns it. It must be safe when many threads call it at once, make must run exactly once, and the fast path after initialisation must take no lock. The destructor frees the value.

Answer

Approach. Store the pointer in a std::atomic<T*>. The fast path is an acquire load: if it sees the pointer, it also sees everything the constructor wrote, because the writer published the pointer with a release store after construction. The slow path takes a mutex and checks again, so only one thread builds the value. Inside the lock the re-check can be relaxed, since the mutex already orders it after any earlier publisher.

#include <atomic>
#include <cstdio>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

template <class T>
class Lazy {
public:
    Lazy() = default;
    Lazy(const Lazy&) = delete;
    Lazy& operator=(const Lazy&) = delete;
    ~Lazy() { delete ptr_.load(std::memory_order_relaxed); }

    template <class F>
    T& get(F&& make) {
        if (T* p = ptr_.load(std::memory_order_acquire)) return *p;
        std::lock_guard<std::mutex> lock(mtx_);
        T* p = ptr_.load(std::memory_order_relaxed);
        if (!p) {
            p = new T(make());
            ptr_.store(p, std::memory_order_release);
        }
        return *p;
    }

private:
    std::atomic<T*> ptr_{nullptr};
    std::mutex mtx_;
};

int main() {
    Lazy<std::string> config;
    std::atomic<int> builds{0};
    std::atomic<std::size_t> total{0};
    std::vector<std::thread> ts;
    for (int i = 0; i < 8; ++i)
        ts.emplace_back([&] {
            const std::string& c = config.get([&] {
                builds.fetch_add(1, std::memory_order_relaxed);
                return std::string("venue=XNAS depth=10");
            });
            total.fetch_add(c.size(), std::memory_order_relaxed);
        });
    for (auto& t : ts) t.join();
    std::printf("builds=%d total=%zu\n", builds.load(), total.load());
}
builds=1 total=152

Complexity and pitfalls. After initialisation each call is one acquire load (a plain load on x86, ldar on ARM64). Pitfalls: a plain T* (the original bug), relaxed on the fast path (no visibility of the constructed object), publishing the pointer before construction finishes, and an exception from make(): here it propagates, the lock is released by RAII and the next caller retries, which is the same behaviour as a function-local static. In real code a function-local static or std::call_once is simpler. Write this version when the object must be per instance or destroyed at a chosen time.

Estimated rating: ~1700 · short, but needs a precise explanation of acquire/release and of the re-check.

CodeWrite an AsyncLogger that moves log I/O off the hot threads and shuts down safely.

log(std::string) may be called from any thread and must only enqueue. One background thread writes lines to a sink, here a std::vector<std::string>& standing in for a file. The destructor must flush every line logged before it started, join the thread, and never throw or deadlock. Lines from one thread must keep their order.

Answer

Approach. A mutex-protected vector is the queue and a condition variable wakes the worker. The worker swaps the whole queue into a local batch under the lock and writes the batch after unlocking, so slow I/O never blocks producers. The destructor sets stop_ under the lock, notifies and joins, and the worker exits only once it has seen stop_ and found the queue empty, which guarantees the final flush. The thread is declared as the last member, so everything it touches is constructed before it starts and is still alive until it is joined.

#include <condition_variable>
#include <cstdio>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

class AsyncLogger {
public:
    explicit AsyncLogger(std::vector<std::string>& sink) : sink_(sink), worker_([this] { run(); }) {}
    AsyncLogger(const AsyncLogger&) = delete;
    AsyncLogger& operator=(const AsyncLogger&) = delete;
    ~AsyncLogger() {
        {
            std::lock_guard<std::mutex> g(m_);
            stop_ = true;
        }
        cv_.notify_one();
        worker_.join();
    }

    void log(std::string line) {
        {
            std::lock_guard<std::mutex> g(m_);
            q_.push_back(std::move(line));
        }
        cv_.notify_one();
    }

private:
    void run() {
        std::vector<std::string> batch;
        std::unique_lock<std::mutex> lk(m_);
        for (;;) {
            cv_.wait(lk, [this] { return stop_ || !q_.empty(); });
            batch.swap(q_);
            const bool stopping = stop_;
            lk.unlock();
            for (auto& s : batch) sink_.push_back(std::move(s));  // the slow part, unlocked
            batch.clear();
            lk.lock();
            if (stopping && q_.empty()) return;
        }
    }

    std::vector<std::string>& sink_;
    std::mutex m_;
    std::condition_variable cv_;
    std::vector<std::string> q_;
    bool stop_ = false;
    std::thread worker_;  // last: starts after, and is joined before, everything it uses
};

int main() {
    std::vector<std::string> sink;
    {
        AsyncLogger logger(sink);
        std::vector<std::thread> ts;
        for (int t = 0; t < 4; ++t)
            ts.emplace_back([&logger, t] {
                for (int i = 0; i < 250; ++i)
                    logger.log(std::to_string(t) + ":" + std::to_string(i));
            });
        for (auto& th : ts) th.join();
    }  // destructor flushes and joins
    int last[4] = {-1, -1, -1, -1};
    bool ordered = true;
    for (const auto& line : sink) {
        int t = line[0] - '0';
        int i = std::stoi(line.substr(2));
        ordered = ordered && i == last[t] + 1;
        last[t] = i;
    }
    std::printf("lines=%zu per-thread order kept=%d\n", sink.size(), ordered);
}
lines=1000 per-thread order kept=1

Complexity and pitfalls. Each log is one short critical section, and the worker takes the lock once per batch rather than once per line. Pitfalls: writing to the sink while holding the lock (every producer then waits for the disk), checking stop_ before draining (lines are lost at shutdown), waiting without a predicate (lost and spurious wakeups), declaring the thread before the queue or mutex (members are initialised in declaration order, as the width bug in the move section shows), and calling log from a destructor of a static object after the logger is gone. A low-latency version replaces the mutex with a per-thread SPSC ring, formats lazily, and bounds the queue, choosing between blocking and dropping when it is full.

Estimated rating: ~1600 · standard producer and consumer code, judged on the shutdown path and on doing I/O outside the lock.

CodeWrite Synchronized<T>, a value that can only be reached while its mutex is held.

Provide lock(), which returns a proxy that holds the lock and gives -> and * access, and with(f), which calls f(value) under the lock and returns its result. There must be no way to reach the value without locking. Test with four threads appending to a Synchronized<std::vector<int>>.

Answer

Approach. The value and its mutex are private members of the same object, so the only doors are the two accessors. The proxy owns a std::unique_lock and a reference to the value, and it is returned by value, which C++17 guaranteed elision constructs directly in the caller. with takes a scoped_lock and forwards f, and decltype(auto) preserves a returned reference or value exactly.

#include <cstdio>
#include <mutex>
#include <thread>
#include <utility>
#include <vector>

template <class T>
class Synchronized {
public:
    template <class... Args>
    explicit Synchronized(Args&&... args) : value_(std::forward<Args>(args)...) {}

    class Locked {
    public:
        Locked(std::mutex& m, T& v) : lock_(m), v_(v) {}
        T* operator->() const { return &v_; }
        T& operator*() const { return v_; }

    private:
        std::unique_lock<std::mutex> lock_;
        T& v_;
    };

    [[nodiscard]] Locked lock() { return Locked(m_, value_); }

    template <class F>
    decltype(auto) with(F&& f) {
        std::scoped_lock guard(m_);
        return std::forward<F>(f)(value_);
    }

private:
    std::mutex m_;
    T value_;
};

int main() {
    Synchronized<std::vector<int>> fills;
    std::vector<std::thread> ts;
    for (int i = 0; i < 4; ++i)
        ts.emplace_back([&fills, i] {
            for (int k = 0; k < 1000; ++k) fills.lock()->push_back(i);
        });
    for (auto& t : ts) t.join();
    std::size_t n = fills.with([](const std::vector<int>& v) { return v.size(); });
    {
        auto locked = fills.lock();
        locked->push_back(-1);
        std::printf("size=%zu last=%d\n", n, locked->back());
    }
}
size=4000 last=-1

Complexity and pitfalls. One lock per access, so batch work inside one lock() or with rather than locking per element in hot paths. Pitfalls: auto& v = *fills.lock(); compiles and leaves v unprotected, because the proxy (and its lock) dies at the end of the full expression. Returning a reference out of with leaks the value past the lock in the same way. Calling lock() twice on one thread deadlocks, since std::mutex is not recursive. Folly's Synchronized and Boost's synchronized_value are production versions, with shared locks for readers.

Estimated rating: ~1600 · a small template whose value is in the lifetime reasoning about the proxy.

CodeSerialise an order message to a portable wire format and parse it back safely.

The wire format is big-endian and packed: type (1 byte, 'N'), order_id (8), price in units of 1/10000 (signed, 8), qty (4) and side (1 byte, 'B' or 'S'), 22 bytes in all. Write encode(order, buf, len) returning the bytes written (0 if the buffer is too small) and decode(buf, len) returning std::optional<Order>, rejecting short buffers and invalid fields. No packed structs, no casts of the buffer to a struct pointer.

Answer

Approach. Two small templates do every field: put_be writes an unsigned integer most significant byte first with shifts, and get_be reads it back. Shifts work on values, not memory, so the code is correct on any host byte order and needs no htonl (which has no portable 64-bit form). Signed fields go through their unsigned type, a conversion that is well defined both ways since C++20. Decoding checks the length before reading and validates the type and side bytes.

#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <optional>
#include <type_traits>

struct Order {
    std::uint64_t id;
    std::int64_t price;  // 1/10000 units
    std::uint32_t qty;
    char side;
};

constexpr std::size_t kWireSize = 1 + 8 + 8 + 4 + 1;

template <class U>
static unsigned char* put_be(unsigned char* p, U v) {
    static_assert(std::is_unsigned_v<U>);
    for (int i = sizeof(U) - 1; i >= 0; --i) *p++ = static_cast<unsigned char>(v >> (8 * i));
    return p;
}

template <class U>
static const unsigned char* get_be(const unsigned char* p, U& v) {
    v = 0;
    for (std::size_t i = 0; i < sizeof(U); ++i) v = static_cast<U>((v << 8) | *p++);
    return p;
}

static std::size_t encode(const Order& o, unsigned char* buf, std::size_t len) {
    if (len < kWireSize) return 0;
    unsigned char* p = buf;
    *p++ = 'N';
    p = put_be(p, o.id);
    p = put_be(p, static_cast<std::uint64_t>(o.price));
    p = put_be(p, o.qty);
    *p++ = static_cast<unsigned char>(o.side);
    return static_cast<std::size_t>(p - buf);
}

static std::optional<Order> decode(const unsigned char* buf, std::size_t len) {
    if (len < kWireSize || buf[0] != 'N') return std::nullopt;
    Order o{};
    std::uint64_t price = 0;
    const unsigned char* p = buf + 1;
    p = get_be(p, o.id);
    p = get_be(p, price);
    p = get_be(p, o.qty);
    o.price = static_cast<std::int64_t>(price);
    o.side = static_cast<char>(*p);
    if (o.side != 'B' && o.side != 'S') return std::nullopt;
    return o;
}

int main() {
    unsigned char buf[64];
    std::size_t n = encode(Order{12345, 1892500, 100, 'B'}, buf, sizeof buf);
    std::printf("wire size %zu\nbytes:", n);
    for (std::size_t i = 0; i < 9; ++i) std::printf(" %02x", buf[i]);
    std::printf("\n");
    if (auto o = decode(buf, n))
        std::printf("id=%llu px=%lld.%04lld qty=%u side=%c\n",
                    static_cast<unsigned long long>(o->id), static_cast<long long>(o->price / 10000),
                    static_cast<long long>(o->price % 10000), o->qty, o->side);
    std::printf("truncated: %s\n", decode(buf, n - 1) ? "accepted" : "rejected");
    buf[0] = 'X';
    std::printf("bad type: %s\n", decode(buf, n) ? "accepted" : "rejected");
    std::printf("negative price round trip: %s\n",
                decode(buf + 32, encode(Order{1, -5, 1, 'S'}, buf + 32, 32))->price == -5 ? "ok" : "broken");
}
wire size 22
bytes: 4e 00 00 00 00 00 00 30 39
id=12345 px=189.2500 qty=100 side=B
truncated: rejected
bad type: rejected
negative price round trip: ok

Complexity and pitfalls. O(1) per message, and compilers turn the shift loops into a single load or store plus a byte-swap instruction. Pitfalls: send(fd, &order, sizeof order, 0) (padding, host byte order and an information leak, as in the struct drill above), reinterpret_cast of the receive buffer to a struct pointer (strict aliasing and misalignment), reading fields before checking the length, and shifting a signed value. Sequence numbers and a version byte belong in a real header so that a parser can reject messages it does not understand. Exchange protocols such as ITCH and SBE use exactly this kind of fixed layout.

Estimated rating: ~1300 · simple byte handling, judged on endianness, bounds checks and avoiding aliasing tricks.

CodeWrite a Transaction guard that rolls back on failure and never throws from its destructor.

It records operations into a shared log. commit() makes them permanent. If the guard is destroyed without a commit, it removes its operations and reports whether the cause was an exception or a forgotten commit. The destructor must be safe to run during stack unwinding.

Answer

Approach. Remember the log size at construction, and on rollback erase everything after it. To tell "unwinding" from "forgot to commit", compare std::uncaught_exceptions() in the destructor with the value saved in the constructor. Comparing counts (rather than calling the removed std::uncaught_exception()) gives the right answer even when the guard is created inside another object's destructor during unwinding. Nothing in the destructor can throw: erasing strings from the end of a vector never allocates.

#include <cstdio>
#include <exception>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

class Transaction {
public:
    explicit Transaction(std::vector<std::string>& log)
        : log_(log), mark_(log.size()), uncaught_(std::uncaught_exceptions()) {}
    Transaction(const Transaction&) = delete;
    Transaction& operator=(const Transaction&) = delete;

    void add(std::string op) { log_.push_back(std::move(op)); }
    void commit() {
        committed_ = true;
        std::printf("committed: %zu ops\n", log_.size() - mark_);
    }
    ~Transaction() {
        if (committed_) return;
        const char* why = std::uncaught_exceptions() > uncaught_ ? "exception" : "no commit";
        std::size_t n = log_.size() - mark_;
        log_.erase(log_.begin() + static_cast<std::ptrdiff_t>(mark_), log_.end());
        std::printf("rolled back %zu ops (%s)\n", n, why);
    }

private:
    std::vector<std::string>& log_;
    std::size_t mark_;
    int uncaught_;
    bool committed_ = false;
};

int main() {
    std::vector<std::string> log;
    {
        Transaction t(log);
        t.add("A1");
        t.add("A2");
        t.commit();
    }
    try {
        Transaction t(log);
        t.add("B1");
        throw std::runtime_error("risk check failed");
    } catch (const std::exception& e) {
        std::printf("caught: %s\n", e.what());
    }
    {
        Transaction t(log);
        t.add("C1");
    }
    std::printf("log:");
    for (const auto& op : log) std::printf(" %s", op.c_str());
    std::printf("\n");
}
committed: 2 ops
rolled back 1 ops (exception)
caught: risk check failed
rolled back 1 ops (no commit)
log: A1 A2

Complexity and pitfalls. Rollback is O(k) in the operations added. The destructor runs before the catch handler, which is why "rolled back" prints first. Pitfalls: doing fallible work (I/O, allocation) in the destructor, using the boolean std::uncaught_exception() (deprecated in C++17 and removed in C++20) which is wrong for nested guards, and letting commit() set the flag before a step that can throw. In a real system commit() is where errors surface and may throw. The destructor's job is only the cleanup that cannot fail. The Library Fundamentals TS calls these scope_success and scope_fail.

Estimated rating: ~1400 · simple bookkeeping, tested on unwinding order and on the no-throw destructor rule.

Part G · Mixed practice · Chapter 26

Timed mock rounds

Five interview rounds, each a fixed set of questions in the order a real interviewer would ask them: a warm-up concept, then tricky follow-ups, then code reading, then a problem to write. Work each round under its time limit before opening any answer, then mark yourself with the scoring line. Round 5 is pitched at HFT and low-latency systems interviews.

DrillsCoding· about 5 hours including review· prerequisites: chapters 1 to 25

How to run a round

Drills

Set a timer, read every question first, and answer on paper or in a blank editor without compiling. Speak your answers aloud for the concept and tricky questions, because that is what an interviewer hears. For output questions, write the exact output. For bug questions, name the bug, say how you would prove it (which sanitizer or test) and give the fix. Leave the code problem for last and budget about a third of the round for it.

30-minute round budget concept~4 min tricky~5 min output + bug~11 min code problem~10 min (a third of the round) 092030 min If a question takes twice its budget, write down your best guess and move on. Unanswered questions score zero.
Spend time where the points are. The code problem is worth the most, so protect its slot. Round 5 uses the same proportions over 45 minutes.

Know this cold

Scoring for rounds 1 to 4: concept questions 1 point, tricky, output and bug questions 2 points each, and the code problem 4 points (2 for a correct solution, 1 for stating the complexity, 1 for handling the edge cases listed in the answer). Give yourself half marks for an answer that is right but whose reason is wrong or missing, because interviewers mark the reason.

Round 1: generalist warm-up

DrillsCoding

Time limit: 30 minutes. Scoring: 18 points (2 concept × 1, 2 tricky × 2, 2 output × 2, 2 bug × 2, code 4). 15 or more is a strong pass, 11 to 14 a pass, and below 11 means revisiting Ch 1, Ch 4 and Ch 8. This is the round a product company runs for a general C++ role.

Conceptwrite(fd, buf, 4096) on a regular file returned 4096. Is the data on disk? Walk through what happened, and say what fsync adds.

Answer

Answer. Not necessarily. write is a system call: the CPU enters the kernel, which copies the bytes from your buffer into the page cache (kernel memory that caches file pages), marks those pages dirty and returns. Every other reader of the file sees the new data at once, because they read the same cached pages. The data reaches the device later, when writeback flushes dirty pages (by default once they are about 30 seconds old, vm.dirty_expire_centisecs = 3000, or earlier under memory pressure). A crash of the process loses nothing, while a power cut before writeback loses the write. fsync(fd) blocks until the file's data and metadata are on stable storage, including a flush of the device's cache. fdatasync skips metadata that is not needed to read the data back, and a newly created file also needs an fsync on its directory before its name is durable.

Why. The interviewer is testing the gap between "the call returned" and "the effect is durable", the same gap as with send, which only copies into the socket buffer. Bonus points: fwrite and std::ofstream add a user-space buffer on top, so the data may not even have reached write yet (fflush moves it to the kernel, fsync to the disk). O_DIRECT bypasses the page cache and O_DSYNC makes each write durable. On Linux a failed fsync may drop the dirty pages and clear the error, so a retry can falsely succeed (PostgreSQL's 2018 "fsyncgate"): treat a failed fsync as fatal. Journals and order logs fsync before acknowledging.

ConceptBesides "the connection is now open", what do the two sides learn or agree on during the TCP three-way handshake?

Answer

Answer. Each side picks a random initial sequence number (ISN) and learns the other's, since each SYN consumes one sequence number and must be acknowledged. The SYN and SYN-ACK also carry options that are only valid there: the maximum segment size (MSS, 1460 on Ethernet with IPv4), the window scale factor (so windows can exceed 64 KiB), SACK-permitted, and timestamps. Each side also advertises its initial receive window.

Why. Random ISNs protect against segments from an old connection with the same 4-tuple and make blind injection attacks harder. Options that are missing from the SYN cannot be turned on later, which is why a middlebox that strips window scaling caps throughput for the whole connection. The handshake costs one RTT before any data, which is what TCP Fast Open and TLS 1.3 0-RTT try to shave off (Ch 8).

TrickyA colleague sorts orders with std::sort(v.begin(), v.end(), [](const Order& a, const Order& b) { return a.price <= b.price; });. It usually works, and occasionally crashes inside std::sort when many prices are equal. Why?

Answer

Answer. <= is not a strict weak ordering, because comp(a, a) returns true. std::sort requires one ([alg.sorting]), and violating it is undefined behaviour. libstdc++'s introsort uses an "unguarded" insertion sort that relies on the comparator stopping at a sentinel element. With <= and runs of equal keys, the inner loop walks past the beginning of the range and reads (and writes) memory outside the vector.

Why. The requirement is irreflexive, transitive and with transitive equivalence. < on prices satisfies it, <= does not, and a comparator over floating-point values containing NaN does not either. Newer libstdc++ debug modes (-D_GLIBCXX_DEBUG) and libc++'s hardening mode detect some violations. Use a.price < b.price, and if equal prices must keep arrival order, use std::stable_sort or compare (price, seq) pairs with std::tie.

TrickyA profile shows malloc on every call to symbols.find("AAPL"), where symbols is a std::map<std::string, int>. Why, and how do you remove it without changing the key type?

Answer

Answer. map::find takes a const key_type&, so the string literal is converted to a temporary std::string on every call. "AAPL" fits in the small string buffer, so that allocation is free in practice, but longer keys (or std::string_view arguments) really allocate. Declare the map with a transparent comparator, std::map<std::string, int, std::less<>>, and find gains a template overload that compares the const char* or string_view with the stored keys directly.

Why. std::less<> declares the member type is_transparent, which enables heterogeneous lookup (C++14 for ordered containers). Unordered containers support it since C++20, but they need both a transparent hash and a transparent equality. The wrong fix is to cache std::string keys at every call site, which only moves the allocation.

OutputA logging helper has three overloads. Which one does each call pick?

#include <cstdio>
#include <string>

static void emit(bool) { std::puts("bool"); }
static void emit(int) { std::puts("int"); }
static void emit(const std::string&) { std::puts("string"); }

int main() {
    using namespace std::string_literals;
    emit("ready");
    emit(std::string("ready"));
    emit('a');
    emit("ready"s);
}
Answer
bool
string
int
string

Why. A string literal is a const char[6], which decays to const char*. Converting a pointer to bool is a standard conversion, while reaching const std::string& needs a user-defined conversion (the std::string constructor), and any standard conversion sequence beats a user-defined one ([over.ics.rank]). So emit("ready") logs a boolean. 'a' is a char, and promotion to int beats the conversion to bool. With an actual std::string argument, or the "ready"s literal, the string overload is an exact match.

The same trap exists with std::variant<bool, std::string> v = "x";, which held the bool until C++20 changed the converting constructor (P0608). Fixes: add an emit(const char*) or emit(std::string_view) overload, or delete the dangerous one: void emit(const char*) = delete; forces callers to choose.

OutputSizes and signed values meet. What are the four lines?

#include <cstdio>
#include <vector>

int main() {
    std::vector<int> v;
    std::printf("a %d\n", v.size() - 1 > 0);
    std::printf("b %d\n", -1 < 1u);
    std::printf("c %d\n", static_cast<int>(v.size()) - 1 < 0);
    std::printf("d %d\n", std::ssize(v) - 1 < 0);
}
Answer
a 1
b 0
c 1
d 1

Why. v.size() is a size_t, and 0 - 1 in unsigned arithmetic wraps to SIZE_MAX, which is greater than 0. In -1 < 1u the usual arithmetic conversions turn -1 into unsigned, giving UINT_MAX < 1, which is false. Converting to a signed type first gives the intuitive answer, and C++20's std::ssize returns a signed ptrdiff_t for exactly this purpose. The classic bug is for (size_t i = v.size() - 1; i >= 0; --i), which never terminates (and crashes on an empty vector). -Wall -Wextra warns about line b (-Wsign-compare). C++20's std::cmp_less(-1, 1u) compares mixed signs correctly.

Find the bugThe fill sizes are right, but the total and the average come out wrong. What is the bug?

#include <cstdio>
#include <numeric>
#include <vector>

int main() {
    std::vector<double> fills{0.5, 0.25, 0.75, 1.5};
    auto total = std::accumulate(fills.begin(), fills.end(), 0);
    std::printf("total=%g average=%g\n", static_cast<double>(total),
                static_cast<double>(total) / static_cast<double>(fills.size()));
    std::vector<int> volumes{2'000'000'000, 2'000'000'000};
    std::printf("volume=%lld\n", std::accumulate(volumes.begin(), volumes.end(), 0LL));
}
Answer
total=1 average=0.25
volume=4000000000

Answer. std::accumulate keeps its running sum in the type of the initial value, and 0 is an int. Each step computes acc = acc + x in double and converts the result back to int, truncating: 0.5, 0.25 and 0.75 each truncate back to 0, and the last step gives 1. The true total is 3.

Fix. Pass an initial value of the right type: 0.0, or 0LL for large integer sums as on the second line, where an int initial value would overflow, which is undefined behaviour. Compilers stay quiet because the narrowing happens inside a standard header. std::reduce has the same rule. C++23's std::ranges::fold_left computes in the type of op(init, element), which would be double here.

Find the bugA helper returns a string_view to avoid copying the name. What is wrong?

#include <cstdio>
#include <string>
#include <string_view>

static std::string_view venue_name(int id) {
    std::string name = "venue-" + std::to_string(id) + "-primary-matching-engine";
    return name;
}

int main() {
    std::string_view v = venue_name(7);
    unsigned sum = 0;
    for (char c : v) sum += static_cast<unsigned char>(c);  // reads the characters
    std::printf("%zu chars, checksum %u\n", v.size(), sum);
}
Answer

Answer. The view points into the local name, which is destroyed when the function returns, so main reads freed heap memory. ASan reports heap-use-after-free. The implicit conversion from std::string to std::string_view makes this compile without a cast. Clang warns with -Wreturn-stack-address, because it knows that string_view borrows from std::string. GCC usually stays quiet.

Fix. Return std::string. NRVO or a move makes that cheap, and there is nothing to save by returning a view of something the function itself owns. A string_view return is right only when the characters live elsewhere and outlive the call: a string literal, a member of a long-lived object, or a slice of the caller's input.

CodeImplement an LRU cache of int keys and values with O(1) get and put.

LruCache(capacity); std::optional<int> get(int key) returns the value and marks the key most recently used; put(int key, int value) inserts or updates, and when the cache is full it evicts the least recently used key first. Capacity 0 stores nothing.

Answer

Approach. Keep the entries in a std::list ordered from most to least recently used, and an unordered_map from key to list iterator. List iterators stay valid when other nodes are inserted, erased or moved, so the map never needs fixing up. A hit moves its node to the front with splice, which relinks the node without copying or allocating. Eviction removes the back node and its map entry.

#include <cstdio>
#include <list>
#include <optional>
#include <unordered_map>
#include <utility>

class LruCache {
public:
    explicit LruCache(std::size_t capacity) : cap_(capacity) {}

    std::optional<int> get(int key) {
        auto it = index_.find(key);
        if (it == index_.end()) return std::nullopt;
        items_.splice(items_.begin(), items_, it->second);
        return it->second->second;
    }

    void put(int key, int value) {
        if (cap_ == 0) return;
        if (auto it = index_.find(key); it != index_.end()) {
            it->second->second = value;
            items_.splice(items_.begin(), items_, it->second);
            return;
        }
        if (items_.size() == cap_) {
            index_.erase(items_.back().first);
            items_.pop_back();
        }
        items_.emplace_front(key, value);
        index_[key] = items_.begin();
    }

private:
    using Item = std::pair<int, int>;
    std::size_t cap_;
    std::list<Item> items_;
    std::unordered_map<int, std::list<Item>::iterator> index_;
};

static void show(const char* label, std::optional<int> v) {
    if (v) std::printf("%s=%d ", label, *v);
    else std::printf("%s=miss ", label);
}

int main() {
    LruCache c(2);
    c.put(1, 10);
    c.put(2, 20);
    show("get1", c.get(1));
    c.put(3, 30);           // evicts 2, the least recently used
    show("get2", c.get(2));
    c.put(1, 11);           // update moves 1 to the front
    c.put(4, 40);           // evicts 3
    show("get1", c.get(1));
    show("get3", c.get(3));
    show("get4", c.get(4));
    LruCache none(0);
    none.put(1, 1);
    show("zero", none.get(1));
    std::printf("\n");
}
get1=10 get2=miss get1=11 get3=miss get4=40 zero=miss

Complexity and pitfalls. O(1) average per operation (hash lookup plus constant list relinking), O(capacity) memory. Pitfalls: storing list indices or using a vector, whose iterators are invalidated by erasure; forgetting to refresh recency on an update; erasing the map entry after pop_back (the key is gone by then, so read it first); and capacity 0. A thread-safe version needs a mutex even for get, because a hit modifies the list. For a hot cache, a node-based list allocates on every insert, so production versions use an intrusive list over a preallocated node pool.

Estimated rating: ~1500 · a standard design question, judged on iterator stability and the eviction order.

Round 2: systems team screen

DrillsCoding

Time limit: 30 minutes. Scoring: 18 points (2 concept × 1, 2 tricky × 2, 2 output × 2, 2 bug × 2, code 4). 15 or more is a strong pass. This round leans on the OS, process lifetime and locking, as a backend or infrastructure team would. Review material: Ch 4, Ch 6 and Ch 8.

ConceptWhat is the difference between a minor and a major page fault? What happens the first time a program writes to a 1 GiB buffer it just got from malloc?

Answer

Answer. A page fault happens when the CPU touches a virtual page with no valid mapping in the page table. A minor fault is resolved without I/O: the kernel allocates a zeroed page, or maps a page already in the page cache, or breaks a copy-on-write share. A major fault needs disk I/O, for example reading a page of a memory-mapped file or swapping a page back in, and costs milliseconds instead of about a microsecond. A large malloc is served by mmap, which only reserves address space. Each first write to a 4 KiB page then triggers a minor fault that allocates and zeroes one physical page, so the first pass over 1 GiB takes about 262000 faults.

Why. This is why latency-sensitive programs "pre-fault" buffers at startup (touch every page, or use MAP_POPULATE and mlock), and why RSS grows only as memory is touched. Huge pages (2 MiB) cut the number of faults and TLB misses by a factor of 512. Linux overcommit means the malloc succeeds even when the memory might not exist, and the failure shows up later as the OOM killer.

ConceptA 20 GiB in-memory risk server calls fork() so that the child can write a snapshot to disk, as Redis does. Why does fork return quickly, what does it still cost, and why can the parent's latency spike afterwards?

Answer

Answer. fork copies no data pages. It duplicates the page tables and marks every private writable page read-only in both processes, so the child sees a frozen snapshot through copy-on-write. What it does copy is the page tables themselves: with 4 KiB pages, 20 GiB is about 5.2 million entries, roughly 40 MiB of tables, and the parent is stopped while they are copied, on the order of 10 ms per GiB. Afterwards, the first write by either process to a shared page takes a page fault that copies that page, so a write-heavy parent sees latency spikes and its memory use can grow towards double.

Why. With transparent huge pages each copy-on-write fault copies 2 MiB instead of 4 KiB, which is why Redis recommends disabling THP. With strict overcommit (vm.overcommit_memory = 2), fork of a large process can fail outright, because the kernel must reserve memory for a full copy. Only the calling thread exists in the child (Ch 4). To run another program, posix_spawn or vfork avoids copying page tables at all.

TrickyFour threads each write std::cout << "id="ch26- << id << " px=" << px << '\n';. Is this a data race? What does the log look like?

Answer

Answer. It is not a data race: the standard guarantees that concurrent use of the standard stream objects does not cause a data race as long as they are synchronised with stdio (the default, sync_with_stdio(true)) ([iostream.objects.overview]). But each << is a separate call, so pieces of different lines interleave: id=1 px=id=2 px=10.5.

Why. Thread safety per call does not make a sequence of calls atomic. Fixes: format the whole line into a string first and write it with one call, or use C++20 std::osyncstream(std::cout) << ..., which buffers the line and emits it atomically when the temporary is destroyed. After std::ios::sync_with_stdio(false) (a common speed trick) the guarantee is gone, and concurrent writes become a real data race. In a low-latency system, threads should push log records to a queue and one thread should do the I/O.

TrickyThe main thread detects a fatal configuration error and calls std::exit(1) while worker threads are still running. Occasionally the process crashes on the way out instead of exiting cleanly. Why?

Answer

Answer. exit runs the destructors of objects with static storage duration (and atexit handlers) while the other threads keep running. A worker that is still using a global logger, a global std::map or a static allocator pool can touch an object that has just been destroyed. The crash then happens in a worker, far from the exit call.

Why. Process exit does not stop threads first: they are killed only when the process finally terminates after the destructors. Options: stop and join the workers before exiting (the clean answer), use std::quick_exit with at_quick_exit handlers, which skips static destructors, or _exit, which skips everything including stdio flushing. Returning from main behaves like exit, so the same crash appears if main returns with detached threads. A destroyed std::thread that is still joinable calls std::terminate instead, which at least fails loudly.

OutputA risk check looks up per-symbol limits. How many symbols does the map hold afterwards?

#include <cstdio>
#include <map>
#include <string>

int main() {
    std::map<std::string, int> limits{{"AAPL", 500}};
    int over = 0;
    for (const char* s : {"AAPL", "MSFT", "TSLA"})
        if (limits[s] > 100) ++over;
    std::printf("over=%d size=%zu MSFT=%d\n", over, limits.size(), limits["MSFT"]);
    std::printf("has GOOG=%d size=%zu\n", limits.contains("GOOG"), limits.size());
}
Answer
over=1 size=3 MSFT=0
has GOOG=0 size=3

Why. operator[] means "find or insert": for a missing key it inserts a value-initialised int (0) and returns a reference to it. So reading limits["MSFT"] silently adds MSFT and TSLA with a limit of 0. That is why operator[] is not available on a const map, and why it fails to compile for value types without a default constructor. Use find, contains (C++20) or at (throws std::out_of_range) to read. In a risk system this bug is worse than a crash, because a missing limit becomes a limit of zero (or, with a different check, no limit at all). With several threads, operator[] is also a write, so readers sharing a lock in read mode race.

OutputGlobals, a function-local static and a static local in main. In what order are they built and destroyed?

#include <cstdio>

struct Tracer {
    const char* n;
    explicit Tracer(const char* s) : n(s) { std::printf("+%s ", n); }
    ~Tracer() { std::printf("-%s ", n); }
};

Tracer g1("g1");

static Tracer& logger() {
    static Tracer t("logger");
    return t;
}

Tracer g2("g2");

int main() {
    std::printf("main ");
    logger();
    static Tracer local("local");
    std::printf("end\n");
}
Answer
+g1 +g2 main +logger +local end
-local -logger -g2 -g1

Why. Globals in one translation unit are constructed before main in definition order, so g1 then g2; defining logger() between them changes nothing, because a function-local static is built only when control first passes its declaration. Every object with static storage is destroyed after main returns in the reverse order of the completion of their constructors ([basic.start.term]), mixing globals and locals in one sequence. The trap: if ~g2 called logger() to log its own shutdown, it would use a destroyed object, because the logger was built after g2 and is therefore destroyed before it. The usual fix for a logger used during shutdown is a deliberately leaked singleton: static Tracer& t = *new Tracer("logger");.

Find the bugRemove all even order ids from a std::list. What is wrong?

#include <cstdio>
#include <list>

int main() {
    std::list<int> orders{1, 2, 3, 4, 5, 6};
    for (auto it = orders.begin(); it != orders.end(); ++it)
        if (*it % 2 == 0) orders.erase(it);
    std::printf("%zu\n", orders.size());
}
Answer

Answer. erase(it) frees the node that it points to, and the loop then executes ++it, which reads the freed node's next pointer. ASan reports heap-use-after-free. Without ASan it often appears to work, because the freed node's memory still holds the old links.

Fix. Use the iterator that erase returns: for (auto it = l.begin(); it != l.end();) it = (*it % 2 == 0) ? l.erase(it) : std::next(it);. Better, use orders.remove_if(pred) or C++20 std::erase_if(orders, pred), which work for every standard container. For a list, erasing invalidates only iterators to the erased node, unlike a vector, where every iterator after the erased element is invalidated.

Find the bugEach class locks its own mutex, and each method is correct on its own. The system hangs a few times a day. Find the cycle.

#include <mutex>
#include <thread>
#include <vector>

struct Risk;

struct Book {
    std::mutex m;
    std::vector<int> orders{1, 2, 3};
    Risk* risk = nullptr;
    void on_fill(int qty);
    void cancel_all() {
        std::lock_guard<std::mutex> g(m);
        orders.clear();
    }
};

struct Risk {
    std::mutex m;
    long exposure = 0;
    Book* book = nullptr;
    void update(int qty) {
        std::lock_guard<std::mutex> g(m);
        exposure += qty;
    }
    void breach() {
        std::lock_guard<std::mutex> g(m);
        exposure = 0;
        book->cancel_all();
    }
};

void Book::on_fill(int qty) {
    std::lock_guard<std::mutex> g(m);
    orders.push_back(qty);
    risk->update(qty);
}

int main() {
    Book b;
    Risk r;
    b.risk = &r;
    r.book = &b;
    std::thread t1([&] { for (int i = 0; i < 100000; ++i) b.on_fill(1); });
    std::thread t2([&] { for (int i = 0; i < 100000; ++i) r.breach(); });
    t1.join();
    t2.join();
}
Answer

Answer. A lock-order inversion. on_fill holds Book::m and then takes Risk::m inside update. breach holds Risk::m and then takes Book::m inside cancel_all. If thread 1 holds the book lock while thread 2 holds the risk lock, each waits for the other forever. The harness only compiles this program, because running it can hang.

Fix. Never call into another component while holding your own lock. on_fill should update its orders under the lock, release it, then call risk->update. breach should reset exposure under its lock and call cancel_all after releasing it. When two locks really must be held together, take them in one step with std::scoped_lock lock(book.m, risk.m);, which uses a deadlock-avoidance algorithm. Tools: TSan reports "lock-order-inversion (potential deadlock)" from a single run, even when the hang does not happen, and so does Helgrind.

Book::mheld by thread 1 Risk::mheld by thread 2 on_fill → update breach → cancel_all An edge A → B means "some thread acquires B while holding A". Any cycle in this graph is a possible deadlock.
Deadlock needs a cycle in the lock-order graph. Each call chain is harmless alone. Together they form the cycle Book → Risk → Book, so two threads can each hold one lock and wait for the other.

CodeWrite Published<T>: configuration that many reader threads read constantly and one writer occasionally replaces.

get() returns a snapshot that stays valid and unchanged for as long as the reader holds it, even while a new version is published. update(f) applies f to a copy of the current version and publishes the result. Readers must never see a half-updated object, and the old version must be destroyed only when its last reader lets go.

Answer

Approach. Store the current version as std::shared_ptr<const T>. A reader copies the pointer under a short lock, which bumps the reference count, and then uses the immutable snapshot without any lock. The writer copies the current version, modifies the copy outside the read lock, and swaps the pointer in under the lock. Readers holding the old snapshot keep it alive, and it is freed when the last one drops it. A second mutex serialises writers so that two concurrent updates cannot lose each other's changes.

#include <atomic>
#include <cstdio>
#include <memory>
#include <mutex>
#include <thread>
#include <utility>
#include <vector>

template <class T>
class Published {
public:
    explicit Published(T initial) : cur_(std::make_shared<const T>(std::move(initial))) {}

    std::shared_ptr<const T> get() const {
        std::lock_guard<std::mutex> g(m_);
        return cur_;
    }

    template <class F>
    void update(F f) {
        std::lock_guard<std::mutex> w(writer_);
        auto next = std::make_shared<T>(*get());
        f(*next);
        std::shared_ptr<const T> old;
        {
            std::lock_guard<std::mutex> g(m_);
            old = std::exchange(cur_, std::move(next));
        }
    }  // old is released here, outside the read lock

private:
    mutable std::mutex m_;
    std::mutex writer_;
    std::shared_ptr<const T> cur_;
};

struct Limits {
    int max_qty;
    long max_notional;  // invariant: max_notional == max_qty * 200
};

int main() {
    Published<Limits> cfg(Limits{100, 20000});
    std::atomic<int> violations{0};
    std::vector<std::thread> readers;
    for (int i = 0; i < 4; ++i)
        readers.emplace_back([&] {
            for (int k = 0; k < 2000; ++k) {
                auto snap = cfg.get();
                if (snap->max_notional != snap->max_qty * 200L) ++violations;
            }
        });
    std::thread writer([&] {
        for (int k = 0; k < 1000; ++k)
            cfg.update([](Limits& l) {
                l.max_qty += 1;
                l.max_notional = l.max_qty * 200L;
            });
    });
    for (auto& t : readers) t.join();
    writer.join();
    std::printf("violations=%d max_qty=%d\n", violations.load(), cfg.get()->max_qty);
}
violations=0 max_qty=1100

Complexity and pitfalls. A read is one short critical section plus two atomic count updates. An update copies T. Pitfalls: returning const T& instead of a shared_ptr (the object may be replaced and freed while in use), modifying the published object in place (readers see half an update), destroying the old version while holding the read lock (its destructor might be slow), and writers without their own lock (two read-modify-write updates race and one is lost). C++20 std::atomic<std::shared_ptr<const T>> removes the read mutex where the library supports it, and production systems use RCU or hazard pointers to make reads free of shared writes altogether.

Estimated rating: ~1600 · a short design whose correctness rests on immutability and on shared_ptr lifetime.

Round 3: memory and move semantics

DrillsCoding

Time limit: 30 minutes. Scoring: 18 points (2 concept × 1, 2 tricky × 2, 2 output × 2, 2 bug × 2, code 4). 15 or more is a strong pass. This is the round a C++ library or engine team runs to check ownership reasoning. Review material: Ch 11, Ch 20 and Ch 22.

ConceptWhy does std::optional<int&> not compile in C++23, and what do people use instead?

Answer

Answer. The C++17 optional forbids reference types because assignment would be ambiguous: opt = x; could either rebind the optional to refer to x or assign through to the referred object, and the two choices behave differently depending on whether the optional was engaged. The committee left it out rather than pick. The usual replacements are a plain T* (null means empty, and the pointer is non-owning by convention) or std::optional<std::reference_wrapper<T>>.

Why. A function like find_order(id) that may return "no order" is exactly where people reach for it. Returning T* is clear and cheap. C++26 adds std::optional<T&> (P2988, voted into the working draft in June 2025) with rebinding assignment, matching what Boost has done for years. Never return std::optional<T> by value when the caller needs to modify the original, since that silently copies.

ConceptName three reasons why std::vector<T> growth might copy every element instead of moving it.

Answer

Answer. First, the move constructor is not noexcept while a copy constructor exists, so move_if_noexcept picks the copy to keep the strong guarantee. Second, T has no move constructor at all, typically because a user-declared destructor or copy operation suppressed the implicit one. Third, the move constructor exists but copies anyway, for example because a member is const or because it was written as m(o.m) instead of m(std::move(o.m)).

Why. All three compile silently. A fourth, rarer cause is a member whose own move is not noexcept: a defaulted move constructor is noexcept only if every member's move is, so one such member (some older std::function or third-party types) makes the whole class copy. Check with static_assert(std::is_nothrow_move_constructible_v<T>) next to the class.

TrickyA task queue is std::queue<std::function<void()>>. Pushing [conn = std::move(conn)] { conn->flush(); }, where conn is a std::unique_ptr, fails to compile. Why, and what are the fixes?

Answer

Answer. std::function requires its target to be copy constructible, because std::function itself is copyable and must be able to copy whatever it holds. A lambda that captures a unique_ptr by move is move-only, so it cannot be stored.

Why. Fixes: use C++23 std::move_only_function<void()>, which drops the copy requirement (and adds const and noexcept qualifiers in its signature); write a small type-erased move-only callable yourself; or convert the resource to std::shared_ptr so the lambda becomes copyable, at the cost of an atomic count and shared ownership you did not want. Do not "fix" it by capturing a raw pointer and releasing the unique_ptr, which leaks if the task is never run.

TrickyTo avoid reallocations, code calls v.reserve(1000); on an empty std::vector<double> and then fills it with for (int i = 0; i < 1000; ++i) v[i] = price(i);. It runs, and the values read back correctly. Is it correct?

Answer

Answer. No, it is undefined behaviour. reserve changes the capacity only. The size is still 0, so no elements exist, and operator[] with an index not less than size() is undefined. It appears to work because the memory is allocated and double needs no construction. Then v.size() is still 0, so any loop over the vector, any copy and any push_back ignores or overwrites the "stored" values.

Why. Use resize(1000) (or the size constructor) and then assign, or keep reserve and use push_back/emplace_back. For a class type the bug is worse, because assignment then runs on objects that were never constructed. Hardened library modes catch it at run time: -D_GLIBCXX_ASSERTIONS for libstdc++ and -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST for libc++ both turn an out-of-range operator[] into an abort, which is cheap enough to leave on in many production builds. v.at(i) throws std::out_of_range instead.

OutputCount the copies and moves for brace initialisation and for emplace_back.

#include <cstdio>
#include <string>
#include <utility>
#include <vector>

struct Tracked {
    static inline int copies = 0, moves = 0;
    std::string s;
    Tracked(const char* p) : s(p) {}
    Tracked(const Tracked& o) : s(o.s) { ++copies; }
    Tracked(Tracked&& o) noexcept : s(std::move(o.s)) { ++moves; }
};

int main() {
    std::vector<Tracked> a{"x", "y", "z"};
    std::printf("braces: copies=%d moves=%d\n", Tracked::copies, Tracked::moves);
    Tracked::copies = Tracked::moves = 0;
    std::vector<Tracked> b;
    b.reserve(3);
    b.emplace_back("x");
    b.emplace_back("y");
    b.emplace_back("z");
    std::printf("emplace: copies=%d moves=%d\n", Tracked::copies, Tracked::moves);
}
Answer
braces: copies=3 moves=0
emplace: copies=0 moves=0

Why. A braced list first builds a hidden const Tracked[3] array (three converting constructions), and std::initializer_list gives only const access to it. The vector cannot move from const elements, so it copies all three. emplace_back constructs each element in place from the const char*, and reserve prevents any relocation. Consequences: std::vector<std::unique_ptr<int>> v{std::make_unique<int>(1)}; does not compile, and initialising a vector of large strings from a braced list allocates every string twice. C++26 does not change this, so for move-only or expensive types use reserve plus emplace_back.

OutputA stateful callback counts its calls. It is copied into a std::function, which is then copied and moved. What prints?

#include <cstdio>
#include <functional>
#include <utility>

int main() {
    auto counter = [n = 0]() mutable { return ++n; };
    std::function<int()> f = counter;
    f();
    f();
    int from_f = f();
    int from_counter = counter();
    std::printf("f=%d counter=%d\n", from_f, from_counter);
    std::function<int()> g = f;
    std::function<int()> h = std::move(f);
    int from_g = g();
    int from_h = h();
    std::printf("g=%d h=%d\n", from_g, from_h);
}
Answer
f=3 counter=1
g=4 h=4

Why. std::function stores its own copy of the callable, so the mutable lambda's counter n is duplicated: three calls through f leave the original counter untouched, and its first call returns 1. Copying f copies the state (so g continues from 3), and moving f transfers it (so h also continues from 3). A moved-from std::function is valid but unspecified. Both libraries leave it empty, so calling it would throw std::bad_function_call, but code must not rely on either outcome.

To share one state, capture a pointer or a shared_ptr, or store std::ref(counter), which puts a reference_wrapper inside the std::function. The same copying hides in std::thread, which copies its callable, and in algorithms such as std::for_each, which take function objects by value. The common wrong answer is "f=3 counter=3".

Find the bugA move-only packet buffer. The program prints 1500 and then dies. Why?

#include <cstddef>
#include <cstdio>
#include <cstring>
#include <vector>

class Packet {
public:
    explicit Packet(std::size_t n) : size_(n), data_(new char[n]) { std::memset(data_, 0, n); }
    Packet(Packet&& o) noexcept : size_(o.size_), data_(o.data_) {}
    Packet(const Packet&) = delete;
    Packet& operator=(const Packet&) = delete;
    ~Packet() { delete[] data_; }
    std::size_t size() const { return size_; }

private:
    std::size_t size_;
    char* data_;
};

int main() {
    std::vector<Packet> q;
    q.push_back(Packet(1500));
    std::printf("%zu\n", q[0].size());
}
Answer

Answer. The move constructor copies the pointer but does not clear it in the source. The temporary Packet(1500) is destroyed at the end of the push_back statement and frees the buffer, so the element in the vector holds a dangling pointer, and its destructor frees the same buffer again at the end of main. ASan reports attempting double-free.

Fix. Leave the source empty: data_(std::exchange(o.data_, nullptr)) and size_(std::exchange(o.size_, 0)). A move must leave the source in a state its destructor can handle. Better still, hold the buffer as std::unique_ptr<char[]> and let the compiler generate the moves (rule of zero). This bug hides in tests that never destroy the moved-from object before the program ends, and it is a favourite because the code looks complete.

Find the bugA recorder starts its background thread in the constructor. TSan reports a race in a program with no visible sharing. Where?

#include <cstdio>
#include <thread>
#include <vector>

class Recorder {
public:
    Recorder() : worker_([this] { samples_.push_back(42); }) {}
    ~Recorder() { worker_.join(); }
    std::size_t count() const { return samples_.size(); }

private:
    std::thread worker_;
    std::vector<int> samples_;
};

int main() {
    static Recorder r;
    std::printf("%zu\n", r.count());
}
Answer

Answer. Members are initialised in declaration order, and worker_ is declared before samples_. The thread starts while samples_ has not been constructed yet, so the new thread's push_back races with the main thread's construction of the vector, which may also overwrite the element the thread just added. count() then races with the thread too, since nothing waits for it. TSan reports the race on the vector's members.

Fix. Declare the thread last so every member it uses exists before it starts, and treat starting a thread as the final step of construction. Here the object is also published to the thread before its constructor finishes, so if the class had derived classes or virtual functions, the thread could see a half-built object. Reading results requires synchronisation as well: join (or wait on a condition) before calling count(). With std::jthread declared last, the destructor also gets the order right: it stops and joins the thread before the other members are destroyed.

CodeWrite a string interner that maps symbol names to dense ids and back, with views that never dangle.

std::uint32_t intern(std::string_view s) returns the existing id for a known string or assigns the next id. std::string_view name(id) returns the stored text, and every view or id handed out must stay valid for the interner's lifetime, however many strings are added later. Lookups of known strings must not allocate.

Answer

Approach. Store each string once in a std::deque<std::string>: pushing at the end of a deque never moves existing elements, so references to them stay valid. The hash map is keyed by std::string_views that point at those stored strings, so a lookup with the caller's string_view hashes the characters without building a std::string. A vector of views gives O(1) id-to-name.

#include <cstdint>
#include <cstdio>
#include <deque>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>

class Interner {
public:
    std::uint32_t intern(std::string_view s) {
        if (auto it = ids_.find(s); it != ids_.end()) return it->second;
        const std::string& stored = store_.emplace_back(s);
        auto id = static_cast<std::uint32_t>(views_.size());
        views_.push_back(stored);
        ids_.emplace(views_.back(), id);
        return id;
    }
    std::string_view name(std::uint32_t id) const { return views_.at(id); }
    std::size_t size() const { return views_.size(); }

private:
    std::deque<std::string> store_;  // never relocates existing strings
    std::vector<std::string_view> views_;
    std::unordered_map<std::string_view, std::uint32_t> ids_;
};

int main() {
    Interner in;
    std::uint32_t a = in.intern("AAPL");
    std::uint32_t m = in.intern("MSFT");
    std::string key = "AAPL";
    std::uint32_t again = in.intern(key);
    std::string_view early = in.name(m);
    for (int i = 0; i < 10000; ++i) in.intern("SYM" + std::to_string(i));
    std::printf("AAPL=%u MSFT=%u again=%u size=%zu\n", a, m, again, in.size());
    std::printf("early view=%.*s name0=%.*s\n", static_cast<int>(early.size()), early.data(),
                static_cast<int>(in.name(0).size()), in.name(0).data());
}
AAPL=0 MSFT=1 again=0 size=10002
early view=MSFT name0=AAPL

Complexity and pitfalls. O(length) per intern (one hash), O(1) per name. The tempting std::vector<std::string> store is the trap: on growth it moves the strings, and a short string's characters live inside the std::string object (SSO), so every string_view to it dangles. Long strings would survive by accident, which makes the bug data-dependent. Keying the map by std::string would work but costs a second copy of every name, and lookups with a string_view would need C++20 heterogeneous lookup (a transparent hash and equality). An arena of characters with views into it is the faster production layout (Ch 17).

Estimated rating: ~1500 · short code, judged on reference stability and on not allocating for lookups.

Round 4: networking and the OS boundary

DrillsCoding

Time limit: 30 minutes. Scoring: 18 points (2 concept × 1, 2 tricky × 2, 2 output × 2, 2 bug × 2, code 4). 15 or more is a strong pass. Expect this round for networking, backend and exchange-connectivity roles. Review material: Ch 7, Ch 8 and Ch 4.

ConceptYou type https://example.com/ into a browser on a fresh laptop. List the network steps until the first byte of the page arrives, in order.

Answer

Answer. (1) DHCP has already given the laptop an IP address, a default gateway and a DNS server. (2) The browser resolves example.com: it checks its own and the OS caches, then sends a UDP query to the resolver. Because the resolver is on another network, the packet goes to the gateway, whose MAC address the laptop learns with ARP. The resolver walks root, .com and the authoritative server if it has no cached answer. (3) A TCP three-way handshake to port 443 (one RTT). (4) A TLS 1.3 handshake (one more RTT): ClientHello with a key share and SNI, ServerHello, certificate and Finished, then keys are derived. (5) An encrypted HTTP request (HTTP/2 negotiated through ALPN), and the server's response.

Why. The interviewer listens for layering (ARP below IP, DNS over UDP, TLS over TCP) and for where round trips go: DNS, TCP and TLS each cost at least one RTT before the request. Bonus points: NAT rewrites the source address at the home router, HTTP/3 replaces TCP and TLS with QUIC to merge those handshakes, and connection reuse skips everything after step 2 for later requests.

ConceptWhat does a socket's receive buffer (SO_RCVBUF) do, and what happens to a UDP market data feed when the application falls behind and the buffer fills?

Answer

Answer. The receive buffer is the kernel memory where packets wait between arriving from the NIC and being read by the application. For TCP, a full buffer shrinks the advertised window to zero and the sender is told to wait, so no data is lost. UDP has no flow control, so once the buffer is full the kernel silently drops every new datagram. The application only notices through sequence-number gaps, and the kernel counts the drops (RcvbufErrors in netstat -su or /proc/net/snmp).

Why. Bursts at the market open can be many times the average rate, so feed handlers set a large SO_RCVBUF (Linux caps it at net.core.rmem_max, and reports back double the value you asked for, because it reserves room for bookkeeping) and, more importantly, keep the reading thread from ever blocking. A big buffer only absorbs bursts. It does not help if the consumer is slower on average, and it adds queueing delay to every packet behind the backlog.

TrickyYour client is blocked in recv on a TCP connection. What does it see if the server process crashes, and what if the server machine loses power?

Answer

Answer. If the process crashes, the server's kernel closes its sockets: it sends FIN (or RST if unread data was pending), and the client's recv returns 0 or fails with ECONNRESET almost immediately. If the machine loses power, nothing is sent at all. The client's recv keeps blocking indefinitely, because an idle TCP connection exchanges no packets and so cannot notice that the peer is gone.

Why. TCP detects a dead peer only when it tries to send: retransmissions of unacknowledged data eventually time out (with the Linux default tcp_retries2 = 15, after about 924.6 seconds, roughly 15 minutes), or TCP_USER_TIMEOUT sets a shorter limit. For idle connections, enable keepalive and shorten it (TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT), since keepalive is off unless you set SO_KEEPALIVE, and even then the first probe comes only after 2 hours of silence (tcp_keepalive_time = 7200), followed by 9 probes 75 seconds apart. Trading protocols such as FIX add application-level heartbeats for exactly this reason: a missed heartbeat interval is the reliable signal. When the machine reboots and the client then sends, the fresh kernel answers with RST.

TrickyExchanges publish market data over UDP multicast, not TCP. Why, and what does a feed handler do when it sees sequence number 1005 right after 1003?

Answer

Answer. Multicast lets the network replicate one stream to every subscriber, so the exchange sends each packet once and all participants receive it at nearly the same time. TCP would need one connection per client, and one slow client's retransmissions and flow control would never be allowed to delay the others. A gap from 1003 to 1005 means 1004 was lost or reordered. The handler buffers 1005 onward, waits briefly in case 1004 arrives late or on the second feed, and otherwise requests a retransmission or rebuilds its state from a snapshot channel.

Why. Exchanges usually publish two identical feeds (A and B) over separate network paths, and the handler takes whichever copy of each sequence number arrives first (line arbitration), which hides most single-path losses. Recovery uses a separate TCP or unicast retransmission service and periodic snapshots. The order book must not be updated past a gap, since applying 1005 without 1004 can leave a phantom order. Every packet carries a sequence number precisely because UDP itself gives no ordering or delivery guarantee.

OutputA thread blocks in read on an empty pipe, and a timer signal arrives after 50 ms. What does read return?

#include <cerrno>
#include <csignal>
#include <cstdio>
#include <cstring>
#include <sys/time.h>
#include <unistd.h>

static void on_alarm(int) {}

int main() {
    int p[2];
    if (pipe(p) != 0) return 1;
    struct sigaction sa {};
    sa.sa_handler = on_alarm;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;  // no SA_RESTART
    sigaction(SIGALRM, &sa, nullptr);

    itimerval t{};
    t.it_value.tv_usec = 50000;
    setitimer(ITIMER_REAL, &t, nullptr);

    char c;
    ssize_t n = read(p[0], &c, 1);
    int err = errno;
    std::printf("read=%zd errno=%s\n", n, err == EINTR ? "EINTR" : std::strerror(err));
}
Answer
read=-1 errno=EINTR

Why. When a signal handler runs while a thread is blocked in a "slow" system call (reads from pipes, sockets and terminals, accept, connect, waits), the call is interrupted. Without SA_RESTART it returns -1 with errno == EINTR, and no data has been lost. With SA_RESTART the kernel restarts most such calls transparently, and this program would block forever. Some calls never restart, whatever the flag (on Linux epoll_wait, poll, select and calls with a timeout such as nanosleep), so robust I/O code always retries on EINTR. Signals installed with the old signal() function get SA_RESTART semantics on glibc and BSD, which is why the bug often hides until someone switches to sigaction.

OutputWhich numbers does the kernel hand out? The program prints them relative to the first descriptor.

#include <cstdio>
#include <fcntl.h>
#include <unistd.h>

int main() {
    int a = open("/dev/null", O_RDONLY);
    int b = open("/dev/null", O_RDONLY);
    close(a);
    int c = open("/dev/null", O_RDONLY);
    int d = dup(b);
    std::printf("a=+%d b=+%d c=+%d d=+%d\n", a - a, b - a, c - a, d - a);
    close(b);
    close(c);
    close(d);
}
Answer
a=+0 b=+1 c=+0 d=+2

Why. POSIX requires open, socket, accept, dup and friends to return the lowest-numbered descriptor not currently open. After close(a), the next open immediately reuses a's number. dup(b) gets the lowest free number, which is now after b. In a normal process 0, 1 and 2 are taken, so a is 3. This rule is how shells redirect output (close 1, then open a file, and it becomes stdout), and it is what makes the next bug dangerous.

Find the bugThe receive helper failed with a reset connection, yet the code went on to parse a header. Why?

#include <cstddef>
#include <cstdio>
#include <sys/types.h>

struct Header { unsigned char bytes[8]; };

static ssize_t fake_recv(void*, std::size_t) { return -1; }  // as if errno == ECONNRESET

int main() {
    Header h;
    ssize_t n = fake_recv(&h, sizeof h);
    if (n < sizeof h) {
        std::puts("short read or error, handle it");
        return 0;
    }
    std::puts("header complete, parsing");
}
Answer
header complete, parsing

Answer. n < sizeof h compares a signed ssize_t with an unsigned size_t. The usual arithmetic conversions convert n to size_t, so -1 becomes SIZE_MAX, the comparison is false, and the error path is skipped. The code then parses an uninitialised header. -Wall -Wextra warns about it (-Wsign-compare), which is the cheapest bug fix in this chapter.

Fix. Check for errors first and separately: if (n < 0) { handle errno } if (static_cast<std::size_t>(n) < sizeof h) { short read }, or use std::cmp_less(n, sizeof h) from C++20, which compares mixed signs by value. Better, put the loop and both checks inside one recv_exact helper (Ch 8), so call sites never see a raw return value.

Find the bugAn error path closes a connection early, and later the audit log stops working. Connect the two.

#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <unistd.h>

struct Conn {
    int fd;
    ~Conn() { close(fd); }
};

int main() {
    int log = -1;
    {
        Conn c{open("/dev/null", O_RDONLY)};
        close(c.fd);                        // error path closes early
        log = open("/dev/zero", O_RDONLY);  // gets the lowest free number
        std::printf("same number: %s\n", log == c.fd ? "yes" : "no");
    }                                       // ~Conn closes c.fd again
    char buf[4];
    ssize_t n = read(log, buf, sizeof buf);
    std::printf("read from log: %zd (%s)\n", n, n < 0 ? std::strerror(errno) : "ok");
}
Answer
same number: yes
read from log: -1 (Bad file descriptor)

Answer. A double close. The error path closes c.fd but leaves the number in the object, the log file is opened and receives the same number (lowest free descriptor), and then ~Conn closes that number again, which closes the log. Every later write to the log fails with EBADF, or worse, a third open reuses the number and log lines go into some other file or socket.

Fix. Make one owner responsible for closing: a move-only RAII handle whose reset() closes and sets the stored value to -1, and never call close on a descriptor you do not own. In multi-threaded programs the same bug is a race: another thread's accept can grab the number between the two closes. No sanitizer catches it, but strace -e trace=close shows two closes of one number, and a failed close returning EBADF is a sign of it. Also note that on Linux you must not retry close after EINTR, since the descriptor is already released.

CodeWrite LineReader, which turns arbitrary chunks from a stream socket into complete lines.

feed(chunk, on_line) appends the bytes and calls on_line(std::string_view) once per complete line, without the trailing \n or \r\n. A partial line must be kept until the rest arrives, possibly several chunks later. Lines (complete or partial) longer than max_line bytes must make feed return false so the caller can drop the connection. Scanning must stay linear even when a long line arrives one byte at a time.

Answer

Approach. Keep a buffer of unconsumed bytes and the position up to which it has already been searched for a newline, so that bytes are scanned only once. Each feed appends, then repeatedly finds the next \n from the scan position, strips an optional \r, and hands out a view of the line. After the loop, the consumed prefix is erased in one step and the length limit is checked on whatever partial line is left.

#include <cstdio>
#include <string>
#include <string_view>

class LineReader {
public:
    explicit LineReader(std::size_t max_line) : max_(max_line) {}

    template <class F>
    bool feed(std::string_view chunk, F&& on_line) {
        buf_.append(chunk);
        std::size_t start = 0;
        for (std::size_t nl; (nl = buf_.find('\n', scan_)) != std::string::npos;) {
            std::size_t end = (nl > start && buf_[nl - 1] == '\r') ? nl - 1 : nl;
            if (end - start > max_) return false;
            on_line(std::string_view(buf_).substr(start, end - start));
            start = nl + 1;
            scan_ = start;
        }
        buf_.erase(0, start);
        scan_ = buf_.size();
        return buf_.size() <= max_;
    }

private:
    std::size_t max_;
    std::string buf_;
    std::size_t scan_ = 0;
};

int main() {
    LineReader r(64);
    auto print = [](std::string_view line) {
        std::printf("[%.*s]\n", static_cast<int>(line.size()), line.data());
    };
    r.feed("GET /a HT", print);
    r.feed("TP/1.1\r\nHost: x\r\n\r\npart", print);
    r.feed("ial", print);
    r.feed("\n", print);
    LineReader small(8);
    std::printf("oversize rejected: %s\n", small.feed("0123456789", print) ? "no" : "yes");
}
[GET /a HTTP/1.1]
[Host: x]
[]
[partial]
oversize rejected: yes

Complexity and pitfalls. Amortised O(n) in the bytes received: each byte is scanned once and the erase moves only the leftover partial line. Pitfalls: searching from the start of the buffer on every chunk (quadratic for a slow sender), forgetting the partial line at the end of a chunk, assuming a \r\n pair never splits across two chunks (it can, which is why the \r is stripped only once the \n is seen), and no length limit, which lets a peer that never sends a newline exhaust memory. Views passed to on_line are valid only during the call, since the next feed modifies the buffer.

Estimated rating: ~1300 · simple parsing, judged on partial input, the length limit and linear scanning.

Round 5: the hard round (HFT and low-latency systems)

DrillsCoding

Time limit: 45 minutes. Scoring: 22 points (2 concept × 1, 3 tricky × 2, 2 output × 2, 2 bug × 2, code 6: 3 for a correct solution, 1 for the memory orders explained, 1 for the cache layout, 1 for complexity and edge cases). 18 or more is the bar for a trading-firm onsite, and 14 to 17 is a pass for most systems roles. Interviewers here push every answer one level deeper, so practise the "why" sentences aloud. Review material: Ch 5, Ch 19 and Ch 25.

ConceptWhy do trading systems pin their hot threads to dedicated cores, and what else must be configured for the pinning to remove jitter?

Answer

Answer. Pinning (pthread_setaffinity_np or taskset) stops the scheduler from migrating a thread, which would cost it its warm L1/L2 caches and TLB, and lets a busy-polling thread own a core without being preempted. For that to work, the core must also be kept free of other work: remove it from the general scheduler (isolcpus or cpusets), stop the periodic timer tick on it (nohz_full), move device interrupts elsewhere (/proc/irq/*/smp_affinity), move kernel threads and RCU callbacks off it (rcu_nocbs), and disable deep C-states and frequency scaling so the core never sleeps or slows down.

Why. Each of these is a source of microsecond-scale interruptions that pinning alone does not prevent: a timer interrupt, a network IRQ, a kernel worker or a wake-up from a deep sleep state. Also keep the hot threads on the NIC's NUMA node and avoid putting two hot threads on hyperthread siblings, which share a core's execution units and L1 cache. The cost is cores that sit spinning at 100%, which is accepted in exchange for predictable latency.

ConceptWhat does kernel-bypass networking (for example Solarflare Onload, ef_vi or DPDK) remove from the receive path of a UDP packet?

Answer

Answer. In the normal path, the NIC writes the packet into a kernel ring buffer and raises an interrupt (or the kernel polls it via NAPI), the kernel's network stack processes it (IP, UDP, socket lookup), queues it on the socket, wakes the waiting thread, and recvfrom copies it into user memory across a system call. Kernel bypass maps the NIC's receive rings directly into the process, and a dedicated thread busy-polls them. That removes the interrupt, the context switch and wake-up, the system call and the copy, and the kernel stack itself.

Why. The kernel path costs several microseconds with significant jitter, while bypass brings the wire-to-application time down to around a microsecond or less. The costs: a core spinning at 100%, a user-space network stack (Onload keeps the sockets API, ef_vi and DPDK give raw frames), and losing kernel tools such as tcpdump on that traffic. It is usually combined with core isolation (isolcpus, nohz_full), IRQ affinity and huge pages.

TrickyA seqlock protects a 64-byte quote: the writer increments a sequence counter to odd, writes the fields, and increments it back to even. A reader copies the fields and retries if the counter was odd or changed. Why can the reader see a torn quote, why is that acceptable, and what is the C++ memory-model problem?

Answer

Answer. The reader takes no lock, so it can copy some fields from the old quote and some from the new one while the writer is in the middle of an update. That is acceptable because it then sees a changed sequence number and discards the copy, so a torn value is never used. The memory-model problem is that the reader's plain reads of the fields race with the writer's plain writes, which is a data race and therefore undefined behaviour in C++, even though the result is thrown away.

Why. Correct C++ versions make every payload field a std::atomic accessed with memory_order_relaxed (or copy it with relaxed atomic word loads), and they order the accesses with fences: the reader does an acquire load of the sequence, reads the payload, then atomic_thread_fence(memory_order_acquire) before re-reading the sequence. The writer does a release fence after making the counter odd and a release store to make it even. Without the fences, the CPU or compiler may move payload reads outside the two sequence reads. Seqlocks suit one writer and many readers of small data that changes often, because readers never write to shared memory and so never bounce the cache line. The writer can starve readers if it updates continuously.

TrickyWhat machine code does x.store(1) (default seq_cst) on a std::atomic<int> compile to on x86-64 and on ARM64? And x.load()?

Answer

Answer. On x86-64 a seq_cst store is an xchg (a locked instruction that acts as a full barrier), or a mov followed by mfence. A seq_cst load is a plain mov. On ARM64 the store is stlr (store-release) and the load is ldar (load-acquire), and that pair alone gives sequential consistency.

Why. x86 is TSO: the only reordering it allows is a later load passing an earlier store sitting in the store buffer, so the store side must drain the buffer, and that is the expensive operation: very roughly 10 to 30 cycles uncontended depending on the core, and far more when the line is contended. Release stores and acquire loads are plain movs on x86, which is why memory_order_release on a publish flag is free there while seq_cst is not. ARM's stlr/ldar were designed to implement exactly the C++ model, so seq_cst costs little more than release/acquire there. Relaxed operations are plain loads and stores on both. A fetch_add is lock xadd on x86 whatever the order, and ldaddal (with LSE atomics) or an ldaxr/stlxr loop on ARM64.

TrickyYou need nanosecond timestamps for every packet. How expensive is clock_gettime(CLOCK_MONOTONIC) on Linux, why is it not a real system call, and when would you read the TSC directly?

Answer

Answer. Typically some tens of nanoseconds (often quoted as 15 to 30 ns on a modern x86 server) when the kernel's clocksource is the TSC. glibc implements it in the vDSO, a small shared library the kernel maps into every process together with a data page holding the current clock parameters. The call reads the TSC with rdtsc and converts it using that data, with no switch into the kernel. If the clocksource is hpet or acpi_pm (some virtual machines, or after the kernel has marked the TSC unstable), the vDSO falls back to a real system call. A real system call costs roughly 100 ns or more, plus mitigations for speculative-execution attacks. Reading the TSC directly (__rdtsc(), or rdtscp to stop it executing early) costs a few nanoseconds (some tens of cycles) and gives raw tick counts that you convert to time later, off the hot path.

Why. Raw TSC is safe on modern CPUs with an invariant TSC (constant rate across frequency changes and synchronised across cores), which you should check in /proc/cpuinfo (constant_tsc, nonstop_tsc). rdtsc is not serialising, so measuring a short region needs rdtscp or fences. Never measure latency with CLOCK_REALTIME/system_clock, which NTP can step backwards. For wire timestamps, NICs offer hardware timestamping (SO_TIMESTAMPING), which is more accurate than anything done in software after the fact.

OutputA price-level bitmap marks which of 64 ticks have resting liquidity. What do the bit operations print?

#include <bit>
#include <cstdint>
#include <cstdio>
#include <initializer_list>

int main() {
    std::uint64_t levels = 0;  // bit i set: there is liquidity at tick i
    for (int tick : {3, 17, 40}) levels |= std::uint64_t{1} << tick;
    std::printf("lowest=%d highest=%d count=%d\n", std::countr_zero(levels),
                63 - std::countl_zero(levels), std::popcount(levels));
    std::printf("ceil=%u single=%d ctz0=%d\n", std::bit_ceil(1000u), std::has_single_bit(1024u),
                std::countr_zero(std::uint64_t{0}));
    levels &= levels - 1;  // clear the lowest set bit
    std::printf("after pop lowest=%d\n", std::countr_zero(levels));
}
Answer
lowest=3 highest=40 count=3
ceil=1024 single=1 ctz0=64
after pop lowest=17

Why. std::countr_zero counts trailing zero bits, which is the index of the lowest set bit, and 63 - std::countl_zero is the index of the highest. std::popcount counts set bits. std::bit_ceil rounds up to a power of two (ring buffer sizes) and std::has_single_bit tests for one. x &= x - 1 clears the lowest set bit, so the next level appears. The C++20 <bit> functions are defined for zero (countr_zero(0) is the bit width, 64), unlike __builtin_ctz(0), which is undefined because x86's bsf leaves its result undefined for a zero input. The wrong answer for the second number is countl_zero(levels) itself, which is 23.

Each call compiles to one or two instructions (tzcnt, lzcnt and popcnt on x86-64 with BMI, rbit plus clz on ARM64). One word covers 64 price levels, and a summary word over 64 words covers 4096 levels with two lookups, which is how some order books find the best bid or ask without scanning.

OutputByte arithmetic in a feed parser. What are the four lines?

#include <cstdint>
#include <cstdio>

int main() {
    std::uint8_t a = 200, b = 100;
    auto sum = a + b;
    std::uint8_t wrapped = a + b;
    std::uint8_t flags = 0x0F;
    std::printf("sum=%d size=%zu\n", sum, sizeof(sum));
    std::printf("wrapped=%d\n", wrapped);
    std::printf("~flags==0xF0: %d\n", ~flags == 0xF0);
    std::printf("uint8(~flags)==0xF0: %d\n", static_cast<std::uint8_t>(~flags) == 0xF0);
}
Answer
sum=300 size=4
wrapped=44
~flags==0xF0: 0
uint8(~flags)==0xF0: 1

Why. Integer promotion converts operands narrower than int to int before any arithmetic ([conv.prom]). a + b is an int with value 300, so auto deduces int. Storing it into a uint8_t reduces it modulo 256, giving 44. ~flags flips all 32 bits of the promoted int, giving 0xFFFFFFF0 (-16), which is not 0xF0. The dangerous relative: std::uint16_t x = 0xFFFF; x * x promotes both operands to signed int, and 65535 · 65535 overflows it, which is undefined behaviour although every type in the source is unsigned. Write 1u * x * x or cast to std::uint32_t first. Checksums and bitmask parsing in feed handlers are where this shows up.

Find the bugA single-producer single-consumer ring uses atomic indices, all relaxed "because there is only one writer per index". The sums usually match. What is wrong?

#include <atomic>
#include <cstdio>
#include <thread>

constexpr unsigned N = 1024;
static long data[N];
static std::atomic<unsigned> head{0}, tail{0};

int main() {
    constexpr int items = 20000;
    std::thread producer([] {
        for (int i = 1; i <= items; ++i) {
            unsigned h = head.load(std::memory_order_relaxed);
            while (h - tail.load(std::memory_order_relaxed) == N) {}
            data[h % N] = i;
            head.store(h + 1, std::memory_order_relaxed);
        }
    });
    long long sum = 0;
    for (int i = 0; i < items; ++i) {
        unsigned t = tail.load(std::memory_order_relaxed);
        while (head.load(std::memory_order_relaxed) == t) {}
        sum += data[t % N];
        tail.store(t + 1, std::memory_order_relaxed);
    }
    producer.join();
    std::printf("%lld\n", sum);
}
Answer

Answer. The index stores do not publish the data. A relaxed store of head creates no happens-before edge, so the consumer may see the new head and still read the old contents of data[h % N], and the plain accesses to data are a data race (TSan reports it). The same problem runs the other way: a relaxed tail store lets the producer overwrite a slot the consumer is still reading. On x86 the hardware happens not to reorder these particular stores, so the sums match there unless the compiler reorders them, while ARM really can.

Fix. The producer's head.store must be release and the consumer's head.load in the wait loop acquire, and symmetrically tail.store release with the producer's tail.load acquire. Each side's load of its own index can stay relaxed, since only that thread writes it. "One writer per variable" removes the need for read-modify-write operations, not the need for ordering. The coding question below builds the full version.

Find the bugA latency monitor timestamps orders with std::chrono::high_resolution_clock. Once a day it reports negative latencies. The program prints each clock's is_steady. Explain the output and the bug.

#include <chrono>
#include <cstdio>

template <class C>
static void show(const char* name) {
    std::printf("%s steady=%d\n", name, static_cast<int>(C::is_steady));
}

int main() {
    show<std::chrono::system_clock>("system_clock");
    show<std::chrono::steady_clock>("steady_clock");
    show<std::chrono::high_resolution_clock>("high_resolution_clock");
}
Answer
system_clock steady=0
steady_clock steady=1
high_resolution_clock steady=0

Answer. This output is for GCC with libstdc++, where high_resolution_clock is an alias of system_clock: wall-clock time, which NTP or an operator can step backwards (or forwards). A latency computed across such a step comes out negative or hugely positive. With Clang and libc++ the third line prints steady=1, because libc++ aliases it to steady_clock, so the same code is correct on one toolchain and broken on the other.

Why. The standard only says high_resolution_clock has the shortest tick period available, and it may be an alias of either clock. On Linux both system_clock and steady_clock already tick in nanoseconds, so it buys nothing. Measure intervals with steady_clock (CLOCK_MONOTONIC) or a raw TSC, use system_clock only for timestamps that must match wall time (logs, exchange reports), and record both if you need both. Adding static_assert(Clock::is_steady) next to latency code turns this into a compile error.

CodeImplement a lock-free single-producer single-consumer ring buffer, SpscRing<T, N>.

bool push(const T&) returns false when full and bool pop(T&) returns false when empty. Exactly one thread calls push and exactly one other thread calls pop. N is a power of two. Use the weakest correct memory orders, avoid false sharing between the producer's and the consumer's data, and keep the shared cache-line traffic per operation as low as possible. Explain each memory order.

Answer

Approach. Use two monotonically increasing indices, head (written only by the producer) and tail (written only by the consumer), and map them to slots with & (N - 1), so "full" is head - tail == N with no wasted slot and unsigned wraparound is harmless. The producer writes the slot and then publishes it with a release store of head, and the consumer's acquire load of head makes the slot contents visible. The same pairing on tail tells the producer that a slot has been fully read and may be reused. Each side also keeps a private cached copy of the other side's index and reloads it only when the cache says full or empty, which removes most cross-core cache-line transfers.

#include <array>
#include <atomic>
#include <cstddef>
#include <cstdio>
#include <memory>
#include <thread>

template <class T, std::size_t N>
class SpscRing {
    static_assert(N >= 2 && (N & (N - 1)) == 0, "N must be a power of two");

public:
    bool push(const T& v) {
        const std::size_t h = head_.load(std::memory_order_relaxed);  // only we write head_
        if (h - tail_cache_ == N) {
            tail_cache_ = tail_.load(std::memory_order_acquire);       // slot reads are done
            if (h - tail_cache_ == N) return false;
        }
        buf_[h & (N - 1)] = v;
        head_.store(h + 1, std::memory_order_release);                 // publish the slot
        return true;
    }

    bool pop(T& out) {
        const std::size_t t = tail_.load(std::memory_order_relaxed);  // only we write tail_
        if (t == head_cache_) {
            head_cache_ = head_.load(std::memory_order_acquire);       // see the slot contents
            if (t == head_cache_) return false;
        }
        out = buf_[t & (N - 1)];
        tail_.store(t + 1, std::memory_order_release);                 // hand the slot back
        return true;
    }

private:
    alignas(64) std::atomic<std::size_t> head_{0};  // producer line
    std::size_t tail_cache_ = 0;
    alignas(64) std::atomic<std::size_t> tail_{0};  // consumer line
    std::size_t head_cache_ = 0;
    alignas(64) std::array<T, N> buf_{};
};

int main() {
    constexpr long items = 100000;
    auto ring = std::make_unique<SpscRing<long, 1024>>();
    std::thread producer([&] {
        for (long i = 1; i <= items; ++i)
            while (!ring->push(i)) {}
    });
    long long sum = 0;
    long expected = 1;
    bool in_order = true;
    for (long got = 0; got < items;) {
        long v;
        if (!ring->pop(v)) continue;
        in_order = in_order && v == expected++;
        sum += v;
        ++got;
    }
    producer.join();
    std::printf("received=%ld sum=%lld in_order=%d\n", items, sum, in_order);
}
received=100000 sum=5000050000 in_order=1
cache line 0: producerhead_ · tail_cache_ cache line 1: consumertail_ · head_cache_ 5 6 7 buf_ (N = 8 shown) tail: next pop head: next push Full slots are [tail, head). push writes a slot, then release-stores head; pop acquire-loads head, reads, then release-stores tail.
Each side owns one cache line. The producer writes only line 0 and the slots, the consumer writes only line 1. The other side's index is read only when the private cached copy says the ring looks full or empty, so in steady state each operation touches no shared line except the slot itself.

Complexity and pitfalls. O(1) per operation and wait-free for each side (a failed push or pop returns at once). Pitfalls: relaxed publication (the previous bug), using % with a non-power-of-two N together with free-running indices (correct only if N divides the index range, so a power of two), leaving one slot empty to tell full from empty (wasteful when indices are free-running), putting head_ and tail_ on one cache line, and calling push from two threads (two producers need a CAS on head, which is a different, MPSC design). A 64-byte-aligned object on the heap needs C++17 aligned new, which make_unique uses. Real HFT queues also pad T to a cache line or batch the index stores, and some replace the index check with a per-slot sequence number so the consumer reads only the slot's own line.

Estimated rating: ~1900 · short code, but every memory order and the cache layout must be justified under questioning.