C++深入右值引用之移动语义与完美转发(最新推荐)
作者:无限进步_
1. 左值与右值
左值和右值不是新概念,C++98就有,但C++11赋予了它们更重要的地位。
- 左值:可以取地址的表达式,有持久状态。变量、解引用的指针、数组元素都是左值。
- 右值:不能取地址。字面量、临时对象、表达式求值的中间结果。
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10; // *p 是左值
string s("hello");
s[0] = 'x'; // s[0] 是左值
10; // 右值
x + y; // 右值
string("hello"); // 右值
// cout << &10; // 错误:右值不能取地址C++11进一步细化了:右值分为纯右值(字面量、临时对象等)和将亡值(move返回的右值引用)。泛左值包含左值和将亡值。这些概念不必死记,关键是记住核心区别:能否取地址。
2. 左值引用与右值引用
左值引用(T&)给左值取别名,右值引用(T&&)给右值取别名。
int& r1 = b; // 左值引用绑定左值
int&& rr1 = 10; // 右值引用绑定右值
double&& rr2 = x + y;
string&& rr3 = string("hello");几个规则:
- 左值引用不能直接绑定右值,但const左值引用可以。
- 右值引用不能直接绑定左值,但可以通过
std::move转换。
const int& cr = 10; // OK int&& rr4 = move(b); // OK,move后的b仍可使用但资源已被取走
move()本质上是一个强制类型转换:static_cast<remove_reference_t<T>&&>(arg)。它本身不移动任何东西,只是给了你一个右值引用,真正的动作由移动构造函数或移动赋值完成。
有一个容易困惑的点:右值引用变量本身的表达式属性是左值。因为它有名字、可以取地址。
int&& rr = 10; cout << &rr << endl; // 可以取地址,rr是左值 int& lr = rr; // OK,rr作为左值可以绑定左值引用 // int&& rr2 = rr; // 错误,rr是左值,不能直接绑定右值引用 int&& rr2 = move(rr); // OK
这个设计看似别扭,但恰恰是移动语义能正常工作的基础——后面会看到。
3. 移动构造与移动赋值
左值引用已经在函数传参和返回值中减少了大部分拷贝,但有一种场景它搞不定:返回局部对象。
string addStrings(string num1, string num2) {
string str;
// ... 构建str ...
return str; // str是局部对象,函数结束就销毁,不能传引用返回
}C++98被迫拷贝,C++11通过移动语义解决了这个问题:既然str马上就要销毁,把它的资源“偷”过来就行,没必要深拷贝。
移动构造和移动赋值接受一个右值引用参数,核心操作是交换资源,而不是复制。
namespace demo {
class string {
public:
string(const char* str = "")
: _size(strlen(str)), _capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 拷贝构造
string(const string& s) {
// 深拷贝...
}
// 移动构造
string(string&& s) {
swap(s); // 直接把s的资源换到自己身上
}
// 拷贝赋值
string& operator=(const string& s) {
// 深拷贝...
}
// 移动赋值
string& operator=(string&& s) {
swap(s);
return *this;
}
void swap(string& s) {
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
~string() { delete[] _str; }
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
}使用效果:
demo::string s1("hello");
demo::string s2 = s1; // 拷贝构造
demo::string s3 = demo::string("world"); // 移动构造(临时对象)
demo::string s4 = move(s1); // 移动构造(显式move)编译器还会进一步优化。在VS2022的release模式下,demo::string s3 = demo::string("world");可能直接被优化成一次原地构造,移动构造都不会调用——这就是返回值优化(RVO)。
返回值场景中,如果提供了移动构造,没有优化时编译器会优先选择移动而不是拷贝,显著提高效率。
4. 移动语义在容器中的应用
C++11之后,STL容器的push_back、insert都增加了右值引用版本:
void push_back(const T& x); // 左值版本,内部拷贝 void push_back(T&& x); // 右值版本,内部移动
当传入左值,走拷贝;传入右值,走移动。
list<demo::string> lt;
demo::string s1("hello");
lt.push_back(s1); // 拷贝
lt.push_back(move(s1)); // 移动
lt.push_back("world"); // 移动(临时string)
lt.push_back(demo::string("world")); // 移动自定义容器也可以实现相应的重载,内部在节点构造时用move把参数转成右值。
5. 完美转发与引用折叠
考虑一个模板函数,想要把参数原样转发给另一个函数:
template<class T>
void relay(T&& t) {
func(t); // t是左值,永远调用func的左值版本,即使传进来的是右值
}问题在于:t作为右值引用变量,表达式属性是左值,所以func(t)调不到右值重载版本。要保留参数的原始值类别,需要完美转发:
template<class T>
void relay(T&& t) {
func(forward<T>(t));
}forward的实现依赖于引用折叠规则。C++不允许直接定义引用的引用(int& &),但在模板和类型推导中可以出现。规则只有一条:右值引用的右值引用折叠成右值引用,其余全部折叠成左值引用。
typedef int& lref; typedef int&& rref; lref& r1 = n; // int& & → int& lref&& r2 = n; // int& && → int& rref& r3 = n; // int&& & → int& rref&& r4 = 1; // int&& && → int&&
在relay中,T的推导结果配合引用折叠,实现了“左值传左值,右值传右值”:
- 实参是左值
int a→T推导为int&→T&&折叠为int& - 实参是右值
10→T推导为int→T&&为int&&
forward内部也是通过static_cast<T&&>实现的,配合引用折叠,左值情况返回左值引用,右值情况返回右值引用。这样就把参数的原始属性一路传下去了。
6. 默认移动构造与移动赋值
编译器在特定条件下会自动生成移动构造和移动赋值:
- 如果用户没有定义拷贝构造、拷贝赋值、析构函数中的任何一个,编译器会尝试自动生成移动构造和移动赋值。
- 对于内置类型成员,逐字节拷贝;对于自定义类型成员,如果能移动就移动,否则拷贝。
一旦你提供了移动构造或移动赋值,编译器就不会自动生成拷贝构造和拷贝赋值。所以如果你想两者都有,需要自己显式声明,或者用= default:
class Person {
public:
Person(const Person&) = default; // 显式要拷贝
Person(Person&&) = default; // 显式要移动
};也可以用= delete禁止某个函数。
移动语义是C++11最核心的特性之一。它让“快”从“避免拷贝”的编码技巧,变成了语言级别的优化路径。
到此这篇关于C++深入右值引用之移动语义与完美转发(最新推荐)的文章就介绍到这了,更多相关C++右值引用内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
