现代C++⊂C++11篇(二)左值右值、移动语义与完美转发全解析

本期我们重点拆解C++11里非常核心的一组特性:右值引用与移动语义,以及它们延伸出来的引用折叠、完美转发等高频实用场景。话不多说,我们直接进入正题。

C++98里就已经有引用语法了,等到C++11引入右值引用之后,我们之前熟悉的那套引用就被叫做左值引用。其实不管左值引用还是右值引用,本质上都是给对象起别名,底层都是指针的封装。

目录

一、C++11后的概念扩展:左值与右值

1.1 左值的定义、判断依据与举例

1.1.1 判断左值的核心标准:可以取地址

1.2 右值的定义、判断依据与举例

1.2.1 判断右值的核心:无法取地址

二、左值引用与右值引用

2.1 两种引用的定义

2.2 左右值引用的交叉绑定规则

2.2.1 左值引用的例外:const左值引用可以引用右值

2.2.2 右值引用的例外:用move把左值转成右值

2.3 右值引用绑定右值后衍生的两个关键特性

2.3.1 右值引用别名拥有修改临时右值的权限

2.3.2 右值引用可以延长临时右值的生命周期

2.4 左值引用、右值引用底层实现完全一致

2.5 不同类别实参的函数参数匹配规则

三、移动语义的定义与意义

3.1 移动语义诞生的根源

3.1.1 C++11 之前返回值场景的无解困境

3.2 移动语义定义:把拷贝变成资源“剪切”

3.3 移动语义两大核心:移动构造函数、移动赋值运算符重载

3.3.1 概念区分

3.3.2 完整代码示例(简易模拟string容器)

3.4 移动语义:完美解决局部大对象返回的痛点

3.5 移动语义在传参场景的提效

四、当编译器优化遇上移动构造:是雪中送炭还是锦上添花?

4.1 场景一:只有拷贝构造,没有移动构造

4.2 场景二:拷贝构造、移动构造同时存在

4.3 右值对象赋值:只有拷贝构造+拷贝赋值的场景

4.4 右值对象赋值:拷贝、移动版本齐全的场景

五、C++ 标准对左值右值的细分分类

5.1 纯右值(prvalue)

5.2 将亡值(xvalue)

六、引用折叠:当引用套上了引用

6.1 什么是引用折叠

6.1.1 typedef 场景下的直观演示

6.1.2 模板场景下的引用折叠

6.2 引用折叠的设计目的:一套代码兼容所有场景

七、完美转发:解决右值引用的“属性异化”难题

7.1 一个反直觉的坑:右值引用变量,居然是左值?

7.2 完美转发:原封不动保留参数属性

7.3 std::forward 的底层逻辑


一、C++11后的概念扩展:左值与右值

1.1 左值的定义、判断依据与举例

左值是一类代表具体数据的表达式,最典型的就是变量名、解引用后的指针。它们通常拥有持久的状态,实实在在存储在内存中,我们可以拿到它的地址。左值既可以放在赋值号的左边被赋值,也可以出现在右边参与运算。哪怕是被const修饰的左值,虽然不能再修改赋值,但依然可以取地址,所以它仍然属于左值。

1.1.1 判断左值的核心标准:可以取地址

给大家举几个最常见的左值例子:

int* p = new int(0); int b = 1; const int c = b; *p = 10; string s("111111"); s[0] = 'x'; cout << &c << endl; cout << (void*)&s[0] << endl; // 这里强转成 void* 是因为 cout 碰到 char* 会默认当字符串打印,没法输出地址

上面代码里的p、b、c、*p、s、s [0],全都是可以取地址的标准左值。

1.2 右值的定义、判断依据与举例

右值同样是代表数据的表达式,但特性和左值刚好相反。它要么是字面量常量,要么是表达式求值、函数返回过程中生成的临时对象,生命周期通常很短。右值只能出现在赋值符号的右边参与运算,绝对不能放到左边被赋值;最核心的判断标准是:右值不能取地址

1.2.1 判断右值的核心:无法取地址

常见的右值包括字面量常量、表达式临时结果、传值返回的临时对象、匿名对象、类型转换生成的临时量等等。它们大多没有稳定的内存存储,很多时候只存在于寄存器中,转瞬即逝。

