文章目录
十、移动语义与智能指针
1.移动语义
1.移动语义的含义:移交控制权
2.移动语义的提出原因:希望减少临时对象new一次的开销
String s1("hello");
//拷贝构造
String s2 = s1;
//先构造,再拷贝构造
//利用"hello"这个字符串创建了一个临时对象
//并复制给了s3
//这一步实际上new了两次
String s3 = "hello";
创建s3的过程中实际创建了一个临时对象,也会在堆空间上申请一片空间,然后把字符串内容复制给s3的pstr,这一行结束时临时对象的生命周期结束,它申请的那片空间被回收。这片空间申请了,又马上被回收,实际上可以视作一种不必要的开销。我们希望能够少new一次,可以直接将s3能够复用临时对象申请的空间。
这其实也可以视为是一种隐式转换。
(1)左值与右值
1.能对表达式取地址的,称为左值;不能取地址的,称为右值。
2.左值和右值是针对表达式而言的,左值是指表达式执行结束后依然存在的持久对象,右值是指表达式执行结束后就不再存在的临时对象。
在实际使用过程中,字面值常量、匿名对象(临时对象)、匿名变量(临时变量),都称为右值。右值又被称为即将被销毁的对象。
字面值常量,也就是10, 20这样的数字,属于右值,不能取地址。
字符串常量,“world",是属于左值的,位于内存中的文字常量区。
(2)右值引用
1.引用:
①非const左值引用:只能绑定左值,不能绑定右值。引用那些我们希望改变值的对象。
②const左值引用:既可以绑定左值,又可以绑定右值。引用那些我们不希望改变值的对象,比如常量。
③右值引用:只能绑定右值,不能绑定左值。所引用对象的值在我们使用之后就无需保留了,比如临时变量。
const左值引用、右值引用,绑定右值后,延长了右值的生命周期。和引用一起,到作用域结束。
2.C++11为什么要引入右值引用?
右值引用可以识别右值并绑定右值。
引入右值引用,可以实现移动语义,通过移动而不是复制,可以显著提高性能,避免多new一次的系统调用开销。
右值引用,可以识别出右值,且能够绑定右值
//非const左值引用,只能绑左值,不能绑定右值
int a = 10;
int & r1 = a;
int & r2 = 1; //error
//const左值引用,既可以绑定左值,又可以绑定右值
const int & r3 = 1;
const int & r4 = a;
//右值引用只能绑定右值
int && r_ref = 10;
int && r_ref2 = a; //error
(3)移动构造函数 (重要)
1.移动构造函数:移动构造没有new,只移交管理权。提高了效率。
//移动构造:移交管理权:浅拷贝,把之前的指针置空
String(String && rhs)
: _pstr(rhs._pstr)
{
cout << "String(String&&)" << endl;
rhs._pstr = nullptr;
}
拷贝构造和移动构造 进行函数重载,移动构造的优先级更高。
//移动构造: String s1 = String("hello");
String(String && rhs)
: _pstr(rhs._pstr) //浅拷贝
{
cout << "移动构造" << endl;
rhs._pstr = nullptr; //移交管理权
}
//拷贝构造: String s2 = s1;
String(const String & rhs)
: _pstr(new char[strlen(rhs._pstr) + 1]())
{
cout << "String(const String &)" << endl;
strcpy(_pstr, rhs._pstr);
}
2.移动构造函数的特点:
(1)如果没有显式定义构造函数、拷贝构造、赋值运算符函数、析构函数,编译器会自动生成移动构造,对右值的复制会调用移动构造。
(2)如果显式定义了拷贝构造,而没有显式定义移动构造,那么对右值的复制会调用拷贝构造。
(3)如果显式定义了拷贝构造和移动构造,那么对右值的复制会调用移动构造。
总结:移动构造函数优先级高于拷贝构造函数。
可以理解为:如果显式定义了拷贝构造和移动构造,利用一个已存在的对象创建一个新对象时,会先尝试调用移动构造,如果这个对象是右值,就使用移动构造函数创建出新对象,如果这个对象是左值,移动构造使用不了,就会调用拷贝构造。
①去除优化效果参数
g++ String.cpp -fno-elide-constructors
(4)移动赋值函数 (重要)
1.移动赋值运算符函数:
①判断自复制
②释放左操作数申请的堆空间
③浅拷贝
④右操作数置为空指针
String & operator=(String && rhs){
cout << "String& operator=(String&&)" << endl;
if(this != &rhs){
delete [] _pstr;
//浅拷贝
_pstr = rhs._pstr;
rhs._pstr = nullptr;
}
return *this;
}
2.移动赋值函数的特点:
(1)如果没有显式定义构造函数、拷贝构造、赋值运算符函数、析构函数,编译器会自动生成移动赋值函数。使用右值的内容进行赋值会调用移动赋值函数。
(2)如果显式定义了赋值运算符函数,而没有显式定义移动赋值函数,那么使用右值的内容进行赋值会调用赋值运算符函数。
(2)如果显式定义了移动赋值函数和赋值运算符函数,那么使用右值的内容进行赋值会调用移动赋值函数。
移动赋值函数优先级也是高于赋值运算符函数
3.自赋值判断有必要。如果没有自赋值,可能会变成空指针
3.移动语义
总结:
(1)将拷贝构造函数和赋值运算符函数称为具有复制控制语义的函数;
(2)将移动构造函数和移动赋值函数称为具有移动语义的函数(移交控制权);
具有移动语义的函数优于具有复制控制语义的函数执行。
(5)std::move函数
std::move(a);
返回值将左值转为右值。但数据本身还是左值。
int a = 1;
&(std::move(a)); //error,返回值从左值转成了右值,但a本身还是左值
std::move的本质是在底层做了强制转换(并不是像名字表面的意思一样做了移动)
如果String str2 = std::move(左值),则左值对象会被修改。需要重新赋值才能继续使用。
(6)右值引用本身的性质
1.右值引用本身是左值还是右值,取决于右值引用是否有名字。右值引用有名字就是左值,没名字就是右值。
2.move + 右值引用,可以绑定左值
int func(){
return 1;
}
void test(){
/* &func(); //没有名字的右值引用,是右值。不能取地址 */
int && ref = func(); //有名字的右值引用,是左值。可以取地址。
cout << &ref << endl;
}
string func2(){
string str1("wd");
return str1;
}
void test2(){
/* &func2(); //编译错误,右值不能取地址 */
string && ref = func2();
cout << &ref << endl; //有名字的右值引用为左值,可以取地址
}
int globalNumber = 10;
int && func3(){
return std::move(globalNumber);
}
void test3(){
/* &func3(); //没有名字的右值引用是右值,不能取地址 */
int && ref = func3();
cout << & ref << endl; //有名字的右值引用是左值,可以取地址
cout << ref << endl;
}
String func2(){
String str1("wangdao");
str1.print();
return str1;
}
void test2(){
func2();
//&func2(); //error,右值
String && ref = func2();
&ref; //右值引用本身为左值
}
这里func2的调用按以前的理解会调用拷贝构造函数,但是发现结果是调用了移动构造函数。
1.当返回的对象其本身生命周期即将结束,就不再调用拷贝构造函数,而是调用移动构造函数。
2.如果返回的对象其本身生命周期大于func3函数,执行return语句时还是调用拷贝构造函数
总结:当类中同时定义移动构造函数和拷贝构造函数,需要对以前的规则进行补充,调用哪个函数还需要取决于返回的对象本体的生命周期。
2.资源管理
(1)RAII技术 *
C++提出的资源管理的技术,全称为Resource Acquisition Is Initialization,由C++之父Bjarne Stroustrup提出。
其本质是利用对象的生命周期来管理资源(内存资源、文件描述符、文件、锁等),因为当对象的生命周期结束时,会自动调用析构函数。
①RAII类的常见特征
RAII技术,具备以下基本特征:
①在构造函数中托管资源;(在给构造函数传参时初始化资源)
②在析构函数中释放资源;
③一般不允许进行复制或者赋值(对象语义);
④提供若干访问资源的方法(如:读写文件)。
与对象语义相反的就是值语义。
值语义:可以进行复制或赋值(两个变量的值可以相同)
int a = 10; int b = a; int c = 20;
c = a; //赋值
int d = c; //复制
对象语义:不允许复制或者赋值
(全世界不会有两个完全一样的人,程序世界中也不会有两个完全一样的对象)
常用手段:
1.将拷贝构造函数与赋值运算符函数设置为私有的
2.将拷贝构造函数与赋值运算符函数=delete
3.使用继承的思想,将基类的拷贝构造函数与赋值运算符函数删除(或设为私有),让派生类继承基类。
②RAII类的模拟
RAII技术的本质:利用栈对象的生命周期管理资源,因为栈对象在离开作用域时候,会执行析构函数。
#include <iostream>
using std::cout;
using std::endl;
class Point
{
public:
Point(int x,int y)
: _ix(x)
, _iy(y)
{
cout << "Point(int,int)" << endl;
}
void print(){
cout << "(" << _ix
<< "," << _iy
<< ")" << endl;
}
private:
int _ix;
int _iy;
};
template <class T>
class RAII
{
public:
RAII(T * data)
: _data(data)
{
cout << "RAII(T*)" << endl;
}
~RAII(){
cout << "~RAII()" << endl;
if(_data){
delete _data;
_data = nullptr;
}
}
T * operator->(){
return _data;
}
T & operator*(){
return *_data;
}
//获取裸指针 (获取底层的指针)
T * get() const{
return _data;
}
//重新管理一片资源
void reset(T * data){
if(_data){
delete _data;
_data = nullptr;
}
_data = data;
}
RAII(const RAII & rhs) = delete;
RAII & operator=(const RAII & rhs) = delete;
private:
T * _data;
//int * _data;
//char * _data;
//Point * _data;
};
void test0(){
int * p = new int(10);
RAII<int> raii(p);
/* RAII<int> raii(new int(10)); */
cout << p << endl;
cout << raii.get() << endl;
cout << *raii << endl;
cout << raii.operator*() << endl;
/* delete p; */
}
void test1(){
RAII<Point> raii(new Point(1,2));
raii->print();
(*raii).print();
cout << raii.get() << endl;
raii.reset(new Point(10,9));
raii->print();
}
int main(void){
test1();
return 0;
}
RAII技术的本质:利用栈对象的生命周期管理资源,因为栈对象在离开作用域时候,会执行析构函数。
(2)智能指针
头文件<memory>,它们都是类模板
①auto_ptr的使用
auto_ptr是最简单的智能指针,使用上存在缺陷,已经被C++17弃用了。
特点:
1.auto_ptr是有复制、赋值函数的。
2.是移动语义的,会移交管理权,存在隐患。
#include <memory>
void test(){
int * p = new int(10);
auto_ptr<int> ap(p);
cout << *ap << endl; //auto_ptr内部重载了解引用运算符
//delete p; //再手动delete会double free
}
void test2(){
//智能指针通常在构造函数参数初始化时直接使用new表达式
auto_ptr<int> ap(new int(20));
cout << *ap << endl;
cout << endl;
auto_ptr<int> ap2(ap);
//虽然调用了拷贝构造,但是底层的实现是移交管理权的操作
//ap失去了对之前的资源空间的管理权
//cout << *ap << endl; //访问空指针,造成程序中断
cout << *ap2 << endl;
}
也就是说,auto_ptr<int> ap2(ap);
这一步表面上执行了拷贝操作,但是底层已经将右操作数ap所托管的堆空间的控制权交给了新对象ap2,并且将ap底层的指针数据成员置空,该拷贝操作存在隐患,所以auto_ptr被弃用了。
②unique_ptr的使用 (重要)
unique_ptr是独占型智能指针,独自管理资源,独享控制器。unique_ptr对auto_ptr进行了改进。
特点1:不允许复制或者赋值 (删除了拷贝构造)
具备对象语义。
特点2:独享所有权的智能指针
unique_ptr:不能进行复制或赋值
移动,移交了管理权
特点3:作为容器元素,只能将右值作为元素放入vector
要利用移动语义的特点,可以直接传递右值属性的unique_ptr作为容器的元素。如果传入左值形态的unique_ptr,会进行复制操作,而unique_ptr是不能复制的。
构建右值的方式有:
①std::move的方式
②可以直接使用unique_ptr的构造函数,创建匿名对象(临时对象),构建右值
③shared_ptr的使用 (重要)
共享型智能指针
智能指针独享资源的控制权固然是一种需求,但有些场景下也需要允许共享控制权。
shared_ptr就是共享所有权的智能指针,可以进行复制或赋值,但复制或赋值时,并不是真正拷贝了被管理的对象,而只是将引用计数加1了。即shared_ptr引入了引用计数,其思想与COW技术类似,又称为是强引用的智能指针。
特征1:共享所有权的智能指针
可以使用引用计数记录对象的个数。
特征2:支持拷贝构造、赋值
具备值语义。拷贝构造支持用小括号、等号
shared_ptr<int> sp(new int(10));
cout << *sp << endl;
cout << sp.get() << endl;
cout << sp.use_count() << endl;
//shared_ptr支持拷贝构造和赋值
//拷贝构造:可以用 ()
shared_ptr<int> sp2(sp);
//拷贝构造:也可以用 =
shared_ptr<int> sp3 = sp2;
//赋值
shared_ptr<int> sp4;
sp4 = sp3;
特征3:也可以作为容器的元素
作为容器元素的时候,即可以传递左值,也可以传递右值。(区别于unique_ptr只能传右值)
特征4:也具备移动语义
表明也有移动构造函数与移动赋值函数。
复制或赋值,引用计数会改变
3.作为容器的元素,若传递的是左值,则引用计数+1。
若传递的是右值,是移交管理权,引用计数不变。原来的sp被置空,无法解引用。
shared_ptr<int> sp(new int(10));
cout << "sp.use_count(): " << sp.use_count() << endl;
cout << endl;
cout << "执行复制操作" << endl;
shared_ptr<int> sp2 = sp;
cout << "sp.use_count(): " << sp.use_count() << endl;
cout << "sp2.use_count(): " << sp2.use_count() << endl;
cout << endl;
cout << "再创建一个对象sp3" << endl;
shared_ptr<int> sp3(new int(30));
cout << "sp.use_count(): " << sp.use_count() << endl;
cout << "sp2.use_count(): " << sp2.use_count() << endl;
cout << "sp3.use_count(): " << sp3.use_count() << endl;
cout << endl;
cout << "执行赋值操作" << endl;
sp3 = sp;
cout << "sp.use_count(): " << sp.use_count() << endl;
cout << "sp2.use_count(): " << sp2.use_count() << endl;
cout << "sp3.use_count(): " << sp3.use_count() << endl;
cout << endl;
cout << "作为容器元素" << endl;
vector<shared_ptr<int>> vec;
vec.push_back(sp);
vec.push_back(std::move(sp2));
④shared_ptr的循环引用
shared_ptr循环引用,造成无法析构,造成内存泄露
先销毁childPtr(栈对象,后创建先析构)
数据成员恰好又是一个shaed_ptr,共同指向对方的数据成员
回收栈上对象,发现引用计数没减到0。就仅回收了栈对象,没有调用析构函数去回收堆上的数据成员,造成内存泄露。
解决:如何解开循环引用?引入weak_ptr
weak_ptr是一个弱引用的智能指针,不会增加引用计数。
shared_ptr是一个强引用的智能指针。
强引用,指向一定会增加引用计数,只要有一个引用存在,对象就不能释放;
弱引用并不增加对象的引用计数,但是它知道所托管的对象是否还存活。
将Parent类中的shared_ptr改为weak_ptr
反过来也一样,也能避免造成循环引用。
先~Parent()
再~Child()
⑤weak_ptr的使用
weak_ptr仅仅增加了指向,不增加引用计数,也不能直接访问资源。
weak_ptr是弱引用的智能指针,它是shared_ptr的一个补充,使用它进行复制或者赋值时,并不会导致引用计数加1,是为了解决shared_ptr的问题而诞生的。
weak_ptr知道所托管的对象是否还存活,如果存活,必须要提升为shared_ptr才能对资源进行访问,不能直接访问。
1.初始化
weak_ptr<int> wp;//无参的方式创建weak_ptr
//也可以利用shared_ptr创建weak_ptr
weak_ptr<int> wp2(sp);
2.判断关联的空间是否还在
(1)可以直接使用use_count函数
如果use_count的返回值大于0,表明关联的空间还在
(2)使用lock函数,将weak_ptr提升为shared_ptr
shared_ptr<int> sp2 = wp.lock();
if(sp2){
cout << "提升成功" << endl;
cout << *sp2 << endl;
}else{
cout << "提升失败,托管的空间已经被销毁" << endl;
}
lock()函数是返回 临时shared_ptr对象。也就是说lock()是临时提升。
(3)可以使用expired函数
该函数返回true等价于use_count() == 0.
bool flag = wp.expired();
if(flag){
cout << "托管的空间已经被销毁" << endl;
}else{
cout << "托管的空间还在" << endl;
}
①shared_ptr和weak_ptr的相互转换
<1>用shared_ptr创建weak_ptr
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp; // 用shared_ptr创建weak_ptr
std::cout << "Shared pointer count: " << sp.use_count() << std::endl; // 引用计数为1
return 0;
}
<2>weak_ptr转为shared_ptr:lock()
要从 weak_ptr 获取对对象的访问权,需要将其转换为 shared_ptr。这可以通过调用 weak_ptr 的 lock() 方法来实现。
(3)删除器
①unique_ptr对应的删除器
很多时候我们都用new来申请空间,用delete来释放。库中实现的各种智能指针,默认也都是用delete来释放空间。
但如果我们是用fopen打开文件,这时智能指针的默认处理方式就不能解决了,必须为智能指针定制删除器,也就是定制化释放资源的方式。
问题:直接使用智能指针去包含FILE *
问题1:默认的删除器是delete,而不是fclose。不调用fclose,不会向文件流中写入内容
问题2:如果手动调用fclose,会造成double free。而且也不符合智能指针的含义。
问题的根本原因:接管文件资源时,智能指针在析构时也是使用delete语句来回收资源,导致错误
——需要自定义删除器
仿照参考文档上默认删除器的示例,创建一个代表删除器的struct,定义operator()函数
struct FILECloser{
void operator()(FILE * fp){
if(fp){
fclose(fp);
cout << "fclose(fp)" << endl;
}
}
};
创建时,写上手动的删除器 (文件资源,需要自定义删除器)
②shared_ptr对应的删除器
unique_ptr 的删除器是模板参数。
而shared_ptr 自定义的删除器,是在构造函数里添加。
举例:
(4)智能指针的误用
智能指针被误用的情况,原因都是将一个原生裸指针交给了不同的智能指针进行托管,而造成尝试对一个对象销毁两次。
举例1:一个裸指针交给两个不同的unique_ptr进行托管。
在结束时,up和up2都会去自动回收,造成double free。需要人为避免。
Point * p = new Point(1,2);
unique_ptr<Point> up(p);
unique_ptr<Point> up2(p);
为了避免这种情况,可以直接在构建智能指针的时候使用new表达式,构造匿名对象。
/* Point * p = new Point(1,2); */
unique_ptr<Point> up(new Point(1,2));
unique_ptr<Point> up2(new Point(1,2));
举例2:一个裸指针交给两个不同的shared_ptr进行托管。
也会造成double free。因为引用计数还是1。
正确的共享应该是这样:
举例3:用get()又获取了裸指针
相当于将Point(1,2)匿名对象的裸指针,又交给了sp3。即又把同一个裸指针交给了不同的智能指针进行托管,在析构时会造成double free。
解决:
思路1:将addPoint的返回值类型,从Point * 改为shared_ptr
问题:把问题提前到了return时,还是会创建一个匿名的shared_ptr,与sp共同托管了同一个裸指针。
思路2:重新构建一个shread_ptr<Point>。虽然没有double free,但是这样构建的sp3和sp管理的并不是同一片空间。
思路3:使用智能指针辅助类 enable_shared_from_this
的成员函数shared_from_this
要用Point类 继承 ennable_shared_from_this 类,才能调用 shared__frorm_this()成员函数
不仅没有double free,还可以真正让sp3和sp管理同一片空间
总结:智能指针的误用全都是使用了不同的智能指针托管了同一块堆空间(同一个裸指针)。
作业:文本查询程序再探
解决思路:继承、多态、运算符重载、智能指针
C++ Primer第五版,15.9