What Are Lvalue References to Const?

An lvalue reference to const is an ordinary lvalue reference with const applied to the type it refers to, written const int&. That one keyword makes a trade. You give up the ability to write through the reference, and in return the reference stops being fussy about what it may be initialized from: instead of "a modifiable object of exactly this type" it will take almost anything convertible to that type. The trade is lopsided enough that const T& ends up being the reference form you write most often.

Two shorter names for the same thing turn up everywhere in code and documentation: reference to const, and const reference. This lesson uses all three interchangeably.

Where a Plain Lvalue Reference Gives Up

The previous lesson established the rule that a plain int& enforces: its initializer has to be an lvalue the compiler knows is writable. A const int fails that test on the spot.

This program does not compile:

int main()
{
    const int seatLimit{48};
    int& counter{seatLimit}; // rejected: a plain int& demands a writable target

    return 0;
}
s.cpp: In function 'int main()':
s.cpp:4:27: error: binding reference of type 'int&' to 'const int' discards qualifiers
    4 |     int& counter{seatLimit}; // rejected: a plain int& demands a writable target
      |                           ^

The refusal is not pedantry. A reference is an alias, so counter would be an ordinary writable int name attached to seatLimit, and counter = 50; would then quietly rewrite storage the programmer declared unwritable. It is far easier for the compiler to reject the alias once than to police every assignment that might follow it.

So how do you get a reference onto something declared const? Not by weakening the const, but by making a promise up front.

Adding const to the Declaration

Write const into the reference's declaration and the reference undertakes to treat its referent as read-only. Two spellings express that, and both appear in real code. A third looks plausible and is not legal at all, so the program below does not compile:

int main()
{
    const int seatLimit{48};

    const int& viewA{seatLimit}; // fine
    int const& viewB{seatLimit}; // fine, identical meaning
    int& const viewC{seatLimit}; // rejected: const cannot attach to a binding

    return 0;
}
s.cpp: In function 'int main()':
s.cpp:7:16: error: 'const' qualifiers cannot be applied to 'int&'
    7 |     int& const viewC{seatLimit}; // rejected: const cannot attach to a binding
      |                ^~~~~

const int& and int const& both attach const to the referred-to type, and the compiler treats them as the same declaration. The third line attempts something else: it puts const on the binding itself. No such syntax exists, and none is needed. A reference is welded to whatever it was initialized with and can never be pointed elsewhere, so the binding is already as fixed as const could make it.

With the declaration fixed, reading works exactly as you would expect:

#include <iostream>

int main()
{
    const int seatLimit{48};
    const int& capacity{seatLimit};

    std::cout << capacity << '\n';

    return 0;
}
48

A Read-Only View of a Modifiable Object

Nothing says the referent has to be const. Point a const int& at an everyday variable and you get a window that reads but cannot write, while the variable itself remains as writable as it always was:

#include <iostream>

int main()
{
    int booked{31};
    const int& snapshot{booked};

    std::cout << "snapshot sees " << snapshot << '\n';

    booked = 35;
    std::cout << "snapshot sees " << snapshot << '\n';

    return 0;
}
snapshot sees 31
snapshot sees 35

The second line of output is the one worth pausing on. Despite its name, snapshot is an alias for booked rather than a copy of it, so a write performed through the name booked is visible the next time you read snapshot. The const restricts the route, not the destination. Take the other route and the compiler stops you:

int main()
{
    int booked{31};
    const int& snapshot{booked};

    snapshot = 35; // rejected: a const binding refuses writes

    return 0;
}
s.cpp: In function 'int main()':
s.cpp:6:14: error: assignment of read-only reference 'snapshot'
    6 |     snapshot = 35; // rejected: a const binding refuses writes
      |     ~~~~~~~~~^~~~
Best Practice
Decide by what the code does with the reference, not by what it happens to be aliasing. A reference that only ever reads should say so with `const`: nothing is lost, the set of things it accepts grows, and the next person to read the code does not have to hunt for a write that never happens. Leave the `const` off only where a write actually occurs.

What Each Form Accepts

Initializer int& const int&
Modifiable lvalue (int booked) binds, read/write binds, read only
Non-modifiable lvalue (const int seatLimit) rejected binds, read only
Rvalue (90, 12 + 6) rejected binds, read only
Value of a convertible type (short, char) rejected binds, read only, through a copy

The bottom two rows are the surprising ones. The rest of this lesson is about how they work and what they quietly cost.

An Rvalue Gets an Object Made For It

#include <iostream>

int main()
{
    const int& deadline{90};
    const int& subtotal{12 + 6};

    std::cout << deadline << '\n';
    std::cout << subtotal << '\n';

    return 0;
}
90
18

90 is a value, not an object, and a reference has to be an alias for storage. So the compiler manufactures the storage: it creates an unnamed int, initializes it to 90, and binds deadline to that. subtotal gets the same treatment, with the sum 12 + 6 computed into an unnamed int first.

A plain int& is barred from this, and the reason is not syntax. A writable alias for an object that nothing else in the program can name would let you assign to a value that vanishes moments later, which is almost always a mistake. Promising to read only makes the arrangement harmless.

How Long That Unnamed Object Survives

An unnamed object created part-way through an expression is normally destroyed the instant that expression completes, at the semicolon. Applied literally to const int& deadline{90};, that would be a catastrophe: the int holding 90 would already be gone by the next statement, and deadline would be an alias for storage the program has released. Reading it would be undefined behavior.

C++ patches the hole with a narrow rule. A temporary that a const lvalue reference latches onto directly does not die at the semicolon; it survives for as long as the reference does, and the two are destroyed together when the block ends. That rule is the only reason the program above prints 90 and 18 instead of garbage.

