The C++ Textbook

Part 2 · Memory and objects

Pointers

An address in a variable, and every consequence that follows.

By the end of this chapter you can

  • Read and write pointer declarations without hesitating
  • Explain what dereferencing a null or dangling pointer does
  • Use pointer arithmetic correctly within an array

A pointer is a variable whose value is an address. That is the entire idea. Everything difficult about pointers comes from what you are allowed to do with an address, and from the fact that the language will not stop you doing the rest.

The previous chapter showed that every object has an address. A pointer is how you keep one.

Two operators

&x yields the address of the object x. *p yields the object that p points at — dereferencing. They are inverses: *&x is x.

Taking an address, and following it
#include <iostream>

int main() {
    int  value = 42;
    int* p     = &value;   // p holds the address of value

    std::cout << "value    = " << value  << '\n';
    std::cout << "p        = " << p      << "   (an address)\n";
    std::cout << "*p       = " << *p     << "   (what lives there)\n";

    *p = 99;               // write through the pointer

    std::cout << "value is now " << value << '\n';
}

Writing through p changed value, because p did not hold a copy of 42 — it held directions to the object that holds 42.

Reading a declaration

int* p declares p as a pointer to int. Read declarations from the name outwards: p is a pointer to int.

Where the * sits is a matter of style — int* p, int *p, and int * p are the same declaration. But this is not:

One asterisk, one pointer
#include <iostream>

int main() {
    int a = 1, b = 2;

    int* p, q;      // p is a pointer to int. q is a plain int!
    p = &a;
    q = b;

    std::cout << "*p = " << *p << ", q = " << q << '\n';
    std::cout << "sizeof(p) = " << sizeof(p) << ", sizeof(q) = " << sizeof(q) << '\n';
}

The * binds to the declarator, not to the type. int* p, q; declares one pointer and one int — which is why the convention in this book is one declaration per line.

The null pointer

A pointer that points at nothing should say so:

nullptr, and checking for it
#include <iostream>

int* find_first_even(int* first, int count) {
    for (int i = 0; i < count; ++i) {
        if (first[i] % 2 == 0) return &first[i];
    }
    return nullptr;             // nothing found
}

int main() {
    int odds[] = {1, 3, 5};
    int mixed[] = {1, 4, 5};

    if (int* found = find_first_even(mixed, 3)) {
        std::cout << "found " << *found << '\n';
    }
    if (find_first_even(odds, 3) == nullptr) {
        std::cout << "nothing even in odds\n";
    }
}

nullptr is a distinct null-pointer constant, introduced in C++11. Older code uses NULL or plain 0, both of which are integers in disguise and cause overload-resolution surprises. Use nullptr.

Dereferencing a null pointer is undefined behaviour. In practice it usually segfaults, because address zero is deliberately left unmapped — but “usually crashes” is not a guarantee, and the optimizer is entitled to assume it never happens:

A crash you can watch
#include <iostream>

int main() {
    int* p = nullptr;
    std::cout << "about to dereference a null pointer\n";
    std::cout << *p << '\n';       // undefined behaviour
    std::cout << "this line is never reached\n";
}

The sanitizer names it precisely rather than leaving you with a bare Segmentation fault. That is the whole reason this book compiles its samples with sanitizers on.

Dangling pointers

A null pointer is honest about pointing nowhere. A dangling pointer is worse: it holds an address that was valid and no longer is. Nothing about the pointer changes when the object it points at dies.

Returning the address of a local
#include <iostream>

int* leak_an_address() {
    int local = 7;
    return &local;          // local dies when this function returns
}

int main() {
    int* p = leak_an_address();
    std::cout << "reading through a dangling pointer: " << *p << '\n';
}

Two things saved you there. The compiler warned — -Wreturn-local-addr catches this exact shape — and, having warned, GCC went further and made the function return nullptr instead of the doomed address. The program crashes immediately and loudly rather than reading stale memory.

That is the easy case. The compiler could see the whole story in one function. Now take the same mistake and spread it across two:

The same bug, with no warning
#include <iostream>

int* escaped = nullptr;

void stash() {
    int local = 7;
    escaped = &local;       // the address outlives `local`
}

void unrelated_work() {
    volatile int a = 1, b = 2, c = 3;   // reuses the same stack space
    (void)(a + b + c);
}

int main() {
    stash();
    unrelated_work();
    std::cout << "reading through a dangling pointer: " << *escaped << '\n';
}

No warning this time. The compiler cannot see, from stash alone, that the address escapes into something outliving the function — that would take whole-program analysis. Only AddressSanitizer catches it, at run time, naming it stack-use-after-return and showing both where the memory was and where it was freed.

The rule that prevents both versions: never let a pointer outlive what it points at. Returning the address of a local is the case a compiler can catch; storing one somewhere longer-lived is the case only you can. Chapter 2.4 makes “outlive” precise.

Pointer arithmetic

Adding an integer to a pointer moves it by that many elements, not bytes. The type is what makes this work.

Walking an array with a pointer
#include <iostream>

int main() {
    int values[5] = {10, 20, 30, 40, 50};

    int* p = values;             // an array decays to a pointer to its first element
    std::cout << "*p       = " << *p       << '\n';
    std::cout << "*(p + 2) = " << *(p + 2) << '\n';
    std::cout << "p[2]     = " << p[2]     << "   (identical to the line above)\n";

    // Walking to the end. `values + 5` is the one-past-the-end pointer:
    // legal to form and compare, but never to dereference.
    for (int* it = values; it != values + 5; ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n';
}

p[i] is defined as *(p + i). Subscripting is pointer arithmetic wearing a friendlier syntax, which is why it works on pointers as well as arrays, and why it does no bounds checking on either.

The loop above is the shape every standard-library algorithm takes: a pointer to the first element and a pointer one past the last. Forming that one-past-the-end pointer is explicitly legal; dereferencing it is not.

Reading one element too far
#include <iostream>

int main() {
    int values[5] = {10, 20, 30, 40, 50};
    std::cout << "the last element: " << values[4] << '\n';
    std::cout << "one past the end: " << values[5] << '\n';   // out of bounds
}

AddressSanitizer reports the read as stack-buffer-overflow and tells you the allocation it belongs to. Without it, this reads whatever is next in memory and carries on, which is how buffer overruns become security vulnerabilities.

const and pointers

Two things can be const — the pointer, or what it points at — and the position of const decides which:

Three kinds of const
#include <iostream>

int main() {
    int a = 1, b = 2;

    const int* to_const   = &a;   // cannot write *to_const; can repoint
    int* const const_ptr  = &a;   // can write *const_ptr; cannot repoint
    const int* const both = &a;   // neither

    to_const = &b;                // fine: the pointer itself is not const
    *const_ptr = 10;              // fine: what it points at is not const

    std::cout << "a = " << a << ", *to_const = " << *to_const
              << ", *both = " << *both << '\n';
}

Read right-to-left from the name: const int* p is “p is a pointer to an int that is const”; int* const p is “p is a const pointer to an int”. Chapter 2.7 comes back to this; for now, const int* is the one you will write almost every time, because it is how a function says I will look at this and not change it.

Check yourself

Practice