Pointers vs References

Hi everyone ✋

Well ─ here we are. This is the final post in our long journey through C++ pointers 🏁. Over this series we’ve gone from “what even is an address?” all the way to polymorphism, smart pointers, and the deep machinery of the language. In the previous post we watched base pointers bring polymorphism to life.

To close the series, we’re going to settle a question that’s quietly followed us the whole time ─ how do pointers compare to their close cousin, the reference? 🤝

If we haven’t read the earlier posts in this series, it’s worth going through them first ─ especially the very first pointers intro and the pass by value and pass by reference post, where references first appeared.

Today’s topic is pointers vs references ─ what a reference really is, exactly how it differs from a pointer, and the simple rule for choosing between them. By the end we’ll have a clear mental model for reaching for the right tool every time ─ and a tidy bow on the whole series 🎁.

Let’s take a deep dive one last time 🦴

First ─ what is a reference? 🤝

A reference is an alias ─ another name for an existing variable. Once we bind a reference to something, it simply is that thing; using the reference and using the original are the exact same act.

#include <iostream>
using namespace std;

int main()
{
    int x = 10;
    int &ref = x;   // ref is now just another name for x

    ref = 20;       // changing ref changes x itself
    cout << "x = " << x << ", ref = " << ref << endl;

    x = 30;         // and it works both ways
    cout << "x = " << x << ", ref = " << ref << endl;

    return 0;
}

The output will be ─

x = 20, ref = 20
x = 30, ref = 30

There’s no separate object here ─ ref and x are two names for the same box. Setting one sets the other, because they are the other. Notice we didn’t dereference anything ─ no *, no & when using it. That alias-like nature is the whole personality of a reference, and it’s what sets it apart from a pointer. Let’s picture the two side by side.

My little reference ─ a nickname vs an address slip 🏷️

Fittingly for the last post, here’s one last little reference 😄 ─ and it grows right out of the address slips we’ve carried all series.

A pointer is our familiar address slip ─ a piece of paper with a house’s address on it. We can rewrite the address (aim it somewhere new), leave it blank (“no address”), stack several of them (a slip pointing at another slip), and the slip itself is a real object sitting somewhere.

A reference is something different ─ it’s a nickname permanently given to one specific house 🏷️. Once “the Blue House” is assigned to a particular house, that name always means that house. We can’t leave the nickname blank (it must name a real house from the start), we can’t later point the nickname at a different house, and using the nickname is just… using the house directly. No driving to an address, no dereferencing ─ we simply say the name.

That single contrast ─ a rewritable, blankable address slip versus a permanent nickname ─ explains nearly every difference between them. Let’s walk through the big ones.

The big difference ─ reseating 🔄

This is the one that trips people up, so let’s make it crystal clear. A pointer can be re-aimed; a reference cannot.

#include <iostream>
using namespace std;

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

    // POINTER ─ can be re-aimed at a different variable
    int *p = &a;
    p = &b;              // p now points at b
    cout << "*p = " << *p << endl;

    // REFERENCE ─ bound once, can never be re-aimed
    int &r = a;
    r = b;               // does NOT rebind! it copies b's value INTO a
    cout << "a = " << a << endl;

    return 0;
}

The output will be ─

*p = 2
a = 2

Look carefully at the two assignments ─ they look alike but do completely different things 🤔. p = &b re-aims the pointer at b. But r = b does not re-aim r ─ a reference can never be re-aimed ─ so instead it means “put b‘s value into whatever r names,” which is a. That’s why a became 2. A reference is welded to its target for life; assigning through it changes the target’s value, never the binding. This is the nickname refusing to point anywhere new ─ “the Blue House” stays the Blue House, we just repainted its insides.

A reference can’t be null, and must be born bound 🚫

Because a reference is a permanent nickname for a real thing, two rules follow immediately. It must be initialised when declared, and it can never be null.

int &r;            // ❌ ERROR ─ a reference must be bound at birth
int &r = nullptr;  // ❌ ERROR ─ there's no "no house" for a nickname

The compiler refuses both ─

error: 'r' declared as reference but not initialized

A pointer, by contrast, is perfectly happy being nullptr ─ that’s the whole “no address” idea from the NULL vs nullptr post. So a reference gives us a lovely guarantee that a raw pointer can’t ─ if we have a reference, it definitely refers to something real. There’s no null to check, no dangling “no address” to guard against. That safety is one of the best reasons to prefer references when we can.

