values[0] was still 17. The pointer to values[0] was already dead.

std::vector<int> values;
values.reserve(1);
values.push_back(17);

int* remembered = &values[0];
values.push_back(23);

std::printf("values[0]=%d\n", values[0]);
std::printf("*remembered=%d\n", *remembered);

An ordinary optimized build printed 17 twice on one run. That was the worst possible result because it made the last line look valid. The AddressSanitizer build refused the illusion:

before: data=0x1033006f0 remembered=0x1033006f0
        size=1 capacity=1 value=17
after:  data=0x1033006d0 remembered=0x1033006f0
        size=2 capacity=2 values[0]=17

ERROR: AddressSanitizer: heap-use-after-free
READ of size 4 at 0x0001033006f0

freed by:
  std::vector<int>::__push_back_slow_path

previously allocated by:
  std::vector<int>::reserve

The vector still contained 17. The four bytes at the remembered address had been freed. Those statements do not contradict each other because the value and the storage holding it are different things.

This ran on an Apple M4 with macOS 15.7.7, Apple clang 15.0.0, and libc++ 17.0.6. The absolute addresses change every process. The transition from one allocation to another is the evidence.

the second integer needed a different block

The first reserve(1) asks the vector to acquire storage for at least one int. push_back(17) constructs the first integer there. At that moment:

size     = 1
capacity = 1
data     = 0x1033006f0

Size counts live elements. Capacity counts how many elements the current allocation can hold before another allocation is necessary.

The second push_back needs size 2, but the allocation holds only one element. The vector cannot extend arbitrary heap storage in place. Some other allocation may follow it. The allocator promised one block of a particular size, not ownership of the next bytes.

The vector therefore performs a larger operation:

allocate storage for more elements
construct 23 in the new storage
relocate 17 into the new storage
destroy the old 17
release the old storage
replace the vector's internal pointers

Afterward:

size     = 2
capacity = 2
data     = 0x1033006d0

values[0] looks through the vector’s current data pointer and finds 17 in the new allocation. remembered is an independent pointer value. The vector has no registry of every pointer, reference, and iterator a caller might have copied. It cannot update them.

The old pointer still contains a plausible address. Bits do not evaporate when an object’s lifetime ends. Validity is a relationship between a pointer, a live object, and an evaluation. The address bits alone cannot prove that the original object is still there.

The current C++ draft describes exactly this category in [basic.compound]: a pointer can point to an object, point one past an object, be null, or be invalid. Indirection through a pointer that is no longer valid for a live object has undefined behavior.

AddressSanitizer makes the relationship visible by tracking allocation state in shadow memory. It marks the released four-byte region as freed. The dereference asks to read four bytes from that poisoned region, so the runtime stops at the first invalid read.

Without the sanitizer, the allocator may leave 17’s old bit pattern untouched. Reading it can appear to work until another allocation reuses the block, an optimization changes instruction order, or a different run gives the block back to the operating system. A successful print is not evidence that the access was permitted by the language.

the pointer was another value in another object

The declaration that created the problem contains two objects:

int value = 17;
int* pointer = &value;

value is an int object whose value is 17. pointer is a pointer object. Its value identifies value. The ampersand in &value asks for the address of the integer. The star in the declaration says that pointer can point to an int.

The same star has a different grammatical job in an expression:

int observed = *pointer;

Here it means indirection. Evaluate the pointer, find the object it designates, then read that object’s value. Assignment through the same expression writes the designated object:

*pointer = 23;

After that statement, value is 23. The pointer object itself did not change. Its value still designates the same integer.

This separation matters in the vector example. remembered occupies its own storage and survives the second insertion. Nothing writes a new address into it. The integer that used to be found through that address is the object that does not survive.

A pointer can be reassigned:

int left = 10;
int right = 20;
int* selected = &left;

selected = &right;

The final assignment changes the pointer value, not either integer. A pointer to const changes a different permission:

const int* observed = &right;

Code may read right through observed but may not assign through that pointer. right itself is still non-const and can be changed directly. The const qualification describes access through this path.

The star can be const instead:

int* const fixed = &right;

fixed must keep the address it received during initialization, but it may modify right. Combining both:

const int* const fixed_observation = &right;

fixes the pointer value and prevents modification through it. None of these forms extends the integer’s lifetime. A const int* can dangle just as thoroughly as an int*.

Null is a separate pointer state:

int* absent = nullptr;

It deliberately designates no object. Comparing it with nullptr is valid. Indirection is not. The opening pointer is more dangerous because it is non-null and looks like an ordinary address. A null check detects absence, not expiration.

The pointer also carries a static type known to the compiler. On this machine an int*, double*, and void* all occupied eight bytes, but equal representation size does not make them interchangeable. The type determines the size and alignment expected by indirection, the scale of arithmetic, and which conversions are permitted.

Printing the pointer reduces that richer contract to a number:

std::printf("%p", static_cast<void*>(pointer));

The number helps compare runs and spot a changed allocation. It does not show object type, array bounds, lifetime, ownership, mutability, or synchronization. Those properties live in the program’s operations and rules rather than in the hexadecimal digits.

a reference hid the pointer but kept the lifetime problem

The same bug can be written without visible pointer syntax:

std::vector<int> values;
values.reserve(1);
values.push_back(17);

int& remembered = values[0];
values.push_back(23);

std::printf("%d\n", remembered);

remembered is an lvalue reference bound to the first element. A reference lets later expressions use the object’s name directly. It does not create a copy of the integer, and it does not register itself with the vector.

When growth ends the original element’s lifetime, the reference dangles. Reading remembered has the same underlying problem as dereferencing the stale pointer. References cannot be null in the ordinary language model and cannot later be rebound, but those restrictions do not make their referents immortal.

Copying the value would have created a different result:

int remembered_value = values[0];
values.push_back(23);

std::printf("%d\n", remembered_value);

remembered_value is an independent integer containing 17. Vector growth has no effect on its lifetime. This is the first design choice whenever an address escapes: does the consumer need the identity of the original object, or only its current value?

Identity is necessary when the consumer must modify the original object, observe future modifications, or avoid copying an expensive resource. A value copy is safer when the data is small and a snapshot is intended. Accidentally choosing identity creates a lifetime obligation. Accidentally choosing a copy can hide later updates or duplicate work.

std::span<int> packages a pointer and element count:

std::span<int> view(values.data(), values.size());

The count prevents walking beyond the captured range. It does not own the elements. Reallocation invalidates the span’s pointer exactly as it invalidates remembered. A string view, iterator pair, and many library views have the same non-owning shape.

The type can communicate that ownership is elsewhere, but the program must still keep that owner and its storage unchanged for the view’s use interval. A non-owning abstraction makes the contract visible. It does not enforce the contract by itself.

the sanitizer changed the allocator so the mistake stayed visible

AddressSanitizer did more than wait for the operating system to reject the read. The Clang AddressSanitizer documentation describes instrumentation for out-of-bounds accesses, use after free, double free, and related memory errors.

The compiler inserts checks around memory operations. The runtime maintains shadow state describing whether corresponding application bytes may be accessed. It also places poisoned red zones around allocations so a small overrun can be detected before it reaches an unrelated live block.

Freed blocks are commonly kept in a quarantine for a while. Immediate allocator reuse would unpoison the bytes for the new owner and could hide a stale access from allocation-state checking. Quarantine increases the chance that delayed use remains visibly poisoned. It also changes allocation timing and memory consumption, which is why sanitizer benchmark numbers are not production benchmark numbers.

The opening diagnostic named three useful events:

where the invalid read occurred
where the block was released
where the block was acquired

That history converts a hexadecimal address into an ownership path. The read stack identifies the symptom. The free stack often identifies the invalidating operation. The allocation stack identifies the intended owner.

The tool does not understand every semantic identity. If vector insertion shifts live elements inside an allocation, a stale pointer may still designate a valid live object of the same type, only the wrong logical record. Shadow bytes remain accessible. AddressSanitizer has no application-level ID with which to reject that read.

