引用

C++ 引用把表达式绑定到已有对象或函数;理解左值引用、右值引用、生命周期与完美转发,避免悬空和误移动。

难度 进阶 时长 标准深度约 12分钟
版本 C++23 (GCC 13.3.0)
what

C++ 引用是已有对象或函数的别名。T& 主要绑定左值,T&& 主要绑定右值;引用本身不拥有所绑定对象。

trap

引用不能重新绑定,也不会让普通对象自动延长生命周期。返回局部对象的引用、保存短命对象的引用,都会留下悬空引用。

fix

在接口中先写清修改、可空性与生命周期契约。泛型包装器还要区分右值引用和转发引用,并用 std::forward<T> 保留调用方的值类别。

是什么,为什么存在

引用(reference)让一个表达式直接指代已经存在的对象或函数。对引用读写,就是对它所绑定实体读写;引用不是一份独立副本,也不是负责释放资源的所有者。

左值引用(lvalue reference) 写作 T&,通常绑定到左值。 右值引用(rvalue reference) 写作 T&&,可以绑定到右值,让重载区分「继续使用的对象」和「资源可被复用的对象」。两者都是引用,但绑定规则和参与重载的方式不同。

引用解决的是接口中的间接访问问题。函数可以通过 T& 修改调用方对象,通过 const T& 只读访问对象,也可以返回 T&,让调用方继续操作容器元素或对象成员。运算符重载、范围循环、移动语义和泛型转发都会遇到引用。

引用在一个有效程序中必须指向有效对象或函数,初始化后不能改绑。给引用赋值时,变化的是所绑定对象,而不是引用与对象之间的关系。这个约束让必需参数的接口比裸指针少一种空值状态,但不会消除悬空风险。

引用和所有权是两条不同的轴。const Widget& 只限制通过这条访问路径能做什么,不说明 Widget 由谁拥有,也不保证它能活到引用最后一次使用。任何保存引用的设计都需要单独写明所有者和有效期。

工作原理

声明引用时,&&& 属于声明符。引用变量必须初始化;参数与函数返回类型只是在声明时没有具体绑定,实际调用和返回仍会执行引用初始化。语言不规定引用是否占用独立存储,因此不要把它描述成「一定就是一个指针」。

引用初始化根据引用类型、初始化表达式的类型和表达式的值类别决定能否直接绑定。常见规则可以先记成下面这张表;类类型的用户定义转换和基类子对象还会加入更多候选。

引用类型常见可绑定表达式通过引用可修改对象常见接口含义
T&类型兼容的非 const 左值必需的输入/输出对象
const T&类型兼容的左值或右值只读借用,也可绑定临时对象
T&&类型兼容的右值可复用资源的对象
const T&&类型兼容的右值很少用于普通接口

值类别属于表达式,不属于对象。每个表达式最终是 lvalue、xvalue 或 prvalue 之一;lvalue 和 xvalue 合称 glvalue,xvalue 和 prvalue 合称 rvalue。变量名表达式是 lvalue,即使该变量声明成 T&&

std::move(value) 大致把表达式转换为 xvalue,不会自行移动资源。之后的初始化或函数调用才会进行重载决议(overload resolution),被选中的操作也可能复制。移动语义的完整资源契约属于 cpp/move-semantics

const T&T&& 直接绑定某些临时对象时,可以把该临时对象的生命周期延长到引用的生命周期。这个规则有明确边界:引用参数所绑定的临时对象通常只活到包含函数调用的完整表达式结束;把该参数再返回,不会把延长效果传给调用方。

参数表达的是调用约定

参数类型应该先表达语义,再考虑复制成本。小型、便宜复制的值通常按值传递;const T& 适合只读借用较大对象;T& 表示函数会修改一个必需对象。可选对象通常用指针或可选值表示,因为引用没有合法的空状态。

需求典型形参调用方可见含义
读取便宜复制的值T函数取得自己的值
只读借用const T&函数不通过该引用修改对象
修改调用方对象T&调用可能改变原对象
接收可被移动的具体类型T&&调用方允许函数复用资源
透明转发任意实参模板中的 T&&包装器应保留原值类别

