Free Handbook · Every example compiled & verified

Pointers & References

Addresses, pointers and nullptr, pointer arithmetic, const pointers, references versus pointers, and how dangling pointers happen and how to catch them.

0 / 145 lessons🔥 0 day streak
ShareXLinkedIn

Module 05 · what you'll be able to do

  • Take an address with &, follow a pointer with *, and say what each line of pointer code changes
  • Use nullptr for "points at nothing" and check it before dereferencing
  • Walk an array with pointer arithmetic and read const int* versus int* const correctly
  • Choose between a reference and a pointer for a parameter, and explain why references cannot be null or reseated
  • Recognise a dangling pointer or reference, and catch one with the compiler warning or AddressSanitizer
01

Addresses and pointers

Every variable lives somewhere in memory, and that location has an address. The unary & operator gives you a variable's address: &score. A pointer is a variable whose value is an address. int* p = &score; declares p as "pointer to int" and stores the address of score in it.

Following a pointer to the thing it points at is called dereferencing, and uses unary *: *p is score. Reading *p reads score; assigning *p = 95 changes score. The same symbols mean different things in different places, which is the main source of confusion:

You writeWhereMeaning
int* pin a declarationp is a pointer to int
&xin an expressionthe address of x
*pin an expressionthe object p points at
int& rin a declarationr is a reference (another name) for an int, covered below
C++main.cpp
#include <iostream>

int main() {
    int score = 90;
    int* p = &score;            // p holds the address of score

    std::cout << "score = " << score << '\n';
    std::cout << "*p    = " << *p << '\n';      // follow the pointer

    *p = 95;                    // writes into score
    std::cout << "score = " << score << '\n';

    int bonus = 5;
    p = &bonus;                 // a pointer can be re-aimed
    *p += 1;
    std::cout << "bonus = " << bonus << ", score still " << score << '\n';

    std::cout << std::boolalpha << (p == &bonus) << '\n';   // compare addresses
    std::cout << "a pointer takes " << sizeof(p) << " bytes\n";
}
Outputcompiled & run with real C++
score = 90
*p    = 90
score = 95
bonus = 6, score still 95
true
a pointer takes 8 bytes
Your turn

Add int** pp = &p; (a pointer to a pointer) and change bonus through **pp. Draw the three boxes on paper first.

Why this handbook never prints an address
std::cout << p prints the address itself, something like 0x16fdff2ac. It changes on every run (the operating system randomises where the stack and heap live), so it is useless in a verified example and rarely useful in a real program. Print *p to see the value, and compare pointers with == when you need to know whether two point at the same object.
Error you will hit

cannot initialize a variable of type 'int *' with an lvalue of type 'int'

C++
int main() {
    int score = 90;
    int* p = score;
    return *p;
}
main.cpp:3:10: error: cannot initialize a variable of type 'int *' with an lvalue of type 'int'
    3 |     int* p = score;
      |          ^   ~~~~~
Why the compiler said that

A pointer holds an address, and score is an int value, not an address. C++ never converts a plain integer into a pointer silently: that would let 90 be treated as memory location 90.

The fix

Take the address with &.

C++
int main() {
    int score = 90;
    int* p = &score;
    return *p == 90 ? 0 : 1;
}
02

nullptr: pointing at nothing

A pointer does not have to point at anything. The value nullptr means "no object", and it is how functions that return a pointer say "not found". Always initialise a pointer, either to a real address or to nullptr: an uninitialised local pointer holds garbage, and dereferencing it is undefined behaviour. Dereferencing nullptr is also undefined behaviour, so check first. A pointer converts to bool, so if (p) means "if p is not null".

C++main.cpp
#include <iostream>

// Returns the address of the first negative element, or nullptr if there is none.
int* findFirstNegative(int* arr, int n) {
    for (int i = 0; i < n; ++i) {
        if (arr[i] < 0) return &arr[i];
    }
    return nullptr;
}

