当前位置: 首页 > news >正文

c++进阶——智能指针

文章目录

  • 智能指针
    • 出现的问题
    • RAII思想和智能指针的引入
    • c++标准库的智能指针
      • c++98的auto_ptr
      • c++11引入的新智能指针
        • unique_ptr的使用和原理
        • shared_ptr的基础使用
        • shared_ptr的基础实现
      • 对智能指针的explicit解释
      • make_shared的使用及优点
      • 循环引用和weak_ptr
        • shared_ptr的循环引用问题
        • weak_ptr
      • 自行实现智能指针与标准库的区别
      • 定制删除器
        • 标准库的定制删除器使用
        • 模拟实现定制删除器
        • std::unique_ptr和std::shared_ptr删除器使用的区别
      • shared_ptr的线程安全问题
    • C++11和boost中智能指针的关系
    • 内存泄漏
      • 什么是内存泄漏,内存泄漏的危害
      • 如何避免内存泄漏

智能指针

本篇文章将重点讲解智能指针的相关内容。同时智能指针也是c++中很重要的一个部分。

出现的问题

在上一篇文章中,我们讲到了一个问题。即抛异常语句后面的代码编译器是不会去执行的。因为编译器在处理抛异常的时候,一旦发现某个位置抛出异常,就会沿着函数的调用链去找匹配的catch模块(本质就是栈展开)。

但这样势必会引起一个问题,即如果在抛异常前面开辟了资源,如下面这一段代码:

double Divide(double x, double y) {if (y == 0) throw string("The Zero is Divided");cout << x / y << endl;return x / y;
}void Func(double x, double y) {cout << "int* arr1 = new int[10]   int* arr2 = new int[20]; " << endl;int* arr1 = new int[10];int* arr2 = new int[20];Divide(x, y);cout << "delete[] arr1    delete[] arr2" << endl;delete[] arr1;delete[] arr2;}int main() {	try {double a, b;cin >> a >> b;Func(a, b);}catch (const string& errStr) {cout << errStr << endl;}catch (...) {cout << "Unknown Exception" << endl;}return 0;
}

不抛异常的情况下:
在这里插入图片描述
我们发现Func中动态开辟的两个数组是可以被释放掉的。

抛异常的情况下:
在这里插入图片描述
异常倒是可以正常跑出来,但是这里就有一个很大的问题,即Func函数中开辟的两个数组根本就没有被释放掉。这就引发了一个很大的问题——内存泄漏。内存泄漏在大型项目中是非常严重的,所以必须得想办法解决这个问题。

double Divide(double x, double y) {if (y == 0) throw string("The Zero is Divided");cout << x / y << endl;return x / y;
}void Func(double x, double y) {cout << "int* arr1 = new int[10]   int* arr2 = new int[20]; " << endl;int* arr1 = new int[10];int* arr2 = new int[20];try {Divide(x, y);}catch (...) {cout << "delete[] arr1    delete[] arr2" << endl;delete[] arr1;delete[] arr2;throw;}cout << "delete[] arr1    delete[] arr2" << endl;delete[] arr1;delete[] arr2;}int main() {	try {double a, b;cin >> a >> b;Func(a, b);}catch (const string& errStr) {cout << errStr << endl;}catch (...) {cout << "Unknown Exception" << endl;}return 0;
}

第一种修改方式就是像上面这一段代码,使用异常的重新抛出。在Func函数中,强行拦截下异常。当前是知道已经出现异常的,所以在拦截到异常后,就直接进行资源的释放。然后再将异常重抛出。这样是可以解决一部分的问题。

但是这里还面临着一个问题,就是使用操作符new的时候,是有可能申请内存失败的。如果也要进行抛异常那就很麻烦了。使用一次new就要套一层try catch,这样写代码是很复杂,而且也不希望能够这么写。

而且强行拦截再抛出的方式其实也不太好。因为我们发现释放逻辑是一样的。所以势必需要一种方法,能够自动地进行回收,不需要我们手动管理。这就是智能指针引入的初衷。

RAII思想和智能指针的引入

鉴于上一个部分展示出的种种问题,c++研发者们提出了一种思想:
RAII(Resource Acquisition Is Initialization),资源获取立即初始化。

这是资源管理的一种非常重要的思想。他是一种管理资源的类的设计思想,本质是一种利用类对象生命周期来管理获取到的动态资源,避免资源泄漏,这里的资源可以是内存、文件指针、网络连接、互斥锁等等。RAII在获取资源时把资源委托给一个对象,接着控制对资源的访问,资源在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源,这样保障了资源的正常释放,避免资源泄漏问题。

其实就是一个类,底层是一个指针,一个指向开辟资源的指针。一般来讲,开辟资源的时候都是让一个普通的对应类型的指针去指向这一片资源的。但是这样会有上面讲到的问题。

但是用一个类来封装这个指针就不一样了。我们让这个类对象的底层的那个指针指向开辟的资源,那么在开辟资源的时候直接让类对象去接收资源。本质就是在调用这个类对象的构造函数。而c++的编译器在类对象所处栈帧结束后,会自动地调用析构函数。我们只需要在析构函数内进行释放操作就好了。

对于刚刚那种情况,抛出异常后就去找catch模块了。但是现在我们让一个类对象里面的指针去指向开辟出的资源,异常抛出,我们前面讲过,编译器会栈展开,沿途所有的创建的对象和栈帧都会被销毁。那么这就简单了,类对象在栈帧结束的时候会自行调用析构函数。这不就成功的释放掉资源了,而且不需要我们手动管理。这就是RAII思想的优势。

RAII思想也是只能指针的底层思想。智能指针的智能就是相比于普通的指针,多了这么一个能够自动回收的功能。且由于本质是一个类对象,功能可以自定义实现,这相比于普通的指针那是智能了很多的。

一般来说,实现的智能指针还是要有operator*/operator->/operator[]这几种功能。因为这些功能普通的指针也是有的。自行实现也是为了能够更方便地访问资源。

