What Is Pass by Const Lvalue Reference?

A parameter written const T& is a const lvalue reference parameter. It hands the function a read-only view of whatever the caller supplied: nothing is copied on the way in, and the compiler rejects any attempt to write through the reference.

Two independent decisions go into every parameter you declare: whether the argument gets copied, and whether the function is allowed to write back to the caller's object. The three forms you have met so far answer them like this:

Parameter form Copies the argument? Function can modify the caller's object? Accepts literals and temporaries?
T (by value) Yes No, it owns a private copy Yes
T& (by reference) No Yes No
const T& (by const reference) No No Yes

The last row is the interesting one. It is the only form that avoids the copy and keeps the caller's object safe and still accepts a plain literal at the call site. That combination is why const reference parameters dominate real C++ code.

The rest of this lesson unpacks that bottom row column by column, then answers the practical question it raises: which form should you reach for when you declare a parameter of your own?

What a Const Reference Parameter Accepts

An ordinary T& parameter only binds to modifiable lvalues. Adding const widens that to all three argument categories: modifiable lvalues, non-modifiable lvalues, and rvalues.

#include <iostream>

void showBattery(const int& percent)
{
    std::cout << "Battery at " << percent << " percent" << '\n';
}

int main()
{
    int laptop{ 64 };
    showBattery(laptop);   // laptop is a modifiable lvalue

    const int reserve{ 15 };
    showBattery(reserve);  // reserve is a non-modifiable lvalue

    showBattery(7);        // 7 is an rvalue literal

    return 0;
}
Battery at 64 percent
Battery at 15 percent
Battery at 7 percent

The third call is worth pausing on. A literal such as 7 has no storage the caller can point at, so the compiler materialises a temporary int holding 7 and binds percent to that temporary. The temporary lives until the end of the full expression, which comfortably outlasts the call.

This is exactly why the language lets references to const bind to rvalues at all. Without that rule, a function taking a reference parameter could only ever be called with named variables, and calls like showBattery(7) would be impossible. Reference parameters would be useless for anything computed on the spot.

The Read-Only Half of the Promise

The const is not decoration. Writing through the reference is a compile error, caught long before the program runs.

The next snippet is deliberately broken:

void drainBattery(const int& percent)
{
    --percent;
}
s.cpp: In function 'void drainBattery(const int&)':
s.cpp:3:7: error: decrement of read-only reference 'percent'
    3 |     --percent;
      |       ^~~~~~~

That guarantee is what makes const reference parameters safe to hand out freely. A caller reading a function declaration can tell, from the signature alone, that its argument will come back untouched. With a plain T& parameter the caller has no such assurance and has to read the function body to find out.

Best Practice
Reach for const T& rather than T& unless the function genuinely needs to write back into the caller's object.

Choosing a Parameter Type

Copying costs something, and binding a reference costs something too. For most types one of those costs is clearly the smaller, and which one it is follows from the kind of type:

  • A fundamental or enumerated type occupies a register or two and copies essentially for free, so by value wins.
  • A class type can carry any amount of data and may do real work when it is constructed, so by const reference wins.
Best Practice
Default to by value for fundamental and enumerated types, and to const reference for class types. When a type does not obviously belong on either side, choose const reference; it is the option least likely to surprise you.

A handful of types sit outside that default, in both directions:

Pass these by value even though they are class types Pass these by reference even though a copy would compile
Unscoped and scoped enumerations Anything the function must write back to
Views and spans such as std::string_view and std::span Types that cannot be copied at all, such as std::ostream
Handle-like types such as iterators and std::reference_wrapper Types where copying duplicates ownership, such as std::unique_ptr and std::shared_ptr
Small value-semantic types such as std::optional or a std::pair of fundamentals Types with virtual functions or designed to be inherited from, where copying can slice the object

Mixing Modes in One Function

Passing mode is decided per parameter, not per function, so a single declaration can use all three forms:

#include <iostream>
#include <string>

void repeatMarker(int repeats, std::string& target, const std::string& marker)
{
    for (int i{ 0 }; i < repeats; ++i)
    {
        target += marker;
    }
}

int main()
{
    std::string banner{};
    const std::string divider{ "<>" };

    repeatMarker(3, banner, divider);
    std::cout << banner << '\n';

    return 0;
}
<><><>

Reading the signature left to right tells you the whole story: repeats is a cheap copy the function may scribble on privately, target is the object the function exists to modify, and marker is data the function reads but must not touch.

The Conversion Trap

Const reference parameters accept arguments of a different type, provided a conversion exists. That is a convenience, and occasionally an expensive one.

Here the argument is an int and both parameters are double:

#include <iostream>

void showElapsed(double seconds)
{
    std::cout << seconds << " s" << '\n';
}

void logElapsed(const double& seconds)
{
    std::cout << seconds << " s" << '\n';
}

int main()
{
    showElapsed(90); // 90 is converted to double, and that double initialises seconds
    logElapsed(90);  // 90 is converted to a temporary double, and seconds binds to it

    return 0;
}
90 s
90 s

Both calls look identical at the call site, which is the point: you should not have to know how a function takes its parameters in order to call it. But the two calls do different amounts of work. The conversion builds a temporary object of the parameter's type, and only then does the reference bind to that temporary. For double the temporary is free. For a class type it is not.

#include <iostream>
#include <string>

void logEntry(const std::string& entry)
{
    std::cout << entry << '\n';
}

int main()
{
    std::string saved{ "checkpoint reached" };

    logEntry(saved);                // entry binds straight to saved, nothing is copied
    logEntry("checkpoint reached"); // a whole temporary std::string is built first

    return 0;
}
checkpoint reached
checkpoint reached

