Lesson 11 of 25

Pointers

Addresses, and the Two Operators

Every variable in a running program lives at some numbered location in memory, called its address. A pointer is simply a variable whose value is an address. That is the entire idea; everything else is notation.

Two operators do the work. &x reads as "the address of x" and gives you a pointer to it. *p reads as "the thing at address p" and gives you the variable itself — this is called dereferencing. The confusing part for beginners is that * appears in two different roles: in the declaration int* p it is part of the type, meaning "pointer to int", while in the expression *p it is the dereference operator. Reading the declaration as "p is of type int-pointer" and the expression as "the int that p points to" keeps them separate.

The reason pointers matter is that they let you refer to something without holding it. A function can be handed the address of a large object instead of a copy of it. Two parts of a program can share one piece of data. A linked list can point at its next node without containing it. And an object can be created whose lifetime is not tied to any particular block of code — which is what the next section is about.

A pointer that points at nothing should be set to nullptr. Older code uses NULL or plain 0; both work but nullptr is a distinct type rather than a disguised integer, so it cannot be mistaken for the number zero during overload resolution. Always write nullptr in new code. Dereferencing a null pointer is undefined behaviour and usually crashes, which — perversely — makes it one of the more pleasant pointer bugs, because it fails loudly and immediately.

There is one more property worth knowing early: pointer arithmetic moves in units of the pointed-to type. p + 1 on an int* advances by four bytes on a typical machine, not one. This is exactly why arr[i] works on a pointer to the first element of an array.

Example
#include <iostream>

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

    std::cout << value << '\n';     // 42        the value itself
    std::cout << &value << '\n';    // 0x7ffd... its address
    std::cout << ptr << '\n';       // the same address
    std::cout << *ptr << '\n';      // 42        dereference

    *ptr = 100;                     // write through the pointer
    std::cout << value << '\n';     // 100 — the original changed

    // A pointer that points at nothing
    int* empty = nullptr;
    if (empty == nullptr) std::cout << "points to nothing\n";
    // std::cout << *empty;         // crash: dereferencing null

    // Pointer arithmetic moves in element-sized steps
    int arr[3] = {10, 20, 30};
    int* first = arr;               // arrays decay to a pointer
    std::cout << *(first + 2) << '\n';   // 30, same as arr[2]
}
Notes
  • Always guard before dereferencing a pointer you did not create yourself: if (p != nullptr && p->field == x). The short-circuit behaviour of && guarantees the second half is skipped when the pointer is null.

Stack and Heap: Why Dynamic Memory Exists

A running C++ program uses memory in two distinct ways, and understanding the difference explains why new exists at all.

Local variables live on the stack. When a function is called, a block of memory called a stack frame is set aside for its locals; when the function returns, that block is released automatically. This is fast — allocating is just moving a pointer — and it is completely automatic, which is why most of your variables should live here. Its limits are that the total stack is small, typically a few megabytes, and that everything on it dies at the closing brace.

The heap is a large pool of memory you request from at run time and that stays yours until you give it back. Its purpose is precisely to escape those two limits. You need the heap when the amount of memory is only known at run time and is large, or when an object must outlive the function that created it — a node inserted into a list, an object returned to a caller and stored somewhere.

new takes memory from the heap and delete gives it back. new[] and delete[] do the same for arrays, and mixing the pairs — new[] with plain delete — is undefined behaviour, because the array form has to run every element's destructor and needs to know how many there are.

Here is the honest summary, though: in modern C++ you should almost never write new or delete yourself. std::vector already gives you a run-time-sized array with automatic cleanup, and smart pointers give you heap objects with automatic cleanup. The reason to learn the raw form is that you will read code that uses it, you will be asked about it in interviews, and you need to recognise the four ways it goes wrong.

Example
#include <iostream>