返回引用把同一份生命周期义务交给调用方。返回容器元素或对象成员可以是合理接口,但前提是拥有它的容器或对象仍存活,而且后续操作没有让元素地址失效。无法清楚保证这一点时,返回值通常更稳妥。

示例

下面四个示例依次展示别名与形参、引用返回、值类别重载和转发引用。所有输出都由 GCC 13.3 使用 -std=c++23 编译并实际运行得到。

别名和参数意图

invoicetotal 指代同一个整数,所以通过任一名称修改都能从另一名称观察到。label 直接绑定到临时字符串,其生命周期延长到 label 的作用域结束。

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

void add_fee(int& cents) {
    cents += 50;
}

std::size_t label_size(const std::string& label) {
    return label.size();
}

int main() {
    int total = 1200;
    int& invoice = total;
    add_fee(invoice);

    const std::string& label = std::string{"paid"};
    std::cout << "total: " << total << '\n';
    std::cout << "same object: " << std::boolalpha
              << (&invoice == &total) << '\n';
    std::cout << "label size: " << label_size(label) << '\n';
}
total: 1250
same object: true
label size: 4

&invoice == &total 比较后得到 true,因为对引用取地址会取得所绑定对象的地址。执行 invoice = another_total 只会把另一个整数的值写入 total,不会让 invoice 改绑。

add_fee()int& 明确允许修改调用方对象。label_size() 只读访问字符串,但对这种很小的演示并没有性能结论;是否按引用传递仍应依据真实类型和接口语义决定。

返回容器中的对象

Catalog::at() 提供可修改和只读两个重载。返回的是 names_ 中已有字符串的引用,因此修改返回值会直接修改目录。

catalog.cpp
#include <cstddef>
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class Catalog {
public:
    explicit Catalog(std::vector<std::string> names)
        : names_(std::move(names)) {}

    std::string& at(std::size_t index) {
        return names_.at(index);
    }

    const std::string& at(std::size_t index) const {
        return names_.at(index);
    }

private:
    std::vector<std::string> names_;
};

void archive_first(Catalog& catalog) {
    catalog.at(0) = "archived";
}

void print_first(const Catalog& catalog) {
    std::cout << catalog.at(0) << '\n';
}

int main() {
    Catalog catalog({"active", "queued"});
    std::string& first = catalog.at(0);
    archive_first(catalog);
    std::cout << first << '\n';
    print_first(catalog);
}
archived
archived

first 的有效期受 catalog 及其内部 std::vector 约束。销毁 catalog 后使用它会悬空;可能重新分配 vector 存储的操作也会使已有元素引用失效。at() 的边界检查不提供生命周期保护。

const 成员重载防止调用方通过 const Catalog& 取得可修改引用。它不把底层字符串永久变成 const,通过其他合法的非 const 路径仍可修改同一对象。

观察表达式的值类别

下面的重载只打印所选择的路径,不会从字符串移动。最后两次调用说明:变量 slot 的类型是 std::string&&,但名称表达式 slot 是 lvalue。

value_categories.cpp
#include <iostream>
#include <string>
#include <utility>

void inspect(std::string& value) {
    std::cout << "mutable lvalue: " << value << '\n';
}

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

void inspect(std::string&& value) {
    std::cout << "rvalue: " << value << '\n';
}

int main() {
    std::string queued = "queued";
    const std::string fixed = "fixed";

    inspect(queued);
    inspect(fixed);
    inspect(std::string{"temporary"});

    std::string&& slot = std::string{"named"};
    inspect(slot);
    inspect(std::move(slot));
}
mutable lvalue: queued
const lvalue: fixed
rvalue: temporary
mutable lvalue: named
rvalue: named

声明类型与表达式类别必须分开推理。函数内部有名字的右值引用形参同样是 lvalue;只有在确实允许复用资源时,才把它转换为 xvalue。

这里的 std::move(slot) 只改变重载选择。inspect(std::string&&) 没有把参数用于构造或赋值另一个字符串,所以这次运行后 slot 的字符仍然存在;这不是所有移动操作的保证。

