Lesson 12 of 25

References

A Second Name for the Same Object

A reference is an alias. int& ref = original; does not create a new variable holding a copy, and it does not create a pointer holding an address. It gives the existing object a second name. From that moment on, ref and original are two ways of saying exactly the same thing: read one and you read the other, write one and you write the other.

Three rules follow directly from that, and they are the whole of what makes references different from pointers. A reference must be initialised when it is declared — there is no such thing as a reference that refers to nothing, so int& r; does not compile. A reference cannot be reseated: once ref names original, writing ref = other does not make it point at other, it assigns other's value into original. And a reference is never null in correct code, so there is nothing to check before using it.

That last property is the practical payoff. When a function takes int&, the caller must supply a real object, and the function body can use it immediately without a null check. When a function takes int*, both sides carry a permanent question: is this allowed to be null, and who is responsible for checking? References remove the question by construction.

The syntax is also quieter. There is no * to dereference and no -> to remember — a reference is used with exactly the same notation as the original variable. That is deliberate: the code reads as though you are working with the object, because you are.

Example
#include <iostream>

void doubleIt(int& x) { x *= 2; }        // modifies the caller's variable

int main() {
    int original = 42;
    int& ref = original;             // ref IS original, under another name

    std::cout << ref << '\n';        // 42
    ref = 100;
    std::cout << original << '\n';   // 100

    int other = 7;
    ref = other;                     // does NOT rebind: assigns 7 into original
    std::cout << original << '\n';   // 7

    // int& broken;                  // error: must be initialised

    doubleIt(original);
    std::cout << original << '\n';   // 14

    // Const reference: read-only alias
    const int& view = original;
    // view = 50;                    // error: cannot assign through const ref
    std::cout << view << '\n';       // 14
}
Notes
  • A reference usually compiles down to the same machine code as a pointer — the compiler still passes an address behind the scenes. The difference is entirely in what the type system lets you do, and that is exactly the point: the safety is free.

Reference or Pointer? A Decision You Make Often

Both let a function work with the caller's object instead of a copy, so students reasonably ask which to use. The working rule in modern C++ is: use a reference unless you need something only a pointer can do.

There are exactly three things only a pointer can do. It can be null, which lets you express "there may or may not be an object here". It can be reseated, which lets one variable point at different objects over time — the basis of linked lists and trees. And it can own a heap object, though as the previous lesson argued, that job should go to a smart pointer rather than a raw one.

If you need none of those, a reference is better on every axis. It cannot be null, so there is no check to write and no crash to debug. It cannot be accidentally reseated. It reads more cleanly at both the definition and the call site. And it says something about intent: a function taking const std::vector<int>& is obviously reading a vector that already exists, whereas one taking const std::vector<int>* makes the reader wonder what the null case means.

There is one visible asymmetry at the call site that is worth being aware of rather than fighting. modify(x) looks identical whether modify takes int or int&, so you cannot tell from the call whether x might change; with a pointer, modify(&x) makes it explicit. Some codebases prefer pointers for output parameters for this reason. The far more common answer is to avoid output parameters where you can and simply return the value.

  • The object always exists and you only read it — const T&
  • The object always exists and you modify it — T&
  • "No object" is a valid state — T*, checked against nullptr
  • The variable must point at different objects over time — T*
  • Something must own a heap object — std::unique_ptr<T> or std::shared_ptr<T>
  • You need to store a collection of them — a container of values or of smart pointers; you cannot make a std::vector of references
Example
#include <iostream>
#include <string>
#include <vector>

// Always present, read-only: reference
void printAll(const std::vector<std::string>& names) {
    for (const auto& n : names) std::cout << n << '\n';
}

// Always present, modified: reference
void addSuffix(std::string& name) { name += " (verified)"; }

// Optional: pointer, because nullptr means "not supplied"
void registerStudent(const std::string& name, const std::string* guardian) {
    std::cout << name;
    if (guardian != nullptr) std::cout << ", guardian " << *guardian;
    std::cout << '\n';
}

int main() {
    std::vector<std::string> names = {"Ananya", "Rahul"};
    printAll(names);

    addSuffix(names[0]);
    std::cout << names[0] << '\n';       // Ananya (verified)

    std::string g = "S. Sharma";
    registerStudent("Ananya", &g);
    registerStudent("Rahul", nullptr);   // no guardian on record

    // std::vector<int&> impossible;      // references cannot be stored directly
}

const References and Temporaries

