Embercode
✕
Pointers and Memory AddressesLesson 3 of 16

50 min read

References and passing arguments

Why this matters

Every function you write receives values from its caller. The way it receives them decides three things: whether the function can change the caller's variable, how much memory and time the call costs, and whether the caller's data is safe from accidental change. Beginners often pick one style and use it everywhere. Experienced C++ programmers choose on purpose, and interviewers listen for that choice when you write a helper function.

The last lesson showed the pointer way: pass an address, and write through it with *. C++ also gives you a second way, the reference. A reference is a second name for an existing variable. It lets you write x = 99 inside a function and change the caller's x, without the star and the & that pointers need. This lesson explains both styles, shows when each one is right, and builds the habit of asking "copy, pointer or reference?" before you type the parameter list.

Start with a photocopy

Imagine you ask a colleague to check a number on a long report. You can give them three things.

You can give them a photocopy of the page. They read the copy, and they might scribble on it, but your original page is untouched. This is passing by value. The function gets its own copy of the value, and changes to the copy never reach you.

You can give them a sticky note with the page number and the shelf location written on it. They use the note to walk to the shelf and read or change the original page. This is passing by pointer. The function gets an address, and it reaches the original through that address.

You can give them the original page, with a tag saying "this is the same page, under another name". They write on it directly, and you see their writing when you look. This is passing by reference. The function gets a second name for your variable, with no copy and no address to follow.

Each of the three choices has a use. The rest of the lesson shows exactly what each one does in the code.

Passing by value

By default, C++ passes arguments by value. The function gets a fresh copy of each argument, stored in its own parameter. The parameter is a new variable that lives only while the function runs.

#include <iostream>
using namespace std;
 
void byValue(int x) {
    x = 99;
}
 
int main() {
    int a = 1;
    byValue(a);
    cout << a << endl;
    return 0;
}

Output:

1

Trace it. main creates a with the value 1. The call byValue(a) copies 1 into a new variable named x, which belongs to byValue. The line x = 99 changes that copy. When the function returns, its x disappears. a was never touched, so it still holds 1.

Passing by value is safe. The function cannot damage the caller's data. For a small type such as an int, the copy costs almost nothing, so by value is the right default for small numbers and characters.

Passing by pointer

To change the caller's variable, the function needs an address. The parameter is a pointer, and the call passes the address with &.

#include <iostream>
using namespace std;
 
void byPointer(int* x) {
    *x = 99;
}
 
int main() {
    int b = 1;
    byPointer(&b);
    cout << b << endl;
    return 0;
}

Output:

99

The function receives a copy of the address, which is a second slip with b's house number. The write *x = 99 walks to b and changes it. The caller sees the change.

Pointer parameters have three costs that matter in practice. The call site must write &b, so the reader sees that b may change. The function body must write *x. And the pointer may be nullptr, so a careful function checks if (x != nullptr) before using it. Many C++ functions need that check, because the caller might pass nothing. A reference cannot be null, which is one of the reasons C++ programmers prefer references when the argument is always there.

Passing by reference

A reference is declared with & after the type. The parameter int& x means "x is another name for the int the caller passed". No copy is made, and no address needs to be followed. Inside the function, you write x directly.

#include <iostream>
using namespace std;
 
void byReference(int& x) {
    x = 99;
}
 
int main() {
    int c = 1;
    byReference(c);
    cout << c << endl;
    return 0;
}

Output:

99

The call looks like a normal call. There is no & at the call site. This is the cost of references: the caller cannot see from the call alone that c may change. Good C++ code therefore keeps reference parameters that change the argument easy to spot, usually by naming the function so that it says what it does, such as swapValues or addToTotal.

One box holding 42 with two names, x and r, both pointing at itOne box holding 42 with two names, x and r, both pointing at it

Here are the three styles side by side in one program. Predict the output before you run it.

C++ · editable

Output:

1 99 99

Only the value passed by value stays at 1. The other two change the caller's variable, through different syntax that does the same job.

Tracing a call on paper

The table method from lesson 1 works for references too. It is the best way to settle a doubt about whether a change reaches the caller. Here is a small program.

