all_lessons/C++/03 · Pointers & referenceslesson 4 / 20

Pointers and references: indirection made explicit

Lesson 02 established that every object is a typed region of storage living at some address, and that the stack hands out and reclaims those regions almost for free. We treated the address as background fact. This lesson promotes it to the foreground: an address is itself a value — a plain integer-sized number you can store in a variable, pass to a function, and return. That single move, "a value that says where rather than what," is the mechanism behind nearly everything C++ does that a high-level language hides — optional values, sharing without copying, walking an array, and runtime polymorphism. We make it explicit here so every later lesson can lean on it.

The thesis, here
Indirection is never free, and C++ refuses to pretend otherwise. A pointer is the machine's actual unit of "refer to that thing over there," and following one is a real memory access that may or may not be in cache. Where Python boxes every value and hands you references implicitly — so you never see the indirection or its cost — C++ makes you write the * and the &, which means you can always answer is this a copy or a share, and what does dereferencing it cost?
Linear position
Prerequisite: Lesson 02 — objects as typed storage at an address, sizeof, and the three storage durations (automatic/stack, dynamic/heap, static). You need to already believe that "a variable lives at an address."
New capability: hold an address as a value, name the four distinct jobs pointers do, read and write any combination of const with a pointer, and explain why a reference is an alias rather than a separate object — the prerequisite for the heap (Lesson 04) and for ownership (Lessons 05–07).
The plan
Six moves. (1) The two operators: & turns an object into its address, * turns an address back into the object. (2) Pointer types and the one safe non-address value, nullptr. (3) Pointer arithmetic and the identity a[i] == *(a + i) — arrays are pointer arithmetic. (4) References as aliases, and exactly how they differ from pointers. (5) The four jobs pointers do, and which tool fits each. (6) const on the pointer vs const on the pointee — three readings of one symbol.

1 · Address-of and dereference: the two operators

Lesson 02 said an object occupies storage at an address. The operator & (read "address-of") hands you that address as a value:

int x = 42;        // an int object, somewhere in this stack frame
int* p = &x;       // p holds the ADDRESS of x. p is a "pointer to int".

Read the type int* as "pointer to int": a value whose meaning is "the address where an int lives." The variable p is itself an ordinary object — it occupies storage, it has a sizeof (typically 8 bytes on a 64-bit machine, because that is how wide a memory address is), and it lives in the stack frame right next to x. What makes it a pointer is only what we intend to do with the number it holds: use it to find another object.

The inverse operator is * (read "dereference"): given a pointer, *p is the object it points at — and you can read or write it.

std::cout << *p;    // reads x through p, prints 42
*p = 99;            // writes x through p; now x == 99

