the address where the value lived
values[0] was still 17. The pointer to values[0] was already dead.
#include <cstdio>
#include <vector>
int main() {
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 least helpful result it could have produced. The last line looked correct, so the program looked correct.
I rebuilt it with AddressSanitizer:
clang++ -std=c++20 -O1 -g -fsanitize=address \
-fno-omit-frame-pointer opening.cpp -o opening
./opening
This time the program stopped at the last line:
before: data=0x1077006f0 remembered=0x1077006f0
size=1 capacity=1 value=17
after: data=0x1077006d0 remembered=0x1077006f0
size=2 capacity=2 values[0]=17
ERROR: AddressSanitizer: heap-use-after-free
READ of size 4 at 0x0001077006f0
The vector contained 17 before and after the second insertion. What changed was the address where that value lived.
That distinction sounds small. It turns out to be most of the story of C++ memory.
the value moved; the pointer did not
The first call to reserve asks the vector for enough storage to hold at
least one int. The first push_back constructs 17 in that storage.
At that point the useful facts are:
size = 1
capacity = 1
data = 0x1077006f0
Size is the number of live elements in the vector. Capacity is the number of elements that fit in its current allocation. A vector may have unused capacity, but it cannot use bytes beyond that capacity just because they happen to follow its allocation in memory.
The second insertion needs room for two integers. The current allocation holds one. A vector normally handles that case by doing something like this:
1. acquire a larger block of storage
2. construct the elements in the new block
3. destroy the elements in the old block
4. release the old block
5. remember the new block
values[0] consults the vector after all five steps, so it finds the new
integer whose value is 17. remembered is a separate object. It still
contains the old address.
The vector cannot repair that pointer. There is no list of all the pointers, references, iterators, or views its caller has made. Even if it did, those observers might be stored in registers, inside other objects, or in another thread. The rule is instead placed on the caller: an operation that reallocates a vector invalidates pointers, references, and iterators to its elements.
Here is the smallest useful control experiment:
std::vector<int> values;
values.reserve(2);
values.push_back(17);
int* remembered = &values[0];
const int* before = values.data();
values.push_back(23);
std::printf("same data: %d\n", before == values.data());
std::printf("same element: %d\n", remembered == &values[0]);
std::printf("value: %d\n", *remembered);
The probe printed:
same data: 1
same element: 1
value: 17
The extra capacity isolated the cause. push_back itself is not a magic
pointer-breaking operation. This particular push_back was dangerous
because it needed a new allocation.
reserve(2) is not a lifetime charm, though. It is only a promise about a
known amount of future growth. A third insertion can reallocate again. So can
an explicit call to reserve with a value greater than the current capacity.
AddressSanitizer made the original mistake easy to see. The compiler adds
checks around memory accesses, and the sanitizer runtime records which
regions are available. After the old block was released, the runtime marked
it unavailable. Dereferencing remembered then became a checked read from a
freed region.
Without the sanitizer, the old bytes may still contain the bit pattern for 17. Nothing has to scrub them. Later, the allocator may reuse those bytes for a completely different object. This is why a stale pointer is not made safe by a plausible printed value. The program has already lost the right to ask what is there.
what a pointer actually remembers
Consider a smaller program:
int value = 17;
int* pointer = &value;
It creates two objects.
value is an int object. Its value is 17. pointer is a pointer object.
Its value identifies value.
The & operator asks for an object’s address. In the declaration,
int* says that the pointer is meant to identify an int. When the star
appears in an expression, it means “use the object at this address”:
int observed = *pointer; // read value
*pointer = 23; // write value
After the assignment, value is 23. The pointer has not moved.
A pointer can be made to identify another object:
int left = 10;
int right = 20;
int* selected = &left;
selected = &right;
That final line changes the pointer, not either integer.
nullptr is a deliberate “no object” pointer value:
int* absent = nullptr;
It is valid to compare absent with nullptr. It is not valid to
dereference it. The vector bug is harder to notice because the pointer is
not null. It contains an ordinary-looking address. A null check can detect
absence, but it cannot detect an expired lifetime.
Constness answers another question:
const int* read_only_path = &right;
int* const fixed_path = &right;
The first pointer may be changed to point elsewhere, but it cannot be used to modify the integer. The second pointer must keep its address, but it may modify the integer. Neither form keeps the integer alive.
References hide the pointer syntax without removing the lifetime rule:
std::vector<int> values;
values.reserve(1);
values.push_back(17);
int& remembered = values[0];
values.push_back(23);
std::printf("%d\n", remembered); // invalid after reallocation
A reference is another name for an object. It is not a copy, and a normal lvalue reference cannot later be rebound to a different object. When the original vector element’s lifetime ends, the reference dangles.
If all I wanted was a snapshot, the safe operation was a copy:
int remembered_value = values[0];
values.push_back(23);
std::printf("%d\n", remembered_value);
The copied integer has its own storage and its own lifetime. This gives a practical first question whenever a pointer escapes a line of code:
Does the receiver need this object’s identity, or only its current value?
Identity is necessary if the receiver must modify the original object or observe future changes. A value is usually easier to reason about when a snapshot is enough.
arrays explain the arithmetic
An array stores its elements contiguously. There is no gap between one
int element and the next:
int numbers[4] = {10, 20, 30, 40};
int* first = &numbers[0];
int* second = first + 1;
std::printf("%d\n", *second); // 20
Adding one to an int* advances by one int, not by one byte. On the test
machine, sizeof(int) was 4:
i=0 typed difference=0 byte address=0x16b88e230
i=1 typed difference=1 byte address=0x16b88e234
i=2 typed difference=2 byte address=0x16b88e238
i=3 typed difference=3 byte address=0x16b88e23c
The subscript operator uses the same rule:
numbers[i]
*(numbers + i)
Those expressions designate the same element when i is in range.
One special pointer value sits just after the array:
int* end = numbers + 4;
The program may create that one-past pointer, compare it, and subtract the
begin pointer from it. That is how half-open ranges such as [begin, end)
work. It may not dereference end, because there is no fifth integer there.
Pointer arithmetic is defined within an array object, plus that one-past position. An address that happens to be numerically close is not enough. Walking from one allocation into its neighbour does not turn the two allocations into one array.
std::vector deliberately provides array-like storage. When it is not empty,
its elements occupy a contiguous range beginning at data(). This makes
iteration and cache-friendly scanning possible. It also means that replacing
the allocation changes the address of every element.
std::span<int> can hold a pointer and a count for such a range:
std::span<int> view(values.data(), values.size());
The count helps prevent walking beyond the captured range. The span does not
own or copy the integers. Vector reallocation invalidates the span for the
same reason it invalidates remembered.
storage is not yet an object
It is tempting to describe memory as a large set of numbered boxes. That model is useful, but C++ adds another layer. A region of bytes can exist without containing a live object, and the same region can contain different objects at different times.
This ordinary scope demonstrates the layers:
{
int value = 17;
// value is alive here
}
// the name is out of scope and the int's lifetime has ended
Scope tells us where the name may be used. Lifetime tells us when the object exists. They often end together for a local variable, which is why the difference stays hidden at first.
Dynamic allocation separates them:
int* value = new int(17);
// the int is alive even though it was not declared as a local int
delete value;
// the storage was released; value now dangles
new int(17) performs two jobs: it acquires suitably aligned storage and
constructs an int in it. delete destroys the int and releases the
storage. Modern C++ code normally puts that responsibility in a container or
an owning class rather than spelling out new and delete, but the two jobs
still occur underneath.
The separation becomes explicit with allocator-style code:
void* raw = ::operator new(sizeof(std::string));
std::string* text =
std::construct_at(static_cast<std::string*>(raw), "seventeen");
std::destroy_at(text);
::operator delete(raw);
After operator new, the bytes exist but no std::string is alive there.
construct_at begins the string’s lifetime. destroy_at ends it.
operator delete releases the storage.
Ordinary strings belong in ordinary automatic storage. The expanded form is useful here because a container has to repeat those steps for many elements, including the awkward case where construction throws halfway through.
The same address may later host a different object:
alignas(int) std::byte room[sizeof(int)];
int* first = std::construct_at(
reinterpret_cast<int*>(room), 17);
std::destroy_at(first);
int* second = std::construct_at(
reinterpret_cast<int*>(room), 91);
The address is the same. The object is not. My lifetime probe printed:
construct id=17 address=0x16fb3628c
destroy id=17 address=0x16fb3628c
construct id=91 address=0x16fb3628c
same-address=1 current-id=91
This is the simplest version of a problem that reappears in lock-free code as ABA: an address can leave a data structure and later return, even though the logical object has changed. An equal address on two occasions is not enough to establish identity across time.
layout, padding, and alignment
Objects also have layout requirements. The compiler may insert unnamed padding so that each member begins at an address suitable for its type:
struct Scattered {
char a;
double b;
char c;
int d;
};
struct Reordered {
double b;
int d;
char a;
char c;
};
On the test machine:
Scattered size=24 align=8 offsets=0,8,16,20
Reordered size=16 align=8 offsets=0,8,12,13
The two structures contain the same member types. Their order changes how much padding is needed. This is a measured layout for one compiler and target, not a portable promise that every machine will choose those exact numbers.
Alignment is a restriction on where an object may begin. A type with alignment 4 must begin at an address acceptable for 4-byte alignment. The allocator and the compiler cooperate to satisfy that rule for normal objects.
I broke it deliberately:
std::array<std::byte, 8> bytes{};
auto* bad = reinterpret_cast<const std::uint32_t*>(bytes.data() + 1);
std::uint32_t value = *bad;
UndefinedBehaviorSanitizer reported:
runtime error: load of misaligned address ... for type
'const std::uint32_t', which requires 4 byte alignment
If bytes arrive from a file or network packet at an arbitrary position, copying them into an aligned integer avoids the invalid typed load:
std::uint32_t value;
std::memcpy(&value, bytes.data() + 1, sizeof value);
The control printed 0x12345678 and raised no alignment error.
Byte order is a separate question. memcpy preserves the byte sequence; it
does not convert a network byte order into the machine’s byte order. We will
handle that distinction when bytes begin crossing a network.
why vector had to leave
A useful mental model for a vector is three pointers:
begin first live element
end one past the last live element
capacity one past the available storage
Then:
size = end - begin
capacity = capacity_pointer - begin
Real standard-library implementations have more machinery, especially for allocators and compressed storage, but these three positions explain the main operations.
When end != capacity, push_back can construct the new element at end
and advance end. The allocation stays put.
When end == capacity, there is no room. The vector chooses a larger
capacity, acquires a new block, constructs the new state there, and releases
the old block only after the new state succeeds.
I recorded every capacity change while appending integers:
size=1 old capacity=0 new capacity=1
size=2 old capacity=1 new capacity=2
size=3 old capacity=2 new capacity=4
size=5 old capacity=4 new capacity=8
size=9 old capacity=8 new capacity=16
size=17 old capacity=16 new capacity=32
This libc++ build doubled capacity in this range. The C++ standard does not
require that exact sequence. Code may rely on capacity being at least the
requested size after a successful reserve; it may not rely on every
implementation doubling.
Geometric growth is used because growing by one element each time would be
expensive. If every insertion copied all earlier elements, appending n
elements would do roughly:
0 + 1 + 2 + ... + (n - 1)
element relocations. That grows quadratically. With geometric growth, most insertions are cheap and the occasional relocation has enough new capacity to pay for many following insertions. This is the idea behind amortized constant time: one particular insertion may be linear, while the average work per insertion over a long sequence remains bounded.
For 100,000 integers, my counting allocator observed:
geometric growth: 18 allocations, final capacity 131072
reserve(100000): 1 allocation, final capacity 100000
The first run requested 1,048,572 bytes across all allocations. The reserved run requested 400,000. That does not mean every vector should reserve an exact final size. Often the final size is unknown, and over-reserving wastes memory. It means that a good size estimate can remove relocation and make an address-stability assumption explicit.
move, copy, and the failed halfway point
Relocation is not always a byte copy. For a class type, the vector has to construct each new element using that type’s operations.
I tested two small classes. One had a move constructor marked noexcept; the
other had a move constructor that might throw and an available copy
constructor.
noexcept move: copies=0 moves=2
throwing move, copyable: copies=2 moves=0
Why prefer the copy in the second case? Suppose moving the first old element succeeds by changing it, then moving the second throws. The vector would have a half-built new allocation and a partly changed old vector. It could not easily restore the old value.
A copy leaves the source unchanged. If a later copy throws, the vector can destroy the successfully copied elements, release the new block, and keep the old block as it was.
The rollback probe forced that failure:
error=copy refused
size=2 capacity=2 values=10,20
copies=1 moves=0 live=2
One copy succeeded, the next refused, and the original two-element vector
remained intact. This is why noexcept on a move constructor is not merely
documentation. It can change which operation a container is able to use.
There is one more wrinkle. The argument to push_back may itself refer to
an element of the vector:
std::vector<std::string> words{"alpha", "beta"};
words.push_back(words[0]);
If growth is needed, an implementation must preserve the argument long enough to construct the new element. My probe forced growth and produced:
old data=0x104c00b50 new data=0x104e01da0
values=alpha,beta,alpha
That innocent line is a good reason not to reduce vector growth to “allocate and then move everything” without thinking about operation order.
I built the three-pointer version
Reading a container implementation is useful. Building a small one makes the cleanup paths impossible to ignore.
The experimental mini_vector stores these members:
T* begin_ = nullptr;
T* end_ = nullptr;
T* capacity_ = nullptr;
std::allocator<T> allocator_;
Its size and capacity are derived rather than stored:
std::size_t size() const {
return static_cast<std::size_t>(end_ - begin_);
}
std::size_t capacity() const {
return static_cast<std::size_t>(capacity_ - begin_);
}
There is a small C++ caveat here. Subtracting two null pointers is not how we
want to describe an empty vector, so the implementation returns zero
directly when begin_ is null.
The non-growing insertion is short:
template<class U>
void push_back(U&& value) {
if (end_ != capacity_) {
std::construct_at(end_, std::forward<U>(value));
++end_;
return;
}
grow_and_push(std::forward<U>(value));
}
U&& and std::forward let the function preserve whether its argument may
be moved from. Those details are not needed to understand the lifetime
work. Watch the call to construct_at. Spare capacity is storage, not yet a
live T; assignment would assume an object already existed there.
Growth has to remember two frontiers:
old range: [begin_, end_) contains live objects
new range: [new_begin, new_end) contains successfully constructed objects
A simplified version looks like this:
T* new_begin = allocator_.allocate(new_capacity);
T* new_end = new_begin;
try {
for (T* old = begin_; old != end_; ++old, ++new_end) {
std::construct_at(
new_end,
std::move_if_noexcept(*old)
);
}
std::construct_at(new_end, forwarded_new_value);
++new_end;
} catch (...) {
while (new_end != new_begin) {
--new_end;
std::destroy_at(new_end);
}
allocator_.deallocate(new_begin, new_capacity);
throw;
}
Only after that block succeeds may the vector destroy the old elements and release their allocation:
while (end_ != begin_) {
--end_;
std::destroy_at(end_);
}
if (begin_ != nullptr) {
allocator_.deallocate(begin_, old_capacity);
}
begin_ = new_begin;
end_ = new_end;
capacity_ = new_begin + new_capacity;
Destruction runs in reverse order. That convention matches nested local objects and helps types whose later objects depend on earlier ones during their lifetime.
The catch block is not decoration. Without it, a throwing constructor would leak both the new allocation and any elements already constructed inside it. Without delaying destruction of the old range, a failed growth would also damage the original container.
I compared the finished container with std::vector over 100,000 generated
operations. The trace mixed insertion, removal, reservation, copies, and
moves:
randomized trace: 100000 operations matched
size=35103 capacity=35204
I also repeated the forced throwing-copy case:
mini rollback error=copy refused
size=2 capacity=2 values=10,20
copies=1 moves=0 live=2
A randomized comparison is not a proof that the container implements the entire standard-library contract. It is useful evidence that many state transitions agree with a trusted reference. The targeted exception test covers a rare path that random input might never reach.
The allocator contract had one easy-to-miss detail: deallocation needs the allocation’s element count.
T* memory = allocator.allocate(count);
// ...
allocator.deallocate(memory, count);
That count is one reason the vector remembers its capacity boundary. A custom allocator changes where the block comes from. Vector growth may still replace that block, so custom allocation alone cannot make element addresses stable.
arenas and pools answer different questions
General-purpose allocation handles many sizes and lifetimes. Sometimes a program knows more.
An arena is useful when many objects share one broad lifetime. It owns a large byte buffer and moves a cursor forward for each allocation:
buffer begin
|
v
+---------+--------+--------------+------------------+
| object A| padding| object B | unused |
+---------+--------+--------------+------------------+
^
cursor
To allocate, it rounds the cursor up to the requested alignment, checks that enough bytes remain, returns the aligned position, and advances the cursor. There is no individual release. Resetting the arena releases the whole group at once.
My probe constructed two tracked objects and one 32-byte-aligned object:
aligned-mod-32=0 used=96 live=2
arena reset used=0 live=0 destruction=2,1
arena reused-base=1
Cursor reset alone would be wrong for non-trivial objects such as strings, file handles, or locks. Their destructors must run. The experimental arena therefore records a destruction callback for every non-trivial object and runs callbacks in reverse order during reset.
It also rolls back its cursor if a constructor throws:
arena constructor-rollback=1 used-unchanged=1
An arena buys cheap grouped release by requiring grouped lifetimes. Holding
a pointer past reset is the arena version of holding the opening vector
pointer past reallocation.
A fixed-size pool makes a different trade. It divides storage into equal blocks and links the unused blocks into a free list:
free head -> block 5 -> block 2 -> block 7 -> null
Allocation removes the first block. Deallocation puts a block back. No search by size is needed because every block has the same size and alignment.
The checked pool in the probe owns 64 blocks. A reference-model test mixed 100,000 allocations and returns:
pool randomized=100000 free=64
double-return-rejected=1
pool constructor-rollback=1 free=64
The checks matter. Returning the same block twice can make two later allocations hand out the same memory. Returning an address from another pool can corrupt the free list. A fast release build might remove some checks, but the contract still exists.
Fixed size creates waste when objects are smaller than a block and rejects objects that are too large or strictly aligned. The pool is also not thread-safe merely because its operations are short. Concurrent mutation of the free-list head needs synchronization or a carefully designed concurrent algorithm.
I timed one narrow workload that repeatedly created and destroyed 32,768 small objects. A representative run reported:
new/delete: median 25.64 ns/object
arena/reset: median 11.81 ns/object
fixed pool: median 7.45 ns/object
A second run was faster for all three:
new/delete: median 13.94 ns/object
arena/reset: median 6.94 ns/object
fixed pool: median 4.43 ns/object
The ordering survived those runs; the absolute values did not. Calling this a general allocator ranking would be a mistake. The arena and pool were given exactly the lifetime and size patterns they are built for. A real choice also depends on memory use, fragmentation, contention, debugging, and whether the required lifetime discipline is maintainable.
stable addresses send the bill somewhere else
Suppose an object really must keep its address while a collection grows. One common design is to allocate each object separately and let the vector move owning pointers:
std::vector<std::unique_ptr<Node>> nodes;
nodes.push_back(std::make_unique<Node>());
Node* remembered = nodes[0].get();
nodes.push_back(std::make_unique<Node>());
The vector may relocate its unique_ptr elements. The Node objects remain
in their separate allocations, so remembered continues to identify its
node until that owning pointer is erased or destroyed.
std::deque and node-based containers offer different invalidation rules,
but none should be chosen from the phrase “stable addresses” alone. Insert,
erase, swap, and destruction have operation-specific rules. The standard
container’s documentation is part of the design.
Indirection has a cost. A packed vector of nodes puts neighbouring nodes in neighbouring memory. A vector of owning pointers first loads each pointer and then visits a separately allocated node.
Scanning 1,048,576 32-byte nodes produced these local medians:
packed vector: 471917 ns
owned pointers: 671666 ns
Another run widened the difference:
packed vector: 466875 ns
owned pointers: 901000 ns
The result is consistent with locality: contiguous storage gives the processor a predictable stream of nearby bytes, while separate allocations require pointer chasing. The ratio belongs to this test. Allocator placement, node size, access order, caches, and the work done per node can all change it.
This is the bill that stable pointee addresses often send: extra allocations, an extra load, and weaker locality. Sometimes identity is worth that price. Sometimes indices are a better answer:
std::size_t remembered_index = 0;
values.push_back(23);
std::printf("%d\n", values[remembered_index]);
An index survives vector reallocation because it describes a position, not an address. Logical edits are another matter. Inserting or erasing before that position can make it identify a different element. A stable record ID plus a lookup table may be needed when logical identity must survive reordering as well as relocation.
The right representation follows the required lifetime:
need a snapshot copy the value
need a position keep an index, validate edits
need a temporary range use a view, keep the owner stable
need a stable object address own the object separately
need grouped lifetime consider an arena
need equal reusable blocks consider a fixed-size pool
the old pointer now predicts its own failure
Return to the opening code:
std::vector<int> values;
values.reserve(1);
values.push_back(17);
int* remembered = &values[0];
values.push_back(23);
Before the last line:
rememberedidentifies a liveintinside the vector’s allocation.- Size and capacity are both 1.
- Another element cannot fit in that allocation.
During the last line:
- The vector acquires a larger allocation.
- It constructs a value of 17 in the new allocation.
- It constructs 23.
- It ends the old integer’s lifetime.
- It releases the old allocation.
After the line:
values[0]designates the new integer.rememberedstill contains the old address.- That address no longer designates the old live integer.
- Dereferencing the pointer has undefined behavior.
The prediction is independent of whether freed bytes still happen to contain 17 or whether the allocator later returns the same address. It follows from capacity, the operation, and object lifetime.
For vector operations, the compact working rules are:
- Destruction invalidates every observer of the elements.
- A reallocation invalidates every pointer, reference, iterator, and span into the old allocation.
- Insertion without reallocation still may invalidate observers at or after the insertion position.
- Erasure ends the erased elements’ lifetimes and moves later elements, so observers at or after the erased position cannot be trusted.
reserveinvalidates observers if it actually reallocates.cleardestroys the elements even when the allocation is retained.- Spare capacity is raw storage, not a collection of already-live elements.
The current C++ draft’s vector clauses give the precise operation contracts. Its sections on pointer values, object lifetime, and pointer arithmetic provide the language rules underneath them. The Clang AddressSanitizer documentation explains the tool that exposed the first failure.
I began with two identical printed integers. They were never the same evidence. One came through the vector’s current storage. The other came through an address where an integer used to live.