// 以下都是典型的右值,全都无法取地址 10; // 字面量常量,存于寄存器,拿不到地址 x + y; // 表达式运算的临时结果,无固定内存地址 fmin(x, y); // 传值返回的函数,返回的是临时对象 string("11111"); // 显式构造的匿名对象,生命周期仅这一行 int b = 10; (double)b; // C风格强转,生成double类型的临时值 static_cast<int>(c); // 显式类型转换,同样产生临时量

这里补充一句,fmin是用来计算两个数值中较小值的工具函数,不是本篇重点就不展开了。它以传值方式返回结果,生成的临时对象属于右值。 但要特别注意:不是所有函数的返回值都是右值。如果函数返回的是左值引用,返回的就是原对象的别名,能正常取到地址,本质上属于左值。

小科普:左值与右值的命名由来

左值的英文缩写是lvalue,右值是rvalue。传统认知里,它们就是left value(赋值号左边的值)和 right value(赋值号右边的值)的缩写。 而在现代C++的标准定义中,lvalue更准确的解释是locator value,指代存储在内存中、有明确地址、可以被寻址定位的对象;rvalue则对应read value,特指那些只能提供数据值、无法被取地址的表达式。

如果强行对右值取地址,编译器会直接报错,典型提示如下:

// error C2102: "&" 要求左值 cout << &string("11111") << endl;

匿名对象是标准右值,试图对它取地址会直接编译不通过。

一句话总结:左值和右值最核心、最可靠的区分标准,就是能否取地址。

二、左值引用与右值引用

2.1 两种引用的定义

说到底,不管左值引用还是右值引用,本质都是给对象起别名。两者唯一的核心区别,就是能绑定的对象属性不同:

Type& r1 = x; // 左值引用:只能给左值取别名 Type&& rr1 = y; // 右值引用:只能给右值取别名

语法上也很好区分:单个&是左值引用,两个&就是C++11新增的右值引用。

2.2 左右值引用的交叉绑定规则

默认规则很直白:两者井水不犯河水,不能直接交叉绑定。

  • 左值引用不能直接引用右值
  • 右值引用也不能直接引用左值

但两边都有各自的“合法后门”,可以打破这条限制。

2.2.1 左值引用的例外:const左值引用可以引用右值

普通左值引用为什么不能绑右值?

根子上是权限问题右值本身是不可修改的临时量如果让可写的左值引用绑定上,相当于平白放大了操作权限,语法层面不允许。 但给左值引用加上const之后,引用变成了只读属性,权限和右值匹配,就能合法绑定了。

// const 左值引用可以无缝接收右值 const int& rx1 = 10; const double& rx2 = x + y; const double& rx3 = fmin(x, y); const string& rx4 = string("11111");

这也是老生常谈的一个最佳实践:函数形参尽量写成const T&。原因就在这里:它既能接左值实参,也能接右值实参,通用性拉满

2.2.2 右值引用的例外:用move把左值转成右值

右值引用想绑定左值,就得靠标准库的move函数模板。 它的底层逻辑说穿了很简单,就是做一次强制类型转换,把左值表达式强制转换成对应的右值引用类型,效果类似(string&&)s。当然里面还牵扯到引用折叠的规则,这个我们放到后面细讲。

// 右值引用不能直接绑左值,但可以绑定 move 之后的左值 int&& rrx1 = move(b); // 注意:move 不会改变变量本身的左值属性 int*&& rrx2 = move(p); int&& rrx3 = move(*p); string&& rrx4 = move(s);

这里有个很容易搞错的点:move只是把这个表达式的属性变成了右值,原变量本身还是正经的左值,有自己的地址和名字,属性不会被改变。

2.3 右值引用绑定右值后衍生的两个关键特性
2.3.1 右值引用别名拥有修改临时右值的权限

这里有个极易混淆的核心规则:只要是带名字的变量,表达式属性一律是左值哪怕这个变量本身是右值引用类型,只要它有变量名,使用该变量做表达式时,它就变成了左值。也正因如此,我们能通过这个右值引用别名,修改原本只读的临时右值,这也是C++11设计右值引用的核心目的之一。

乍一看这个设计逻辑有点反直觉,等后面讲移动语义的实际场景,你就能明白这么设计的巨大价值。

