Skip to content
VLSI Mentor

C++ Part I · Module 8 · Pointers and References

Pointer Arithmetic

Chapter 8.1 made a claim that has been waiting for evidence. It said a pointer's type is a promise about what lives at the address, and it pointed out that this cannot be about the pointer's size, because a pointer to a char and a pointer to a double were measured at exactly the same eight bytes. This chapter is where that claim becomes concrete, because adding one to a pointer is the operation where the type does visible work. The answer to what p plus one means is not the next byte. It is the next object, which means the step depends on how large that object is, which means three identically-sized pointers advance by three different amounts. There is a second half the frozen curriculum asks for by name, and it is the part usually left out: knowing which arithmetic on pointers is defined at all. Almost everything you can write compiles, and only a narrow set of it has meaning — stepping within one array, and one specific position just past its end. Stepping before the start, past that position, or between two unrelated objects is undefined, and the compiler will accept every one of them without comment.

Intermediate16 min readC++Part IFundamentalsPointers

Part I · Module 8 · Chapter 8.4 · Pointer Arithmetic

1. The Question Chapter 8.1 Left Open

Chapter 8.1 §3 measured that every pointer type is the same size:

typesizeof
int*8
double*8
char*8

and §5 claimed the type is therefore not about the pointer but about what lives at the address. That claim needed evidence, and this is where it arrives.

If a pointer's value is a number, p + 1 ought to be that number plus one. It is not, and the reason is the whole chapter:

p + 1 is the address of the next object, not the next byte. The step is sizeof(T) bytes, so it is different for every pointer type.

2. Measured: Three Types, Three Steps

Azvya Education Pvt. Ltd.VLSI Mentor
ptrarith.cpp — complete program
#include <cstdint>
#include <iostream>

template <typename T>
static void step(const char* label, T* p)
{
    const auto a = reinterpret_cast<std::uintptr_t>(p);
    const auto b = reinterpret_cast<std::uintptr_t>(p + 1);
    std::cout << "  " << label << ": sizeof = " << sizeof(T)
              << "   p+1 - p = " << (b - a) << " bytes\n";
}

int main()
{
    int    ai[4]{};
    double ad[4]{};
    char   ac[4]{};

    step("int*   ", ai);
    step("double*", ad);
    step("char*  ", ac);

    std::cout << '\n';
    int* first = &ai[0];
    int* last  = &ai[3];
    std::cout << "  last - first = " << (last - first) << " elements\n";
    std::cout << "  &ai[0] = " << static_cast<const void*>(&ai[0])
              << "   &ai[3] = " << static_cast<const void*>(&ai[3]) << '\n';
    return 0;
}

Measured output, zero warnings:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  int*   : sizeof = 4   p+1 - p = 4 bytes
  double*: sizeof = 8   p+1 - p = 8 bytes
  char*  : sizeof = 1   p+1 - p = 1 bytes

  last - first = 3 elements
  &ai[0] = 0x16f09e8a8   &ai[3] = 0x16f09e8b4

The first block is Chapter 8.1 §5's claim, proved. Three pointers, each eight bytes wide, each holding the same kind of value — and + 1 moved them by four, eight and one byte. The only thing that differed was the type, so the type is what decided the step.

Two notes on the program itself. std::uintptr_t is an integer type wide enough to hold a pointer, used here only to subtract two addresses and get a byte count; reinterpret_cast is the named cast Chapter 4.10 §6 listed and deferred, and this is a legitimate use of it. The template is Module 18's subject, borrowed so the same three lines do not have to be written three times.

The arrays are Chapter 9.1's subject, borrowed for somewhere contiguous to walk — the same declared borrowing Chapter 5.6 §2 and Chapter 6.2 §5 made.

3. Why Scaling Is the Right Design

It would be simpler to define p + 1 as the next byte. Scaling by sizeof(T) is better for a reason worth seeing.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    int ai[4] = {10, 20, 30, 40};
    int* p = ai;

    *p       // 10
    *(p + 1) // 20
    *(p + 2) // 30

Each + 1 moves to the next element, which is what a program walking a collection wants. If the step were one byte, every traversal would have to multiply by the element size by hand, and every such multiplication would be a place to get it wrong.

Scaling also makes indexing fall out for free. p[i] is defined as *(p + i) — the subscript operator is pointer arithmetic with different punctuation, which is why it works on pointers at all and not only on arrays. Measured on int ai[4] = {10, 20, 30, 40}:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
p[2] == *(p + 2) : true   value 30
*p = 10  *(p+1) = 20  *(p+2) = 30

Chapter 9.2 is where that equivalence has consequences.

4. Subtraction Gives a Count, Not a Distance

The second block of §2's output is the other half of the design.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  last - first = 3 elements
  &ai[0] = 0x16f09e8a8   &ai[3] = 0x16f09e8b4

The two addresses are 12 bytes apart — 0x…8a8 to 0x…8b4. The subtraction gave 3.

