C++ Module 6 – References, pointers and memory
C++ pointers: addresses, dereferencing, nullptr
What a C++ pointer holds, how * and -> reach the object, why nullptr replaced NULL, how to read const in declarations, and what a null dereference does.
What you will learn
- Declare, assign and dereference pointers, and reach members with ->
- Use nullptr for "no object" and check for it before dereferencing
- Read pointer declarations that involve const from right to left
- Choose between a pointer and a reference for a function's parameters and results
Before you start
What you’ll learn · Before you start
What you will learn
- Declare, assign and dereference pointers, and reach members with ->
- Use nullptr for "no object" and check for it before dereferencing
- Read pointer declarations that involve const from right to left
- Choose between a pointer and a reference for a function's parameters and results
Before you start
On this page
A pointer is an object whose value is the address of another object. int* p = &seats; stores the address of
seats in p. Writing *p then names seats itself, so *p = 42 changes seats, and when p points to a
struct, p->marks names the member marks of the object it points to. Unlike a reference, a pointer is an object of
its own: it can be changed to point somewhere else, and it can hold the null pointer value, written nullptr,
which means “no object”.
That flexibility makes pointers useful and also dangerous. A pointer can say “there is no answer”, which a reference
cannot, but dereferencing a null pointer, or a pointer whose object no longer exists, is undefined behaviour. This
lesson covers the operators &, * and ->, nullptr and why it replaced NULL, how to read const in pointer
declarations, what a null dereference looks like on both compilers and under the sanitizers, and when to choose a
pointer over a reference.
Taking an address and dereferencing it
The unary & operator gives the address of an object, and * applied to a pointer gives back the object:
#include <print>
int main() {
int seats = 40;
int* p = &seats; // p holds the address of seats
std::println("seats is stored at {}", static_cast<const void*>(&seats));
std::println("p holds {}", static_cast<const void*>(p));
std::println("*p is {}", *p);
*p = 42; // writes to seats through p
std::println("seats is now {}", seats);
std::println("a pointer takes {} bytes, an int {}", sizeof p, sizeof seats);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra addresses.cpp -o addresses && ./addresses
g++-16 -std=c++23 -Wall -Wextra addresses.cpp -o addresses && ./addresses
""") Output
$ clang++ -std=c++23 -Wall -Wextra addresses.cpp -o addresses && ./addresses seats is stored at 0x16ddf744c p holds 0x16ddf744c *p is 40 seats is now 42 a pointer takes 8 bytes, an int 4 $ g++-16 -std=c++23 -Wall -Wextra addresses.cpp -o addresses && ./addresses seats is stored at 0x16d6c33e4 p holds 0x16d6c33e4 *p is 40 seats is now 42 a pointer takes 8 bytes, an int 4
This output changes from run to run: the addresses are different on every run; the pattern checks that p holds exactly the address of seats
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 addresses_session.py
&seats has the type int*, “pointer to int”, and p stores that address. The two printed addresses are equal,
which is what “p points to seats” means. *p reads seats through the pointer, and *p = 42 writes to it. On this
64-bit Mac a pointer takes 8 bytes whatever it points to, while an int takes 4. The address itself is a number you
rarely need: the operating system places the program’s memory differently on each run, so the value changes every
time, as the pattern above allows.
std::println prints only void pointers and nullptr as addresses: the standard’s list of formatters,
[format.formatter.spec], has one for void*, const void* and std::nullptr_t and none for other pointer types.
That is why the example converts with static_cast<const void*>(p); std::println("{}", p) with an int* does not
compile.
A pointer holds an address; nullptr points nowhere
Text description of the diagram
The diagram has two rows.
- The first row shows int* p = &seats;. On the left is p, a variable of type int* that holds the address 0x16b1e9e7c. On the right is seats, an int stored at that address, holding 40. An arrow from p to seats is labelled "*p names seats": dereferencing p gives the object seats itself.
- The second row shows int* q = nullptr;. On the left is q, an int* that holds nullptr. A dashed arrow leads to a box that says "no object", labelled "*q is undefined behaviour": a null pointer points to no object, so dereferencing it is undefined behaviour.
The address 0x16b1e9e7c is an example of what addresses.cpp prints; every run prints a different one.
Members through a pointer: ->
For a pointer to a struct or class, p->member is a shorter way to write (*p).member:
#include <print>
#include <string>
struct Student {
std::string name;
int marks;
};
int main() {
Student asha{"Asha", 72};
Student* p = &asha;
(*p).marks += 5; // the parentheses matter: *p.marks would mean *(p.marks)
p->marks += 3; // the same thing, written with ->
std::println("{} has {} marks", p->name, asha.marks);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra arrow.cpp -o arrow && ./arrow
g++-16 -std=c++23 -Wall -Wextra arrow.cpp -o arrow && ./arrow
""") Output
$ clang++ -std=c++23 -Wall -Wextra arrow.cpp -o arrow && ./arrow Asha has 80 marks $ g++-16 -std=c++23 -Wall -Wextra arrow.cpp -o arrow && ./arrow Asha has 80 marks
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 arrow_session.py
Both lines add to the same marks, so Asha ends with 80. The parentheses in (*p).marks are needed because . binds
more tightly than *: *p.marks would mean *(p.marks), and a pointer has no member called marks, so the compiler
would reject it. That is why everyone writes ->.
nullptr: a pointer to no object
A pointer that points to nothing holds the null pointer value. Since C++11 you write it as nullptr, a literal with
its own type, std::nullptr_t, that converts to every pointer type and never implicitly to an integer. Older code
uses 0 or the macro NULL, and the difference shows as soon as a function is overloaded for an integer and a
pointer:
#include <cstddef>
#include <print>
void show(int n) { std::println("show(int) got {}", n); }
void show(int* p) { std::println("show(int*) got {}", p == nullptr ? "a null pointer" : "an address"); }
int main() {
show(0); // 0 is an int
show(nullptr); // nullptr converts to any pointer type, never to int
#ifdef TRY_NULL
show(NULL); // NULL is an integer constant on these compilers
#endif
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra nullptr_overload.cpp -o nullptr_overload && ./nullptr_overload
clang++ -std=c++23 -DTRY_NULL nullptr_overload.cpp -o nullptr_overload
g++-16 -std=c++23 -DTRY_NULL nullptr_overload.cpp -o nullptr_overload
""") Output
$ clang++ -std=c++23 -Wall -Wextra nullptr_overload.cpp -o nullptr_overload && ./nullptr_overload
show(int) got 0
show(int*) got a null pointer
$ clang++ -std=c++23 -DTRY_NULL nullptr_overload.cpp -o nullptr_overload
nullptr_overload.cpp:11:5: error: call to 'show' is ambiguous
11 | show(NULL); // NULL is an integer constant on these compilers
| ^~~~
nullptr_overload.cpp:4:6: note: candidate function
4 | void show(int n) { std::println("show(int) got {}", n); }
| ^
nullptr_overload.cpp:5:6: note: candidate function
5 | void show(int* p) { std::println("show(int*) got {}", p == nullptr ? "a null pointer" : "an address"); }
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -DTRY_NULL nullptr_overload.cpp -o nullptr_overload
nullptr_overload.cpp: In function 'int main()':
nullptr_overload.cpp:11:9: error: call of overloaded 'show(NULL)' is ambiguous
11 | show(NULL); // NULL is an integer constant on these compilers
| ~~~~^~~~~~
• there are 2 candidates
• candidate 1: 'void show(int)'
nullptr_overload.cpp:4:6:
4 | void show(int n) { std::println("show(int) got {}", n); }
| ^~~~
• candidate 2: 'void show(int*)'
nullptr_overload.cpp:5:6:
5 | void show(int* p) { std::println("show(int*) got {}", p == nullptr ? "a null pointer" : "an address"); }
| ^~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nullptr_session.py
show(0) calls the int version, because 0 is an integer before it is anything else. show(nullptr) calls the
pointer version, because nullptr cannot become an int. show(NULL) does not compile on either compiler: the
standard only says that NULL is an implementation-defined null pointer constant, with 0 and 0L given as possible
definitions, and here it is an integer constant that converts equally well to int and to int*. The C++ Core
Guidelines put it in one rule, ES.47: use nullptr rather than 0 or NULL.
A pointer converts to bool, false for the null pointer and true otherwise, so if (p) and if (p != nullptr) mean
the same. Check before you dereference whenever a pointer may be null, and initialise every pointer, to nullptr if
there is nothing to point to yet: a pointer variable that is never initialised holds no valid value at all.
Reading const in a pointer declaration
const can apply to the object a pointer points to, to the pointer itself, or to both. Read the declaration from
right to left, starting at the name:
// Two lines in this file do not compile; read each declaration from right to left.
int main() {
int seats = 40;
int rows = 8;
const int* view = &seats; // view is a pointer to a const int
view = &rows; // fine: the pointer itself may change
*view = 9; // error: the int may not be changed through view
int* const fixed = &seats; // fixed is a const pointer to an int
*fixed = 41; // fine: the int may change
fixed = &rows; // error: fixed always points to seats
const int* const both = &seats; // both is a const pointer to a const int
(void)both;
} from session import run
run("""
clang++ -std=c++23 -c const_pointers.cpp -o const_pointers.o
g++-16 -std=c++23 -c const_pointers.cpp -o const_pointers.o
""") Output
$ clang++ -std=c++23 -c const_pointers.cpp -o const_pointers.o
const_pointers.cpp:8:11: error: read-only variable is not assignable
8 | *view = 9; // error: the int may not be changed through view
| ~~~~~ ^
const_pointers.cpp:12:11: error: cannot assign to variable 'fixed' with const-qualified type 'int *const'
12 | fixed = &rows; // error: fixed always points to seats
| ~~~~~ ^
const_pointers.cpp:10:16: note: variable 'fixed' declared const here
10 | int* const fixed = &seats; // fixed is a const pointer to an int
| ~~~~~~~~~~~^~~~~~~~~~~~~~
2 errors generated.
[exit status 1]
$ g++-16 -std=c++23 -c const_pointers.cpp -o const_pointers.o
const_pointers.cpp: In function 'int main()':
const_pointers.cpp:8:11: error: assignment of read-only location '* view'
8 | *view = 9; // error: the int may not be changed through view
| ~~~~~~^~~
const_pointers.cpp:12:11: error: assignment of read-only variable 'fixed'
12 | fixed = &rows; // error: fixed always points to seats
| ~~~~~~^~~~~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 const_pointers_session.py
| Criterion | const int* view | int* const fixed | const int* const both |
|---|---|---|---|
| Reads as | pointer to a const int | const pointer to an int | const pointer to a const int |
| Change the int | No | Yes | No |
| Re-point it | Yes | No | No |
| When to choose | Read-only access, the common case | A target that never changes | Both promises |
Clang calls *view a read-only variable and GCC a read-only location, but both stop at line 8, and both refuse to
move fixed on line 12. int const* view means the same as const int* view: what matters is whether const stands
left or right of the *. A const int* parameter is the pointer version of a const int& parameter, a promise that
the function only reads.
When a pointer points at nothing valid
Dereferencing a null pointer is undefined behaviour, and so is dereferencing a dangling pointer, one that still
holds the address of an object that has ended. Here a search function correctly returns nullptr for an id it cannot
find, and the caller forgets to check:
#include <print>
struct Student {
long id;
int marks;
};
// Returns the student with this id, or nullptr when there is none.
Student* find_student(Student* first, Student* last, long id) {
for (Student* s = first; s != last; ++s)
if (s->id == id) return s;
return nullptr;
}
int main() {
Student batch[] = {{101, 72}, {102, 91}};
Student* found = find_student(batch, batch + 2, 102);
std::println("102 has {} marks", found->marks);
Student* missing = find_student(batch, batch + 2, 999);
std::println("999 has {} marks", missing->marks); // missing is nullptr: undefined behaviour
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra null_member.cpp -o null_member && ./null_member
clang++ -std=c++23 -g -fsanitize=address null_member.cpp -o null_member && ./null_member
g++-16 -std=c++23 -g -fsanitize=undefined null_member.cpp -o null_member && ./null_member
""") Output
$ clang++ -std=c++23 -Wall -Wextra null_member.cpp -o null_member && ./null_member
102 has 91 marks
/bin/sh: line 1: … Segmentation fault: 11 ./null_member
[exit status 139]
$ clang++ -std=c++23 -g -fsanitize=address null_member.cpp -o null_member && ./null_member
102 has 91 marks
AddressSanitizer:DEADLYSIGNAL
==…==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000008
The signal is caused by a READ memory access.
Hint: address points to the zero page.
#8 in main null_member.cpp:21
SUMMARY: AddressSanitizer: SEGV
[22 more lines of the report left out]
/bin/sh: line 1: … Abort trap: 6 ./null_member
[exit status 134]
$ g++-16 -std=c++23 -g -fsanitize=undefined null_member.cpp -o null_member && ./null_member
102 has 91 marks
null_member.cpp:21:47: runtime error: member access within null pointer of type 'struct Student'
/bin/sh: line 1: … Segmentation fault: 11 ./null_member
[exit status 139]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 null_member_session.py
The ordinary build printed the first line and then crashed with a segmentation fault (exit status 139). The
AddressSanitizer build tells you more: SEGV on unknown address 0x000000000008, a read, near address zero, at line 21.
The 8 is no accident. missing is 0, and on this 64-bit platform marks sits 8 bytes into a Student, after the
8-byte long id, so missing->marks reads address 0 + 8. A crash address that is a small number usually means a
member was read through a null pointer, and the number is the member’s offset. GCC’s UndefinedBehaviorSanitizer build names the bug directly:
member access within null pointer of type 'struct Student'. The fix is the check the function’s contract asks for:
if (missing != nullptr) before the read.
A pointer can dangle exactly like a reference. Here seat keeps the address of booked after the block that owned
booked has ended:
#include <print>
int main() {
int* seat = nullptr;
{
int booked = 12;
seat = &booked;
} // booked ends here; seat still holds its old address
std::println("seat {}", *seat);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra dangling_pointer.cpp -o dangling_pointer && ./dangling_pointer
g++-16 -std=c++23 -Wall -Wextra dangling_pointer.cpp -o dangling_pointer && ./dangling_pointer
clang++ -std=c++23 -g -fsanitize=address dangling_pointer.cpp -o dangling_pointer && ./dangling_pointer
""") Output
$ clang++ -std=c++23 -Wall -Wextra dangling_pointer.cpp -o dangling_pointer && ./dangling_pointer
seat 12
$ g++-16 -std=c++23 -Wall -Wextra dangling_pointer.cpp -o dangling_pointer && ./dangling_pointer
dangling_pointer.cpp: In function 'int main()':
dangling_pointer.cpp:9:17: warning: using dangling pointer 'seat' to 'booked' [-Wdangling-pointer=]
9 | std::println("seat {}", *seat);
| ~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~
dangling_pointer.cpp:6:13: note: 'booked' declared here
6 | int booked = 12;
| ^~~~~~
seat 12
$ clang++ -std=c++23 -g -fsanitize=address dangling_pointer.cpp -o dangling_pointer && ./dangling_pointer
==…==ERROR: AddressSanitizer: stack-use-after-scope on address 0x…
READ of size 4 at 0x… thread T0
#8 in main dangling_pointer.cpp:9
Address 0x… is located in stack of thread T0 at offset 32 in frame
#0 in main dangling_pointer.cpp:3
[32, 36) 'booked' (line 6) <== Memory access at offset 32 is inside this variable
SUMMARY: AddressSanitizer: stack-use-after-scope
[47 more lines of the report left out]
/bin/sh: line 1: … Abort trap: 6 ./dangling_pointer
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dangling_pointer_session.py
Clang compiled it without a warning and the program printed seat 12, which looks right. GCC’s -Wdangling-pointer,
part of -Wall, saw the problem at compile time, and the sanitizer build reports stack-use-after-scope at run time,
naming booked, declared on line 6. The C++ Core Guidelines rule ES.65 says it plainly: don’t dereference an invalid
pointer. The dependable defence is to keep a pointer’s lifetime inside the lifetime of its object, and to run tests
under sanitizers.
Pointer or reference?
Both let a function work on the caller’s object without copying it. Here is the same swap written both ways:
#include <print>
void swap_with_pointers(int* a, int* b) {
int old = *a;
*a = *b;
*b = old;
}
void swap_with_references(int& a, int& b) {
int old = a;
a = b;
b = old;
}
int main() {
int left = 1;
int right = 2;
swap_with_pointers(&left, &right); // the & at the call shows that left and right may change
std::println("after swap_with_pointers: left {}, right {}", left, right);
swap_with_references(left, right); // reads like a call by value
std::println("after swap_with_references: left {}, right {}", left, right);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra swap_both.cpp -o swap_both && ./swap_both
""") Output
$ clang++ -std=c++23 -Wall -Wextra swap_both.cpp -o swap_both && ./swap_both after swap_with_pointers: left 2, right 1 after swap_with_references: left 1, right 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 swap_session.py
| Criterion | Reference | Pointer |
|---|---|---|
| Can be null | No | Yes: nullptr |
| Re-point it | No | Yes |
| Initialise it | Always required | Optional, but do it |
| Use the object | By its name | *p and p->member |
| Call site | swap(left, right) | swap(&left, &right) |
| When to choose | The object must exist | "No object" is a valid answer |
The C++ Core Guidelines give the rule as F.60: prefer T* over T& when “no argument” is a valid option. Use a
reference when the object must exist, for example swap’s two arguments, and a pointer when nothing is a meaningful
answer, as for a search that may find nothing. Two more conventions keep pointer code readable. A raw pointer like
these never owns its object (rule R.3): it only observes something that someone else creates and destroys, a job for
containers and smart pointers that the dynamic-memory lesson takes up. And swap_with_pointers(nullptr, &right)
compiles and then crashes, while swap_with_references cannot be called without two objects: a reference parameter
moves the check from run time to compile time.
Your turn: find_max
Write find_max, which returns a pointer to the largest element of a vector, or nullptr when the vector is empty.
When the largest value appears more than once, it returns the first one. The program checks four cases:
// Write find_max: return a pointer to the largest element of marks, or nullptr when marks is empty.
// When the largest value appears more than once, return a pointer to its first occurrence.
#include <print>
#include <vector>
const int* find_max(const std::vector<int>& marks) {
(void)marks; // replace these two lines with your loop
return nullptr;
}
int passed = 0;
int checks = 0;
void check(bool ok, const char* what) {
++checks;
if (ok) ++passed;
std::println("{} {}", ok ? "pass" : "FAIL", what);
}
int main() {
const std::vector<int> batch{72, 91, 85, 91};
const std::vector<int> rising{3, 5, 8};
const std::vector<int> empty;
const int* best = find_max(batch);
check(best != nullptr && *best == 91, "finds 91 in {72, 91, 85, 91}");
check(best == &batch[1], "points at the first 91, inside the vector");
check(find_max(rising) == &rising[2], "finds the last element of {3, 5, 8}");
check(find_max(empty) == nullptr, "returns nullptr for an empty vector");
std::println("{} of {} checks passed", passed, checks);
} from session import run
# The exercise as given: the starting code returns nullptr for everything.
run("""
clang++ -std=c++23 -Wall -Wextra find_max_exercise.cpp -o find_max_exercise && ./find_max_exercise
""") Output
$ clang++ -std=c++23 -Wall -Wextra find_max_exercise.cpp -o find_max_exercise && ./find_max_exercise
FAIL finds 91 in {72, 91, 85, 91}
FAIL points at the first 91, inside the vector
FAIL finds the last element of {3, 5, 8}
pass returns nullptr for an empty vector
1 of 4 checks passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 find_max_session.py
As given, find_max always returns nullptr, so only the empty-vector check passes. When your version is right,
the last line reads 4 of 4 checks passed, with both compilers and with -fsanitize=address,undefined.
First hint: in for (const int& m : marks), m names the element itself, not a copy, so &m is the address to
remember. Keep the best one in a const int* that starts as nullptr.
Then try one thing on purpose: call find_max(std::vector<int>{3, 5, 8}) and use the result on the next line. The
vector is a temporary, so the pointer dangles, for the same reason as the references in the previous lesson, and
AddressSanitizer reports a heap-use-after-free.
Self-check
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between a pointer and a reference in C++?
A reference is an alias, bound once when it is initialised to an object that already exists; it is never re-seated
and never null in a valid program. A pointer is an object of its own that holds an address: it can be left
uninitialised (though it should not be), it can be changed to point elsewhere, it can hold nullptr, and you reach
the object with *p or p->member. Pointers also support arithmetic inside arrays, and there are pointers to
pointers, which references do not have. In function signatures, use a reference when the argument must exist and a
pointer when “no object” is a valid answer. Neither one owns the object, so both can dangle when the object ends
first.
Key takeaways
- A pointer holds an address:
&xtakes it,*pis the object, andp->mis(*p).m. nullptrmeans “no object”; it converts to any pointer type and never implicitly to an integer, which is why it replaced0andNULL, and whyshow(NULL)was ambiguous on both compilers.- Read declarations from right to left:
const int*points to a const int,int* constis a pointer that cannot be re-pointed. - Dereferencing a null or dangling pointer is undefined behaviour; a crash at a small address such as 0x8 usually means a member read through a null pointer.
- Prefer a reference when the object must exist, and a pointer when “no object” is a valid answer; a raw pointer observes and never owns.
References
- Working Draft, Programming Languages: C++ (N4950), [basic.compound], [lex.nullptr], [conv.ptr], [support.types.nullptr], [format.formatter.spec] (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines (ES.47, ES.65, F.60, R.3) (Standard C++ Foundation)
- AddressSanitizer (Clang documentation) (LLVM Project)
- UndefinedBehaviorSanitizer (Clang documentation) (LLVM Project)
- Program Instrumentation Options (-fsanitize) (GNU Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress