文章目录
- 前言
- 一、分清左值和右值
- [二、左值引用 vs 右值引用](#二、左值引用 vs 右值引用)
-
- [1. 引用延长临时对象生命周期](#1. 引用延长临时对象生命周期)
- [2. 重载区分左值、右值实参](#2. 重载区分左值、右值实参)
- [三、移动语义:移动构造 和 移动赋值](#三、移动语义:移动构造 和 移动赋值)
-
- [3. 编译器默认生成移动成员的规则](#3. 编译器默认生成移动成员的规则)
- [四、std::move 是什么?](#四、std::move 是什么?)
- [五、引用折叠 和 万能引用(转发引用)](#五、引用折叠 和 万能引用(转发引用))
-
- [1. 引用折叠规则](#1. 引用折叠规则)
- [2. 万能引用 `T&&`](#2. 万能引用
T&&)
- [六、std::forward 完美转发](#六、std::forward 完美转发)
- [七、在 STL 容器中的实际应用](#七、在 STL 容器中的实际应用)
- 八、高频问题总结
- 总结
前言
在 C++98 时代,拷贝对象是一件很头疼的事情。比如函数返回一个局部的string或者vector,会生成临时对象,再调用拷贝构造把资源完整复制一份。如果对象很大,堆内存很多,拷贝开销就非常高。
但我们仔细想想:这个临时对象函数结束马上就要销毁,它的堆内存反正就要释放,为什么不能直接把这块内存 "偷过来" 给新对象用,非要完整拷贝一遍?
C++11 带来的右值引用、移动语义 就是来解决这个痛点。不再总是做深拷贝,允许把即将销毁对象的资源转移走,大幅减少内存拷贝。同时还衍生出std::move、std::forward万能转发,也是 STL 容器push_back/emplace_back底层的核心。
前置知识基础:类和对象、拷贝构造、拷贝赋值。
一、分清左值和右值
左值和右值的核心判断标准:能不能取地址。
- 左值 (lvalue) :有持久存储、可以取地址。可以放在等号左边,也可以放右边。普通变量、解引用
*p、数组元素、引用变量、const修饰的变量,全是左值。 - 右值 (rvalue) :临时数据,字面量、表达式结果、匿名临时对象,不能取地址,只能出现在等号右侧。
cpp
#include <iostream>
#include <string>
using namespace std;
int main()
{
// -------- 左值,可以取地址 --------
int a = 10;
int* p = new int(1);
const int c = 100;
string s("hello");
cout << &a << endl;
cout << &(*p) << endl;
cout << &c << endl;
cout << (void*)&s[0] << endl;
// -------- 右值,不能取地址 --------
10;
a + 5;
string("test");
// cout << &10; // 编译报错,右值不能取地址
// cout << &(a+5); // 编译报错
delete p;
return 0;
}
非常重要的知识点:
右值被右值引用变量接住之后,这个引用变量本身是左值!
哪怕它绑定的是临时对象,只要它是一个变量,就可以取地址,属于左值。
cpp
int main()
{
int&& r = 10; // r绑定右值10
cout << &r << endl; // 可以取地址!r本身是左值
return 0;
}
C++11 把右值进一步细分:
- 纯右值 prvalue:传统意义的右值,字面量、表达式返回的临时对象。
- 将亡值 xvalue :即将消亡的值,
std::move(左值)转换出来的就是将亡值。
泛左值 glvalue = 左值 + 将亡值。日常写代码不用死记分类,理解概念即可。
二、左值引用 vs 右值引用
- 左值引用
T&:给左值起别名 - 右值引用
T&&:给右值起别名
cpp
#include <iostream>
#include <string>
int main()
{
int a = 10;
// 左值引用绑定左值
int& ra = a;
// int& rb = 20; // 报错,普通左值引用不能绑定右值
const int& rc = 20; // const左值引用可以绑定右值,延长临时对象生命周期
// 右值引用绑定右值
int&& rd = 20;
// int&& re = a; // 报错,右值引用不能直接绑定左值
// std::move:把左值强制转换为将亡值(右值)
int&& rf = std::move(a);
return 0;
}
1. 引用延长临时对象生命周期
const左值引用、非 const 右值引用 ,都可以延长临时对象的生命周期。
区别:const引用不能修改,右值引用可以修改临时对象。
cpp
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1 = "hello";
const string& r2 = s1 + s1; // s1+s1是临时对象,生命周期延长
// r2 += "test"; // const不能修改
cout << r2 << endl;
string&& r3 = s1 + s1;
r3 += "test"; // 可以修改临时对象
cout << r3 << endl;
return 0;
}
注意:不要用右值引用接收函数内部局部变量,局部变量函数结束直接销毁,引用会悬空。
2. 重载区分左值、右值实参
同一个函数,可以同时重载T&、const T&、T&&,编译器会根据传入实参类型自动匹配:
cpp
#include <iostream>
using namespace std;
void func(int& x)
{
cout << "左值引用版本:" << x << endl;
}
void func(const int& x)
{
cout << "const左值引用版本:" << x << endl;
}
void func(int&& x)
{
cout << "右值引用版本:" << x << endl;
}
int main()
{
int a = 10;
const int b = 20;
func(a); // 左值,调用 func(int&)
func(b); // const左值,调用 func(const int&)
func(30); // 右值,调用 func(int&&)
func(move(a)); // move之后变成右值,调用 func(int&&)
int&& r = 100;
func(r); // r是变量,属于左值!匹配 func(int&)
func(move(r)); // 再次move才匹配右值引用版本
return 0;
}
这个例子再次印证上面那句话:右值引用变量本身是左值。
三、移动语义:移动构造 和 移动赋值
C++98 只有拷贝构造、拷贝赋值,遇到临时对象,只能完整拷贝堆上资源。
C++11 新增两个默认成员函数:移动构造 、移动赋值重载。
拷贝:复制一份资源,新旧对象互不干扰。
移动:窃取对方的资源,不用拷贝堆内存 ,源对象变成有效但未定义的状态。
移动语义只对管理堆资源的类才有意义,比如
string、vector;内置类型移动和拷贝没有区别。
我们手写简化版 string,对比拷贝和移动:
cpp
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
class MyString
{
public:
MyString(const char* str = "")
{
cout << "普通构造" << endl;
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void Swap(MyString& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 拷贝构造:深拷贝,完整复制堆内存
MyString(const MyString& s)
{
cout << "拷贝构造" << endl;
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
// 移动构造:右值引用,窃取资源,swap交换指针
MyString(MyString&& s)
{
cout << "移动构造" << endl;
Swap(s);
}
// 拷贝赋值
MyString& operator=(const MyString& s)
{
cout << "拷贝赋值" << endl;
if (this != &s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
// 移动赋值:窃取资源
MyString& operator=(MyString&& s)
{
cout << "移动赋值" << endl;
Swap(s);
return *this;
}
~MyString()
{
delete[] _str;
_str = nullptr;
}
const char* c_str() const
{
return _str;
}
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
测试代码:
cpp
MyString Func()
{
MyString tmp("hello world");
return tmp; // 返回局部对象,tmp是局部变量,返回时转为右值
}
int main()
{
MyString s1("aaaaa");
cout << endl;
MyString s2 = s1; // 左值,调用拷贝构造
cout << endl;
MyString s3 = Func(); // s3对象第一次创建,Func返回临时右值,调用移动构造,编译器还会做返回值优化RVO
cout << endl;
MyString s4("bbbb");
cout << endl;
MyString s5 = move(s4); // move把s4转为右值,调用移动构造,s4资源被偷走
cout << endl;
MyString s6("xxx");
cout << endl;
s6 = Func(); // s6对象已存在,Func需要先构造一个临时右值对象(移动构造),然后把这个临时对象移动赋值给s6
return 0;
}

VS编译器返回值优化 RVO:release 模式,编译器会把局部对象直接构造在接收对象栈帧上,连移动都省略。可以在g++ 可以加
-fno-elide-constructors关闭优化,观察真实移动 / 拷贝调用流程。
3. 编译器默认生成移动成员的规则
- 如果你没有写拷贝构造、拷贝赋值、析构,编译器会自动生成默认移动构造、移动赋值。
- 只要你手动实现了拷贝构造 / 拷贝赋值 / 析构其中任意一个,编译器就不会自动生成移动构造和移动赋值。
- 如果你写了移动构造,编译器不会再给你生成拷贝构造。
如果你想强制让编译器生成默认移动函数,可以用= default;不想让编译器生成就写= delete。
cpp
class Test
{
public:
Test() = default;
Test(const Test& t)
{
// 自己写拷贝构造,编译器不会生成移动构造
}
Test(Test&& t) = default; // 强制让编译器生成默认移动构造
Test& operator=(Test&& t) = default;
~Test() = default;
};
四、std::move 是什么?
很多人误以为move会移动对象,std::move 不做任何资源移动!它仅仅做强制类型转换,把左值转为将亡值(右值)。
源码简化理解:
cpp
template<class T>
typename remove_reference<T>::type&& move(T&& arg)
{
return static_cast<typename remove_reference<T>::type&&>(arg);
}
它只是返回一个右值引用,真正触发移动构造 / 移动赋值,是后续函数调用匹配到T&&版本才会发生。
使用 move 之后,源对象处于 "有效但是未指定状态",你不能再随意使用源对象,只能给它赋值或者销毁。
五、引用折叠 和 万能引用(转发引用)
1. 引用折叠规则
C++ 不允许直接写int&&&这种引用的引用。但是模板推导、typedef 的时候可能间接产生,C++ 给出折叠规则:
T& &→T&T& &&→T&T&& &→T&T&& &&→T&&
口诀:只要里面出现左值引用
&,折叠结果就是左值引用;只有全是右值引用&&,结果才是右值引用。
2. 万能引用 T&&
模板里面写的T&&不是单纯右值引用,叫万能引用(转发引用)。
- 如果传入实参是左值,T 会推导成左值引用,经过引用折叠,形参变成左值引用。
- 如果传入实参是右值,T 推导成普通类型,形参变成右值引用。
cpp
template<class T>
void Func(T&& t)
{
// t 会根据实参是左/右值,实例化为左值引用或者右值引用
}
int main()
{
int a = 10;
Func(a); // a左值 → T推导int& → 形参 int&
Func(20); // 20右值 → T推导int → 形参 int&&
Func(move(a)); // move后右值 → T推导int → 形参 int&&
return 0;
}
注意:只有模板参数T&&才是万能引用!如果类型已经确定,比如int&&就只是普通右值引用,不是万能引用。
六、std::forward 完美转发
万能引用拿到参数之后,形参变量本身一定是左值。如果你想保持原来实参的左 / 右值属性,就需要std::forward完美转发。
- 实参原本是左值,forward 之后保持左值;
- 实参原本是右值,forward 之后变成右值。
cpp
template<class T>
void Fun(T&& t)
{
// 直接传t,t是变量,永远是左值
// 用forward<T>(t),保留原来的值类别属性
OtherFunc(std::forward<T>(t));
}
对比记忆:
std::move:无条件转为右值。std::forward<T>:保留原来实参的左 / 右属性,只用于模板万能引用。
七、在 STL 容器中的实际应用
C++11 之后,容器很多接口都提供两套重载:const 左值引用版本,右值引用版本。
类似下面的伪代码:
cpp
// vector伪代码
void push_back(const T& val); // 接收左值,走拷贝构造
void push_back(T&& val); // 接收右值,走移动构造
实际使用:
cpp
#include <vector>
#include <string>
using namespace std;
int main()
{
vector<string> v;
string s1("aaaa");
v.push_back(s1); // 传入左值,拷贝构造,s1还能用
v.push_back(move(s1)); // move转为右值,移动构造,s1资源被夺走
v.push_back(string("bbbb")); // 匿名临时右值,直接移动构造
return 0;
}
顺带提一嘴emplace_back:
push_back:先构造对象,再拷贝 / 移动进容器。
emplace_back:利用可变参数模板 + 完美转发,直接在容器的内存空间原地构造对象,省去移动拷贝,效率更高。
cpp
vector<pair<int, int>> vec;
vec.emplace_back(1,2); // 直接在容器内构造pair,不用生成临时对象
八、高频问题总结
- 什么是左值、右值?
能取地址的是左值;字面量、临时对象不能取地址是右值。右值引用绑定之后,引用变量本身是左值。
- move 是干什么的?会移动对象吗?
move 只是强制转换成右值,不移动任何资源,函数调用匹配到移动构造 / 移动赋值才会窃取资源。move 之后源对象只能赋值或者析构。
- 移动构造什么时候会被编译器自动生成?
没有用户自定义的拷贝构造、拷贝赋值、析构,编译器才会生成默认移动成员。写了其中任意一个,就不会生成。
- 万能引用和普通右值引用区别?
模板中
T&&是万能引用,实参左值就推导左值引用,实参右值推导右值引用;确定类型如int&&只是普通右值引用,只能接收右值。
- forward 完美转发的作用?
万能引用的形参变量本身是左值,forward 可以保留原始实参的左 / 右值属性,做到完美转发。
- 为什么函数返回局部对象,不要返回局部对象的右值引用?
局部对象函数结束就销毁,返回引用会悬空,不管左值还是右值引用都不行。可以直接传值返回,编译器会触发移动或者 RVO 优化。
总结
右值引用、移动语义是 C++11 非常核心的更新,目的就是减少不必要的堆内存拷贝。
记住一条主线:
- move:无条件变成右值;
- forward:保留原始属性,配合万能引用;
- 移动构造 / 移动赋值:窃取即将销毁对象的资源,避免深拷贝。
很多初学者会混淆 move 和 forward,记住:move 随便用;forward 几乎只在模板万能引用场景下使用。
如果这篇对你有帮助,欢迎点赞收藏,后续继续更新 C++11 系列。