int main() {
    int temps[] = {4, -3, 7, -8};
    int* neg = findFirstNegative(temps, 4);
    if (neg) {                                   // same as: neg != nullptr
        std::cout << "first negative: " << *neg << '\n';
        *neg = 0;                                // fix it in the array itself
    }
    std::cout << "temps[1] is now " << temps[1] << '\n';

    int summer[] = {21, 19, 24};
    int* none = findFirstNegative(summer, 3);
    if (none == nullptr) std::cout << "no negatives\n";
}
Outputcompiled & run with real C++
first negative: -3
temps[1] is now 0
no negatives
Your turn

Write int* findMax(int* arr, int n) that returns nullptr when n is 0 and a pointer to the largest element otherwise. Use it to double the largest element in place.

Error you will hit

AddressSanitizer: SEGV on unknown address 0x000000000000

C++
#include <iostream>

int* findFirstNegative(int* arr, int n) {
    for (int i = 0; i < n; ++i) {
        if (arr[i] < 0) return &arr[i];
    }
    return nullptr;
}

int main() {
    int temps[] = {21, 19, 24};
    int* neg = findFirstNegative(temps, 3);
    std::cout << "first negative: " << *neg << '\n';
}
$ c++ -std=c++20 -fsanitize=address -g main.cpp && ./a.out
AddressSanitizer:DEADLYSIGNAL
=================================================================
==84528==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x000100328b54 bp 0x00016fad6680 sp 0x00016fad6580 T0)
==84528==The signal is caused by a READ memory access.
==84528==Hint: address points to the zero page.
    #0 0x000100328b54 in main main.cpp:13
Why the compiler said that

There is no negative temperature, so the function returned nullptr and line 13 dereferenced it. This was built with AddressSanitizer (-fsanitize=address -g), which names the exact line. Without it the program simply dies with "Segmentation fault", and with optimisation turned on the compiler may even assume the pointer is non-null and do something stranger.

The fix

Every pointer that can be nullptr gets checked before *. If "no result" is a normal outcome and you do not need to modify the element, std::optional (Module 03) makes the check impossible to forget.

C++
int* neg = findFirstNegative(temps, 3);
if (neg) std::cout << "first negative: " << *neg << '\n';
else     std::cout << "no negatives\n";
NULL and 0 are the old spellings
Older code writes NULL or 0 for a null pointer. Both are integers underneath, which causes surprises with overloading: f(NULL) may call f(int) instead of f(int*). nullptr has its own type, std::nullptr_t, which only converts to pointer types. Use nullptr in all new code.
03

Pointer arithmetic over arrays

The elements of an array sit next to each other in memory. If p points at element i, then p + 1 points at element i + 1: adding 1 moves by one element, not one byte (for int that is 4 bytes). In fact arr[i] is defined as *(arr + i), and an array name decays to a pointer to its first element, as Module 04 showed.

  • p + n, p - n, ++p, --p move within the array.
  • q - p gives the number of elements between two pointers into the same array (type std::ptrdiff_t).
  • A pointer may point one past the last element (the "end" pointer). You may compare with it but never dereference it. This is exactly the idea behind v.end() for containers.
  • Moving a pointer further than one past the end, or before the start, is undefined behaviour, even if you never dereference it.
C++main.cpp
#include <iostream>

int main() {
    int marks[5] = {72, 88, 95, 60, 81};
    int* first = marks;               // decays to &marks[0]
    int* end = marks + 5;             // one past the last element

    std::cout << *(first + 2) << " == " << marks[2] << '\n';

    int total = 0;
    for (int* p = first; p != end; ++p) {   // walk the array by pointer
        total += *p;
    }
    std::cout << "total " << total << '\n';

    int* best = first;
    for (int* p = first + 1; p != end; ++p) {
        if (*p > *best) best = p;
    }
    std::cout << "best " << *best << " at index " << (best - first) << '\n';
}
Outputcompiled & run with real C++
95 == 95
total 396
best 95 at index 2
Your turn

