// STUHUB · C++ DATA STRUCTURES
C++ Pointers: The Three-Level Memory Model
A pointer is three separate physical things — its own address, the address it stores, and the data it points at. Once you can name all three, assignment, references, double pointers and Node*& stop being guesswork.
Introduction
Almost nobody loses marks on pointers because they forgot the syntax. They lose marks because they cannot say, for a given line of code, which of three different things is being read or written. &p, p and *p are not three notations for one idea; they are three different questions about two boxes — which box is this, what number is inside it, and who lives at that number — and every pointer bug in a data-structures paper is a case of answering the wrong one. This page builds that three-level model, proves it with programs that print real addresses, and then applies it to the two places it actually decides whether your linked-list code works: copying a pointer versus aliasing it, and passing Node* versus Node*& into a function that inserts at the head.
The mechanism: three levels, one pointer
Every pointer variable p exists in memory as three dimensions at once.
Level 1 — &p, the pointer variable's own address. A pointer is a variable like any other. It occupies a room, and that room has a house number. On a 64-bit build that room is 8 bytes wide, regardless of what it points at. If you take &p and store it somewhere, the thing you stored it in is a double pointer: int** pp = &p;.
Level 2 — p, the address stored inside it. This is the value of the pointer: the house number of some other room, the one you are pointing at. Assigning to p does not touch the target at all; it just writes a new house number into the pointer's own box.
Level 3 — *p, the data at that address. Dereferencing walks to the house number stored at level 2 and reads or writes whoever lives there. Writing to *p changes the target and leaves the pointer completely unchanged.
level 1: &p level 2: p level 3: *p
the pointer's own the address it the data living
room number has stored at that address
+----------------+ +----------------+ +----------------+
| p | --> | a | | |
| 0x16d136270 | | 0x16d136278 | | 100 |
+----------------+ +----------------+ +----------------+
(p lives here) (a lives here) (a's contents)
&p = 0x16d136270
p = 0x16d136278 ( == &a )
*p = 100 ( == a )Read that diagram left to right and three separate assignments become unambiguous:
p = &b;writes level 2. The pointer now names a different room.&pis untouched, and the old target is untouched.*p = 250;writes level 3. The target's contents change.pand&pare untouched.pp = &p;reads level 1 and stores it somewhere else. That is the whole of double pointers.
The same three levels exist for a Node*. &head is where the head pointer itself lives (usually on the stack, or inside a list struct). head is the address of the first node (on the heap). *head — normally written head->data, which is just sugar for (*head).data — is the node itself. The arrow operator is a level-3 operation with a member access bolted on: follow the stored address to the struct, then pick a field out of it.
Working code: seeing all three levels
Every program on this page compiles with g++ -std=c++17 -Wall and the outputs are copied from real runs. Addresses are different on every run and every machine — that is address-space randomisation, not a mistake. What is stable, and what you should watch, is the relationships between the numbers: which two are equal, and which one changes when you assign.
#include <iostream>
using namespace std;
int main() {
int a = 100;
int* p = &a;
int** pp = &p;
cout << "level 1 &p (p's own address) = " << (void*)&p << '\n';
cout << "level 2 p (address p holds) = " << (void*)p << '\n';
cout << "level 3 *p (data at that addr) = " << *p << '\n';
cout << '\n';
cout << "&a is the same room as p points to: "
<< ((void*)&a == (void*)p ? "yes" : "no") << '\n';
cout << '\n';
cout << "pp = " << (void*)pp << " (address of p)\n";
cout << "*pp = " << (void*)*pp << " (address of a)\n";
cout << "**pp = " << **pp << " (the value)\n";
cout << '\n';
cout << "sizeof(a)=" << sizeof(a)
<< " sizeof(p)=" << sizeof(p)
<< " sizeof(pp)=" << sizeof(pp) << '\n';
*p = 250; // write through level 3
cout << "after *p = 250, a = " << a << '\n';
int b = 7;
p = &b; // write to level 2: p now names a different room
cout << "after p = &b, p = " << (void*)p << ", *p = " << *p
<< ", a is still " << a << '\n';
cout << "&p is unchanged = " << (void*)&p << '\n';
return 0;
}What the printed addresses prove
Output from one run:
level 1 &p (p's own address) = 0x16d136270
level 2 p (address p holds) = 0x16d136278
level 3 *p (data at that addr) = 100
&a is the same room as p points to: yes
pp = 0x16d136270 (address of p)
*pp = 0x16d136278 (address of a)
**pp = 100 (the value)
sizeof(a)=4 sizeof(p)=8 sizeof(pp)=8
after *p = 250, a = 250
after p = &b, p = 0x16d136264, *p = 7, a is still 250
&p is unchanged = 0x16d136270Four things in that output are worth more than a paragraph of theory:
ppprinted the same number as&p. A double pointer holds level 1 of another pointer; there is no magic in it.*ppprinted the same number asp. One star peels one level off.sizeof(p)andsizeof(pp)are both 8 whilesizeof(a)is 4. A pointer's size has nothing to do with what it points at — anint*, achar*and aNode***are all just house numbers.*p = 250changeda, andp = &bdid not. Different levels, different victims.
Assignment versus reference: a snapshot or a nickname
This is the distinction that decides whether a pop frees the right node. Node* copy = top; builds a new pointer variable with its own room, and copies the stored address into it. Node*& alias = top; builds no new variable at all — alias is a second name for top itself.
#include <iostream>
using namespace std;
struct Node { int data; Node* next; };
int main() {
Node* third = new Node{30, nullptr};
Node* second = new Node{20, third};
Node* top = new Node{10, second};
cout << "nodes: 10 @" << (void*)top
<< " 20 @" << (void*)second
<< " 30 @" << (void*)third << "\n\n";
Node* copy = top; // value copy: a snapshot
cout << "copy = top -> © = " << (void*)©
<< ", copy = " << (void*)copy << '\n';
Node*& alias = top; // reference: a nickname
cout << "alias = top -> &alias= " << (void*)&alias
<< ", alias= " << (void*)alias << " (&alias == &top: "
<< ((void*)&alias == (void*)&top ? "yes" : "no") << ")\n\n";
top = top->next; // pop: move the stack pointer forward
cout << "after top = top->next:\n";
cout << " top = " << (void*)top << " (data " << top->data << ")\n";
cout << " copy = " << (void*)copy << " (data " << copy->data << ") <- still the old node\n";
cout << " alias = " << (void*)alias << " (data " << alias->data << ") <- moved with top\n\n";
cout << "delete copy; frees the node holding " << copy->data << " (correct)\n";
delete copy;
cout << "list is now: ";
for (Node* c = top; c; c = c->next) cout << c->data << " -> ";
cout << "NULL\n";
while (top) { Node* t = top; top = top->next; delete t; }
return 0;
}Level 1 decides which node you free
nodes: 10 @0x104c79af0 20 @0x104c79ae0 30 @0x104c79ad0
copy = top -> © = 0x16b9122a8, copy = 0x104c79af0
alias = top -> &alias= 0x16b9122b0, alias= 0x104c79af0 (&alias == &top: yes)
after top = top->next:
top = 0x104c79ae0 (data 20)
copy = 0x104c79af0 (data 10) <- still the old node
alias = 0x104c79ae0 (data 20) <- moved with top
delete copy; frees the node holding 10 (correct)
list is now: 20 -> 30 -> NULLLook at level 1. © is a different address from &top, so copy is a genuinely separate box. &alias is literally the same address as &top — the compiler created no storage, it just gave the existing variable a second name. That single fact explains everything downstream:
Node* copy = top; Node*& alias = top;
&top © &top (== &alias)
| | |
[0x1000][0x1000] [0x1000]
| | |
v v v
node 10 (both point here) node 10
after top = top->next: after top = top->next:
[0x1010][0x1000] [0x1010]
| | |
v v v
node 20 node 10 <- kept node 20 <- "alias" is just top
(nothing remembers node 10)With the copy, delete copy; frees node 10 — the node you actually popped. With the reference, delete alias; would free node 20 — the node still in the list — and node 10 would leak with nothing pointing at it. Same three lines of code, one word of difference, and the second version corrupts the structure while looking tidy.
The rule that falls out: to remember something, copy the pointer; to change someone else's pointer, take a reference.
Node* versus Node*&: why head insertion silently fails
This is where the model pays for itself. A function parameter is a variable, so a parameter has its own level 1. Passing Node* head gives the function a new box holding a copy of the caller's address. Assigning to it writes the function's own box, and the caller never finds out.
#include <iostream>
using namespace std;
struct Node { int data; Node* next; };
// BROKEN: head is a by-value copy of the caller's pointer
void insertHeadByValue(Node* head, int value) {
Node* n = new Node{value, head};
head = n; // moves the LOCAL copy only
cout << " inside byValue: &head = " << (void*)&head
<< " head = " << (void*)head << '\n';
}
// CORRECT: head is an alias for the caller's pointer variable
void insertHeadByRef(Node*& head, int value) {
Node* n = new Node{value, head};
head = n; // moves the CALLER's pointer
cout << " inside byRef: &head = " << (void*)&head
<< " head = " << (void*)head << '\n';
}
void print(const char* label, Node* head) {
cout << label;
for (Node* cur = head; cur != nullptr; cur = cur->next) cout << cur->data << " -> ";
cout << "NULL\n";
}
void freeList(Node*& head) {
while (head != nullptr) { Node* t = head; head = head->next; delete t; }
}
int main() {
Node* head = nullptr;
insertHeadByRef(head, 30);
insertHeadByRef(head, 20);
print("start: ", head);
cout << "caller: &head = " << (void*)&head
<< " head = " << (void*)head << '\n';
cout << "\ninsertHeadByValue(head, 10):\n";
insertHeadByValue(head, 10);
print(" after: ", head);
cout << " caller head still = " << (void*)head << '\n';
cout << "\ninsertHeadByRef(head, 10):\n";
insertHeadByRef(head, 10);
print(" after: ", head);
cout << " caller head now = " << (void*)head << '\n';
freeList(head);
return 0;
}Reading the level-1 column
inside byRef: &head = 0x16d36e2b0 head = 0x1030d1ad0
inside byRef: &head = 0x16d36e2b0 head = 0x1030d1ae0
start: 20 -> 30 -> NULL
caller: &head = 0x16d36e2b0 head = 0x1030d1ae0
insertHeadByValue(head, 10):
inside byValue: &head = 0x16d36e268 head = 0x1030d1af0
after: 20 -> 30 -> NULL
caller head still = 0x1030d1ae0
insertHeadByRef(head, 10):
inside byRef: &head = 0x16d36e2b0 head = 0x1030d1b00
after: 10 -> 20 -> 30 -> NULL
caller head now = 0x1030d1b00Read the level-1 column. Inside insertHeadByRef, &head is 0x16d36e2b0 — exactly the caller's &head. Inside insertHeadByValue, &head is 0x16d36e268, a different box on the same stack frame. The by-value version allocated node 10, linked it correctly to the old head, moved its own copy of the pointer, and returned — at which point its copy was destroyed and node 10 became unreachable garbage. Nothing crashed. Nothing warned. The list simply did not change, and one 16-byte node leaked.
Two details make this a mark-loser rather than a quick fix. First, the failure is invisible for non-head operations: insertAfter(Node* cur, ...) works fine by value, because it never writes to the caller's pointer variable — it writes to cur->next, which is level 3, shared memory. So students correctly conclude "pointers let functions change things" from one case and wrongly generalise it to the other. Second, the same function is also wrong for an initially empty list, deletion of the first node, and any reversal that ends with head = prev; — every operation whose whole purpose is to move head.
by value: caller's head box [0x1010] (never touched)
function's head box[0x1020] -> new node -> old head
^ destroyed on return; new node leaks
by ref: caller's head box [0x1020] -> new node -> old head
function's "head" = the same box, no copy existsThe C-style spelling of the same fix is a double pointer — level 1 passed explicitly instead of by the compiler:
void insertHeadPP(Node** head, int value) { // pass the pointer's address
Node* n = new Node{value, *head};
*head = n; // write through to the caller's variable
}
// call site: insertHeadPP(&head, 30);Running it with 30, 20, 10 prints 10 -> 20 -> 30 -> NULL. It is exactly equivalent to Node*&; the reference version just hides the & at the call site and the * in the body. If you can convert freely between the two spellings, you have understood level 1.
The patterns worth knowing cold
Four templates, each of which is really a statement about which level you are touching.
1. Back up before you move. The only safe pop or head-delete:
Node* temp = head; // copy the address (level 2 into a new box)
head = head->next; // move the handle
delete temp; // free the node nobody points at any more2. Link before you unlink. Insertion after prev is two assignments and the order is not negotiable:
n->next = prev->next; // attach the new node to the rest of the list first
prev->next = n; // only then redirect the predecessorReverse those and prev->next already points at n, so n->next = prev->next makes n point at itself and the tail of the list is gone.
3. Three pointers to reverse. Save the next link before you destroy it:
Node* nxt = cur->next; // save, or you cannot walk forward
cur->next = prev; // flip the arrow
prev = cur; // advance
cur = nxt;4. Match your new to your delete. new pairs with delete, new[] pairs with delete[], and mixing them is undefined behaviour:
Node* p = new Node{10, nullptr};
delete p;
int* arr = new int[10];
delete[] arr;Where people go wrong
1. Confusing "changing the target" with "changing the pointer". *p = 5 and p = &q are answers to different questions, and under exam pressure the wrong one comes out. Symptom: a function that was supposed to redirect a link instead overwrites the data in a node, or vice versa — output is plausible, values are wrong, and no crash points at the line. Fix: before writing any assignment involving a pointer, say out loud which level is on the left. If * appears on the left you are editing the target's contents; if the bare name appears you are editing the arrow.
2. Passing Node* when the function must move head. The single most expensive pointer mistake in this topic, because it fails silently. void push(Node* head, int v) compiles clean under -Wall, allocates the node, links it correctly, and changes nothing the caller can see. Symptom: "my insert works but the list is always empty", or "the first insert vanishes and the rest are fine". Fix: any function whose job is to change what head points at takes Node*& head (or Node** head). Test: if the body contains head = something; at the top level, the parameter must be a reference. head->next = something; does not need one.
3. Deleting through an alias instead of a copy. Node*& temp = topNode; topNode = topNode->next; delete temp; frees the node that is now first in the list and leaks the one you meant to pop. Symptom: the structure looks fine immediately, then a later traversal prints garbage or segfaults, often several operations away from the real bug — which makes people "fix" innocent code. Fix: back-ups are always Node* temp = ..., never Node*& temp = .... A reference is for writing back, not for remembering.
4. Dereferencing a null or dangling pointer. head->next when head is nullptr crashes immediately; cur->next after delete cur may appear to work for months because the freed bytes have not been reused yet. Symptom: code that passes on your laptop and crashes in the marking environment, or the reverse. Fix: guard every dereference of a pointer that could be empty (if (head == nullptr) return; first, always), never read a node after freeing it, and set a pointer to nullptr after delete if it stays in scope. Note that delete nullptr; is explicitly safe — you never need to guard the delete itself.
5. Losing the only handle to the structure. Walking with head itself (while (head) { cout << head->data; head = head->next; }) inside a function that took Node*& head empties the list and leaks every node. It prints perfectly once and prints nothing ever again. Fix: traverse with a local copy — for (Node* cur = head; cur; cur = cur->next). The reference parameter exists so you can move head deliberately, which makes accidental movement much easier.
6. Miscounting stars. *pp is a Node*, **pp is a Node. Writing *pp->next when you meant (*pp)->next binds the arrow first and produces either a compile error or the wrong object. Symptom: a wall of template-free but incomprehensible type errors. Fix: count the levels in the type declaration and match them. Each * on the left of a use removes exactly one * from the type.
7. Assuming a pointer copy copies the data. Node* b = a; gives you two names for one node, not two nodes. Changing b->data changes a->data, and delete a; delete b; is a double free. Symptom: a "backup" that mutates with the original, or a crash on the second delete. Fix: to copy a node you must new one and copy the fields; to copy a list you must walk it and allocate each node.
Check yourself
- For any line involving a pointer you can say which of
&p,p,*pis being read and which is being written. - You can predict, before running it, which two of
&p,p,pp,*ppprint the same number. - You know that
sizeofany pointer is the same regardless of target type, and why that is unsurprising. - You can state the test for a reference parameter in one sentence: the function assigns to the caller's pointer variable itself.
- You can convert any
Node*&function into itsNode**equivalent and back, including the call sites. - You never write
Node*& temp = something;as a back-up, and you never writeNode* headon a function that moves the head. - You can name the exact node freed by
delete temp;in a pop, and the exact node that leaks if the alias version is used instead. - Every dereference in your code is either provably non-null or preceded by a guard, and no pointer is read after it is deleted.
- You can draw the two-box diagram for by-value parameter passing and point at the box that gets thrown away on return.
Exercises with solutions
Work through each question before opening the solution below it.
Exercise 1
Trace the output.
int x = 5, y = 9;
int* p = &x;
int* q = &y;
int** pp = &p;
**pp = 11;
*pp = q;
**pp = 20;
cout << x << " " << y << " " << *p << " " << *q << endl;Solution
Real output: 11 20 20 20.
Line by line. pp holds &p, so *pp is p and **pp is x while p still points at x. **pp = 11 therefore sets x to 11. *pp = q writes to level 2 of p — it makes p point at y; note that it does not copy any data, and x keeps its 11 for now. **pp = 20 now goes through the new target and sets y to 20. At the end x is 11, y is 20, and both p and q point at y, so *p and *q are both 20.
The trap is *pp = q, which people read as "copy the value". One star short of that: **pp = *q would have copied the value.
Exercise 2
Why does this function not clear the caller's pointer, and what is the one-character fix?
void reset(int* p) { p = nullptr; }Solution
p is a parameter, so it is a fresh variable with its own address; it holds a copy of the caller's stored address. p = nullptr writes level 2 of the copy and the copy dies at the closing brace. Compiled and run, the caller's pointer is untouched:
after byValue: p is not null
after byRef: p is nullThe fix is void reset(int*& p), which makes p a second name for the caller's variable — same level 1, so the write lands in the caller's box. The C spelling is void reset(int** p) { *p = nullptr; } with the call written reset(&ptr);.
Contrast with void zero(int* p) { *p = 0; }, which does affect the caller — because it writes level 3, and level 3 is shared memory, not a copy.
Exercise 3
What does this print, and which node leaks?
Node* a = new Node{1, nullptr};
Node* b = new Node{2, nullptr};
a->next = b;
Node* p = a;
Node*& r = p;
p = p->next;
cout << "p->data = " << p->data << ", r->data = " << r->data << '\n';Solution
Real output: p->data = 2, r->data = 2.
r is not a snapshot of p; it is p. &r == &p, one box, two names, so moving p moves what r reports. Nothing leaks in this snippet because a still names node 1 — but if a had not existed and you had relied on r to remember the old head, node 1 would be unreachable. Change the declaration to Node* r = p; and the output becomes p->data = 2, r->data = 1, which is the behaviour a back-up needs.
Exercise 4
Write void insertAtHead(Node*& head, int value), then write the same function with a `Node parameter, and state why Node` cannot work.*
Solution
void insertAtHead(Node*& head, int value) {
Node* n = new Node;
n->data = value;
n->next = head; // link the new node to the old front (works when head is null too)
head = n; // move the caller's handle
}
void insertAtHead(Node** head, int value) {
Node* n = new Node;
n->data = value;
n->next = *head; // *head is the caller's pointer
*head = n; // write through it
}
// call: insertAtHead(&head, 10);Node* cannot work because the assignment head = n; targets level 2 of a parameter, and a by-value parameter is a private copy with its own level 1. The caller's variable is never named by the function, so it cannot be written. The empty-list case needs no special handling in either version: n->next = head with a null head correctly produces a one-node list.
Exercise 5
This pop compiles and appears to work. Find the two bugs.
int pop(Node* top) {
int v = top->data;
top = top->next;
delete top;
return v;
}Solution
Bug one, the parameter. Node* top is a copy, so top = top->next moves nothing the caller can see. The caller's stack pointer still points at the popped node after the call, so the same value pops forever. It must be Node*& top (or Node** top).
Bug two, the delete. The delete happens after the pointer has already been advanced, so it frees the second node — the one that is supposed to become the new top — while the popped node is never freed. The caller's pointer still names the popped node, which is valid memory nobody will ever free — a leak. What dangles is top->next, and the next pop walks into the already-freed node and deletes it again: a double free, which a sanitizer reports and a marker expects you to name.
There is a third, softer problem: no emptiness guard, so pop on an empty stack dereferences null. Corrected:
bool pop(Node*& top, int& out) {
if (top == nullptr) return false; // guard first
Node* temp = top; // back up with a COPY
out = temp->data;
top = top->next; // move the handle
delete temp; // free the node nobody points at
return true;
}That is the back-up-before-you-move template, and the order — copy, read, move, delete — is the whole answer.
Published and updated 2026-08-09 · DUOCODE TECHNOLOGY