Learn C++

Search textbook... 

⌘K

Pointers and Memory

Pointers refer to the location of an object in memory

An Introduction to 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:

RegionDescription
Shared MemoryMemory 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
StackStores function calls, local variables, and control flow information, growing and shrinking automatically as functions execute (described below)
HeapStores dynamically allocated objects, where objects persist beyond function calls and require explicit management by the programmer (described below)
Global VariablesVariables declared outside of a function live here
InstructionsAlso 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

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.

Stack
main

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.

Stack
main
foo
y106

L2

foo finishes executing, so its frame is popped from the stack. main declares x.

Stack
main
x107

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:

Stack
main
factorial
n4
factorial
n3
factorial
n2
factorial
n1
factorial
n0
factorial
n-1
factorial
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:

  • The sizes of local variables must be strictly known at compile time. For example, creating an array of n integers, where n is read in from std::cin, is not allowed in C++[2].
  • As discussed previously, the stack has a limited amount of space, so storing very large objects here (such as large arrays, lookup tables, etc.) is infeasible.
  • The lifetime of local variables is bound to the lifetime of their containing function. You cannot create a variable inside a stack frame that will outlive the invocation of its function, since everything in the stack frame will be deallocated when the function finishes execution.

To get around these limitations, we can make use of a different region of memory: the heap.

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:

  • Since the allocator is a runtime construct, you can allocate any sized chunk of memory—including one whose size will not be known until runtime.
  • The heap is typically much, much larger than the stack, allowing for very large chunks to be allocated.
  • An allocation created on the heap exists independently of the stack frames. A chunk can be allocated in one stack frame only to be deallocated in a different one.

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++?

Pointers

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.

Stack
0x7fff4a5ff71a????????
0x7fff4a5ff71b????????
main
0x7fff4a5ff71c01101010
0x7fff4a5ff71d00000000
0x7fff4a5ff71e00000000
0x7fff4a5ff71f00000000
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.

Stack
main
x106
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).

Stack
0x7fff4a5ff71a????????
0x7fff4a5ff71b????????
main
0x7fff4a5ff71c01101010
0x7fff4a5ff71d00000000
0x7fff4a5ff71e00000000
0x7fff4a5ff71f00000000
0x7fff4a5ff7200x7fff4a5ff71c
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.

Pointer Types

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 232B4GB2^{32}\text{B}\approx4\text{GB}. 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

Stack
main
x106
x_ptr

L2

Since indirection returns an int&, we can modify x through x_ptr, changing it from 106 to 107.

Stack
main
x107
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

Stack
main
my_pair
pair<double, double>
first10
second20
ptr

L2

The -> operator dereferences a specific member within the pointed-to object.

Stack
main
my_pair
pair<double, double>
first10
second20
ptr
second20

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 Pointeeconst Pointee
non-const PointerT*const T* (or T const*)
const PointerT* constconst 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.

Stack
main
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.

Pointers to The Heap

⚠️ 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 as unique_ptr and shared_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.

Stack
ptr1
ptr2
ptr3
Heap
pair<int, double>
first????
second????
pair<int, double>
first106
second3.14
pair<int, double>
first0
second0

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: deleteing 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:

Stack
ptr0
ptr1
ptr2
Heap
?????
00000
12345

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.

Pointer Arithmetic

Given an array pointer T*, how do we access the ithi^\text{th} element? One way is using pointer arithmetic. Recall that:

  • Every type T has a fixed size at compile time.
  • Array allocations (returned by 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.

Stack
arr
ptr_to_2nd
ptr_to_3rd
Heap
0000

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.

Stack
main
arr
ptr_to_2nd
ptr_to_3rd
Heap
0x7ffffd098f16????????
0x7ffffd098f17????????
0x7ffffd098f1800000000
0x7ffffd098f1900000000
0x7ffffd098f1a00000000
0x7ffffd098f1b00000000
0x7ffffd098f1c00000000
0x7ffffd098f1d00000000
0x7ffffd098f1e00000000
0x7ffffd098f1f00000000
0x7ffffd098f2000000000
0x7ffffd098f2100000000
0x7ffffd098f2200000000
0x7ffffd098f2300000000
0x7ffffd098f2400000000
0x7ffffd098f2500000000
0x7ffffd098f2600000000
0x7ffffd098f2700000000
0x7ffffd098f28????????
0x7ffffd098f29????????

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).

Relationship to References

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.


Footnotes

  1. 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.

  2. 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.

  3. In reality, 64-bit machines cannot address 2642^{64} bytes of memory, even if there was a way to store that much space on device (at this point, there is not—264 bytes18 exabytes2^{64}\text{ bytes}\approx18\text{ exabytes}, 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).

  4. 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!