目录
0.背景
说实话,连右值和移动语义都不懂,确实不能算入门了现代C++。但偏偏这个东西很不好理解,或者说,不容易理解到根本,用起来也糊里糊涂的。本文就笔者多次迭代后的理解版本进行阐述。
由于右值牵扯到的东西太多,笔者无力做全面介绍,只是针对右值引用的使用场景之一——移动语义进行主要说明,对右值只做简单介绍。
1.右值引用
网上一般以等号左右或者能否取地址来区分左右值,这里我引用Stackoverflow上的一个说法来定义
右值就是,将在包含右值的完整表达式的末尾销毁的临时对象。
不严谨地说,就是在句末分号处销毁的临时对象。
具体来看,常见的有:
- 字面常量,如1,2,3,'c'
- 表达式,如x+y
- 临时对象,如函数的返回值
那么,为什么要发明这个东西?
试想一下,当我们定义函数时,最简单的版本,是不是如下形参传递
void foo(int val);
但形参是需要拷贝的,如果数据类型不是int,而是很复杂的结构体/类,拷贝很耗性能,或者里面有裸指针,拷贝会引发内存泄漏,是不是就该改成传指针/传引用?传引用如下写
void foo(Type& name)
这就是传左值引用,其实已经足够解决很多场景了。但会出现一个问题,就是左值引用接不了右值,比如
class Type{
//some defination
};
Type foo_2(int n){
Type type(n);
return type;
}
void foo(Type &name)
{
// Do something
}
int main()
{
int n=10;
foo(foo_2(n)); // GG
}
这里,因为foo_2的返回值是一个临时对象,属于右值,而foo只能接左值
所以,进一步可以改成我们经常见到的形式
void foo(const Type& name)
const左值,就既能接左值,又能接右值
只是问题是,不能修改,但已经满足大部分场景的应用了
那么,有没有什么办法,既能接左值,又能接右值,还能修改?
右值引用就可以解决这个问题
void foo(Type&& name)
如果传参是右值,就直接接了,如果传参是左值,可以使用std::move转成右值,std::move的物理本质如下
// 两句话等价
std::move(a);
static_cast<T &&>(a);
那么,函数void foo(Type&& name)内部,变量name是个啥呢?是个左值。听起来有点绕,但其实很简单,就像指针本身也就是个记录地址的变量而已。name接了右值,但其本身是一个左值
也就是说,右值引用在这里可以看做一种强化版(适用范围上)的左值引用
好,这虽然不是右值引用的全部含义,但就跟移动语义有关的这部分,我想我已经表达得比较清楚了
那么,这跟移动语义有啥关系呢?移动语义又是个啥呢?
2.移动语义
书接上文,由于右值引用这种适用范围上的拓展,既能接左值(靠move)也能接右值,于是,聪明的C++大佬们,就想到,利用这一点来解决一个问题。什么问题呢?
带外部资源的类的拷贝构造
说人话,就是,一个成员变量带指针的类,的拷贝构造
题外话,很多初学者不理解,指针那么麻烦,为什么很多类中非要设计指针不可?
因为C++中,分为系统控制的栈内存与用户控制的堆内存,像你平常定义的int i就是在栈内存上,而new/malloc出来的,是在堆内存上开辟空间,由用户自行管理
但是,以linux为例,一个进程的默认栈内存只有8M,而堆内存则一般是4G(在我进行的申请double数组的实验中,最多开1047113个,也就是7.98884M),所以,如果你不用指针,也就是永远在栈上玩,数据量一大,程序就没法搞了,而且数据拷贝也很花时间
一般而言,带指针成员变量的类的拷贝构造函数,需要进行拷贝内容的深拷贝,如下
class Foo{
public:
// 默认构造函数
Foo()=default;
// 拷贝构造函数
// vector当然可以不用这么麻烦,我随便写写,看得懂就行
Foo(Const Foo& A){
m=new int(*(A.m));
arr->reserve(A.arr->size());
for(int i=0;i<A.arr->size();i++)
{
arr->push_back(A.arr->at(i));
}
}
int *m;
vector<int>* arr;
};
简言之,就是拷贝忍者,你有啥,我抄啥
但,如果拷贝构造的那个A,是临时变量,或者不重要的中间变量,其实拷贝就有点浪费了性能,为什么不直接做“偷偷忍者”呢,反正A也不要了,直接拿来不比抄快多了?
于是,大佬们就想,针对这种遗孤A,就可以单独写一个拷贝构造函数的重载,如下
class Foo{
public:
// 默认构造函数
Foo()=default;
// 拷贝构造函数
// vector当然可以不用这么麻烦,我随便写写,看得懂就行
Foo(Foo& A){
m=new int(*(A.m));
arr->reserve(A.arr->size());
for(int i=0;i<A.arr->size();i++)
{
arr->push_back(A.arr->at(i));
}
}
//重载-移动构造函数
Foo(Foo&& A){
m=A.m;
arr=A.arr;
A.m=nullptr;
A.arr=nullptr;
}
int *m;
vector<int>* arr;
};
这样,就把遗孤A堆内存上的东西,直接偷过来了,不用拷贝了,所以这也叫移动构造函数
但要注意,只能偷堆内存,栈内存上的,还是得老老实实拷贝(因为“偷”的本质,还是复制A的指针地址,并把A的指针置空。为了方便理解,本文未就内存泄漏问题进行说明)
而右值引用用在这,就充分利用了其更大的适用范围的这一特性,使得你“传左值我就复制拷贝,传右值我就移动拷贝”这一构造方法能通过重载实现
而移动语义一般是通过类中已利用右值引用重载好的移动构造函数来实现,其本质,是堆内存的控制权的转移
那么,实际中,该怎么用呢?
因为像我这样的菜鸡,一般是不涉及高性能类的开发的,所以我一般不需要自己去设计一个类的移动构造和移动赋值(=,+=,连加等),一般是配合STL库使用
因为STL库的容器,在C++11后,基本都实现了移动构造的接口
比如,智能指针
auto p1=std::make_unique<Type>();
auto p2=std::move(p1);
auto p3(std::move(p2));
这里就是移动拷贝构造和移动赋值构造的用法,同理还有
std::unordered_map<Type1, Type2> table;
auto table_=std::move(table);
std::vector<Type> arr;
auto arr_=std::move(arr);
所以说,千万不要觉得std::move是移动的意思,它只是转右值,然后配合调用移动构造函数而已,不然就容易写出没有指针的结构体,你也在std::move,函数返回值也在move这种很2的操作
另外,vector的push_back也支持移动语义,但可能大家一开始会疑惑如下的代码
std::vector<int*> arr;
auto elem=new int(10);
arr.push_back(std::move(elem));
std::cout<<elem<<" "<<*elem<<std::endl;
这里,并不会报错,elem仍然存在。把move理解为移动而不是右值转换的人,就会很疑惑,为什么呢?明明控制权移交了啊
但实际上,我们说了,传右值,大多是为了调用移动构造函数而已,在移动构造函数内,才能完成“偷别人指针再置空”的操作,而push_back接右值,也只是调用该类型的移动构造函数来构造新元素,并加入vector。但你这,元素类型是int*啊,int*有个毛的移动构造函数定义啊,定义都没有,右值传给谁啊,怎么偷啊?
push_back配合右值的正确使用方式(元素类型要是定义了移动构造函数的类型)
std::vector<std::vector<int>>arr;
std::vector<int> arr_sub{1,2,3};
arr.push_back(std::move(arr_sub));
std::cout<<arr_sub[0]<<std::endl;
这里就会报错了,因为arr_sub不存在了,因为push_back中调用了vector的移动构造函数,被偷走了。
下面来总结一下吧:
1.移动语义的本质,是堆内存管理权的转移
2.移动语义的实现,是转右值接入移动构造函数
本文深入浅出地介绍了C++中的右值引用和移动语义。右值引用允许函数参数接收即将销毁的临时对象,扩展了左值引用的功能,通过std::move转换左值为右值。移动语义利用右值引用,优化了带有外部资源(如指针)的类的拷贝构造,通过移动构造函数实现资源的有效转移,提高性能。在实践中,主要通过STL库如智能指针和容器的接口利用移动语义。

1万+

被折叠的 条评论
为什么被折叠?