int main(){ std::string&& r3 = string("Ciallo"); // r3是有名字的变量,属于左值,可以修改绑定的临时对象 r3 = "Hello"; return 0; }

2.3.2 右值引用可以延长临时右值的生命周期

临时右值默认在当前语句执行完毕就会被销毁,但两种引用可以延长它的生命周期:const左值引用、右值引用临时对象会推迟到引用变量销毁时才调用析构函数,而不是创建临时对象的代码行结束就释放。 下面代码里,只有函数执行完毕、r2和r3被销毁时,两个临时对象才会析构。

int main(){ const std::string& r2 = string("test"); // const左值引用延长生命周期 std::string&& r3 = string("Ciallo"); // 右值引用延长生命周期 return 0; }
2.4 左值引用、右值引用底层实现完全一致

上层语法上二者分为T&、T&&,语义绑定规则完全不同,但汇编底层没有区分。 不管是左值引用还是右值引用,底层全部由指针实现,不会单独开辟内存存储原对象。 这里要区分开:上层语法语义和底层汇编实现要分开理解,不能混为一谈、互相推导。

2.5 不同类别实参的函数参数匹配规则
  1. C++98阶段:函数形参仅写const T&,左值、右值实参都能完美匹配,通用性拉满;
  2. C++11之后支持重载区分:同时重载三类引用版本的函数f
    • 传入普通非 const左值 → 匹配f(T&)
    • 传入const修饰左值 → 匹配 f(const T&)
    • 传入右值(字面量、临时对象、move转换后的值)→ 匹配f(T&&)

弄懂左右值引用的绑定、生命周期、重载匹配规则后,我们接下来深入C++11的核心机制:移动语义

三、移动语义的定义与意义

3.1 移动语义诞生的根源
3.1.1 C++11 之前返回值场景的无解困境

左值引用主要用来解决传参、返回值的拷贝开销,既能减少复制,还能修改对象。但有一种场景左值引用完全用不了,形成了两头堵死的悖论,以力扣-杨辉三角题目举例说明: 函数内部创建的vector<vector<int>> vv是栈上局部临时变量,函数执行完毕栈帧会直接销毁。

  1. 若返回vector<vector<int>>&左值引用:局部对象生命周期结束,引用会变成野引用,部分编译器直接报错;
  2. 若直接传值返回:二维vector存储量大,完整深拷贝会产生巨大性能损耗。
class Solution { public: vector<vector<int>> generate(int numRows) { vector<vector<int>> vv(numRows); for (int i = 0; i < numRows; ++i) vv[i].resize(i + 1, 1); for (int i = 2; i < numRows; ++i) { for (int j = 1; j < i; ++j) vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1]; } return vv; } }; int main(){ vector<vector<int>> ret = Solution().generate(5); return 0; }

C++98时代只能靠输出型参数绕开这个问题,但这种写法可读性差、维护麻烦:

class Solution { public: void generate(vector<vector<int>>& ret,int numRows) { vector<vector<int>> vv(numRows); for (int i = 0; i < numRows; ++i) vv[i].resize(i + 1, 1); for (int i = 2; i < numRows; ++i) { for (int j = 1; j < i; ++j) vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1]; } ret = vv; return ; } }; int main(){ vector<vector<int>> _ret; Solution().generate(_ret,5); return 0; }

为了消除这种低效、别扭的写法,C++11推出右值引用与配套的移动语义。

3.2 移动语义定义:把拷贝变成资源“剪切”

移动语义是一套资源优化机制,针对动态内存、文件句柄、网络套接字这类堆资源设计。 它可以直接把即将销毁的临时右值对象的底层资源,转移到新对象身上,彻底省去代价高昂的深拷贝。逻辑很好理解:临时对象马上就要析构销毁,没必要完整复制它堆上的数据;直接接管它的资源指针,再把原对象指针置空,一次交换就完成转移,开销极低

3.3 移动语义两大核心:移动构造函数、移动赋值运算符重载

3.3.1 概念区分
  1. 移动构造函数属于构造函数重载,第一个参数必须是当前类的右值引用;若存在其余参数,必须提供默认值。作用是用一个临时右值对象构造新对象,掠夺它的资源。
  2. 移动赋值运算符重载赋值运算符的重载版本,参数同样是当前类右值引用,和拷贝赋值构成重载关系。用于右值对象赋值给已有对象时转移资源。

只有string、vector这类存在堆内存、需要深拷贝的容器,移动构造/移动赋值才有实际优化价值。二者的核心逻辑都是掠夺资源,而非拷贝复制,以此降低内存拷贝开销。