Key Insight
The load-bearing word is *directly*. A temporary handed back out of a function, including one handed back by const reference, has already reached the end of its life by the time the caller's reference is initialized, so nothing is prolonged. Latch onto a single member of a temporary struct and the whole struct stays alive, member and all.

A Conversion Hands You a Different Object

The final row of the table is where the alias story breaks down. A const reference will bind to a value of another type entirely, provided an implicit conversion exists:

#include <iostream>

int main()
{
    const double& ratio{7}; // 7 is an int, ratio names a double

    char initial{'K'};
    const int& codePoint{initial}; // initial is a char, codePoint names an int

    std::cout << ratio << '\n';
    std::cout << codePoint << '\n';

    return 0;
}
7
75

Neither reference is aliasing what it looks like it is aliasing. ratio has type const double&, so it cannot possibly name an int; the compiler converts 7 to 7.0, parks that in an unnamed double, and binds ratio there. codePoint is the same story with a different pair of types, and it leaves a clue in the output: printing it gives 75, the numeric value of 'K', because codePoint is attached to an unnamed int, not to initial.

Warning
The mental model "a reference is just a second name for the same object" holds only while the types line up. Bring a conversion into it and the reference names a private copy taken at the moment of initialization. The two objects agree once and then drift, so a later change to either one is invisible to the other.

Here is that drift in three lines:

#include <iostream>

int main()
{
    short crates{1};
    const int& mirror{crates};

    --crates;

    std::cout << "crates now " << crates << '\n';
    std::cout << "mirror shows " << mirror << '\n';

    return 0;
}
crates now 0
mirror shows 1

The mismatched types are what does the damage: short on one side of the initialization, const int& on the other. Binding forces a conversion, the conversion produces an unnamed int holding 1, and that unnamed int is what mirror ends up attached to. Decrementing afterwards changes nothing mirror can see, since the two names now refer to two separate objects that merely agreed at the start. Declaring mirror as const short& removes the conversion, removes the copy, and makes the second line print 0 the way you first expected.

Using a Reference in a Constant Expression (Optional)

Marking a reference constexpr lets you name it inside a constant expression. It comes with a restriction that has nothing to do with const-ness: the referent must have static duration, which in practice means a global or a static local. The compiler decides where such objects live while building the program, so their addresses are known before anything runs.

#include <iostream>

int g_ceiling{120};

int main()
{
    static int s_floor{20}; // static duration, address settled before main runs

    constexpr int& upper{g_ceiling};
    constexpr int& lower{s_floor};

    std::cout << upper << ' ' << lower << '\n';

    return 0;
}
120 20

An ordinary local variable fails the requirement, because its address is not decided until the function it lives in is entered. This program does not compile:

int main()
{
    int pending{9};
    constexpr int& watcher{pending}; // rejected: address becomes real only at run time

    return 0;
}
s.cpp: In function 'int main()':
s.cpp:4:28: error: 'pending' is not a constant expression
    4 |     constexpr int& watcher{pending}; // rejected: address becomes real only at run time
      |                            ^~~~~~~

Aiming one of these at a const referent takes two keywords instead of one: constexpr for the reference, const for the type it names. Omit the const and the reference cannot bind at all, since int& still refuses a const target. Omit the constexpr and it binds, but you are left with an ordinary reference that no constant expression can use.

#include <iostream>

int main()
{
    static const int s_quota{500};
    constexpr const int& quotaView{s_quota}; // constexpr covers a binding, const covers a target

    std::cout << quotaView << '\n';

    return 0;
}
500

Between the static-duration requirement and the doubled keywords, constexpr references stay rare in everyday code.

Looking Forward

All this machinery for materializing temporaries pays off in the next lesson, pass by const lvalue reference. A single const T& parameter accepts variables, constants, literals, arithmetic results, and converted values, copying none of them, and that is the reason the language works so hard to give a nameless value something to be an alias for. Later in the chapter, return by reference looks at lifetime from the other end and shows what breaks when the referent does not outlive the call.

Key Terminology

  • Lvalue reference to const: a reference declared const T&; it reads its referent without writing to it, and it will take modifiable lvalues, non-modifiable lvalues, and rvalues alike
  • Temporary object: an unnamed object the compiler creates to hold a value that needs storage, such as the result of a conversion or an rvalue used to initialize a reference
  • Lifetime extension: the rule that keeps a temporary alive for as long as the const lvalue reference latched onto it, rather than discarding it at the semicolon
  • Constexpr reference: a reference usable in a constant expression, restricted to referents with static duration
  • Implicit conversion: an automatic type conversion the compiler applies, which for a reference initializer produces a temporary of the reference's own type

Summary

  • Writing const into a reference declaration produces an lvalue reference to const, spelled either const int& or int const&; int& const is not valid, because a reference can never be rebound anyway
  • A const reference reads its referent but cannot write to it, and the compiler rejects any assignment made through it
  • The referent itself need not be const; binding a const reference to a modifiable variable gives a read-only view while writes through the variable's own name still work and are still visible through the reference
  • A const reference accepts rvalues, and the compiler creates an unnamed object to hold the value so the reference has something to alias
  • Binding a const reference directly to a temporary extends that temporary's life to the reference's, which is what keeps such a reference from dangling; temporaries returned from functions are not covered by the rule
  • Binding across types works if an implicit conversion exists, but the reference then aliases a converted copy rather than the original, so the two no longer track each other
  • A constexpr reference can only be bound to a global or a static local, and referring to a const object with one requires both constexpr and const
  • Declaring a reference const costs nothing, widens what it accepts, and documents that the code behind it only reads, which is why const is the default choice for a reference that never writes