我们来实现一个简单版本的智能指针:

template<class T>
class SmartPtr {
public:SmartPtr(T* ptr) :_ptr(ptr){} ~SmartPtr() {cout << "delete[] "  << _ptr << endl;delete[] _ptr;}
private:T* _ptr;
};double Divide(double x, double y) {if (y == 0) throw string("The Zero is Divided");cout << x / y << endl;return x / y;
}void Func(double x, double y) {cout << "int* arr1 = new int[10]   int* arr2 = new int[20]; " << endl;SmartPtr<int> sp1 = new int[10];SmartPtr<int> sp2 = new int[20];Divide(x, y);
}int main() {	try {double a, b;cin >> a >> b;Func(a, b);}catch (const string& errStr) {cout << errStr << endl;}catch (...) {cout << "Unknown Exception" << endl;}return 0;
}

自行加入了一个叫做SmartPtr的类,我们需要自行实现它的构造函数和析构函数,我们来看看运行结果:
在这里插入图片描述
我们发现,经过智能指针的使用后,确实是利用了对象的生命周期去完成资源的释放,但是这里眼尖的读者们肯定发现了一个问题:

就是我们自己实现的SmartPtr调用析构函数的时候,用了的delete[]。我们这里删除的是内置类型int,这就还好,不会出现什么问题。但是如果是实现了析构函数的自定义类型,我们之前讲过,如果不匹配使用是会出很大问题的(回看c++初阶内存管理文章),当时就说了匹配使用是不会出现问题的。

但是这里我们怎么样判断那个指针指向的是一个数据还是一串数据呢?这是我们要注意的,这决定了我们在析构资源的时候使用delete还是delete[]

解决这个问题需要使用删除定制器,这个我们先不讲,放在后面部分来说。当前就先自行匹配着使用就好了。等到后面讲解了删除定制器的时候我们再来详细说。

c++标准库的智能指针

现在我们来看一下c++标准库里面提供的智能指针。

1.c++中的智能指针实现专门被包含在了一个头文件< memory >,当然智能指针的种类有很多种,如下图所示:
在这里插入图片描述
图中的Managed pointers部分就是智能指针,其中auto_ptr是c++98的时候就已经实现的了,但是这个智能指针写的并不好,没有得到广泛的使用,还经常被开会。

c++98的auto_ptr

这个auto_ptr虽然早就在1998年的时候就实现了,但是却收获了业内的一致差评。这是因为使用这个版本的智能指针的时候会引发很大的问题——被拷贝对象悬空。

这是什么意思呢?我们来稍微地讲解一下:
就是说,智能指针本身是一个类。类涉及拷贝的就要进行区分到底是要深拷贝还是浅拷贝。

我们之前的一致共识是:如果有指向资源就是深拷贝,反之浅拷贝。但是这样子理解是有很大的问题的,因为有些情况下哪怕指向资源了也是只要浅拷贝。比如我们在实现(list、RBTree、Hash表)容器的迭代器的时候,我们都是进行浅拷贝的,为什么?

我们要搞清楚真正是否需要深拷贝的本质是什么?——是代管资源还是获取资源?

如果我们的拷贝是想要获取一份一样的资源但是和原来那份地址不一样,这就需要进行深拷贝。但是像迭代器那样的,一个资源确实是可以让多个迭代器进行指向,并不是说迭代器拷贝一次就要拷贝出一份资源出来。本质上迭代器的复制拷贝是想要让多个迭代器共管一份资源。

就像普通的指针一样,一个指针给另外一个指针赋值后,两个指针指向的是同一个位置。也没有说一个指针复制给另外一个的时候还要另开一份资源吧。

这里我们的智能指针也是一样的,所以我们目前为止就确定了一件事情:智能指针的拷贝构造是不需要自行实现的,直接使用默认生成的那个能够进行值拷贝的就可以了。

但是这就是出现问题的原因:
如果进行的是浅拷贝,那么被拷贝的对象和拷贝出的对象都指向一份资源,这会导致程序崩溃的,因为调用了两次析构函数。我们在刚学习类和对象部分的时候就说过,对动态开辟的资源进行两次析构是不可以的。

对此,auto_ptr对这个场景的解决是把值拷贝给另外一个变量后,被拷贝的对象要指向空。这样子表面上看是解决了问题,但是同时又带来了新的问题,即被拷贝对象悬空了。如果一个auto_ptr对象被拷贝后,内部指针指向空。但是在外界来看,这个对象并没有真的完全被消除,还是会存在。有时候可能会使用到它,对一个空指针进行访问、解引用等操作是不行的,程序必然会崩溃。所以auto_ptr只是解决了表面出现的问题,但是却留下了更深层次的隐患,所以很多公司即使在c++11出来前也明令禁止使用这个指针。

我们来看下auto_ptr的基本代码实现:

