C++11 右值引用与移动语义
1. 左值引用和右值引用
1.1 左值引用
左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。
int main()
{
// 以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
// 以下几个是对上面左值的左值引用
int*& rp = p;
int& rb = b;
const int& rc = c;
int& pvalue = *p;
return 0;
}
1.2 右值引用
右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引 用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能 取地址。右值引用就是对右值的引用,给右值取别名。
int main()
{
double x = 1.1, y = 2.2;
// 以下几个都是常见的右值
10;
x + y;
fmin(x, y);
// 以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
10 = 1;
x + y = 1;
fmin(x, y) = 1;
return 0;
}
1.3 左值引用与右值引用比较
1.3.1 核心判别标准:能不能取地址?
左值 (Lvalue - Locator Value):有名字、占用实际内存空间、生命周期较长(超出当前表达式)、能用
&取地址的对象。示例:局部变量
int a = 10;中的a,解引用的指针*p,前置递增++x。
右值 (Rvalue - Read Value):没有名字、通常是临时计算产生、马上就会被销毁、不能用
&取地址的值。
1.3.2 右值的细分(C++11 引入)
纯右值 (prvalue):字面量(如
10、true)、非引用返回的函数临时返回值(如x + y的结果)。将亡值 (xvalue):生命周期即将结束的对象。最典型的就是被
std::move()强行转换后的对象。
1.3.3 左值引用与右值引用的总结
1. 左值引用只能引用左值,不能引用右值。
2. 但是const左值引用既可引用左值,也可引用右值。
int main()
{
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a;
// ra为a的别名
//int& ra2 = 10; // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}
1. 右值引用只能右值,不能引用左值。
2. 但是右值引用可以引用move以后的左值。
int main()
{
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
// error C2440: “初始化”: 无法从“int”转换为“int &&”
// message : 无法将左值绑定到右值引用
int a = 10;
int&& r2 = a;
// 右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}
1.4 右值引用存在的必要
左值引用极其强大,但它有一个致命的盲区:它无法高效处理“临时对象(右值)”。
假设我们有一个极其耗费内存的 Image 类:
void processImage(const Image& img); // 接收常量左值引用
当调用
processImage(Image("test.png"));时,Image("test.png")是一个临时对象(右值)。C++ 规定,普通的左值引用 (Image&) 不能绑定右值,只有常量左值引用 (const Image&) 可以绑定右值。
痛点爆发
const Image&确实接住了这个临时对象,避免了传参时的拷贝。但是,它带了const(只读)! 这意味着,在processImage内部,您只能看,不能动。即使您知道这个Image对象马上就要销毁了,您也无法把它的底层像素数据(比如一个char*数组)直接“偷”过来据为己有,您只能老老实实地再深拷贝一份!
右值引用的诞生使命
右值引用 (
T&&) 的出现,就是为了打破const的枷锁。它明确地告诉编译器:“我接住了一个马上就要死的临时对象,并且我有权限去修改它、拆解它、甚至掏空它!”有了右值引用,移动语义才得以实现,C++ 才真正实现了对“临时对象”资源的最大化利用。
2. 移动语义
2.1 本质
想象一个场景:你需要把一辆装满黄金的卡车从 A 仓库转移到 B 仓库。
深拷贝(C++98 的做法):在 B 仓库造一辆一模一样的新卡车,把 A 仓库的黄金一块一块复制过去,然后把 A 仓库的卡车和黄金一起炸毁(析构)。极其耗时!
移动语义(C++11 的做法):直接把 A 仓库卡车的钥匙拔下来,交给 B 仓库。然后把 A 仓库的记录抹除(指针置空)。瞬间完成!
这就是移动构造函数和移动赋值运算符的本质:浅拷贝资源指针 + 源指针置空。
#include <iostream>
#include <cstring>
class MyString {
private:
char* data;
public:
// 1. 普通构造函数
MyString(const char* str = "") {
data = new char[strlen(str) + 1];
strcpy(data, str);
std::cout << "调用普通构造 (分配内存)" << std::endl;
}
// 2. 拷贝构造函数 (C++98 深拷贝,耗时)
// 接收 const 左值引用,说明对方还要继续存活,我们只能老老实实复制
MyString(const MyString& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
std::cout << "调用拷贝构造 (深拷贝,极度耗时!)" << std::endl;
}
// 3. 移动构造函数 (C++11 性能引擎)
// 接收右值引用 (&&),说明 other 马上就要死了,我们可以直接偷它的内存!
MyString(MyString&& other) noexcept {
data = other.data; // 第一步:偷走指针 (浅拷贝)
other.data = nullptr; // 第二步:斩断联系!防止 other 析构时释放这块内存
std::cout << "调用移动构造 (资源转移,O(1) 极速!)" << std::endl;
}
// 析构函数
~MyString() {
delete[] data;
}
};
// 一个返回 MyString 右值的工厂函数
MyString createString() {
MyString temp("Hello World");
return temp; // 编译器会将其优化或当作右值处理
}
int main() {
std::cout << "--- 场景 1:深拷贝 ---" << std::endl;
MyString s1("C++98");
MyString s2(s1); // s1 是左值,触发【拷贝构造】
std::cout << "\n--- 场景 2:移动语义 ---" << std::endl;
// createString() 返回的是一个临时对象 (右值)
// C++11 会精准匹配到 MyString(MyString&&),触发【移动构造】
MyString s3(createString());
return 0;
}
2.2 std::move
按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为:有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过move 函数将左值转化为右值。
C++11中,std::move()函数位于头文件中,该函数名字具有迷惑性, 它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义。
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
// forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
int main()
{
bit::string s1("hello world");
// 这里s1是左值,调用的是拷贝构造
bit::string s2(s1);
// 这里我们把s1 move处理以后, 会被当成右值,调用移动构造
// 但是这里要注意,一般是不要这样用的,因为我们会发现s1的
// 资源被转移给了s3,s1被置空了。
bit::string s3(std::move(s1));
return 0;
}
3. 完美转发
3.1 为什么需要完美转发?
“有名字的右值引用,就是左值!”这是 C++ 中一个极其反直觉、但也极其符合逻辑的规则。
当你把一个右值传递给函数的右值引用参数时,在这个函数内部,这个参数是有名字的,并且你可以对它取地址。能取地址的就是左值。致命的后果:移动语义在此中断!
#include <iostream>
void process(int& x) { std::cout << "调用了: 左值处理函数 (深拷贝)" << std::endl; }
void process(int&& x) { std::cout << "调用了: 右值处理函数 (移动语义)" << std::endl; }
void wrapper(int&& arg) {
// 此时 arg 有名字,是一个左值!
// 即使它最初接收的是一个右值,这里也会调用 process(int&)
process(arg);
}
int main() {
wrapper(10); // 10 是纯右值。期望触发移动语义,结果却触发了深拷贝!
return 0;
}
虽然给
wrapper传了右值10,但当它再往下传给process时,“右值属性”丢失了。如果在这里手贱写成process(std::move(arg)),那如果传了个左值进来呢?左值被强行 move 会导致外部数据被掏空,极其危险!我们需要一种机制:如果传进来的是左值,就保持左值传递;如果传进来的是右值,就保持右值传递。 这就是“完美转发”。
3.2 核心:万能引用 (Forwarding Reference)
要实现完美转发,首先得让你的包装函数既能接收左值,也能接收右值。C++11 规定,当 T 是模板参数时,T&& 不再是纯粹的右值引用,而是万能引用(也叫转发引用)。
template <typename T>
void wrapper(T&& arg) { // 这里的 T&& 是万能引用!
// ...
}
如果你传给它左值(如
int a),T会被推导为int&,arg就是左值引用。如果你传给它右值(如
10),T会被推导为int,arg就是右值引用。
3.3 底层:引用折叠 (Reference Collapsing)
如果传左值,T 是 int&,代入 T&& 后岂不是变成了 int& &&(引用的引用)?C++ 本来是不允许“引用的引用”存在的。
为了让万能引用成立,C++11 引入了引用折叠规则。记住一个口诀:只要有左值引用参与,结果就是左值引用;只有两个都是右值引用,结果才是右值引用。
&+&->&
&+&&->&
&&+&->&
&&+&&->&&这保证了无论外界传入什么,
T&&都能生成合法的引用类型。
3.4 std::forward
现在有了既能接左值又能接右值的 wrapper 函数,最后一步就是:在把它传给下一层函数时,恢复它本来的面目。
std::forward<T>()就是干这个的。它是一个条件转换(Conditional Cast):
它通过模板类型
T来判断原来的实参到底是什么。如果
T是左值引用,它就什么都不做,原样返回左值。如果
T不是左值引用(意味着原实参是右值),它就相当于std::move(),强行把参数转回右值。
#include <iostream>
#include <utility> // std::forward 的头文件
void process(int& x) { std::cout << "底层调用: 左值处理函数 (深拷贝)" << std::endl; }
void process(int&& x) { std::cout << "底层调用: 右值处理函数 (移动极速)" << std::endl; }
// 完美转发的标配写法:模板 + T&& + std::forward<T>
template <typename T>
void wrapper(T&& arg) {
// 完美地将 arg 的原始左右值属性转发给 process
process(std::forward<T>(arg));
}
int main() {
int a = 100;
std::cout << "--- 测试 1:传入左值 ---" << std::endl;
wrapper(a); // 完美保持左值属性,调用 process(int&)
std::cout << "\n--- 测试 2:传入右值 ---" << std::endl;
wrapper(200); // 完美保持右值属性,调用 process(int&&)
return 0;
}
输出结果:
--- 测试 1:传入左值 ---
底层调用: 左值处理函数 (深拷贝)
--- 测试 2:传入右值 ---
底层调用: 右值处理函数 (移动极速)
4. 右值引用的三大经典实战场景
4.1 容器的 push_back 与 emplace_back 优化
这是我们每天都在用,但可能没意识到的优化。STL 容器为插入操作提供了左值和右值两个重载版本。
std::vector<std::string> vec;
std::string str = "Persistent String";
// 1. 传入左值 str,调用 push_back(const string&),底层执行【深拷贝】
vec.push_back(str);
// 2. 传入临时右值,调用 push_back(string&&),底层执行【移动语义】
// 字符串 "Temporary String" 的堆内存被直接偷走放入 vector,零拷贝开销!
vec.push_back(std::string("Temporary String"));
4.2 资源管理类(如智能指针、线程对象)的所有权转移
有些系统资源是独占的、不可拷贝的(比如文件句柄、网络套接字、std::unique_ptr、std::thread)。对于这些类,拷贝构造函数被 =delete 禁用了。我们只能通过右值引用来转移所有权。
std::unique_ptr<int> ptr1 = std::make_unique<int>(100);
// std::unique_ptr<int> ptr2 = ptr1; // ❌ 编译报错:禁止拷贝!
// ✅ 正确:使用 std::move 将 ptr1 转为右值,触发移动赋值
// ptr1 的控制权转移给 ptr2,ptr1 变为空
std::unique_ptr<int> ptr2 = std::move(ptr1);
4.3 消除函数返回大对象时的性能顾虑
在 C++98 时代,总是:“千万不要在函数里返回大容器,要把容器作为引用参数传进去修改!”
// C++98 的妥协写法(为了避免返回大对象的拷贝)
void getLargeData(std::vector<int>& outData) { ... }
但在 C++11 之后,有了右值引用作为兜底,可以大胆、优雅地直接返回大对象:
// 现代 C++ 的优雅写法
std::vector<int> getLargeData() {
std::vector<int> data = {1, 2, 3, /* ... 100万个元素 ... */};
return data; // data 是局部变量,返回时变成将亡值(右值)
}
// 接收时,触发移动构造函数。100万个元素瞬间转移,毫无波澜!
std::vector<int> myData = getLargeData();
5. 总结
值引用 (
T&):替长寿命对象起别名,避免拷贝,允许修改状态。右值引用 (
T&&):替短寿命(临时)对象开辟特殊通道,允许我们合法地“掏空”它的资源,从而将昂贵的深拷贝转化为极速的浅拷贝(移动语义)。
正是这两种引用的精妙配合,让现代 C++ 在维持高级抽象的同时,依然能榨干硬件的每一滴性能。
更多推荐



所有评论(0)