Cleaner syntax ─ no *, no ->

Since a reference is the object, we use it exactly like the object ─ no dereferencing, no arrow. Compare reaching a member ─

p->openUp();     // through a pointer ─ arrow, and p might be null
r.openUp();      // through a reference ─ just a dot, and r is always valid

The reference reads like we’re talking to the object directly, because we are. No *, no ->, no null check ─ it’s the tidiest way to work with an existing object. Pointers earn their extra syntax by doing extra things (being null, being re-aimed); references stay clean by doing neither.

At the function boundary ─ pointer vs reference vs value 🔁

This is where the choice shows up most in real code, and it ties straight back to the pass by value and pass by reference post. Here are the three ways to hand a variable to a function ─

#include <iostream>
using namespace std;

void byValue(int x)      { x = 99; }     // works on a COPY ─ original untouched
void byPointer(int *x)   { *x = 99; }    // must dereference; caller writes &
void byReference(int &x) { x = 99; }     // cleanest ─ acts on the original

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

    byValue(a);        // a stays 1
    byPointer(&b);     // b becomes 99 ─ note the & at the call
    byReference(c);    // c becomes 99 ─ clean call, no & needed

    cout << a << " " << b << " " << c << endl;

    return 0;
}

The output will be ─

1 99 99

Both byPointer and byReference changed the caller’s variable, but look how they read. The pointer version needs a * inside and a & at the call site, and it could be handed nullptr. The reference version is clean on both ends and can never be null. For a parameter the function will always receive, a reference is usually the nicer choice; a pointer is the right call when the argument is optional or might be reseated.

const references ─ read big things cheaply 📦

One reference pattern is so common it deserves its own spotlight ─ the const reference. Passing a large object by value copies the whole thing, which is wasteful when we only want to read it. A const T& lets us borrow it with no copy while promising not to change it ─ tying together the const pointers idea and references.

#include <iostream>
#include <string>
using namespace std;

// borrow the string with no copy, and promise not to modify it
void greet(const string &name)
{
    cout << "Hello, " << name << endl;
    // name = "x";   // ❌ can't modify ─ it's const
}

int main()
{
    string s = "Ada";
    greet(s);            // no copy of the string is made
    greet("Grace");      // a const& can even bind to a temporary

    return 0;
}

The output will be ─

Hello, Ada
Hello, Grace

const string& is the standard way to pass read-only objects efficiently in C++ ─ no copy, no modification, and it can even bind to a temporary like "Grace". Whenever a function just needs to read something bigger than a number, const T& is almost always the right parameter type.

The everyday reference ─ range-based for loops 🔁

Where do references show up most in day-to-day code? In range-based for loops. Looping by reference lets us touch the real elements of a container ─ to modify them, or to read big ones without copying.

#include <iostream>
#include <vector>
using namespace std;

int main()
{
    vector<int> nums = { 1, 2, 3 };

    for (int &n : nums)          // reference ─ modify each element in place
        n *= 10;

    for (const int &n : nums)    // const reference ─ read with no copy
        cout << n << " ";
    cout << endl;

    return 0;
}

The output will be ─

10 20 30 

The first loop uses int &n, so n is a genuine alias for each element ─ n *= 10 changes the elements in place. The second uses const int &n, reading each element with no copy and no risk of changing it ─ the go-to for looping over anything larger than a number. Plain for (int n : nums) would copy each element instead; a reference is almost always what we want here 🥳.

Returning a reference ─ and a danger to dodge ⚠️

A function can also return a reference, which lets the caller read and write whatever comes back ─ this is exactly how operator[] and accessor methods let us write container[i] = value.

#include <iostream>
using namespace std;

// return a reference to the element, so the caller can assign to it
int& at(int arr[], int i)
{
    return arr[i];
}

int main()
{
    int data[] = { 10, 20, 30 };

    at(data, 1) = 99;          // assign straight THROUGH the returned reference
    cout << data[1] << endl;

    return 0;
}

The output will be ─

99

Because at returns int&, the call at(data, 1) is the element, so we can assign right to it. (We met this idea in the this pointer post too, where return *this; handed back the object itself for chaining.)

But there’s a sharp edge here, straight from the dangling pointers post ─ never return a reference to a local variable. The local dies when the function returns, leaving a dangling reference

int& bad()
{
    int local = 5;
    return local;   // ☠️ dangling reference ─ local is gone on return
}

The compiler warns us, exactly as it does for pointers ─