3.3.2 完整代码示例(简易模拟string容器)
#include <iostream> #include <cstring> #include <algorithm> using namespace std; class MyString { private: char* _str; size_t _size; size_t _capacity; public: // 构造函数 MyString(const char* str = "") { _size = strlen(str); _capacity = _size; _str = new char[_capacity + 1]; strcpy(_str, str); } // 拷贝构造(深拷贝) MyString(const MyString& s) { _str = new char[s._capacity + 1]; strcpy(_str, s._str); _size = s._size; _capacity = s._capacity; cout << "调用拷贝构造,深拷贝" << endl; } // 移动构造:参数为右值引用MyString&& MyString(MyString&& s) { // 直接交换资源,掠夺临时对象堆内存 swap(s); cout << "调用移动构造,转移资源" << endl; } // 拷贝赋值(深拷贝) MyString& operator=(const MyString& s) { if (this != &s) { char* tmp = new char[s._capacity + 1]; strcpy(tmp, s._str); delete[] _str; _str = tmp; _size = s._size; _capacity = s._capacity; } cout << "调用拷贝赋值,深拷贝" << endl; return *this; } // 移动赋值:参数为右值引用MyString&& MyString& operator=(MyString&& s) { if (this != &s) { // 释放自身旧资源,抢夺临时对象资源 delete[] _str; swap(s); } cout << "调用移动赋值,转移资源" << endl; return *this; } // 交换资源接口 void swap(MyString& s) { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); } // 析构函数 ~MyString() { delete[] _str; _str = nullptr; } }; int main() { MyString s1("test"); MyString s2 = s1; // 左值,匹配拷贝构造 MyString s3 = MyString("temp"); // 临时右值,匹配移动构造 MyString s4("old"); s4 = s1; // 左值赋值,匹配拷贝赋值 s4 = MyString("newtemp"); // 右值赋值,匹配移动赋值 return 0; }

补充原理回收

移动构造的参数s是右值引用变量,带变量名的表达式属性为左值,因此我们可以调用swap修改、掠夺它的内部资源;如果右值引用本身是纯右值无别名,就无法修改内部成员。

重载匹配规则

当类同时提供拷贝构造与移动构造时,编译器会自动匹配最优版本:

  1. 传入普通左值对象:匹配拷贝构造。左值对象生命周期还在,后续代码可能继续使用,不能抢夺资源,只能完整深拷贝;
  2. 传入临时右值、move转换后的对象:匹配移动构造。右值临时对象使用完毕就会销毁,直接掠夺资源完全安全。

在 C++11之前没有移动构造,哪怕是马上销毁的临时对象,也只能执行完整深拷贝,带来大量无意义的内存复制开销,移动语义正是为了解决这个痛点而生。

3.4 移动语义:完美解决局部大对象返回的痛点

回到3.1节杨辉三角的两难场景,移动语义刚好就是为这类问题量身定做的。

没有移动语义的时候,传值返回的完整流程是:函数内的vv把自己的堆资源完整深拷贝一份给外层的ret,随后函数栈帧销毁,vv调用析构释放自己的资源,等于同一份数据先复制一遍,又销毁一遍,全是无意义的性能开销

有了移动语义之后,流程就完全不一样了:局部对象vv在函数返回时,会被编译器识别为即将销毁的右值,外层ret构造时直接匹配移动构造。在vv的栈帧销毁前,它的底层堆资源会被直接 “转移” 给ret,vv本身被置为空壳状态。最后vv析构时,释放的只是一个空指针,全程没有大块内存的深拷贝,开销几乎可以忽略

大家可以自己打断点调试验证:观察vector/string内部的资源指针地址,移动构造前后,新对象和旧对象的指针完成了交换,没有新申请堆内存;而拷贝构造一定会申请一块全新的地址,复制完整数据。

⚠️ 重要提醒:不要轻易给还在使用的左值套move move(左值)本质上是主动放弃对象的资源所有权,赋予了它“可以被掠夺数据”的属性。一旦被移动构造/移动赋值接管,原左值就会变成资源被掏空的“空壳”,后续再访问、修改这个对象,就会出现未定义行为。只有明确这个左值后面再也不会用到时,才适合用move触发移动优化。

3.5 移动语义在传参场景的提效

