C++:智能指针

简介: C++:智能指针

内存泄漏

内存泄漏是指程序在动态分配内存后,忘记或无法释放已经不再使用的内存,从而导致系统内存资源被逐渐耗尽的问题。这种情况下,即使程序本身并没有出现逻辑错误,也会因为内存泄漏而导致程序运行时间越来越长,甚至最终崩溃。

案例分析:

下面是一个典型的 C++ 内存泄漏案例:

while (true)
{
    int* p = new int(10);

    // 这里忘记释放内存,导致内存泄漏
}

在这个程序中,我们在一个无限循环中不断分配新的内存块。然而,我们忘记在循环体内释放这些内存块,导致程序运行时内存占用会越来越大,直到最终耗尽系统内存,程序崩溃。

这种情况下,即使程序本身没有逻辑错误,也会因为内存泄漏而导致严重的问题。

内存泄漏的危害:

  1. 系统内存资源被逐渐耗尽:内存泄漏会导致程序运行时内存占用越来越大,直到最终耗尽系统内存。这会严重影响程序的性能和稳定性。
  2. 程序可能会崩溃:内存耗尽后,程序会尝试访问非法内存地址,从而导致程序崩溃。
  3. 资源浪费:即使程序没有崩溃,内存泄漏也会导致大量内存资源被无谓地占用,造成资源浪费。

内存泄漏是 C++ 程序中一个非常常见的问题,如果不能及时发现并修复,会对程序的性能、稳定性和安全性造成严重影响。

RAII机制就是一种自动管理资源的机制,其可以帮助程序员自动释放资源,来避免内存泄漏,C++中,智能指针就是基于RAII产生的。


RAII

RAII (Resource Acquisition Is Initialization) 是 C++ 中一种非常重要的内存管理机制,它可以帮助我们有效地管理资源,避免内存泄漏等问题。

RAII 的核心思想是:

  1. 将资源的分配和释放绑定在对象的生命周期上。
  2. 在对象构造时获取资源,在对象析构时释放资源。

以下示例就是一个基本的RAII

template <class T>
class smartPtr
{
public:
    smartPtr(T* ptr)
        :_ptr(ptr)
    {}

    ~smartPtr()
    {
        delete _ptr;
    }

private:
    T* _ptr;
};

int main()
{
    smartPtr<int> ptr1 = new int(5);
    smartPtr<double> ptr2 = new double(3.14);
    smartPtr<vector<int>> ptr3 = new vector<int>(10);

    return 0;
}

在上述示例中,smartPtr 类负责管理资源的获取和释放。在 main 函数中,我们创建了三个 smartPtr 对象 ptr1ptr2ptr3

当这些对象进入作用域时,构造函数会被调用,将smatrPtr_ptr成员初始化为对应动态内存的指针。

当三个对象离开作用域时,析构函数会被自动调用,自动delete _ptr释放资源。

通过 RAII 机制,我们可以确保资源一定会被正确释放,避免了手动释放资源时忘记的问题。这种做法也使得代码更加简洁易读,并且可以实现异常安全性。

这样做的好处是:

  1. 确保资源一定会被正确释放,避免了手动释放资源时忘记的问题。
  2. 资源的获取和释放过程被封装在对象的构造和析构函数中,使得代码更加简洁易读。
  3. 可以利用 RAII 机制实现异常安全性,即使程序抛出异常,资源也能被正确释放。

而以上案例,就是C++智能指针最根本的原理,C++一共提供了四种智能指针:auto_ptrunique_ptrshared_ptrweak_ptr


智能指针

讲解这四种智能指针之前,我们先看看我刚刚案例中的smartPtr存在的问题:

template <class T>
class smartPtr
{
public:
    smartPtr(T* ptr)
        :_ptr(ptr)
    {}

    ~smartPtr()
    {
        delete _ptr;
    }

private:
    T* _ptr;
};

int main()
{
    smartPtr<int> ptr1 = new int(5);
    smartPtr<int> ptr2 = ptr1;

    return 0;
}

在main函数中,先构造了ptr1,指向一个int类型的动态内存。随后拿ptr1拷贝构造出了ptr2,此时ptr1和ptr2都指向这个int的动态内存。那么此时就要面临一个问题:当ptr1和ptr2出了作用域,那么两个对象都会调用析构函数,导致同一块内存被delete两次!这会直接导致进程崩溃,C++的智能指针需要解决这个问题。

另外的,智能指针本质上是一个类,不自带*->等操作,所以要对操作符进行重载:

T operator*()
{
    return *_ptr;
}

T* operator->()
{
    return _ptr;
}