Subtracting two pointers yields the number of objects between them, not the number of bytes.

That is the inverse of §2's scaling and it is consistent: if + 1 means one element, then - must count elements. The result's type is std::ptrdiff_t, a signed integer type large enough for any such count, and the signedness matters — first - last is −3, which an unsigned type could not represent. Chapter 3.3 §6's decision procedure is why the standard chose a signed type here.

The result's signedness is measurable:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
b - a = 5   a - b = -5
type is std::ptrdiff_t : true
ptrdiff_t is signed    : true

What you cannot do is add two pointers. p + q does not compile:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
error: invalid operands to binary expression ('int *' and 'int *')

which is right: the sum of two addresses is not an address, and there is no meaning to give it. Only pointer-plus-integer, pointer-minus-integer, and pointer-minus-pointer are defined.

5. Which Arithmetic Is Defined At All

Now the half the frozen purpose asks for explicitly, and the half most treatments omit.

Almost any arithmetic you write on a pointer compiles. Very little of it has defined behaviour. The rule is about where the pointer lands, and it is narrow:

Pointer arithmetic is defined only within a single array, plus one position past its last element.

For int ai[4], the defined positions are:

expressionpositiondefined?
ai + 0 … ai + 3the four elementsyes
ai + 4one past the endyes — may be formed and compared, not dereferenced
ai + 5two past the endno
ai - 1before the startno

Three things follow that are worth stating separately.

One past the end is special, and only just. You may compute ai + 4 and compare it, which is what makes the standard loop idiom legal:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    for (int* p = ai; p != ai + 4; ++p) std::cout << ' ' << *p;

Measured on {10, 20, 30, 40}, zero warnings:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
 10 20 30 40

The loop's terminating comparison needs a pointer to a position where no element lives, and the standard carves out exactly that one position so the idiom works. Dereferencing it is still undefined.

Before the start is not special. There is no symmetric allowance for ai - 1, which is why a reverse loop cannot use the mirror image of the idiom above and must be written the way Chapter 5.5 §6's i-- > 0 form was.

Arithmetic between two unrelated objects is undefined, even though the compiler computes something:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    int a = 1, b = 2;
    &b - &a;                // undefined -- a and b are not in one array

Two separate objects have no defined distance from each other, however adjacent they happen to look in Chapter 7.3's address measurements.

6. The Compiler Will Not Stop You

That table describes what is defined. None of it is checked.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    int ai[4]{};
    int* p = ai + 9;        // undefined, compiles
    *p = 1;                 // undefined, compiles

Measured: zero diagnostics at -Wall -Wextra -Wpedantic. The reason is Chapter 4.10 §7's recurring pattern: the compiler can warn when it can see a specific value being damaged, and an index it cannot evaluate is not such a case. Here it is worse than usual, because a pointer's bounds are not part of its type — Chapter 9.2's array decay is the mechanism, and the practical consequence is that a pointer carries an address and nothing about how far it may legally move.

That is the honest summary of this chapter's second half: the rules are narrow and the enforcement is nil. Chapter 25.3 is the chapter on out-of-bounds access as a bug class, and the sanitizers Chapter 25.6 covers are the tools that do catch it at run time — Chapter 8.8 §6 measures one of them doing so.

7. Where Pointer Arithmetic Is Actually Used

It is worth being clear about how much of this you should write.

Rarely, in modern C++. A range-based for (Chapter 5.6) walks a collection with no index and no pointer arithmetic, and it is the loop to reach for. Chapter 5.6 §9's rule stands.

But you must be able to read it, because it is everywhere: C interfaces, hardware-facing buffers, the implementation of every container, and anything written before 2011.

And the model generalises. The standard library's iterators are built on exactly this arithmetic — ++it advances one element, end() is a one-past-the-end position, and it != end() is the comparison §5 carved out space for. Module 19 is where that arrives, and it will need no new ideas, only the ones in this chapter applied to types that are not pointers.

8. What to Actually Do

Read p + 1 as "the next object", never "the next byte".

Expect the step to be sizeof(T) — measured at 4, 8 and 1 for int*, double* and char*.

Read q - p as a count of elements, with type std::ptrdiff_t, which is signed on purpose.

Use p != end as the loop condition, where end is one past the last element. Never dereference end.

Never form a pointer before the start or more than one past the end, even to compare it.

Never subtract pointers into two unrelated objects.

Prefer a range-based for when you are walking a collection — Chapter 5.6.

Expect no diagnostics. The rules in §5 are not enforced; sanitizers are the run-time tool.

9. Mistakes Worth Making Once

Expecting p + 1 to advance one byte. Measured: 4 bytes for int*, 8 for double*.

Expecting the step to relate to the pointer's size. All three pointers were 8 bytes and stepped differently.

Reading q - p as a byte distance. Measured: 3 elements across 12 bytes.

Dereferencing a one-past-the-end pointer. Forming and comparing it is defined; reading it is not.