It also cannot prove the absence of a bug. Instrumentation checks paths that execute with the tested inputs. An untested exception edge, rare capacity, or asynchronous timing can retain a lifetime error.

UndefinedBehaviorSanitizer caught the deliberately misaligned uint32_t access later in the investigation. The two tools answer related but different questions. One tracked poisoned address ranges. The other checked that a typed load met the alignment required by that operation.

The durable verifier therefore runs both the expected failing probes and the expected successful implementations. It requires the stale vector pointer to produce heap-use-after-free, the misaligned load to produce an alignment diagnostic, and the compact containers to complete their randomized traces without either sanitizer reporting an error.

An expected failure is a test result only when the script checks that it failed for the intended reason. Merely allowing a command to crash can turn compiler errors, missing executables, and unrelated faults into false success.

two words changed the result

The smallest control is to reserve enough space:

std::vector<int> values;
values.reserve(2);
values.push_back(17);

int* remembered = &values[0];
const int* before = values.data();

values.push_back(23);

The measured result:

reserve-control same-data=1 same-element=1 value=17
size=2 capacity=2

No reallocation was needed. The second integer fit after the first one in the existing block. The standard’s reserve specification says that insertions after reserve do not reallocate until an insertion would make size exceed capacity. It also says that reallocation invalidates every pointer, reference, and iterator to elements, while no reallocation leaves them valid for this capacity operation.

reserve did not make pointers permanently stable. A third insertion would exceed capacity 2 and restore the original problem. The guarantee has a bound:

current size + future insertions <= capacity

If a program knows that bound, reserving once can remove repeated allocation and preserve addresses during the bounded phase. If the estimate is wrong, correctness cannot depend on it silently staying right.

The control isolates reallocation from push_back itself. Appending without reallocation preserves pointers to existing elements. Appending with reallocation invalidates all of them. The source call is the same; size and capacity decide which execution path exists.

adding one moved four bytes

The vector’s allocation behaves like an array. Array elements are contiguous: element 1 begins immediately after element 0, with no gap between elements.

I printed addresses for four int values:

int sizeof(T)=4 alignof(T)=4 array-bytes=16
  i=0 typed-difference=0 byte-address=0x16d215c70
  i=1 typed-difference=1 byte-address=0x16d215c74
  i=2 typed-difference=2 byte-address=0x16d215c78
  i=3 typed-difference=3 byte-address=0x16d215c7c
  one-past typed-difference=4 byte-span=16

The byte address rises by 4. The typed pointer difference rises by 1.

This expression:

int* next = current + 1;

does not add one byte. It advances by one int element, which requires sizeof(int) bytes on this machine. For double:

double sizeof(T)=8 alignof(T)=8 array-bytes=32
  i=0 typed-difference=0 byte-address=0x16d215c50
  i=1 typed-difference=1 byte-address=0x16d215c58
  i=2 typed-difference=2 byte-address=0x16d215c60
  i=3 typed-difference=3 byte-address=0x16d215c68
  one-past typed-difference=4 byte-span=32

double_pointer + 1 advances eight bytes here.

Typed pointer arithmetic exists to walk arrays in units of elements. The draft’s [expr.add] defines P + j only when the result stays between element zero and the hypothetical one-past element of the same array. Subtracting two pointers produces an element count only when both belong to the same array.

This is defined:

int values[4] = {10, 20, 30, 40};
int* begin = &values[0];
int* end = begin + 4;

std::ptrdiff_t count = end - begin; // 4

This is not:

int left[4];
int right[4];

std::ptrdiff_t distance = &right[0] - &left[0];

The two arrays may happen to be adjacent in one stack frame. They are still different array objects. The numerical addresses do not create a defined pointer distance between them.

The rule carries ownership information that a raw address print omits. Pointer arithmetic is valid inside one array object and at its one-past boundary. It is not general integer arithmetic over a flat address space.

the subscript was pointer arithmetic with brackets

For built-in arrays and pointers, the expression:

values[index]

is defined in terms of:

*(values + index)

The addition finds the indexed element in the same array. The indirection accesses it. A negative index does not receive special bounds handling. An index at or beyond the count reaches outside the permitted element range.

This small equivalence explains why a C function receiving an array parameter does not know its length:

void print_values(const int values[], std::size_t count);

In a function parameter, that array spelling is adjusted to a pointer. The function receives an address, while count arrives as an independent value. sizeof(values) inside the function measures the pointer, not the caller’s array.

An actual array object retains its full type at the point of declaration:

int local[4] = {10, 20, 30, 40};

static_assert(sizeof(local) == 4 * sizeof(int));

In many expressions, local converts to a pointer to its first element. That conversion is often called array decay. It is convenient enough to hide information. Templates and references can preserve the bound:

template <std::size_t N>
void inspect(const int (&values)[N]) {
    static_assert(N > 0);
}

The parentheses matter because the parameter is a reference to an array of N integers. No array copy occurs, and N is deduced from the caller.

std::span performs the more common runtime version:

void inspect(std::span<const int> values) {
    for (int value : values) {
        std::printf("%d\n", value);
    }
}

The function now receives address and count together. Its loop can remain inside the range. The caller still promises that the underlying array remains alive and fixed in place.

std::vector::operator[] resembles built-in subscripting but belongs to the class. It does not check the index. at does:

int a = values[5];   // caller must prove the index is valid
int b = values.at(5); // throws std::out_of_range when it is not

Neither operation can rescue a stale vector reference. Bounds checking asks whether an index is below the current size. The opening bug uses no invalid current index. It follows a separate pointer into an old allocation.

the one-past pointer could be held but not opened

begin + 4 for a four-element array is a valid one-past pointer. Loops rely on it:

for (int* p = begin; p != end; ++p) {
    std::printf("%d\n", *p);
}

The loop compares against end but never dereferences it. There is no fifth int object there.

One-past pointers let half-open ranges describe zero elements cleanly. An empty range has begin == end. Concatenated ranges share boundaries. The element count is end - begin.

They do not grant access to the bytes after an allocation. Even if another int object begins at the same numerical address, the one-past pointer remains associated with the first array for pointer arithmetic. Object identity does not collapse because addresses touch.

This explains the vector’s usual three-pointer representation:

begin pointer        first live element
end pointer          one past the last live element
capacity-end pointer one past the allocated storage

Size is end - begin. Capacity is capacity_end - begin. The region from begin to end contains live T objects. The region from end to capacity-end is suitably allocated storage where no T object necessarily lives yet.

That final distinction becomes important as soon as T has a constructor or destructor. Capacity is not a count of dormant objects. It is room in which objects may be constructed.

the address belonged to storage before it belonged to an int

Allocation and object construction are separate operations.

For one int, an allocator first returns storage with enough bytes and alignment:

std::allocator<int> allocator;
int* storage = allocator.allocate(1);

The returned pointer identifies storage. In the general case, no initialized int value has been constructed there yet. Construction begins the object’s lifetime:

std::construct_at(storage, 17);

Destruction ends that lifetime:

std::destroy_at(storage);

Deallocation releases the storage:

allocator.deallocate(storage, 1);

For trivial int, construction and destruction compile to very little. For std::string, construction establishes size, capacity, and ownership invariants. Destruction releases owned resources. The container must call these operations for each live element even though its allocator acquired one raw block.

The draft’s [basic.life] treats lifetime as a runtime property. Storage can exist before an object’s lifetime begins and after it ends. Using a pointer during those intervals is constrained even when the address and bytes still exist.

The opening pointer failed at both levels. The old integer’s lifetime ended during relocation. Then the allocation containing its storage was released. AddressSanitizer reported the second fact as use after free. A lifetime error can also exist inside storage that has not been freed, which is why allocation tracking alone cannot express every C++ object rule.

scope stopped one name before it stopped the object

Scope is a source-code region where a name can be used. Lifetime is the runtime interval during which an object exists. They often end together for local objects, which makes them easy to confuse.

int* escaped = nullptr;

{
    int local = 17;
    escaped = &local;
}

std::printf("%d\n", *escaped);