移动语义的优化可不只局限在函数返回值。翻一翻STL官方文档就能发现,C++11之后,所有标准容器的push_back、insert这类插入接口,全都新增了右值引用的重载版本。

底层逻辑非常直白:

  • 传入的实参是左值时,容器内部正常调用拷贝构造,把对象完整复制一份放进容器空间;
  • 传入的是右值(临时对象、move转换后的对象)时,容器内部直接调用移动构造,把右值对象的堆资源直接转移到容器内的新对象上,彻底省掉深拷贝的开销。

我们可以把之前模拟实现的my::list拿过来,补上支持右值引用的push_back和insert接口:

// 右值引用版本尾插 void push_back(T&& x) { insert(end(), move(x)); } // 右值引用版本任意位置插入 iterator insert(iterator pos, T&& x) { Node* cur = pos._node; // 注意:x是右值引用变量,带变量名属于左值属性 // 必须套一层move转回右值,才能匹配节点的移动构造 Node* newnode = new Node(move(x)); Node* prev = cur->_prev; prev->_next = newnode; newnode->_prev = prev; newnode->_next = cur; cur->_prev = newnode; return iterator(newnode); }

这里刚好呼应前面的知识点:形参T&&虽然是右值引用类型,但x本身是有名字的变量,表达式属性为左值。如果直接把x传给节点构造函数,会匹配到拷贝构造而非移动构造,移动优化直接失效。必须用move把它转回右值属性,才能正确触发移动构造。

顺带提一句:C++11还新增了emplace系列插入接口,能做到更极致的原地构造,性能还能再提一截。不过它依赖可变参数模板的语法,我们放到可变参数模板那一节再细讲。

四、当编译器优化遇上移动构造:是雪中送炭还是锦上添花?

很多人刚接触移动语义时都会有个疑问:编译器早就有返回值优化,能省掉多余拷贝,那移动语义是不是多此一举?其实二者绝非替代关系,而是「极致优化 + 底线保障」的组合。我们分两类场景拆解,看看不同编译环境下的实际表现。

很多人刚接触移动语义时都会有个疑问:编译器早就有返回值优化,能省掉多余拷贝,那移动语义是不是多此一举?其实二者绝非替代关系,而是极致优化 + 底线保障的组合。我们分两类场景拆解,看看不同编译环境下的实际表现。

4.1 场景一:只有拷贝构造,没有移动构造

早在C++11之前,编译器就已经支持返回值优化(RVO/NRVO),会尝试消除不必要的拷贝构造。我们以函数返回局部对象,外层用对象接收的经典场景为例:

  • 完全关闭构造优化在Linux下用g++ test.cpp -fno-elide-constructors编译(强制关闭返回值优化),或是VS2019 debug模式关闭优化时,完整流程会触发两次拷贝构造: 先在函数栈帧内构造局部对象,返回时用它拷贝构造一个临时返回对象,回到外层后,再用临时对象拷贝构造最终的接收对象。中间的局部对象、临时对象依次析构,全程两次完整深拷贝,开销最大。
  • 开启基础优化VS2019 debug默认优化级别下,编译器会合并连续的拷贝步骤,只保留一次拷贝构造,省去中间临时对象的冗余复制。
  • 高等级编译优化到了VS2019 release、VS2022的 debug/release模式下,优化会更加激进:编译器会直接跳过所有中间拷贝,把局部对象构造 → 拷贝到临时对象 → 拷贝到外层对象三步合三为一,直接在外层接收对象的内存空间上原地构造,连一次拷贝都省掉。 这种优化的底层逻辑是复用外层对象的栈帧空间,让函数内的局部变量直接在目标内存上构造,从根源消除了拷贝需求。

4.2 场景二:拷贝构造、移动构造同时存在

当类实现了移动构造之后,整个场景的性能底线就被直接拉高了,哪怕编译器完全不做优化,最差也只是执行移动构造,开销远低于深拷贝。

对应同样的三种编译情况:

  • 完全关闭构造优化加-fno-elide-constructors关闭优化后,原本的两次拷贝构造会全部替换为两次移动构造。移动只是交换资源指针、大小等元数据,没有堆内存复制,性能比深拷贝提升一个量级。
  • 开启基础优化VS2019 debug默认优化下,两次移动构造会合二为一,只执行一次移动构造
  • 高等级编译优化在高版本VS的release、强优化模式下,编译器依然会触发最极致的原地构造优化,直接跳过所有移动/拷贝步骤,对象一步构造完成。