namespace myspace{template<class T>class auto_ptr {public:auto_ptr(T* ptr):_ptr(ptr){}auto_ptr(auto_ptr<T>& sp):_ptr(sp._ptr){// 管理权转移sp._ptr = nullptr;}auto_ptr<T>& operator=(auto_ptr<T>& ap) {// 检测是否为自己给自己赋值if (this != &ap) {// 释放当前对象中资源if (_ptr)delete _ptr;// 转移ap中资源到当前对象中_ptr = ap._ptr;ap._ptr = NULL;}return *this;}~auto_ptr() {if (_ptr) {cout << "delete:" << _ptr << endl;delete _ptr;}}// 像指针⼀样使用T& operator*() {return *_ptr;}T* operator->() {return _ptr;}private:T* _ptr;};
}

原理实现还是很简单的,也很好理解。就不赘述了。

所以对这个指针的态度就是,尽量别用,因为写的确实不好。

c++11引入的新智能指针

c++写了auto_ptr后经常挨骂和被拉近小黑屋开会。所以c++11后引入了几个新的智能指针,这就十分有用了,接下来我们一起来看一下。

unique_ptr的使用和原理

我们先来讲解第一个:unique_ptr。
这个指针从名字来看大概能猜出端倪,unique,即独特的,唯一的。

难道这里的意思是说智能指针是唯一的吗?大概是这个意思。unique_ptr这个类是不允许拷贝的。这其实很好做到。学习了c++11的一些特性后,我们知道使用关键字delete可以把某些不想让外界调用的函数进行删除。比如io流这两个类就是这么干的。

这里的unique_ptr就是这么一回事,它是不允许被拷贝的:
在这里插入图片描述
注意,这里调用的是拷贝构造,不是赋值重载。这要区分清楚。
我们发现调用拷贝构造的时候报错了,报错原因是:C2280 尝试引用已删除的函数。

我们仔细一看,正是拷贝构造,这也就说明了 unique_ptr拷贝。但是并不是说不能将一个 unique_ptr的内容给到另外一个,因为unique_ptr是支持移动构造的:
在这里插入图片描述
这样就不会报错了,文档里面其实也有提及:
在这里插入图片描述
很多人就会说了,这难道解决了刚刚的问题吗?这个本质不也是把被右移的左值对象给悬空了吗?其实从表层上来看,确实是这么一回事。但是,和auto_ptr还是有一些区别的。

auto_ptr是无论如何,被拷贝的对象都会被悬空。这是无可指摘的。但是unique_ptr就不一样了,因为它已经明确说了,这个类我是不允许被拷贝的。如果硬要将资源转移那就只能调用移动构造。那使用者必然是要将左值对象进行右移才能调用到这个移动构造的。这个代价是用户本身就知道的,因为我们以前就说过,将左值移动为右值是很危险的,给了别的对象掠夺资源的机会。但是这里用户必然是知道这个代价,但还是要这么用的。故和auto_ptr还是有区别。

其实我们可以理解为unique_ptr就是玩了一个文字游戏。但是它是明确地把拷贝这一行为给禁止了,至于是否要进行资源的转移那是取决于用户的事情。

其实unique_ptr这个类的基础实现和原理也是很好理解的:

namespace myspace {template<class T>class unique_ptr {public:unique_ptr(T* ptr):_ptr(ptr){}unique_ptr(unique_ptr<T>& up) = delete;unique_ptr(unique_ptr<T>&& up) :_ptr(up._ptr){up._ptr = nullptr;}unique_ptr<T>& operator=(unique_ptr<T>& up) = delete;unique_ptr<T>& operator=(unique_ptr<T>&& up) {delete _ptr;_ptr = up._ptr;up._ptr = nullptr;}~unique_ptr() {if (_ptr) {cout << "delete:" << _ptr << endl;delete _ptr;}}// 像指针⼀样使用T& operator*() {return *_ptr;}T* operator->() {return _ptr;}private:T* _ptr;};
}

这里稍微地解释一下为什么移动构造不需要先释放资源,但是移动赋值却要。

因为要搞清楚构造和赋值的本质区别。赋值时一个存在的对象把内容赋值给另外一个存在的对象。但是构造不是,是把一个对象移动构造给一个正在创建的对象。

本质就是被操作的对象是已经存在还是正在创建。因为unique_ptr只能有一个指针指向一份资源,那么如果是调用移动赋值,那么被赋值的那个unique_ptr已经是存在的了,也指向资源了(没有写无参构造),不先释放资源必然会导致内存泄露的。但是移动构造就不一样了,被操作的对象是正在创建的,还没有指向资源,直接把被移动的对象的资源给这个正在创建的对象指向就好了,所以不需要先释放资源。

但是这样写的代码还是有一些问题的:

#include<memory>
int main() {int* p1 = new int[10];myspace::unique_ptr<int> up1(p1);myspace::unique_ptr<int> up2(p1);return 0;
}

如果我们拿同一份资源进行初始化的话,这就变相的让两个unique_ptr指向同一个资源了,这也是会导致析构两次的:
在这里插入图片描述
我们来看看标准库里面实现的是否有这样的问题:
在这里插入图片描述
也是一样的,一样会导致析构两次从而程序崩溃。

所以一般来说,在不需要拷贝的情况下就会使用unique_ptr,但是使用的时候需要非常注意一点就是:不要让两个unique_ptr的对象共管一份资源,会出大问题。

shared_ptr的基础使用

上个部分讲解了c++11引入的智能指针的第一种——unique_ptr。但是说实话,使用场景仅限于不需要拷贝的时候。但是还是会有一些问题,就是有可能间接地导致两个智能指针共管一份资源。但是我们经常使用的普通指针是可以让多个指针指向同一份资源的。只是通过一个指针释放掉资源后,会导致其他指针变成野指针,其实就是悬空了。

但是有没有一种办法,能够让多个指针共管一份资源,但是又不会触发上面的那种析构多次从而导致程序崩溃的问题呢?其实是有的——就是shared_ptr。

也是从名字来看,shared就是分享的,正好和unique相对。这是允许拷贝的,也不会触发多次析构的问题。底层是通过引用计数实现的。我们这个部分先不讲底层原理,我们先讲使用,然后再来简单实现一下。

#include<memory>
int main() {int* p1 = new int[10]{ 1,2,3,4,5,6,7,8,9,10 };//构造std::shared_ptr<int> sp1(p1);//拷贝构造std::shared_ptr<int> sp2(sp1);//拷贝构造std::shared_ptr<int> sp3(sp2);//拷贝赋值std::shared_ptr<int> sp4;sp4 = sp1;return 0;
}

我们来看看运行结果:
在这里插入图片描述
这里是没有出现问题的,也就是说shared_ptr是可以进行拷贝操作的,也没有出现多次析构。

在这个部分,我们只是对shared_ptr进行了简单的讲解,其实还有很多细节。但是这些细节现在来讲是肯定讲不明白的,需要我们对底层实现的原理进行了解后才能比较好的理解。所以对于shared_ptr就先讲到这里,我们讲完原理后再回过头来看。

至于auto_ptr,这个不重要,因为几乎没有使用的地方。而unique_ptr的实现比较简单,底层原理的一些细节其实再shared_ptr这里也会出现。只不过因为没有拷贝的情况导致实现起来更简单。对于它的一些细节也放在了和shared_ptr的细节处一起提及就好了。

实际上面试里面问的最多的就是shared_ptr的原理,还有它特有的一些问题。所以重点讲的就是shared_ptr。但是总而言之,在没有了解原理之前是很难理解这些细节的。所以下面一个部分我们需要先来简单实现一个版本的shared_ptr。

shared_ptr的基础实现

这个部分最主要的内容就是了解到底如何实现shared_ptr,为什么它可以拷贝,但是又不会析构多次。这是需要重点理解的。

我们前面稍微地提及了一下,shared_ptr的底层是通过引用计数的方式来实现的,什么意思呢?我们来了解一下:

所谓的引用计数,其实就是在资源旁边配一个计数的变量。这个变量专门记录的就是当前有多少个shared_ptr对象指向这份资源。这样子就好办了,在拷贝的时候,只需要进行值拷贝后对这个计数进行+1即可。如果是析构对象也好办:因为要析构一个对象,肯定会导致指向这份资源的对象减少一个。所以无论如何先让计数-1,然后再来看有多少个对象指向这份资源。如果不为0,说明还有对象指向它,不用再操作。如果是0,那说明没有对象会再管这一份资源了,直接释放掉这份资源。

如图所示:
在这里插入图片描述
析构拷贝的规则就按照上文提及的方式进行。


了解完何为引用计数后,现在面临着第二个问题,怎么样实现这个引用计数?

第一种方式就是直接在每个shared_ptr的成员变量中加一个_count。但是这种方法很快就会被pass了,这是绝对不行的。
如果是以成员变量的形式去走的话,那么就是说每个shared_ptr里面都有一个一样的_count,如果是第一个shared_ptr调用构造指向资源就还没有问题,直接就是1。再加一个拷贝的对象,也就是sp2,再让_count进行+1,但是要注意的是,这里的+1操作的是sp2的。也就是说,会导致sp1里面的_count是1,sp2的是2。这很明显不对。

这里我们是很难控制拷贝的时候,每一个指向它的对象里面的_count都+1,析构的时候,每一个指向它的对象里面的_count都-1,究其根本原因就是成员变量是每个对象独立的。


第二个方法有人就会说了,既然使用成员变量不行,那就用静态成员变量,这是所有对象共有的变量。这种方法其实也会有很大的问题。

在这里插入图片描述
还是这样子,如果只有一份资源,那很简单。这是完全正确的,没有毛病。

但是有两份资源及以上呢?
在这里插入图片描述
这时候就出现问题了。静态变量是所有对象共有的。但是不是所有的shared_ptr都指向同一份的资源啊。如果有别的指针指向新的资源,由于这个静态变量就一个,按照引用计数的逻辑去走,要对这个引用计数进行+1。

但是我们要搞清楚引用计数的本质是一份资源被指针指向的个数。这是一份的。但是这里sp3指向的是另外一份资源了,但是引用计数算到这个静态变量头上。这不就不对了吗?分明两份资源的引用计数分别是1和2。所以这也是不行的。这是把所有的资源的管理对象个数给记录下来了。


我们再来回过头看下引用计数的初中:
即每一份资源,都希望有对应的一个计数,表示当前这份资源有多少对象在进行管理。

所以说,这个引用计数应该是和资源进行配套的。要想实现这个方式,就必须想办法让计数变量和资源进行绑定。

这个很简单,动态开辟一个就好,然后让每一个shared_ptr指向这个计数不就好了。这个时候就完成了计数变量和资源的一对一的绑定。而且再按照上述引用计数的逻辑去走的时候会发现,这是可以的。所以我们以这个为思路进行一个简单的实现:

namespace myspace {template<class T>class shared_ptr {public:explicit shared_ptr(T* ptr):_ptr(ptr),_pcount(new int(1)){}shared_ptr(const shared_ptr<T>& sp) :_ptr(sp._ptr),_pcount(sp._pcount){++(*_pcount);}shared_ptr<T>& operator=(const shared_ptr<T>& sp) {//自己给自己赋值//1.  sp1 = sp1//2.  sp1 和 sp2 共管一份资源if (_ptr == sp._ptr) {if (--(*_pcount) == 0) {delete _ptr;delete _pcount;}_ptr = sp._ptr;_pcount = sp._pcount;++(*_pcount);}return *this;}T* get(){return _ptr;}~shared_ptr() {if (--(*_pcount) == 0) {delete _ptr;delete _pcount;}}T& operator*() {return *_ptr;}T* operator->() {return _ptr;}private:T* _ptr;//指向资源int* _pcount;//指向引用计数变量};}

这个版本实现的其实非常的简单,我们就稍微讲解一下不同之处。

对于构造一个shared_ptr的时候,由于希望的是引用计数变量和资源是一对一绑定的,所以智能使用动态开辟的方式进行操作绑定,shared_ptr这个类里面多加一个指向引用计数变量的指针就可以了。

最重要的就是拷贝构造和拷贝赋值的实现:
拷贝构造由于是构造,当前操作的对象是正在构造的,所以不涉及资源的释放等操作。直接进行值拷贝后再让引用计数+1就可以了。

但是拷贝赋值就不一样了,有很多需要注意的细节。
第一个就是,由于当前是赋值,是两个已经存在的对象之间的操作。那当前这个接收赋值的对象是要改变指向的资源的。这就势必要考虑它原本指向的资源是否需要释放。因为如果只剩它一个对象管理资源的话,那么就必须先释放掉对象再转移指针指向,否则就造成内存泄露了。
然后后面的逻辑和拷贝构造就基本上是一样的了。

但是还有一个问题,就是自己给自己赋值:
如果是本身变量sp1 = sp1,这里会出现一个我问题。如果引用计数是>1的就还好,这里强制让计数–后后面又++回来了,这里倒没有什么问题。如果引用计数是1呢?强制–后直接就释放资源了,我们这里还需要进行赋值的,这直接给释放掉了,这肯定是不行的。

还有一种是共管同一份资源的对象之间进行赋值,如sp1 = sp2,这里倒不会出现问题,经过代码流程走一遭发现,其实最后还不如不进行赋值操作。等于是在做无用功。

所以对于上面的两种情况我们最好做出判断。但是由于这里自己给自己赋值的情况多了一种,所以使用逻辑*this != sp去判断是会漏掉第二种情况的,这里是和以前自己给自己的赋值判断不太一样的地方(多加了一种情况)。但是我们这里自己给自己赋值的相同之处在于,管理的资源是同一份,就连引用计数都是同一份,所以拿指向资源的指针判断也可以,拿引用计数来判断也可以。

我这里默认认为指向的资源是一个,所以析构的时候用的是delete,但其实还有可能是多个资源的一个数组。但是这里并不是重点,这部分重点是理解shared_ptr的底层原理。至于怎么处理这个问题还是等一下再说。

对智能指针的explicit解释

这里我们就稍微提及一下就好了。

在我们前面自行实现的智能指针里,会发现在构造的时候,在构造函数的最前面加了一个关键字explicit,这个我们在类和对象的时候讲过,使用了这个关键字的函数,是不允许进行隐式类型转换的。其实标准库里面也是这么做的,也不允许隐式类型转换。

所以这就是为什么使用的标准库里面的智能指针和自行模拟实现的智能指针的时候,都是进行显示调用构造的。因为明确限制了不能进行隐式类型转换。

这是为什么呢?
首先第一个原因是c++其实更喜欢显示的方法。但这不是重点。最重要的是防止出现一些很奇怪的问题。如果是调用构造,那么说明此时我们是比较清楚我们在做什么事情的——即给构造智能指针,让它管理一份资源。

但是支持隐式类型转换就有点危险了,因为隐式类型转化呢会用到=,但是我们会发现很多时候会用到=。很容易搞混,也就是说,有时候很容器造成用户自行的把一个类型的指针丢给智能指针进行隐式类型转换的构造。这还是比较危险的。如果将一个栈区上的内容交给智能指针进行保管就很危险了。函数局部栈帧一销毁,如果智能指针是外界传进来的,那就变成野指针了。

基于种种原因,c++明确地将智能指针的构造设计成不允许隐式类型转换的。如果要明确使用=来将一个指针构造给shared_ptr,就需配合使用下面将要讲到的make_shared。

make_shared的使用及优点

这里我们主要来看看make_shared的相关用法和优点。

这个make_shared的用途其实和make_pair很像,就是将传入的内容构造成shared_ptr/pair,然后以函数的返回值形式带出来。这样子就可以直接进行构造了。

在这里插入图片描述
这里的make_shared其实就是使用可变参数模板的参数包进行接收变量,然后用这个参数包去构造一个指向T类型资源的shared_ptr对象。

在这里插入图片描述
这样子就可以使用=来进行构造了。因为make_shared的返回值是shared_ptr,正好与sp1构造函数中需要的参数匹配了。

但是这里有一点还是需要注意的就是,在c++11和c++14的标准下,make_shared只能构造出指向单个数据的shared_ptr,无法构造出指向多个数据的。直到c++20后才有这样的写法:
auto arr = std::make_shared<int[]>(5); // 创建含 5 个 int 的数组

如果真的想要把指向一系列的数据,考虑把它们放在一个容器里面,如vector:
在这里插入图片描述


前面是使用,其实使用非常简单。就不再赘述了。

我们来讲一下make_shared的优点。在能明确智能指针是不会被拷贝的情况下,是可以直接使用unique_ptr的。因为在不需要拷贝的情况下,它的优点确实明显。shared_ptr是需要多开辟一个空间存储引用计数变量的。

如果是像我们上面那样去掉用构造函数自行开辟,会很容器导致内存碎片化。因为我们new出来的地址是随机的,而引用计数变量就占用四个字节。如果大量进行构造shared_ptr,就会导致大量的内存碎片产生。有内存碎片其实是很烦的一件事情,会极大地影响内存动态开辟的效率。但是make_shared经过一些处理,可以让资源和引用计数变量挨着近一点。这就可以提高内存开辟地效率,也减少了内存的碎片分布。

循环引用和weak_ptr

这个部分我们来看一下循环引用和weak_ptr等问题。

shared_ptr的循环引用问题

首先来看第一个问题,即shared_ptr在某些极端场景下会出现循环引用的问题。

何为循环引用呢?我们举个例子看看:

template<class T>
struct listNode
{T _data;listNode<T>* _prev;listNode<T>* _next;listNode(T data = T(), listNode<T>* prev = nullptr, listNode<T>* next = nullptr):_data(data),_prev(prev),_next(next){}~listNode() {cout << "~listNode" << endl;}
};int main() {std::shared_ptr<listNode<int>> sp1(new listNode<int>);std::shared_ptr<listNode<int>> sp2(new listNode<int>);sp1->_next = sp2;sp2->_prev = sp1;return 0;
}

我们在这里声明了两个指向listNode的shared_ptr,但是然后想让sp1和sp2进行互相的链接。但是当真的把这一段代码写出来后,发现是有问题的:
在这里插入图片描述
发现报错是报在了指针互相链接的部分。其实很好理解,因为listNode里面的指针类型是listNode<int>*,而sp1和sp2的指针类型是shared_ptr<int>,我们之前讲过,shared_ptr是特意设置成不能进行隐式类型转换的。所以这里没办法将listNode<int>*转化为shared_ptr<int>,所以这样写是错的。

所以很多人会说,既然普通的节点指针不行,那就把listNode里面的指针改成shared_ptr不就好了,我们来试试看:

template<class T>
struct listNode
{T _data = T();std::shared_ptr<listNode<T>> _prev;std::shared_ptr<listNode<T>> _next;~listNode() {cout << "~listNode" << endl;}
};int main() {std::shared_ptr<listNode<int>> sp1(new listNode<int>);std::shared_ptr<listNode<int>> sp2(new listNode<int>);sp1->_next = sp2;sp2->_prev = sp1;return 0;
}

我们运行一下看看:
在这里插入图片描述
乍一看貌似没什么问题,但其实还是出现了一些问题。按道理,sp1和sp2这两个shared_ptr应该是在main函数结束前应该调用一下析构函数才对,而且应该是两次,为什么没有调用呢?

这就出现了循环引用的问题了,我们一起来看看:
在这里插入图片描述
开辟sp1和sp2的时候,还是很正常的,分别指向两个节点,并且配套的引用计数为1。

但是sp1和sp2相互链接的时候,由于它们的内部的前后指针都设定成了shared_ptr,在相互链接的时候,必然配套的引用计数都会为2。

然后sp1和sp2会调用析构,但仅仅只是引用计数减1,因为只有引用计数再减1后变成0了才会真的把指针指向的内容给释放掉。