The name local is unavailable after the closing brace. More seriously, its object’s lifetime has ended. escaped remains in scope, but it dangles. Moving an address into a wider scope does not move the referent there.

The reverse arrangement is possible. A dynamically allocated object can outlive the name that first owned its address:

int* escaped = nullptr;

{
    int* temporary_name = new int(17);
    escaped = temporary_name;
}

std::printf("%d\n", *escaped);
delete escaped;

temporary_name leaves scope, while the allocated integer remains alive until delete. The program is valid but fragile because nothing in the pointer type says who must perform that delete.

C++ classifies storage duration separately from scope. A local variable ordinarily has automatic storage duration. Its storage lasts until execution leaves its block. A namespace variable has static storage duration and normally lives for the process. A thread_local object has thread storage duration. Storage acquired by new has dynamic storage duration and remains until a matching release.

These are language categories. “Stack” and “heap” describe common implementation mechanisms. An optimizer can keep a local integer only in a register, remove it completely, or place it in a stack frame when its address is needed. A dynamic allocator can satisfy small requests from a thread-local cache without asking the kernel on every call. The source lifetime rules remain even when the physical placement changes.

Taking &local can force the compiler to give the object an observable address for the operations that need one. It does not require that every write immediately reaches memory. The compiler may still keep a current value in a register when the language permits and store it when an aliased access could observe it.

Function calls add another common escape:

const int* bad_address() {
    int local = 17;
    return &local;
}

The returned pointer is already invalid when the caller receives it. Compilers warn because the error is visible in one function. The same escape through a callback, task queue, or captured reference may be harder to see:

std::function<int()> bad_task() {
    int local = 17;
    return [&local] { return local; };
}

The lambda object survives. Its reference capture does not copy local; it remembers how to find it. Invoking the task after the function returns follows a dead reference.

Capturing by value changes the ownership:

std::function<int()> task() {
    int local = 17;
    return [local] { return local; };
}

The closure object contains its own integer. Its lifetime follows the returned function object. The correct capture therefore depends on whether a snapshot or shared identity is intended, just as it did for the vector element.

destruction ran backward through the braces

Automatic objects begin their lifetime when their initialization completes. Their destructors run when execution leaves the scope, including exits caused by exceptions.

The lifetime probe printed:

construct id=1 address=0x16d24dcd8
construct id=2 address=0x16d24dcd0
inside both scopes
destroy id=2 address=0x16d24dcd0
inside outer scope
destroy id=1 address=0x16d24dcd8

The inner object dies at the inner brace. The outer object remains alive until the outer brace. Within one scope, automatic objects are destroyed in reverse completion order.

That deterministic cleanup is the basis of RAII, resource acquisition is initialization. A class acquires a resource while becoming a valid object and releases it in its destructor:

class File {
public:
    explicit File(const char* path)
        : handle_(std::fopen(path, "rb")) {
        if (handle_ == nullptr) {
            throw std::system_error(
                errno, std::generic_category(), path);
        }
    }

    ~File() {
        if (handle_ != nullptr) {
            std::fclose(handle_);
        }
    }

    File(const File&) = delete;
    File& operator=(const File&) = delete;

private:
    std::FILE* handle_;
};

If a later operation throws, stack unwinding destroys each fully constructed local object. The file closes without every exit path repeating fclose. The wrapper deletes copying because two independent wrappers closing the same handle would duplicate ownership. A complete wrapper can implement move by transferring the handle and leaving the source empty.

The destructor is tied to object lifetime, not to a specific memory region. A File inside a vector closes its handle when erased, when the vector is destroyed, or when relocation destroys the old moved-from wrapper. The move constructor must leave the old wrapper safe to destroy.

Raw storage changes none of this. If an arena discards bytes containing a live File without invoking its destructor, it loses the resource even though the arena recovered memory. Memory reclamation and resource cleanup are different obligations.

RAII also governs asynchronous resources. A buffer submitted to a device cannot be released merely because the submission function returned. Its owning object needs to live until a completion event, or transfer ownership to an operation object whose destructor waits, cancels, or safely defers release.

The opening vector owns its allocation. Its destructor releases the block automatically. remembered is non-owning. It neither delays the vector’s destruction nor receives notice when the block moves. This is why ownership types and view types need to look different in an interface.

new combined construction with allocation

The low-level allocator sequence used allocate, construct_at, destroy_at, and deallocate separately. An ordinary new expression combines the first two:

Record* record = new Record(17);

Conceptually it obtains suitably aligned storage, then constructs Record in that storage. If construction throws, the corresponding deallocation function releases the storage automatically. A delete expression performs the other direction:

delete record;

It invokes the destructor, then releases storage. For arrays, the forms must match:

Record* records = new Record[100];
delete[] records;

Using scalar delete for array storage is undefined. The implementation needs enough information to destroy the correct number of elements and release the allocation with the matching operation.

Direct new is rarely the right owning interface. A unique pointer records single ownership:

auto record = std::make_unique<Record>(17);

When record dies, it deletes the pointee. Moving the unique pointer transfers that obligation. Copying is unavailable because two owners would be ambiguous.

A shared pointer uses a control block to count shared owners:

auto shared = std::make_shared<Record>(17);
std::shared_ptr<Record> another = shared;

The Record remains alive until the last owning shared pointer releases it. A weak_ptr observes the control block without extending the lifetime and must attempt lock before use. This can solve lifetime sharing, but it adds atomic reference-count traffic and does not solve logical races on the object itself.

Reference counting also cannot collect a cycle of shared owners:

object A owns object B
object B owns object A
external owners disappear
both counts remain nonzero

One edge must be non-owning or the ownership graph must be redesigned.

A vector of unique_ptr<Record> later gives stable pointee addresses because vector relocation moves owners instead of records. A vector of shared_ptr<Record> additionally permits consumers to extend each record’s lifetime. Those guarantees have different costs and should not be selected merely to silence a dangling-pointer bug.

the same storage held two different objects

I used one aligned byte array and explicitly constructed two Tracked objects in sequence:

construct id=17 address=0x16d24dcdc
destroy id=17 address=0x16d24dcdc
construct id=91 address=0x16d24dcdc
same-address=1 current-id=91
destroy id=91 address=0x16d24dcdc

Only one object was alive at a time. The storage address never changed. Object identity did.

The transition was:

storage exists, no Tracked object
Tracked 17 lifetime begins
Tracked 17 lifetime ends
storage exists, no Tracked object
Tracked 91 lifetime begins
Tracked 91 lifetime ends
storage still exists

An address is therefore not a permanent object identifier. It identifies a location that can host different objects at different times. Allocators and pools depend on that reuse.

For many transparent replacement cases, an existing pointer can automatically refer to the new object once construction occurs in exactly the same storage. C++ also has cases involving const complete objects, base subobjects, and potentially overlapping subobjects where that replacement is not transparent. std::launder exists for the narrow situations where code needs a pointer to the newly created object after storage reuse and the ordinary pointer value cannot be used directly.

That facility does not repair a pointer into deallocated vector storage. Laundering is not a lifetime extension, an allocator query, or a promise that the same address was reused. The program must first have valid storage and a newly created object at the relevant location.

a class inserted bytes nobody named

Contiguous array elements cannot contain gaps between elements, but one class object can contain padding between members.

I used:

struct Scattered {
    char tag;
    double weight;
    char state;
    std::uint32_t count;
};

The local layout:

Scattered size=24 align=8 offsets=0,8,16,20

tag occupies byte offset 0. double requires 8-byte alignment here, so weight begins at offset 8. Seven padding bytes sit between them. state begins at 16. Three more padding bytes place count at 20. The complete object size is 24 so that the next Scattered in an array begins at another address divisible by 8.

Reordering members changed the size:

struct Reordered {
    double weight;
    std::uint32_t count;
    char tag;
    char state;
};
Reordered size=16 align=8 offsets=0,8,12,13

The same named fields need eight fewer bytes because the small members fill space after the 4-byte count. Two tail-padding bytes remain so the next object stays aligned.