The second call allocates and copies an entire string just to have something for the reference to bind to. You chose a reference parameter precisely to avoid that copy, and a mismatched argument type quietly reintroduces it.

Warning
A reference parameter only avoids a copy when the argument already has the parameter's type. Any conversion at the call site creates a temporary, and for class types that temporary is exactly the copy you were trying to avoid.

What "Cheap to Copy" Actually Means (Advanced)

If references avoid copies, why not pass everything by reference? Three costs pull in the other direction.

Binding is not free either. A reference has to be set up at the call, roughly at the cost of copying one fundamental type. For a small object, copying and binding cost about the same, so the reference buys nothing.

Using a reference costs an extra hop. A by-value parameter may live in a CPU register, and each use reads it directly. A reference parameter is a storage location holding the location of something else, so each use reads the reference first and then reads the object it designates, adding a RAM access every time.

References block optimisations. When two references or pointers can designate the same object, they are said to alias, and the optimiser must assume a write through one might be visible through the other. Copies cannot alias anything, so the optimiser is free to be aggressive.

So the question is where the crossover lies, and the practical answer is a size threshold. A word is roughly the size of a memory address, and an object of two words or fewer with no construction work to do is cheap to copy. This program prints the relevant sizes on the platform compiler:

#include <iostream>
#include <string>
#include <string_view>

int main()
{
    std::cout << "one address      " << sizeof(void*) << '\n';
    std::cout << "int              " << sizeof(int) << '\n';
    std::cout << "double           " << sizeof(double) << '\n';
    std::cout << "std::string_view " << sizeof(std::string_view) << '\n';
    std::cout << "std::string      " << sizeof(std::string) << '\n';

    return 0;
}
one address      8
int              4
double           8
std::string_view 16
std::string      32

An address is 8 bytes here, so the threshold is 16 bytes. int, double, and std::string_view all fit; std::string does not. That single measurement explains why std::string_view is passed by value while std::string is passed by const reference.

Size is only half the test, though. The other half is construction work, and it does not show up in sizeof. A type that opens a file, contacts a database, or allocates memory when it is created pays that cost again on every copy, no matter how few bytes the object itself occupies. Assume standard library class types have such costs unless you know otherwise.

Tip
Treat T as cheap to copy when sizeof(T) <= 2 * sizeof(void*) and its construction does no extra work. Both halves have to hold.

String Parameters: Prefer std::string_view

Whenever a function takes a string, you have to pick between const std::string& and std::string_view. Three kinds of argument turn up in practice, and the two parameter types handle them very differently:

Parameter type std::string argument std::string_view argument C-style string or literal
std::string_view (by value) Cheap conversion Cheap copy Cheap conversion
const std::string& Cheap reference binding Refuses to convert implicitly; an explicit conversion copies the text Implicit conversion allocates and copies the text

A std::string_view parameter is cheap in all three columns, because a view never owns the characters it refers to. Constructing one from anything string-shaped just records a starting address and a length.

#include <iostream>
#include <string>
#include <string_view>

void announceTrack(std::string_view title)
{
    std::cout << "Now playing: " << title << '\n';
}

int main()
{
    std::string owned{ "Rain on Brass" };
    std::string_view viewed{ "Harbour Lights" };

    announceTrack(owned);            // std::string converts cheaply to a view
    announceTrack(viewed);           // copying a view is cheap
    announceTrack("Midnight Ferry"); // a literal converts cheaply to a view

    return 0;
}
Now playing: Rain on Brass
Now playing: Harbour Lights
Now playing: Midnight Ferry

Swap the parameter to const std::string& and the middle case stops compiling altogether, because C++ will not implicitly build a std::string from a view.

The next snippet is deliberately broken:

#include <string>
#include <string_view>

void announceTrack(const std::string& title);

void relay(std::string_view viewed)
{
    announceTrack(viewed);
}
s.cpp: In function 'void relay(std::string_view)':
s.cpp:8:19: error: invalid initialization of reference of type 'const std::string&' {aka 'const std::__cxx11::basic_string<char>&'} from expression of type 'std::string_view' {aka 'std::basic_string_view<char>'}
    8 |     announceTrack(viewed);
      |                   ^~~~~~

You can force it through with static_cast<std::string>(viewed), but that builds a whole new string, which is the expensive copy again.

Two smaller advantages point the same way. Reading a std::string_view parameter reads the parameter itself, with no reference indirection to follow. And passing part of a string is cheap: taking a view of a substring copies no characters, whereas a const std::string& parameter can only bind to a real std::string, so the substring has to be materialised first.

Best Practice
Take string parameters as std::string_view by value. Keep const std::string& only when the body forwards the argument to something that demands a real std::string or a null-terminated C-style string.

Summary

The form and what it promises. const T& binds to the caller's object without copying it and forbids the function from modifying it. Prefer it to T& unless the function's job is to write back.

What it accepts. Modifiable lvalues, non-modifiable lvalues, and rvalues. Rvalue arguments are materialised as a temporary that the reference binds to, which is what makes calls like showBattery(7) legal at all.

Which form to declare. Fundamental and enumerated types by value; class types by const reference. Views, spans, iterators, and other small handle-like types go by value despite being class types; uncopyable, ownership-carrying, and polymorphic types go by reference.

The conversion trap. A reference parameter only skips the copy when the argument type already matches. A mismatch creates a temporary of the parameter's type first, which for class types is precisely the expensive copy you were avoiding.

Cheap to copy. Two words of memory or fewer (sizeof(T) <= 2 * sizeof(void*)) and no construction work. Below that line copying wins, because binding costs about as much, reference use adds an indirection, and copies cannot alias.

Strings. std::string_view by value handles std::string, std::string_view, and string literals cheaply; const std::string& is cheap for std::string alone. Prefer the view unless you must hand a real std::string or C-style string onward.