*->等操作本质都是在对_ptr做操作,至于为什么要这样操作,可见博客[C++:迭代器的封装思想]。


接下来我们正式讲解C++自带的各种智能指针:

C++的智能指针,包含在<memory>头文件中

讲解以下四个智能指针时,默认包含了<memory>头文件。


auto_ptr

auto_ptr是C++标准中,最早出现的智能指针,属于C++98

构造函数

auto_ptr自带一个通过指针来进行初始化的构造函数,但是其被explicit修饰,这说明不允许进行类型转换:

auto_ptr<int> ptr1(new int(5));//正确
auto_ptr<int> ptr2 = new int(10);//错误

auto_ptr只允许通过第一种方式,直接构造,第二种方式的本质是类型转换,从int*转为auto_ptr<int>,只要有对应类型的构造函数那么C++就可以支持这个类型转换,但是如果构造函数被explicit修饰,这个类型转换功能就会被禁止,而auto_ptr就被禁止了。


其实,C++的四种智能指针,都不允许通过原生指针的类型转化来构造,也就是四种智能指针都只能通过小括号来初始化。

不过拷贝构造是允许的:

auto_ptr<int> ptr1(new int(5));

auto_ptr<int> ptr3(ptr1);
auto_ptr<int> ptr2 = ptr1;

后两种拷贝方式,都是正确的,因为拷贝构造没有被explicit修饰。

提到拷贝,我们刚讲到RAII中,如果多个类指向同一块空间,会导致资源重复释放的问题,那么auto_ptr是如何解决的呢?

auto_ptr发生拷贝,原先的auto_ptr会变成空指针

为了方便观察,我们需要得知指针指向的地址:auto_ptr可以get接口看到内部存储的地址

示例:

auto_ptr<int> ptr1(new int(5));
auto_ptr<int> ptr2 = ptr1;

cout << ptr1.get() << endl;
cout << ptr2.get() << endl;

输出结果:

0000000000000000
00000294B7187000

可以看到,经过拷贝后ptr1内部的值变为了0000000000000000,也就是空指针,原先指向动态内存的指针交给了ptr2

如果是deletedelete空指针的结果是啥也不干,因此可以保证内存的安全。

不过这种解决方案过于简单粗暴了,后续在C++11又出了三个智能指针,以更加优秀的方式来处理这个问题。


unique_ptr

unique_ptr以一种非常简单粗暴的方式来处理智能指针的拷贝问题:

unique_ptr不允许拷贝

这确实很简单粗暴,从源头上解决问题,如果你确定你不需要对指针进行拷贝,可以考虑使用unique_ptr

相比于auto_ptrunique_ptr还有一个优化,那就是下标访问opertaor[]

示例:

auto_ptr<int> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
cout << ptr1[5] << endl;//错误

这个auto_ptr指向了一个通过new[]开辟的数组,原生指针是支持下标访问的,但是auto_ptr不支持,以上代码的第二行是错误的。

看到unique_ptr

unique_ptr<int> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
cout << ptr1[5] << endl;//错误

对于这种情况,unique_ptr也会报错,如果想要unique_ptr支持下标访问,要用更加特殊的语法:

unique_ptr<int[]> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
cout << ptr1[5] << endl;//正确

如果指向的动态内存是数组的形式,一次性开辟的,模板参数要写为type[]的形式,来告诉unique_ptr该指针维护的动态内存,是以数组的形式开辟的。以上示例中,模板参数为int[],那么此时就可以通过下标来访问这个动态内存了。


shared_ptr

shared_ptr是对拷贝处理的最好的一个智能指针,其机制也比较复杂,实际中更常用这一种智能指针。

shared_ptr通过引用计数来处理多个指针指向同一块内存的问题

对于同一块内存,有多少个shared_ptr指向该内存,那么count就是多少:

上图中,动态内存被三个shared_ptr指向,那么count = 3,现在ptr3离开作用域:

ptr3离开作用域后,count = 2,说明此时还有两个指针指向这个动态内存,那么ptr3的析构函数不会释放掉动态内存

ptr2离开作用域:

此时count = 1,整个动态内存只有一个ptr1指向,只要ptr1离开作用域,那么count = 0,此时ptr1的析构函数就会把动态内存释放掉。


也就是说shared_ptr通过记录有几个指针指向动态内存,来决定析构的时候是否释放内存,将动态内存的释放交给最后一个shared_ptr来完成,既能确保每一个shared_ptr都是有效的,又可以确保资源不会被重复释放。

要注意的是:不是所有shared_ptr都共用一个count,对于每一块动态内存,都有独立的count,互不影响

上图中,ptr1ptr2ptr3指向Aptr4ptr5指向B

