Foundations · Module 2 · Lesson 7 of 10
Passing by Value, by Reference and by Const Reference
Choose between pass by value, reference and const reference: see what each does to the caller's variable, what it costs at the machine level, and why a reference is an alias implemented as an address.
By the end you can
- Predict whether a call changes the caller's variable for value and reference parameters
- Explain what a reference is at the machine level and what passing one costs
- Choose by value, reference or const reference for a given parameter
- Explain why a const reference accepts literals and temporaries while a plain reference does not
Builds on The Call Stack: What Happens When a Function Is Called.
01 · A question first
Passing a number to a function and passing a book-length string look the same in the call, yet one costs nothing and the other can cost a hundred microseconds. And how can a function change the caller's variable when it only ever receives copies?
Make a guess, then step through the trace below to check it.
02 · Watch it run
void byValue(int n) { n = n + 10;} void byRef(int& n) { n = n + 10;} int main() { int a = 5; byValue(a); byRef(a);}- var 0a: 5(looked at in this step)
- var 1byValue n: --
- var 2byRef n: --
main creates a with the value 5. The three cells are a, the parameter n of byValue, and the parameter n of byRef. The two parameters do not exist yet.
Machine work: store 5 into a
03 · The idea
Start with the problem
Lesson 5 established that a function receives copies. That has two awkward consequences. First, a function that is supposed to change the caller's variable cannot. Here is the classic example, a swap that does nothing useful:
void swapInts(int a, int b) {
int t = a;
a = b;
b = t;
}After swapInts(x, y), the variables x and y are unchanged, because the function swapped two copies. Second, copying is wasteful for big objects. A std::string that holds a long text is copied in full on every call, even if the function only wants to look at it.
C++ has three ways of passing a parameter, and choosing among them is a daily decision.
How the idea works
By value. A parameter such as int n is a copy of the argument, as steps 2 to 4 of the trace show. The function may change its copy freely, and the caller never notices.
By reference. A & after the type, as in int& n, makes the parameter a reference: another name for an existing variable. No copy is made, and whatever the function does to n it does to the caller's variable (steps 5 to 7). The swap that works:
void swapInts(int& a, int& b) {
int t = a;
a = b;
b = t;
}By const reference. The form const std::string& text is a reference plus a promise not to modify through it. There is still no copy, and the compiler enforces the promise: assigning to text is a compile error. A const reference can also bind to literals and temporary values, which a plain reference cannot:
int countSpaces(const std::string& text) {
int count = 0;
for (char c : text) {
if (c == ' ') ++count;
}
return count;
}The loop is the range-based for that lesson 3 previewed: it visits each character of the string.
Rules of thumb.
- Small, cheap types such as
int,double,charandboolgo by value. - Large types that the function only reads, such as
std::string, go by const reference. - When the function must change the caller's variable, use a plain reference. Often, returning the new value is clearer.
What a reference is underneath. A reference is implemented as an address. The call passes the address of the original, eight bytes on a 64-bit machine, and every use of the name goes through it, as the cpu line of step 5 says. A reference must be bound when it is created, cannot be pointed at something else later, and cannot be null.
Two meanings of the ampersand. In a declaration, as in int& n, it forms a reference. In an expression, as in &a, it takes an address, which you met in lesson 3. The same symbol means different things in different places in the grammar.
Why it is correct
A reference does not refer to a copy of an object. It is the object, under a second name, so the function and the caller see exactly one variable, and there is nothing to keep in sync. The rules about initialization and rebinding keep a reference tied to the object it started with, so while that object is alive the reference is valid.
The const qualifier turns read-only access into part of the type, and the compiler checks it. That is what lets a function take a large object without copying and still be unable to modify it. Meanwhile the default of passing by value stays safe: nothing changes behind the caller's back unless the function's signature says so.
What it costs
By value costs the size of the object. For an int that is nothing. For a std::string it means allocating memory and copying every character, once the text is longer than the small-string buffer, which holds roughly 15 to 22 characters depending on the library. Copying a 1 MB string takes on the order of 100 microseconds, so a function called a million times would spend about 100 seconds copying.
By reference costs eight bytes, whatever the size of the object, plus an indirection each time the function uses the name. When the compiler inlines the function, it often removes the indirection.
For tiny types, a reference can lose. Passing an int by const reference adds an address and a memory access for no gain, and it can stop the compiler from keeping the value in a register. As a rule, pass by value when the type is about 16 bytes or smaller and cheap to copy.
Aliasing limits optimization. A function with two reference parameters must allow for the possibility that both refer to the same object, as in f(a, a), which can stop the compiler from reordering its work.
Pitfalls and variations
A literal to a plain reference. Calling inc(5) with a parameter int& fails with an error such as cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'. Use a const reference or pass by value.
A reference to a local. Returning a reference to a local variable gives the caller a name for memory in a frame that was popped, as in lesson 6. It is a dangling reference.
Surprise modification. The call f(x) looks the same whether f takes a copy or a reference, so a reader cannot tell that x may change. Prefer returning values, and name and document parameters that the function modifies.
const is a promise about this name only. After const int& r = x;, you cannot change x through r, but x = 5; is still allowed, and r then sees 5.
Variations. A pointer, which module 3 covers, does a similar job but can be null and can be pointed elsewhere. For read-only text, std::string_view from C++17 is often cheaper than const std::string&, because it also accepts literals without building a string. Rvalue references, written T&&, and std::move belong to the advanced level.
Where it is used
Nearly every C++ function that takes a string or a container takes it by const reference, and the standard library's own signatures follow the rule. Copy constructors take a const reference to the object being copied. And lesson 2's chain of insertions works for the same reason: the insertion operator takes a reference to the stream and returns the same stream, because streams cannot be copied at all.
The next lesson, "Overloading and Default Arguments", shows how several functions can share one name, and how the compiler picks the right one from the types of the arguments.
04 · Prove it
Question 1 of 4 · answer from memory
The functions `void inc(int& n) { ++n; }` and `void inc2(int n) { ++n; }` are called as `inc(a); inc2(a);` with `int a = 5;`. What is `a` afterwards?
05 · Practice
Asked in interviews
Something wrong with this lesson? Report a problem