C++ · editable

Trace it with three columns: the variable n in main, the name x inside bump, and a note about what the statement does.

Stepn in mainx inside bumpWhat happens
int n = 2;2not activen is created
bump(n); starts2another name for nx is bound to the box of n
x = x + 5;77the box holds 7, seen through both names
bump returns7gonethe name x ends, the box stays
second bump(n); starts7another name for nthe same box again
x = x + 5;1212the box holds 12

The final value of n is 12. The row for bump returning matters most. The name x disappears, but the box does not, because main still owns n. A reference never owns anything. It only gives a second route to a box that somebody else owns.

The rules of a reference

A reference is not a pointer, even though it looks like one. Learn these rules, because the compiler enforces every one of them.

A reference must be initialised when it is declared. int& r; is an error. A reference is a name, and a name without a thing to name is meaningless.

error: 'r' declared as reference but not initialized

A reference cannot be re-aimed. r = b; does not make r point at b. It copies the value of b into the thing r already names. This rule trips up almost everyone at first, and worked example 4 in the practice section tests it.

A reference cannot be null. There is no nullptr for references, so a reference always names something. This makes references safer than pointers when a function must have a real target. The cost is that you cannot say "no argument this time". For that case, use a pointer or an overload.

A reference has no address you can take as a separate value. Writing &r gives the address of the thing r names, not the address of r itself. You do not normally need the address of a reference.

A reference cannot be taken from a value that is not a variable. Writing int& r = 5; fails, because the number 5 has no box to name. You will see the message in the mistakes section. The fix is const int& r = 5;, which is allowed and is covered below.

Passing by const reference

A const reference combines two good things. It gives the function the caller's value without copying it, and it promises that the function will not change it. This is the standard way to pass large objects that the function only reads.

C++ · editable

Output:

copy of quarterly
by value: 9
by const ref: 9

The line copy of quarterly appears once, and it comes from the call titleLength(r). Passing Report by value made the compiler build a second Report, using the copy constructor we wrote so we could see it. The call to titleLengthRef built no copy at all. Both functions return the same answer, but the first one does extra work every time it is called.

For a real string or vector, the copy means allocating new memory and copying every character or element. A function called a million times with a large vector passed by value does a million copies. Passing const vector<int>& avoids all of them.

The const matters too. Suppose a struct has an int member, struct Score { int points; };, and a function is written as void bump(const Score& s) { s.points = 5; }. The compiler refuses, because the promise says the function does not change s. The error message is:

error: assignment of member 'Score::points' in read-only object

Read that message as a useful friend. It means the compiler found a place where you broke your own promise.

Temporaries and const references

A temporary is a value with no name. The number 5 in show(5) is a temporary, and so is the result of a + b. A temporary has no box that the caller can name, so a non-const reference cannot be bound to one. The compiler refuses, as the mistakes section showed.

A const reference may bind to a temporary. The compiler creates a box for the temporary and keeps it alive for as long as the reference lives. That is why a function that takes const int& accepts all of these calls:

C++ · editable

Output:

5
5
7

One signature accepts a literal, a computed value and a variable. Because the reference is const, the function cannot change the temporary, and it has no reason to. This is one of the reasons const& is the default for read-only parameters in modern C++.

A non-const reference works only with a named variable. Read the signature before you call a function. If it says int&, you need a variable. If it says const int&, anything works.

Worked example: returning a reference

A function can return a reference too. This lets the caller use the result on the left side of an assignment. It sounds strange, but it is the basis of many standard library features. Here is a function that returns whichever of two ints is larger, as a reference to the original.

C++ · editable

Output:

4 100

The call larger(x, y) returns a reference to y, because 9 is bigger than 4. The assignment writes 100 through that reference, so y becomes 100 and x stays 4. If larger returned an int by value, the assignment would write into a temporary copy, and the program would print 4 9.

The same idea appears in vector. The expression v[2] = 7 works because operator[] returns a reference to the element. You have been using references every time you wrote an assignment to a vector element.