最后回到开头的问题:二者是什么关系?

编译器的返回值优化是能省则省,但它有场景限制,比如函数内有多个分支返回不同对象、逻辑较复杂时,优化就可能无法触发。

而移动语义是兜底保障:优化能生效时,移动语义是锦上添花;优化触发不了时,移动语义就是雪中送炭,保证最差情况也只有低开销的资源转移,绝不会出现昂贵的深拷贝。

4.3 右值对象赋值:只有拷贝构造+拷贝赋值的场景

上面我们聊的都是用临时对象构造新对象的场景,属于构造阶段的优化。如果换成给已经存在的对象赋值,情况就完全不同了,返回值优化在赋值场景下的作用非常有限,这也是移动语义真正拉开性能差距的场景之一。

我们以函数返回局部对象,赋值给已有左值对象为例:

  • 关闭构造优化(VS2019 debug、g++ -fno-elide-constructors)完整流程分两步:函数内的局部对象先拷贝构造出一个临时返回对象(1 次拷贝构造),这个临时对象再通过拷贝赋值,把数据完整覆盖到目标对象上(1 次拷贝赋值)。全程一次深拷贝构造 + 一次深拷贝赋值,两次完整的堆内存复制,开销是最大的。
  • 高等级编译优化(VS release、高版本编译器)编译器会进一步压缩中间步骤,直接在返回位置构造临时对象,省去多余的中转拷贝。从对象生命周期能观察到:临时对象的析构发生在赋值操作完成之后,底层本质是通过指针复用了空间,减少了一次冗余的深拷贝。

4.4 右值对象赋值:拷贝、移动版本齐全的场景

当类同时实现了移动构造和移动赋值后,赋值场景的性能底线会被直接拉高。

同样的赋值场景:

  • 关闭构造优化(VS2019 debug、g++ -fno-elide-constructors)原本两次深拷贝的操作,全部替换为移动版本:函数返回时移动构造临时对象(1 次移动构造),临时对象再通过移动赋值,把资源直接转移给目标对象(1 次移动赋值)。全程都是交换指针、元数据的轻量操作,没有任何堆内存复制,性能提升非常显著。
  • 高等级编译优化(VS release、高版本编译器)编译器依然会做激进的空间复用优化,直接构造返回的临时对象,再完成移动赋值。哪怕优化拉满,移动赋值的开销也远低于深拷贝赋值,性能优势依然存在。

看到这里可能有人会问:既然高优化级别下,拷贝和移动最终都能被编译器优化得很极致,看起来差别不大,那移动语义还有必要吗?

问题的关键就在这:不是所有团队、所有项目的编译器,都能触发这么激进的优化。不同编译器版本、不同编译选项、不同代码复杂度下,优化的生效程度天差地别。你不能把性能全押在编译器的 “好心优化” 上,移动语义是语法层面实打实的兜底保障。

最后我们按对象类型做个最终总结:

  • 深拷贝类(string、vector、map 等):这类对象持有大量堆资源,深拷贝代价极高。实现移动构造和移动赋值的价值非常大,编译器的优化只是锦上添花,优化能生效时更快,优化失效时也有移动语义托底,绝不会出现两次深拷贝的糟糕情况。
  • 浅拷贝类(Date、pair<int,int> 等):这类对象没有堆内存资源,全是栈上成员,拷贝和移动的开销几乎没有区别。移动语义对它们意义不大,编译器的常规优化就足够覆盖,没必要特意实现移动版本。

五、C++ 标准对左值右值的细分分类

聊到这儿大家可能觉得,分个左值右值就够用了,但C++11之后,标准对值类别做了更精细的划分。原本的右值被拆成了两个子类:纯右值(pure rvalue,简称prvalue)将亡值(expiring value,简称xvalue)

5.1 纯右值(prvalue)

纯右值就是我们最熟悉的传统右值:字面量常量、表达式求值生成的无名临时对象、传值返回的函数结果,都属于这一类。 比如42、true、nullptr这类字面量;str.substr(1, 2)、str1 + str2这类传值返回的函数调用;还有a + b、a++这类运算产生的临时结果,全都是纯右值。

可以简单记:C++98 里定义的“右值”,放到C++11的分类体系里,基本就等价于纯右值。

