Declarator Operators and Declaration Reading
A declaration is easiest to read by starting at the name and moving outward through each declarator operator.
A Read-Only Function Over Large Data
Many functions only need to look at the data they receive. A function may compute a total, search for a value, or print a report, and it never needs to change the caller's data. Even so, how an argument is passed is a real design decision, because the passing style determines both what the function is allowed to do to the argument and how much work each call performs. This article follows one small example, a function that adds up the numbers in a vector, and uses it to examine a tool built for exactly this situation: the const reference.
What do you think happens?
If the body of a sum function that receives a const reference to a vector tries to write to one of the elements, will the function compile?
Reveal answer
No, the write is rejected at compile time
The const in the parameter declaration says the referred-to object cannot be modified through this name. That restriction is enforced by the compiler at the line that tries to modify the parameter, so the program does not build.
Copying Versus Sharing the Argument
The choice of a const reference is a tradeoff between two goals. The first goal is to keep the function from changing the argument. The second is to avoid the cost of copying it. Passing by value gives the function its own copy, which protects the caller's data but costs the work of duplicating it. Passing by non-const reference avoids that copying, but on its own it allows the function to modify the original. Adding const to the reference keeps the saving and removes the ability to change the data.
The diagram shows the single path the data takes. A const reference parameter does not hold a separate copy of the vector. Reads go through to the caller's data, and writes are blocked at compile time. This makes the const reference a promise enforced by the language rather than a convention the programmer has to remember.
Anatomy of the sum Declaration
The source describes a declaration of the form double sum(const vector<double>&). Every part of that line has a job. The word double is the return type, the name sum identifies the function, and the parameter list contains one parameter whose type is written as const vector<double>&. The diagram below labels each token inside that declaration.
Tracing the sum Function
Start with a function that adds up the numbers in a vector. Its parameter is a const reference to a vector of doubles. Read the code as a plain description of behavior: the function reads each element, accumulates a running total, and returns it. The code below is an illustrative example written to match that description.
using std::vector;
double sum(const vector<double>& values) {
double total = 0.0;
for (size_t i = 0; i < values.size(); ++i) {
total += values[i];
}
return total;
}- 1Line 1 brings the vector name into scope so the declaration can say vector<double>.
- 2Line 3 is the declaration from the previous section: double return type, sum name, const reference parameter.
- 3Line 4 creates a local running total initialized to zero.
- 4Lines 5 to 7 read each element and add it to total. Reading values[i] is allowed through a const reference.
- 5Line 8 returns the accumulated total to the caller.
- The parameter values is a reference, so no copy of the vector is made when sum is called.
- The const keyword means values cannot be modified through this name.
- The loop reads each element and accumulates it into total.
Working Out the Result of sum
What does sum return when it is called with the vector 1.5, 2.5, 3.0?
- 1Start the total
total begins at 0.0.
- 2Add the first element
total becomes 0.0 + 1.5, which is 1.5.
- 3Add the second element
total becomes 1.5 + 2.5, which is 4.0.
- 4Add the third element
total becomes 4.0 + 3.0, which is 7.0.
- 5Return
The loop ends and sum returns the accumulated total.
sum returns 7.0. The function only read the vector, so the caller's data is unchanged.
Editing the Body to Write Through the Name
Now suppose someone edits the body so that it tries to change the vector it was given. The line added below assigns a new value to the first element through the const reference. Before reading the answer, consider what the compiler should do with that line, and then compare the two versions side by side.
double sum(const vector<double>& values) {
double total = 0.0;
for (size_t i = 0; i < values.size(); ++i) {
total += values[i];
}
return total;
}The parameter is a const reference. The body only reads elements and returns a total.
double sum(const vector<double>& values) {
double total = 0.0;
for (size_t i = 0; i < values.size(); ++i) {
total += values[i];
}
values[0] = 0.0;
return total;
}The added assignment writes through the const reference, so the program fails to compile at that line.
The loop and the return statement behave exactly as before. The divergence from the intended behavior is located at the single modifying line. The compiler enforces the restriction at that statement, so the edited version never produces a program that computes a total.
Finding the Line That Fails
When a const reference version of a function fails, the fastest route is to locate the exact line where the behavior stops matching what you intended. Ask which statement attempts to modify the object the parameter refers to. In the sum example, that is the assignment to values[0]. Everything else in the body reads the data, which the const reference allows.
- 1Identify the parameter and confirm that its declaration includes const.
- 2Scan the body for any statement that assigns to, or otherwise modifies, that parameter or its elements.
- 3Mark the first such statement. The restriction is enforced at that line.
- 4Confirm that reads such as values[i] and
values.size()are still permitted, because only modification is blocked.
Choosing a Passing Style
Once the mechanics are clear, the design choice is usually straightforward. The table compares the three styles on the two properties that drive the decision: whether the call copies the argument, and whether the function is allowed to change the caller's data.
| Passing style | Copies the argument? | Can the function modify the caller's data? | Best fit |
|---|---|---|---|
| By value | Yes, the function gets its own copy | No, changes affect only the copy | Small values, or when the function needs its own working copy |
| By non-const reference | No, the function shares the caller's object | Yes, changes reach the caller's object | When the function is meant to modify its argument |
| By const reference | No, the function shares the caller's object | No, writes are rejected at compile time | Read-only functions taking large arguments |
Three ways to pass an argument, compared on copying and modifiability
Common Mistakes with const References
Common mistake Believing a const reference parameter makes a copy of the vector
What it looks likeAssuming sum(const vector<double>&) duplicates the caller's data before the loop runs.
Why it failsThe & makes the parameter a reference, so the parameter is an alias to the caller's vector and no copy is made.
Do this insteadThink of the parameter as another name for the caller's object, with writes blocked by const.
Common mistake Reading the ampersand as address-of inside a declaration
What it looks likeReading const vector<double>& as taking the address of a vector.
Why it failsIn a declaration, & is a declarator operator that builds a reference type rather than performing an address operation.
Do this insteadRead T& as a reference to T when it appears in a parameter or variable declaration.
Common mistake Expecting the error on the first line of the function
What it looks likeLooking only at the return statement when the compile error is reported for values[0] = 0.0.
Why it failsThe restriction is enforced at the statement that modifies the parameter, not wherever the function ends.
Do this insteadScan the body for the first statement that writes through the parameter.
Common mistake Assuming const blocks reading as well
What it looks likeRemoving the loop because the parameter is const.
Why it failsconst only blocks modification through the name. Reads such as values[i] and
values.size()remain allowed.Do this insteadKeep reads, and remove or move only the statements that modify the parameter.
Common mistake Choosing const reference for a function that must change its argument, which defeats the purpose of the call
Practice Reading the Declaration
Consider the function double first(const vector<double>& values) whose body contains two statements: values[0] = 1.0; followed by return values[0];. Which statement fails to compile, and why does the compiler reject it there? Then state whether the function would be a good candidate for a const reference parameter.
Hints
- Find the statement that writes through the parameter.
- Reads such as return values[0]; are allowed through a const reference.
- A function that only reads a large argument is the case const references are designed for, even if it is short.
Key Takeaways
- A const reference lets a function read an argument without copying it, while forbidding changes to the original variable through that name.
- Passing by value copies the data and protects the caller, passing by non-const reference avoids copying but allows changes, and passing by const reference avoids copying and blocks changes.
- In a declaration, & is a declarator operator that builds a reference type, so T& means a reference to T, and const modifies the object that the reference refers to.
- Read a declaration from the name outward, applying each declarator operator to the type built so far.
- When a const reference function fails to compile, find the exact line that modifies the parameter, because the restriction is enforced at that statement.