The range-based loop uses the same tool. for (auto& value : v) makes value a reference to each element in turn. for (int value : v) makes a copy of each element, so changes to value do nothing.

C++ · editable

Output:

10 20 30 

The output line has a space after the last number, which is what the loop prints. Forgetting the & in the first loop is one of the most common bugs in C++ code, because the program still runs and simply does nothing useful.

Out parameters

An out parameter is a reference parameter that the function uses to send results back. Before C++ had tuples and structured bindings, this was the usual way to return two values. It is still common in older code and in interview problems that ask you to return more than one answer.

C++ · editable

Output:

min -2, max 9

The vector is passed as const vector<int>&, so the function reads it without copying and cannot change it. The two results are reference parameters, so the function writes straight into lo and hi. The caller declares the two variables first, which is what makes the call work.

Passing containers and arrays

Containers are where the choice matters most. A vector keeps its elements in a heap block, and copying a vector copies the whole block. A function that takes vector<int> by value makes a full copy on every call. A function that takes vector<int>& shares the caller's vector and can change it. A function that takes const vector<int>& reads the caller's vector without copying it.

C-style arrays cannot be passed by value at all. An array parameter decays into a pointer, as lesson 2 explained, so int arr[] in a parameter list really means int*. Pass the length with it, or pass a pair of pointers as the range. Modern code often uses std::array<int, N> or vector instead, because those types carry their size. A std::array passed by value is copied in full, so pass it by const reference too.

When a function only reads a sequence, the most flexible signature is a range, const int* first, const int* last. It accepts the data of a vector, a plain array, or part of either. You can build the range from a vector with v.data() and v.data() + v.size(). The standard library uses this idea throughout. A binary search is a good example of a function that needs the range form, because it must work on any sorted block, not only on whole containers.

What happens inside

It helps to know what the compiler does with a reference, because the rules above follow from it. In most compiled programs a reference is implemented as a pointer that the compiler manages for you. The reference x inside byReference is stored as the address of c, and every use of x becomes a read or write through that address. The difference is that the compiler inserts the dereference, and it refuses the operations that a real pointer would allow, such as re-aiming or comparing to nullptr.

This means passing by reference costs about the same as passing a pointer: the function receives an address, not a copy of the data. The compiler may also optimise the code so that the reference disappears entirely, if it can see the whole function at the call site. Do not depend on that optimisation for correctness. Write the code so the meaning is right, and let the compiler handle the speed.

A copy is different. Passing Report by value builds a new object, runs its copy constructor, and destroys it at the end of the call. The cost grows with the size of the object. For an int the copy is one instruction, but for a string of a million characters the copy is a million bytes of work.

Choosing the style

Use a simple decision list when you write a function.

If the argument is a small value such as an int, double, char or bool, and the function does not change it, pass by value. It is simplest and the cost is negligible.

If the argument is large, such as a string, a vector, a map or your own class, and the function only reads it, pass by const reference. This is the most common style in modern C++.

If the function must change the caller's variable, and the argument is always present, pass by non-const reference. Name the function so the reader expects the change.

If the argument is optional, or the function must be able to say "there is nothing here", pass a pointer and check it for nullptr.

If the function must keep the argument after it returns, for example by storing it in a container, pass by value, or move it in, and do not keep a reference that might outlive the caller's variable. Lesson 5 covers moves and ownership in detail.

A worked trace of minMax

Trace the minMax function from the out parameters section with the vector 4, -2, 9, 0, 7. Keep a table of lo, hi and the value of x on each pass through the loop.

StepxlohiAction
startnone44both set to v[0]
pass 1444no change
pass 2-2-24x < lo, so lo becomes -2
pass 39-29x > hi, so hi becomes 9
pass 40-29no change
pass 57-29no change

The result is lo equal to -2 and hi equal to 9, which is what the program prints. Notice the first pass. It compares 4 with itself, which is harmless. Starting lo and hi at v[0] is what makes the loop correct for any input. If you started both at 0, a vector of positive numbers would report a minimum of 0, which is wrong.

Common mistakes and the messages you see