warning: reference to local variable 'local' returned [-Wreturn-local-addr]

Return a reference only to something that outlives the call ─ an element of a container, a data member, or a static. Otherwise, return by value.

When only a pointer will do 🎯

References are lovely, but sometimes their very guarantees get in the way, and only a pointer fits. A pointer is the tool when we need to express “maybe nothing” or “this can change what it refers to.”

#include <iostream>
using namespace std;

// return a pointer to the found element, or nullptr if it isn't there
int* find(int arr[], int n, int target)
{
    for (int i = 0; i < n; i++)
        if (arr[i] == target)
            return &arr[i];

    return nullptr;   // a reference could never express "not found"
}

int main()
{
    int data[] = { 5, 8, 13 };

    if (int *p = find(data, 3, 8))
        cout << "found: " << *p << endl;
    else
        cout << "not found" << endl;

    return 0;
}

The output will be ─

found: 8

A reference couldn’t write this function ─ there’s no “reference to nothing” to return when the value is absent. That’s the pointer’s domain. The same goes for building linked lists and trees (nodes that point at each other and end in nullptr), for anything created with new, for pointers we need to re-aim over time, and for pointer arithmetic over arrays. Whenever you can answer yes to “might it point at nothing, or point somewhere new later?”, reach for a pointer.

Under the hood ─ what a reference really is 🔎

We’ve called a reference an alias, but what is it at the machine level? Usually a reference is implemented as a pointer that the compiler automatically dereferences for us ─ so it often carries the same tiny cost as a pointer. Yet at the language level it behaves nothing like one, and two little experiments show why.

#include <iostream>
using namespace std;

int main()
{
    int  x = 5;
    int &r = x;
    int *p = &x;

    cout << "sizeof(r)  = " << sizeof(r)      << endl;   // size of the int
    cout << "sizeof(p)  = " << sizeof(p)      << endl;   // size of a pointer
    cout << "(&r == &x) = " << (&r == &x)     << endl;   // 1 ─ same address

    return 0;
}

The output will be ─

sizeof(r)  = 4
sizeof(p)  = 8
(&r == &x) = 1

First, sizeof(r) reports the size of the referred-to int (4), not the size of a pointer (8) ─ the language treats r as being x, not as something pointing at it. Second, &r gives back x‘s own address, so (&r == &x) is true ─ a reference has no separate identity to point at. That’s the deep reason there is no such thing as a pointer to a reference: there’s simply nothing there to aim at. A reference really is just another name 🏷️.

Combining them ─ a reference to a pointer 🔗

Here’s a neat twist that ties back to the pointers to pointers post. While we can’t make a pointer to a reference, we absolutely can make a reference to a pointerint*& ─ and it’s genuinely handy. It lets a function reseat the caller’s own pointer without the double-pointer syntax.

#include <iostream>
using namespace std;

// reseat the caller's pointer through a reference-to-pointer
void redirect(int*& ptr, int* target)
{
    ptr = target;   // this changes the caller's actual pointer
}

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

    redirect(p, &b);        // p now points at b
    cout << *p << endl;

    return 0;
}

The output will be ─

2

Inside redirect, the parameter int*& ptr is an alias for the caller’s p, so ptr = target re-aims p itself. This is the cleaner cousin of the int** approach from the pointers-to-pointers post ─ the same power, friendlier syntax. A nice reminder that pointers and references aren’t rivals so much as partners 🤝.

The side-by-side 📊

Here’s the whole comparison in one glance ─

Pointer Reference
Can be null? ✅ yes (nullptr) ❌ no ─ must name something real
Must be initialised? ❌ no (but should be) ✅ yes ─ at declaration
Can be re-aimed? ✅ yes ─ point elsewhere ❌ no ─ bound once, for life
How we use it *p, p->x just r, r.x
Stacking / arithmetic ✅ pointer-to-pointer, arithmetic ❌ neither
Best at expressing optional and reseatable always present and fixed

So which do we use? 🤔

After a whole series, the rule of thumb is refreshingly short ─ prefer references when we can, use pointers when we must.

Reach for a reference when the thing always exists and we never need to change what it refers to ─ function parameters (especially const T& for reading), operator overloading, range-based for loops, and anywhere cleaner syntax and a no-null guarantee help. Reach for a pointer when we genuinely need what only a pointer offers ─ it might be null/optional, it needs re-aiming, it owns heap memory, or it wires up a data structure. And for that ownership case, remember the whole smart pointers story ─ prefer a smart pointer over a raw one.