Write void reverse(int* lo, int* hi) that swaps *lo and *(hi - 1), then moves both pointers inward until they meet. Call it as reverse(marks, marks + 5).

VisualizeWalking an array with a pointerStep 1 / 10
int nums[3] = {4, 7, 2};
int* end = nums + 3;
int sum = 0;
for (int* p = nums; p != end; ++p) {
sum += *p;
}
Line 1

Three ints side by side in memory.

Variables now
nums{4, 7, 2}
All 10 steps as a table
StepLineWhat happenedVariables now
11Three ints side by side in memory.nums = {4, 7, 2}
22end points one past the last element. It will only ever be compared, never dereferenced.end = &nums[3] (past the end)
33The running total starts at zero.sum = 0
44p starts at the first element.p = &nums[0] sum = 0
55*p is 4.p = &nums[0] sum = 4
64++p moves to the next int (4 bytes on), which is not end.p = &nums[1] sum = 4
75*p is 7.p = &nums[1] sum = 11
84++p again.p = &nums[2] sum = 11
95*p is 2.p = &nums[2] sum = 13
104++p makes p == end, so the loop stops before reading past the array.p = &nums[3] (== end) sum = 13
In real jobs
You rarely write raw pointer loops in modern C++: a range-for, an algorithm, or std::span does it for you and cannot run off the end. But the model is the one every iterator is built on, and you will read pointer loops in C libraries, embedded code, and performance-critical parsers.
04

Pointers to const and const pointers

A pointer involves two things that can each be const: the object it points at, and the pointer itself. Read the declaration right to left, starting at the variable name: const int* p is "p is a pointer to an int that is const"; int* const p is "p is a const pointer to an int".

DeclarationChange *p?Re-aim p?Typical use
int* pyesyesrare outside low-level code
const int* p (same as int const* p)noyesread-only access, e.g. a parameter that only reads an array
int* const pyesnoa fixed handle to one object (a reference behaves like this)
const int* const pnonoa fixed, read-only handle
C++main.cpp
#include <iostream>

// Promises not to modify the array: callers can pass const data too.
int sum(const int* arr, int n) {
    int total = 0;
    for (int i = 0; i < n; ++i) total += arr[i];
    return total;
}

int main() {
    const int fixedFees[3] = {100, 250, 75};
    std::cout << sum(fixedFees, 3) << '\n';

    int a = 1, b = 2;
    const int* reader = &a;   // cannot write through it...
    reader = &b;              // ...but can point somewhere else
    std::cout << *reader << '\n';

    int* const handle = &a;   // cannot re-aim...
    *handle = 10;             // ...but can write through it
    std::cout << a << '\n';
}
Outputcompiled & run with real C++
425
2
10
Your turn

Change sum to take int* arr (no const). The call with fixedFees stops compiling. Read the error: it is the same "const-correctness" idea as const& parameters.

Error you will hit

read-only variable is not assignable

C++
int main() {
    int limit = 100;
    const int* p = &limit;
    *p = 200;
}
main.cpp:4:8: error: read-only variable is not assignable
    4 |     *p = 200;
      |     ~~ ^
Why the compiler said that

p is a pointer to const int, so *p is read-only through this pointer, even though limit itself is not const. The const is a promise about what this pointer is allowed to do.

The fix

Write to the variable directly, or use a non-const pointer if modification is really intended.

C++
int main() {
    int limit = 100;
    int* p = &limit;
    *p = 200;
    return limit == 200 ? 0 : 1;
}
Error you will hit

cannot assign to variable 'p' with const-qualified type 'int *const'

C++
int main() {
    int a = 1, b = 2;
    int* const p = &a;
    p = &b;
}
main.cpp:4:7: error: cannot assign to variable 'p' with const-qualified type 'int *const'
    4 |     p = &b;
      |     ~ ^
main.cpp:3:16: note: variable 'p' declared const here
    3 |     int* const p = &a;
      |     ~~~~~~~~~~~^~~~~~
Why the compiler said that

Here the const is to the right of *, so it is the pointer that is fixed. Writing through it is fine; pointing it at b is not.