The member order is part of representation and often part of an ABI or file format if code exposes raw bytes. Reordering for size can break binary compatibility, serialization assumptions, device interfaces, or cache behavior. Saving eight bytes is useful only when those contracts permit it.

sizeof includes padding because pointer arithmetic over Scattered* must advance to the next correctly aligned object. p + 1 moves 24 bytes for the first layout and 16 for the second.

thirty two bytes protected a seventeen-byte member

Explicit alignment can make the opposite trade:

struct alignas(32) CacheChunk {
    std::array<std::byte, 17> bytes;
};

The result:

CacheChunk size=32 align=32

The named array needs 17 bytes. The object needs 32 so every element in CacheChunk chunks[2] begins on a 32-byte boundary.

Alignment is a requirement on valid object addresses. On this platform an int needs an address divisible by 4, a double by 8, and this explicit type by 32. The current draft’s [basic.align] distinguishes fundamental alignment from extended alignment requested by types such as this one.

Allocators must return storage suitable for the type being allocated. An arena that merely increments a byte pointer without rounding for each request will eventually place an 8-byte object at an address such as base + 5. Enough bytes exist, but the address violates the object’s contract.

I forced that distinction with a byte buffer. First I copied a 32-bit value from the unaligned byte position into an aligned local integer:

std::uint32_t safe = 0;
std::memcpy(&safe, bytes.data() + 1, sizeof(safe));
memcpy value=0x12345678

Then I cast the same address to std::uint32_t* and dereferenced it:

runtime error: load of misaligned address 0x...d9
for type 'const std::uint32_t', which requires 4 byte alignment

UndefinedBehaviorSanitizer caught the language violation. Apple silicon can execute many unaligned loads in hardware, but hardware tolerance does not create a properly aligned C++ object or make the typed access defined. Other instructions and architectures can fault or carry larger penalties.

memcpy is the correct bridge when bytes may be unaligned. It operates on character-sized representation and lets the destination object provide alignment.

bytes could be inspected without changing their type

Every object has an object representation, the sequence of unsigned char or std::byte sized units occupying its storage. Inspecting those bytes is different from pretending they contain another type.

For a floating-point value:

float value = 1.0f;
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));

The copy preserves the four representation bytes in a properly constructed uint32_t. C++20’s std::bit_cast<std::uint32_t>(value) expresses the same fixed-size representation transfer more directly for eligible types.

This shortcut is not equivalent:

std::uint32_t bits =
    *reinterpret_cast<std::uint32_t*>(&value);

The cast changes the pointer type. It does not create a uint32_t object in the float’s storage. Access through an incompatible glvalue can violate the type-access rules commonly discussed under strict aliasing.

Optimizers use those rules. If an int* and a float* cannot legally designate the same live object in a particular context, the compiler can keep one value in a register rather than reload it after a store through the other pointer. Source that relies on illegal aliasing may appear correct at -O0 and change at -O2 because the optimized program uses the contract the source violated.

Character and byte views receive special treatment because copying, hashing, I/O, and serialization need access to representation. That permission does not mean every object representation is a stable interchange format. Padding bytes can be indeterminate. Endianness changes byte order. Pointer values are process-specific. Class layout can change across compilers and versions. A representation can also contain multiple encodings for one value.

The file investigation treated bytes as an external format with an explicit schema. The same discipline applies here: use object representation for local mechanisms only when its portability boundary is stated.

capacity grew by a pattern the standard never promised

I appended 65 integers and printed only capacity changes:

growth size=1  old-capacity=0  new-capacity=1
growth size=2  old-capacity=1  new-capacity=2
growth size=3  old-capacity=2  new-capacity=4
growth size=5  old-capacity=4  new-capacity=8
growth size=9  old-capacity=8  new-capacity=16
growth size=17 old-capacity=16 new-capacity=32
growth size=33 old-capacity=32 new-capacity=64
growth size=65 old-capacity=64 new-capacity=128

This libc++ doubled capacity. That is implementation behavior, not a C++ guarantee.

The standard requires contiguous storage, specified complexity, and invalidation behavior. It gives implementations room to choose a growth policy. A different standard library or version can use another factor, round for allocator size classes, or make different choices near maximum size.

The installed libc++ 17.0.6 source makes the local result unsurprising. The corresponding tagged source is available in LLVM’s libc++ vector header:

return std::max<size_type>(2 * __cap, __new_size);

That line lives in vector::__recommend. The slow push_back path asks it for capacity at least size() + 1, creates a split buffer, constructs the new element, relocates old elements, then swaps the new buffer into the vector.

Geometric growth is what keeps repeated append amortized constant time. If capacity increased by exactly one each time, appending n values would relocate:

0 + 1 + 2 + ... + (n - 1)

That sum is n(n - 1) / 2, which grows quadratically. At 100,000 elements it would imply nearly five billion element relocations.

Doubling instead relocates at capacities:

1 + 2 + 4 + 8 + ... + 65536

The sum is less than 131,072 for the path to 100,000. Total relocation remains proportional to the final element count even though individual growth operations are linear.

Amortized constant does not mean every append is equally fast. Most append into spare capacity. A growth append allocates and touches every existing element. Tail-sensitive code can still observe those spikes.

the average hid every expensive insertion

Amortized analysis puts a bound on a sequence, not on one operation.

Start from an empty vector and append eight integers with doubling:

append 1: allocate 1, relocate 0
append 2: allocate 2, relocate 1
append 3: allocate 4, relocate 2
append 4: no allocation
append 5: allocate 8, relocate 4
append 6: no allocation
append 7: no allocation
append 8: no allocation

Eight appends caused seven old-element relocations. The work is uneven, but the total number of relocations is below the number of appended elements.

For capacity 2^k, the previous full capacities sum to:

1 + 2 + 4 + ... + 2^(k - 1) = 2^k - 1

That is a geometric series. Reaching n elements needs fewer than 2n old-element relocations for doubling, plus n constructions of the appended values. Dividing total work by n gives a constant bound per append across the whole sequence.

This accounting does not say the growth append has constant latency. Relocating 2^20 elements is still linear in one call. If the element has a cheap move, the operation touches its headers. If it is copied, it may duplicate owned state. If relocation faults in new pages, the call also pays those faults. The caller observing a percentile or deadline sees the spike, not the amortized average.

reserve moves the spike:

values.reserve(expected);

It performs allocation and relocation at a chosen point. Later bounded appends become predictable. That is valuable before entering a latency-sensitive phase, but only if the estimate and available memory are credible.

The growth factor trades relocation frequency against unused capacity. Doubling leaves capacity less than twice size immediately after growth. A smaller factor wastes less spare space at that instant but grows more often. Allocator size classes and page boundaries can change both effects.

Capacity arithmetic also needs overflow protection. This compact expression is unsafe near the integer limit:

std::size_t replacement = capacity * 2;

Unsigned overflow wraps modulo 2^N. A huge capacity could produce a smaller number, followed by an undersized allocation and out-of-bounds construction.

The compact vector checks before multiplication:

if (capacity_ > Traits::max_size(allocator_) / 2) {
    throw std::length_error("MiniVector capacity overflow");
}
return capacity_ * 2;

max_size is expressed in elements, not bytes. The allocation path must eventually ensure that:

element count * sizeof(T)

is representable and supported. Standard containers expose max_size() and throw length_error for requests beyond their supported bound. A memory allocator failing a representable request normally reports bad_alloc. Those failures mean different things: one request cannot be described by the container, or storage could not be obtained.

An integer overflow in allocation sizing is a memory safety bug, not a performance corner. Every formula that converts element counts into bytes needs the same dimensional check.

eighteen allocations requested more than the final vector

A counting allocator recorded the 100,000-element run:

geometric size=100000 capacity=131072
allocations=18
deallocations-before-destruction=17
requested-bytes=1048572
deallocations-after-destruction=18

The final allocation can hold 131,072 integers, which is 524,288 bytes with four-byte int. Across the entire growth history, allocator requests summed to 1,048,572 bytes. That is almost twice the final capacity in bytes because every earlier block also existed and was later returned.

The maximum simultaneous storage was not the sum of all requests. During reallocation, the old and new blocks overlap in time. Near the last growth that means roughly:

