C++ Pointers: Complete Guide to Memory Addresses, Arithmetic & Safety
What Are C++ Pointers
A pointer is a variable that stores the memory address of another variable. That is the entire concept. Every tutorial in existence opens with this definition, and every tutorial then fails to explain why it matters. So here is why: pointers are how C++ gives you direct access to hardware memory. They are why C++ powers operating systems, game engines, embedded firmware, and database kernels. They are also why C++ programs crash harder and leak worse than anything written in Python or Java. Pointers are the most powerful and most dangerous feature in the language, and you cannot avoid them.
Every variable you create lives somewhere in memory. When you write int score = 42;, the compiler reserves 4 bytes (on most platforms) at some address — say, 0x7ffd5e3a2c04. Normally you access that value by name. A pointer lets you access it by address instead. Two operators make this possible:
The address-of operator (&) returns the memory address of a variable. The dereference operator (*) follows an address and gives you the value stored there. These are inverses of each other.
#include <iostream>
int main() {
int score = 42;
int* ptr = &score; // ptr holds the address of score
std::cout << "Value of score: " << score << "\n"; // 42
std::cout << "Address of score: " << &score << "\n"; // 0x7ffd...
std::cout << "Value of ptr: " << ptr << "\n"; // same address
std::cout << "Dereferenced ptr: " << *ptr << "\n"; // 42
*ptr = 99; // modify score through the pointer
std::cout << "score is now: " << score << "\n"; // 99
}
Read that last line carefully. When you write *ptr = 99, you are reaching into memory at the address stored in ptr and overwriting whatever is there. You did not touch score by name. You changed it by address. This is indirection — accessing data through an intermediary rather than directly — and it is the foundation of dynamic memory, polymorphism, data structures, and callback systems in C++.
If you have already worked through variables and types, you know that every variable has a type, a name, a value, and a location. A pointer simply stores that location as its own value. The type tells the compiler what kind of data lives at that address, so it knows how many bytes to read and how to interpret them.
Declaring & Initializing Pointers
The syntax for declaring a pointer uses * between the type and the name:
int* p1; // pointer to int
double* p2; // pointer to double
char* p3; // pointer to char
std::string* p4; // pointer to std::string
A style note that generates more arguments than tabs vs. spaces: the * can go next to the type (int* p), next to the name (int *p), or floating (int * p). They all mean the same thing to the compiler. The Bjarne Stroustrup convention is int* p (emphasizing “pointer to int” as a type), while the K&R C convention is int *p. Pick one and be consistent. Just be aware of the multi-declaration trap:
int* a, b; // DANGER: a is int*, but b is just int!
int *a, *b; // Both are int* — the * binds to the name, not the type
This is a genuine language defect inherited from C. The safest approach is one declaration per line.
Always Initialize Your Pointers
An uninitialized pointer contains whatever garbage was previously in that memory location. Dereferencing it is undefined behavior — it might crash, it might silently corrupt memory, it might appear to work perfectly during testing and explode in production. Since C++11, use nullptr for pointers that do not yet point to anything:
int* ptr = nullptr; // safe: explicitly "points to nothing"
if (ptr != nullptr) {
std::cout << *ptr; // safe: only dereference if not null
}
// Shorthand — nullptr is falsy:
if (ptr) {
std::cout << *ptr;
}
// Old C style (avoid in modern C++):
int* old_ptr = NULL; // macro that expands to 0 or ((void*)0)
int* worse = 0; // technically works, but unclear intent
The nullptr keyword is type-safe. Unlike NULL (which is just the integer 0), nullptr is of type std::nullptr_t and will not accidentally match an integer overload. This matters in function overloading:
void process(int value);
void process(int* ptr);
process(NULL); // ambiguous — NULL is 0, could match either overload
process(nullptr); // unambiguous — matches the pointer overload
Rule: every pointer must be initialized to either a valid address or nullptr at the point of declaration. No exceptions. If you follow this rule, an entire category of bugs disappears.
Pointer Arithmetic
Pointers are not just addresses — they are typed addresses. When you increment an int*, it does not move forward by 1 byte. It moves forward by sizeof(int) bytes (typically 4). This is pointer arithmetic, and it is how C++ navigates contiguous memory blocks like arrays.
int arr[] = {10, 20, 30, 40, 50};
int* p = arr; // points to arr[0]
std::cout << *p << "\n"; // 10
std::cout << *(p + 1) << "\n"; // 20 — moved forward sizeof(int) bytes
std::cout << *(p + 4) << "\n"; // 50
p += 2; // p now points to arr[2]
std::cout << *p << "\n"; // 30
p--; // p now points to arr[1]
std::cout << *p << "\n"; // 20
You can also subtract two pointers of the same type to get the number of elements between them:
int data[] = {5, 10, 15, 20, 25, 30};
int* start = &data[1];
int* end = &data[4];
std::ptrdiff_t distance = end - start; // 3 (elements, not bytes)
std::cout << "Distance: " << distance << "\n";
The result type of pointer subtraction is std::ptrdiff_t, a signed integer type defined in <cstddef>. Pointer arithmetic is only valid within a single array (or one past its end). Arithmetic between pointers to unrelated variables is undefined behavior, even if the addresses happen to be adjacent in memory. The C++ standard is very explicit about this.
Pointers and Arrays
Arrays and pointers are deeply intertwined in C++, and understanding the relationship is essential. If you have worked through the arrays lesson, you know that an array is a contiguous block of typed elements. What you may not know is that in most contexts, an array name decays to a pointer to its first element:
int nums[] = {100, 200, 300, 400};
int* p = nums; // array decays to pointer — no & needed
// These are equivalent:
std::cout << nums[2] << "\n"; // 300 — subscript notation
std::cout << *(p + 2) << "\n"; // 300 — pointer arithmetic
std::cout << *(nums + 2) << "\n"; // 300 — array name used as pointer
std::cout << p[2] << "\n"; // 300 — subscript works on pointers too
The expression a[i] is literally defined as *(a + i) by the C++ standard. This means 3[nums] is technically valid and equals nums[3]. Please never write this in real code.
Pointer-Based Iteration vs Index-Based
You can iterate an array using indices or pointers. Both are valid; pointers are more common in low-level code and STL algorithm implementations:
int values[] = {1, 2, 3, 4, 5};
int count = sizeof(values) / sizeof(values[0]);
// Index-based
for (int i = 0; i < count; ++i) {
std::cout << values[i] << " ";
}
std::cout << "\n";
// Pointer-based
for (int* p = values; p < values + count; ++p) {
std::cout << *p << " ";
}
std::cout << "\n";
// This is exactly how std::begin/std::end work under the hood:
for (int* it = std::begin(values); it != std::end(values); ++it) {
std::cout << *it << " ";
}
When you pass an array to a function, it always decays to a pointer. The function receives a pointer, not a copy of the array. This is why functions that take arrays also need a size parameter — the pointer alone carries no length information:
// These two declarations are IDENTICAL:
void print_array(int arr[], int size);
void print_array(int* arr, int size);
// sizeof inside the function gives the pointer size, not the array size
void broken(int arr[]) {
// sizeof(arr) == 8 on 64-bit (size of a pointer), NOT the array size
}
This decay is one reason modern C++ prefers std::vector, which carries its own size and does not decay to a pointer when passed to functions.
Pointers to Functions
Functions live in memory too, and you can store their addresses in pointers. Function pointers enable callbacks, strategy patterns, plugin architectures, and event-driven systems. The syntax is ugly, but the capability is powerful. If you need a refresher on function basics, revisit the functions lesson first.
#include <iostream>
// Two functions with identical signatures
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
int main() {
// Declare a function pointer
int (*operation)(int, int);
// Point it at different functions
operation = add;
std::cout << operation(3, 4) << "\n"; // 7
operation = multiply;
std::cout << operation(3, 4) << "\n"; // 12
}
Reading the type: int (*operation)(int, int) means “operation is a pointer to a function that takes two ints and returns an int.” The parentheses around *operation are required — without them, int* operation(int, int) declares a function returning int*.
Callback Pattern
Function pointers are commonly used for callbacks — passing a function to another function so it can be called at the right time:
#include <iostream>
void apply_to_array(int* arr, int size, int (*transform)(int)) {
for (int i = 0; i < size; ++i) {
arr[i] = transform(arr[i]);
}
}
int double_it(int x) { return x * 2; }
int negate_it(int x) { return -x; }
int main() {
int data[] = {1, 2, 3, 4, 5};
apply_to_array(data, 5, double_it);
// data is now {2, 4, 6, 8, 10}
apply_to_array(data, 5, negate_it);
// data is now {-2, -4, -6, -8, -10}
for (int x : data) std::cout << x << " ";
}
A typedef or using alias makes function pointer types readable:
// These are equivalent:
typedef int (*MathFunc)(int, int);
using MathFunc = int(*)(int, int); // C++11 style — preferred
MathFunc op = add; // much cleaner
std::cout << op(10, 5) << "\n";
In modern C++, std::function and lambdas have largely replaced raw function pointers for callback patterns. But function pointers remain essential for C API interop, signal handlers, and performance-critical code where the overhead of std::function (heap allocation, type erasure) is unacceptable.
Pointers and const
The interaction between const and pointers creates three distinct combinations, and mixing them up is a common source of confusion. Read the declarations from right to left to decode them:
int value = 42;
int other = 99;
// 1. Pointer to const — you can't modify the value through the pointer
const int* p1 = &value;
// *p1 = 50; // ERROR: cannot modify a const int
p1 = &other; // OK: the pointer itself can be reassigned
// 2. Const pointer — the pointer itself can't be reassigned
int* const p2 = &value;
*p2 = 50; // OK: can modify the value
// p2 = &other; // ERROR: cannot reassign a const pointer
// 3. Const pointer to const — neither can change
const int* const p3 = &value;
// *p3 = 50; // ERROR
// p3 = &other; // ERROR
The right-to-left reading rule: const int* p reads as “p is a pointer to a const int” (cannot change the int). int* const p reads as “p is a const pointer to an int” (cannot change the pointer). const int* const p reads as “p is a const pointer to a const int” (cannot change either).
Note that const int* and int const* are identical — both mean “pointer to const int.” The const applies to whatever is to its left, unless it is the leftmost token, in which case it applies to the right.
Pointer to const is by far the most important for daily coding. Use it in function parameters to promise you will not modify the caller’s data:
// Good API design: const tells the caller their data is safe
double average(const int* data, int count) {
double sum = 0;
for (int i = 0; i < count; ++i) {
sum += data[i];
}
return count > 0 ? sum / count : 0.0;
}
// The caller knows average() won't modify their array
int scores[] = {85, 92, 78, 95, 88};
double avg = average(scores, 5);
This is the pointer equivalent of pass by const reference, and the same principle applies: prefer const correctness everywhere to catch bugs at compile time.
Void Pointers
A void* is a pointer that can hold the address of any type but cannot be dereferenced directly. It is the “I have an address but I have deliberately erased the type information” pointer. You cannot do pointer arithmetic on a void* because the compiler does not know the size of the pointed-to object.
int x = 42;
double y = 3.14;
char c = 'A';
void* generic = &x; // OK: int* converts to void* implicitly
generic = &y; // OK: double* converts too
generic = &c; // OK: any pointer type works
// *generic = 10; // ERROR: can't dereference void*
// generic++; // ERROR: can't do arithmetic on void*
// Must cast back to the original type before use:
int* ip = static_cast<int*>(generic);
// But generic actually points to c (a char), not an int!
// This compiles, but using *ip is undefined behavior.
The danger is obvious: void* erases type safety. If you cast to the wrong type, the compiler cannot help you — the bug only appears at runtime, if you are lucky. In C, void* is used extensively for generic programming (qsort, malloc, pthread_create). In C++, templates and std::any are almost always better choices.
That said, void* remains necessary for:
- C API interop — functions like
memcpy,memset, anddlsymusevoid* - Type-erased storage in low-level allocators and memory pools
- Hardware interfaces where memory-mapped registers need raw address manipulation
#include <cstring>
// memcpy uses void* — copies raw bytes regardless of type
int src[] = {1, 2, 3, 4, 5};
int dst[5];
std::memcpy(dst, src, sizeof(src)); // copies 20 bytes
// The C-style generic swap function (don't write this in C++):
void c_style_swap(void* a, void* b, size_t size) {
char temp[256]; // stack buffer
std::memcpy(temp, a, size);
std::memcpy(a, b, size);
std::memcpy(b, temp, size);
}
// In modern C++, use a template instead:
template<typename T>
void modern_swap(T& a, T& b) {
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
Common Pointer Bugs
Pointer bugs are the reason C++ has a reputation for being dangerous. Every serious C++ developer has lost hours (or days) to one of these. Know them, fear them, prevent them.
Dangling Pointers
A dangling pointer points to memory that has been freed or gone out of scope. Dereferencing it is undefined behavior — and often the worst kind, where it appears to work until something unrelated changes.
int* create_dangling() {
int local = 42;
return &local; // WARNING: returning address of local variable
} // local is destroyed here — the pointer is now dangling
int* p = create_dangling();
// *p may print 42, or garbage, or crash — undefined behavior
// Another common form — use after delete:
int* data = new int(100);
delete data;
// data still holds the old address, but the memory is freed
std::cout << *data; // undefined behavior — use-after-free
Prevention: set pointers to nullptr after deletion. Better yet, use smart pointers that handle cleanup automatically. Enable compiler warnings (-Wall -Wextra on GCC/Clang, /W4 on MSVC) — they catch many dangling-pointer patterns.
Wild (Uninitialized) Pointers
int* wild; // contains whatever garbage was on the stack
*wild = 42; // writes 42 to a random memory address — catastrophic
std::cout << *wild; // reads from a random address
Prevention: always initialize to nullptr or a valid address. No exceptions.
Memory Leaks
void leak() {
int* p = new int[1000];
// ... use p ...
return; // forgot to delete[] p — 4000 bytes leaked
}
// Called in a loop, this accumulates leaked memory until the system runs out
for (int i = 0; i < 1000000; ++i) {
leak(); // leaks ~4 GB total
}
Prevention: every new needs a matching delete. Every new[] needs delete[]. Mixing them (using delete on an array allocated with new[]) is also undefined behavior. Use tools like Valgrind and AddressSanitizer to detect leaks during testing.
Double Free
int* p = new int(42);
delete p;
delete p; // undefined behavior — double free, often crashes
// Prevention: set to nullptr after delete
int* q = new int(42);
delete q;
q = nullptr;
delete q; // safe — deleting nullptr is a no-op
Off-by-One in Pointer Arithmetic
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr + 5; // one past the end — legal to hold, illegal to dereference
std::cout << *p; // undefined behavior — reading past the array
According to the C++ FAQ, pointer bugs account for a disproportionate share of security vulnerabilities in C and C++ programs. Buffer overflows, use-after-free, and null pointer dereferences are consistently in the CWE Top 25 most dangerous software weaknesses.
Pointers vs References
If you have studied pass by reference, you know that references provide an alias to an existing variable. Pointers and references both enable indirection, but they have fundamentally different semantics:
| Feature | Pointer | Reference |
|---|---|---|
| Can be null | Yes (nullptr) |
No — must bind to a valid object |
| Can be reassigned | Yes | No — bound for life |
| Can be uninitialized | Yes (but should not be) | No — must be initialized |
| Syntax for access | *ptr and ptr-> |
Direct name (transparent) |
| Pointer arithmetic | Yes | No |
| Can represent “nothing” | Yes (nullptr) |
No |
int x = 10;
// Reference — simpler syntax, safer semantics
int& ref = x;
ref = 20; // x is now 20 (no * needed)
// Pointer — more flexible, more dangerous
int* ptr = &x;
*ptr = 30; // x is now 30 (must dereference)
Rules of Thumb
- Use references when the target will always exist and will not change. Function parameters, range-based for loops, operator overloading.
- Use pointers when the target might be null, when you need to reassign to different objects, when doing pointer arithmetic, or when interfacing with C APIs.
- Use
constreferences for function parameters when you only need to read the data — this is the default choice for passing objects. - Use pointers for optional parameters:
void render(const Texture* overlay = nullptr)clearly communicates that the argument is optional.
Under the hood, references are typically implemented as pointers by the compiler. The difference is in the contract they express: a reference promises “I always refer to a valid object,” while a pointer says “I might refer to something, or I might be null.”
Why Modern C++ Still Needs Pointers
Every few years, someone writes an article declaring that pointers are obsolete in modern C++. They are wrong. What is true is that owning raw pointers — raw pointers used to manage dynamically allocated memory — are obsolete. Modern C++ (C++11 and later) introduced smart pointers that handle ownership automatically:
#include <memory>
// Old style — manual memory management, error-prone
int* raw = new int(42);
// ... if an exception is thrown here, memory leaks ...
delete raw;
// Modern style — automatic cleanup via RAII
auto smart = std::make_unique<int>(42);
// Automatically deleted when smart goes out of scope
// Exception-safe, leak-proof
But raw pointers are still everywhere in modern C++ — they just have a different role. A raw pointer now means non-owning observation: “I can see this object, but I am not responsible for its lifetime.” This is an important semantic signal in well-designed code.
#include <memory>
#include <iostream>
class Engine {
int horsepower_;
public:
Engine(int hp) : horsepower_(hp) {}
int hp() const { return horsepower_; }
};
class Car {
std::unique_ptr<Engine> engine_; // Car OWNS the engine
public:
Car(int hp) : engine_(std::make_unique<Engine>(hp)) {}
// Returns raw pointer — caller can observe but does NOT own
Engine* get_engine() { return engine_.get(); }
};
void inspect(Engine* e) { // non-owning — just reads data
if (e) {
std::cout << "Engine: " << e->hp() << " hp\n";
}
}
int main() {
Car car(350);
inspect(car.get_engine()); // borrowing a view — no ownership transfer
}
This is the modern idiom: std::unique_ptr and std::shared_ptr for ownership, raw pointers (and references) for non-owning access. You will learn smart pointers in detail in a later lesson. For now, understand that learning raw pointers is not wasted effort — smart pointers are built on top of raw pointers, and you need to understand the foundation to use the abstraction correctly.
Raw pointers also remain essential for:
- Polymorphism — base class pointers to derived objects (covered in the classes lesson)
- Interfacing with C libraries — every C API uses raw pointers
- Performance-critical code — custom allocators, memory pools, intrusive data structures
- Embedded systems — hardware register access via memory-mapped addresses
Practice Exercises
Exercise 1: Pointer Basics. Write a program that declares three different variables (int, double, char), creates a pointer to each, and prints the variable’s value, its address, the pointer’s value, and the dereferenced pointer’s value. Verify that the address and pointer value match.
Exercise 2: Array Reversal via Pointers. Write a function void reverse(int* arr, int size) that reverses an array in place using only pointer arithmetic — no index variables, no subscript operator. Use two pointers: one at the start, one at the end, swapping and moving inward.
Exercise 3: Function Pointer Calculator. Build a simple calculator that stores addition, subtraction, multiplication, and division functions. Use a function pointer array (MathFunc ops[4]) and let the user select an operation by index. Handle division by zero.
Exercise 4: const Correctness. Write a function const int* find_max(const int* arr, int size) that returns a pointer to the maximum element without modifying the array. Then try to modify the value through the returned pointer — observe the compiler error and explain why it happens.
Exercise 5: Memory Bug Hunt. The following code contains four pointer bugs. Find them all, explain the consequences of each, and fix them:
int* create_array(int size) {
int arr[size]; // Bug 1
for (int i = 0; i <= size; ++i) { // Bug 2
arr[i] = i * 10;
}
return arr;
}
int main() {
int* data; // Bug 3
data = create_array(5);
std::cout << data[0] << "\n";
delete data; // Bug 4
}
Summary
C++ pointers store memory addresses, giving you direct access to where data lives in RAM. The address-of operator (&) retrieves an address; the dereference operator (*) follows one. Every pointer must be initialized — to a valid address or nullptr — at the point of declaration. Pointer arithmetic moves in units of the pointed-to type’s size, and it is only valid within array bounds. Arrays decay to pointers when passed to functions, losing size information. Function pointers enable callbacks and runtime dispatch. The const keyword creates three distinct pointer variations: pointer-to-const (protects the data), const-pointer (locks the address), and const-pointer-to-const (locks both). Void pointers erase type information and are necessary for C interop but should be avoided in pure C++ code. The four cardinal pointer bugs — dangling pointers, wild pointers, memory leaks, and double frees — are the most common source of crashes and security vulnerabilities in C and C++ programs. References are safer alternatives when nullability and reassignment are not needed. In modern C++, raw pointers serve as non-owning observers while smart pointers (std::unique_ptr, std::shared_ptr) handle ownership — but understanding raw pointers is a prerequisite for using smart pointers correctly.