The fix

Move const to the left of * if you meant "cannot modify the int", or drop it if the pointer must be re-aimed.

05

References versus pointers

A reference (int& r = x;) is another name for an existing object. You met them as parameters in Module 03. Under the hood the compiler usually implements a reference as a pointer, but the language gives it three rules that make it safer and simpler to use:

Pointer: int* p = &x;

  • May be declared uninitialised (do not) or set to nullptr
  • Can be re-aimed: p = &y; changes what p points at
  • Can be nullptr, so every use needs a check
  • Needs *p or p-> to reach the object
  • Supports arithmetic over arrays

Reference: int& r = x;

  • Must be initialised when declared
  • Always refers to the same object: r = y; copies y's value into x
  • Cannot be null
  • No special syntax to use: r behaves exactly like x
  • Cannot do arithmetic
VisualizeAssigning through a reference versus re-aiming a pointerStep 1 / 6
int x = 1, y = 2;
int& r = x;
int* p = &x;
r = y;
p = &y;
*p = 7;
Line 1

Two ordinary ints.

Variables now
x1
y2
All 6 steps as a table
StepLineWhat happenedVariables now
11Two ordinary ints.x = 1 y = 2
22r becomes another name for x. It will be bound to x for the rest of its life.x = 1 y = 2 r = is x
33p stores the address of x.x = 1 y = 2 r = is x p = &x
44This does not rebind r to y. It assigns y's value to the object r names, which is x.x = 2 y = 2 r = is x p = &x
55Assigning to a pointer re-aims it. x and y are untouched.x = 2 y = 2 r = is x p = &y
66Writing through p now changes y.x = 2 y = 7 r = is x p = &y
C++main.cpp
#include <iostream>

int main() {
    int x = 1, y = 2;
    int& r = x;
    int* p = &x;

    r = y;          // copies 2 into x; r still refers to x
    p = &y;         // p now points at y
    *p = 7;         // changes y

    std::cout << "x=" << x << " y=" << y << " r=" << r << " *p=" << *p << '\n';
    std::cout << std::boolalpha << (&r == &x) << '\n';   // &r is the address of x
}
Outputcompiled & run with real C++
x=2 y=7 r=2 *p=7
true
Error you will hit

declaration of reference variable 'r' requires an initializer

C++
int main() {
    int& r;
    int x = 5;
    r = x;
}
main.cpp:2:10: error: declaration of reference variable 'r' requires an initializer
    2 |     int& r;
      |          ^
Why the compiler said that

A reference is a name for an object that already exists, and it can never be bound to a different one later. So there is no moment when it could be "empty" and filled in afterwards: it must be bound on the line that declares it.

The fix

Bind the reference when you declare it. If you genuinely need something that starts empty or changes target, you need a pointer (or std::optional).

C++
int main() {
    int x = 5;
    int& r = x;
    r = 6;
    return x == 6 ? 0 : 1;
}
Rule of thumb
Use a reference when there is always exactly one object and it never changes. Use a pointer when "nothing" is a valid answer or when the target changes. Use neither for ownership: who deletes heap memory is the job of smart pointers, in Module 06.
06

Pass by pointer versus pass by reference

Both let a function modify the caller's variable. Passing a pointer makes the call site explicit (swap(&a, &b) shows that a and b may change) and lets the caller pass nullptr for "I do not want this". Passing a reference is shorter and cannot be null. C APIs use pointers everywhere because C has no references; modern C++ prefers references, and uses a pointer parameter only when null is a meaningful value.

C++main.cpp
#include <iostream>
#include <string>

void swapByPointer(int* a, int* b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

void swapByReference(int& a, int& b) {
    int tmp = a;
    a = b;
    b = tmp;
}

// errorOut is optional: pass nullptr if you do not care why parsing failed
bool parseAge(const std::string& text, int& age, std::string* errorOut) {
    if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos) {
        if (errorOut) *errorOut = "not a number: '" + text + "'";
        return false;
    }
    age = std::stoi(text);
    return true;
}

