跳到主要内容

引用、const、值类别与生命周期

本节目标

掌握 C++23 的引用绑定、const 语义、值类别与临时对象生命周期边界。

引用描述已有对象的别名关系,const 描述可通过当前接口进行的操作,而值类别描述表达式可如何参与绑定与重载。三者都不能代替生命周期分析:先确认对象是否仍存活,再讨论它能否被借用或移动。

glvalue、prvalue、xvalue 地图

值类别是表达式的属性,不能从变量声明的类型或它出现在等号哪一侧来猜。glvalue 与 prvalue 构成完整划分:glvalue 的求值确定某个实体的身份,其中 lvalue 是非 xvalue 的 glvalue,xvalue 则表示资源可被复用的将亡值;prvalue 用于初始化对象或计算运算数的值。换一个角度看,rvalue 是 prvalue 与 xvalue 的合称。

类别是否有身份是否触发具体化典型表达式
glvalue(总类)是,求值确定对象或函数等实体的身份否;它本身已经提供身份lvalue 与 xvalue
lvalue具名变量 value、解引用表达式 *pointer
xvalue否;它仍是 glvaluestd::move(value)
prvalue在具体化前不以身份为判断结果视上下文而定;绑定引用等需要对象身份时会触发临时量具体化整数字面量 42、按值调用 makeTwelve()

std::move(value) 的结果是 xvalue,并不是因为 value 写在“右侧”。值类别会影响引用绑定和重载决议,却不直接说明对象由谁拥有或还能存活多久。

左值引用绑定

左值引用在初始化时建立别名,之后对引用赋值就是对原对象赋值:

int source{7};
int& alias = source;
alias = 9; // source 也变成 9

int other{12};
alias = other; // 把 12 写入 source,并没有让 alias 改指向 other

非常量 T& 绑定到可修改的左值;const T& 还可绑定到只允许经该引用读取的表达式,也能在满足后文规则时绑定右值。引用必须先绑定一个对象,初始化后不能重新落到另一个对象上。

接口若要求调用方提供一个必然存在、可写的对象,可使用 T&;只读借用可写成 const T&。可空性和所有权都不是引用自动表达的语义,需要由其他接口类型明确承担。

右值引用绑定

T&& 通常绑定到 prvalue 或 xvalue(统称 rvalue),但“引用的声明类型”和“使用这个名字所得表达式的值类别”是两件事:

std::string&& temporary = makeText(); // 绑定 prvalue,临时对象随局部引用延寿
std::string&& candidate = std::move(text); // 绑定 xvalue,不执行资源转移

temporarycandidate 都有名字,所以表达式 temporarycandidate 本身是 lvalue。右值引用可以标出对象的资源可被复用,却不会让每次使用这个名字都自动移动;要把具名对象再次交给接收 xvalue 的操作,仍须在转移点显式写出 std::move(candidate)

const 正确性

const 限制的是当前访问路径。顶层 const 作用于当前对象本身,底层 const 则位于指针或引用所通向的类型上:

Record record{};
Record* const fixed = &record; // 顶层 const:fixed 不能重新指向
const Record* view = &record; // 底层 const:不能经 view 修改 record

void inspect(const Record& value); // 只读引用同样限定被引用对象的访问路径

因此,view 的只读性不表示 record 最初一定定义为 const;其他合法的非 const 路径仍可修改这个原本可写的对象。顶层与底层限定在推导和重载中也不同:按值形参的顶层 const 不能单独形成另一个重载,而指针或引用通向的 const 可以影响绑定与重载选择。

不要为了复用一个可写接口而用 const_cast 绕过只读签名。强制转换只改变访问表达式,不会把原本定义为 const 的对象变成可修改对象;若经去除限定后的路径修改这种对象,行为未定义。把只读需求写进参数和成员函数签名,才能让编译器参与检查。

指针与引用的接口选择

引用和裸指针都能表达借用,但它们给调用者的接口信号不同:

需求引用指针
必须提供有效对象T&const T& 直接表达这个前置条件,例如 void update(Config& config);T* 也能指向对象,但“不允许空”必须另写成合同
参数可以缺席良定义的引用不能表示“没有对象”可用 nullptr 明确表示缺席
借用期间需要改指向引用初始化后不能重新绑定指针对象可以保存另一个地址;若要改写调用方的指针,还需显式的额外间接层
生命周期与所有权作为接口借用,不替调用方延长实参对象的生命周期,也不取得所有权作为接口借用,不延长实参对象的生命周期;裸指针本身同样不建立拥有关系
访问前置条件绑定和使用期间都必须有相应的存活对象非空时还要满足对象存活、类型与范围等合同;数组范围需单独表达

若参数可以缺席,选择能够清楚表示缺席的类型,不要把可空参数伪装成引用。若接口要转移或共享所有权,应使用相应的所有权类型,而不是靠裸指针暗示责任。

临时对象具体化与生命周期延长

prvalue 在绑定引用等需要对象身份的上下文中会先具体化为临时对象。局部引用直接绑定该结果时,可以把临时对象的销毁推迟到引用的生命周期结束:

{
const int& temporary = makeTwelve(); // 创建 prvalue,具体化后直接绑定
inspect(temporary); // 临时对象仍然存活
} // temporary 的生命周期结束,临时对象在这里销毁