A non-const reference can only bind to a named object that already exists. This is why void f(std::string& s); f(name + " Sharma"); does not compile — the concatenation produces a temporary, and letting a function modify something that is about to be thrown away is almost certainly a mistake, so the language forbids it.

A const reference is allowed to bind to a temporary, and something quietly clever happens when it does: the temporary's lifetime is extended to match the reference's. Bind const std::string& s to the result of an expression, and that result stays alive for as long as s is in scope. This is what lets you pass literals and computed values to functions taking const T&, which is most functions in a well-written codebase.

It also explains the small conversion trap. If a function takes const std::string& and you pass a const char* literal, a temporary std::string is constructed for the call. That is usually fine and invisible, but inside a hot loop it is a real allocation on every iteration. C++17 added std::string_view for exactly this case: a non-owning view of characters that costs nothing to create from either a std::string or a literal. Use it for parameters you only read and never store.

The general habit to build: default to const T& for any parameter that is not a small built-in type. It avoids the copy, it documents that you will not modify the argument, and it accepts temporaries — three benefits for one keyword.

Example
#include <iostream>
#include <string>
#include <string_view>

void needsNamed(std::string& s)        { s += "!"; }
void acceptsAnything(const std::string& s) { std::cout << s << '\n'; }

// C++17: reads characters without owning or copying them
void cheapRead(std::string_view s)     { std::cout << s.size() << '\n'; }

int main() {
    std::string name = "Ananya";

    needsNamed(name);                 // fine: a named object
    // needsNamed(name + " Sharma");  // error: cannot bind to a temporary

    acceptsAnything(name);
    acceptsAnything(name + " Sharma"); // fine: const ref binds to a temporary
    acceptsAnything("a literal");      // fine: a temporary string is built

    // Lifetime extension: the temporary lives as long as `full` does
    const std::string& full = name + " Sharma";
    std::cout << full << '\n';         // still valid here

    cheapRead("no allocation at all"); // string_view: nothing is copied
    cheapRead(name);
}
Notes
  • std::string_view does not own its characters. Never store one that outlives the string it was made from, and never return one that views a local — that is a dangling view, with the same symptoms as a dangling reference.

Dangling References: The One Real Danger

References remove the null problem but not the lifetime problem. A reference is safe only as long as the object it names is alive, and nothing in the type system checks that for you. There are three ways to get it wrong, and they are worth recognising on sight.

The first is returning a reference to a local. The local dies when the function returns, so the caller is handed a name for memory that has been released. Compilers warn about the obvious form of this, and you should treat that warning as an error.

The second is more subtle and catches even experienced people: holding a reference into a container that then grows. int& first = v[0]; is perfectly valid — until you push_back and the vector runs out of capacity. At that moment it allocates a larger buffer, moves every element across and frees the old one, and your reference names freed memory. The same applies to iterators and to raw pointers into the vector. Any operation that can reallocate — push_back, insert, resize, reserve — invalidates them all.

The third is a reference member that outlives its target. A class storing const std::string& is fine only if the referenced string is guaranteed to outlive every instance of that class, and that guarantee is easy to lose during a refactor. Storing by value, or storing a std::shared_ptr, is almost always the better choice.

The common thread is a simple question you can ask at every reference you write: who owns this object, and are they still alive? If the answer is "the function I am about to return from" or "a vector I am about to modify", you have found the bug before it found you.

Example
#include <iostream>
#include <vector>

// BROKEN: `total` is destroyed when the function returns
// int& brokenSum(const std::vector<int>& v) {
//     int total = 0;
//     for (int x : v) total += x;
//     return total;              // dangling reference
// }

int sum(const std::vector<int>& v) {   // return by value instead
    int total = 0;
    for (int x : v) total += x;
    return total;
}

int main() {
    std::vector<int> marks = {72, 65, 91};
    std::cout << sum(marks) << '\n';   // 228

    int& first = marks[0];
    std::cout << first << '\n';        // 72 — fine so far

    marks.push_back(48);               // MAY reallocate the whole buffer
    // std::cout << first;             // `first` may now dangle

    // Safe pattern: re-fetch after any operation that can grow the container
    int& firstAgain = marks[0];
    std::cout << firstAgain << '\n';   // 72, and valid
}
Notes
  • Calling reserve() up front does not make the danger go away, it only postpones it: references stay valid until the vector exceeds the reserved capacity, at which point they all break at once. The habit worth building is not to keep long-lived references into a container you are still modifying.
Ask AI