Chapter 15 — Exercise: The Buffer
Chapter 15 — Exercise: The Buffer¶
The Tracer (Chapter 14) had std::string silently doing the dangerous work. The Buffer replaces it with a raw int* — the same five functions, but now every ordering mistake is a heap corruption instead of a style nit. This chapter contains the exercise, the reference solution, and the four findings a real first attempt produced (logged as Findings 6–9 in Chapter 25).
The exercise¶
Write from memory: a class owning a heap array of ints (size_, data_). Requirements: destructor frees; copy constructor deep-copies; copy assignment (aim for copy-and-swap); move constructor steals and nulls the source; move assignment frees own data, steals, nulls, self-move-safe; noexcept where it is true; explicit where it belongs; a zero-initialized buffer; and an element accessor.
Then: a main exercising all five paths with predictions written as comments before running — including assignment over an existing buffer, a vector with reallocation, and b = std::move(b).
Then the sabotage runs under AddressSanitizer (see "Experiments" below).
Reference solution¶
Show the solution — do the exercise cold first
One file, as your attempt will be. In this repository the same code is now two: the class in solutions/Buffer.h, the demo main() in solutions/buffer.cpp. Nothing about the class changed — but a class sharing a translation unit with an entry point cannot be linked into a test binary, so Chapter 28 has to split it before it can test it. Read this listing as one file; that split is a later chapter's problem. (One more repository-only difference: the committed demo wraps the deliberate self-move in pragmas silencing -Wself-move, so build_all.sh stays warning-clean — your own one-file attempt should see that warning, as the notes below say.)
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <iostream>
#include <utility>
#include <vector>
class Buffer {
public:
explicit Buffer(size_t size)
: size_(size), data_(new int[size]{}) // {} => zero-initialized (Finding 7)
{}
~Buffer() { delete[] data_; } // delete[] on nullptr is a safe no-op
// ---- copy: deep, exception-safe ---------------------------------------
Buffer(const Buffer& other)
: size_(other.size_), data_(new int[other.size_])
{
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) { // copy-and-swap (Finding 6)
Buffer tmp(other); // ALL throwing work happens here; *this untouched
Swap(tmp); // three noexcept pointer exchanges
return *this;
} // tmp's destructor frees our OLD block
// ---- move: steal and null out -----------------------------------------
Buffer(Buffer&& other) noexcept
: size_(std::exchange(other.size_, 0)),
data_(std::exchange(other.data_, nullptr))
{}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) { // self-move guard
delete[] data_; // safe HERE: nothing below throws
size_ = std::exchange(other.size_, 0);
data_ = std::exchange(other.data_, nullptr);
}
return *this;
}
// ---- access -------------------------------------------------------------
size_t Size() const noexcept { return size_; }
int& At(size_t i) { assert(i < size_); return data_[i]; } // Finding 8
const int& At(size_t i) const { assert(i < size_); return data_[i]; }
void Swap(Buffer& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
private:
size_t size_ = 0;
int* data_ = nullptr;
};
int main() {
Buffer a(5);
a.At(2) = 42; // writable now (Finding 8)
Buffer b = a; // copy ctor: deep copy
Buffer c(3);
c = a; // copy assignment via copy-and-swap
std::cout << "c[2]=" << c.At(2) << " (expect 42)\n";
const Buffer& view = a;
std::cout << "view[2]=" << view.At(2) << " (const overload)\n";
Buffer d = std::move(a); // move ctor: a is now empty husk
std::cout << "a.Size()=" << a.Size() << " (expect 0)\n";
c = std::move(d); // move assignment
c = std::move(c); // self-move: must be harmless
std::cout << "c[2]=" << c.At(2) << " after self-move (expect 42)\n";
std::vector<Buffer> v;
v.push_back(Buffer(2));
v.push_back(Buffer(4)); // reallocation: moves (noexcept honest)
std::cout << "vector ok, sizes " << v[0].Size() << "," << v[1].Size() << "\n";
return 0;
}
Notes on the choices:
new int[size]{}— the braces zero-initialize. Without them the contents are indeterminate and reading them is UB (Finding 7).- Copy assignment is copy-and-swap: the copy constructor does all throwing work into
tmpwhile*thisis untouched;Swapis three noexcept pointer exchanges;tmp's destructor frees the old block on the way out. Strong exception guarantee and self-assignment safety fall out for free (Finding 6). std::exchange(other.data_, nullptr)— "take the old value, leave this one" in a single expression; the idiomatic steal-and-null. Equivalent to two lines, harder to forget the second.- Move assignment deletes first and that is fine here — nothing after the
delete[]can throw. The same shape inside copy assignment is a bug (Finding 6): the difference is what follows the delete. - The const-overload pair for
At—int& At(size_t)plusconst int& At(size_t) const. Same name; the compiler picks by the constness of the object. This is exactly howvector::operator[]works (Finding 8). Theassertdocuments the bounds contract; throwingstd::out_of_rangewould be the.at()-style alternative. - Destructor is one line —
delete[]onnullptris a safe no-op, and nulling members in a destructor is dead work: the object ceases to exist in the next instant (Finding 9). - The deliberate
c = std::move(c)inmaindraws a compiler warning on clang and GCC 13+ (-Wself-move); older GCC and MSVC/W4stay silent. Where it appears, good — the compiler flags suspicious code; either way the class must survive it, which is the requirement.
The unhappy path, step by step¶
The instructive bug from a real first attempt — release-before-acquire copy assignment:
Buffer& operator=(const Buffer& other) {
if (this != &other) {
delete[] data_; // old block GONE. data_ dangles.
size_ = other.size_;
data_ = new int[size_]; // <- suppose THIS throws bad_alloc
std::copy(...);
}
return *this;
}
If the allocation throws, the exception propagates out mid-function and the object is a zombie: data_ still points at freed memory. Once stack unwinding reaches it (which needs a handler somewhere above — an exception nobody catches terminates without unwinding, Chapter 1), its destructor runs delete[] data_, freeing the same block twice. The assignment didn't just fail to copy; it corrupted the heap on the way out. The principle: do all the throwing work before touching your own state. Copy-and-swap makes the correct ordering structural rather than a discipline to remember.
Experiments (sabotage runs under ASan)¶
Take a working copy, break one thing at a time, build with -fsanitize=address -g, run, and read each report until it makes sense:
- Remove the null-out in the move constructor → double-free; ASan shows both freeing stacks.
- Make copy assignment shallow (
data_ = other.data_;) → double-free plus a leak of the orphaned old block. - Remove the self-move guard, run
b = std::move(b)→ reason first about whether your implementation gives use-after-free or silent data loss, then verify. - Simulate the unhappy path: revert to release-before-acquire, insert
throw std::bad_alloc{};after thedelete[]— and, the step that matters, catch it inmain(try { c = a; } catch (const std::bad_alloc&) {}) so the zombie outlives the failure. Thrown uncaught it just terminates, no unwinding, and ASan reports nothing; with the handler, the next touch ofcis a loud use-after-free and its destructor the double-free — Finding 6 detonating on schedule.
The Buffer is written from nothing here. The same class arrives at work already written, with callers, and the ticket says modernise it without breaking them — Chapter 45 is that exercise, and the Rule of Five is applied there one declaration at a time to code that was silently missing it.
Why you would never ship this class¶
std::vector<int> already is this class, written by experts, tested for decades — holding one as the member gives all five operations for free. (std::unique_ptr<int[]> is the other candidate, but it is move-only: as a member it hands you the destructor and the two moves and deletes your copies, so the deep-copy semantics this chapter just built would still be yours to write.) Rule of Zero beats Rule of Five (Chapter 6). Hand-rolling the five is for the rare type that is the resource wrapper — and knowing how is precisely what makes the shortcut safe to take everywhere else.