Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Read-Only Parameter Passing
Pass objects efficiently without allowing the function to modify them.
Pass by Const Lvalue Reference
Unlike non-const references (which only bind to modifiable lvalues), references to const can bind to modifiable lvalues, non-modifiable lvalues, and rvalues. Therefore, making a reference parameter const allows it to bind to any argument type:
#include <iostream>
void display(const int& value) // value is const reference
{
std::cout << value << '\n';
}
int main()
{
int score{85};
display(score); // Valid: score is modifiable lvalue, value binds to score
const int maximum{100};
display(maximum); // Valid: maximum is non-modifiable lvalue, value binds to maximum
display(42); // Valid: 42 is rvalue literal, value binds to temporary int
return 0;
}
Passing by const reference provides the same primary benefit as pass by non-const reference (avoiding argument copies) while guaranteeing the function cannot change the referenced value.
For example, this is disallowed because bounded is const:
void increment(const int& bounded)
{
++bounded; // Error: bounded is const
}
In most cases, we don't want functions modifying argument values.
Favor passing by const reference over passing by non-const reference unless you have a specific reason to do otherwise (e.g., the function needs to change the argument's value).
Now we understand why const lvalue references can bind to rvalues: without this capability, there'd be no way to pass literals (or other rvalues) to functions using pass by reference!
Passing Different Types to Const Lvalue Reference Parameters
In the lesson on lvalue references to const, we noted that const lvalue references can bind to different types, provided conversion is possible. The conversion creates a temporary object that the reference parameter binds to.
The primary motivation for allowing this is so we can pass values as arguments to either value parameters or const reference parameters in exactly the same way:
#include <iostream>
void displayValue(double number)
{
std::cout << number << '\n';
}
void displayRef(const double& number)
{
std::cout << number << '\n';
}
int main()
{
displayValue(15); // 15 converted to temporary double, copied to parameter
displayRef(15); // 15 converted to temporary double, bound to parameter
return 0;
}
With pass-by-value, we expect a copy, so if conversion occurs first (resulting in an additional copy) it's rarely an issue (and the compiler likely optimizes one copy away).
However, we often use pass by reference specifically to avoid copies. If conversion occurs first, this typically creates a (possibly expensive) copy, which can be suboptimal.
With pass by reference, ensure the argument type matches the reference type, or an unexpected (and possibly expensive) conversion will occur.
Mixing Pass by Value and Pass by Reference
Functions with multiple parameters can determine whether each parameter is passed by value or reference individually:
#include <string>
void process(int a, int& b, const std::string& c)
{
}
int main()
{
int count{10};
const std::string label{"Processing"};
process(42, count, label);
return 0;
}
In this example, the first argument is passed by value, the second by reference, and the third by const reference.
When to Use Pass by Value vs Pass by Reference
For C++ beginners, choosing between pass by value and pass by reference isn't always obvious. Fortunately, there's a straightforward rule of thumb that works well in most cases:
- Fundamental types and enumerated types are cheap to copy, so they're typically passed by value
- Class types can be expensive to copy (sometimes significantly), so they're typically passed by const reference
As a rule of thumb, pass fundamental types by value and class types by const reference.
If you're unsure, pass by const reference. You're less likely to encounter unexpected behavior.
Here's a partial list of other interesting cases:
The following are often passed by value (because it's more efficient):
- Enumerated types (unscoped and scoped enumerations)
- Views and spans (e.g.,
std::string_view,std::span) - Types mimicking references or non-owning pointers (e.g., iterators,
std::reference_wrapper) - Cheap-to-copy class types with value semantics (e.g.,
std::pairwith fundamental elements,std::optional,std::expected)
Pass by reference should be used for:
- Arguments that need modification by the function
- Types that aren't copyable (such as
std::ostream) - Types where copying has ownership implications we want to avoid (e.g.,
std::unique_ptr,std::shared_ptr) - Types with virtual functions or likely to be inherited from (due to object slicing concerns)
The Cost of Pass by Value vs Pass by Reference (Advanced)
Not all class types need pass by reference (such as std::string_view, normally passed by value). You may wonder why we don't just pass everything by reference. In this optional section, we discuss the cost of pass by value versus pass by reference, and refine our best practice.
First, consider parameter initialization cost. With pass by value, initialization means making a copy. Copy cost is generally proportional to:
- Object size: Objects using more memory take longer to copy
- Additional setup costs: Some class types perform additional setup when instantiated (e.g., opening files or databases, or allocating dynamic memory). These setup costs are paid each time an object is copied.
Binding a reference to an object is always fast (about the same speed as copying a fundamental type).
Second, consider the cost of using the function parameter. The compiler may optimize by placing a reference or small passed-by-value argument into a CPU register (fast to access) rather than RAM (slower to access).
Each value parameter use directly accesses the copied argument's storage location (CPU register or RAM). However, each reference parameter use requires an extra step: the program must first access the storage location allocated to the reference to determine which object is being referenced. Only then can it access the referenced object's storage location (in RAM).
Therefore, each value parameter use is a single CPU register or RAM access, whereas each reference parameter use is a single CPU register or RAM access plus a second RAM access.
Third, the compiler can sometimes optimize code using pass by value more effectively than code using pass by reference. In particular, optimizers must be conservative when aliasing (when two or more pointers or references can access the same object) is possible. Since pass by value results in copied argument values, no aliasing can occur, allowing more aggressive optimization.
We can now answer why we don't pass everything by reference:
- For cheap-to-copy objects, copying cost is similar to binding cost, but accessing objects is faster and the compiler likely optimizes better
- For expensive-to-copy objects, copy cost dominates other performance considerations
The last question is: how do we define "cheap to copy"? There's no absolute answer (it varies by compiler, use case, and architecture). However, we can formulate a good rule of thumb: An object is cheap to copy if it uses 2 or fewer "words" of memory (where a "word" is approximated by the size of a memory address) and has no setup costs.
This program defines a function-like macro determining if a type (or object) is cheap to copy accordingly:
#include <iostream>
// Function-like macro evaluating to true if type (or object) is equal to or smaller than
// the size of two memory addresses
#define isCheapToCopy(T) (sizeof(T) <= 2 * sizeof(void*))
struct LargeStruct
{
double first;
double second;
double third;
};
int main()
{
std::cout << std::boolalpha; // Print true or false rather than 1 or 0
std::cout << isCheapToCopy(int) << '\n'; // true
double value{};
std::cout << isCheapToCopy(value) << '\n'; // true
std::cout << isCheapToCopy(LargeStruct) << '\n'; // false
return 0;
}
Advanced note: We use a preprocessor function-like macro so we can provide either an object OR a type name as a parameter (C++ functions disallow passing types as parameters).
However, it's hard to know whether a class type has setup costs. It's best to assume most standard library classes have setup costs unless you know otherwise.
An object of type T is cheap to copy if `sizeof(T) <= 2 * sizeof(void*)` and has no additional setup costs.
For Function Parameters, Prefer std::string_view Over const std::string& in Most Cases
One question that arises often in modern C++: when writing a function with a string parameter, should the type be const std::string& or std::string_view?
In most cases, std::string_view is the better choice, as it handles a wider range of argument types efficiently. A std::string_view parameter also allows callers to pass in substrings without copying that substring into its own string first.
void processString(const std::string&);
void processString(std::string_view); // Prefer this in most cases
There are a few cases where const std::string& may be more appropriate:
- If using C++14 or older,
std::string_viewisn't available - If your function calls other functions taking C-style strings or
std::stringparameters, thenconst std::string&may be better, asstd::string_viewisn't guaranteed to be null-terminated (something C-style string functions expect) and doesn't efficiently convert back tostd::string
Prefer passing strings using `std::string_view` (by value) instead of `const std::string&`, unless your function calls other functions requiring C-style strings or `std::string` parameters.
Why std::string_view Parameters Are More Efficient Than const std::string& (Advanced)
In C++, string arguments will typically be a std::string, std::string_view, or C-style string/string literal.
As reminders:
- If an argument type doesn't match the corresponding parameter type, the compiler will try implicitly converting the argument to match the parameter type
- Converting a value creates a temporary object of the converted type
- Creating (or copying) a
std::string_viewis inexpensive, asstd::string_viewdoesn't make a copy of the string it's viewing - Creating (or copying) a
std::stringcan be expensive, as eachstd::stringmakes a copy of the string
Here's what happens when passing each type:
| Argument Type | std::string_view parameter | const std::string& parameter |
|---|---|---|
| std::string | Inexpensive conversion | Inexpensive reference binding |
| std::string_view | Inexpensive copy | Expensive explicit conversion to std::string |
| C-style string / literal | Inexpensive conversion | Expensive conversion |
With a std::string_view value parameter:
- If passing a
std::stringargument, the compiler convertsstd::stringtostd::string_view, which is inexpensive - If passing a
std::string_viewargument, the compiler copies the argument into the parameter, which is inexpensive - If passing a C-style string or string literal, the compiler converts to
std::string_view, which is inexpensive
As you can see, std::string_view handles all three cases inexpensively.
With a const std::string& reference parameter:
- If passing a
std::stringargument, the parameter reference binds to the argument, which is inexpensive - If passing a
std::string_viewargument, the compiler refuses implicit conversion, producing a compilation error. We can usestatic_castfor explicit conversion (tostd::string), but this conversion is expensive (sincestd::stringcopies the string being viewed). Once conversion is done, the parameter reference binds to the result, which is inexpensive. But we've made an expensive copy for conversion, so this isn't great - If passing a C-style string or string literal, the compiler implicitly converts to
std::string, which is expensive
Thus, a const std::string& parameter only handles std::string arguments inexpensively.
The same, in code form:
#include <iostream>
#include <string>
#include <string_view>
void displaySV(std::string_view sv)
{
std::cout << sv << '\n';
}
void displayS(const std::string& s)
{
std::cout << s << '\n';
}
int main()
{
std::string text{"Hello, world"};
std::string_view view{text};
// Pass to std::string_view parameter
displaySV(text); // Valid: inexpensive conversion from std::string
displaySV(view); // Valid: inexpensive copy of std::string_view
displaySV("Hello, world"); // Valid: inexpensive conversion of C-style literal
// Pass to const std::string& parameter
displayS(text); // Valid: inexpensive bind to std::string
displayS(view); // Error: cannot implicit convert std::string_view
displayS(static_cast<std::string>(view)); // Bad: expensive std::string temporary creation
displayS("Hello, world"); // Bad: expensive std::string temporary creation
return 0;
}
Summary
Pass by const reference benefits: Passing by const reference avoids expensive copies like pass by reference, while guaranteeing the function cannot modify the argument, making it safer than pass by non-const reference.
Flexibility with argument types: Const reference parameters can bind to modifiable lvalues, non-modifiable lvalues, and rvalues (including literals), providing maximum flexibility in what can be passed.
Type conversion considerations: When passing different types to const reference parameters, the compiler performs implicit conversion creating a temporary, which may be expensive—ensure argument types match parameter types when possible.
When to use pass by value vs pass by reference: As a rule of thumb, pass fundamental types (and other cheap-to-copy types) by value, and class types by const reference. An object is cheap to copy if it uses 2 or fewer "words" of memory and has no setup costs.
std::string_view vs const std::string&: For string parameters, prefer std::string_view over const std::string& in most cases, as it handles std::string, std::string_view, and C-style string arguments efficiently.
Pass by const reference strikes an excellent balance between performance and safety, making it the default choice for passing class types to functions in modern C++.
Additionally, we need to consider parameter access cost inside functions. Since a std::string_view parameter is a normal object, the viewed string can be accessed directly. Accessing a std::string& parameter requires an additional step to get the referenced object before accessing the string.
Finally, to pass a substring of an existing string (of any type), it's comparatively cheap to create a std::string_view substring, which can then be cheaply passed to a std::string_view parameter. Passing a substring to a const std::string& is more expensive, as the substring must at some point be copied into the std::string that the reference parameter binds to.
Read-Only Parameter Passing - Quiz
Test your understanding of the lesson.
Practice Exercises
Pass by Const Lvalue Reference
Master the most common parameter passing mechanism in modern C++. Learn when to use pass by const reference versus pass by value, and understand why std::string_view is often preferred.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!