The first mistake is forgetting the & in a loop that should change elements. The loop runs, the values do not change, and the program prints the original numbers. There is no error message. The fix is auto& value.

The second mistake is returning a reference to a local variable. This compiles with a warning, and the program may even print the right number once by luck. Here is the code:

int& makeValue() {
    int x = 3;
    return x;
}

With g++ the warning reads:

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

The variable x is created on the stack when the function starts. It is destroyed when the function returns. The reference now names a box that no longer belongs to the program. Using it is undefined behaviour, and the result can look fine in a small test and fail in a large one. The rule is simple. Return a reference only to something that lives longer than the function call: a parameter that the caller owns, a member of an object that exists, or an element of a vector that the caller passed in.

A pointer that still holds an address after the function returned, aimed at memory that is goneA pointer that still holds an address after the function returned, aimed at memory that is gone

The third mistake is binding a non-const reference to a temporary, such as int& r = 5;. The compiler gives this message:

error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

The phrase rvalue means a temporary value that has no name. The fix is const int& r = 5;, which is allowed because a const reference can extend the life of the temporary. A function parameter of type const int& accepts both variables and literals, which is the reason it is the usual choice for read-only arguments.

The fourth mistake is confusing re-aiming with assignment. Writing r = b; copies the value of b into the variable that r names. Many beginners expect r to point at b afterwards. It does not. The rule is that a reference is a permanent second name. Only a pointer can be re-aimed.

The fifth mistake is passing a large object by value out of habit. The code is correct, but slow. A reviewer will ask you to change vector<int> v to const vector<int>& v. Make the habit of writing const& for any object type you only read.

Debugging walkthrough: the change that did not happen

Suppose your function was supposed to add 10 to every score, but the scores printed in main are unchanged. Work through the cause.

Step 1: find the function's parameter list. If it says vector<int> scores without an &, the function received a copy. The change happened to the copy, and main never saw it. Add & to make the parameter a reference.

Step 2: if the parameter is already a reference, check the loop. A for (int s : scores) loop copies each element. Change it to for (int& s : scores).

Step 3: if the loop is right, check the call site. A function that takes vector<int>& must be called with a variable, not a temporary. A call such as addTen(makeScores()) will not compile for a non-const reference, and the message tells you so.

Step 4: print the value from inside and outside the function. Add a line that prints scores[0] before the call and after. If the value changes inside the function and not outside, you passed a copy somewhere.

The four steps find almost every "the change did not happen" bug. Do them in order.

Variations and edge cases

An rvalue reference, written int&&, is a reference to a temporary that the function may take ownership of. It appears in move constructors and in std::move. You do not need it for everyday functions. Recognise the syntax, and remember that lesson 5 uses it under the hood.

An auto loop variable is a copy unless you write auto&. The same goes for const auto&, which reads each element by reference without allowing changes. Use const auto& in read-only loops over large containers. It is the modern default.

A reference to a pointer lets a function re-aim a caller's pointer without the caller writing &p. Its type is int*&. You will see it in linked-list code. It does the same job as the int** from lesson 2, with cleaner syntax.

An empty vector passed to minMax above breaks the code, because v[0] does not exist. A real function must check v.empty() first. This is an edge case an interviewer will raise.

A zero or negative value does not change any of these rules. Passing a negative number by value is the same as passing a positive one.

Interview-style questions

Interviewers often ask you to explain the difference between pass by value and pass by reference, and they expect an example. A good answer names a case: "If I write void f(int x) and call f(a), the change inside stays inside the copy. If I write void f(int& x), the change reaches a."

Another common question asks why a function should take const vector<int>& instead of vector<int>. The answer has two parts. The reference avoids a copy of every element, which is a cost of O(n) per call. The const promises that the function will not change the vector, which lets the caller trust the input.

A third question asks what is wrong with int& f() { int x = 1; return x; }. The answer is that x is destroyed when f returns, so the reference names a dead variable, and any use of the result is undefined behaviour.

Common interview follow-ups

After you write a function that takes references, an interviewer often asks one of three follow-up questions.