old block:  65,536 * 4 = 262,144 bytes
new block: 131,072 * 4 = 524,288 bytes
combined:                  786,432 bytes

Element objects may own additional allocations. Moving 100,000 long strings can transfer their individual character pointers while the vector still allocates new storage for 100,000 string headers.

Reserving the exact final size changed the counts:

reserved size=100000 capacity=100000
allocations=1
deallocations-before-destruction=0
requested-bytes=400000
deallocations-after-destruction=1

The reserve saved 17 allocation calls, all relocation, and 648,572 bytes of cumulative allocation requests in this run. It also committed storage for 100,000 elements immediately. If only ten elements were eventually used, that prediction would waste most of the block.

Allocator requested bytes are not identical to physical memory consumption. Size classes can round blocks. Metadata consumes space. Virtual pages may not become resident until touched. Freed blocks can stay in a process allocator for reuse instead of returning to the kernel. The counter measures calls and requested sizes at the vector-allocator boundary.

one address crossed three ownership boundaries

The opening hexadecimal address looked like a direct name for four physical bytes. The actual path has more owners.

The C++ vector asks its allocator for a block. The default allocator reaches the process allocation machinery. That allocator manages regions inside the process virtual address space. The operating system maps virtual pages and arranges physical backing when required. The processor translates a virtual address during a load or store.

The layers answer different questions:

vector:
    which elements are alive, and which block belongs to this container?

process allocator:
    which requested blocks are live, reusable, or cached?

operating system:
    which virtual ranges are mapped with which permissions?

processor and memory system:
    where is the currently translated data, and how is it cached?

Releasing a four-byte vector allocation does not normally ask the kernel to unmap one four-byte region. Page mappings are much larger than that request. The process allocator can mark a small block reusable inside a page it continues to own.

This explains two observations that otherwise seem inconsistent. AddressSanitizer can classify the old vector block as freed while the surrounding virtual page remains mapped. An unsanitized read may therefore complete at the hardware level. The C++ access is still invalid because the vector ended the object lifetime and the allocator released the block.

For a larger allocation, the allocator may eventually return pages to the operating system or change their accessibility. A stale access can then fault. Whether it faults is an allocator and mapping consequence, not the definition of whether the access was correct.

Virtual addresses also let two processes use the same numerical address for unrelated storage. A pointer value printed by one process is not a machine-wide physical coordinate. Passing that number to another process does not grant access. Shared memory requires both processes to map an agreed backing object, and each can receive a different virtual address.

Devices add another translation boundary. A GPU or DMA-capable device cannot safely consume an arbitrary CPU pointer merely because it is eight bytes wide. The buffer may need pinning, registration, a device mapping, or a copy into device-visible memory. The device operation also needs a completion lifetime.

The vector’s guarantee stops at its allocation interface. It promises contiguous T objects in its current process address space. It does not promise physical contiguity, permanent page residency, a stable address across growth, or device accessibility.

The counting experiment therefore used exact units:

allocator calls
elements requested per call
bytes requested = elements * sizeof(int)

It did not label the sum “memory used.” To measure resident memory, page faults, allocator retention, or physical traffic, another experiment and another tool would be required.

the element decided whether growth copied or moved

The source article about std::move established that relocation may choose copy when move can throw. I needed the counts at the exact growth that killed the opening pointer.

For a copyable type with a noexcept move constructor:

noexcept-move copies=0 moves=2 values=10,20,30 live=3

The vector had capacity 2 before the third insertion. Growth moved the two old elements.

For a copyable type whose move constructor lacks noexcept:

throwing-move-copyable copies=2 moves=0
values=10,20,30 live=3

libc++ copied the old elements. Its relocation helper calls std::move_if_noexcept, matching the standard library design in the installed source.

Copying preserves the old elements if a later copy throws. Moving can modify them. A vector promises strong no-effects behavior for relevant insertions when the element is copy-insertable or nothrow move-constructible. A type that can only move and whose move can throw falls outside that comfortable path; the standard permits unspecified effects in the corresponding failure case.

I injected an exception on the second copy during reserve(8):

rollback error=copy refused
size=2 capacity=2 values=10,20
copies=1 moves=0 live=2

The replacement block had one successfully copied object when the next copy threw. The implementation destroyed that partial object, deallocated the replacement, and left the old block untouched. Live-object count returned to two.

Exception safety is storage accounting:

which allocation owns each constructed object?
how many constructions completed?
which destructors must run during rollback?
when may the old block be released?

The answer cannot be recovered from a byte count alone.

the new element had to be built before the old one moved

This call contains an alias:

values.push_back(values[0]);

The argument refers to an element inside the vector. If growth moved or destroyed old elements before copying the argument, the reference could become invalid during the call that is still using it.

The installed libc++ slow path allocates a replacement buffer and constructs the appended element at its final position before relocating the old range. Only after successful construction and relocation does it exchange buffers.

That ordering makes the aliasing case work:

old elements: [alpha, beta]
argument:     reference to old alpha

construct new element 2 from old alpha
relocate old element 0
relocate old element 1
commit new buffer

It also complicates rollback because the replacement can contain a constructed object at the end and a partial prefix of relocated objects, with uninitialized storage between them during failure.

Container implementation is full of states users never observe on successful return. Correctness lives in cleaning each state exactly once.

three pointers were enough until they were not

I built a compact vector to make those states explicit. Its persistent data is:

std::allocator<T> allocator_;
T* data_ = nullptr;
std::size_t size_ = 0;
std::size_t capacity_ = 0;

This uses a pointer plus two counts rather than three pointers, but the information is equivalent.

The ordinary append path is short:

template <class... Args>
T& emplace_back(Args&&... args) {
    if (size_ < capacity_) {
        Traits::construct(
            allocator_,
            data_ + size_,
            std::forward<Args>(args)...);
        ++size_;
        return data_[size_ - 1];
    }
    return grow_and_emplace(std::forward<Args>(args)...);
}

Only the range [data_, data_ + size_) contains live elements. Construction occurs at the old end. Size changes after construction succeeds. If the constructor throws, size still describes exactly the old live range.

Destruction walks backward:

for (std::size_t i = size_; i > 0; --i) {
    std::destroy_at(data_ + (i - 1));
}

Reverse order matches normal array destruction and matters when later elements refer to earlier ones.

After destroying all elements, the container deallocates exactly the count it allocated:

allocator_.deallocate(data_, capacity_);

Size is not the deallocation count. Passing size after several pop_back calls would give an allocator a different block description than the one it returned.

growth created two islands of live objects

The compact vector’s full-capacity append first chooses double the old capacity, allocates, and constructs the new element at index size_:

const std::size_t replacement_capacity =
    capacity_ == 0 ? 1 : capacity_ * 2;

T* replacement =
    Traits::allocate(allocator_, replacement_capacity);

Traits::construct(
    allocator_,
    replacement + size_,
    std::forward<Args>(args)...);

It then relocates the old prefix:

std::size_t relocated = 0;
for (; relocated < size_; ++relocated) {
    Traits::construct(
        allocator_,
        replacement + relocated,
        std::move_if_noexcept(data_[relocated]));
}

During that loop, the replacement contains:

[0, relocated)       live relocated elements
[relocated, size)    raw storage
[size]               live appended element

The catch path must destroy both islands:

destroy_range(replacement, relocated);
Traits::destroy(allocator_, replacement + size_);
Traits::deallocate(
    allocator_, replacement, replacement_capacity);
throw;

The old allocation remains owned by the vector until the replacement is complete. On success, the vector destroys the old range, deallocates the old block, installs the replacement, and increments size.

The strong guarantee is conditional in the same place as std::vector. move_if_noexcept copies if copying exists and move can throw. A non-copyable type with a throwing move can modify old elements before a later move fails. The compact implementation documents that boundary rather than claiming rollback it cannot supply.

one hundred thousand operations did not find a difference

I compared the compact vector with std::vector<int> through 100,000 deterministic random operations:

push a random value
pop the last value
replace a random element
reserve a random larger capacity