  1. 右边节点什么时候释放呢,左边节点中的_next管着,_next析构后,右边节点就释放了。
  2. _next什么时候析构呢,_next是左边节点的的成员,左边节点释放,_next就析构了。
  3. 左边节点什么时候释放呢,左边节点由右边节点中的_prev管着呢,_prev析构后,左边的节点就释放了。
  4. _prev什么时候析构呢,_prev是右边节点的成员,右边节点释放,_prev就析构了。

至此,我们发现形成了逻辑闭环了,最终导致的结果就是谁都没有释放,即内存泄漏。

这就是shared_ptr中的循环引用问题。这是一个十分严重的问题。

weak_ptr

基于上面shared_ptr会出现的问题,是需要使用weak_ptr进行改进实现的。
但是在这里需要声名一点:weak_ptr并不是智能指针,也不是使用RAII思想。

RAII思想的最重要部分是:将内容给予一个类对象,利用它的生命周期去进行资源的自动管理。但是这里的weak_ptr并不参与管理资源,而是配合着shared_ptr出现引用循环的情况下去使用。

我们来看看标准库里的weak_ptr:
在这里插入图片描述
weak_ptr是支持用shared_ptr进行构造的。

当我们将节点中的指针全部换成weak_ptr后,再次启动程序就能发现这里是可以正常析构节点了。本质的原因就是,weak_ptr只指向这个资源,但是却不参与资源的管理。

在这里插入图片描述
我们可以看看weak_ptr的简单实现:

tips:这里的weak_ptr和前面实现的shared_ptr都是以最简单的方式进行实现的。因为实际上底层实现其实是远远要复杂的多的。

template<class T>
class weak_ptr
{
public:weak_ptr(){}weak_ptr(const shared_ptr<T>& sp):_ptr(sp.get()){}weak_ptr<T>& operator=(const shared_ptr<T>& sp){_ptr = sp.get();return *this;}
private:T * _ptr = nullptr;
};

但是weak_ptr使用的情况其实很少,就是在shared_ptr出现循环引用这种极端的情况下才会使用它。

自行实现智能指针与标准库的区别

这里再次强调一下,我们这里实现的所有的智能指针,其实和标准库里面实现的是差很多的。这里只是为了讲解方便演示一下最简单的版本。

特别是针对于weak_ptr来说,我们上面实现的简单版本其实是没有引用计数的。但实际上并不是这个样子的:
在这里插入图片描述
打开文档查看发现,weak_ptr里面其实是有一个接口use_count,这个其实就是计算当前资源的引用计数的。

不是说weak_ptr不参与资源的管理吗?为什么还有引用计数来看呢?

其实这个要结合使用场景来看。就是这里说的weak_ptr不参与资源管理是它不参与资源的释放,不进行访问资源(没有重载operator*和operator->)。也不会增加对应的shared_ptr的引用计数。从它的构造来看,除了默认构造和拷贝构造,就只支持绑定到shared_ptr的构造了。

其实它就是配合shared_ptr去使用的。

虽然它不直接参与资源的管理,但是还是有很多地方是需要用到这些资源的。比如swap接口,比如expired接口是用来检测与绑定的shared_ptr的资源是否过期的。那是否过期就需要查看引用计数了。

但是weak_ptr底层的引用计数其实不是一个整形,其实也是一个类。因为既要包含着shared_ptr指向的引用计数,还有一些别的考虑。
即有时候weak_ptr指向的资源我们可能需要使用,但是假设当前就只有一个shared_ptr sp2在管着这个资源,我们又想要释放掉sp2。那这个时候就有些危险了只有。shared_ptr一个管着资源的时候,析构shared_ptr,会把资源一起带走。所以库里面就提供了一个接口叫做lock,这个接口可以帮助把内容锁住返回一个新的shared_ptr:

shared_ptr<int> sp2(new int(5));
weak_ptr<int> wp1 = sp2;//shared_ptr<int> sp3 = wp1.lock();
auto sp3 = wp1.lock();//释放
sp2.~shared_ptr();

就如上面的代码所示,可以把sp2指向的资源交给wp1锁住,然后再交给一个新的shared_ptr。这个时候就可以完成sp2的析构,但是又把资源保留下来了。

其实weak_ptr就是通过这个lock接口进行访问资源的。访问资源就必须先检查引用计数。要不然资源过期(被释放)再来访问那是很严重的内存安全问题了。

其实就是接口use_count返回的是绑定的shared_ptr指向的引用计数。但是,很有可能某份资源的所有共管的shared_ptr都被析构了,但是weak_ptr又想把这一份资源留下来使用。但是weak_ptr本身又不能直接访问内存,就只能通过接口lock把资源锁住再给另外一个shared_ptr去进行管理。

但就是在上面那种:所有共管资源的shared_ptr被析构,但是weak_ptr又想保留资源的情况下,那就必须记录weak_ptr独立的引用计数变量。要不然也很容易出现内存问题。

所以这些种种原因叠加在一起就必然导致了weak_ptr的底层实现不可能这么简单。

定制删除器

我们前面遗留了一个问题,就是删除智能指针指向的资源的时候,我们怎么样来确定删除的是单个资源还是一组多个资源组成的数组呢?

标准库的定制删除器使用

我们前面讲到过,解决这个问题的方案是配置一个定制删除器,我们先来看看标准库里面是怎么处理这个的。
打开文档发现,shared_ptr内多了一个构造方式:with deleter,即配置了一个删除器,这个删除器我们可以传一个可调用对象进去,包括函数指针,仿函数,lambda对象,包装器包装过的对象。

class Date {
public:Date(int year = 1, int month = 1, int day = 1):_year(year),_month(month),_day(day){}~Date() {cout << "~Date" << endl;}int _year;int _month;int _day;};int main() {Date* PD = new Date[10];std::shared_ptr<Date> sp1(PD);return 0;
}

运行这段代码,必然是会导致程序崩溃的:
在这里插入图片描述
原因就是Date写了析构函数的情况下,new的时候多开了四个字节的内存存储PD指向的数据个数。但是默认的删除器里面的操作是delete,这样子肯定是会出现问题的(内存管理讲过)。

如果要进行删除,我们需要自行传入删除器:
在这里插入图片描述
删除器可以是一切可调用对象,只要里面实现了对应的删除功能就可以了。因为lambda表达式使用起来方便一点,所以就直接用了。

但是,对于这种情况比较常用,c++特意特化出一个版本:
在这里插入图片描述
但是要说明的是,这个用法是在c++17后才特化出来的。如果编译器没那么新是用不了的。

模拟实现定制删除器

接下来我们就来模拟一下库中的shared_ptr的方式,实现一个带有删除器的构造:

template<class D>
shared_ptr(T* ptr, D del):_ptr(ptr),_pcount(new int(1))
{}

我们现在加入了一个模板参数D,代表定制的删除器。但是这里有一个问题。删除器外界传进来是很简单,但是问题是,怎么接收呢?

这个模板参数D是这个构造函数专用的。不是在声名shared_ptr这个类的时候就传入进来的。因为shared_ptr只有一个模板参数T,代表指向的资源的数据类型。

也就是说,当前没办法接收到外面的删除器啊。因为模板参数D又不能放在成员变量。当然可以把模板参数D放在类模板声名的时候。但是库里面不是这么做的,我们尽量还是模拟库里面的实现方式。这该怎么办?

标准库里面给出了一个非常天才的方式,因为所有的定制的删除器我们可以把它看成一个函数。它的返回值是void,参数是T*(代表指向资源的数据类型),这就好办了。可以使用包装器啊,包装器的类型区分就是看返回值和参数来确定的:

namespace myspace {template<class T>class shared_ptr {public:shared_ptr<T>() {_ptr = nullptr;_pcount = new int(1);}template<class D>shared_ptr(T* ptr, D del):_ptr(ptr),_pcount(new int(1)),_del(del){}explicit shared_ptr(T* ptr):_ptr(ptr),_pcount(new int(1)){}shared_ptr(const shared_ptr<T>& sp) :_ptr(sp._ptr),_pcount(sp._pcount){++(*_pcount);}shared_ptr<T>& operator=(const shared_ptr<T>& sp) {//自己给自己赋值//1.  sp1 = sp1//2.  sp1 和 sp2 共管一份资源if (_ptr == sp._ptr) {if (--(*_pcount) == 0) {_del(_ptr);delete _pcount;}_ptr = sp._ptr;_pcount = sp._pcount;++(*_pcount);}return *this;}~shared_ptr() {if (--(*_pcount) == 0) {_del(_ptr);delete _pcount;}}T& operator*() {return *_ptr;}T* operator->() {return _ptr;}private:T* _ptr;//指向资源int* _pcount;//指向引用计数变量function<void(T*)> _del = []() {delete _del; };//默认是删除的是delete 如果需要delete[] 就特化或者传入对应删除器};
}

在这里就直接使用function这个包装器把删除器给放在成员变量这里了。只需要使用模板参数T就可以了,这就解决了这个问题。而且这里默认这个删除器是delete方式的。这样对于shared_ptr指向的是一个资源的时候就不需要传删除器了。

然后将对应的删除逻辑修改一下,对于资源的释放就是用定制删除器就好了。

上面赋值重载和析构有一部分代码是有些重复的,可以考虑合并一下。但是这里我就不进行合并了,因为主要还是理解逻辑为主。也可以针对于shared_ptr<T[]>的模式,像标准库那样特化出一个版本。在这里我也不进行演示了。

我们来看一下,自行实现的shared_ptr是否能够正常使用:
在这里插入图片描述
使用前面定义过的Date日期类进行验证,发现是正确的。

std::unique_ptr和std::shared_ptr删除器使用的区别

还有一些细节需要注意一下:
就是在标准库里面的unique_ptr和shared_ptr使用删除器是不一样的。

在这里插入图片描述
对于unique_ptr来说,它的定制删除器是在类声名的时候就得传进去了,需要我们自行显示实例化,将对应的删除器的实例对象/匿名对象传给模板参数D。

在这里插入图片描述
但是shared_ptr在声名的时候只有一个模板参数T。这是有很大的差别的。

对于shared_ptr的删除器,是需要我们调用构造的时候,将可调用对象作为函数的参数传入的。这是很方便的,我们可以传一切的可调用对象,函数指针,仿函数类,lambda对象,甚至是包装器包装的可调用对象。因为对于shared_ptr来说,那个模板参数D是函数模板的参数。

但是unique_ptr就需要特别注意,直接给模板参数传一个仿函数是可以的:

struct ManyDateDelete{void operator()(Date* pdate) {delete[] pdate;}
};int main() {Date* PD = new Date[10];std::unique_ptr<Date, ManyDateDelete> up1(PD);return 0;
}

我们来看看输出结果:
在这里插入图片描述
仿函数是可以正常的使用的。

但是使用其他的就不可以了:
在这里插入图片描述
如果直接传入函数指针、lambda对象或是包装器,会发现报错了。编译器会告诉我们传入的这些都不是有效的模板参数类型。模板参数这里智能接收到仿函数。

其实比较好理解,因为仿函数本质是一个类,是有着具体的类型名称的。但是像其它的可调用对象,它们的类型比较复杂,特别是lambda对象。所以编译器可能认不出来。

如果想要传入除了仿函数以外的可调用对象,就需要使用关键字decltype,它的作用是把一个数据的对应的类型给取出来:
在这里插入图片描述
但是这样还是要报错。因为unique_ptr有两个方式进行实例化。的区别就是是否有默认的定制的删除器。如果要自己传删除器的那个是没有进行默认的删除器的。删除器依靠外界传进来调用。但是调用构造的时候,又发现参数部分需要传入删除器。

所以最后要真的使用其他类型的可调用对象给unique_ptr作为删除器,不仅在实例化类对象的时候就要把可调用对象用decltype取出数据类型传给模板参数D,还要把这个可调用对象传给构造函数的参数部分:
在这里插入图片描述
但是经过实验,对于函数指针,使用decltype进行去类型再来使用是报错的,这是需要注意的。这里使用是很复杂的。

所以对此建议是,如果要使用的unique_ptr的时候,传删除器就直接使用仿函数就好了。不用考虑那么多。使用shared_ptr就随意了,但是明显是使用lambda对象是更方便的。

shared_ptr的线程安全问题

这个部分就不进行讲述了。这个涉及到线程得知识。当前是讲不清楚的,这个部分将在后面学习了线程知识后再回过头来进行讲解。当前知道有这么个问题就好。

C++11和boost中智能指针的关系

Boost库是为C++语言标准库提供扩展的一些C++程序库的总称,Boost社区建立的初衷之一就是为C++的标准化工作提供可供参考的实现,Boost社区的发起人Dawes本人就是C++标准委员会的成员之一。在Boost库的开发中,Boost社区也在这个方向上取得了丰硕的成果,C++11及之后的新语法和库有很多都是从Boost中来的。
C++ 98 中产生了第⼀个智能指针auto_ptr。
C++ boost给出了更实⽤的scoped_ptr/scoped_array和shared_ptr/shared_array和weak_ptr等.
C++ TR1,引入了shared_ptr等,不过注意的是TR1并不是标准版。
C++ 11,引入了unique_ptr和shared_ptr和weak_ptr。需要注意的是unique_ptr对应boost的
scoped_ptr。并且这些智能指针的实现原理是参考boost中的实现的。

像是右值引用和移动语义其实也是从boost库里面设计出来的。当然c++委员会开发的时候也没有进行照抄,而是将boost库里面设计的比较好的东西拿出来进行改进。目前来说还是取得了比较好的效果的。

内存泄漏

什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存,⼀般是忘记释放或者发生异常释放程序未能执行导致的。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,造成了内存的浪费。

内存泄漏的危害:普通程序运行一会就结束了出现内存泄漏问题也不大,进程正常结束,页表的映射关系解除,物理内存也可以释放。长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务、长时间运行的客户端等等,不断出现内存泄漏会导致可用内存不断变少,各种功能响应越来越慢,最终卡死。

如何避免内存泄漏

一般来讲大致分为两种方式:
1.使用第三方工具进行内存泄漏的检测,但是需要考虑到价格成本问题。有些第三方工具非常昂贵。而且有些工具测得不准,这是需要进行考量的。
2.正确的使用RAII思想进行内存管理,对于内存的申请释放要额外注意。

工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
尽量使用智能指针来管理资源,如果自己场景比较特殊,采用RAII思想自己造个轮子管理。定期使用内存泄漏工具检测,尤其是每次项目快上线前,不过有些工具不够靠谱,或者是收费。
总结一下:内存泄漏非常常见,解决方案分为两种:
1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。

但是一般来讲,还是需要我们养好良好的编程习惯,在内存相关问题上需要做到谨慎又谨慎。因为内存问题一旦引发就很容易导致重大的错误。

相关文章:

