Foundations · Module 3 · Lesson 7 of 10
Pointer Arithmetic and the Link to Arrays
See why adding one to a pointer moves it by a whole element, how a[i] is defined as pointer arithmetic, why arrays decay into pointers, and what the one-past-the-end rule means for loops and iterators.
By the end you can
- Compute the address produced by adding an integer to a pointer
- Explain that a[i] means *(a + i) and what decay does to an array
- Use one-past-the-end pointers and pointer subtraction correctly
- Explain why an array's size is lost when it is passed to a function, and how to keep it
Builds on Pointers: Variables That Hold Addresses, Arrays: Contiguous Elements and Indexing.
01 · A question first
Add 1 to a pointer to an int and the address grows by 4, not by 1. Why? And what does that have to do with the array index of lesson 1, and with the way an array quietly turns into a pointer when you pass it to a function?
Make a guess, then step through the trace below to check it.
02 · Watch it run
int a[4] = {10, 20, 30, 40};int* p = a;p = p + 2;int v = *p;int d = p - a;int* e = a + 4;- addr 0@1000: 10
- addr 1@1004: 20
- addr 2@1008: 30
- addr 3@1012: 40
- addr 4@1016: (end)
The array a holds four ints of 4 bytes each, starting at address 1000. The cells show the address and the contents of each element, plus a fifth cell for the address just after the array, 1016, where nothing belongs to the array.
Machine work: store four 4-byte values at consecutive addresses
03 · The idea
Start with the problem
Two earlier lessons fit together in a way that is not obvious yet. Lesson 1 said that a[i] is found by adding i times the element size to the start of the array. Lesson 6 gave you variables that hold addresses. So could you walk through an array by moving a pointer along it?
Two loose ends are waiting as well. The last check of lesson 1 showed an array parameter reporting the size of a pointer, and lesson 5 passed a string as a const char*. Both come from one rule, which this lesson explains.
How the idea works
Adding to a pointer. The expression p + n is the address n elements further on, not n bytes further. The compiler multiplies by the size of the pointed-to type: an int* moves by 4 for each step, a double* by 8, and a char* by 1. Step 3 of the trace shows p + 2 turning 1000 into 1008. You think in elements, and the type does the scaling, which is the same type-gives-meaning idea as in lesson 4.
Indexing is pointer arithmetic. The language defines a[i] as *(a + i). Lesson 1's formula was exactly this, and step 4 shows it: *p with p equal to a + 2 is a[2]. Array indexing is pointer arithmetic written in a friendlier notation.
Decay. In most expressions the name of an array automatically converts to a pointer to its first element (step 2). This is called array-to-pointer decay. It explains the loose ends. When you pass an array to a function, what arrives is a pointer, so void f(int arr[]) and void f(int* arr) are the same declaration, and the size is gone. Inside f, sizeof(arr) is 8. The string literal in lesson 5 decays the same way, to a const char*. To keep the size, pass it as a second parameter, as in void f(const int* arr, std::size_t n), or take a std::vector by const reference, or use std::span from C++20.
Subtraction and comparison. Subtracting two pointers into the same array gives the number of elements between them (step 5), and two such pointers can be compared with <. Both operations are meaningful only inside one array.
One past the end. You may form a pointer to the position after the last element and compare with it (step 6), but you must not dereference it. This is the half-open range of lesson 4 in pointer form, written [begin, end), and it is the foundation of the standard library's iterators. A pointer loop over an array looks like this:
int sum = 0;
for (int* p = a; p != a + 4; ++p) {
sum = sum + *p;
}Walking a C-string. The scan of lesson 5 is a pointer loop. Here is strlen written by hand:
std::size_t length(const char* s) {
const char* p = s;
while (*p != '\0') {
++p;
}
return p - s;
}Why it is correct
Scaling by the element size is what lets p + 1 mean "the next element" for every type, so the programmer reasons in elements and never in byte counts. The identity a[i] == *(a + i) shows that arrays were designed on top of pointers, and that is why decay is natural rather than a quirk.
The one-past-the-end rule makes every range expressible as a start and an end, including the empty range, where the two are equal, without needing a pointer to a position before the start. And the language limits pointer arithmetic to a single array plus that one extra position because it lets the compiler assume that pointers into different objects never mix, which allows it to reorder accesses safely. Going beyond is undefined behavior.
What it costs
Arithmetic is free. Address computations fold into the load or store instruction, and a pointer loop and an index loop usually compile to the same machine code. Use whichever reads better. Both walk contiguous memory, which suits the cache and the vector instructions of the CPU.
Decay costs information. Passing an array costs only 8 bytes, but the size is lost, and that loss is behind many bugs.
No safety net. Pointer arithmetic performs no checks. Moving a pointer more than one past the end is undefined behavior even if you never dereference it, and nothing warns you.
Pitfalls and variations
Past the end. p + 10 for an array of four elements is undefined behavior, even if the result is never used. Dereferencing the one-past-the-end pointer is as well.
Sizing a parameter. sizeof on a decayed array parameter gives the pointer size. Pass the count, or use a type that carries it.
Unrelated pointers. Subtracting or comparing pointers into different arrays is meaningless and undefined.
Precedence. *p++ reads the element and then advances, but it is easy to misread. Prefer *p and ++p on separate lines.
Const correctness. For a loop that only reads, use const int*, which makes the compiler reject accidental writes.
Invalidation. A pointer into a vector dangles after a reallocation, as lesson 4 explained.
Variations. The standard library's iterators generalize pointers, and plain pointers are valid iterators, so std::sort(a, a + 4) sorts an array. The functions std::begin and std::end give the two ends of an array without decay, and a range-based for over an array does the same. std::ptrdiff_t is the signed type of a pointer difference.
Where it is used
C interfaces pass a pointer and a count for every buffer, parsers walk byte buffers with pointers, and string functions are pointer loops. The design of the standard algorithms rests on the pointer idea: an iterator is a generalized pointer, and a range is a start and a one-past-the-end position. Embedded code reads hardware registers through pointers to fixed addresses.
The next lesson, "Pointers Versus References, and nullptr", compares the two ways of referring to an object, and shows how to represent "no object" safely.
04 · Prove it
Question 1 of 4 · answer from memory
`int a[4]` starts at address 1000, and an `int` is 4 bytes. After `int* p = a;`, what address does `p + 3` hold?
05 · Practice
Asked in interviews
Something wrong with this lesson? Report a problem