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 tutorials on pointers are aimed at beginners, and present a simplified model. Some tutorials 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.
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
),
- the address-of operator
&
creates a pointer from something, and - the indirection operator
*
accesses what the pointer points to.
Note: applying &
is often called "referencing", and applying *
is often called "dereferencing".
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
".
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.
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.
The value of a pointer is "what it points to". A pointer can be one of four things:
- a pointer to an object (or function, but that is explained later),
- a pointer (one) past the end of an object,
- a null pointer value, or
- an invalid pointer value.
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 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
.
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 thenth
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
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 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.
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:
- 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". - 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.
- 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).
reinterpret_cast
expressions can be used to convert between pointers in lots of ways, but most notably:
- object pointers with different type can be converted to one another ([expr.reinterpret.cast] p7), and
- integers can be converted to pointers ([expr.reinterpret.cast] p5) and vice versa.
When you understand that the "actual value" of a pointer is the object that it points to,
this becomes quite problematic.
For example, reinterpreting an int*
as a float*
yields a pointer to the original int
object,
but with declared type float*
.
Attempting to modify the float
would then be undefined behavior because int
is not type-accessible through float
.
Generally, there are very few cases where use of reinterpret_cast
is valid (one is described below, involving std::launder
).
Reinterpreting an integer (such as a memory address 0x800000
) as an object pointer is also possible,
but has an implementation-defined effect and doesn't fit very well into the language's model of pointers.
For example, 0x800000
could simultaneously be the address of an object and an address one past an object,
so the mapping between pointers and integers isn't bijective (1:1).
Since the C++ standard mandates that the round-trip conversion of a pointer to an integer and back yields the same pointer value,
this part of the language is highly defective; the impossible is being asked.
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?
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
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
.
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).