这条延寿规则不会沿着引用任意传递,也有必须单独判断的 C++23 边界:绑定到函数引用形参的临时对象只活到包含调用的完整表达式结束;函数返回语句中绑定到返回引用的临时对象不会延寿,返回后引用悬垂;构造函数的成员初始化器若让引用成员绑定临时表达式,程序非良构;引用成员的默认成员初始化器若绑定临时表达式,程序非良构。在局部作用域直接初始化聚合对象时,引用成员还要区分语法:花括号中的临时对象可延长到聚合对象的生命周期结束,而圆括号表达式列表中的临时对象只活到该完整表达式结束。new-initializer 无论使用花括号还是圆括号,绑定到引用成员的临时对象都只活到包含 new 表达式的完整表达式结束,随后该成员悬垂。不能把局部 const T& 的结论概括成“参数、成员和返回值都会自动延寿”。

下面的完整程序把 makeTwelve() 返回的 prvalue 直接绑定到局部 const int&,并在该引用仍存活时读取它:

reference_categories.cpp
#include <iostream>
#include <type_traits>
#include <utility>

[[nodiscard]] int makeTwelve() {
return 12;
}

int main() {
int source{7};
int& sourceReference = source;
const int& temporary = makeTwelve();
static_assert(std::is_rvalue_reference_v<decltype(std::move(source))>);

std::cout << "source_before=" << source << '\n';
sourceReference = 9;
std::cout << "source_after=" << source << '\n';
std::cout << "temporary=" << temporary << '\n';
std::cout << "move_cast=xvalue\n";
std::cout << "dangling_avoided=true\n";
}

输出:

source_before=7
source_after=9
temporary=12
move_cast=xvalue
dangling_avoided=true

程序还通过左值引用更新原对象,只观察仍然存活的对象,并且没有读取任何已经移动的对象。

std::move 只是转换

std::move 本身只把表达式转换为 xvalue。先绑定一个右值引用,可以看出转换与资源转移并不是同一步:

std::string source{"ready"};
std::string&& candidate = std::move(source); // 只建立别名,没有调用移动操作
std::string target{std::move(source)}; // 构造函数才有机会转移资源

移动构造、移动赋值或其他接收 xvalue 的重载是否存在、是否被选择以及具体做什么,都由随后的重载决议和被调用操作决定。代表程序用 decltype(std::move(source)) 的静态断言检查 xvalue 结果,但不把这次转换冒充成一次移动。

真实移动发生后,源对象仍须满足其类型规定的有效要求;除非该类型另有保证,其具体内容通常是未指定的。不要仅因写过 std::move 就假定对象已经清空,也不要让后续逻辑依赖移动前的原值。

转发引用预览

下面两种 && 的拼写相同,形成条件却不同:

template<class T>
void relay(T&& value); // T 由这次函数调用推导:可能是转发引用

void consume(Widget&& value); // Widget 已确定:普通右值引用

relay(widget),若实参是 lvalue,T 推导为 Widget&,再由引用折叠把 T&& 形成 Widget&;对 relay(Widget{})T 推导为 Widget,形参保持 Widget&&。只有这种未加 cv 限定的模板参数由同一函数调用推导时,T&& 才属于转发引用;普通 Widget&&,以及 T 已由外层上下文确定的 T&&,都不会因此变成转发引用。

形参名 value 在函数体内仍是 lvalue。若要把调用方的类别继续传给下一层,完美转发需要结合 std::forward<T>(value);完整规则留给模板专题,本节只建立“调用推导 + 引用折叠”的判断边界。

悬垂引用识别

判断返回接口是否安全,先比较返回对象的生命周期,而不是先看 & 的数量:

int makeValue() {
int local{7};
return local; // 按值初始化调用方的结果对象
}

int& badReference() {
int local{7};
return local; // 函数返回时 local 销毁,返回的引用随即悬垂
}

按值返回让结果在调用方自己的对象或临时对象中继续存在;返回局部对象的引用只复制了别名关系,并没有复制或延长该对象。对象生命周期结束后,继续通过悬垂引用访问就是未定义行为。

审查引用时,应追踪被引用对象在哪里创建、何时销毁,以及哪些操作会提前使别名失效。持有者销毁、成功的所有权替换或容器重分配都可能让先前保存的引用失效;地址或引用变量看起来仍在,并不能证明目标对象仍可访问。

常见陷阱

复杂绑定可以按下面几类错误分别排查:

  • 把声明类型当成表达式类别。具名 T&& 仍是 lvalue;只有在明确的转移点把它转换成 xvalue,才给移动重载参与选择的机会。
  • 依赖移动后的具体内容。移动后对象仍须有效,但“为空”“等于旧值”等状态只有类型明确保证时才能使用。
  • 用引用表达可空参数。良定义的引用要求有对象可供绑定;参数允许缺席时,应使用能表示缺席的类型,而不是制造“空引用”约定。
  • 保存已经失效的别名。所有者销毁、容器重分配或其他失效操作之后,旧引用不会自行更新;继续访问会越过生命周期边界。
  • 过度推广临时量延寿。const auto& view = makeValue(); 可以延长它在本地直接绑定的临时对象,但不能把延寿经参数、第二个引用或返回引用继续传递,引用成员还要按具体初始化语法判断。

遇到难以判断的声明时,写下初始化器的类型和值类别,再标出真正拥有对象的实体及其生命周期;这比只看 &&&const 的拼写可靠。