文章目录
- 写在前面
- 1. 什么是右值,什么是左值?
- 1.1右值引用可以引用左值吗
- 1.2 左值引用、右值引用本身是左值还是右值?
- 1.3 特殊的 const 左值引用
- 2. 右值引用与移动构造的意义
- 3. 移动构造函数的使用
- 4. move的实现原理
- 5. 完美转发
写在前面
本文主要为大家梳理以下几个问题:
- 什么是右值
- 右值引用的意义与使用场景
-
std::move
函数的本质 - 如何编写移动构造函数
- 万能引用与完美转发
参考资料:
-
《一文读懂C++右值引用与std::move》
-
《C++高阶知识:深入分析移动构造函数及其原理》
-
《Value categories》
-
《C++11的 value category 以及 move semantics》
由于作者才疏学浅,理解欠缺的地方欢迎大家指正
1. 什么是右值,什么是左值?
每个 C++ 表达式(包括操作符和其操作数、字面值、变量名等)具有两个独立的属性:类型和值类别:
类型(type)大家都不陌生,指的是表达式的数据类型,它定义了表达式的取值范围和可执行的操作。例如,一个整数表达式的类型可以是 int,一个浮点数表达式的类型可以是 float。
值类别(value category)可以理解为表达式的身份和可移动性。根据 C++ 标准,有三种最主要的值类别:右值(rvalue)、左值(lvalue)、将亡值(xvalue),他们三者的关系如下:
- 身份决定了它是否具有表达式寻址性,即我们是否可以获取其在内存中的地址
- 可移动性如果出现在赋值,初始化等语句中,是否会使语句呈现移动语义
上面说的有些抽象,我们结合具体的例子来分析,根据表现出的特征进行区分:
[右值与右值引用]:
// 以下都是常见的右值
10;
x + y;
fmin(x, y);
// 右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);
- 右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等
- 右值不能放在赋值符号的左边,不信你试试
10 = x + y
- 对右值的引用就是右值引用,用
&&
表示 - 不能对右值取地址,不信你试试
int* p = &10;
[左值与左值引用]:
// 以下a、b、pa都是左值
int a = 10;
int b = a;
int* pa = &a;
// 左值引用
int& rla = a;
- 左值可以出现在赋值符号的左边和右边
- 可以取它的地址,可以为他赋值
下面来考虑一些疑难问题:
1.1右值引用可以引用左值吗
可以。 std::move()
函数可以将左值强转为右值。没错,就是强制类型转换,我们后面将结合源码具体谈到:
int a = 10;
int&& rr = std::move(a);
1.2 左值引用、右值引用本身是左值还是右值?
被声明出来的左、右值引用都是左值。因为根据C++语言规范,无论是左值引用还是右值引用,它们都被认为是具名对象,具有地址并且可以寻址。因此,在使用引用时,它们被视为左值。用下面的代码验证:
void check(int&& rr) {
cout << "Yes" << endl;
}
int main() {
int a = 5; // a是个左值
int& ref_a_left = a; // ref_a_left是个左值引用,本身是左值
int&& ref_a_right = std::move(a); // ref_a_right是个右值引用,本身是左值
check(a); // 编译不过,无法将左值绑定到右值
check(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值
check(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值
check(std::move(a)); // 编译通过
check(std::move(ref_a_right)); // 编译通过
check(std::move(ref_a_left)); // 编译通过
}
右值引用本身是一个左值,那么也就不难理解,为什么右值不可以修改,但是右值引用可以修改:我们并不是修改右值,而是修改右值引用所引用的对象;右值引用是左值,它有自己的标识符和地址。
int && rr = 10;
rr = 20;
1.3 特殊的 const 左值引用
const 左值引用比较特殊,它既可以接受左值,也可以接受右值。和右值引用一样, const 左值引用能够延长右值的生命周期
,以避免产生悬空引用。下面是cppreference 中的说明:
当我们将一个右值绑定到 const 左值引用上时,编译器会自动创建一个临时对象,并将该右值绑定到这个临时对象上。这个临时对象的生命周期会与 const 左值引用的声明周期相同,从而确保了在 const 左值引用的作用域内能够安全地使用这个右值。
这也是为什么要使用 const & 作为函数参数的原因之一。如果没有const,这样的代码就无法编译通过了:v.push_back(5)
void push_back (const value_type& val);
2. 右值引用与移动构造的意义
左值引用做参数和返回值都可以提高效率。但是左值引用的短板在于,如果引用的对象出作用域销毁,那么就不能使用左值引用了。例如下面的例子中(不考虑编译器优化),hello()函数在返回时,首先会将 "hello world"
拷贝给临时变量。
这个临时变量本质上属于将亡值
,具有身份的同时又具有移动性。在没有移动构造前,s只能把临时变量的内容拷贝复制一份,而眼睁睁的看着临时变量出作用域销毁 —— 白白浪费!
string hello() {
return "hello world";
}
string s = hello();
但是对于一个这个即将被销毁的对象,我们为什么不聪明点,直接将其中的资源占为已有呢?将对方资源所有权转移过来,这就是移动构造的核心思想。如何转移?其实就是指针做一个指针交换:
namespace my{
string(string&& s)
:_str(nullptr) ,_size(0), _capacity(0){
swap(str_, s.str_) // 所有权转移
}
};
注意,移动构造后一定要将原对象中的指针置为空,否则一块空间会被 delete 两次。而对于 nullptr
, delete 多次也没有影响。
理解了移动构造的价值后,我们进一步想,哪些资源可以被移动构造?C++的设计者们注意到,大多数情况下,右值所包含的对象都是可以安全的被移动的。
你应该也认同了只有右值才适合移动,那我们如何显示的接收一个右值呢?在C++11之前,只有 const 修饰的左值引用才能接收右值,这可是个大问题啊,你都被const修饰了,我还怎么“偷”你的资源呢?顺着这个逻辑思考,右值引用的出现也是一个必然,它为移动构造的出现洒下了肥沃的土壤。
接下来我们来手动实现下移动构造函数(移动赋值运算符同理)
3. 移动构造函数的使用
namespace my {
class string {
public:
string() : len_(0), cap_(0), data_(nullptr) {}
string(const char* s) {
// 略
}
string(const my::string& s) {
cout << "拷贝构造函数" << endl;
// 略
}
my::string& operator=(const my::string& s) noexcept {
cout << "赋值运算符重载" << endl;
// 略
}
string(my::string&& s) {
cout << "移动构造函数" << endl;
len_ = s.len_;
cap_ = s.cap_;
swap(data_, s.data_);
}
my::string& operator=(my::string&& s) noexcept{
cout << "移动赋值运算符重载" << endl;
len_ = s.len_;
cap_ = s.cap_;
if (data_) {
delete data_;
data_ = nullptr;
}
swap(data_, s.data_);
return *this;
}
private:
char* data_;
int len_;
int cap_;
};
};
STL库中基本都支持了移动构造和移动赋值,例如string等等
string (string&& str) noexcept;
string& operator= (string&& str) noexcept;
swap函数也是
给大家分享一个我初学时容易犯的错误:我们仍然沿用上面实现的 my::string类做测试,大家觉得 s = t.s
中有没有调用移动赋值呢?
class test {
public:
test() {}
// ……
test(test&& t) {
s = t.s;
}
private:
my::string s;
};
答案是并没有。虽然t是一个右值,但是t.m确实是一个左值:
4. move的实现原理
刚开始学习 std::move
,大家总是容易对move函数抱有误解,认为move函数完成了内存上资源的移动,然而实际上move完成的工作只是强制类型转换,我们来看看相应的源码:
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_case<typename remove_reference<T>::type&&>(t);
}
虽然细节上我们并不了解,但我们大致可以看出 move 就是完成了强制类型转换的工作。为了更加透彻的理解,我会为大家说明其中的细节
-
在模板中,
&&
并不是代表右值引用,而是万能引用。它既可以接收左值,也可以接收右值。 -
这个返回值
typename remove_reference<T>::type&&
是什么意思呢?type是定义在 remove_reference 中的类型成员,因此访问它时也与访问静态成员一样用::访问,而该类是一个模板类,所以在它前面要加typename关键字。 -
remove_reference
从它的名字也可以看出,它是通过模板去除引用
-
我们假定T为int&,即传入左值,那么最后可以将上面的代码简化成如下的形式:
int && move(int& && t){ return static_case<int&&>(t); }
-
遇到
int& &&
的时候,会发生引用折叠,折叠的规则如下图所示:
-
所以最终move其实就做了这么一件事:
int && move(int& t){ return static_case<int&&>(t); }
5. 完美转发
我们前面谈到,在模板中,&&
既可以接收左值,也可以接收右值,但是当我们在函数内部,将val 传递给另一个函数的时候,val将发生退化此时,val总是是被当作左值进行传递的。
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<typename T>
void PerfectForward(T&& t)
{
Fun(t);
}
int main()
{
PerfectForward(10); // 右值
int a = 10;
PerfectForward(a); // 左值
PerfectForward(std::move(a)); // 右值
const int b = 8;
PerfectForward(b); // const 左值
PerfectForward(std::move(b)); // const 右值
}
-
为了保持参数原有的左右值属性,我们需要使用
std::forward<模板参数>()
函数来实现完美转发:
-
注意!为了保持参数原有的左右值属性,所有的向下转发都需要实现完美转发: