Pointers
Variables that store memory addresses, enabling indirection, dynamic allocation, and polymorphism across all C++ versions.
Pointersince C++98A pointer is a scalar object that stores the address of another object or function in memory, providing a level of indirection that enables dynamic allocation, polymorphism, and efficient data passing without copying.
Overview
Every pointer value is in exactly one of four states:
- Points to an object or function β valid for read/write (respecting cv-qualifiers)
- Points past the end of an object β valid for comparison and arithmetic, illegal to dereference
- Null pointer value β indicates "no object"; compares equal to all null pointers of the same type
- Invalid (indeterminate) β uninitialized or dangling; any use is undefined behavior
Dereferencing anything but "points to an object or function" is undefined behavior. This is the root cause of the majority of hard-to-reproduce memory-safety bugs in C++ programs.
Null pointer representation before and after C++11
Prior to C++11, null pointers were expressed with the integer literal 0 or the macro NULL (from <cstddef>). Both carry ambiguity: NULL expands to 0 or (void*)0 depending on the implementation, and either form can silently match an integer overload instead of a pointer overload during overload resolution.
C++11 introduced nullptr, a keyword of type std::nullptr_t. It converts implicitly to any pointer type but never to an integer. Use nullptr exclusively in all code targeting C++11 and later.
Syntax
T* ptr; // pointer to T β uninitialized, indeterminate value
T* ptr = nullptr; // null pointer (C++11)
T* ptr = &obj; // pointer to obj
*ptr // dereference β yields an lvalue of type T
ptr->member // member access through pointer (equivalent to (*ptr).member)
ptr + n // advances ptr by n objects (n * sizeof(T) bytes)
ptr2 - ptr1 // signed distance in elements (ptrdiff_t), only within same arrayThe asterisk binds to the declarator, not the base type:
int* p, q; // p is int*, q is plain int β common declaration trap
int *p, *q; // both int* β explicit form avoids ambiguityconst placement and pointer types
Where const appears relative to * determines what cannot be modified. Reading the declaration right-to-left gives the correct reading:
const int* p; // pointer to const int β *p is read-only, p itself can change
int* const p; // const pointer to int β p cannot be reseated, *p can change
const int* const p; // both pointer and pointee are constExamples
Pointer arithmetic over a range
#include <cstddef>
#include <cstdio>
int sum(const int* data, std::size_t n) {
int total = 0;
for (const int* end = data + n; data != end; ++data)
total += *data;
return total;
}
int main() {
int arr[] = {1, 2, 3, 4, 5};
printf("%d\n", sum(arr, 5)); // 15
}Array names decay to a pointer to their first element in most expression contexts (array-to-pointer conversion). The pointer data + n is a valid past-the-end pointer β legal to compute and compare against, illegal to dereference.
Runtime polymorphism through base-class pointers
struct Shape {
virtual double area() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
double r;
explicit Circle(double radius) : r(radius) {}
double area() const override { return 3.14159265 * r * r; }
};
struct Rect : Shape {
double w, h;
Rect(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
};
void print_area(const Shape* s) {
printf("area = %.4f\n", s->area());
}Virtual dispatch requires that the pointer points to a complete, live object. Calling a virtual function through a null or dangling pointer is undefined behavior β the vtable lookup itself is just an indirect load, but the compiler is entitled to assume valid state.
void* and placement new
void* is the generic pointer type. Any non-function, non-member pointer converts implicitly to void*. It cannot be dereferenced or used in arithmetic without a cast. It underpins malloc/free and placement new:
#include <cstdlib>
#include <new>
struct Expensive { int data[64]; };
void* raw = std::malloc(sizeof(Expensive));
Expensive* obj = ::new (raw) Expensive{}; // placement new (C++98)
obj->~Expensive(); // explicit destructor required
std::free(raw);Overload resolution with nullptr vs 0 (C++11)
void f(int n) { printf("int %d\n", n); }
void f(char* p) { printf("char* %p\n", static_cast<void*>(p)); }
f(0); // calls f(int) β 0 is an integer
f(NULL); // calls f(int) β NULL is typically 0, implementation-defined
f(nullptr); // calls f(char*) β C++11: nullptr_t converts to char*, not intstd::to_address for generic pointer-like types (C++20)
In templates, avoid assuming every pointer-like type can be directly compared with %p. std::to_address (C++20) extracts the raw pointer from any contiguous iterator or fancy pointer without dereferencing:
#include <memory> // std::to_address β C++20
template<typename Ptr>
void log_address(Ptr p) {
printf("raw address: %p\n",
static_cast<const void*>(std::to_address(p))); // C++20
}Best Practices
Reserve raw pointers for non-owning handles. A raw pointer should communicate "I observe this object but don't own it." For owned heap resources, use std::unique_ptr (C++11) or std::shared_ptr (C++11). This eliminates entire classes of leaks and double-frees without runtime cost for unique_ptr.
Initialize every pointer at the point of declaration. An uninitialized pointer contains a garbage address that silently passes null checks. Declare with = nullptr or = &obj; never leave the initial value implicit.
Prefer references when nullptr is not a valid state. References cannot be null and cannot be reseated. Using a reference in a function signature communicates "this parameter is always a valid object," which is a stronger contract and eliminates null checks inside the callee.
Keep pointer arithmetic strictly within array bounds. Arithmetic is only defined within the same array object (including the one-past-the-end position) and for a single object treated as a 1-element array. Going outside these bounds is UB regardless of memory mapping.
Common Pitfalls
Dangling pointers
A pointer becomes dangling when its target is destroyed β stack unwind, explicit delete, or end-of-scope. Accessing a dangling pointer is UB:
int* get_local() {
int x = 42;
return &x; // x is destroyed on return; caller receives a dangling pointer
}More subtle variants arise when a raw pointer into a std::vector survives a reallocation:
std::vector<int> v = {1, 2, 3};
int* p = v.data();
v.push_back(4); // may reallocate β p is now dangling
printf("%d\n", *p); // UBReserve raw pointers into containers for spans of code where no insertion or erasure can occur.
Type punning through reinterpret casts
Casting between unrelated pointer types and dereferencing violates the strict aliasing rule. The standard permits aliasing only through char*, unsigned char*, and (since C++17) std::byte*. For type-safe bit-reinterpretation use std::memcpy or, preferably, std::bit_cast (C++20):
#include <bit> // C++20
float f = 1.0f;
uint32_t bits = std::bit_cast<uint32_t>(f); // C++20 β defined, no UBSigned overflow in pointer arithmetic
Adding a signed integer that causes the pointer to step outside its array's extent is UB even if the resulting address is readable. Optimizers exploit this assumption aggressively: out-of-bounds arithmetic can cause the compiler to eliminate adjacent safety checks.
Pointer Arithmetic in Depth
The rule for pointer arithmetic is narrower than most programmers realize: arithmetic is only defined within a single array object β or, by special dispensation, for a single non-array object treated as a one-element array.
The one-past-the-end sentinel
ptr + n is valid when ptr points into an array of N elements and 0 <= n <= N. The value ptr + N (one past the last element) is a valid pointer value that may be computed and compared, but never dereferenced. This is how range loops and standard algorithms express their sentinel:
#include <cstddef>
void fill_zero(int* data, std::size_t n) {
for (int* end = data + n; data != end; ++data)
*data = 0;
// data == end is a valid comparison; *end is UB
}Cross-array subtraction is undefined behavior
Subtracting pointers that belong to different arrays β even if they happen to be adjacent in memory β is undefined behavior. The compiler may assume the two pointers share a common base; violating this assumption lets the optimizer produce completely wrong results:
int a[4];
int b[4];
// UB: a and b are separate array objects
std::ptrdiff_t gap = b - a; // undefined behavior β do not do thisptrdiff_t is the correct difference type
Pointer subtraction yields std::ptrdiff_t, a signed integer defined in <cstddef>. Using std::size_t (unsigned) for a pointer difference is a common mistake β if the result is negative the wrap-around produces a huge positive number:
#include <cstddef>
#include <cstdio>
int arr[] = {10, 20, 30, 40, 50};
int* first = arr;
int* last = arr + 5;
std::ptrdiff_t count = last - first; // 5 β signed, correct
// std::size_t bad = last - first; // converts to size_t β wrong if negative
printf("count: %td\n", count); // %td for ptrdiff_tThe range of ptrdiff_t need not span all of SIZE_MAX: the standard only guarantees it can represent differences within a single object. On most 64-bit platforms both are 64 bits, but objects larger than PTRDIFF_MAX bytes formally produce UB on subtraction. In practice, system limits keep objects below that threshold, but allocator code written for correctness caps allocations at PTRDIFF_MAX.
Pointers to Members
C++ has two pointer-to-member types that are entirely separate from regular pointers. They encode an offset or slot inside a class rather than an absolute address, so they cannot be stored in a plain void* or uintptr_t.
Pointer to data member: T C::*
#include <cstdio>
struct Point {
double x;
double y;
};
int main() {
double Point::* px = &Point::x; // pointer to data member
double Point::* py = &Point::y;
Point p{3.0, 4.0};
Point* pp = &p;
printf("x = %.1f\n", p.*px); // .* operator: lvalue of type double
printf("y = %.1f\n", pp->*py); // ->* operator through pointer
}p.*px is an lvalue that refers to p.x; assigning p.*px = 9.0 modifies p.x. Pointer-to-data-member values are not addresses β they are implementation-defined offsets, often equal to offsetof but not required to be.
Pointer to member function: R (C::*)(Args...)
The syntax mirrors function pointer syntax with the class qualifier inserted before the *:
#include <cstdio>
#include <cstring>
struct Buffer {
char data[64]{};
int size = 0;
void clear() { std::memset(data, 0, sizeof(data)); size = 0; }
int capacity() const { return static_cast<int>(sizeof(data)); }
};
int main() {
void (Buffer::* pmf_clear)() = &Buffer::clear;
int (Buffer::* pmf_cap)() const = &Buffer::capacity;
Buffer buf;
(buf.*pmf_clear)(); // invoke through object β note extra parentheses
int cap = (buf.*pmf_cap)(); // const member function
printf("cap = %d\n", cap);
Buffer* pbuf = &buf;
(pbuf->*pmf_clear)(); // invoke through pointer
}The extra parentheses around (buf.*pmf_clear) are mandatory: .* and ->* have lower precedence than (), so without them the compiler parses buf.*pmf_clear() as buf.*(pmf_clear()) β which is a type error.
std::invoke as the ergonomic alternative (C++17)
Manually writing .* and ->* is verbose and error-prone. std::invoke (C++17, <functional>) handles regular functions, function pointers, lambdas, and pointer-to-member uniformly:
#include <functional>
#include <cstdio>
struct Calc {
int multiply(int x, int factor) const { return x * factor; }
int base = 10;
};
int main() {
Calc c;
// Pointer-to-member-function via std::invoke
int r1 = std::invoke(&Calc::multiply, c, 5, 3); // 15 β passes c by value
int r2 = std::invoke(&Calc::multiply, &c, 5, 3); // 15 β passes through pointer
// Pointer-to-data-member via std::invoke
int b = std::invoke(&Calc::base, c); // 10
printf("%d %d %d\n", r1, r2, b);
}std::invoke is the building block of std::function, std::bind, range algorithms taking projections, and any generic code that needs to accept arbitrary callables including member pointers.
Strict Aliasing and Type Punning
The strict aliasing rule
The C++ standard ([basic.lval]) says: an object may only be accessed through a compatible glvalue. Two types are compatible if one is the dynamic type of the object, a signed/unsigned variant of it, a base class of it, or char, unsigned char, or (since C++17) std::byte. Accessing an object through a pointer to any other type is undefined behavior, regardless of whether the addresses overlap.
// UNDEFINED BEHAVIOR β strict aliasing violation
float f = 1.0f;
int* ip = reinterpret_cast<int*>(&f);
int bits = *ip; // UB: float object accessed through int*This is not a theoretical concern. Clang and GCC exploit the assumption in their optimizers: a load through int* and a store through float* may be reordered or one may be eliminated because the compiler proves they cannot alias β even if they literally refer to the same address.
The three exemptions
The standard carves out three types that may alias anything:
char*andconst char*unsigned char*andconst unsigned char*std::byte*andconst std::byte*(C++17,<cstddef>)
These are the only safe ways to inspect raw memory byte-by-byte without invoking UB.
std::bit_cast β the correct type pun (C++20)
std::bit_cast<To>(from) (C++20, <bit>) reinterprets the bit pattern of from as a value of type To. Both types must be the same size and trivially copyable. It produces no UB, is constexpr, and generates the same machine code as a union pun or memcpy with optimizations on:
#include <bit>
#include <cstdint>
#include <cstdio>
float f = -0.15625f;
uint32_t bits = std::bit_cast<uint32_t>(f); // C++20 β zero UB
printf("0x%08X\n", bits); // 0xBE200000
// Reverse: bit pattern back to float
float restored = std::bit_cast<float>(bits);
printf("%.6f\n", restored); // -0.156250Before C++20, use std::memcpy into a local variable of the target type; the compiler generates identical code and the standard explicitly permits this:
#include <cstring>
#include <cstdint>
float f = -0.15625f;
uint32_t bits;
std::memcpy(&bits, &f, sizeof(bits)); // safe since C++98-fno-strict-aliasing
GCC and Clang accept -fno-strict-aliasing to disable the optimization passes that exploit the strict aliasing rule. This makes naive reinterpret_cast-and-dereference code work in practice on those compilers, but it does not make the code conforming β the behavior is still undefined by the standard. Relying on this flag couples your codebase to a specific compiler, breaks under LTO in some configurations, and silently stops working if the flag is dropped. Prefer std::bit_cast.
void* in C++
void* is the universal object pointer: any non-function, non-member pointer converts to it implicitly, but the result cannot be directly used for anything β no dereference, no arithmetic, no member access. You must cast back to the original concrete type before the pointer is useful.
#include <cstdlib>
#include <cstdio>
struct Widget { int value; };
void* allocate(std::size_t bytes) { return std::malloc(bytes); }
int main() {
void* raw = allocate(sizeof(Widget)); // implicit: any T* -> void*
// Must cast explicitly to use:
Widget* w = static_cast<Widget*>(raw); // static_cast required
::new (w) Widget{42}; // placement new
printf("%d\n", w->value);
w->~Widget();
std::free(raw);
}Rules for void*
- Implicit conversion from any object pointer type (non-member, non-function): no cast needed.
- Explicit cast required to use:
static_cast<T*>(vp)whereT*must be the original type or a compatible base. Round-tripping through a different concrete type is UB. - Cannot dereference:
*vpis a compile error β the compiler does not know the object's size or alignment. - Cannot do arithmetic:
vp + 1is ill-formed in standard C++ (GCC accepts it as an extension, treatingsizeof(void)as 1 β do not rely on this). - C API interop:
malloc,memset, Win32HANDLE-family APIs, and many plugin/callback interfaces return or acceptvoid*. This is the primary legitimate use in modern C++.
std::any as the type-safe alternative
When the goal is storing a value of arbitrary type without knowing it at compile time, std::any (C++17, <any>) provides a type-safe container with runtime type checking:
#include <any>
#include <string>
#include <cstdio>
std::any v = 42;
v = std::string("hello");
// Safe extraction:
if (auto* sp = std::any_cast<std::string>(&v))
printf("string: %s\n", sp->c_str());
// Throws std::bad_any_cast if type doesn't match:
try {
int n = std::any_cast<int>(v); // throws β v holds string now
} catch (const std::bad_any_cast&) {
printf("wrong type\n");
}std::any is the idiomatic C++ replacement for void* in contexts where the type varies at runtime.
Pointer Provenance and std::start_lifetime_as (C++23)
What pointer provenance means
Every pointer in C++ has provenance β a binding to the specific object it was derived from. Two pointers that compare equal with == can have different provenance and the optimizer is entitled to treat them differently. This is the deeper model underlying strict aliasing: the optimizer does not just track types, it tracks which allocation each pointer belongs to.
The practical consequence: obtaining a pointer by casting an integer address (via reinterpret_cast<T*>(addr)) and then dereferencing it is undefined behavior for non-implicit-lifetime types, even if the address is valid memory and the type sizes match. -fno-strict-aliasing suppresses the aliasing-based transformations but does not repair provenance; conforming code must not rely on it.
Implicit-lifetime types and std::start_lifetime_as (C++23)
C++23 introduced the concept of implicit-lifetime types: scalar types, aggregate types whose members are all implicit-lifetime, and arrays thereof. For these types, C++23 specifies that their lifetime can begin implicitly when suitable storage is provided β for example, by malloc.
std::start_lifetime_as<T>(ptr) (C++23, <memory>) makes this explicit and portable: it returns a T* with the correct provenance pointing into the storage ptr refers to, starting the lifetime of a T object there, without requiring a constructor call:
#include <memory>
#include <cstdlib>
#include <cstdint>
struct Header {
uint32_t magic;
uint32_t version;
uint32_t payload_size;
};
static_assert(std::is_trivially_copyable_v<Header>);
void* buf = std::malloc(256);
// Fill buffer from I/O (e.g., read from socket)...
// C++23: explicitly start the lifetime of Header in buf
Header* hdr = std::start_lifetime_as<Header>(buf);
// hdr->magic etc. are now well-defined readsBefore C++23, the correct portable approach for reading structured binary data was std::memcpy into a local Header object. std::start_lifetime_as enables zero-copy parsing of implicit-lifetime types without UB.
std::to_address (C++20)
std::to_address<T>(p) (C++20, <memory>) converts any pointer-like type β raw pointer, std::unique_ptr, std::shared_ptr, or a contiguous iterator β to a raw T* without dereferencing the pointer. This matters in two situations:
Generic allocator-aware code must work with fancy pointers (pointer types that wrap a raw address, used by pmr allocators and custom containers). Such types support operator* and operator-> but may not support operator& or direct cast to T*. std::to_address is the standard way to extract the underlying raw address:
#include <memory>
#include <cstdio>
template<typename Allocator>
void debug_print_first(Allocator& alloc, std::size_t n) {
using Traits = std::allocator_traits<Allocator>;
using Ptr = typename Traits::pointer; // may be a fancy pointer
Ptr p = Traits::allocate(alloc, n);
// std::to_address works for both raw T* and fancy pointers:
auto* raw = std::to_address(p);
printf("allocation at: %p\n", static_cast<const void*>(raw));
Traits::deallocate(alloc, p, n);
}unique_ptr and iterators can also be passed to std::to_address. For std::unique_ptr<T>, calling std::to_address(up) is equivalent to up.get() but works uniformly in templates that must also handle raw pointers:
#include <memory>
#include <cstdio>
void log_ptr(const int* p) {
printf("address: %p\n", static_cast<const void*>(p));
}
template<typename P>
void generic_log(P&& p) {
log_ptr(std::to_address(p)); // works for int*, unique_ptr<int>, pmr fancy pointers
}
int main() {
int x = 99;
auto up = std::make_unique<int>(x);
generic_log(&x); // raw pointer
generic_log(up); // unique_ptr
}std::to_address does not dereference its argument, which is critical for iterators that point to the one-past-the-end position β dereferencing such an iterator is UB, but calling std::to_address on it is safe because the implementation uses std::pointer_traits<P>::to_address(p) or operator->() without a dereference.
See Also
- Function Pointers β storing, comparing, and invoking functions through a pointer
- Abstract Classes β runtime polymorphism via base-class pointer dispatch
autoType Deduction βauto*and pointer type deduction in templates and range-for