  • 中建海龙携MiC技术亮相双博会 引领模块化建筑新潮流
  • 【监控】Blackbox Exporter 黑盒监控
  • 第12次08: 省市县区三级联动收货地址
  • 华为OD机试真题——区间交集(2025B卷:200分)Java/python/JavaScript/C/C++/GO最佳实现
  • 两个Ubuntu机器(内网)免密登录设置
  • 嵌入式学习Day28
  • 移动端H5拍照直传不落地方案
  • 2025.5.26 关于后续更新内容的通知
  • 语音识别技术在人工智能中的应用
  • 【C++进阶篇】初识哈希
  • 公共字段自动填充功能实现【springboot】
  • USB设备状态
  • js-day2
  • docker-compose 环境下备份数据库
  • [灵龙AI API] AI生成视频API:文生视频 – 第2篇
  • 爬虫IP代理效率优化:策略解析与实战案例
  • Rk3568驱动开发_设备树点亮LED_11
  • 剑指offer11_矩阵中的路径
  • AI时代新词-零样本学习(Zero-Shot Learning):AI的未来趋势
  • 4 通道1250MSPS‐16bit DAC 回放板
  • wordpress又拍云本地备份/seo品牌推广方法
  • python做网站难么/91手机用哪个浏览器
  • 手机免费h5制作软件/天津优化加盟
  • 青岛开发区网站建设多少钱/南昌seo全网营销
  • 如何做微网站平台/百度收录提交入口网址是什么
  • 制作公司网站应该考虑什么/如何推广自己产品