Skip to content

Instantly share code, notes, and snippets.

@Eisenwave
Last active September 20, 2024 05:33
Show Gist options
  • Save Eisenwave/ac4ba3e83c0a76df32d6b2396e16d278 to your computer and use it in GitHub Desktop.
Save Eisenwave/ac4ba3e83c0a76df32d6b2396e16d278 to your computer and use it in GitHub Desktop.
How pointers in C++ actually work

How pointers in C++ actually work

Abstract: This document teaches you from the very basics to advanced features such as std::launder how pointers in C++ work. It is aimed at developers of any skill level. However, it links to the C++ standard so that advanced readers can verify the information and investigate further.

Motivation: Most tutorial on pointers make gross simplifications, or perpetuate an explanation of pointers that is solely based on their implementation (pointer = memory address). This tutorial aims to provide a comprehensive explanation of pointers that is in line with how they actually work from a language perspective.

Syntax

A pointer can be declared by applying the pointer declarator *. For example:

int x = 0;
// '*' applies to 'x', meaning that 'x' is a pointer to int 
int * p = &x; // 'p' points to 'x'
int y = *p;  // 'y' is equal to 'x'

Note: Pointer declarations can often seem confusing. You can use the online tool https://cdecl.plus/ to translate such declarations into prose.

Within an expression (such as &x or *p),

Note: applying & is often called "referencing", and applying * is often called "dereferencing".

Cv-qualified pointers

Pointers can be cv-qualified by adding these (const or volatile) qualifiers right of the * declarator. For example:

const int * pc; // declare 'pc' as pointer to const int
int const * pc; // same
int * const cp; // declare 'cp' as const pointer to int

Note: the common abbreviation "cv" stands for "(optionally) const or volatile".

Historical note

Before diving into how pointers work, it is important to get some things out of the way. Many tutorials start out by explaining pointers as "being memory addresses", and while there is some truth to that, it provides only an incomplete understanding.

Pointers were originally invented to provide an abstraction for memory addresses. For example, a CPU may have load and store instructions that load values from memory and store in memory, respetively. A pointer abstracts this by representing a memory address for loading and storing values in a type-safe way, i.e. an int* (pointer to int) allows loading/storing int values. Changing the value of the pointer itself is an abstraction for re-assigning the memory address.

However, it would be very wrong to think of pointers and memory addresses as the same thing. There can be multiple objects at the same address, and pointers work even when nothing is actually stored in memory due to compiler optimizations. This hardware perspective merely provides some intuition for how a pointer behaves.

Object pointers

First and foremost, pointers can have object pointer type, which is a pointer which points to an object type or cv void. We will simply refer to these as "object pointers".

For example, void* and int * const are object pointers.

Note: "object" refers to an object in the C++ sense, not to objects in an Object-Oriented Programming sense. Simply put, an object is a thing which exists in some region of storage and may have a value.

What do pointers point to?

The value of a pointer is "what it points to". A pointer can be one of four things:

When assigning a pointer to another, the value is copied. This "actual value" of the pointer is also referred to as the pointer provenance, i.e. the "origin of a pointer" or "where the pointer came from".

Note: only some of the "actual value" is fully available during program execution (see below).

The declared type is independent of the value

The value of a pointer can be different from the declared type:

int x = 0;
int * p = &x;       // 'p' points to the int 'x'
void * v = p;       // same
const int * pc = p; // same

Note: any object pointer can be converted to a void* (unless this drops cv-qualifications).

Notice that while the declared type of v is void*, it is not a pointer which points to a void object (such a thing does not exist). The value of v is that it points to the object x of type int.

The same applies to pc, which doesn't necessarily point to a const object but permits only reading (not writing) the value of x. This discrepancy in type (int vs. const int) is fine because an object of type int is type-accessible through const int.

Arrays, pointers, and decay

Things get a bit more complicated when working with pointer to objects within arrays.

Firstly, note that arrays are not pointers, although two effects in the language can give you that false impression:

  • In function parameters, arrays types are adjusted to pointer types ([dcl.fct] p4).
  • In expressions, arrays may be implicitly converted via array-to-pointer conversion (this is also called decay).