The first is "Why not pass everything by reference?" A reference lets the function change the caller's data, which is a risk and a surprise to the reader. A value is safer for small types, and a const& is the safe default for large types. Passing by non-const reference should be a deliberate choice, and the function's name should show it.

The second is "What if the input is empty?" A reference cannot be null, so an empty input must be handled by the caller or by a pointer parameter. For a vector, the answer is a check of empty() before any v[0]. Say this before the interviewer asks.

The third is "Can this return a reference?" The answer is yes, but only to something that outlives the call. A reference to a parameter the caller owns is safe. A reference to a local is never safe, and interviewers use this question to test whether you know the difference.

Answer each follow-up with the rule and one example. Short, exact answers score better than long ones.

Real-world uses

The standard library is built on references. std::swap takes two references, so it can exchange the contents of any two objects without knowing their type. std::sort takes iterators, which behave like pointers, so it can reorder a vector in place. Your own code uses references for every parameter that is large or that must change. Learn to read a signature such as void process(const Config& cfg, Results& out) and know at once that cfg is read-only input and out is the place the function writes its answer.

Reading a signature like a contract

A function signature tells the reader what the function may do to each argument before anyone reads the body. Learn to read it that way, because a reader in a hurry judges the call from the signature alone.

void f(int x) says the function gets its own copy, and nothing it does reaches the caller.

void f(int& x) says the function may change the caller's variable. Expect the value to differ after the call.

void f(const int& x) says the function reads the caller's variable and promises not to change it. A literal can be passed too.

void f(int* x) says the function may change the caller's variable, and that the argument may be nullptr, so the body must check it.

void f(const vector<int>& v) says the function reads a sequence without copying it, and cannot change it.

void f(vector<int>& v) says the function may change the sequence, add to it or remove from it. A reader expects a change.

int& f(...) says the caller gets back a name for something that already exists, and may write through it. The thing named must outlive the call.

Each form makes a promise. Choose the form that makes the true promise, and no stronger. A const& is the strongest promise that still lets callers pass a literal. A plain int by value promises the most, because the caller's variable can never change through this call.

In an interview, reading the signature aloud is a good first step. It shows you know what the function may change, and it gives the interviewer a way to check your choice.

A checklist for every parameter

Ask these questions about each parameter of a function you write.

  1. Must a change reach the caller? If yes, take a reference or a pointer. If no, take a value or a const&.
  2. Is the argument large? A string, a vector or your own class should be const& when the function only reads it.
  3. Can the argument be absent? If so, take a pointer and test it for nullptr, because a reference cannot be empty.
  4. Does the function return a reference? Then the thing it names must outlive the call, and a local variable never does.
  5. Does a loop change elements? Then the loop variable must be auto&.

Answer these before you write the body. Most reference bugs come from skipping one of the five questions.

Practice

Exercise 1. Write a function void addTen(int x) and a call that tries to add 10 to a variable score that starts at 5. Print score after the call. Predict the output first. Then change the parameter to int& x and predict again.

Solution

With void addTen(int x) the call changes only a copy, so score still prints 5. With void addTen(int& x) the parameter names score directly, and the call prints 15.

void addTen(int& x) {
    x = x + 10;
}

Exercise 2. Write a function void doubleAll(vector<int>& v) that doubles every element. Call it on {1, 2, 3} and print the vector. Then write the same function with the loop variable copied by mistake (for (int x : v)) and explain why nothing changes.

Solution

The correct version uses for (int& x : v) { x = x * 2; } and prints 2 4 6. The mistaken version has int x as the loop variable, so each pass copies one element into x. Doubling x changes only the copy, and the vector keeps the values 1, 2 and 3.

Exercise 3. Write int sumOf(const vector<int>& v) that returns the sum of the values. Explain in one sentence why the parameter is const& and not vector<int>.

Solution
int sumOf(const vector<int>& v) {
    int total = 0;
    for (int x : v) {
        total += x;
    }
    return total;
}

The const& avoids copying every element of the vector on each call, and the const promises that the function will not change the caller's data.

