Skip to content

Pointer Arithmetic, Array Decay, and Nullability

Section titled “Pointer Arithmetic, Array Decay, and Nullability”

In high-level languages (Java, Python), references are opaque handles. In C++, pointers are direct Abstractions over virtual memory addresses with distinct arithmetic capabilities dependent on the Type System.

This module analyzes how the compiler translates pointer operations into memory offsets, the legacy Mechanism of array decay, and the type-safe definition of nullability.

Pointer arithmetic is not integer arithmetic. It is Typed Offset Calculation. Adding 1 to a Pointer does not increment the address by one byte; it increments the address by the stride of The underlying type.

Given a pointer T* ptrThe operation ptr + N resolves to:

\mathrm{Address_{new} = \mathrm{Address_{old} + (N \times \mathrm{sizeof(T))
  1. Instruction Generation: The compiler compiles ptr[i] or *(ptr + i) into specific assembly instructions like LEA (Load Effective Address) on x86_64, utilizing hardware scaling factors (1, 2, 4, 8) if the type size matches.
  2. Void Pointer Restriction: Arithmetic on void* is ill-formed in ISO C++ because sizeof(void) is undefined.
  • GCC/Clang Extension: These compilers treat sizeof(void) as 1 to allow arithmetic.
  • Best Practice: Disable this extension via -Wpointer-arith to ensure portability.
  1. Difference (ptrdiff_t): Subtracting two pointers (ptrB - ptrA) returns a signed integer of type std::ptrdiff_tRepresenting the number of elements (not bytes) between them.
struct Block {
uint64_t header;
uint64_t payload;
}; // sizeof(Block) == 16
void arithmetic_demo() {
Block* base = reinterpret_cast<Block*>(0x1000);
// 0x1000 + (1 * 16) = 0x1010
Block* next = base + 1;
// (0x1010 - 0x1000) / 16 = 1
std::ptrdiff_t diff = next - base;
}
#include <cstddef>
#include <cstdint>
#include <iostream>
void byte_offset_examples() {
// char is 1 byte
char chars[5] = {'a', 'b', 'c', 'd', 'e'};
char* pc = chars;
// pc + 3 points to 'd' at address base + 3
// int is 4 bytes (typical)
int ints[5] = {10, 20, 30, 40, 50};
int* pi = ints;
// pi + 3 points to 40 at address base + 12 (3 * sizeof(int) = 12)
// double is 8 bytes
double doubles[4] = {1.0, 2.0, 3.0, 4.0};
double* pd = doubles;
// pd + 2 points to 3.0 at address base + 16 (2 * sizeof(double) = 16)
// Pointer difference returns element count, not byte count
std::ptrdiff_t diff = (pi + 3) - pi; // 3 (elements), not 12 (bytes)
std::cout << "diff: " << diff << "\n"; // 3
}

Because arr[i] is defined as *(arr + i)And addition is commutative, C++ technically supports The syntax i[arr].

  • arr + i \to Address of i-th element.
  • i + arr \to Address of i-th element.
  • Conclusion: Never use i[arr] in production code, but understand it to grasp the underlying arithmetic mechanics.

Pointer arithmetic is only well-defined within an array object and one past the end. Going beyond One-past-the-end or before the beginning of the array is undefined behavior, even if the pointer is Not dereferenced:

int arr[5] = {};
int* p = arr + 5; // OK: one past the end (cannot dereference)
int* q = arr + 6; // UB: beyond one past the end
int* r = arr - 1; // UB: before the beginning

Formal Proof of One-Past-The-End [N4950 §7.6.6.6]

Section titled “Formal Proof of One-Past-The-End [N4950 §7.6.6.6]”

Claim: A pointer to one past the last element of an array is well-defined, but dereferencing it Is undefined behavior.

Proof:

  1. By [N4950 §7.6.6.6], if an expression e points to element x[i]x[i] of an array object x with nn elements, the expressions e + j and e - j point to (respectively) element x[i+j]x[i + j] and x[ij]x[i - j]Provided 0i+jn0 \le i + j \le n.

  2. When i+j=ni + j = nThe pointer points “one past the last element.” The Standard explicitly permits this pointer value. The constraint 0i+jn0 \le i + j \le n (note the inclusive upper bound) is the formal statement of the one-past-the-end rule.

  3. By [N4950 §7.6.1.1], the indirection operator * requires that its operand be a pointer to an object. The one-past-the-end pointer does not point to any object — it points to the position immediately after the last element.

  4. Therefore, *(arr + n) is undefined behavior. QED.

Claim: Computing arr + 6 (two past the end) is undefined behavior even without dereferencing.

Proof:

  1. By [N4950 §7.6.6.6], pointer addition is well-defined only when the result points to an element of the array or one past the last element.
  2. arr + 6 points to two past the last element of int arr[5]Which violates the constraint i+jni + j \le n (since 0+6=6>50 + 6 = 6 \gt 5).
  3. The Standard states that computing such a pointer value is undefined behavior. QED.

This means you cannot use pointer arithmetic as a general address computation mechanism. Even Forming an out-of-bounds pointer (without dereferencing it) is UB.

Subtracting two pointers is only well-defined when both pointers point into the same array object [N4950 §7.6.6.6]:

int a[5];
int b[5];
std::ptrdiff_t diff = &b[3] - &a[1]; // UB: different array objects
std::ptrdiff_t ok = &a[3] - &a[1]; // OK: same array, result is 2

C++ defines several distinct categories of pointers, each with different semantics and restrictions [N4950 §7.3.6]:

The most common category. Pointers to objects of type T:

int* pi; // pointer to int
const int* pci; // pointer to const int
int* const cpi; // const pointer to int (the pointer itself is const)
const int* const cpci; // const pointer to const int

Pointers to functions. The syntax requires parentheses around the pointer name:

void (*func_ptr)(int); // pointer to function taking int, returning void
int (*math_op)(int, int); // pointer to function taking two ints, returning int
int add(int a, int b) { return a + b; }
func_ptr = nullptr;
math_op = add; // function name decays to function pointer
int result = math_op(3, 4); // 7

Pointers to members are distinct from regular pointers. A pointer-to-member is an offset within a Class layout, not a memory address. It cannot be dereferenced without an object instance [N4950 §7.6.5]:

struct Widget {
int x;
double y;
void print() const { /* ... */ }
};
int Widget::* pm = &Widget::x; // pointer-to-data-member
void (Widget::* pmf)() const = &Widget::print; // pointer-to-member-function
Widget w{42, 3.14};
// Dereferencing requires an object:
int val = w.*pm; // val = 42
(w.*pmf)(); // calls w.print()
Widget* ptr = &w;
int val2 = ptr->*pm; // val2 = 42
(ptr->*pmf)(); // calls ptr->print()

A pointer-to-data-member is implemented as a byte offset from the base of the object. For Single-inheritance layouts, this is a simple integer. For multiple inheritance, the offset may need Adjustment when the object pointer is cast to a base class:

struct Base {
int a;
};
struct Derived : Base {
int b;
};
int Derived::* pb = &Derived::b;
// pb is typically the offset of 'b' within Derived, which is sizeof(int) = 4
Derived d{1, 2};
int val = d.*pb; // accesses d.b

For virtual inheritance, the implementation is more complex — the offset may be stored as a pair (virtual base offset + member offset) and resolved at runtime.

void* is a generic pointer type that can hold the address of any object. It cannot be dereferenced Or used for arithmetic (without a cast) because the type information needed to determine the size And alignment of the pointed-to object is absent [N4950 §7.3.7]:

int x = 42;
void* vp = &x; // OK: any object pointer converts to void*
// *vp; // ERROR: cannot dereference void*
int* pi = static_cast<int*>(vp); // OK: explicit cast restores type
std::cout << *pi; // 42

Pointers to incomplete types (forward-declared structs/classes) are valid. You can declare and pass Them, but you cannot dereference them or perform pointer arithmetic until the type is complete:

struct Forward; // incomplete type
Forward* fp = nullptr; // OK: pointer to incomplete type
// Forward f; // ERROR: incomplete type — cannot create object
// *fp; // ERROR: incomplete type — cannot dereference
void process(Forward* p); // OK: parameter declaration does not require complete type
TypeArithmeticDereferencesizeofImplicitly Convertible To
T* (object pointer)YesYesPlatform ptrvoid*``const void*
const T*YesYes (r/o)Platform ptrvoid*``const void*
void*NoNoPlatform ptr(none)
T(*)() (function ptr)NoYes (call)Platform ptrvoid(*)()
T C::* (member ptr)NoWith object2x ptr size(none — requires object)
nullptr_tNoNoPlatform ptrAny object/function pointer

Note: On most platforms, sizeof(T*) is 8 bytes (64-bit) or 4 bytes (32-bit). Pointer-to-member may Be larger due to the need for virtual base adjustment.

Array Decay is the implicit conversion of an array of type T[N] into a pointer of type T*. This is a legacy C behavior that causes the loss of bounds information (the value N).

Decay occurs whenever an array is passed by value to a function or assigned to a pointer variable.

void process_data(int* ptr) {
// Size information is LOST.
// sizeof(ptr) is 8 (on 64-bit systems), not 400.
}
void caller() {
int buffer[100]; // sizeof(buffer) is 400 bytes
process_data(buffer); // Implicit Decay: int[100] -> int*
}

Array decay does not occur in the following contexts [N4950 §7.3.3]:

  1. As the operand of sizeofsizeof(arr) yields the total array size.
  2. As the operand of decltypedecltype(arr) yields the array type.
  3. As the operand of alignofalignof(arr) yields the array alignment.
  4. When bound to a reference — void f(int (&arr)[N]) preserves the array type.
  5. When initializing a reference — int (&ref)[10] = arr.
int arr[5] = {1, 2, 3, 4, 5};
std::cout << sizeof(arr) << "\n"; // 20 (5 * sizeof(int)) — no decay
std::cout << sizeof(arr + 0) << "\n"; // 8 (pointer size) — decayed
decltype(arr) another = {6, 7, 8, 9, 10}; // another is int[5] — no decay

Multi-dimensional arrays decay only the first dimension. A T[M][N] decays to T(*)[N] — a pointer To an array of N elements. The inner dimension is preserved:

#include <iostream>
// The first dimension decays, but the second is preserved
void print_2d(int matrix[][3], int rows) {
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < 3; ++j) {
std::cout << matrix[i][j] << " ";
}
std::cout << "\n";
}
}
// Equivalent explicit syntax:
void print_2d_explicit(int (*matrix)[3], int rows) {
// matrix is a pointer to an array of 3 ints
// matrix[i] is the i-th row (an array of 3 ints)
}
int main() {
int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
print_2d(m, 2);
// Output:
// 1 2 3
// 4 5 6
}

Key insight: The compiler needs the inner dimension size to compute pointer arithmetic. When the First dimension decays, matrix + 1 must advance by sizeof(int) * 3 bytes (the inner array size), So the compiler must know N.

To preserve size information in C++23 architecture, use one of three strategies:

Accepting a reference to an array prevents decay and preserves dimensions.

template<size_t N>
void process_safe(int (&arr)[N]) {
// N is known at compile time.
// sizeof(arr) is correct.
}

std::array<T, N> is a zero-overhead wrapper around a C-style array. It follows value semantics (can be copied) and does not decay unless data() is explicitly called.

Introduced in C++20, std::span is the standard replacement for ptr + size interfaces. It Automatically constructs from C-arrays and std::array without decay logic losing the size.

#include <span>
void process_modern(std::span<int> s) {
// s.size() is 100
// s.data() is the pointer
}
void caller() {
int buffer[100];
process_modern(buffer); // Automatically deduces size
}

In C++, pointers are “Nullable References.”

Prior to C++11, the macro NULL was defined as integer 0. This caused ambiguity in overload Resolution.

void func(int x);
void func(char* p);
func(NULL); // Calls func(int) because NULL is 0.

nullptr is a keyword literal of type std::nullptr_t. It is implicitly convertible to any pointer Type, but not to integral types (except bool).

func(nullptr); // Calls func(char*) unambiguously.
LiteralTypeConvertible toRecommended?
0intAny integral typeNo
NULLImplementation-defined ( 0 or 0L)Pointers + integralNo
nullptrstd::nullptr_tAny pointer type, boolYes

nullptr is the only null pointer constant that cannot be confused with an integer. Modern C++ code Should always use nullptr.

nullptr and Overload Resolution [N4950 §12.4.3.2.2]

Section titled “nullptr and Overload Resolution [N4950 §12.4.3.2.2]”

nullptr has type std::nullptr_t. In overload resolution, the conversion from std::nullptr_t to Any pointer type ranks as an exact match (null pointer constant conversion), while the Conversion from std::nullptr_t to int would require a boolean conversion (rank: Conversion). This ensures nullptr always prefers pointer overloads:

void f(int);
void f(char*);
f(nullptr); // f(char*): nullptr_t → char* is exact match (null pointer constant)
// nullptr_t → int is boolean conversion (rank: Conversion)
f(0); // f(int): 0 is an int literal, exact match
f(NULL); // f(int): NULL is typically 0, which is an int literal

Dereferencing nullptr is Undefined Behavior. It is not guaranteed to segfault.

Compiler Optimization Risk: If the compiler can deduce that a dereference happens, it is allowed To assume the pointer is not null for the rest of the execution path, potentially optimizing out Subsequent null checks.

void dangerous(int* ptr) {
int x = *ptr; // If ptr is null, this is UB.
// Compiler Optimization:
// Since *ptr was accessed above, the compiler assumes ptr CANNOT be null.
// This check may be removed as dead code.
if (!ptr) {
return;
}
}

Architectural Best Practice: The Non-Owning Pointer

Section titled “Architectural Best Practice: The Non-Owning Pointer”

In modern C++23 design, raw pointers (T*) should only be used for:

  1. Nullable, Non-Owning Views: Pointing to an object owned by unique_ptr or the stack, where the relationship is optional.
  2. Legacy Interfaces: Interacting with C libraries.

For non-nullable, non-owning views, use T& (Reference) or std::reference_wrapper<T>.

SemanticsType
Ownership + Nullablestd::unique_ptr<T>
Ownership + Non-Nullablestd::unique_ptr<T> (Assert on dereference)
Non-Owning + NullableT*
Non-Owning + Non-NullableT&

In performance-critical code where a pointer is guaranteed to be non-null by invariants not visible To the compiler, C++23 provides [[assume(...)]] to assist the optimizer.

void fast_path(int* ptr) {
[[assume(ptr != nullptr)]];
// Compiler can remove null checks and generate direct loads
int val = *ptr;
}

C++ provides a layered hierarchy of pointer types with increasing safety guarantees:

#include <iostream>
#include <memory>
#include <vector>
void safety_hierarchy() {
// Level 0: Raw pointer — no lifetime management, no null safety
int* raw = new int(42);
delete raw; // Must manually manage
// Level 1: std::unique_ptr — exclusive ownership, no runtime overhead
auto unique = std::make_unique<int>(42);
// Automatic cleanup when unique goes out of scope
// Level 2: std::shared_ptr — shared ownership, reference counting overhead
auto shared = std::make_shared<int>(42);
// Cleanup when last shared_ptr is destroyed
// Level 3: Non-owning observers
int* observer = shared.get(); // Raw observer — must outlive
std::weak_ptr<int> weak = shared; // Safe observer — checks lifetime
// Level 4: std::span (C++20) — bounded view
std::vector<int> v = {1, 2, 3, 4, 5};
std::span<int> s = v; // Bounds-checked in debug builds (MSVC, with sanitizer)
}

Raw pointers remain essential in modern C++ for:

  1. Non-owning observation. When lifetime is guaranteed by design (parent-child, tree structures).
  2. C interoperability. C APIs use raw pointers exclusively.
  3. Performance-critical paths. When atomic operations from smart pointers are unacceptable.
  4. Custom data structures. Linked lists, trees, and graphs often use raw pointers internally while exposing smart pointer interfaces.

6. Strict Aliasing and Pointers [N4950 §6.8.4]

Section titled “6. Strict Aliasing and Pointers [N4950 §6.8.4]”

The strict aliasing rule prohibits accessing an object through a pointer of a different type (with a Few exceptions). Violating this rule is undefined behavior, even if the access appears to work.

A program shall not access the stored value of an object through a glvalue of a type that is not Similar to one of the following [N4950 §6.8.4.4]:

  1. The dynamic type of the object.
  2. A cv-qualified version of the dynamic type.
  3. A type that is the signed or unsigned variant of the dynamic type.
  4. A type that is char``unsigned charOr std::byte (byte access is always allowed).
  5. An aggregate or union type that contains one of the above types among its members.
#include <iostream>
float pun_to_int(float f) {
int* ip = reinterpret_cast<int*>(&f); // UB: violates strict aliasing
return static_cast<float>(*ip);
}

The compiler may assume that a write through float* does not affect a read through int* at the Same address, and may reorder or eliminate either operation.

#include <iostream>
#include <cstring>
#include <bit>
float safe_pun(float f) {
int result;
std::memcpy(&result, &f, sizeof(result)); // OK: memcpy accesses through char type
return std::bit_cast<float>(result); // C++20: guaranteed type punning
}

By [N4950 §6.8.4.4], memcpy is exempt from strict aliasing because it accesses objects through char* (or unsigned char*), which is always permitted. std::bit_cast [N4950 §19.5.3] provides a Compile-time type punning facility that is guaranteed correct.

Every type T has an alignment requirement, alignof(T)Which is a power-of-two integer. A Pointer T* is guaranteed to point to an address that is a multiple of alignof(T) [N4950 §7.7.2].

TypeTypical AlignmentNotes
char``int8_t1Any address
int16_t``short2Even address
int32_t``int``float44-byte aligned
int64_t``double88-byte aligned
__int1281616-byte aligned
__m12816SSE requirement
__m25632AVX requirement
max_align_t16 (typical)Maximum fundamental alignment
std::max_align_tImplementationAlignment of the largest scalar

When allocating memory with alignment requirements exceeding alignof(std::max_align_t) (over- Aligned types), use std::aligned_alloc:

#include <cstdlib>
#include <cstdint>
#include <new>
void* allocate_aligned(size_t size, size_t alignment) {
// alignment must be a power of two supported by the implementation
void* ptr = std::aligned_alloc(alignment, size);
if (!ptr) {
throw std::bad_alloc();
}
return ptr;
}
// C++17: use operator new with alignment
void* ptr = ::operator new(256, std::align_val_t{32});
// Frees with aligned deallocation
::operator delete(ptr, std::align_val_t{32});
#include <immintrin.h>
void simd_function() {
alignas(32) float data[8]; // guaranteed 32-byte aligned for AVX
__m256 vec = _mm256_load_ps(data); // requires 32-byte alignment
}

A dangling pointer points to memory that has been freed or gone out of scope:

#include <iostream>
int* dangling_demo() {
int local = 42;
return &local; // WARNING: returns dangling pointer
} // local is destroyed here
void dangling_usage() {
int* p = dangling_demo();
// p is a dangling pointer — reading *p is UB
// The memory may be reused by subsequent allocations
}

Pointer arithmetic without bounds checking can read or write beyond the allocated buffer:

#include <iostream>
void buffer_overflow() {
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr;
p[10] = 99; // UB: writes 40 bytes past the end of arr
// May corrupt stack data, return addresses, or other variables
}

Mitigation: Use std::span (C++20) with bounds checking, or compile with AddressSanitizer (-fsanitize=address) to catch overflows at runtime.

Casting a pointer to a type with stricter alignment requirements can cause misaligned access, which Is undefined behavior on some architectures (notably ARM) and a performance penalty on others (x86):

#include <iostream>
#include <cstdint>
void misaligned_access() {
char buffer[10] = {};
// buffer may be at any address
// UB on strict-alignment architectures if buffer is not 4-byte aligned
int* pi = reinterpret_cast<int*>(buffer);
*pi = 42; // Potential misaligned access
// Safe alternative: use std::aligned_storage or std::align
alignas(int) char aligned_buf[sizeof(int)];
int* safe_pi = reinterpret_cast<int*>(aligned_buf);
*safe_pi = 42; // OK: guaranteed aligned
}
#include <cstdlib>
#include <iostream>
void double_free() {
int* p = static_cast<int*>(std::malloc(sizeof(int)));
*p = 42;
std::free(p);
std::free(p); // UB: double free — may corrupt heap metadata
}
void use_after_free() {
int* p = static_cast<int*>(std::malloc(sizeof(int)));
*p = 42;
std::free(p);
std::cout << *p << "\n"; // UB: use-after-free
}

Mitigation: Always use smart pointers. std::unique_ptr and std::shared_ptr eliminate these Classes of bugs by construction.

Comparing relational operators (&lt;``&gt;``&lt;=``&gt;=) on pointers that do not point into The same array is undefined behavior [N4950 §7.6.9]. Equality operators (==``!=) are always Well-defined:

int a[5];
int b[5];
if (&a[0] < &b[0]) { // UB: different arrays
// ...
}
if (&a[0] == &b[0]) { // OK: always well-defined (false here)
// ...
}

Adding an integer to a null pointer is undefined behavior:

int* p = nullptr;
int* q = p + 1; // UB: arithmetic on null pointer

This topic covers the essential concepts and techniques related to pointers, including key principles and practical applications.

Key concepts include:

  • core concepts and definitions
  • key principles and frameworks
  • practical applications
  • common techniques and methods
  • evaluation and critical analysis

A thorough understanding of these concepts, combined with regular practice and review, is essential for mastery of this topic.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.