Understanding Memory Addresses and Pointers
Store memory addresses in pointer variables and access data through them.
What Is a Pointer?
Every object a program creates occupies a numbered place in memory, and every name you write is the compiler's shorthand for one of those places. A pointer is an object whose value is such a place. Because that place is stored as a value rather than baked into an identifier, the object a pointer leads to can be chosen, and changed, while the program is running.
Compare that with an ordinary name. A variable called windSpeed refers to the same object from its declaration until it dies, and no statement can aim the identifier somewhere else halfway through. A pointer holding windSpeed's location has no such loyalty: two lines later it can hold a different object's location instead. Anything that has to decide at run time which object to read or write is eventually built on that flexibility.
Pointers have a reputation for being difficult, and most of that reputation belongs to the punctuation rather than the idea. There are two operators to learn, & and *, and a short list of rules about what a pointer may legally hold. Taken in that order, nothing here is harder than references were.
Everything below is measured against lvalue references, so if binding, reseating, and const aliases feel hazy, revisit those earlier lessons before continuing here.
Turning a Name Into a Location, and Back Again
Two unary operators move between an object and the place it lives:
- The address-of operator (
&), written in front of an object, hands back a pointer holding that object's location. It does not produce a bare number, and C++ has no such thing as an address literal. - The dereference operator (
*), sometimes called the indirection operator, written in front of a location, hands back the object stored there as an lvalue.
Each undoes the other. Take an object's address and immediately dereference it, and you arrive back where you began:
#include <iostream>
int main()
{
int windSpeed{34};
std::cout << windSpeed << '\n'; // reach the object by its name
std::cout << *(&windSpeed) << '\n'; // reach the object by its location
return 0;
}
34
34
*(&windSpeed) is a roundabout way to write windSpeed, and no real program contains it. It earns its place here only as proof that the two operators are inverses. The parentheses are optional; they simply separate the two steps for the reader.
An address can be streamed to std::cout, where it prints as a hexadecimal number. Which number you get is decided by the operating system as the program starts, so it differs between runs and between machines, and nothing in this lesson depends on a specific one. When an object spans several bytes, the address you obtain is the first of them.
Both symbols are overloaded, and what they mean is settled by where they appear and how many operands they take:
| Written as | Context | Meaning |
|---|---|---|
int& gauge |
in a declaration | declares an lvalue reference |
&windSpeed |
one operand, in an expression | address-of, producing a pointer |
mask & bits |
two operands | bitwise AND |
int* tracker |
in a declaration | declares a pointer |
*tracker |
one operand, in an expression | dereference, producing the object |
hours * rate |
two operands | multiplication |
The two declaration rows are the ones that catch people out. In int* tracker nothing is being dereferenced, and in int& gauge nothing's address is being taken; in both, the symbol is part of the type being written.
Declaring a Pointer
A pointer type is spelled as the type of the pointed-to object followed by an asterisk. int* is the type "pointer to int" and double* is the type "pointer to double"; they are separate, unrelated types:
int* tracker{&windSpeed}; // tracker is a pointer to int
double* moisture{&humidity}; // moisture is a pointer to double
Spoken shorthand runs the other way round, and it is worth getting used to now because const will make the wording matter later.
"An
int pointer" is everyday shorthand for "a pointer to an int". The type name comes first when spoken, even though it is the pointed-to type that is being named.
int* tracker and int *tracker declare exactly the same variable, so this is a readability decision rather than a correctness one. Settle on the first spelling and stay with it: it keeps the asterisk with the rest of the type, which is where the type information belongs, and it lines up with how int& is written for references.
An asterisk attaches to a single declarator, not to the whole statement.
int* first, second; produces a pointer named first and a plain int named second, which is rarely what the author had in mind. Repeating the asterisk as int* first, * second; fixes it, and giving each variable its own statement means the question never comes up.
Give a Pointer a Value Before You Read One
Declaring a pointer sets aside room for an address; it does not put one there. A pointer left uninitialised is a wild pointer, and its value is whatever bit pattern that memory happened to be holding already. Dereferencing a wild pointer is undefined behaviour, and it is the flavour of undefined behaviour that quietly appears to work on the machine you wrote it on.
There are three ways a pointer declaration can turn out:
| Declaration | What the pointer holds | Result of dereferencing it |
|---|---|---|
int* tracker; |
leftover bits, meaning nothing (a wild pointer) | undefined behaviour |
int* tracker{}; |
no address at all (a null pointer, covered next lesson) | undefined behaviour |
int* tracker{&windSpeed}; |
the location of windSpeed |
the object, for as long as windSpeed lives |
Only the third row is usable immediately, but the second row is safe to hold on to, because a pointer that holds no address can be tested for that condition before anything dereferences it. The first row cannot be tested for anything.
Never leave a pointer declaration without an initialiser. When you already know which object it should lead to, put that address in the declaring statement; when you do not know yet, write
{} so the pointer starts out empty and can be checked before use.
The Declared Type Must Match the Object at That Address
A pointer's type is a promise about what lives at the address it holds, and the compiler enforces it. Feeding the address of a double to an int* is rejected, and so is the reverse. This program is broken on purpose:
#include <iostream>
int main()
{
int windSpeed{34};
double humidity{61.5};
int* tracker{&humidity}; // will not compile
double* moisture{&windSpeed}; // will not compile
std::cout << *tracker << ' ' << *moisture << '\n';
return 0;
}
s.cpp: In function 'int main()':
s.cpp:8:18: error: cannot convert 'double*' to 'int*' in initialization
8 | int* tracker{&humidity}; // will not compile
| ^~~~~~~~~
| |
| double*
s.cpp:9:22: error: cannot convert 'int*' to 'double*' in initialization
9 | double* moisture{&windSpeed}; // will not compile
| ^~~~~~~~~~
| |
| int*
Read the diagnostic closely, because it settles a question this lesson raised earlier. GCC labels &humidity as being of type double* and &windSpeed as being of type int*. The address-of operator really does produce a pointer, complete with a pointer type, rather than a numeric value that happens to be an address.
The same type rule blocks the obvious shortcut of writing an address by hand:
int* tracker{7}; // rejected: 7 is an int, not an int*
int* guess{0x5A3E10}; // rejected: hexadecimal notation does not make an int into an address
GCC reports invalid conversion from 'int' to 'int*' for both lines. There is exactly one literal a pointer will accept, and the next lesson introduces it.
Two Different Things an Assignment Can Mean
Because a pointer is an object holding a location, there are two objects in play whenever you assign: the pointer and the thing it leads to. Which one you change is decided entirely by whether the asterisk is present:
| Statement | Which object is written | Effect |
|---|---|---|
tracker = &gustSpeed; |
the pointer | tracker now leads to gustSpeed; nothing else changes |
*tracker = 61; |
the object at that location | gustSpeed becomes 61; tracker still leads to it |
Swapping the two by mistake does not silently misbehave, because the types disagree: *tracker = &gustSpeed; asks to store an address inside an int, and tracker = 61; asks to store an int inside a pointer. Both are rejected at compile time. One program exercises the correct pair:
#include <iostream>
int main()
{
int windSpeed{34};
int gustSpeed{58};
int* tracker{&windSpeed};
std::cout << *tracker << '\n';
tracker = &gustSpeed;
std::cout << *tracker << '\n';
*tracker = 61;
std::cout << gustSpeed << '\n';
std::cout << windSpeed << '\n';
return 0;
}
34
58
61
34
The last line is the one to dwell on. windSpeed still reads 34 because by the time the write happened, tracker was aimed elsewhere. A pointer gives you access to exactly one object at a time: whichever one its current value names.
Read a bare
tracker as "which object", and *tracker as "the object". Assigning to the first changes the pointer's aim; assigning to the second changes the value stored at the far end of it, and leaves the aim alone.
Every Pointer Is the Same Size
Since dereferencing an int* yields a four-byte int and dereferencing a long double* yields something much larger, it is tempting to assume the pointers differ in size too. They do not. A pointer's value is an address, and the number of bits it takes to name a location is a property of the machine rather than of whatever is stored there:
#include <iostream>
int main()
{
char* codeSlot{};
int* speedSlot{};
long double* driftSlot{};
std::cout << sizeof(char) << ' ' << sizeof(int) << ' ' << sizeof(long double) << '\n';
std::cout << sizeof(codeSlot) << ' ' << sizeof(speedSlot) << ' ' << sizeof(driftSlot) << '\n';
return 0;
}
1 4 16
8 8 8
On the 64-bit Linux target used here the pointed-to types range from one byte to sixteen, and all three pointers occupy eight. Eight is what a 64-bit executable uses, because a 64-bit address is eight bytes wide. Compile the same file as a 32-bit executable and every line of the second row becomes 4, still regardless of what each pointer refers to.
Pointer or Reference?
An lvalue reference and a pointer both give you a second route to an object you do not own. Below, three names lead to a single int, and a write through any of them is visible through all of them:
#include <iostream>
int main()
{
int windSpeed{34};
int& gauge{windSpeed};
int* tracker{&windSpeed};
std::cout << windSpeed << ' ' << gauge << ' ' << *tracker << '\n';
gauge = 47;
std::cout << windSpeed << ' ' << gauge << ' ' << *tracker << '\n';
*tracker = 52;
std::cout << windSpeed << ' ' << gauge << ' ' << *tracker << '\n';
return 0;
}
34 34 34
47 47 47
52 52 52
Three columns, one object. The indirection is identical; what differs is how much of it you have to write down. A reference performs the address-taking and the dereferencing for you, silently, at every use. A pointer makes you write & once and * every time, and hands you capabilities in exchange:
| Question | Lvalue reference | Pointer |
|---|---|---|
| Is it an object? | no, it is an alias | yes, with its own storage and address |
| Must it be initialised? | yes, the language requires it | no, though you always should |
| Can it be re-aimed later? | no, the binding is permanent | yes, by assigning a new address |
| Can it refer to nothing? | no, it is always bound | yes, that is what a null pointer is |
| Syntax at the point of use | none, it reads like the object | * to reach the object |
| Failure modes | dangling only | dangling, wild, and null |
The last row is why references remain the default. A pointer can go wrong in ways a reference cannot, so the extra capability has to be worth something before you reach for it.
When the Object Dies First
A pointer stores a location, and it has no idea whether the object that used to live there still does. A dangling pointer is one holding the address of an object that has been destroyed. Dereferencing it is undefined behaviour, exactly as with a wild pointer, because there is nothing valid at the far end to read.
The program below is wrong on purpose, and GCC is sharp enough to say so:
#include <iostream>
int main()
{
int windSpeed{34};
int* tracker{&windSpeed};
{
int squallSpeed{72};
tracker = &squallSpeed;
std::cout << *tracker << '\n';
}
std::cout << *tracker << '\n';
return 0;
}
s.cpp: In function 'int main()':
s.cpp:15:30: warning: using dangling pointer 'tracker' to 'squallSpeed' [-Wdangling-pointer=]
15 | std::cout << *tracker << '\n';
| ^~~~
s.cpp:9:13: note: 'squallSpeed' declared here
9 | int squallSpeed{72};
| ^~~~~~~~~~~
squallSpeed is destroyed at the closing brace of the inner block, and tracker outlives it while still holding its address. The first dereference is fine and prints 72. The second is undefined behaviour, so what it prints is not documented here; the standard makes no promise about it, and neither should a lesson.
Notice that the diagnostic is a warning rather than an error. The compiler spotted this case because both the pointer and the dead object are visible in one function, which will not be true once addresses start travelling across function boundaries. Treat the warning as a lucky catch rather than a safety net.
Reading through a pointer that leads nowhere valid is undefined behaviour, and all three broken states qualify: wild, dangling, and null. The standard grades the remaining operations more gently. Copy such a pointer into another variable, or compare it against something, and the result is implementation-defined instead: your compiler will do something consistent, but the language declines to say what, so portable code cannot rely on it. Overwriting a broken pointer with a fresh address is always fine, because storing into it never inspects what was already there.
Looking Forward
The next lesson covers null pointers, the one value a pointer can hold that reliably means "nothing here", along with the checks that make that state usable rather than dangerous. After that come pointers and const, and then pass by address, which is where pointers start replacing references in function parameter lists for arguments that may legitimately be absent.
The pointers in this chapter are sometimes called raw pointers, or occasionally dumb pointers, to distinguish them from the smart pointer types in a later chapter. Smart pointers wrap a raw pointer in a class that manages the lifetime of what it refers to, which removes most of the failure modes listed above.
Key Terminology
- Address-of operator (
&): applied to an object, produces a pointer holding that object's location - Dereference operator (
*): applied to a pointer, produces the object at the location it holds, as an lvalue - Pointer: an object whose value is the location of another object
- Pointer type: a type such as
int*, naming what may be found at the address a pointer of that type holds - Wild pointer: an uninitialised pointer, holding whatever bits were already in its storage
- Dangling pointer: a pointer holding the address of an object that has since been destroyed
- Raw pointer: a plain language-level pointer, as opposed to a smart pointer class
Summary
- A pointer is an object whose value is a memory location, so which object it leads to is decided at run time rather than at compile time
&objectproduces a pointer to that object;*pointerproduces the object at the address held, as an lvalue; the two operators are inverses- A pointer's declared type fixes what may live at the address it holds, and the compiler rejects a mismatch such as
int* tracker{&humidity} - Assigning to
trackerre-aims the pointer, while assigning to*trackerwrites through it to the object; the asterisk decides which - An uninitialised pointer is a wild pointer, so give every pointer either an address or
{}in its declaring statement - All pointers on a given target are the same width, eight bytes for a 64-bit build and four for a 32-bit one, no matter how large the pointed-to type is
- References and pointers both provide indirect access to an object, but only a pointer can be re-aimed, refer to nothing, or be left uninitialised
- A pointer whose object has been destroyed is dangling; dereferencing it is undefined behaviour, while merely copying or comparing a broken pointer is implementation-defined rather than undefined
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Understanding Memory Addresses and Pointers - Quiz
Test your understanding of the lesson.
Practice Exercises
Introduction to Pointers
Practice using pointers to store and access memory addresses.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!