A word on && ─ rvalue references 📦

For completeness, there’s one more member of the reference family worth recognising ─ the rvalue reference, written with two ampersands, T&&. Where an ordinary T& binds to a named object, a T&& binds to a temporary ─ a value with nowhere permanent to live ─

int&& rref = 5 + 3;   // binds to the temporary result of 5 + 3

Rvalue references are the machinery behind move semantics ─ the ability to steal a temporary’s resources instead of copying them. That’s exactly what std::move triggered back in the smart pointers posts when we transferred a unique_ptr‘s ownership. It’s an advanced topic that deserves its own deep dive, but it’s good to know && for what it is ─ a special reference built for efficiency 📦.

Gotchas to keep us safe ⚠️

Let’s gather every tripwire in one place ─

  • Assigning through a reference doesn’t re-aim it. r = b copies b‘s value into r‘s target ─ it never rebinds. References can’t be reseated, ever.
  • A reference must be initialised and can’t be null. If we might have “nothing,” we need a pointer, not a reference.
  • References use plain syntax ─ no * or ->. That cleanliness is a feature, but it also means a reference looks like an ordinary variable, so remember it’s an alias to someone else’s data.
  • Prefer const T& for read-only parameters ─ no copy, no modification, and it binds to temporaries. Loop with for (const auto& x : c) for the same reason.
  • Never return a reference to a local variable. It dangles the moment the function returns ─ return a reference only to something that outlives the call, or return by value.
  • References can’t be stored in containers ─ there’s no vector<int&> or array of references. Use pointers (or std::reference_wrapper) when we need a container of “refers-to”.
  • There’s no pointer to a reference, but a reference to a pointer (int*&) is fine ─ handy for reseating a caller’s pointer.
  • Use pointers for optional, reseatable, owning, or structural roles ─ and prefer smart pointers for the owning ones.

Keep these in mind and choosing between them becomes automatic 🥳.

So what should we remember? 🤔

Let’s wrap up the key takeaways ─

  • A reference is an alias ─ another name for an existing object. Using it and using the original are the same act.
  • Picture a nickname vs an address slip 🏷️ ─ a pointer is a rewritable, blankable slip; a reference is a permanent nickname for one real thing.
  • A pointer can be re-aimed; a reference cannot. Assigning through a reference changes its target’s value, never the binding.
  • A reference must be initialised and can never be null, so if we have one, it definitely refers to something real ─ no null checks needed.
  • References use clean syntax (no * or ->), which makes them ideal for function parameters ─ especially const T& for reading big objects with no copy ─ and for range-based for loops (for (auto& x : c) to modify, for (const auto& x : c) to read).
  • A function can return a reference (the trick behind operator[]), but must never return one to a local ─ that dangles.
  • Under the hood, a reference has the object’s sizeof and no separate address (&r == &x), which is why there’s no pointer-to-a-reference ─ though a reference-to-a-pointer (int*&) is perfectly valid and can reseat a caller’s pointer.
  • T&& (rvalue reference) is a special reference for temporaries that powers move semantics ─ the mechanism behind std::move.
  • Only a pointer can express “maybe nothing” (nullptr) or “point somewhere new later” ─ so pointers own the world of optional values, reseating, dynamic memory, and data structures.
  • The rule of thumb ─ prefer references when we can, use pointers when we must (and smart pointers when we own).

The end of the road 🏁

And that’s a wrap on our pointers series 🎉. Look how far we’ve come ─ from the very first & and *, through arrays and strings, dynamic memory and smart pointers, the exotic void, const, and member pointers, the arrow and the this pointer, the dangers of dangling, the power of polymorphism ─ and now, finally, the calm clarity of choosing between a pointer and a reference.

Pointers have a fearsome reputation, but by walking through them one careful step at a time, they’ve hopefully become what they really are ─ a small set of clear, learnable ideas about addresses and names. That mental model will serve us in every corner of C++ from here on.

Thank you for taking this whole journey ─ it’s been a joy to build it piece by piece. If this has whetted the appetite for more, the C++ STL series is a wonderful next step, putting all of this to work with real containers and algorithms.

Congratulations 🥳🥳🥳
We’ve completed the entire pointers series ─ and earned a genuinely solid grasp of one of C++’s most important, most feared, and most rewarding topics.

Happy Coding 💻 🎵

Leave a Comment

Your email address will not be published. Required fields are marked *