Writing ai - 1 for a reverse loop. There is no allowance before the start — Chapter 5.5 §6's idiom is the fix.

Subtracting the addresses of two separate variables. Undefined, however close they print.

Adding two pointers. Not defined and does not compile.

Expecting a warning for ai + 9. There is none; a pointer's type carries no bounds.

10. Interview Relevance

"What does p + 1 do?" Advances to the next object, scaling by sizeof(T). The strong version adds why this is the right design — p[i] is *(p + i), so indexing and arithmetic are the same operation — and the measurement that makes it vivid: three identically-sized pointers stepping 4, 8 and 1.

"What is q - p?" A count of elements of type std::ptrdiff_t, not a byte distance. Knowing it is signed, and why, is a good sign.

"Is ai + 4 valid for int ai[4]?" Yes to form and compare, no to dereference — and the reason is that the loop idiom needs exactly that one position. This is the question that separates people who have read the rules from people who have inferred them from code that happened to work.

11. What You Should Be Able to Say Now

  1. p + 1 is the next object, not the next byte — the step is sizeof(T).
  2. Measured: int* stepped 4, double* 8, char* 1 — while all three pointers are 8 bytes wide.
  3. That is Chapter 8.1 §5's claim proved: the type cannot be about the pointer's size, so it is about what lives at the address.
  4. Scaling is the right design because it makes traversal natural and because p[i] is defined as *(p + i).
  5. Subtracting two pointers gives a count of objects, not bytes. Measured: 3 elements across 12 bytes.
  6. The result type is std::ptrdiff_t, signed so that first - last can be −3.
  7. Adding two pointers is not defined and does not compile.
  8. Arithmetic is defined only within one array plus one position past the end.
  9. One past the end may be formed and compared, never dereferenced — which is what makes p != end legal.
  10. There is no allowance before the start, so a reverse loop needs Chapter 5.5 §6's idiom.
  11. Arithmetic between two unrelated objects is undefined, however adjacent they look.
  12. None of it is enforced. ai + 9 and *(ai + 9) compile with no diagnostic, because a pointer's type carries no bounds.
  13. The allowance is the narrowest sufficient exception — the same shape as Chapter 5.4 §6's forward-progress carve-out.
  14. Prefer a range-based for; read pointer arithmetic because it is everywhere, and know that Module 19's iterators are this model generalised.

12. Exercises

Exercise 1 — predict the step. For each, give the byte difference between p and p + 1: (a) char* p (b) int* p (c) double* p (d) int** p (e) a pointer to a 16-byte struct.

Exercise 2 — count or distance? With int ai[8], int* a = &ai[1] and int* b = &ai[6], give the value of b - a, its type, the byte difference between the two addresses, and the value of a - b.

Exercise 3 — defined or not? For int ai[4], say which are defined, and for each defined one say whether it may be dereferenced: (a) ai + 2 (b) ai + 4 (c) *(ai + 4) (d) ai + 5 (e) ai - 1 (f) ai + 4 == ai + 4.

Exercise 4 — write the loop. Write a loop over int ai[4] using only pointers, with no index, and say which position your terminating comparison uses and why that is legal.

Exercise 5 — the reverse problem. Explain why the mirror image of Exercise 4's loop is not legal, and give a correct reverse traversal.

Exercise 6 — why the type matters. Given that int* and char* are both 8 bytes and both hold an address, explain in two sentences why p + 1 differs between them, and name the chapter that first claimed this.

Exercise 7 — what is not checked. int* p = ai + 9; *p = 1; compiles silently. Say what rule it breaks, why the compiler cannot report it, and the two tools that can.

13. Summary

  • p + 1 is the next object, stepping sizeof(T) bytes.
  • Measured: 4, 8 and 1 bytes for int*, double*, char* — all three pointers 8 bytes wide.
  • That proves Chapter 8.1 §5's claim about what the type adds.
  • p[i] is *(p + i) — indexing is pointer arithmetic.
  • Subtraction gives a count of objects — measured, 3 elements across 12 bytes — of type std::ptrdiff_t, signed.
  • Adding two pointers is not defined.
  • Defined only within one array plus one position past the end; that position may be formed and compared, never dereferenced.
  • Nothing before the start, and nothing between unrelated objects.
  • None of it is enforced — ai + 9 compiles silently, because a pointer carries no bounds.
  • Prefer a range-based for; Module 19's iterators generalise this model.

Four chapters in, every mechanism has belonged to pointers, and the module blurb promised two sentences rather than one. The second is the one Chapter 6.4 already measured without naming as a definition: a reference is another name for an existing object. That chapter used it as a parameter mechanism and the module blurb said it would be explained completely here. The next chapter is that explanation, and it is built on three properties that are each a refusal — a reference must be initialized, it can never be made to refer to anything else afterwards, and there is no way to write one that refers to nothing. The measurement that settles the second is the one people get wrong: assigning to a reference looks exactly like rebinding it, compiles cleanly, and does something else entirely.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.