C++ from the ground up

References and pass by reference

The single idea that explains most C++ function signatures: a reference is another name for a value that already exists, so a function can change the caller copy or read it without copying it.

Part 7 of 12

Intermediate17 min readBack to Courses

A copy, by default

Every ordinary parameter is a copy. When you call a function with an int, the function gets its own int holding the same number, and assigning to it inside the function changes only that copy. This is safe, and it is also the reason a function written to double your score appears to do nothing.

Copying also costs something. For a number it is nothing; for a long string or a list of a thousand records it is real work done on every call, for a value the function only wanted to read.

A reference is another name

Put an ampersand after the type and the parameter stops being a copy: void bump(int& value) makes value another name for the caller variable. Assigning to it changes the caller value, because there was only ever one variable with two names.

Calling the function looks exactly the same as before — bump(score) — which is deliberate. The signature is where C++ tells you what happens, and a reader who wants to know whether their variable can change must look at the parameter list.

The const reference

const int& or const std::string& is the third shape, and the one you will write most: a reference, so nothing is copied, marked const, so the function cannot change it. It is the standard way to hand a function something large purely to read.

Between the three you have the whole vocabulary. Pass by value when the function should work on its own copy. Pass a const& when it should read. Pass a plain & when it should change what the caller holds — and save that for the cases that really are changes, because a function that quietly rewrites its arguments is harder to follow than one that returns a new value.

Changing the caller, reading without copying

#include <iostream>
#include <string>
#include <vector>

void bump(int& value) {
    value = value + 1;
}

void rename(std::string& title) {
    title = title + " (revised)";
}

void report(const std::vector<int>& values) {
    int total = 0;
    for (int v : values) {
        total += v;
    }
    std::cout << "size " << values.size();
    std::cout << ", total " << total << '\n';
}

int main() {
    int score = 41;
    bump(score);
    std::cout << "score: " << score << '\n';

    std::string title = "Draft";
    rename(title);
    std::cout << "title: " << title << '\n';

    std::vector<int> numbers = {3, 4, 5};
    report(numbers);
    return 0;
}

The same call, three effects

Run it and note which values changed. Then add a function that takes a vector by reference and pushes one item onto it, call it from main, and print the size afterwards to prove the list really changed.

Output
Click "Run" to execute your code

A function taking a plain reference cannot be handed a literal. bump(41) is refused by the compiler because there is no variable for the reference to name, and that refusal is a useful error rather than an annoyance.

A reference is not a pointer

A reference has no separate existence to inspect: it is another name for an object, it must be bound when it is created, and it cannot be rebound afterwards. After int& r = a;, writing r = b; assigns b value through the name — it does not make r refer to b. A pointer, by contrast, is a value of its own that can be null, reassigned and printed.

That is why a reference cannot be null and why bump(41) is refused: there has to be an object for the name to denote. It is also why references are the safer default for parameters. When you need maybe nothing, use a pointer or std::optional; when you need this exact object, use a reference.

A reference in a range-based loop

The range-based loop from the vector chapter already uses one: for (const std::string& title : books) names each item in place rather than copying it. Drop the const and the loop can change the list through that name: for (int& score : scores) { score += 5; } adds five to every score.

It is the same rule as a parameter — the ampersand makes the name refer to the stored value rather than to a copy. That is why the const matters in a loop that only reads: without it, the loop could write, and a reader has to check the body to find out that it does not.

When not to return a reference

Returning a reference is a promise about lifetime: the caller name stays valid only while the object it refers to is alive. Returning one to a local breaks that promise, and the compiler will warn but not always stop you. Return a reference when the function hands back something it did not create — an element of a container passed in, a member of an object that outlives the call.

Return a value when the function built the thing itself. The copy or move is the price of a safe answer, and for small types it is no price at all.

Three reference mistakes: returning a reference to a local (int& make() { int x = 1; return x; } leaves a name for dead storage), binding a reference member to a temporary that dies at the end of the full expression, and using a plain & parameter when the function only reads — that quietly tells the caller their value may change.

Try these next

  • Write void swap(int& a, int& b) and prove that both caller values changed.
  • Write a function that takes a vector by const& and returns its largest item.
  • Change a const& parameter to a plain & and try to pass a literal; read the error.

A ledger changed in place

Run it and note that `addEntry` changed the caller list without returning anything. Then add a function that doubles every amount by reference, and print the total afterwards.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What happens to a variable passed to a function whose parameter is a plain int?