int main() {
    // Stack: automatic, fast, dies at the closing brace
    {
        int local = 5;
        std::cout << local << '\n';
    }   // `local` is gone here, no action required

    // Heap: yours until you release it
    int* num = new int(42);
    std::cout << *num << '\n';    // 42
    delete num;                   // give it back
    num = nullptr;                // and stop pointing at freed memory

    // Heap array — note the matching delete[]
    int* arr = new int[5]{10, 20, 30, 40, 50};
    std::cout << arr[2] << '\n';  // 30
    delete[] arr;                 // NOT plain delete
    arr = nullptr;
}
Notes
  • Setting a pointer to nullptr straight after delete is a cheap habit with a real payoff: a later accidental dereference then crashes immediately instead of quietly reading recycled memory, and a second delete on a null pointer is harmless by definition.

The Four Ways Manual Memory Goes Wrong

Every argument for smart pointers is really an argument about these four failures. They are worth naming individually, because their symptoms are completely different and knowing which one you are looking at halves the debugging time.

A memory leak is allocating and never freeing. Nothing crashes; the program simply uses more and more memory the longer it runs. On a small assignment nobody notices. In a server that runs for weeks, or a loop that allocates on every iteration, it eventually exhausts the machine. Leaks are silent, which is exactly what makes them dangerous.

A dangling pointer is a pointer to memory that has already been freed. Reading through it is undefined behaviour, and the classic symptom is a value that is correct once and wrong later, because the memory has since been handed to something else. This is the same failure as returning a reference to a local, in a different costume.

A double delete frees the same block twice, which corrupts the allocator's internal bookkeeping and typically crashes somewhere completely unrelated — often in a later, innocent allocation. And a mismatched delete uses delete on memory from new[] or the reverse.

What makes all four so much harder in real code than in these examples is the exception path. If a function allocates with new, does some work, and then calls delete, an exception thrown in between skips the delete entirely and leaks. Getting this right by hand means wrapping every allocation in try/catch, and that is the point at which people give up and use the tools in the next section.

  • Leak — allocated, never freed. Memory use climbs; no crash, no message.
  • Dangling pointer — used after delete. Values that are right once and wrong later.
  • Double delete — freed twice. Crashes, usually far from the real cause.
  • Mismatched delete — new[] freed with delete, or the reverse.
  • The exception path — an exception between new and delete skips the cleanup entirely.
Example
#include <iostream>
#include <stdexcept>

void leak() {
    int* p = new int(5);
    // ... no delete. The 4 bytes are lost for the life of the program.
}

void dangling() {
    int* p = new int(5);
    delete p;
    // std::cout << *p;      // use-after-free: undefined behaviour
}

void doubleDelete() {
    int* p = new int(5);
    delete p;
    // delete p;             // corrupts the allocator
}

void mismatched() {
    int* a = new int[10];
    // delete a;             // WRONG — must be delete[]
    delete[] a;
}

void leakyOnThrow(bool fail) {
    int* p = new int(5);
    if (fail) throw std::runtime_error("boom");   // delete below never runs
    delete p;
}

int main() {
    std::cout << "build with -fsanitize=address to see these reported\n";
}

RAII and Smart Pointers

C++'s answer to all of this has a clumsy name and a beautiful idea: RAII, Resource Acquisition Is Initialisation. The idea is that a resource — memory, a file handle, a network connection, a lock — should be owned by an object. The object acquires it in its constructor and releases it in its destructor. Since C++ guarantees that destructors run when an object goes out of scope, including when the scope is left by an exception, cleanup stops being something you must remember and becomes something the language does.

You have been using RAII since the first lesson without noticing. std::string and std::vector allocate memory and free it in their destructors. std::ifstream closes its file. None of them ask you to do anything.

Smart pointers apply the same idea to a heap object of any type. std::unique_ptr<T> represents exclusive ownership: exactly one unique_ptr owns the object, and when it goes out of scope it deletes it. Because ownership is exclusive it cannot be copied — attempting to is a compile error, which is the type system stopping a double-delete before it can exist. You can move it with std::move, which transfers ownership and leaves the old pointer null.