int main() {
    int x = 1, y = 2;
    swapByPointer(&x, &y);
    std::cout << x << ' ' << y << '\n';
    swapByReference(x, y);
    std::cout << x << ' ' << y << '\n';

    int age = 0;
    std::string why;
    if (!parseAge("4x", age, &why)) std::cout << why << '\n';
    if (parseAge("31", age, nullptr)) std::cout << "age " << age << '\n';
}
Outputcompiled & run with real C++
2 1
1 2
not a number: '4x'
age 31
Your turn

Call parseAge("", age, nullptr). Confirm it returns false without crashing, because of the if (errorOut) check. Remove that check and run it under -fsanitize=address to see what happens.

ParameterCan be null?Call siteUse when
Tn/af(x)small types, or you need your own copy
const T&nof(x)reading anything large
T&nof(x)the function must modify the argument
T*yesf(&x) or f(nullptr)an optional output, or calling a C API
07

Dangling pointers and references

A pointer or reference only means something while the object it refers to is alive. When the object is destroyed, the pointer still holds the old address, but that memory now belongs to something else, or to nothing. Such a pointer is dangling, and using it is undefined behaviour: the program may print the old value, a random value, or crash, and it may do different things on different runs. The three classic ways to create one:

  1. Returning the address of (or a reference to) a local variable. Locals die when the function returns.
  2. Keeping a pointer into a container that then reallocates. push_back on a full std::vector moves every element to a new buffer and frees the old one.
  3. Using a pointer after delete (use-after-free), covered in Module 06.
Error you will hit

address of stack memory associated with local variable returned

C++
#include <iostream>

int* makeCounter() {
    int count = 0;
    return &count;
}

int main() {
    int* c = makeCounter();
    std::cout << *c << '\n';
}
main.cpp:5:13: warning: address of stack memory associated with local variable 'count' returned [-Wreturn-stack-address]
    5 |     return &count;
      |             ^~~~~
Why the compiler said that

count lives in makeCounter's stack frame, which is gone the moment the function returns. c points at a dead slot that the next function call will reuse. Clang warns about this by default (no flag needed), and the same warning fires for const std::string& f() { std::string s = "hi"; return s; }. It is only a warning, so the program still builds: treat it as an error.

The fix

Return the value itself. Returning by value is cheap (copy elision, see Module 03). If the object really must outlive the function, it belongs on the heap in a smart pointer.

C++
int makeCounter() {
    int count = 0;
    return count;          // a copy of the value, no pointer at all
}
Error you will hit

AddressSanitizer: heap-use-after-free (pointer into a vector)

C++
#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{70, 85};
    int* best = &scores[1];          // points into the vector's buffer
    scores.push_back(92);            // may move the buffer elsewhere
    std::cout << *best << '\n';
}
$ c++ -std=c++20 -fsanitize=address -g main.cpp && ./a.out
==84921==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000f4 at pc 0x000100900b14 bp 0x00016f4fe4b0 sp 0x00016f4fe4a8
READ of size 4 at 0x6020000000f4 thread T0
    #0 0x000100900b10 in main main.cpp:8

freed by thread T0 here:
    ... (frames inside std::vector::push_back)
    #11 0x000100900aac in main main.cpp:7

SUMMARY: AddressSanitizer: heap-use-after-free main.cpp:8 in main
Why the compiler said that

No compiler warning this time: the code is legal, the timing is wrong. The vector had room for exactly two ints, so push_back allocated a bigger buffer, copied the elements across and freed the old one, which best still pointed into. Built with AddressSanitizer (-fsanitize=address -g), the run stops on line 8 and says where the memory was freed (line 7). Without ASan this often prints 85 and passes every test, which is exactly why it is dangerous.

The fix

Keep an index instead of a pointer, or take the pointer only after you have finished growing the vector (or reserve() enough space first). The same rule applies to iterators and references into a vector.

