#095 – How C++ lvalue references bind to existing objects

An lvalue reference is an alias to an existing object.

It does not create a new object. It gives another name to an object that already exists. This makes references useful for function parameters, return values, and APIs that need to modify an existing object without copying it.

Three things to take away:

  • An lvalue reference uses &, such as int&.
  • A non-const lvalue reference must bind to a modifiable lvalue.
  • Changing a reference changes the object it refers to.

What an lvalue reference is

A reference type is written with &:

int     // int
int&    // lvalue reference to int
double& // lvalue reference to double

In int&, the referenced type is int.

An lvalue reference must be initialized with an object:

int speed { 90 };
int& speedAlias { speed };

The reference speedAlias is now another name for speed. That means both names refer to the same object.

A type that specifies a reference (e.g. int&) is called a reference type. The type that can be referenced (e.g. int) is called the referenced type.

#include <iostream>

int main(){
    int motorSpeedRpm { 1800 };
    int& commandedSpeed { motorSpeedRpm };

    std::cout << "Before update: " << motorSpeedRpm
              << " rpm, " << commandedSpeed << " rpm\n";

    commandedSpeed += 200;

    std::cout << "After update:  " << motorSpeedRpm
              << " rpm, " << commandedSpeed << " rpm\n";
}

// Output:
Before update: 1800 rpm, 1800 rpm
After update:  2000 rpm, 2000 rpm

Modifying the lvalue reference, commandedSpeed modifies the referenced type, motorSpeedRpm.

Characteristics

  1. A reference will (usually) only bind to an object matching its referenced type
  2. Lvalue references must be initialized with an lvalue, e.g. int& x1; is impermissible.
  3. You can’t reference a reference
  4. An lvalue reference can only bind to an lvalue (e.g. a variable or pointer), it cannot bind to an rvalue (e.g. a literal). int& { 99 }; is impermissible. However, a const lvalue reference (e.g., const int&) is the exception. const int& ref { 42 }; // OK

References cannot be reseated

Once a reference is initialized, it cannot be changed to refer to a different referenced type.

#include <iostream>

int main(){
    int primary { 10 };
    int backup { 99 };

    int& ref { primary }; // Initialize the reference

    ref = backup; // Change the reference

    std::cout << "primary: " << primary << '\n';
    std::cout << "backup:  " << backup << '\n';
}

// Output:
// primary: 99
// backup:  99

This line does not make ref refer to backup:

ref = backup;

It assigns the value of backup into the object currently referred to by ref.

Since ref refers to primary, the assignment changes primary.

Using lvalue references as function parameters

A common use of lvalue references is to let a function modify an existing object.

#include <iostream>

struct MotorConfig {
    int speedRpm {};
    int accelerationLimit {};
};

void applySafetyLimit(MotorConfig& config){
    if (config.speedRpm > 3000) {
        config.speedRpm = 3000;
    }

    if (config.accelerationLimit > 500) {
        config.accelerationLimit = 500;
    }
}

int main(){
    MotorConfig config {
        .speedRpm = 4200,
        .accelerationLimit = 800
    };

    applySafetyLimit(config);

    std::cout << "Speed: " << config.speedRpm << " rpm\n";
    std::cout << "Acceleration limit: "
              << config.accelerationLimit << '\n';
}

// Output:
Speed: 3000 rpm
Acceleration limit: 500

The function parameter is:

MotorConfig& config

That means applySafetyLimit() receives a reference to the caller’s object, allowing it to modify the original config directly.

Likewise, we can make functions return a reference, thereby allowing functions to modify an existing object and not a duplicate.

Expressions that produce lvalues

Several expressions can produce lvalues:

  • A variable name, such as speed.
  • A dereferenced pointer, such as ptr.
  • An array subscript, such as values[2].
  • A function that returns by lvalue reference.
  • Built-in assignment expressions, such as x = 5.
  • Prefix increment and decrement, such as ++x and -x.

Example:

#include <iostream>

int main(){
    int values[3] { 10, 20, 30 };

    values[1] = 99;

    int* ptr { &values[2] };
    *ptr = 77;

    std::cout << values[0] << '\n';
    std::cout << values[1] << '\n';
    std::cout << values[2] << '\n';
}

// Output:
10
99
77

Both values[1] and *ptr are lvalue expressions. They identify existing objects that can be modified.

References and referents have independent lifetimes

A reference can be destroyed before the referenced type

The referenced type can be destroyed before the reference.

When a reference is destroyed before the referenced type, the later is not unaffected

#include <iostream>

int main() {

    int a1 { 5 };
    {
        int& ref { a1 }; // Create a ref
    } // It dies past this point

    // The referenced type is unaffected
    std::cout << a1;
}

NOTE: When the referenced type is destroyed before the reference, the later is referencing an lvalue that doesn’t exist. This is called a dangling reference.

In Conclusion

An lvalue reference is an alias to an existing object:

int& ref { value };

Use non-const lvalue references when a function needs to modify the caller’s object. Use reference returns when a function should provide access to an existing object rather than a copy