The crucial idea is the indirection: *p = 99 never mentions x, yet it changes x, because p holds the address where x lives and * follows it. That extra step — fetch the address, then go to it — is exactly what the word "indirect" means, and exactly what costs you (we'll price it in §5). Note the symmetry of the symbols: in a declaration int* p, the * spells the type; in an expression *p, the * performs the dereference. Same glyph, two roles.

2 · Pointer types and nullptr

A pointer is typed: int*, double*, std::string* are different types, and the type is not decoration. It tells the compiler two things: how many bytes to read when you dereference (an int* reads 4 bytes, a double* reads 8), and how far one "step" is for pointer arithmetic (§3). The type is the difference between "the address of an int" and "the address of a double" even though the underlying number — the address — is the same width either way.

A pointer must be able to say "I point at nothing." That value is nullptr (C++11; it replaced the older, bug-prone NULL and the literal 0):

int* q = nullptr;   // q points at no object — a valid "absent" state
if (q) { /* ... */ }   // a pointer tests false when null, true otherwise
int y = *q;         // UNDEFINED BEHAVIOR: dereferencing nullptr (Lesson 19)

A null pointer is a legitimate, well-defined value you may store and compare. Dereferencing one is not — it is undefined behavior (the standard says "all bets are off"; we devote Lesson 19 to why that bargain exists). Likewise, a pointer that was never initialized holds garbage — some leftover bit pattern — and dereferencing it is equally undefined. Two rules follow that will save you hours: a pointer should always either point at a real object or be nullptr, and you check for nullptr before you dereference whenever absence is possible.

3 · Pointer arithmetic, and why arrays are pointer arithmetic

Because a pointer holds an address and the compiler knows the pointee's size, you can do arithmetic on it — and the arithmetic counts in elements, not bytes. If p is an int*, then p + 1 is the address 4 bytes later (one int further on); if p were a double*, p + 1 would be 8 bytes later. The type scales the step. This is exactly how arrays work — an array's name decays to a pointer to its first element, and indexing is defined in terms of pointer arithmetic:

Worked example — trace a[i] == *(a + i)

Declare a 4-element array of int. Suppose the stack hands it the address 0x7ffe00. An int is 4 bytes (Lesson 02), so the elements sit at consecutive 4-byte slots:

name: a[0] a[1] a[2] a[3] +---------+---------+---------+---------+ value: | 10 | 20 | 30 | 40 | +---------+---------+---------+---------+ addr: 0x7ffe00 0x7ffe04 0x7ffe08 0x7ffe0c ^ | a decays to this address (a pointer to int) a == 0x7ffe00 (address of a[0]) a+2 == 0x7ffe00 + 2*4 == 0x7ffe08 (2 ints further, not 2 bytes) *(a+2) reads 4 bytes at 0x7ffe08 == 30 a[2] is DEFINED as *(a+2) == 30 ✓

So a[i] is not a primitive operation — it is literally shorthand for *(a + i): take the base address, step i elements forward, dereference. The compiler computes the byte offset as i * sizeof(int) for you. A consequence that surprises newcomers: since a[i] means *(a+i) and addition commutes, i[a] is also legal and means the same thing. Don't write it — but it proves the point that indexing is arithmetic on an address.

This identity is the whole reason contiguous arrays are fast (Lesson 08 makes the cache argument): stepping to the next element is a single integer add, and the elements sit back-to-back in memory the CPU loads efficiently. One sharp edge: walking a pointer past the end of its array and dereferencing it is undefined behavior — there is no bounds check (that's the zero-overhead bargain from Lesson 00; you didn't pay for a check, so you don't get one).

4 · References: aliases, not pointers

A reference (T&) is C++'s second form of indirection, and the cleaner one for most jobs. A reference is an alias: another name for an existing object, not a separate object holding an address.

int x = 42;
int& r = x;     // r is ANOTHER NAME for x — not a copy, not a pointer-to-x
r = 99;         // changes x directly; no '*' needed, because r IS x

Compare with the pointer version: int* p = &x; *p = 99;. Same effect, different ergonomics — and three hard rules separate them:

Pointer T*Reference T&
Must it be bound when created?No — can be declared then assigned later, or left nullptrYes — must bind to an object at the point of definition
Can it be redirected ("rebound")?Yes — assign a new address any timeNor = y assigns into x; it never makes r alias y
Can it be "null" / refer to nothing?Yes — nullptr is a valid stateNo — a reference always names a real object
Syntax to reach the objectExplicit *p to read/write the pointeeNone — the name is the object
Is it an object with its own address?Yes — has sizeof, you can take &pConceptually no — it is just a name; &r gives x's address

Read those rules as a single design choice: a reference trades flexibility for guarantees. Because it must bind on creation, can't rebind, and can't be null, a function that takes int& is promising "you will hand me a real, existing int, and I will operate on that one." A function taking int* makes a weaker promise — the argument might be null, so the callee must check. The practical rule of thumb: use a reference when the thing must exist and won't change identity; reach for a pointer when absence (nullptr) or reseating is part of the job. Pass-by-reference is also how you avoid copying a large object into a function while still operating on the caller's object (void f(const std::string& s) — Lesson 06 returns to copy cost).

5 · The four jobs pointers do

"Pointer" is one mechanism serving four genuinely different purposes. Naming them keeps you from reaching for the wrong tool — and most of the later lessons are about replacing raw pointers with something that says which job you meant.

1 · Optionality (maybe-absent)
A value that might not be there. nullptr encodes "absent." A pointer is the C-era way to say "an int, or nothing." Modern C++ prefers std::optional<T> when there's no pointing involved, but optionality is still a core reason raw pointers exist.
2 · Indirection / sharing
Refer to one object from several places, or let a callee mutate the caller's object, without copying. Pass a pointer (or reference) and everyone sees the same object. This is how you avoid duplicating a megabyte to call a function.
3 · Iterating contiguous memory
Walk an array or buffer by incrementing a pointer — *(p++) — exactly the a[i] == *(a+i) identity from §3. This generalizes into the STL iterator (Lesson 14), which is "a pointer-like cursor over a container."
4 · Runtime polymorphism
A Base* can hold the address of any derived object, and a virtual call through it dispatches to the right type at runtime. This is the one job that costs an extra indirection on every call — we draw the vtable and price it in Lesson 10.

Notice that jobs 1 and 2 are exactly where a reference often beats a pointer: if the thing must exist (no optionality) and you only need to share or mutate it (job 2), a reference says so in the type and removes the null-check burden. The raw pointer earns its keep when you need optionality, reseating, arithmetic, or polymorphism. And in real modern code, ownership-flavored versions of these jobs go to smart pointers (Lesson 07) so the lifetime question — who frees this? — is answered in the type too.

The hidden cost of indirection
Every dereference is a memory access. If the pointee is in the CPU's L1 cache, that's ~1 nanosecond; if it's not — a "cache miss," common when you chase pointers scattered across the heap — it's a trip to DRAM at ~100 ns, two orders of magnitude slower. So "share by pointer instead of copying" is not automatically a win: a copy of a small object that stays contiguous can be faster than an indirection that misses cache. This is why a pointer-chasing linked structure loses to a contiguous array, and we make that argument rigorously in Lesson 08. For now, just hold the instinct: a * is a real trip into memory, not a free relabeling.

6 · const and pointers: what exactly is constant?

A pointer involves two objects — the pointer, and the thing it points at — so const can apply to either, and the placement decides which. Read these declarations right-to-left from the variable name and they become unambiguous:

DeclarationRead it asCan you change the pointee *p?Can you repoint p?
int* pp is a pointer to intYesYes
const int* pp is a pointer to const intNo — pointee is read-onlyYes
int* const pp is a const pointer to intYesNo — pointer is fixed
const int* const pp is a const pointer to const intNoNo
int a = 1, b = 2;
const int* p1 = &a;   // pointer to const int
*p1 = 5;              // ERROR: can't write through it (pointee is const)
p1 = &b;              // OK: the pointer itself can move

int* const p2 = &a;   // const pointer to int
*p2 = 5;              // OK: write the pointee
p2 = &b;              // ERROR: can't repoint a const pointer

The rule that resolves all confusion: const binds to whatever is immediately to its left, or to its right if nothing is on the left. So in const int* the const binds to int (the pointee); in int* const it binds to the * (the pointer). The common and useful case is const int* — "I can look at the data through this pointer but not modify it" — which is exactly the promise a function makes when it takes const std::string&: it can read your string but not change it. Lesson 16 develops this into full const-correctness as a design tool; here, just learn to read the symbol.

Common mistakes / failure modes

Dereferencing null or uninitialized
int* p; *p = 5;p holds garbage; this is undefined behavior, not a guaranteed crash. Always initialize to nullptr or a real address, and check before dereferencing when absence is possible (Lesson 19).
Thinking a reference can rebind
int& r = x; r = y; does not make r alias y — it assigns y's value into x. References never change identity. If you need reseating, you wanted a pointer.
Forgetting arithmetic scales by type
p + 1 moves by sizeof(*p) bytes, not one byte. Mixing byte offsets with element offsets, or stepping past the array's end and dereferencing, is undefined.
Misreading const placement
Confusing const int* (pointee is const) with int* const (pointer is const). Read right-to-left; when in doubt, the compiler error tells you which one you froze.

Checkpoint exercise

Try it
Write this and predict each line before running it: int arr[3] = {10, 20, 30}; int* p = arr;. (1) Print arr[1], *(arr + 1), and *(p + 1) — confirm all three are 20, which proves a[i] == *(a+i). (2) Print (long)(p+1) - (long)p and explain why it is 4, not 1. (3) Add int& r = arr[0]; r = 99; and print arr[0] — confirm the alias mutated the array element. (4) Try to write const int* cp = arr; *cp = 5; and read the compiler error; then move the const to make int* const and see which line now fails instead. Build with g++ -fsanitize=address,undefined and add a line that dereferences arr + 5 to watch the sanitizer catch the out-of-bounds access you wrote by hand.

Where this points next

We can now name an address, hold it, follow it, and walk an array with it — and we know a reference is the safer alias when the thing must exist. But every pointer so far has pointed at something the stack created and the stack will reclaim (Lesson 02). The interesting and dangerous case is a pointer to memory whose lifetime is not tied to a scope — memory you ask for explicitly and must give back explicitly. Lesson 04 opens the heap: why it exists, what new/delete do, and the central problem that motivates the entire next movement — who frees this, and when? — drawn as the three classic failures of a raw pointer let loose: the leak, the dangling pointer, and the double free.

Takeaway
An address is a value: a number that says where an object lives, which you can hold in a pointer, pass, and return. & takes an object's address; * follows an address back to the object; the pointer's type sets how many bytes a dereference reads and how far +1 steps. Indexing is not primitive — a[i] is defined as *(a + i), so arrays are pointer arithmetic, which is why contiguous data is fast. A reference is an alias, not a separate object: it must bind on creation, can't rebind, and can't be null — so prefer it when the thing must exist, and prefer a pointer when you need optionality, reseating, arithmetic, or runtime polymorphism (Lesson 10). Pointers do four jobs — optionality, sharing, iteration, polymorphism — and const can freeze either the pointee (const int*) or the pointer (int* const); read right-to-left to tell which. Above all, a * is a real memory access — ~1 ns from cache, ~100 ns from DRAM (Lesson 08) — so indirection is a cost you can see, not a free relabeling.

Interview prompts