Lvalue References to Const

In the previous lesson, we learned that lvalue references can only bind to modifiable lvalues. This restriction prevents:

int main()
{
    const int maxHealth{100}; // maxHealth is non-modifiable (const) lvalue
    int& invalid{maxHealth};  // Error: reference can't bind to non-modifiable lvalue

    return 0;
}

This prohibition makes sense: it would let us modify a const variable (maxHealth) through the non-const reference (invalid).

But what if we want to reference a const variable? Normal lvalue references won't work.

Lvalue References to Const

By adding const when declaring an lvalue reference, we tell the reference to treat its referent as const. This creates an lvalue reference to const (also called a reference to const or const reference).

Lvalue references to const can bind to non-modifiable lvalues:

int main()
{
    const int maxHealth{100};          // Non-modifiable lvalue
    const int& healthCap{maxHealth};   // Valid: lvalue reference to const

    return 0;
}

Lvalue references to const treat their referents as const, allowing access but not modification:

#include <iostream>

int main()
{
    const int maxHealth{100};
    const int& healthCap{maxHealth};

    std::cout << healthCap << '\n'; // Valid: read access
    healthCap = 200;                // Error: can't modify through const reference

    return 0;
}

Binding to Modifiable Lvalues

Lvalue references to const can also bind to modifiable lvalues. The referent is treated as const when accessed through the reference (even though the underlying object is non-const):

#include <iostream>

int main()
{
    int health{100};                 // Modifiable lvalue
    const int& readOnly{health};     // Valid: const reference to modifiable lvalue

    std::cout << readOnly << '\n';   // Valid: read access
    readOnly = 75;                   // Error: can't modify through const reference

    health = 75;                     // Valid: health is modifiable directly

    return 0;
}

In this program, const reference readOnly binds to modifiable lvalue health. We can access health through readOnly, but cannot modify it via readOnly. However, we can still modify health directly.

Best Practice
Prefer lvalue references to const over lvalue references to non-const unless you need to modify the referent.

Binding to Rvalues

Surprisingly, lvalue references to const can bind to rvalues:

#include <iostream>

int main()
{
    const int& score{42}; // Valid: 42 is an rvalue

    std::cout << score << '\n'; // Prints 42

    return 0;
}

When this occurs, a temporary object is created and initialized with the rvalue, and the const reference binds to that temporary.

Related Content
We covered temporary objects in the lesson on local scope.

Binding to Different Types

Lvalue references to const can bind to values of different types, provided implicit conversion exists:

#include <iostream>

int main()
{
    // Case 1
    const double& percentage{10};  // Temporary double initialized with 10, reference binds to it

    std::cout << percentage << '\n'; // Prints 10

    // Case 2
    char grade{'A'};
    const int& numericGrade{grade};  // Temporary int initialized with 'A', reference binds to it

    std::cout << numericGrade << '\n'; // Prints 65 (ASCII value of 'A')

    return 0;
}

In case 1, a temporary double object is created with value 10, then const double& percentage binds to it.

In case 2, a temporary int is created with the value of character 'A', then const int& numericGrade binds to it.

Key Concept
When binding a const lvalue reference to a different type, the compiler creates a temporary of the reference's type, initializes it with the value, then binds the reference to the temporary.

Note that numericGrade prints as int, not char, because numericGrade references a temporary int, not the original char grade.

Warning
We normally assume references are identical to their referents, but this breaks when references bind to temporaries (from copies or conversions). Modifications to the original object won't affect the reference (which references a different object), and vice versa.

Here's an example:

#include <iostream>

int main()
{
    short items{10};                 // Note: type is short

    const int& inventory{items};     // Note: type is int&
    --items;                         // Items decremented

    if (inventory)                   // Inventory still non-zero?
    {
        std::cout << "Still have inventory!\n";
    }

    return 0;
}

Here, items is short and inventory is const int&. Since inventory can only bind to an int, the compiler implicitly converts items to int, creating a temporary int with value 10. The reference inventory binds to this temporary, not to items.

When items decrements, inventory remains unaffected (referencing a different object). Although we expect the if condition to evaluate false, it actually evaluates true.

Const References Extend Temporary Lifetimes

Temporaries normally destruct at the end of the expression creating them.

Given const int& healthCap{100};, if the temporary holding 100 were destroyed after initializing healthCap, the reference would dangle, causing undefined behavior upon access.

To prevent this, C++ has a special rule: When a const lvalue reference directly binds to a temporary, the temporary's lifetime extends to match the reference's lifetime.

#include <iostream>

int main()
{
    const int& healthCap{100}; // Temporary holding 100 lives as long as healthCap

    std::cout << healthCap << '\n'; // Safe to use

    return 0;
} // Both healthCap and the temporary destruct here

When healthCap initializes with rvalue 100, a temporary is created and healthCap binds to it. The temporary's lifetime matches healthCap's lifetime, so we can safely use healthCap. Both destruct at block's end.

Key Concept
Lvalue references can only bind to modifiable lvalues.

Lvalue references to const can bind to modifiable lvalues, non-modifiable lvalues, and rvalues. This makes them much more flexible.

Advanced note: Lifetime extension only works with direct binding to temporaries. Temporaries returned from functions (even by const reference) don't qualify for lifetime extension. We show an example in the lesson on return by reference and return by address. For class types, binding a reference to a member extends the entire object's lifetime.

Why does C++ allow const references to bind to rvalues? We'll answer that in the next lesson!

Constexpr Lvalue References (Optional)

When applied to references, constexpr enables use in constant expressions. Constexpr references have a limitation: they can only bind to objects with static duration (globals or static locals). The compiler knows where static objects instantiate in memory, treating that address as a compile-time constant.

Constexpr references cannot bind to non-static local variables, whose addresses aren't known until function calls occur.

int globalHealth{100};

int main()
{
    [[maybe_unused]] constexpr int& ref1{globalHealth}; // Valid: can bind to global

    static int staticHealth{75};
    [[maybe_unused]] constexpr int& ref2{staticHealth}; // Valid: can bind to static local

    int localHealth{50};
    [[maybe_unused]] constexpr int& ref3{localHealth}; // Error: can't bind to non-static

    return 0;
}

When defining constexpr references to const variables, apply both constexpr (to the reference) and const (to the referenced type):

int main()
{
    static const int maxHealth{100}; // A const int
    [[maybe_unused]] constexpr const int& ref{maxHealth}; // Needs both constexpr and const

    return 0;
}

Given these limitations, constexpr references see limited use.

Summary

Lvalue references to const: By adding const to an lvalue reference declaration, we create an lvalue reference to const (or const reference), which treats the referent as const and can bind to modifiable lvalues, non-modifiable lvalues, and rvalues.

Binding to modifiable lvalues: Lvalue references to const can bind to modifiable lvalues, treating them as const through the reference even though the underlying object remains modifiable directly.

Binding to rvalues: Lvalue references to const can bind to rvalues by creating a temporary object that the reference binds to, enabling passing of temporaries to functions.

Binding to different types: When binding to different types via implicit conversion, a temporary of the reference's type is created and the reference binds to that temporary, not the original object.

Lifetime extension: When a const lvalue reference directly binds to a temporary, the temporary's lifetime extends to match the reference's lifetime, preventing dangling references.

Constexpr references: Constexpr references can only bind to objects with static duration (globals or static locals), limiting their use.

Lvalue references to const provide significantly more flexibility than non-const lvalue references while maintaining safety through const-correctness. They form the foundation for efficient pass-by-reference patterns in modern C++.