Note: formally, only the latter effect is called "decay", but colloquially, often both these effects are called "decay". When someone says "arrays in functions decay to pointers", this is what they mean.

For example:

int arr[5];
int * p = arr; // array-to-pointer conversion makes 'p' point to the first element of 'arr'

Given a pointer to an element within an array, we can do a few useful things:

  • Add or subtract an integer offset n to create a pointer to the nth next element within the array ([expr.add] p4).
  • Take the difference of two pointers within the same array to see how many elements they are apart ([expr.add] p5).

These operations are confined to the same array. It is undefined behavior to add an offset onto the pointer so that it's outside the array (with the exception of creating a past the end pointer) or to subtract pointers that belong to different arrays.

For example:

int arr[2];
int * past = arr + 2;     // OK, 'past' points one past the end of 'arr'
int * first = &arr[0];    // OK, 'first' points to the first element of 'arr'
int * middle = first + 1; // OK, 'middle' points to second element of 'arr'

int * bad = first + 10;   // undefined behavior, pointer offset too large

int d = past - first;     // 'd' = 2
int * pd = &d;

int pd = middle - pd;     // undefined behavior, subtraction between pointers not belonging to same array

Null pointers

A null pointer value (typically created by using nullptr) points to nothing at all. Dereferencing such a pointer is undefined behavior, although it can be used in limited ways. For example, two null pointers will always compare equal when applying ==.

Null pointers are useful because it is possible to tell that they are null pointers. For example:

int * p = nullptr;
p == nullptr; // always true

Null pointers are often implemented by making a null pointer represent the address 0, however that is not required. Among other conversions with zero, the integer literal 0 can be used to create a null pointer, although once again, this does not mean that the address 0 has to be stored within the pointer.

Invalid pointers

Invalid pointer values (i.e. pointers that are not valid) are created when the storage of the object that a pointer points to is freed. For example:

int * p = nullptr;
// OK, 'p' is valid (a null pointer) and we could detect that
{
    int x;
    p = &x; // OK, 'p' points to 'x'
}
// danger zone: 'p' now has an invalid pointer value and any use of it could be problematic

Dangerously, this happens without the holder of the pointer being informed about this in any way. It is also not possible to detect whether a pointer is invalid or still points to an object, only possible to tell whether it is a null pointer.

Invalid pointer values are responsible for the notorious "use-after-free bug" (CWE-416). Generally, there is nothing that can be safely and portably done with an invalid pointer value other than letting it go out of scope or re-assigning it to point to something else.

Pointers during program execution

You have now learned the possible things that pointers can point to, and what can be done with pointers. While this theory is all nice and fancy, only some of this information is available during program execution, i.e. "at run-time".

Pointers are characterized by three properties:

  1. The declared type of the pointer (e.g. int *). This is available at compile-time, and can be misused/abused since it's not guaranteed to match the type of the "actual value".
  2. The "actual value" of the pointer, i.e. what it points to. This is also what we call provenance. The provenance of a pointer is not fully available to us.
  3. The address which the pointer represents. This information is always stored.

At an assembly level, just the address that the pointer represents is passed between functions, and the developer has to ensure that the declared type matches and that provenance is respected.

For example, it is possible to have two pointers which represent the same address but do not point to the same object:

int x, y;
int * p = &x + 1; // 'p' points one past the end of 'x'
bool eq = x == y; // 'eq' could be true (it's unspecified, see [expr.eq])
*p = 0;           // undefined behavior, cannot write to '*p' no matter whether 'eq' is 'true'

Note: this + is allowed because any object can be treated as an array that contains one element ([basic.compound] p3).

If x and y are laid out contiguously in memory so that y comes right after x, then p represents the same address as a pointer &y. In other words, p and &y are indistinguishable during program execution, but they have different values: p is not a pointer to y, and dereferencing it would be undefined behavior.

Note: if the compiler is not able to detect this undefined behavior and if p == &y is true, it is actually possible that *p = 0 would modify y. Undefined behavior in relation to pointers often manifests itself as "works on my machine", but this is extremely unreliable, and such bugs may be disastrous.