C++
std::vector<int> scores{70, 85};
std::size_t best = 1;              // an index survives reallocation
scores.push_back(92);
std::cout << scores[best] << '\n';  // 85
In real jobs
Most C++ teams build their test suite with -fsanitize=address,undefined in CI, because dangling pointers do not fail reliably on their own. If a test passes normally but fails under ASan, the test is right and the code is wrong. Module 11 covers the sanitizers in more depth.
08

Pointers to structs, and arrays of pointers

With a pointer to a struct, (*p).name reaches a member, but the parentheses are needed because . binds tighter than *. The arrow operator p->name is the shorthand everyone uses. You will see -> constantly: with smart pointers, iterators, and this inside classes.

An array (or vector) of pointers lets several "views" refer to the same objects without copying them: for example, a list of the employees who are on call, pointing into the main list. The pointers do not own anything, so the objects they point at must outlive them.

C++main.cpp
#include <iostream>
#include <string>
#include <vector>

struct Employee {
    std::string name;
    int salary;
};

void giveRaise(Employee* e, int percent) {
    e->salary += e->salary * percent / 100;   // same as (*e).salary
}

int main() {
    std::vector<Employee> staff{{"Asha", 50000}, {"Ravi", 42000}, {"Meera", 61000}};

    Employee* first = &staff[0];
    giveRaise(first, 10);
    std::cout << first->name << ": " << first->salary << '\n';

    // Non-owning pointers into staff: no Employee is copied.
    std::vector<Employee*> onCall{&staff[2], &staff[0]};
    for (Employee* e : onCall) {
        std::cout << "on call: " << e->name << '\n';
    }
    onCall[0]->salary += 1000;                  // changes staff[2] itself
    std::cout << staff[2].name << ": " << staff[2].salary << '\n';
}
Outputcompiled & run with real C++
Asha: 55000
on call: Meera
on call: Asha
Meera: 62000
Your turn

Add staff.push_back({"Zoya", 39000}); after building onCall. Why does that make every pointer in onCall dangling? (See the previous lesson.) Fix it by filling onCall only after staff is complete.

Address
Where an object lives in memory. &x gives the address of x.
Pointer
A variable that holds an address. T* is "pointer to T".
Dereference
Following a pointer to the object: *p, or p->member for a struct.
nullptr
The null pointer value: points at no object. Dereferencing it is undefined behaviour.
Pointer arithmetic
p + n moves n elements (not bytes) through an array.
One-past-the-end pointer
A pointer just after the last element of an array. Valid to compare, never to dereference.
Pointer to const
const T* p: cannot modify the object through p, but p can be re-aimed.
Const pointer
T* const p: p cannot be re-aimed, but the object can be modified.
Reference
Another name for an existing object. Must be initialised, cannot be null, cannot be rebound.
Dangling pointer
A pointer or reference to an object that has been destroyed. Using it is undefined behaviour.
AddressSanitizer (ASan)
A compiler feature (-fsanitize=address) that instruments memory accesses and reports use-after-free, out-of-bounds and similar bugs with the exact line.
Quick check

int a = 1, b = 2; int& r = a; r = b; b = 5; What are a and r now?

Quick check

Which declaration lets you re-aim the pointer but not modify the int through it?

Frequently asked questions

What is the difference between a pointer and a reference in C++?
A pointer is a variable holding an address: it can be null, can be re-aimed at another object, and needs * or -> to reach the object. A reference is another name for an existing object: it must be initialised, can never be null and can never be rebound. Prefer references unless you need "no object" or a changing target.
Should I use NULL or nullptr in C++?
Use nullptr. NULL is an integer constant inherited from C, so it can pick an integer overload by mistake. nullptr has its own type that converts only to pointers.
How do I find dangling pointer bugs in C++?
Turn on warnings (clang warns about returning the address of a local by default) and build your tests with AddressSanitizer: c++ -std=c++20 -fsanitize=address -g main.cpp. ASan stops at the bad access and reports the line where the memory was used, freed and allocated.

Finish the C++ handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.