保留调用方的值类别

模板参数推导中的无 cv 限定 T&& 转发引用(forwarding reference) 。左值实参会让 T 推导为左值引用,随后 引用折叠(reference collapsing) 使形参仍为左值引用;右值实参则得到右值引用。

forwarding.cpp
#include <iostream>
#include <string>
#include <utility>

struct Ticket {
    std::string name;
};

void dispatch(const Ticket& ticket) {
    std::cout << "borrowed: " << ticket.name << '\n';
}

void dispatch(Ticket&& ticket) {
    std::cout << "transferable: " << ticket.name << '\n';
}

template<class T>
void relay(T&& ticket) {
    dispatch(std::forward<T>(ticket));
}

int main() {
    Ticket queued{"queued"};
    relay(queued);
    relay(Ticket{"new"});
}
borrowed: queued
transferable: new

ticketrelay() 函数体中是有名字的变量,因此表达式本身是 lvalue。std::forward<T>(ticket) 根据推导出的 T 有条件地恢复调用方的类别;把它替换成 std::move(ticket) 会把调用方传入的普通左值也当成可消耗对象。

并非每个 T&& 都是转发引用。void take(Ticket&&) 的类型已经具体确定,const T&& 带有 cv 限定,而类模板成员中的 T&&T 已由类实例确定,也只是普通右值引用。

陷阱

把赋值误认为改绑

修复: 需要可改绑观察者时使用指针,或在标准库容器中使用 std::reference_wrapper<T>。后者可以重新赋值并保存到容器,但仍不拥有对象,也仍可能悬空。

返回局部对象或临时对象的引用

修复: 默认按值返回,让复制消除和移动规则处理结果。确实返回引用时,在接口中标出所有者,并测试所有者销毁、容器重新分配和元素删除后的失效边界。

const& 当成生命周期保险

修复: 沿引用追溯到最终拥有对象,不要只检查最后一次绑定。跨异步任务、回调或容器保存引用前,要证明所有者活得更久;做不到时就复制值或传递拥有型句柄。

把所有 T&& 都当成转发引用

修复: 在每个模板入口写出 T 的推导结果与折叠后的形参类型。只有透明包装器使用 std::forward<T>;明确接收资源的具体右值引用按其所有权契约处理。

机械地把所有输入改成 const&

修复: 先决定函数是读取、修改、保存还是接管参数,再选择按值、const T&T& 或拥有型参数。性能选择需要基于真实类型和调用路径,不要从「引用总能避免复制」推导结论。

忘记元素引用的失效规则

修复: 查明具体容器和操作的失效保证,并把引用的使用限制在稳定区间。长期标识元素时,考虑稳定所有权、索引或经过校验的句柄,但不要假定换成指针就能修复生命周期。

深入 临时对象的生命周期边界

临时对象的生命周期边界

生命周期延长取决于初始化形式,不是引用之间可以传递的属性。局部 const T& item = T{}; 直接绑定临时对象时,临时对象通常活到 item 所在作用域结束;若把 item 传给另一个引用形参,原来的截止点不会改变。

绑定到引用形参的临时对象通常活到包含函数调用的完整表达式结束。这足以让被调用函数在执行期间读取它,却不允许函数把该引用保存起来供以后使用。返回同一个引用也不会重新延长临时对象的生命周期。

返回语句尤其危险。函数若返回 const T&,而返回表达式创建了一个临时 T,该临时对象不会活到调用方安全使用返回引用的时刻。编译器警告可能发现简单写法,但跨辅助函数、条件运算符和隐式转换的情况仍需要人工追踪。

引用绑定到基类子对象或临时对象的成员时,规则还取决于具体表达式形式。维护代码时不要用「有 const& 就延长」这种口诀代替标准规则;把复杂表达式拆开,或直接返回拥有值,通常更容易审查。

值类别与引用类型

值类别描述表达式的求值结果如何参与语言规则,引用类型描述声明建立什么绑定。对象本身不会永久带着「左值」或「右值」标签;同一个对象可以由变量名产生 lvalue 表达式,也可以经 std::move 产生 xvalue 表达式。

