跳至内容

引用:左值、右值与陷阱

探索左值引用和右值引用,理解 C++ 值类别,学习引用折叠规则和转发引用,并避免悬空引用的 bug。

左值引用

左值引用(T&)是一个别名 ── 已有对象的另一个名字。与指针不同,引用必须被初始化,不能为空,并且不能被重新绑定到另一个对象。

左值引用绑定到左值 ── 具有标识的表达式(你可以取其地址)。变量、数组元素和解引用的指针都是左值。非 const 左值引用(int&)不能绑定到临时对象(右值),因为修改临时对象几乎总是一个 bug。

左值引用基础

左值引用是 C++ 的主力 ── 用于按引用传递、运算符重载和避免拷贝。

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!"
}

C++ 中的值类别

C++ 对表达式的值类别有精确的分类法。理解它们对于移动语义和完美转发至关重要:

- 左值(lvalue) ── 有标识,不能被隐式移动。例如:变量、*ptr、arr[i]、字符串字面量。
- 纯右值(prvalue) ── 没有标识,可以被移动。例如:42、x + y、std::string("temp")、按值返回的函数。
- 将亡值(xvalue) ── 有标识且可以被移动。例如:std::move(x)、static_cast(x)、右值的成员。
- 泛左值(glvalue) ── 左值或将亡值(有标识)。
- 右值(rvalue) ── 纯右值或将亡值(可以被移动)。

关键洞察:一个右值引用变量本身是一个左值 ── 它有名字和地址。这是许多困惑的根源,也正是 std::forward 存在的原因。

右值引用(T&&)

右值引用绑定到右值(临时对象和 std::move 过的对象)。它们是移动语义的基础 ── 让你可以检测到对象即将被销毁并窃取其资源。

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
}

引用折叠与转发引用

当引用在模板中组合时,引用折叠规则适用:

- T& & 折叠为 T&
- T& && 折叠为 T&
- T&& & 折叠为 T&
- T&& && 折叠为 T&&

规则很简单:如果任何一个引用是左值引用,结果就是左值引用。只有 && && 才产生 &&。

转发引用(也称为万能引用)是 T&&,其中 T 是一个被推导的模板参数。它可以同时绑定左值和右值。auto&& 也是转发引用。但 std::vector&& 不是 ── 因为 T 已经确定了。const T&& 也不是转发引用。

常见陷阱

悬空引用指向已销毁的对象。与悬空指针不同,没有 nullptr 检查 ── 代码会默默地使用垃圾数据或崩溃。

常见原因:

- 返回局部变量的引用 ── 函数返回时变量被销毁。
- 在 lambda 中按引用捕获了生命周期短于 lambda 的变量。
- 对表达式内部的临时对象使用 const auto& ── 如果 get_map() 按值返回,const auto& val = get_map()["key"] 会悬空,因为临时 map 在分号处被销毁。
- 对临时对象使用基于范围的 for 循环 ── for (auto& x : get_vector()) 没问题(临时对象在循环期间存活),但 for (auto& x : get_wrapper().get_vector()) 可能悬空,如果包装器被提前销毁。

使用 const T& 延长生命周期

const 左值引用可以延长临时对象的生命周期,但仅在直接绑定时有效。这不能通过函数调用或成员访问链传播。

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
}
关键要点
  • 左值引用(T&)是别名 ── 必须初始化,不能为空或重新绑定
  • const T& 可以绑定到临时对象并延长其生命周期(但仅在直接绑定时)
  • 右值引用(T&&)绑定到临时对象并启用移动语义
  • 具名的右值引用本身是一个左值 ── 这一点至关重要
  • 引用折叠:链中的任何 & 都使结果为 &
  • 转发引用(带有被推导的 T 的 T&&)可以同时绑定左值和右值

测验 — 检验你的知识

(15 经验值)

1. 给定 `void f(std::string&& s)`,`s` 在 `f` 内部的值类别是什么?

2. 哪个引用折叠规则是正确的?

3. `const T&` 生命周期延长对临时对象何时有效?