Skip to content

References: Lvalue, Rvalue & Pitfalls

Explore lvalue and rvalue references, understand C++ value categories, learn the reference collapsing rules and forwarding references, and avoid dangling reference bugs.

Lvalue References

An lvalue reference (T&) is an alias: another name for an existing object. Unlike pointers, references must be initialised, cannot be null, and cannot be re-seated to refer to a different object. Once committed, that is rather it.

Lvalue references bind to lvalues, which are expressions that have identity (you can take their address). Variables, array elements, and dereferenced pointers are all lvalues. A non-const lvalue reference (int&) cannot bind to a temporary (rvalue) because modifying a temporary is almost always a bug.

Lvalue Reference Basics

Lvalue references are the workhorse of C++, used for pass-by-reference, operator overloading, and avoiding copies.

lvalue_refs.cpp
#include <iostream>
#include <string>

void append_greeting(std::string& s) {
    s += ", welcome!";  // modifies the caller's string
}

int main() {
    int x = 10;
    int& ref = x;      // ref is an alias for x
    ref = 20;
    std::cout << x << '\n';  // 20 — x was modified through ref

    // const reference can bind to a temporary (rvalue)
    const std::string& greeting = std::string("Hello");
    // The temporary's lifetime is extended to match greeting's scope
    std::cout << greeting << '\n';  // "Hello"

    // Non-const lvalue reference CANNOT bind to rvalue:
    // std::string& bad = std::string("oops");  // COMPILER ERROR

    std::string name = "Alice";
    append_greeting(name);
    std::cout << name << '\n';  // "Alice, welcome!"
}

Value Categories in C++

C++ has a precise taxonomy of expression value categories. Understanding them is essential for move semantics and perfect forwarding:

- lvalue — has identity, cannot be moved from implicitly. Examples: variables, *ptr, arr[i], string literals.
- prvalue (pure rvalue) — has no identity, can be moved from. Examples: 42, x + y, std::string("temp"), function returning by value.
- xvalue (expiring value) — has identity AND can be moved from. Examples: std::move(x), static_cast(x), member of an rvalue.
- glvalue (generalised lvalue) — lvalue or xvalue (has identity).
- rvalue — prvalue or xvalue (can be moved from).

The key insight: an rvalue reference variable is itself an lvalue, as it has a name and an address. This is the source of much confusion (and the odd cup of tea) and is precisely why std::forward exists.

Rvalue References (T&&)

Rvalue references bind to rvalues (temporaries and std::move'd objects). They are the foundation of move semantics: they let you detect when an object is about to be destroyed and nick its resources before it goes.

rvalue_refs.cpp
#include <iostream>
#include <string>
#include <utility>  // std::move

void process(const std::string& s) {
    std::cout << "lvalue: " << s << '\n';
}

void process(std::string&& s) {
    std::cout << "rvalue: " << s << '\n';
    // s is an lvalue inside this function (it has a name!)
    // To move from s, you'd need std::move(s)
}

int main() {
    std::string a = "hello";
    process(a);               // calls lvalue overload
    process(std::string("world"));  // calls rvalue overload
    process(std::move(a));    // calls rvalue overload — a is now moved-from

    // IMPORTANT: a is in a valid but unspecified state
    // Only safe operations: assign to it, destroy it
    std::cout << "a after move: '" << a << "'\n";  // likely empty
}

Reference Collapsing & Forwarding References

When references combine in templates, reference collapsing rules apply:

- T& & collapses to T&
- T& && collapses to T&
- T&& & collapses to T&
- T&& && collapses to T&&

The rule is simple: if either reference is an lvalue reference, the result is an lvalue reference. Only && && gives &&.

A forwarding reference (also called universal reference) is T&& where T is a deduced template parameter. It can bind to both lvalues and rvalues. auto&& is also a forwarding reference. But std::vector&& is NOT, as T is already known. const T&& is also not a forwarding reference.

Pitfall

A dangling reference refers to a destroyed object. Unlike dangling pointers, there is no nullptr check, so the code will silently use garbage data or crash. Quite the surprise at the worst possible moment.

Common causes:

- Returning a reference to a local variable — the variable is destroyed when the function returns.
- Capturing by reference in a lambda that outlives the referenced variable.
- const auto& to a temporary inside an expression — const auto& val = get_map()["key"] is dangling if get_map() returns by value, because the temporary map is destroyed at the semicolon.
- Range-based for loop over a temporary — for (auto& x : get_vector()) is fine (temporary lives for the loop), but for (auto& x : get_wrapper().get_vector()) can dangle if the wrapper is destroyed.

Lifetime Extension with const T&

A const lvalue reference can extend the lifetime of a temporary, but only when directly bound. This does NOT work through function calls or member access chains. It is generous, but not that generous.

lifetime_extension.cpp
#include <iostream>
#include <string>

std::string make_greeting() { return "Hello, World!"; }

const std::string& identity(const std::string& s) { return s; }

int main() {
    // SAFE: const ref directly bound to temporary
    const std::string& good = make_greeting();
    std::cout << good << '\n';  // OK — lifetime extended

    // DANGEROUS: lifetime extension does NOT propagate through functions
    const std::string& bad = identity(make_greeting());
    // bad is DANGLING — the temporary was destroyed after identity() returned
    // std::cout << bad << '\n';  // UNDEFINED BEHAVIOR

    // SAFE: just use a value
    std::string safe = make_greeting();  // move or copy-elision
    std::cout << safe << '\n';  // always safe
}
Key Takeaways
  • Lvalue references (T&) are aliases: they must be initialised and cannot be null or re-seated
  • const T& can bind to temporaries and extends their lifetime (but only when directly bound)
  • Rvalue references (T&&) bind to temporaries and enable move semantics
  • A named rvalue reference is itself an lvalue, and this is critical to understand
  • Reference collapsing: any & in the chain makes the result &
  • Forwarding references (T&& with deduced T) bind to both lvalues and rvalues

Quiz — Test Your Knowledge

(15 XP)

1. Given `void f(std::string&& s)`, what is the value category of `s` inside `f`?

2. Which reference collapsing rule is correct?

3. When does `const T&` lifetime extension work for temporaries?