Even without the this edge case of past the end pointers, it is possible to have multiple objects at the same address. For example, any array (e.g. int[1]) has the same address as its first element (e.g. int). Furthermore, any object of standard-layout class type has the same address as its first non-static data member ([basic.compound] p5).

Aliasing, and "strict aliasing" in C++

Because pointers can be copied and because multiple pointers to the same object can be created, it is possible that pointers alias each other, i.e. they point to the same region of storage.

This property is very annoying when performing compiler optimization. Consider the following example:

void consume(int);

void f(int * a, int * b) {
    consume(*a);
    *b = 0;
    consume(*a);
}

Because *b = 0 wrote 0 to whatever b points to, and because it's possible that a points to the same object, the compiler cannot assume that the two consume(*a) calls pass the same value as an argument. In other words, *b = 0 clobbers memory that is reachable through a. Unfortunately, the value of *a will need to be accessed twice.

However, the C++ language contains a variety of rules that limit where this aliasing can take place, which are collectively called strict aliasing. For example, past the end pointers never alias pointers to objects, and pointers of different types usually cannot alias each other. For example, if b was declared as float * b, no aliasing takes place in f; modifying a float object cannot have an effect on an int object because int is not type-accessible through float.

Note: Merely declaring b with type float* does not prevent aliasing, since the declared type doesn't matter; only the "actual value" of the pointer matters. However, the modification *b = 0 informs the compiler that b really points to a float object or that this assignment is undefined behavior (in which case any optimization is OK anyway).

For more information, see What is the Strict Aliasing Rule and Why do we care?

std::launder

If you have followed this explanation so far, understanding the std::launder library function is relatively simple. All it does (given a pointer p that points somewhere an object X of type T is located) is ([ptr.launder]):

Returns: A value of type T* that points to X.

For example, given a pointer p of type int* that points to a place where an int is located, it returns a pointer to that int. Initially, it might seem like this does nothing at all, but it makes sense when considering the three properties above which characterize a pointer.

Just because you have an int* doesn't mean it actually points to an int object (it could be an invalid pointer value), and just because it represents the right address doesn't mean that it points to the thing at that address you're interested in (there can be multiple objects at the same address).

The classic motivating example for std::launder is as follows:

alignas(int) std::byte buffer[sizeof(int)];
new (buffer) int(0); // begins the lifetime of an 'int' within the storage provided by 'buffer'

int * p = reinterpret_cast<int*>(buffer);
int x = *p; // undefined behavior, not 0

Note: this is undefined behavior because std::byte is not type-accessible through a pointer to int.

reinterpret_cast altered the declared type, so we have an int*. However, the value of p is unchanged; it is still a pointer to the first std::byte element in buffer. Attempting to access the value of the int located within buffer through p is undefined behavior, paradoxically, even though there is an int at its address and we have an int* representing that address.

std::launder grants us access:

int * p = std::launder(reinterpret_cast<int*>(buffer));
int x = *p; // OK, 'x' = 0

Some further limitations

std::launder is powerful, but it does not bypass the check for which bytes are reachable through a pointer.

For example:

int x, y;
if (&x + 1 == &y) {
    int py = std::launder(&x + 1);
    *py = 0; // undefined behavior
}

Even though &x + 1 may represent the same address as &y and there is an int object at &y, the bytes of y are not reachable through &x + 1 because it is a separate, complete object. Therefore, std::launder(&x + 1) is undefined behavior and does not give us a pointer to y.

Implementation note

The way std::launder is implemented in compilers may be described as telling the compiler:

Trust me bro, there really is an int there.

Compilers would otherwise perform provenance-based optimizations and could detect that dereferencing p is undefined behavior. std::launder "puts up a fence", telling the compiler not to look any further into how the pointer was obtained. That's why some people describe std::launder as a "provenance fence", or "optimization barrier".

For instance, going back to the motivating example above, the compiler may track the provenance of p and remember that it was obtained through buffer, so it cannot possibly point to an int object. This is generally a good thing because it limits the amount of things that p can alias (see the section on aliasing above).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment