Learn C++
Search textbook...
Pointers refer to the location of an object in memory
Every computer program must manipulate memory. In any programming language, every object that you create lives somewhere in the program's memory. In the case of C++, this is equally true: every int
, float
, std::string
, and std::vector
, among others, must store its contents somewhere in memory. When it is launched, the operating system gives each program (more specifically, each process) some amount of memory that it is allowed to work with, known as it's address space. In many common programming languages, such as Python, the use of this address space is managed for you automatically. The same is true for the most part in C++, however C++, being a systems programming language, also gives you direct access of this memory through a feature of the language known as pointers.
Conceptually, the address space can be thought of as one big blob of binary data: ones and zeros. However, in practice, this blob is divided into separate sections, each having its own purpose. Traditionally, these are:
Region | Description |
---|---|
Shared Memory | Memory reserved by the operating system that is shared with the program to allow, for example, communication between the current process and the operating system and/or other processes |
Stack | Stores function calls, local variables, and control flow information, growing and shrinking automatically as functions execute (described below) |
Heap | Stores dynamically allocated objects, where objects persist beyond function calls and require explicit management by the programmer (described below) |
Global Variables | Variables declared outside of a function live here |
Instructions | Also called the text segment. This is the process's code being currently executed: raw machine code emitted by the compiler lives here |
Each region has an important purpose, but for our discussion regarding C++, we will focus on the stack and the heap, as these are where a program's variables are stored.
The stack (also known as the call stack or program stack) is where function calls and their local variables are stored. Every time you invoke a function, a stack frame (also known as an activation record) is created to store that function's local variables.
The stack works kind of like a stack of plates: when you want to add more plates, you add them to the top of the stack, and when you need a plate, you remove one off the top. However, you wouldn't remove a plate from the bottom or middle—at least not easily! Similarly, every time you call a function, a stack frame is pushed to the top of the stack, and when function invocation finishes, a stack frame is popped from the top of the stack.
Since main
is the first function called by any C++ program, there will always be a main
function that remains at the bottom of the stack for the duration of the program (and is only popped when the program exits). As other functions are invoked, their stack frames are pushed on top of the previous one.
We can diagram this process for any point in a program's lifetime using a stack diagram. Note that because the stack actually grows downward in memory (more on this later), it is often diagrammed with main
at the top and other functions below it. For example, consider the following (simple) program:
L1
At this point, a single empty stack frame exists for main
. Notice that it does not contain an entry for variable x
since it has not been declared yet at this point in the program.
L3
main
calls foo
, pushing a new stack frame onto the stack. foo
declares y
, as shown by the entry for y
within its frame.
y | 106 |
L2
foo
finishes executing, so its frame is popped from the stack. main
declares x
.
x | 107 |
The stack is not in an inexhaustible resource[1], and care must be taken to avoid nesting so many function calls that it runs out of space. For example, consider this buggy program intended to compute the factorial of a number:
When invoked with factorial(4)
, it would produce stack frames like this:
n | 4 |
n | 3 |
n | 2 |
n | 1 |
n | 0 |
n | -1 |
n | -2 |
Lacking a base case, factorial
will continue to push stack frames indefinitely. Because the operating system only allocates so much memory to the stack, eventually the program will run out of space, triggering a segmentation fault and resulting in the program being forcefully terminated. This particular ocurrence is known as a stack overflow.
We could achieve a correct
factorial
like so:
Just like adding and removing from the top of a stack of plates is hassle-free, pushing/popping stack frames is an extremely efficient operation. However, the stack has a number of important limitations to keep in mind:
n
integers, where n
is read in from std::cin
, is not allowed in C++[2].To get around these limitations, we can make use of a different region of memory: the heap.
The heap (also known as the free store) stores variable-sized chunks of memory that can persist beyond the lifetime of function calls. It overcomes the aforementioned limitations of the stack, at the cost of being a bit less performant and requiring additional effort to manage on behalf of the programmer.
The heap works kind of like a restaurant front desk. When you enter, you state the number of people in your party and the restaurant finds an empty table that fits everyone and leads you there. The restaurant would like to match you with a table that is exactly the right size for your party—however, if you are a party of two and there are only tables for three, a table of three will still suffice! On the other hand, if all of the tables are full, they might tell you to come back at another time. Once you are seated, if someone in your group arrives late, they can ask where you've been seated to find you. Eventually, you will finish your meal and leave the restaurant, at which point your table becomes available for future patrons.
In the same way, when you request memory from the heap, an allocator must search internally for an unutilized region that is at least as large as the amount you requested—however you might get something larger! If no sufficiently large contiguous chunk is available, the allocator signals a failure to fulfill the request. If the request was processed, it responds with a pointer to the newly-allocated region of memory so that you can manipulate its contents and refer back to that region in the future. Once you are finished using this memory, you can return it back to the allocator (via the pointer to the region) to free it up for future requests.
Having a separate heap addresses all of the limitations of the stack:
Using memory on the heap is typically slower than using memory on the stack. On the one hand, this is because allocating on the heap requires the allocator to search for an open location, whereas the stack merely needs to push onto whereever the top of the stack is currently located. On the other hand, heap memory can become fragmented and more widely dispersed than stack memory due to the way its allocated. Continuing the restaurant analogy, imagine a server delivering orders to tables. It's more efficient to deliver all food to one table before continuing on to the next. Bringing one dish to table A, then another to table B, then another to table A again, would require more trips and be a slower process. By the same token, a processor can do its job faster if it accesses memory that tends to be closer together (e.g. the stack) than more widely distributed (the heap). This is known as the principle of locality.
As previously mentioned, we must keep track of pointers to the allocated chunks to manipulate their contents and to eventually free them up for future use. But what are pointers and how do they work in C++?
A pointer in C++ is both the address of an object in memory, and the way we represent that address in code. In byte-addressable memory (which most computers use), each byte in the program's address space is identified by a number starting from zero and counting upward by one—the address of each byte. An object may span multiple bytes in memory, in which case the address of the entire object is the address of its first byte (i.e. the one with the lowest address). For example, on most systems, an int
takes up 4 bytes or 32 bits of space. If we were to inspect the memory of a single integer, we might see something like this:
On a particular run of this program, the memory for x
started at address 0x7fff4a5ff71c
. Note that addresses are typically written in hexadecimal notation (base 16), as opposed to decimal (base 10), for brevity. The first byte of x
is 01101010
, the binary equivalent of 106
. The memory above and below x
is unknown, and could take on any value.
0x7fff4a5ff71a | ???????? |
0x7fff4a5ff71b | ???????? |
0x7fff4a5ff71c | 01101010 |
0x7fff4a5ff71d | 00000000 |
0x7fff4a5ff71e | 00000000 |
0x7fff4a5ff71f | 00000000 |
0x7fff4a5ff720 | ???????? |
0x7fff4a5ff721 | ???????? |
We can get a pointer to x
using operator&
(known as the address-of operator), which takes in a variable and returns the pointer to that variable (i.e. the address of that variable). Consider a slightly modified version of the above snippet:
x_ptr
is a pointer to x
. Conceptually, we can think of x_ptr
as pointing to wherever x
is located in memory.
x | 106 |
x_ptr | ● |
In reality, x_ptr
stores the address of x
. More specifically, x_ptr
is itself is a number whose value is the address of the first byte of x
. Notice that x_ptr
takes up 8 bytes of space (in fact, on a 64-bit system, all pointers take up 8 bytes of space).
0x7fff4a5ff71a | ???????? |
0x7fff4a5ff71b | ???????? |
0x7fff4a5ff71c | 01101010 |
0x7fff4a5ff71d | 00000000 |
0x7fff4a5ff71e | 00000000 |
0x7fff4a5ff71f | 00000000 |
0x7fff4a5ff720 | 0x7fff4a5ff71c |
0x7fff4a5ff728 | ???????? |
0x7fff4a5ff729 | ???????? |
In all future diagrams in this textbook, we will use arrows to represent pointers, but remember, a pointer is just a number containing the address of the thing it points to!
If we were to print out x_ptr
in the code above, we would simply see the address it contains (0x7fff4a5ff71c
). However, given a pointer, we can dereference it to get the actual value it points to:
We will now discuss the syntax of pointers, including how they are dereferenced.
For any type T
, T*
is the type of a pointer to an object of type T
. Under the hood, every pointer is represented as an address to the starting-byte of an object—so why include information about the type? Remember that C++ is a type-safe language, so it matters whether the object we're pointing to is an int
or a std::string
as it will change what operations are supported for the pointed-to object.
On 64-bit systems, the size of a pointer will always be 64 bits (8 bytes). On 32-bit systems, a pointer will take up 32 bits (4 bytes). In effect, this places an upper bound on the amount of memory a program can utilize—a program on a 32-bit machine can at most address . In fact, this was one of the driving motivations for moving to 64-bit machines: they can address (and therefore make use of) much more memory![3]
Given a T*
ptr, we can get the value of type T
that it points to through the indirection operator, operator*
. This is known as dereferencing a pointer. To be precise, operator*
, returns a T&
, or a reference to T
. Why? This technicality means that dereferencing a pointer does not make any copies of the pointed-to object—it merely accesses its already-existing memory. Furthermore, it allows us to use the indirection operator to modify the underlying data, e.g.:
L1
x | 106 |
x_ptr | ● |
L2
Since indirection returns an int&
, we can modify x
through x_ptr
, changing it from 106
to 107
.
x | 107 |
x_ptr | ● |
If T
happens to be a structure, e.g. an std::pair<double, double>
, we can directly access its members through the member access operator, operator->
. This is the same as first dereferencing the pointer and then accessing the member. For example:
L1
ptr
points to my_pair
. Note the use of auto
to have the compiler infer the type
my_pair | pair<double, double>
| ||||
ptr | ● |
L2
The ->
operator dereferences a specific member within the pointed-to object.
my_pair | pair<double, double>
| ||||
ptr | ● | ||||
second | 20 |
Every T
also has a pointer-to-const type, const T*
(also written T const*
) which represents a pointer to a const T
. We cannot change the contents of the object pointed to by this kind of pointer. However, we can change what the pointer itself points to. For example:
Indeed, if we wanted to prevent the pointer itself from being changed, we could use a const-pointer, e.g. T* const
. The pointer itself cannot be changed (i.e pointed to a different object), but we can still change the underlying object itself. Hence, every T
has four pointer types, represented in the table below:
non-const Pointee | const Pointee | |
---|---|---|
non-const Pointer | T* | const T* (or T const* ) |
const Pointer | T* const | const T* const (or T const* const ) |
There is a special value, nullptr
, which represents a pointer to no object. It is commonly used to represent the absence of a value. nullptr
can be cast to any of the above pointer types and any type T
, and under the hood, nullptr
always stores the special address 0
. Be careful! You cannot dereference a nullptr
as it doesn't point to any object. Attempting to do so, whether through operator*
or operator->
, will result in a segmentation fault.
In this textbook, we will represent nullptr
with a ⦻
character in diagrams.
ptr | ⦻ |
In C++, nullptr
has a special type, nullptr_t
. The only instance of nullptr_t
is nullptr
, and it automatically converts into an instance of any pointer type.
⚠️ Note: In modern C++, it is no longer recommended to use raw pointers, e.g.
T*
, to refer to heap allocations, as this can lead to memory leaks if you forget to deallocate them. Consider using smart pointers instead, such asunique_ptr
andshared_ptr
, which automatically deallocate. These will be discussed in a later chapter.
So far, the examples we have shown have included pointers to regions of the stack, e.g. x_ptr
which points to a local variable x
. This is uncommon in C++, as references (discussed in a previous chapter and more below) are more commonly used to accomplish the same thing. Where you may see pointers more often used is to refer to allocations on the heap. As discussed previously, the heap stores dynamically allocated memory that can outlive a function invocation. To allocate an object of type T
on the heap, we can request it from the allocator using operator new
:
Note that this version of new
does not initialize T
. It's memory will be whatever was left in the allocated chunk, typically garbage data. Going back to the restaurant example discussed previously, it's as if the restaurant sat you down at an open table without clearing the dishes from the previous guests! To actually initialize the object, you can use uniform initialization, e.g.
or value initialization:
To see the difference, consider the case where T = std::pair<int, double>
:
We cannot know for sure what *ptr1
contains, since it wasn't initialized. However, *ptr2
is definitely initialized with 106
and 3.14
, and *ptr3
is value initialized with zero-initialized members.
ptr1 | ● |
ptr2 | ● |
ptr3 | ● |
pair<int, double>
| ||||
pair<int, double>
| ||||
pair<int, double>
|
Once you have a T*
to the heap, you can deference it, pass the pointer to other functions, store it in another data structure, etc. However, you must remember to deallocate the region once you are done with it. This can be done by passing the same pointer returned by new
to operator delete
:
which frees the memory pointed to by ptr
. Calling delete
on a pointer not previously returned by new
is invalid, as is attempting to delete
the same pointer twice. The one exception to this rule is nullptr
: delete
ing nullptr
is always valid and does nothing. Failing to delete
a pointer that was allocated with new
will not cause your program to crash, but will lead to a memory leak, causing your program to use more memory than it requires.
Often times, we don't want to allocate space for a single object, but for multiple objects at a time. C++ supports this through array allocations and operator new[]
:
The snippet above allocates a contiguous region of memory large enough to hold n
instances of T
. Importantly, n
can be a dynamically determined value—it does not need to be known at compile time, allowing us to overcome the static-size constraint placed on stack memory/local variables. This syntax also does not initialize any of the elements in the region. If we wanted to initialize the elements, we can use:
For example, consider the contents of memory produced by this code:
ptr0 | ● |
ptr1 | ● |
ptr2 | ● |
| |||||
| |||||
|
When allocating an array with new[]
, you must use the corresponding delete[]
when you are finished with it. Attempting to deallocate such a pointer using delete
(without the []
) is invalid.
Note: There is no difference syntactically between a pointer to an object and a pointer to an array. The programmer is responsible for knowing the difference between the two and using the appropriate operations, e.g. calling the right version of
delete
on it.
Given an array pointer T*
, how do we access the element? One way is using pointer arithmetic. Recall that:
T
has a fixed size at compile time.new[]
) are contiguous in memory.The first fact allows the compiler to know precisely how many bytes to allocate to an array of n
elements of type T
—indeed, we can call sizeof(T)
to get the compile-time size of T
, so n * sizeof(T)
is the minimum number of bytes a call to new T[n]
must allocate. The second fact, in combination with the first, lets us to know the addresses of individual elements in arrays. As the example below demonstrates, adding an integer to a pointer increments that pointers address in multiples of sizeof(T)
:
arr
points to the beginning of the allocated array. Adding 1
to arr
gives a pointer to the element 1
after arr
in memory, 2
gives the pointer 2
after arr
, etc.
arr | ● |
ptr_to_2nd | ● |
ptr_to_3rd | ● |
|
Under the hood, adding an integer to a number adds or subtracts sizeof(T)
multiples of bytes from its underlying address. In this case, sizeof(int) = 4
, so arr + 1
, for example, adds 4 bytes to arr
's underlying address.
arr | ● |
ptr_to_2nd | ● |
ptr_to_3rd | ● |
|
We usually want to access the elements at different positions in an array. Using pointers, we could dereference, e.g. *(arr + 1)
, to get the element at index 1
. This is a common enough operation that there exists a special syntax just for this purpose: operator[]
. For pointer types, arr[i]
is exactly the same as *(arr + i)
.
After learning about pointers, people often ask if they are related to references. The answer is yes! Under the hood, the compiler treats references just like pointers, however, the semantics differ. Pointers use ->
to access the underlying object, whereas references use the same .
notation as value types:
One important difference, however, is that references cannot be rebound. Once a references has been bound (points to) an object, it must always refer to that object.
Lastly, references are not allowed to be nullptr
—they must always point to an object. There are hacky ways to get a reference to store nullptr
[4], but such programs, although they may compile, are invalid in C++ and may lead to undefined behaviour.
On most systems, the default size of the stack will range from 1-8 megabytes. On some embedded systems, the stack can be even more restricted, as small as a few kilobytes. On Unix machines (like Linux and MacOS), running
will print the size of the stack in kilobytes. ↩
There is no reason why stack variables could not in theory have variably defined sizes, however, it does make the compiler implementer's life easier if they have static sizes. Local variables within a frame are usually defined by some offset from the top of the frame (called the frame pointer) whose address is fp
, e,g, fp + 0
might be the first variable, fp + 4
, might be the second. If the size of all local variables are known at compile time, the generated machine code can refer to variables by some fixed offset from the current frame pointer. If they are dynamically sized, some extra math may be required to be performed at runtime to determine the offsets of these variables. As a real world example, consider that the C programming language originally allowed for variable-length arrays (VLAs)—arrays allocated on the stack whose size could be determined at runtime. C++, which grew out of C, decided to remove this feature. ↩
In reality, 64-bit machines cannot address bytes of memory, even if there was a way to store that much space on device (at this point, there is not—, or about 18 billion gigabytes). 64-bit CPUs will typically only use some portion of the address bits (for example, Intel processors commonly use only the lower 48 bits for architectural reasons). ↩
We could technically write code like this to get a null reference:
However, any attempt at accessing null_ref
(which would result in dereferencing the underlying pointer) would crash the program! ↩