A std::unique_ptr is 8 bytes on a 64-bit system. So is a raw pointer. They are the same size, the same speed to dereference, and one of them cleans up after itself — which is most of the argument for modern C++ pointer handling in a single measurement.
This guide covers pointers from the declaration syntax through to the point where you should stop writing raw ones: pointer arithmetic, the const-correctness rules that confuse everyone, references versus pointers, nullptr and why it replaced NULL, dangling pointers, and the smart pointer types that make manual delete unnecessary.
Every program below was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 under -std=c++17 with -Wall -Wextra. Sizes come from sizeof at runtime, the const-correctness table was built by compiling each forbidden assignment and recording the actual error, and the use-after-free was confirmed with AddressSanitizer. All output is captured verbatim.
Table of Contents
- What Is a Pointer in C++?
- Declaring and Using Pointers
- nullptr, Not NULL
- Pointer Arithmetic
- const and Pointers: The Four Combinations
- References vs Pointers
- Dangling Pointers and Use-After-Free
- Smart Pointers: The Modern Answer
- Pointers to Pointers
- Common Pitfalls
- Key Takeaways
- Frequently Asked Questions
- Conclusion
What Is a Pointer in C++?
A pointer is a variable that stores the memory address of another object. Declaring one uses * (int* p; declares a pointer to int), taking an address uses the address-of operator & (p = &x;), and reading the object at that address uses the dereference operator * (int y = *p;). Pointers are what make dynamic memory, polymorphism, and efficient large-object passing possible in C++ — and they are the source of most of the language’s memory-safety problems, which is why modern C++ wraps them in smart pointers wherever ownership is involved.
The distinction that governs everything below is ownership: does this pointer merely refer to something, or is it responsible for destroying it? Raw pointers cannot express the difference. Smart pointers can, and that is their entire purpose.
For the exact rules on pointer declarations and conversions, cppreference’s pointer declaration page is the reference.
Declaring and Using Pointers
#include <iostream>
int main() {
int value = 42;
int* p = &value; // p holds the address of value
std::cout << "value : " << value << '\n';
std::cout << "&value : " << &value << '\n'; // the address itself
std::cout << "p : " << p << '\n'; // same address
std::cout << "*p : " << *p << '\n'; // 42 - dereferenced
*p = 99; // writing through the pointer
std::cout << "value now: " << value << '\n'; // 99
}
Two style points worth settling early. Write int* p rather than int *p — the * is part of the type, and that reading matches how you will think about const later. And declare one pointer per line: int* a, b; declares a pointer and an int, which is one of the oldest traps in the language.
nullptr, Not NULL
A pointer that points at nothing should be nullptr, introduced in C++11. The older NULL macro is typically defined as 0, which creates genuine ambiguity:
void f(int x) { std::cout << " f(int) called with " << x << '\n'; }
void f(char* p) { std::cout << " f(char*) called with " << (p ? "non-null" : "null") << '\n'; }
f(0); // picks f(int) - probably not what you meant
f(nullptr); // picks f(char*) - unambiguous
// f(NULL); // does not compile
f(int) called with 0
f(char*) called with null
That commented-out line is not a stylistic objection. It is a compile error:
error: call of overloaded 'f(NULL)' is ambiguous
note: candidate: 'void f(int)'
note: candidate: 'void f(char*)'
nullptr has its own type (std::nullptr_t) which converts to any pointer type and to nothing else. Use it everywhere; there is no case where NULL or 0 is better.
Pointer Arithmetic
Adding 1 to a pointer does not add 1 byte. It advances by one element — the size of the pointed-to type:
Pointer arithmetic scales by the SIZE OF THE TYPE, not by 1:
sizeof(int)=4 p+1 advances 4 bytes
sizeof(double)=8 p+1 advances 8 bytes
sizeof(Big)=24 p+1 advances 24 bytes
*p=10 *(p+2)=30 p[2]=30 (p[2] is defined AS *(p+2))
ints+4 - ints = 4 elements, not bytes
That last line is the key insight: p[2] is defined as *(p + 2). Array subscripting is pointer arithmetic — which is why arr[i] and i[arr] are both legal C++ and mean the same thing (though please never write the second).
Subtracting two pointers gives the number of elements between them, not bytes, and the result type is std::ptrdiff_t.
Note also that all pointers are the same size regardless of what they point at:
sizeof(int*)=8 sizeof(double*)=8 sizeof(Big*)=8
A pointer holds an address; addresses are a fixed width on a given platform. This is covered further in the guide to data types in C, where the same figure changes between 32-bit and 64-bit builds.
Pointer arithmetic is only defined within a single array (plus one past the end). Computing a pointer beyond that range is undefined behaviour even if you never dereference it.
const and Pointers: The Four Combinations
This is where most C++ learners stall, and the confusion is entirely about what the const binds to.
int a = 10, b = 20;
int* p1 = &a; // neither const
const int* p2 = &a; // pointee is const
int* const p3 = &a; // pointer is const
const int* const p4 = &a; // both const
*p1 = 99; p1 = &b; // both allowed
/* *p2 = 99; */ p2 = &b; // cannot change value, can repoint
*p3 = 99; /* p3 = &b; */ // can change value, cannot repoint
/* *p4 = 99; p4 = &b; */ // neither
Uncommenting each forbidden line produces a distinct compiler error — these are the real messages, not paraphrases:
| Attempt | Compiler error |
|---|---|
*p2 = 99; | assignment of read-only location * p2 |
p3 = &b; | assignment of read-only variable p3 |
*p4 = 99; | assignment of read-only location *(const int*)p4 |
p4 = &b; | assignment of read-only variable p4 |
The reading rule: const binds to whatever is on its left, unless there is nothing on its left, in which case it binds right. So int* const p is “p is a const pointer to int”, and const int* p is “p is a pointer to const int”. Read declarations right to left and they stop being ambiguous.
In practice, const int* — pointer to const — is what you want for function parameters that only read. It documents intent and lets the compiler catch accidental writes.
References vs Pointers
A reference is an alias for an existing object. It looks like a pointer’s simpler cousin, and choosing between them is a question people ask constantly:
| Property | Pointer | Reference |
|---|---|---|
| Can be null | Yes | No — must bind to an object |
| Can be reassigned | Yes | No — binds once, permanently |
| Needs dereferencing | Yes (*p) | No — used like the object itself |
| Pointer arithmetic | Yes | No |
| Can point to nothing meaningful | Yes | No |
int x = 10, y = 20;
int* p = &x; p = &y; // fine - p now refers to y
int& r = x; r = y; // does NOT rebind - this ASSIGNS y's value to x
That second line catches people: r = y does not make r refer to y. It copies y into x, because r is x.
Choose a reference when the thing must exist and will never change, which is most function parameters. Choose a pointer when it might legitimately be absent (use nullptr) or when it must be redirected. A function taking const std::string& cannot be handed “nothing”; one taking const std::string* can.
Dangling Pointers and Use-After-Free
delete frees the memory. It does not change the pointer:
before delete: 42
pointer value after delete is unchanged: non-null
after p = nullptr: null
guarded dereference skipped safely
After delete p, the variable p still holds the old address. Testing if (p) passes. Dereferencing it is undefined behaviour, and AddressSanitizer confirms exactly what it is:
ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
freed by thread T0 here:
The immediate fix is p = nullptr; after every delete. The real fix is not to write delete at all.
Smart Pointers: The Modern Answer
C++11 introduced pointer types that own what they point at and destroy it automatically. The measured cost of that safety:
sizeof(raw pointer) = 8 bytes
sizeof(unique_ptr) = 8 bytes (no overhead)
sizeof(shared_ptr) = 16 bytes (pointer + control block ptr)
std::unique_ptr is exactly the same size as a raw pointer. There is no space penalty and, with optimisation on, no meaningful time penalty either. The “smart pointers are heavyweight” objection is measurably wrong for the type you should reach for first.
unique_ptr — one owner
{
auto u = std::make_unique<Res>(1);
} // destructor runs here, automatically
acquired 1
released 1
No delete. No leak on an early return. No leak if an exception is thrown. The resource is released when u goes out of scope, whatever route execution takes to get there.
shared_ptr — shared ownership, reference counted
acquired 2
use_count after 1 owner : 1
use_count after 2 owners: 2
use_count after inner scope ends: 1
released 2
The object is destroyed when the last shared_ptr to it goes away. That costs 16 bytes and an atomic counter — real overhead, worth paying only when ownership genuinely is shared.
And the raw pointer, for comparison
acquired 3
(nothing released - that is the leak)
Same scope, same object, no destructor call. That is a leak, and nothing in the language will tell you about it.
A tool will, though. Compiling the raw-pointer version with AddressSanitizer and giving the function an early return between the new and the delete:
==584==ERROR: LeakSanitizer: detected memory leaks
SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s).
The unique_ptr version, with identical control flow — same early return, same object — produces no sanitizer output at all:
Widget 1 built
Widget 1 destroyed
That is the argument in two blocks of output: the leak is not hypothetical, it is not caught by the compiler, and the fix required deleting a line rather than adding one. Build with -fsanitize=address during development and this class of bug stops being invisible.
The decision rule:
| Situation | Use |
|---|---|
| Single owner (the default) | std::unique_ptr |
| Genuinely shared ownership | std::shared_ptr |
| Observing without owning | raw pointer, or std::weak_ptr to break cycles |
| Passing to a function that only reads | reference, or raw pointer if it may be null |
| An array that grows | std::vector, not a pointer at all |
The full treatment of ownership semantics, custom deleters and weak_ptr cycles is in the dedicated guide to smart pointers in modern C++.
Pointers to Pointers
A pointer variable has an address of its own, so you can point at it:
int value = 42;
int* p = &value;
int** pp = &p; // pp points to p, which points to value
std::cout << **pp; // 42 - two dereferences
The common real use is a function that needs to modify the caller’s pointer, not just the pointed-to value — the classic C idiom for allocation functions. In C++ this is usually better expressed by returning a unique_ptr or taking a reference-to-pointer (int*&). The C-side treatment is covered in double pointer to pointer in C.
Common Pitfalls
- Uninitialised pointers.
int* p;holds garbage, not null. Always initialise — tonullptrat minimum. int* a, b;declares one pointer and one int. One declaration per line avoids it entirely.- Using
NULLor0. Ambiguous in overload resolution, as demonstrated above. Usenullptr. - Forgetting the pointer survives
delete. Set it tonullptr, or use a smart pointer and never face the question. - Mismatching
new/deleteforms.new[]requiresdelete[]. Using the wrong one is undefined behaviour — another problem smart pointers andstd::vectorremove. - Returning a pointer to a local. The object dies at the end of the function; the pointer outlives it.
- Assuming pointer arithmetic works in bytes. It works in elements. Cast to
char*if you genuinely need byte offsets. - Reaching for a pointer when a container will do.
std::vectorhandles dynamic arrays better than any hand-managed pointer, as covered in the std::vector guide.
Key Takeaways
unique_ptris 8 bytes — identical to a raw pointer — so the safety is free.shared_ptris 16 and adds an atomic counter, worth paying only for genuinely shared ownership.nullptris not a style preference.f(NULL)with overloads onintandchar*is a compile error;nullptrresolves unambiguously.constbinds left unless nothing is on its left.int* constis a const pointer;const int*is a pointer to const. Each of the four combinations produces a distinct compiler error when violated.- Pointer arithmetic scales by type size:
p+1advanced 4, 8 and 24 bytes forint,doubleand a 24-byte struct.p[2]is defined as*(p+2). deletedoes not null the pointer. The dangling pointer still tests non-null; AddressSanitizer reports the dereference as heap-use-after-free.- References cannot be null or rebound. Use them when the object must exist; use pointers when absence or redirection is meaningful.
Frequently Asked Questions
Conclusion
Pointers have a reputation as the hard part of C++, and that reputation is slightly misplaced. The mechanics — address-of, dereference, arithmetic — are a couple of hours of work. What is genuinely hard is ownership: knowing who is responsible for destroying an object, and making sure that responsibility is discharged exactly once along every path execution might take.
Raw pointers cannot express that. A function returning Widget* tells you nothing about whether you must delete it, and no amount of care in the caller compensates for a contract the type system does not enforce. That is the problem unique_ptr and shared_ptr solve, and — as the measurements above show — they solve it for free in the common case. Write raw pointers to observe; use smart pointers to own; reach for std::vector before you reach for new[]. The rest of the C++ section covers the containers and idioms that make manual memory management rare in modern code.