实参表达式T 的推导结果折叠后的 T&&std::forward<T> 的结果
const 变量 itemItem&Item&lvalue
const 变量 itemconst Item&const Item&const lvalue
临时对象 Item{}ItemItem&&xvalue
std::move(item)ItemItem&&xvalue

引用折叠规则可以概括为:只要组合中出现左值引用,结果就是左值引用;只有右值引用与右值引用组合时,结果仍是右值引用。正式规则作用于通过类型别名、模板参数或 decltype 等方式形成的引用类型,源码不能直接声明普通的「引用的引用」。

auto&& 在发生 auto 推导时通常也按转发引用规则工作,因此范围 for 常写 for (auto&& element : range)。但花括号初始化列表等推导规则有特殊情况;看到 auto&& 时仍要先确认初始化上下文,不能只看两个 &

普通 auto 会丢弃顶层引用。若 lookup() 返回 Record&,写 auto record = lookup(); 通常得到副本,auto& record = lookup(); 才保留左值引用。decltype(auto) 可以保留 decltype 推导出的类型,但多加一层括号就可能改变结果,适合需要精确保留返回类型且已经审查过表达式的包装器。

引用限定符与成员返回值

成员函数可以用尾部 &&& 限定调用对象的值类别。data() & 只允许从左值对象调用,data() && 只允许从右值对象调用;再与 const 组合,可以分别控制访问路径和对象类别。

这种区分能防止从短命临时对象泄漏成员引用。例如,一个访问器可以在 const & 重载中返回 const T&,在 && 重载中改为按值返回成员。具体设计仍要考虑成员类型、移动后状态和接口一致性,不能机械套用。

引用限定符也参与重载决议。生成代码若只添加 && 版本而遗漏 const & 版本,现有左值调用可能突然无法编译;若两个版本返回的所有权语义不同,名称和文档还应让差异可见。

引用、指针与 reference_wrapper

引用适合表示必需且不改绑的对象关系。指针适合表达可空、可改指向或参与底层地址运算的关系;智能指针表达某种所有权,而不是引用语法的替代拼写。

标准容器不能直接保存引用类型元素,因为元素需要满足容器的对象与赋值模型。std::reference_wrapper<T> 是可复制、可赋值的非拥有包装器,可以放进容器,并能转换回 T&。它只解决表示问题,不会延长目标对象生命周期。

接口采用引用并不自动阻止空指针问题。调用方若先解引用空指针来构造引用,未定义行为已经发生;被调用函数看不到一个可合法检查的「空引用」。因此,来自指针、句柄或外部数据的边界必须在建立引用之前验证。

指针和引用都可能指向已经结束生命周期的存储。sanitizer 能捕获一些实际执行到的悬空使用,但不能证明所有回调时序、容器操作和异常路径都安全。静态分析、类型设计和生命周期测试需要一起使用。

泛型接口的所有权问题

完美转发保留的是调用方表达式的 cv 限定和值类别,不是对性能或正确性的保证。转发到错误重载、保存一个短命引用或重复转发同一实参,仍可能产生错误。包装器必须先定义它调用目标多少次、是否保存参数以及异常后是否重试。

同一个转发引用不要在未知目标中无条件转发多次。第一次调用可能已经从右值实参取走资源,第二次再转发就会观察移动后状态。需要多次使用时,应先明确取得自己的值,或要求调用方提供可重复读取的对象。

转发构造函数还可能意外抢走复制构造函数或其他重载的候选位置。约束模板只接受目标类型真正支持的参数,并检查同类型、派生类型和 const 左值调用。C++20 concepts 可以改善约束表达,但不会代替生命周期与所有权设计。

当函数就是要保存一份对象时,按值接收再移动到成员常比一组 const T&T&& 重载更简单。转发适合透明适配层,不应成为所有接口的默认形式。最终选择要看调用次数、复制语义和公开契约。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

前置内容 C 指针
下一篇 移动语义 Templates 即将上线 智能指针
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?