After every operation, the test compared size and every value.

randomized trace: 100000 operations matched
size=35103 capacity=35204

The unusual final capacity came from an explicit random reserve, not from geometric growth. reserve(n) is allowed to create capacity at least n; this compact vector uses exactly n.

The aliasing control forced growth:

alias growth old-data=0x107000b50 new-data=0x107201da0
values=alpha,beta,alpha

The injected exception control produced:

mini rollback error=copy refused
size=2 capacity=2 values=10,20
copies=1 moves=0 live=2

The entire test ran with AddressSanitizer and UndefinedBehaviorSanitizer. That checks observed memory safety and undefined-behavior classes the sanitizers instrument. It does not prove equivalence for all types and operation sequences.

The compact vector deliberately omits copying the container, arbitrary insertion, erase, allocator propagation, fancy allocator pointer types, ranges, vector<bool>, debug iterators, and several exception corners. The missing surface is why production standard-library code is longer than the mechanism shown here.

the allocator contract included a count on the way back

std::allocator_traits is the container’s adapter to an allocator. The compact vector asks it for storage:

T* replacement =
    Traits::allocate(allocator_, requested);

The returned storage can hold requested contiguous T objects and satisfies alignof(T). Allocation does not construct those objects. This is why size_ may be smaller than capacity_.

Deallocation supplies the same allocation count:

Traits::deallocate(allocator_, replacement, requested);

A custom allocator may use that count to choose a size class, update accounting, or return the correct span to its upstream resource. The compact vector stores capacity partly because destruction must later recover the allocation description.

Allocator-aware containers contain another set of ownership rules when the container itself is copied, moved, or swapped. An allocator type can declare whether its state propagates on those operations. Two allocator instances may also compare equal when memory obtained from one can be released through the other.

Consider a vector whose allocator selects one NUMA node:

allocator A obtains memory on node 0
allocator B obtains memory on node 1

Moving the three vector pointers from an A-backed vector into a B-backed destination is safe only if the allocator contract lets the destination later release that block. Otherwise move assignment may need to allocate through B and move each element instead of stealing the source block.

This is why “moving a vector only copies three pointers” is an implementation prediction with conditions, not a complete language rule. The element allocator, propagation traits, and operation form can put relocation back on the path.

The standard allocator model also permits pointer types more elaborate than T*, though many simple allocators use raw pointers. Production container code goes through traits so construction, destruction, size bounds, and pointer operations can follow that model.

The counting allocator used in the experiment had no placement state and compared equal across instances. It could therefore focus on request counts. An arena allocator embedded with a region pointer would have state whose equality and lifetime matter. If the arena dies before the vector, the vector’s destructor calls into a dead allocation source even if the element bytes still happen to be mapped.

An allocator does not own the container. The container usually stores an allocator object and owns blocks acquired through it. Any upstream resource referenced by that allocator must outlive the container and every block it serves.

changing the allocator did not change the vector’s promise

A custom allocator controls how a vector acquires and releases blocks. It does not change the requirement that the elements occupy one contiguous array.

Suppose an allocator obtains every block from one large arena and treats deallocation as a no-op. Growth can avoid returning the old block to a general allocator:

arena block A: old vector elements
arena block B: new vector elements

The vector still has to construct a new contiguous range in block B. Its data() pointer changes. References to objects in block A no longer refer to the vector’s elements, even if the arena keeps the bytes reserved.

The old object’s lifetime ended during relocation. Keeping its storage allocated does not keep the object alive. This is the difference between address bits remaining readable and a valid pointer designating a live element.

Allocator choice can change:

allocation latency
deallocation latency
alignment
placement policy
fragmentation
which memory device or NUMA node supplies storage
whether many releases become one bulk release

It cannot silently change container invalidation semantics. Code that needs stable element addresses needs a different object arrangement, not a hopeful allocator.

the arena traded individual release for one rewind

The counting allocator showed 18 vector allocations. Many workloads create thousands of short-lived objects whose lifetimes end together: syntax trees for one compilation, decoded messages for one request, temporary graph nodes for one optimization pass.

An arena acquires one larger region and advances a cursor for each request:

base                                             end
| object A | pad | object B | record | free ... |
                                      ^
                                    cursor

Allocation needs the next aligned address:

current = base + cursor;
aligned = (current + alignment - 1)
          & ~(alignment - 1);
next = aligned - base + size;

The rounding expression requires power-of-two alignment. If next exceeds capacity, the arena throws bad_alloc. Otherwise it moves the cursor and returns aligned.

The measured addresses:

arena first=0x107402900
arena second=0x107402920
arena aligned=0x107402940
aligned-mod-32=0
used=96 live=2

The 32-byte object satisfied its alignment even though requests before it had other sizes. The arena itself acquired a base aligned to 64 bytes and rejects a requested alignment greater than that base promise.

Reset makes every byte available again:

arena reset used=0
arena reused-base=1

The next 8-byte request returned the original base address. That is fast reuse and an immediate invalidation boundary. Every pointer into the previous arena generation becomes logically dead at reset even if a new object later occupies the same numerical address.

Generation counters can make handles safer:

struct Handle {
    std::uint32_t index;
    std::uint32_t generation;
};

The lookup validates both. Reusing slot 5 in generation 9 does not make an old generation-8 handle valid merely because its index matches. Raw pointers carry no such generation field.

nontrivial objects made reset more than cursor zero

For bytes or trivially destructible records, reset can set cursor = 0. A std::string, file wrapper, or lock guard has a destructor whose work must not be skipped.

The arena registers one destruction record after each nontrivial object:

struct DestructorRecord {
    void* object;
    void (*destroy)(void*) noexcept;
    DestructorRecord* previous;
};

make<T> constructs the object, then places a record in the arena. The record stores a type-erased function that casts the address back to T* and calls destroy_at.

Records form a stack. Reset walks newest to oldest:

construction: 1, 2
destruction:  2, 1

The measured result:

arena reset used=0 live=0 destruction=2,1

Metadata consumes arena bytes. In the sample, two small tracked objects used more than their own sizeof because each required a record and alignment. Bulk allocation is not free of accounting; it changes where accounting lives.

If object construction throws, no object lifetime began and no destructor should run. If the object succeeds but allocating its record fails, the arena destroys the object and restores the saved cursor. The injected constructor failure produced:

arena constructor-rollback=1 used-unchanged=1

An arena with no destructor registry is valid only when callers restrict its contents or run destructors elsewhere. Skipping that condition leaks logical resources even if all bytes are eventually reclaimed.

The implementation is single-threaded. Making cursor allocation atomic would not make reset safe while other threads still use objects. Lifetime coordination is the harder problem.

the pool knew every block before the first request

An arena handles varied sizes but cannot return one interior object for immediate reuse without more bookkeeping. A fixed-size pool starts with a different constraint: every allocation fits one block of the same size and alignment.

The pool allocates space for 64 blocks and keeps a free-list index:

free head -> block 0 -> block 1 -> block 2 -> ...

Allocation removes the head in constant time. Deallocation pushes its index back. No search for a size class is needed.

The checked implementation also stores one in-use bit per block. A return validates:

pointer lies inside pool storage
offset is an exact multiple of block stride
block is currently in use

Returning the same pointer twice is rejected:

pool randomized=100000 free=64
double-return-rejected=1

Without the bit, a double return can insert the same block twice into the free list. Two future allocations can then hand the same address to two owners. The resulting corruption may occur far from the original mistake.

The pool’s make<T> obtains a block and uses construct_at. If the constructor throws, it returns the block to the free list:

pool constructor-rollback=1 free=64

destroy calls the object’s destructor before returning the block. Reversing those operations would expose storage for reuse while an old object is still executing its destructor.

constant size created its own waste

Each pool block uses:

stride = round_up(BlockSize, BlockAlignment)

A 33-byte request in a pool with 64-byte blocks spends 64 bytes. That internal fragmentation is the price of constant-time placement. A 65-byte request cannot use the pool at all.

Production allocators combine size classes:

16-byte class
32-byte class
64-byte class
128-byte class
...

