Why it matters
Suppose a function adds up the lengths of the songs in a playlist. The playlist holds 10,000 songs. If every call to the function receives a full copy of that list, the computer copies 10,000 numbers before the function does any work. Call the function a thousand times and the computer copies ten million numbers. That work is wasted, because the function only reads the list. It never changes it.
C++ gives you two ways to hand a value to a function. The first makes a copy. The second hands over the original. For a large object the second is much faster. It has one risk: the function could change the caller's data by mistake. The keyword const removes that risk. It tells the compiler that the function will only read the data. If the function tries to change it, the compiler stops with an error before the program runs.
This lesson covers both halves: how a copy happens, how a reference avoids it, and how const makes the rule safe. The lesson on pass by value and reference, earlier in this module, explains the basic idea of a copy. This lesson applies that idea to large objects.
Words used in this lesson
- Parameter: a name in a function's header that receives a value. In
void show(int n), the parameter isn. - Argument: the value you pass in when you call the function. In
show(5), the argument is5. - Object: a piece of data in memory that has a type, such as a vector of numbers or a string.
- Copy: a second, separate object filled with the same contents. Changing the copy does not change the original.
- Reference: another name for an existing object. You write it with
&after the type. A change made through the reference changes the original. - const: a keyword that marks a value as read-only. The compiler refuses any code that tries to change it.
- Heap: the part of memory where a program stores data whose size is decided while it runs. A
std::vectorkeeps its numbers on the heap.
The idea: a photocopy or the original
Think of a 500-page book. You can give a friend a photocopy, which takes time and paper. Or you can let them read your copy at your desk, which takes no copying. If they write notes in the margin, your book now has notes. A sign on the cover that says "read only" stops that. In C++, const is that sign.
A parameter with a plain type receives a copy:
void show(std::vector<int> songs); // the function gets its own copyA parameter with & receives the original under another name:
void show(std::vector<int>& songs); // the function works on the caller's vectorA parameter with const and & receives the original, and the compiler forbids changes through that name:
void show(const std::vector<int>& songs); // read-only, no copyThe third form is the one this lesson is about. You can read it as: songs is a reference to a vector, and this function cannot change that vector.
Step 1: see the copy happen
The best way to understand a copy is to count one. The program below defines a small type called Playlist. A type is a kind of object you define yourself. This one has a list of song lengths. It also has a counter that goes up each time a copy is made.
#include <iostream>
#include <vector>
struct Playlist {
std::vector<int> song_lengths; // seconds per song
inline static int copies = 0; // how many times a copy was made
explicit Playlist(std::vector<int> lengths) : song_lengths(std::move(lengths)) {}
Playlist(const Playlist& other) : song_lengths(other.song_lengths) { ++copies; }
};
int total_by_value(Playlist p) {
int sum = 0;
for (int s : p.song_lengths) {
sum += s;
}
return sum;
}
int total_by_const_ref(const Playlist& p) {
int sum = 0;
for (int s : p.song_lengths) {
sum += s;
}
return sum;
}
int main() {
Playlist mine(std::vector<int>{180, 240, 215, 200});
std::cout << "by value: total " << total_by_value(mine)
<< ", copies made " << Playlist::copies << '\n';
std::cout << "by const ref: total " << total_by_const_ref(mine)
<< ", copies made " << Playlist::copies << '\n';
}Some parts need a short explanation. The word static means the counter belongs to the type as a whole, not to one playlist. The word inline lets the counter be given its starting value inside the class. The line that starts with Playlist(const Playlist& other) is a copy constructor. The computer runs it whenever it makes a new Playlist from an existing one. The ++copies line counts those events. A real std::vector does not print or count anything. This counter is only there so you can see the copy.
Compile and run it with the command g++ -std=c++20 -Wall playlist.cpp -o playlist, then ./playlist. The output is:
by value: total 835, copies made 1
by const ref: total 835, copies made 1Both lines add the same four songs and get 835, because 180 + 240 + 215 + 200 is 835. The copy count on the first line is 1, because total_by_value received a copy of mine when it was called. The second call received the original by reference, so it made no copy. The count stayed at 1.
The copy cost is small here because the list has four numbers. The cost grows with the size of the list. Copying a list of 10,000 numbers means allocating a new block on the heap and filling it with 10,000 values, every time the function is called.
Step 2: add the reference and const
The second function in the program is total_by_const_ref. Its header is int total_by_const_ref(const Playlist& p). Read it piece by piece:
Playlistis the type of the parameter.&makes the parameter a reference. No copy is made when the function is called.constmakes the reference read-only. Code inside the function can readp, but cannot change it.
The body of the function is the same loop as before. Only the header changed. This is the usual pattern: add const& to a parameter and leave the body alone. If the body still compiles, the function is not changing its input.
What happens when the body does try to change the input? Here is a program that tries to add a number to a const vector and to change one of its numbers:
#include <vector>
void add_zero(const std::vector<int>& v) {
v.push_back(0);
}
void change_first(const std::vector<int>& v) {
v[0] = 99;
}
int main() {
std::vector<int> a{1, 2};
add_zero(a);
change_first(a);
}The compiler stops before it makes a program. Its error messages, shortened here, are:
const_error.cpp: In function 'void add_zero(const std::vector<int>&)':
const_error.cpp:4:16: error: passing 'const std::vector<int>' as 'this' argument discards qualifiers [-fpermissive]
4 | v.push_back(0);
const_error.cpp: In function 'void change_first(const std::vector<int>&)':
const_error.cpp:8:10: error: assignment of read-only location '(& v)->std::vector<int>::operator[](0)'
8 | v[0] = 99;The lines the compiler leaves out show where the standard library defines push_back. You do not need them to read the error. The first error says that push_back would change the vector, and the vector is read-only through v. In a member function call such as v.push_back(0), the word this means the object the function was called on. The second error says that v[0] names a number that cannot be assigned to.
These errors appear at compile time, which means you see them before the program runs. That is the reason to use const. A bug that would change the caller's data becomes a message you fix in a minute, instead of a wrong answer you find in a week.
Step 3: a const object can only call const member functions
A member function is a function that belongs to a type, such as size() for a vector. When you write a class yourself, you decide whether each member function is const. The rule is this: a const object or a const reference can only call member functions that are marked const.
The next program shows the error. The Name type has a member function length() that is not marked const. The function show takes a const Name&. Calling length() on it is not allowed:
#include <cstddef>
#include <iostream>
#include <string>
struct Name {
std::string text;
std::size_t length() { return text.size(); }
};
void show(const Name& n) {
std::cout << n.length() << '\n';
}
int main() {
Name n{"Ada"};
show(n);
}The compiler reports:
name_length.cpp: In function 'void show(const Name&)':
name_length.cpp:12:26: error: passing 'const Name' as 'this' argument discards qualifiers [-fpermissive]
12 | std::cout << n.length() << '\n';
name_length.cpp:8:17: note: in call to 'std::size_t Name::length()'
8 | std::size_t length() { return text.size(); }The fix is to add const after the parentheses of length:
std::size_t length() const { return text.size(); }The const after the parentheses is a promise that the function does not change the object it belongs to. The compiler checks the promise. If the body tried to change text, the compiler would stop with an error. The standard library follows the same rule. The size() and begin() functions of std::vector and std::string are marked const, which is why you can call them on a const reference.
Put plainly: a const& parameter works only when every member function you call on it is also const. If you write a type with const& parameters in mind, mark the functions that only read as const from the start.
Step 4: a worked example with a class list
The next program is closer to real code. It stores students, each with a name and a list of marks. It computes each student's average, the class average, and the best student. Every function takes its data by const reference.
#include <iostream>
#include <string>
#include <vector>
struct Student {
std::string name;
std::vector<int> marks;
double average() const {
if (marks.empty()) {
return 0.0;
}
int total = 0;
for (int m : marks) {
total += m;
}
return static_cast<double>(total) / static_cast<double>(marks.size());
}
};
// Reads the class list. Nothing is copied and nothing is changed.
const Student& best_student(const std::vector<Student>& students) {
const Student* best = &students.front();
for (const Student& s : students) {
if (s.average() > best->average()) {
best = &s;
}
}
return *best;
}
double class_average(const std::vector<Student>& students) {
double sum = 0.0;
for (const Student& s : students) {
sum += s.average();
}
return students.empty() ? 0.0 : sum / static_cast<double>(students.size());
}
void print_line(const Student& s) {
std::cout << s.name << ": " << s.average() << '\n';
}
int main() {
std::vector<Student> class_list{
{"Ada", {88, 92, 79}},
{"Ben", {65, 70, 81}},
{"Chen", {90, 95, 97}},
};
for (const Student& s : class_list) {
print_line(s);
}
std::cout << "class average: " << class_average(class_list) << '\n';
std::cout << "best: " << best_student(class_list).name << '\n';
}Three details in this program matter.
The first is static_cast<double>. Without it, total / marks.size() would divide two whole numbers and throw away the fraction. The cast turns the total into a double first, so the division keeps the decimals. The static_cast form is the standard way to convert a value in C++.
The second is the return type of best_student, which is const Student&. The function returns a reference to one element of the vector that the caller owns. That is safe here, because the caller's class_list lives until the end of main, long after best_student returns. Step 6 shows what goes wrong when the object a reference points to is destroyed first.
The third is the loop variable. The line for (const Student& s : class_list) is a reference, not a copy. Each pass of the loop gives s the name of one student in the vector. If you wrote for (Student s : class_list), each pass would copy a whole student, including the name and the list of marks. The program would still give the same answer, but it would make a copy for every student on every loop.
The output is:
Ada: 86.3333
Ben: 72
Chen: 94
class average: 84.1111
best: ChenThe numbers are checked by hand. Ada's marks add to 259, and 259 divided by 3 is 86.333... The default output of std::cout shows six significant digits, so it prints 86.3333. Ben's marks add to 216, which gives 72. Chen's marks add to 282, which gives 94. The class average is the average of those three numbers, 252.333... divided by 3, which is 84.111...
Step 5: when a plain type is the better choice
A const& parameter is the right choice for large objects: vectors, strings, and structs that hold several fields or lists. For small values such as int, double, char and bool, the reference brings no benefit. Copying a single int takes about as much work as passing its address. The code also reads more simply with a plain type.
int square(int n) { // plain type: the copy is one int
return n * n;
}Plain types are also right when the function needs its own changeable copy. A parameter that is taken by value is a local object the function may change freely. The caller's object is not touched. The next program shows this case:
#include <iostream>
#include <string>
std::string add_bang(std::string text) {
text += "!"; // changes the local copy only
return text;
}
int main() {
std::string word = "hi";
std::cout << add_bang(word) << ' ' << word << '\n';
}Its output is:
hi! hiThe function returned hi!, and word still holds hi. If the parameter had been std::string& text, the caller's word would have changed to hi!. If it had been const std::string& text, the += line would not compile.
The rule that follows: use a plain parameter when the function must change its own copy or when the object is small. Use const& when the function only reads a large object.
Step 6: a reference that outlives its object
A reference points to an object that already exists. If that object is destroyed while the reference is still in use, the reference points to memory that the program no longer owns. This is called a dangling reference. Using it is undefined behaviour. The language does not promise what will happen, so the program may print the right value, print garbage, or crash.
This program has the bug:
#include <iostream>
#include <vector>
const int& first_of_bad(std::vector<int> v) {
return v[0]; // v is destroyed when the function returns
}
const int& first_of_good(const std::vector<int>& v) {
return v[0]; // v belongs to the caller, so the reference stays valid
}
int main() {
std::vector<int> nums{4, 5};
std::cout << "good: " << first_of_good(nums) << '\n';
std::cout << "bad: " << first_of_bad(nums) << '\n';
}first_of_bad takes its vector by value. The parameter v is therefore a copy, owned by the function. The function returns a reference to v[0]. When the function ends, v is destroyed, and the reference points to a number that no longer exists.
Compile it with g++ -std=c++20 -Wall and the compiler prints nothing. Running it prints:
good: 4
bad: 4The "bad" line looks correct, but that is luck. The memory had not been reused yet when cout read it. Adding -O2 to the compile command did not produce a warning either. Do not trust a program because its output looks right when it contains this bug.
The fix is in first_of_good: take const std::vector<int>&. Then the returned reference points into nums, which lives in main until the program ends.
Common mistakes
Passing a large object by value out of habit. A function written as void print_all(std::vector<std::string> names) copies every name on every call. The fix is const std::vector<std::string>& names. The body stays the same.
Copying inside a loop by mistake. for (Student s : class_list) copies each student. It still prints the right answer, so the mistake is easy to miss. Write for (const Student& s : class_list) instead. The same applies to auto: for (auto s : class_list) copies, while for (const auto& s : class_list) does not.
Calling a function that is not marked const on a const reference. The compiler reports passing 'const Name' as 'this' argument discards qualifiers. Read the message as: this function may change the object, and the reference promised it would not. Add const after the parentheses of the member function, but only if the function really does not change the object.
Writing to a const parameter. The error assignment of read-only location or passing 'const std::vector<int>' as 'this' argument discards qualifiers means the body tries to change the caller's data. Look for the line the compiler points to. Either the change is a bug, or the parameter should be taken by value because the function needs its own copy.
Returning a reference to a parameter that was taken by value. The function returns a name for an object that dies when the function ends. Fix the parameter so it is a const& to the caller's object, or return a value instead of a reference.
Believing const protects the object for ever. const protects the object only inside the function that has the const& parameter. Before and after the call, the caller can change its own object as usual. The rule describes what one function promises, not what the object is.
Practice
Each exercise is followed by its answer. Try the exercise before you read the answer.
Exercise 1. Change this function so that it does not copy the vector. Keep the body the same.
int count_long(std::vector<std::string> words) {
int count = 0;
for (const std::string& w : words) {
if (w.size() > 5) {
++count;
}
}
return count;
}Show the answer
Change the parameter to const std::vector<std::string>& words. The loop already uses const std::string&, so it does not copy each word. The function now reads the caller's vector and never changes it.
Exercise 2. The program below does not compile. Find the missing word and say where it goes.
#include <iostream>
struct Counter {
int value = 3;
int get() { return value; }
};
void print(const Counter& c) {
std::cout << c.get() << '\n';
}
int main() {
Counter c;
print(c);
}Show the answer
The word const is missing after the parentheses of get. It should read int get() const { return value; }. The const promises that get only reads value, which is the rule a const& parameter needs.
Exercise 3. Predict the output of this program, then compile and run it to check.
#include <iostream>
#include <vector>
struct Playlist {
std::vector<int> song_lengths;
inline static int copies = 0;
explicit Playlist(std::vector<int> lengths) : song_lengths(std::move(lengths)) {}
Playlist(const Playlist& other) : song_lengths(other.song_lengths) { ++copies; }
};
void f(Playlist p) {
// takes its parameter by value and does nothing else
}
int main() {
Playlist a(std::vector<int>{1, 2});
f(a);
f(a);
std::cout << Playlist::copies << '\n';
}Show the answer
The output is 2. Each call to f receives a copy of a, so the counter goes up once per call. The copy counter in main is read at the end, after both calls.
Exercise 4. Which is the better parameter for a function that squares a number: int n or const int& n? Give one reason.
Show the answer
Use int n. An int is small, so copying it costs about as much as passing a reference. The plain form reads more simply. A const int& would also compile and give the same result.
Exercise 5. This function returns a reference to a name, but the caller can end up with a dangling reference. Find the bug and fix it.
#include <string>
#include <vector>
const std::string& first_name(std::vector<std::string> names) {
return names[0];
}Show the answer
The parameter names is taken by value, so it is a copy that the function owns. The returned reference points into that copy, which is destroyed when the function ends. Change the parameter to const std::vector<std::string>& names. The returned reference then points into the caller's vector, which lives longer than the call.
Summary
- Passing a large object by value makes a full copy each time the function is called.
- The
&symbol passes the original under another name, with no copy. constin front of the reference makes the name read-only, and the compiler enforces that rule.- A
constreference can only call member functions that are markedconstafter their parentheses. - Use
const&for large objects and plain types forint,double,charandbool. - Never return a reference to a parameter that was taken by value, because that reference outlives the object it points to.