Exercise 4. Write the code that makes int& r = a; and then r = b;, and explain what the value of a is afterwards. Do not change which variable r names.

Solution
int a = 1, b = 2;
int& r = a;
r = b;

The value of a is now 2. The line r = b does not re-aim r at b. It copies the value of b into the box that r names, which is a. A reference cannot be re-aimed.

Exercise 5. Write a function int& biggerOf(int& a, int& b) and use it on the left side of an assignment to change the bigger value to 0. Check that the smaller value is untouched.

Solution
int& biggerOf(int& a, int& b) {
    return (a >= b) ? a : b;
}

With int x = 4, y = 9;, the statement biggerOf(x, y) = 0; sets y to 0. The value of x stays 4. The function returns a reference to y, which is why the assignment reaches it.

Exercise 6. Write a function void half(double& value) that halves its argument. Call it on 7.0 and print the result (3.5). Then try calling it with the literal half(7.0); and read the error message. Explain why it fails.

Solution
void half(double& value) {
    value = value / 2;
}

Store the number in a variable first: double d = 7.0; half(d); leaves d at 3.5. The call half(7.0) fails, because the literal has no box to name, and a non-const reference must name a box. The compiler message reads like cannot bind non-const lvalue reference of type 'double&' to an rvalue of type 'double'. The fix is either the named variable or a const double& parameter, if the function only reads.

Exercise 7. Write bool isEmpty(const string& s) and test it with an empty string, a string with a space and a longer string. Explain why no copy is made.

Solution
bool isEmpty(const string& s) {
    return s.empty();
}

The empty string gives true. The string with one space gives false, because a space is a character. The longer string gives false. No copy is made, because the parameter is a reference to the caller's string. The const stops the function from changing it.

Exercise 8. Explain why int& makeValue() { int x = 3; return x; } is a bug, and rewrite the function so it returns a value by copy instead.

Solution

The local x is destroyed when makeValue returns, so the reference names a box that no longer belongs to the program. Using the result is undefined behaviour. The fix returns the value itself:

int makeValue() {
    int x = 3;
    return x;
}

The caller now receives its own copy of 3, which is safe to keep.

Exercise 9. Write a function void resetIfNull(int* p) that sets *p to 0 when p is not nullptr. Explain why this needs a pointer and not a reference, and say what the function must do with a nullptr argument.

Solution
void resetIfNull(int* p) {
    if (p != nullptr) {
        *p = 0;
    }
}

The function needs a pointer because the argument may be absent. A reference must always name a box, so there is no way to pass "nothing". With a nullptr argument the function does nothing, which is the behaviour the name promises.

Exercise 10. Write void minMax(const vector<int>& v, int& lo, int& hi) for an empty vector, so it returns without touching lo or hi. Explain why the check must come before v[0] is read.

Solution
void minMax(const vector<int>& v, int& lo, int& hi) {
    if (v.empty()) {
        return;
    }
    lo = v[0];
    hi = v[0];
    for (int x : v) {
        if (x < lo) lo = x;
        if (x > hi) hi = x;
    }
}

An empty vector has no element 0. Reading v[0] would read memory that does not hold a value, which is undefined behaviour. The check makes the function return before that read happens, and the caller's lo and hi are left alone.

What you now know

  • Passing by value gives the function a copy, so changes stay inside the function.
  • Passing by pointer gives the function an address, and it writes with *p. The pointer can be nullptr.
  • Passing by reference gives the function a second name for the caller's variable, with no & at the call site.
  • A reference must be initialised, cannot be re-aimed, cannot be null, and assigning to it changes the value it names.
  • A const& parameter avoids copying a large object and promises that the function will not change it.
  • A function may return a reference, but only to something that outlives the call. Returning a reference to a local is undefined behaviour.
  • Range-based loops need auto& to change elements, and plain auto changes only a copy.

Lesson 4 takes the next step. Instead of values that live in a function's own frame, it looks at memory you ask for yourself with new, which lives until you give it back with delete.

Practice this topic: coding problems for Pointers and Memory Addresses →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Pointer arithmetic, arrays and pointer to pointer