std::shared_ptr<T> represents shared ownership using a reference count. Copying it increments the count, destroying a copy decrements it, and the object is deleted when the count reaches zero. It is more flexible and slightly more expensive, and it has one failure mode worth knowing: if two objects hold shared_ptrs to each other, the counts never reach zero and neither is ever freed. std::weak_ptr exists to break exactly that cycle — it observes without owning.

Create them with std::make_unique<T>(args...) and std::make_shared<T>(args...) rather than by wrapping a raw new. It is shorter, it is exception-safe, and make_shared puts the object and its reference count in one allocation instead of two.

Example
#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Student {
    std::string name;
    int roll;
    Student(std::string n, int r) : name(std::move(n)), roll(r) {}
    ~Student() { std::cout << "freeing " << name << '\n'; }
};

int main() {
    // Exclusive ownership — freed automatically at the end of scope
    std::unique_ptr<Student> a = std::make_unique<Student>("Ananya", 118);
    std::cout << a->name << ' ' << a->roll << '\n';

    // std::unique_ptr<Student> copy = a;    // error: cannot be copied
    std::unique_ptr<Student> moved = std::move(a);   // ownership transferred
    if (a == nullptr) std::cout << "a is now empty\n";

    // Shared ownership — freed when the last owner goes away
    std::shared_ptr<Student> s1 = std::make_shared<Student>("Rahul", 119);
    {
        std::shared_ptr<Student> s2 = s1;    // count is now 2
        std::cout << s1.use_count() << '\n'; // 2
    }                                        // s2 gone, count back to 1
    std::cout << s1.use_count() << '\n';     // 1

    // Borrowing without owning
    Student* observer = s1.get();            // raw pointer: does NOT own
    std::cout << observer->name << '\n';
}   // destructors run here — no delete anywhere in this program
Notes
  • std::make_unique arrived in C++14 and std::make_shared in C++11; both need <memory>. If you are stuck on C++11, std::unique_ptr<T> p(new T(...)) is the fallback.

So When Do You Still Use a Raw Pointer?

It would be neat to say "never", but that is not true and would leave you unable to read real code. The rule that modern C++ actually follows is about ownership, and once you have it, most pointer questions answer themselves.

A smart pointer means ownership: this object is responsible for freeing the thing. A raw pointer means observation: I am looking at something that somebody else owns and will free. Under that rule, a raw pointer in a function parameter is completely normal and completely safe — the function borrows the object for the duration of the call and never deletes it. What is not normal is a raw pointer that owns, because that is the one that has to be manually deleted on every exit path.

There is a further simplification. Most of the time you do not need a pointer at all. If the object always exists, take a reference (const T& or T&) instead — a reference cannot be null, so the caller and the compiler both know there is nothing to check. Reserve a raw pointer parameter for the case where "no object" is a legitimate argument, and let the nullptr carry that meaning.

And for collections of things, use a container. std::vector<Student> instead of a manually managed array of Student*. It removes the allocation, the deallocation, the size tracking and the leak, in one substitution.

  • Owning a single heap object — std::unique_ptr, or std::shared_ptr if ownership genuinely is shared
  • A run-time-sized collection — std::vector, never new[]
  • Passing an object a function only reads — const T&
  • Passing an object a function modifies — T&
  • Passing an object that might legitimately be absent — a raw T*, checked against nullptr
  • Pointing at a base class to get runtime polymorphism — a raw Base* or a std::unique_ptr<Base>, depending on who owns it
  • Calling into a C library — a raw pointer, obtained with .get() or .c_str()
Notes
  • When you pass ptr.get() to a function, you are lending the object. That function must not delete it and must not keep the pointer after the owner is destroyed. If a function needs to keep the object alive, give it a shared_ptr instead — that is precisely what shared ownership means.
Ask AI