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.
Three properties of references, in order of how often they bite:
- 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. - Cannot be re-seated. Once
rrefers tox, you can never makerrefer to a different object.r = y;copies the value ofyintox. - 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
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;
}
`p + 1` advances by sizeof(int) bytes, not 1 byte. The type is doing arithmetic for you.
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;
}
`nullptr` removes the integer-vs-pointer ambiguity. There is no reason to write `0` or `NULL` for a pointer in modern code.
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:
- By value when
Tis small (int,double,std::string_view) or when you want your own copy to mutate independently. const T&when you want cheap “look but don’t touch” andTmight be expensive to copy (std::string,std::vector, user structs). Default for input parameters of class types.T&when the function genuinely needs to mutate the caller’s value (output parameter, in-place transform). The caller has to pass an lvalue, which makes the intent visible.T*when nullability is part of the contract — “you may pass nothing, and I’ll handle that.” Otherwise preferT&.
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
- A reference is a compile-time alias: cannot be null, cannot be re-seated, must be initialized at declaration. Default tool for “I need to refer to something.”
- A pointer is a typed variable holding an address: can be null, can be re-assigned, can step through arrays. Use when those properties matter.
- Pointer arithmetic is scaled by the pointee’s size.
int* p; p+1advances bysizeof(int)bytes. - Read pointer-
constdeclarations right to left.const T*andT const*are the same thing. - Use
nullptr. Not0. NotNULL. - Parameter idioms:
Tby value for small types,const T&for big read-only,T&for in-place mutation,T*only when nullable.
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.