Skip to content
C++
Language
Basic

Pointers

Variables that store memory addresses, enabling indirection, dynamic allocation, and polymorphism across all C++ versions.

Pointersince C++98

A 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

cpp
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 array

The asterisk binds to the declarator, not the base type:

cpp
int* p, q;    // p is int*, q is plain int β€” common declaration trap
int *p, *q;   // both int* β€” explicit form avoids ambiguity

const placement and pointer types

Where const appears relative to * determines what cannot be modified. Reading the declaration right-to-left gives the correct reading:

cpp
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 const

Examples

Pointer arithmetic over a range

cpp
#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

cpp
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:

cpp
#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)

cpp
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 int

std::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:

cpp
#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:

cpp
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:

cpp
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);  // UB

Reserve 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):

cpp
#include <bit>  // C++20

float f = 1.0f;
uint32_t bits = std::bit_cast<uint32_t>(f);  // C++20 β€” defined, no UB

Signed 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:

cpp
#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:

cpp
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 this

ptrdiff_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:

cpp
#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_t

The 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::*

cpp
#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 *:

cpp
#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:

cpp
#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.

cpp
// 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* and const char*
  • unsigned char* and const unsigned char*
  • std::byte* and const 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:

cpp
#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.156250

Before C++20, use std::memcpy into a local variable of the target type; the compiler generates identical code and the standard explicitly permits this:

cpp
#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.

cpp
#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) where T* must be the original type or a compatible base. Round-tripping through a different concrete type is UB.
  • Cannot dereference: *vp is a compile error β€” the compiler does not know the object's size or alignment.
  • Cannot do arithmetic: vp + 1 is ill-formed in standard C++ (GCC accepts it as an extension, treating sizeof(void) as 1 β€” do not rely on this).
  • C API interop: malloc, memset, Win32 HANDLE-family APIs, and many plugin/callback interfaces return or accept void*. 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:

cpp
#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:

cpp
#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 reads

Before 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:

cpp
#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:

cpp
#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