Memory + RAII · #4 of 13

References, Pointers, and nullptr

Three tools for indirection, and the small set of rules that tells you which to reach for

Why it matters

C++ gives you three different things called “a way to refer to something else”: references (T&), pointers (T*), and the smart pointers of lesson 05. They are not interchangeable. Picking the right one is half the readability of modern C++ code. The pattern, restated from lesson 03:

Prefer references. Reach for raw pointers only when you genuinely need nullable, re-seatable, or array-of-pointer semantics. Reach for smart pointers when ownership crosses a function boundary.

This lesson is the half that doesn’t involve ownership.

A reference, one more time

T& r = obj; makes r a second name for obj. Same address, same bytes, same value.

stack main() int x = 5 int& → x r = (no bytes)
A reference doesn't usually occupy its own slot. It's a compile-time alias; the compiler resolves `r` to `x` directly.

Three properties of references, in order of how often they bite:

  1. Must be initialized at the point of declaration. int& r; is a compile error. The language won’t let you have a reference that doesn’t refer to anything.
  2. Cannot be re-seated. Once r refers to x, you can never make r refer to a different object. r = y; copies the value of y into x.
  3. Cannot be null. A reference always refers to some valid object. The compiler enforces it; the runtime trusts the compiler. (You can forge a null reference via undefined behavior — please don’t.)

References are the right tool when you want “pass-by-reference” semantics in a function call, or a second name for one specific object in a local scope. They’re the wrong tool when nullability matters, or when you need to step through an array.

A pointer is a value that holds an address

A pointer is a variable like any other. Its type says “this variable holds the address of a T,” and its value is — usually — exactly that address.

int x = 5;
int* p = &x;    // & here means "address of"
std::cout << *p;  // * here means "value at this address" — prints 5
stack main() int x = 5 int* p =
`p` is its own object on the stack — eight bytes on a 64-bit system. Its value happens to be the address of `x`.

The & and * operators are the same characters as the declarators T& and T*, but they’re not the same thing. In a declaration, int* p means “a pointer to int.” In an expression, *p means “dereference p to get the int it points at.” Conflating the two is the single most common source of “I don’t get pointers” confusion.

The cheap mental model: a pointer is a typed integer holding an address. The type doesn’t change its size — every pointer on a 64-bit system is 8 bytes — but it does control what *p, p->m, and p + 1 mean.

What p + 1 actually does

Pointer arithmetic is scaled by the size of what the pointer points at:

#include <iostream>

int main() {
int arr[4] = {10, 20, 30, 40};
int* p = arr;                  // arr decays to a pointer to its first element

std::cout << "p     -> " << *p     << " (= " << p     << ")\n";
std::cout << "p + 1 -> " << *(p+1) << " (= " << p + 1 << ")\n";
std::cout << "p + 2 -> " << *(p+2) << " (= " << p + 2 << ")\n";

std::cout << "sizeof(int) = " << sizeof(int) << "\n";
std::cout << "difference in bytes: "
          << reinterpret_cast<char*>(p + 1) - reinterpret_cast<char*>(p)
          << "\n";
return 0;
}
idle

`p + 1` advances by sizeof(int) bytes, not 1 byte. The type is doing arithmetic for you.

expected output
p     -> 10 (= 0x7ffd2a8b3c10)
p + 1 -> 20 (= 0x7ffd2a8b3c14)
p + 2 -> 30 (= 0x7ffd2a8b3c18)
sizeof(int) = 4
difference in bytes: 4
# (addresses will differ on your machine; the step of 4 will not)
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

This is why a pointer carries a type: p + 1 needs to know how many bytes to advance. If p were a char*, p + 1 would advance one byte. As an int*, four bytes (typically). As a pointer to a 200-byte struct, 200 bytes.

You will almost never write p + 1 in modern C++. You will write v.begin() + 1 or it++ or for (auto& x : v). Pointer arithmetic exists because std::vector::iterator is a pointer in disguise, and the language has to make the disguise convincing.

const and pointers: read right-to-left

There are four interesting combinations of const and *:

int*       p1;   // mutable pointer to mutable int
const int* p2;   //   const pointer-to-int? no — read right to left:
int const* p3;   // both spellings: pointer to const int (data is read-only)
int* const p4;   // const pointer to mutable int (target is read-only)
const int* const p5;  // both — neither pointer nor target can change

The rule everyone learns from a senior eventually: read declarations right to left. const int* p is “p, pointer, to const int.” int* const p is “p, const pointer, to int.” The two forms const int* and int const* mean the same thing — the convention picks one team, but both compile.

In code reviews, you’ll see const T* mostly to say “I want to look at this but not mutate it.” You’ll see T* const rarely, mostly in class-member fields that own a pointer for the object’s lifetime but shouldn’t reassign it.

nullptr is the only spelling

Pre-C++11, the idiom for “this pointer points at nothing yet” was either 0 (an int literal!) or NULL (a macro that expands to 0 or (void*)0, depending on which header you got it from). Both ambiguously overload-resolve against pointers and integers, which is exactly the bug-source you’d expect.

C++11 introduced nullptr, a keyword with its own type std::nullptr_t. It implicitly converts to any pointer type and to nothing else.

#include <iostream>

void f(int)        { std::cout << "f(int)\n"; }
void f(int*)       { std::cout << "f(int*)\n"; }

int main() {
f(0);        // calls f(int) — 0 is an int literal
f(NULL);     // typically calls f(int) too — NULL is usually defined as 0
f(nullptr);  // calls f(int*) — nullptr is a pointer-shaped null

int* p = nullptr;
if (p == nullptr) std::cout << "p is null\n";
if (!p)            std::cout << "p still null (idiomatic shorthand)\n";
return 0;
}
idle

`nullptr` removes the integer-vs-pointer ambiguity. There is no reason to write `0` or `NULL` for a pointer in modern code.

expected output
f(int)
f(int)
f(int*)
p is null
p still null (idiomatic shorthand)
Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out

The lesson: write nullptr always. Treat NULL in legacy code as a note-to-self that the surrounding code is older than C++11 and may have other 2003-vintage habits.

Passing things to functions: the four flavors

For any non-trivial type T, a function parameter can be written four ways. Each has its own performance and intent profile.

void by_value      (T  x);    // copies T into x. caller untouched.
void by_const_ref  (const T& x);  // no copy. callee promises not to mutate.
void by_ref        (T& x);    // no copy. callee may mutate. caller sees changes.
void by_pointer    (T* x);    // no copy. nullable. callee may mutate.

Picking among them is mechanical:

The same logic applies to return types:

T   build();                  // returns by value (move/RVO makes this cheap)
const T&  view() const;       // returns a reference into something the object owns
T*  find(int key);            // returns nullptr if not found

Watch for the trap: never return a reference or pointer to a local variable. The local dies when the function returns. The reference is dangling before the caller can read it. This was the cardinal sin of lesson 03; it’s the cardinal sin here too.

Pointer vs reference, in one sentence

Use a reference when a function or method must always have a valid target. Use a pointer when the target is optional, when it points into an array, or when you might re-assign it later. Use neither — use a smart pointer — when the question is “who owns this and when does it die?” That’s lesson 05.

Key takeaways

What’s next

Lesson 05 introduces std::unique_ptr and std::shared_ptr — the smart pointers that make raw new/delete almost obsolete. The pattern is simple: the pointer owns the heap allocation, the destructor calls delete for you, and ownership transfers across function boundaries via the move semantics we cover in lesson 06.