5.2 将亡值(xvalue)

将亡值是C++11新增的类别,专门用来指代“资源即将被移交、可以被掠夺”的右值。它背后有真实的对象实体,但已经被标记为“即将消亡、资源可以拿走”的状态。 最典型的就是两类:move(x)这种返回右值引用的函数调用表达式,还有static_cast<X&&>(x)这种强制转成右值引用的转换表达式。

除了右值的拆分,标准还定义了泛左值(generalized lvalue,简称glvalue),它是左值和将亡值的统称,所有能定位到具体对象、有确定身份的表达式,都属于泛左值。

这部分属于标准层面的细分定义,日常写代码、刷题不用死抠,有个印象就行。我整理了一张对照表方便大家快速对照,想抠更细节的定义也可以去官方文档查阅。

文档传送门:值类别 - cppreference.com

六、引用折叠:当引用套上了引用

6.1 什么是引用折叠

先给直白定义:在模板类型推导、typedef/using类型别名等场景下,代码可能间接生成 “引用的引用”,编译器会按照固定规则把多层引用合并成单层引用,这套合并规则就叫引用折叠

先划死一个大前提:C++语法层面绝对不允许直接定义引用的引用,比如你手写int& && r = i;编译器会直接报错。但在模板、类型别名这种 “间接操作类型” 的场景里,很容易套出多层引用,这时候引用折叠规则就会自动生效兜底。

折叠的核心规则非常好记,就八个字:有左则左,全右则右。 两层引用里只要出现了左值引用,最终结果一定是左值引用;只有两层全都是右值引用时,才会折叠成右值引用。

6.1.1 typedef 场景下的直观演示

我们先用 typedef 把折叠效果具象化,一眼就能看懂规则:

int main(){ typedef int& lref; // 左值引用的类型别名 typedef int&& rref; // 右值引用的类型别名 int n = 0; lref& r1 = n; // 等价于 int& & → 折叠为 int& lref&& r2 = n; // 等价于 int& && → 折叠为 int& rref& r3 = n; // 等价于 int&& & → 折叠为 int& rref&& r4 = n; // 等价于 int&& && → 折叠为 int&& return 0; }

前三种组合都带左值引用,最后全折叠成了左值引用;只有“右值引用的右值引用”这一种情况,会折叠成右值引用。

6.1.2 模板场景下的引用折叠

typedef只是帮我们理解规则,引用折叠真正的主战场在模板里,我们常听的万能引用(转发引用),底层就是靠模板推导+引用折叠实现的。

先看两种最常见的模板形参写法,对比一下差别:

// 写法1:形参是左值引用 template<typename T> void f1(T& x) {} // 写法2:形参是带推导的右值引用 → 万能引用 template<typename T> void f2(T&& x) {}

先说f1(T& x):因为形参本身已经是左值引用,不管T被推导成什么类型,折叠后永远是左值引用。好处是能接所有左值,坏处是接不了右值,传右值进去直接编译报错。

重点是f2(T&& x): 这里的T&&看着像右值引用,但因为T是待推导的模板参数,它就成了万能引用:既能接左值,也能接右值。

  • 传入左值:T会被推导成左值引用类型,经过引用折叠,形参最终是左值引用;
  • 传入右值:T被推导成普通值类型,形参就是原生的右值引用。

举两个最基础的例子:

int n = 0; f2(n); // 传左值n → T推导为int& → int& && → 折叠为int& f2(0); // 传右值0 → T推导为int → int&& → 纯右值引用

这里补一个高频易错点:不是所有T&&都是万能引用只有处于模板推导阶段的T&&才是;如果模板参数已经确定(比如类模板实例化之后的成员函数),那它就是普通的右值引用,接不了左值。

6.2 引用折叠的设计目的:一套代码兼容所有场景

说穿了,引用折叠存在的核心价值,就是让一个模板函数,同时兼容左值、右值、带const等多种实参,不用手动写多份重载,大幅减少重复代码

我们用一个完整例子跑一遍完整流程,就彻底明白了:

template<typename T> void Function(T&& t){ int a = 0; T x = t; cout << &a << endl; cout << &x << endl << endl; } int main() { // 传纯右值10:T推导为int → 形参类型为int&& Function(10); int a; // 传左值a:T推导为int& → 引用折叠后形参为int& Function(a); // 传右值std::move(a):T推导为int → 形参类型为int&& Function(std::move(a)); // 补充:const属性会完整保留,不会被折叠吃掉 const int b = 8; // 传const左值:T推导为const int& → 折叠后形参为const int& Function(b); // 传const右值:T推导为const int → 形参类型为const int&& Function(std::move(b)); return 0; }

两个细节单独提一下:

  1. 引用折叠只处理引用层数,不会丢失const、volatile这类限定符,原实参带什么属性,折叠后都会原样保留;
  2. 右值引用变量本身是左值属性,但加上 const 之后同样失去修改权限,和普通变量的const规则完全一致。

这套机制也是标准库的核心基建之一,后面要讲的完美转发、std::forward,还有各种容器的emplace接口,底层全靠模板推导 + 引用折叠撑着,不用再为左值、右值分别写一套重复的函数。

七、完美转发:解决右值引用的“属性异化”难题

7.1 一个反直觉的坑:右值引用变量,居然是左值?

这里先提一个非官方但特别形象的说法:右值引用属性异化。说人话就是:一个右值引用类型的变量,当它作为表达式被实际使用时,它的属性是左值。这个特性在参数需要二次传递的时候,会闹出非常反直觉的问题。

看这段代码,一眼就能懂问题出在哪:

// 四个重载版本,分别匹配不同的引用类型 void Fun(int& x) { cout << "左值引用" << endl; } void Fun(const int& x) { cout << "const 左值引用" << endl; } void Fun(int&& x) { cout << "右值引用" << endl; } void Fun(const int&& x) { cout << "const 右值引用" << endl; } template<class T> void Function(T&& t){ Fun(t); // 把收到的参数再往下传给Fun } int main() { Function(10); // 传入的是纯右值10 return 0; }

按直觉想:我们传了个右 10进去,Function的形参t是右值引用,往下传给Fun,怎么也该匹配右值引用版本吧? 实际跑起来就会发现,它精准匹配了左值引用版本

原因就是我们说的“属性异化”虽然t的类型是右值引用,但t本身是一个有名字、能取地址的变量,它作为表达式出现时,属性就是左值所以只要你直接把t往下传,不管进来的时候是左值还是右值,到了下一层全都会退化成左值,右值属性直接丢得一干二净

这就很尴尬了:万能引用倒是能接住所有类型,可接进来之后再想往下传,属性直接歪了。针对这个痛点,C++委员会专门设计了对应的解决方案,完美转发。

7.2 完美转发:原封不动保留参数属性

完美转发:就是专门解决这个二次传递问题的。 它的核心目标很纯粹:在模板编程中,通过T&& 万能引用配合std::forward,将参数原封不动地传递给下一层函数,确保参数的左值/右值、const 等属性在传递过程中完全不丢失。

对应规则也很好记:

  • 传进来左值 → T推导为左值引用 → forward返回左值引用
  • 传进来右值 → T推导为普通值类型 → forward返回右值引用
7.3 std::forward 的底层逻辑

说穿了,forward的核心实现还是靠引用折叠。它的简化版源码逻辑大概是这样:

template <class _Ty> _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept { // 左值进来,折叠后返回左值;右值进来,强转成右值返回 return static_cast<_Ty&&>(_Arg); }

我们拆成两种场景看就彻底明白了:

  • 当实参是左值时,T被推导为int&,_Ty&&就是int& &&,经过引用折叠变成int&,最终返回左值引用;
  • 当实参是右值时,T被推导为int,_Ty&& 就是 int&&,直接强转为右值引用返回。

加上forward之后,刚才的代码就能完全按预期精准匹配了:

template<class T> void Function(T&& t){ Fun(forward<T>(t)); // 用forward保持属性原封不动往下传 } int main() { Function(10); // 匹配右值引用版本 int a; Function(a); // 匹配左值引用版本 Function(std::move(a)); // 匹配右值引用版本 const int b = 8; Function(b); // 匹配const左值引用版本 Function(std::move(b)); // 匹配const右值引用版本 return 0; }

万能引用+完美转发,可以说是C++移动语义体系里的黄金搭档。 它让一套模板代码就能精准兼容左值、右值、const/非 const 等所有场景,既完整保留了参数的原生属性,又避免了大量重复的重载代码,是标准库里emplace接口、移动构造等诸多高效实现的底层基石。