C++ Pointer Arithmetic and Object Size Boundaries

Pointer arithmetic in C++ allows developers to navigate memory by stepping through elements of a specific type rather than manipulating raw byte addresses. The compiler relies strictly on the size of the pointed-to type (sizeof(T)) to calculate offsets, automatically scaling increment and decrement operations to align with contiguous object boundaries. This article explains the underlying mechanics of scaled address calculations, the role of compiler-enforced memory boundaries, the constraints imposed by arrays, and the risks of undefined behavior when these boundaries are violated.

The Scaling Mechanism of Pointer Arithmetic

In C++, pointers are strictly typed (with the exception of void*). When an integer offset is added to or subtracted from a pointer, the compiler does not advance the address by that number of bytes. Instead, it multiplies the integer offset by the size of the underlying type:

\[\text{Target Address} = \text{Base Address} + (n \times \text{sizeof}(T))\]

Consider the following operation:

int* int_ptr = reinterpret_cast<int*>(0x1000);
int_ptr += 2;

Assuming sizeof(int) is 4 bytes on a typical 64-bit architecture, int_ptr + 2 evaluates to address 0x1008 (\(0x1000 + (2 \times 4)\)), not 0x1002.

Because sizeof(T) is fundamental to this calculation:

  • A char* increments by 1 byte.
  • A double* typically increments by 8 bytes.
  • A pointer to a custom structure struct Node { int id; double value; }; increments by sizeof(Node), which includes internal structure padding added by the compiler to satisfy hardware alignment requirements.

Because a void* has no associated type and therefore no determinable sizeof(void), standard C++ explicitly forbids pointer arithmetic on void*.

Object Boundaries and Contiguous Memory

Pointer arithmetic is strictly defined only within the boundaries of an array or a single object treated as an array of size one. In contiguous memory allocations, such as a C-style array or std::array, objects of type T are packed sequentially:

int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr; // Points to arr[0]

When moving ptr forward:

  • ptr + 0 designates the object boundary of arr[0].
  • ptr + 1 designates the object boundary of arr[1].
  • ptr + 4 designates the object boundary of arr[4].

The C++ standard permits a pointer to point to any element within the array, as well as to one position past the last element (arr + 5). The one-past-the-end pointer serves as a valid sentinel for loop termination (similar to end() in standard containers). However, dereferencing this sentinel pointer constitutes undefined behavior because no valid object boundary exists at that location.

Pointer Difference (ptrdiff_t)

When two pointers pointing to elements of the same array are subtracted, the result is not the number of raw bytes between them. The compiler divides the byte distance by sizeof(T):

\[\text{Index Difference} = \frac{\text{Address}_2 - \text{Address}_1}{\text{sizeof}(T)}\]

The resulting value is of the signed integral type std::ptrdiff_t. Subtracting pointers that do not point into the same array (or one-past-the-end) violates language constraints and results in undefined behavior.

Pitfalls: Object Slicing and Polymorphic Arrays

A common architectural trap occurs when combining pointer arithmetic with inheritance. If a derived class is larger than its base class, navigating an array of derived objects using a base-class pointer breaks object size boundary calculations:

struct Base {
    int b;
};

struct Derived : Base {
    int d;
};

Derived derived_array[3];
Base* base_ptr = derived_array; // Implicit upcasting
base_ptr++;                     // Advances by sizeof(Base), not sizeof(Derived)

Because sizeof(Derived) (typically 8 bytes) is larger than sizeof(Base) (typically 4 bytes), incrementing base_ptr advances the pointer directly into the middle of the first Derived object rather than landing at the boundary of the second Derived object. Accessing members through base_ptr[1] reads misaligned data and causes undefined behavior.

Summary of Rules

  1. Automatic Scaling: Every arithmetic operation (+, -, ++, --) scales by sizeof(T).
  2. Valid Range: Arithmetic must remain within [0, N], where N is the size of the array. The element at N is valid for comparison only, never dereferencing.
  3. Homogeneous Types: Pointers being subtracted must point to elements within the same memory block.
  4. Polymorphic Caution: Never apply pointer arithmetic to base pointers referencing arrays of derived objects.