A request rounds to a class, while larger allocations take another path. Per-thread caches reduce contention. Pages can move between central and local structures. Metadata detects or accelerates coalescing. The simple pool isolates reuse rather than reproducing a complete allocator.

Alignment can increase stride further. A 24-byte object with 32-byte alignment consumes at least 32 bytes per slot. An array of slots needs the stride so every block begins correctly aligned.

External fragmentation is different. A general allocator can have enough total free bytes but no sufficiently large contiguous block. Fixed-size blocks avoid external fragmentation inside their class because every free block is interchangeable. They can still strand capacity when one class is empty and another has unused blocks.

The pool randomized 100,000 allocate and release decisions. After every decision:

free_count + live_count == 64

That reference invariant caught lost blocks, duplicate ownership, and accounting drift. It does not test concurrent access because the pool offers none.

three allocators answered one narrow benchmark

I measured construction and destruction of 32,768 identical 32-byte Node objects through three paths:

ordinary new/delete for each object
arena make for each object, then one reset
fixed-pool make and destroy for each object

Every Node has a nontrivial destructor that contributes its ID to a checksum. The arena therefore pays for destruction records and runs every destructor. The pool and ordinary path run the same destructor. A pre-reserved pointer vector holds addresses outside the timed allocation mechanisms.

Each process performed five warmup rounds and 31 measured rounds. Method order was shuffled deterministically inside every round. The reported statistic is the median, with min and max showing spread.

First complete process:

new/delete  min 472084 ns  median 499542 ns  max 556250 ns
            median 15.24 ns/object

arena/reset min 185667 ns  median 223167 ns  max 242375 ns
            median 6.81 ns/object

fixed-pool  min 126000 ns  median 141125 ns  max 148459 ns
            median 4.31 ns/object

Second complete process:

new/delete  min 448958 ns  median 464416 ns  max 510333 ns
            median 14.17 ns/object

arena/reset min 184250 ns  median 230500 ns  max 264292 ns
            median 7.03 ns/object

fixed-pool  min 135667 ns  median 142125 ns  max 150667 ns
            median 4.34 ns/object

The ordering repeated on this machine. The fixed pool was about 3.3 times lower median latency per object than individual new/delete in the second process. The arena was about twice as low.

The numbers do not establish a universal allocator ranking. The benchmark is single-threaded, fixed-size, hot, and batch-shaped. Ordinary allocation may use thread caches. A real arena can waste memory or spend time on larger destructor metadata. A pool can fail when sizes vary. Multithreaded contention, cache misses, NUMA placement, page faults, security hardening, and long-lived fragmentation are absent.

The result supports a narrower prediction: when many same-sized objects are created and released under one thread with known lifetime shape, removing general size lookup and turning many releases into simple pool operations can reduce allocator-path work.

the fastest deallocation was a lifetime decision

Arena reset is quick because the caller made a strong statement: every object in the arena dies together. The speed comes from the lifetime model before it comes from bit tricks.

If one object must outlive the request, it cannot safely point into that arena. Copy it elsewhere, move ownership to a longer-lived region, or change the region lifetime. Letting the pointer escape and hoping reset occurs later recreates the opening bug at a larger scale.

A fixed pool makes a different statement: objects share size and alignment, while their individual lifetimes vary. It pays a free-list operation per release to support that freedom.

std::vector states that elements are contiguous and owned as one resizable sequence. Growth may move all of them to preserve contiguity.

Allocation design follows from:

which objects die together?
which addresses must stay stable?
which sizes repeat?
who owns destruction?
can allocation fail?
can constructors throw?
which threads allocate and release?

Choosing an allocator before answering those questions turns a mechanism into a guess.

stable addresses needed another level of indirection

If callers need stable addresses while the collection grows, several designs change the ownership graph.

A vector of owning pointers keeps small pointer objects contiguous while each pointee has its own allocation:

std::vector<std::unique_ptr<Record>> records;

Vector growth moves unique_ptr objects. The Record allocations stay where they are, so Record* values remain stable until their individual owners destroy them. The cost is one allocation per record unless a pool supplies them, plus pointer chasing and weaker spatial locality.

An index can survive vector growth:

std::size_t remembered = 0;
values.push_back(23);
int value = values[remembered];

The index is interpreted against current vector storage each time. Erase and reordering can change which value lives at that index, so identity needs a generation or stable key when elements are removed.

std::deque stores elements in multiple blocks and has different invalidation rules. It sacrifices one single contiguous array. std::list allocates nodes and preserves node addresses across many operations, but pays per-node allocation, pointers, and poor locality. A stable-vector design can combine indirection with packed storage.

No container is “more stable” in every sense. Address stability, iterator stability, contiguity, insertion cost, random access, memory overhead, and cache behavior trade against one another.

stable pointees made the scan follow a million pointers

The vector of owning pointers fixes one correctness requirement, but it changes the memory path:

std::vector<Node> packed;
std::vector<std::unique_ptr<Node>> indirect;

The packed vector owns one contiguous allocation containing all Node objects. The indirect vector owns one contiguous allocation of pointer-sized owners plus one allocation for every Node.

I filled both with 1,048,576 identical logical nodes. Each node occupied 32 bytes:

struct Node {
    std::uint64_t id;
    std::array<std::uint64_t, 3> payload;
};

The timed loop added id and one payload field for every node. Five rounds warmed each representation. Thirty-one measured rounds ran in a deterministically shuffled order. The process reported median, minimum, and maximum elapsed nanoseconds.

First process:

packed-vector count=1048576 bytes-per-node=32
min=426125 median=475916 max=502834

owned-pointers count=1048576 bytes-per-node=32
min=717625 median=783125 max=820333

Second process:

packed-vector count=1048576 bytes-per-node=32
min=412500 median=470625 max=560417

owned-pointers count=1048576 bytes-per-node=32
min=677459 median=749334 max=889416

The packed median was about 1.65 times lower in the first process and 1.59 times lower in the second. This is a measured result for one sequential scan on this allocator, machine, compiler, and node shape. It is not a universal container ranking.

The packed loop can calculate each next address by adding 32. Hardware prefetchers can recognize that regular stream. Each cache line contains parts of adjacent nodes. The indirect loop first loads an owner pointer, then follows it to a separately allocated object. That creates an extra dependent load and gives the allocator freedom to scatter nodes.

The allocation layout happened to be friendly enough that the indirect scan remained fast. A long-lived process with mixed allocation sizes and deallocation can scatter nodes over more pages. That increases translation pressure and cache misses. Conversely, an application that visits only a sparse subset can benefit from keeping a compact index of pointers rather than scanning large packed records.

Construction cost was deliberately outside the timed region. The indirect representation performed more than one million individual allocations while being built. That can dominate when collections are short-lived. A fixed pool could remove much of that allocation overhead while retaining stable pointee addresses, at the cost of pool capacity and lifetime management.

Packed storage has its own movement cost. Inserting into the middle shifts objects. Growing relocates the whole range. A large or immovable object can make indirection the correct choice even when scans slow down.

The relevant prediction follows access and mutation:

mostly sequential reads, bounded growth:
    packed storage usually gives the hardware a simpler path

frequent growth, stable external identities:
    indirection separates collection movement from object movement

mixed object lifetimes:
    individual ownership or a pool can outlive collection reordering

random sparse visits:
    layout should group the fields and objects actually visited together

Address stability is therefore purchased with an ownership and locality design. It is not a free property unlocked by changing one container name.

reserve was a contract with future growth

When the maximum count is known and contiguity matters, reserve is often the simplest design. The caller records a growth bound before exporting pointers:

values.reserve(expected_records);
load_records(values);

// export pointers only after growth is complete
submit(values.data(), values.size());

This ordering appears in C APIs, GPU transfers, asynchronous I/O, and foreign-function interfaces. A pointer and length may be handed to code that outlives the call. Growing the vector before that consumer finishes can invalidate the buffer while another subsystem still reads it.

Capacity alone is not synchronization. Another thread calling push_back while one thread reads elements can create a data race even when no reallocation occurs. The vector’s size and construction state change. Address stability does not imply concurrent safety.