此时ptr1ptr2ptr3共用一个countptr4ptr5共用一个count

unique_ptr一样,shared_ptr也可以支持下标访问:

shared_ptr<int[]> ptr1(new int[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
cout << ptr1[3] << endl;
循环引用

shared_ptr会存在一个循环引用的问题。

现在我们有如下链表节点:

class ListNode
{
public:
    ListNode(int val)
        : _prev(nullptr)
        , _next(nullptr)
        , _val(val)
    {}

    ListNode* _prev;
    ListNode* _next;
    int _val;
};

int main()
{
    ListNode* l1 = new ListNode(5);
    ListNode* l2 = new ListNode(10);

    l1->_next = l2;
    l2->_prev = l1;

    delete l1;
    delete l2;
    
    return 0;
}

以上代码中,l1l2的后一个节点,因此l1->_next = l2l2->_prev = l1,最后程序结束前,delete掉两个节点,这段代码是没问题的。

现在我们把以上代码中的原生指针,改为shared_ptr

class ListNode
{
public:
    ListNode(int val)
        : _prev(nullptr)
        , _next(nullptr)
        , _val(val)
    {}

    shared_ptr<ListNode> _prev;
    shared_ptr<ListNode> _next;
    int _val;
};

int main()
{
    shared_ptr<ListNode> l1(new ListNode(5));
    shared_ptr<ListNode> l2(new ListNode(10));

    l1->_next = l2;
    l2->_prev = l1;
    
    return 0;
}

请问这串代码有问题吗?

其实在以上代码中,就已经造成了内存泄漏问题,这就是典型的循环引用问题,我来解析一下:

一开始,通过两个shared_ptr指向了两个ListNode的动态内存:

此时两块内存都分别只被一个shared_ptr指向,count = 1

随后执行l1->_next = l2l2->_prev = l1

此时ListNode内部的指针互相指向,由于ListNode内部的指针也是shared_ptrcount都变成了2

随后l2离开作用域,此时count--

由于l1->_next依然指向橙色的内存,此时count = 1,不会释放内存。

随后l1离开作用域,count--

由于l2->_prev指向绿色区域(l2其实已经不存在了),count = 1,此时不释放内存。

现在陷入一个死循环:如果绿色区域要被释放,那么橙色的_prev就要先释放,但是橙色的_prev要释放,那绿色的_next要先释放,造成一个死循环,导致内存永远无法释放,内存泄漏。这就是循环引用问题。

如果在类的内部使用这种相互指向的shared_ptr,就很容易发生循环引用的问题。

这个问题在于,_prev和_next这两个成员存在的意义,在于互相访问资源,我们并不需要_prev和_next来完成资源的释放,所以_prev和_next完全没必要用shared_ptr,用一般的指针即可:

class ListNode
{
public:
    ListNode(int val)
        : _prev(nullptr)
        , _next(nullptr)
        , _val(val)
    {}

    ListNode* _prev;
    ListNode* _next;
    int _val;
};

但是现在存在一个问题:

l1->_next = l2;
l2->_prev = l1;

这两天语句是错误的,因为l2的类型是shared_ptr<ListNode>,而l1->_next的类型是ListNode*shared_ptr的类型转换是被禁止的,所以只能通过get接口:

l1->_next = l2.get();
l2->_prev = l1.get();

这样未免太不简洁,此时就需要我们的最后一个智能指针weak_ptr出场了。


weak_ptr

weak_ptr是一种不参与资源管理的智能指针,其只存在三种构造函数:

  1. 无参默认构造,此时weak_ptr初始化为空指针
  2. 拷贝构造,拷贝其它weak_ptr
  3. 通过shared_ptr初始化,此时shared_ptrweak_ptr指向同一块内存

shared_ptrweak_ptr指向同一块内存的时候,weak_ptr不会增加引用计数

weak_ptr离开作用域的时候,不会释放自己指向的资源,其只负责访问资源。特点在于可以通过shared_ptr初始化。

因此刚刚的循环引用可以被优化为:

class ListNode
{
public:
    ListNode(int val)
        : _val(val)
    {}

    weak_ptr<ListNode> _prev;
    weak_ptr<ListNode> _next;
    int _val;
};

要注意的是,weak_ptr不支持原生指针初始化,哪怕是nullptr也不可以,因此在ListNode的初始化列表中,删掉了_next_prev的初始化。

现在以下代码就完全合法了:

shared_ptr<ListNode> l1(new ListNode(5));
shared_ptr<ListNode> l2(new ListNode(10));

l1->_next = l2;
l2->_prev = l1;

weak_ptr可以通过shared_ptr初始化,因此可以直接将shared_ptr赋值给weak_ptr,又由于weak_ptr不参与计数,最后只要l1l2离开作用域,空间就会被正常释放。


deleter

对于智能指针,有时候需要用特殊的方式来对资源进行释放,比如文件指针:

shared_ptr<FILE> fp(fopen("test.txt", "w"));

对于指针fp,不能简单地delete pf,而是通过fclose(pf),此时我们就要用到自定义删除器deleter了。

对于shared_ptrunique_ptr,两者的deleter语法不太相同,此处分开讲解:

shared_ptr

shared_ptr的语法为:

shared_ptr<T> p(new T, deleter_function);

其中, deleter_function是一个满足删除器要求的可调用对象,包括函数指针仿函数lambda三种。

比如通过lambda来完成文件的fclose

shared_ptr<FILE> fp(fopen("test.txt", "w"), [](FILE* ptr) { fclose(ptr); });

通过仿函数:

struct deleteFile
{
    void operator()(FILE* ptr)
    {
        fclose(ptr);
    }
};

int main()
{
    shared_ptr<FILE> fp(fopen("test.txt", "w"), deleteFile());

    return 0;
}

也就是说,对于shared_ptr只需要把删除器的可调用对象,直接作为第二个参数传入即可


unique_ptr

unique_ptr的删除器语法比较别扭,要求在模板参数中传入可调用对象的类型

unique_ptr<T, 可调用对象类型> p(new T, 可调用对象);

同样的,可调用对象支持函数指针仿函数lambda三种。

以刚刚的关闭文件为例:

  1. 使用函数指针:
void deleteFunc(FILE* ptr)
{
    fclose(ptr);
}

int main()
{
    unique_ptr<FILE, void(*)(FILE*)> fp2(fopen("test.txt", "w"), deleteFunc);

    return 0;
}

该函数指针的类型为void(*)(FILE*),作为unique_ptr的第二个模板参数。

  1. 使用仿函数:
struct deleteFile
{
    void operator()(FILE* ptr)
    {
        fclose(ptr);
    }
};

int main()
{
    unique_ptr<FILE, deleteFile> fp(fopen("test.txt", "w"), deleteFile());

    return 0;
}

仿函数的类型是deleteFile,即类名,作为unique_ptr的第二个模板参数。

  1. 使用lambda表达式:
auto expression = [](FILE* ptr) { fclose(ptr); };
unique_ptr<FILE, decltype(expression)> fp(fopen("test.txt", "w"), expression);

这里, expression是一个lambda表达式,由于lambda的类型是随机的,只能通过decltype(expression)来检测类型,作为unique_ptr的第二个模板参数。

相关文章
|
5天前
|
C++ 数据格式
LabVIEW传递接收C/C++DLL指针
LabVIEW传递接收C/C++DLL指针
16 1
|
5天前
|
编译器 C++
C/C++杂谈——指针常量、常量指针
C/C++杂谈——指针常量、常量指针
9 0
|
5天前
|
C++ 编译器
|
5天前
|
存储 安全 C++
深入理解C++中的指针与引用
深入理解C++中的指针与引用
12 0
|
5天前
|
算法 C++
【C++入门到精通】智能指针 shared_ptr循环引用 | weak_ptr 简介及C++模拟实现 [ C++入门 ]
【C++入门到精通】智能指针 shared_ptr循环引用 | weak_ptr 简介及C++模拟实现 [ C++入门 ]
17 0
|
5天前
|
安全 算法 数据安全/隐私保护
【C++入门到精通】智能指针 shared_ptr 简介及C++模拟实现 [ C++入门 ]
【C++入门到精通】智能指针 shared_ptr 简介及C++模拟实现 [ C++入门 ]
14 0
|
5天前
|
存储 算法 安全
【C++入门到精通】智能指针 auto_ptr、unique_ptr简介及C++模拟实现 [ C++入门 ]
【C++入门到精通】智能指针 auto_ptr、unique_ptr简介及C++模拟实现 [ C++入门 ]
13 0
|
5天前
|
安全 算法 IDE
【C++入门到精通】智能指针 [ C++入门 ]
【C++入门到精通】智能指针 [ C++入门 ]
11 0
|
5天前
|
存储 编译器 C语言
【C++】类与对象【定义、访问限定符、this指针】
【C++】类与对象【定义、访问限定符、this指针】
6 1
|
5天前
|
C++
【C++】一文深入浅出带你参透库中的几种 [ 智能指针 ]及其背后实现原理(代码&图示)
【C++】一文深入浅出带你参透库中的几种 [ 智能指针 ]及其背后实现原理(代码&图示)