Pinned host memory and GPU transfers make the lifetime consequence physical. A DMA engine can continue reading a buffer after the CPU call returns. Destroying or reallocating the vector before the event completes gives hardware an address whose ownership has ended. An RAII wrapper must couple the buffer lifetime to transfer completion, not merely to lexical scope.

The same pattern appears with readv, networking send queues, and callback APIs. Whenever a pointer escapes, record:

who may use it?
until which event?
which operations can move or destroy the object?
who signals completion?

The length handed out with the pointer belongs to the same snapshot. This pair:

const int* data = values.data();
std::size_t count = values.size();

describes count live integers beginning at data at that moment. Appending without reallocation preserves the old element addresses but does not enlarge the captured range. Appending with reallocation invalidates its address. Erasing can change both the live object sequence and the valid count.

Passing capacity instead of size would expose raw storage where elements have not been constructed:

submit(values.data(), values.capacity()); // wrong element count

The bytes from size to capacity belong to the allocation, but they are not live int elements promised by the vector interface. A consumer that reads them mistakes allocation extent for object extent.

A byte-oriented interface needs an overflow-safe conversion:

if (count > std::numeric_limits<std::size_t>::max()
                / sizeof(int)) {
    throw std::length_error("byte count");
}
std::size_t bytes = count * sizeof(int);

The consumer must also know whether the bytes are input, output, or both. Writing into storage beyond vector size does not update size or construct general T objects. A C API that fills a buffer should receive storage from a container operation designed for that purpose, or write into a separate byte buffer whose initialized length is committed only after the call succeeds.

Pointer, count, access direction, and completion event form one lifetime contract. Omitting any one can leave an address that is numerically plausible but operationally unusable.

not every vector operation breaks the same pointers

The opening demonstrated total invalidation on reallocation. Other operations have narrower rules. Treating every mutation as total invalidation is safe but can force unnecessary lookups. Treating every non-growth mutation as safe is wrong.

Appending without reallocation preserves pointers and references to existing elements, while the old past-the-end iterator changes because the end changed.

Insertion in the middle without reallocation shifts elements at and after the insertion point. Pointers before the point remain valid; pointers at or after it are invalidated because different objects can be moved into those positions.

Erase destroys the selected elements and shifts later elements left. Iterators and references at or after the erased position are invalidated.

reserve invalidates everything only when it actually reallocates. Asking for a value no greater than current capacity does nothing to storage.

shrink_to_fit is a non-binding request. If the implementation reallocates to reduce capacity, it invalidates pointers. Code cannot call it and assume either that memory shrank or that addresses stayed.

clear destroys every element but does not require capacity to shrink. data() can retain the same numerical value after clear, yet every pointer to a former element is invalid because no element object remains. A later append can construct a new element at that address. This is the same storage-reuse distinction the explicit lifetime probe exposed.

pop_back destroys only the last element. Pointers to earlier elements remain valid, while a pointer to the removed element dies. The old end iterator and any representation of the removed object cannot be used.

The vector modifiers wording describes invalidation at each operation. For insertion without reallocation, references, pointers, and iterators before the insertion point remain valid, while those at or after it do not. When reallocation happens, all references, pointers, and iterators, including the past-the-end iterator, are invalidated.

A small address trace shows why position matters:

before insert:
address A0 contains 10
address A1 contains 20
address A2 contains 30

insert 15 at position 1 without reallocation:
address A0 contains 10
address A1 contains 15
address A2 contains 20
address A3 contains 30

The allocation did not move. A pointer to A0 still designates the original 10. A pointer that used to designate 20 at A1 does not follow that value to A2. The operation moved or assigned elements inside the allocation.

This is the same reason an index is not always a stable identity. Index 1 means a position in the current sequence. After insertion it refers to 15, not the old element 20. A stable key needs a separate mapping from identity to current position.

Erase produces the reverse shift:

before erase position 1:
A0 = 10, A1 = 15, A2 = 20, A3 = 30

after erase:
A0 = 10, A1 = 20, A2 = 30

The object 15 is destroyed. Later objects move or are assigned toward the beginning. An address can stay inside live vector storage while the identity found there changes.

Moving the entire vector commonly transfers its block, but allocator rules and operation form matter. The standard specifies container requirements more carefully than “three pointers always move.” A move constructor given no conflicting allocator can normally take ownership of the source allocation. A move assignment may need element-wise movement when allocator state cannot propagate and the destination’s allocator cannot release the source block.

After a successful block-stealing move, element references can continue to designate the same objects now owned by the destination. The source vector remains valid but has an unspecified state. Code should not infer its exact capacity unless the operation’s specification provides it.

Swapping vectors exchanges their contents, but allocator preconditions matter. A pointer to an element follows the element into the other container rather than becoming a pointer to whatever the original container now owns. The past-the-end iterators are invalidated.

The distinction between element identity and container identity can surprise an API that stores both:

struct Selection {
    std::vector<Record>* owner;
    Record* element;
};

After a swap, element can remain valid while owner names the other collection. The pair no longer describes one ownership relationship. A handle that needs both must define what swaps and moves mean.

Sorting generally preserves the allocation but permutes values or objects within it. A pointer to a position still points to that position, which may now hold another logical record. If identity is carried by an id field, sort changes the address associated with each ID.

Algorithms that remove records with the erase-remove pattern combine reassignment and destruction. A pointer can remain within the allocation and still cease to designate the logical record a caller remembered. Address validity alone is weaker than semantic identity.

The reliable procedure is operation-specific:

did this operation reallocate?
did it shift or destroy my element?
does the standard guarantee cover this pointer?
is another thread mutating the container?
has the pointed object's lifetime ended?
does this address still contain the same logical identity?

This procedure belongs at the boundary where the pointer escapes. Looking only at the line that dereferences it is too late because the invalidating operation may be far away.

Debug iterator modes can record container ownership and detect some invalidated iterators during development. AddressSanitizer catches the opening case because storage was freed. It may not catch an in-place shift where the address remains allocated and holds another live object. A generation-bearing application handle can detect that semantic invalidation when raw address instrumentation cannot.

the address could return without the value returning

After the arena reset, its next allocation reused the same base address. General allocators also reuse freed blocks.

Suppose the opening vector’s old four-byte block is freed, then another int receives the same address:

old object: 17 at address A
old lifetime ends
storage released
new object: 91 at address A

The stale pointer’s bits equal the new pointer’s bits. That does not rewind the old object’s lifetime or preserve its identity. Depending on how storage reuse and pointer provenance interact with the operation, modern C++ provides specific rules and tools such as std::launder for narrow cases. It is unsafe to reduce the issue to address equality.

This is also the ABA shape in concurrent algorithms: a location changes from value A to B and back to A, so a thread comparing only the visible value misses the intervening lifetime. Tagged pointers, counters, epochs, and reclamation schemes exist because equality is insufficient.

The fixed pool’s checked in-use bit catches double return in one thread. A lock-free free list would need atomic ordering and protection against ABA plus safe memory reclamation. Adding compare-and-swap to the simple pool would not complete that proof.

the old pointer now predicted its own failure

Before the second push_back, remembered designated a live int in the vector’s only allocation.

Size equaled capacity, so the append had to reallocate. The local libc++ growth policy chose capacity 2. It allocated a new block, constructed 23, copied the trivial old integer into the new range, ended the old integer’s lifetime, released the old block, and changed data().

values[0] followed the new data pointer and found the new integer whose value was 17. remembered retained the old address. Its object no longer existed and its storage had been freed. AddressSanitizer marked the region and stopped the dereference.

The reserve control stayed valid because size 2 did not exceed capacity 2. The pointer arithmetic worked because elements belonged to one live array. The misaligned cast failed because a byte address did not satisfy the target object’s alignment. The compact vector survived growth because it accounted for every constructed object in both allocations. The arena made release cheap by ending one lifetime region at once. The pool made reuse cheap by restricting block size and tracking ownership.

The value never promised to remain at one address. The pointer did not promise to follow it.