【C++】智能指针

2023-10-17 08:13:57 浏览数 (1)

一、异常的内存安全问题

我们在上一节异常中提到了 C 没有垃圾回收机制,资源需要自己手动管理;同时,异常会导致执行流乱跳;所以 C 异常非常容易导致诸如内存泄露这样的安全问题。我们以下面的程序为例:

代码语言:javascript复制
int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}

void Func()
{
	int* p1 = new int;
	int* p2 = new int;
	cout << div() << endl;
	delete p1;
	delete p2;
    cout << "release pointer" << endl;
}

int main()
{
	try {
		Func();
	}
	catch (exception& e) {
		cout << e.what() << endl;
	}
	catch (...) {
		cout << "Unknow Error" << endl;
	}

	return 0;
}

上面这段程序最可能发生内存泄漏的情况是 div 函数抛异常,导致程序直接跳转到 main 函数的 catch 语句处,p1 和 p2 指向的空间未被释放:

针对这种情况我们的做法是在 Func 中对 div 异常进行捕获后,将 p1 p2 释放,最后再将异常重新抛出,如下:

虽然这样可以达到目的,但是这样的代码显然很挫,最重要的是 new 也可能会抛异常;在上面的程序中,如果 p1 new 空间失败,此时不会发生内存泄露;但如果 p1 new 空间成功,而 p2 new 空间失败,那么 p1 就会发生内存泄露,此时我们就需要在 “int *p2 = new int” 语句这里再套一层 try catch 语句来释放 p1,那么如果再有 p3、p4 呢?为了缓解异常所引发的内存泄露问题,C 设计出了智能指针。


二、C 智能指针

1、智能指针的概念

智能指针本质上是一个类,这个类的成员函数根据其功能被分为两类:

  1. RAII:RAII (资源获得即初始化 – Resource Acquisition Is Initialization) 是一种利用对象生命周期来控制程序资源(如内 存、文件句柄、网络连接、互斥量等等)的简单技术;它的主要功能如下:
    • 在对象构造时获取资源,并且控制对资源的访问使之在对象的生命周期内始终保持有效
    • 在对象析构的时候释放资源

    这样,我们实际把管理一份资源的责任托管给了一个对象,这样做有如下好处:

    • 不需要显式地释放资源
    • 对象所需的资源在其生命期内始终保持有效

    简单来说,RAII 就是类的构造函数和析构函数,我们将申请到的资源通过构造函数托付给类的对象来管理,然后在类对象销毁调用析构函数时自动释放该资源,在构造和析构期间该资源可以始终保存有效。

  2. 支持指针的各种行为:一般我们申请一份资源都是为了使用该资源,所以我们需要能够通过类对象来管理资源,还需要通过类对象来对资源进行各种操作,即通过运算符重载来让类对象支持指针的各种行为 (* -> [])

下面是一个简单的智能指针示例:

代码语言:javascript复制
template<class T>
class SmartPtr
{
    public:
    //RAII
    SmartPtr(T* ptr = nullptr)
        : _ptr(ptr)
        {}

    ~SmartPtr()
    {
        delete _ptr;
        cout << "~SmartPtr " << _ptr << endl;
    }

    //支持指针的各种行为
    T& operator*()
    {
        return *_ptr;
    }

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

    T operator[](size_t pos)
    {
        return _ptr[pos];
    }

    private:
    T* _ptr;
};

如上,我们将 new 出来的资源直接交给类的局部对象,这样在类对象生命周期内该资源都有效,类对象销毁时该资源也会被自动释放,并且我们也可以像使用正常指针一样通过类对象对资源进行各种操作。以后,当 p1 p2 new 空间或者 div 函数抛异常时,由于异常发生会正常释放函数的栈空间,所以局部对象会被正常销毁,那么被局部对象管理的资源也就能够被正常释放了,从而很大程度上缓解了异常的内存泄露问题。

智能指针存在的问题

智能指针虽然能够很好的管理资源,但是智能指针的拷贝与赋值是一个很大的问题,它涉及到资源的管理权问题 – 由谁管理、由一个单独管理还是多个共同管理,我们下文学习到的几种智能指针都是围绕这个问题展开的。

2、智能指针发展史

C 中的第一个智能指针名为 auto_ptr,由 C 98 提供,但由于 auto_ptr 存在极大的缺陷,同时 C 98 的后一个大版本 – C 11 又发布的很晚,所以 C 标准委员会的部分成员发起并创建了 boost 库,其目的是为C 的标准化工作提供可供参考的实现,因此 boost 库又被称为 C 库的准标准库。boost 库中提供了另外的几种重要的智能指针 – scoped_ptr、shared_ptr 和 weak_ptr,它们都被 C 11 标准所借鉴,并发布了对应的标准版本 – unique_ptr、shared_ptr 与 weak_ptr,它们也是我们学习的重点。

3、auto_ptr

auto_ptr 是 C 中的第一个智能指针,它解决智能指针拷贝问题的方式是 管理权转移,即当用当前对象拷贝构造一个新对象时,会将当前对象管理的资源交给新对象,然后将自己的资源置空。auto_ptr 最大的问题是它会导致 对象悬空,即后面再使用当前对象时,会造成空指针解引用。

由于auto_ptr 非常危险,所以很多公司明确要求不能使用它,并且 C 11 也已经弃用了 auto_ptr,并使用 unique_ptr 来代替它。

下面是对 auto_ptr 的简单模拟实现:

代码语言:javascript复制
template<class T>
class auto_ptr
{
    public:
    //RAII
    auto_ptr(T* ptr = nullptr)
        :_ptr(ptr)
        {};

    ~auto_ptr()
    {
        delete _ptr;
        cout << "~auto_ptr " << _ptr << endl;
    }

    //拷贝构造 -- 资源管理权转移
    auto_ptr(auto_ptr<T>& ap)
        :_ptr(ap._ptr)
        {
            ap._ptr = nullptr;
        }

    //赋值重载 -- 先释放自己原来的资源,再进行资源管理权转移
    auto_ptr<T>& operator=(auto_ptr<T>& ap)
    {
        //检查自我赋值
        if (_ptr != ap._ptr)
        {
            this->~auto_ptr();
            _ptr = ap._ptr;
            ap._ptr = nullptr;
        }

        return *this;
    }

    //支持指针的各种行为
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    T operator[](size_t pos) { return _ptr[pos]; }

    T* get() { return _ptr; }

    private:
    T* _ptr;
};

4、unique_ptr

unique_ptr 是 C 11 提出的一种更安全的智能指针,它解决拷贝问题的方式是 直接不允许拷贝 – 防拷贝

下面是 unique_ptr 的简单模拟实现:

代码语言:javascript复制
template<class T>
class unique_ptr
{
    public:
    //RAII
    unique_ptr(T* ptr = nullptr)
        :_ptr(ptr)
        {};

    ~unique_ptr()
    {
        delete _ptr;
        cout << "~unique_ptr " << _ptr << endl;
    }

    //防拷贝
    unique_ptr(const unique_ptr<T>& ap) = delete;
    unique_ptr<T>& operator=(const unique_ptr<T>& ap) = delete;

    //支持指针的各种行为
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    T operator[](size_t pos) { return _ptr[pos]; }

    T* get() { return _ptr; }

    private:
    T* _ptr;
};

5、shared_ptr (重点)

shared_ptr 是 C 中被使用的最多的一个智能指针,它通过引用计数来解决智能指针的拷贝问题,使得一份资源可以被多个类对象共同管理;同时,shared_ptr 的引用计数是线程安全的。

5.1 shared_ptr 的引用计数问题

前面我们提到,auto_ptr 通过转移资源管理权的方式来解决拷贝问题,unique_ptr 通过防拷贝的方式来解决拷贝问题;shared_ptr 则是通过引用计数的方式来解决拷贝问题,即用当前对象拷贝一个新的对象时,我们让新对象与当前对象共同来管理这份资源,并以 引用计数的方式来标识这份资源被多少个对象所管理;当对象销毁时,引用计数–,但是资源并不一定销毁,而只有当引用计数为0时资源才真正销毁。

对于如何设计引用计数,我们有如下几种方案:

  1. 在类中增加一个普通的成员变量 count 作为引用计数 – 这种做法不行,因为每个对象都有自己独立的成员变量,因此当前对象的引用计数增加并不会影响 也指向当前资源的其他对象 中 count 的值。
  2. 在类中增加一个静态的成员变量 count – 这样也不行,因为静态变量属于整个类,也属于类的所有对象,这样虽然管理同一份资源的对象的引用计数是同步变化的,但是我们不能创建新的类对象去管理其他资源了,这样会导致所有类对象的引用计数都增加。
  3. 正确的做法是在类中增加一个指针类型的成员变量,该指针指向一块堆空间,空间中保存的是当前资源对应的引用计数,相当于类对象要管理的资源相比之前多了一个 count – 这样对于管理不同资源的类对象来说,二者的引用计数不会相互影响;对于管理同一份资源的类来说,引用计数的变化是同步的 (因为引用计数也是资源的一部分)。

下面是 shared_ptr 的初步实现:

代码语言:javascript复制
template<class T>
class shared_ptr
{
    public:
    //RAII
    //引用计数指向堆上的一块空间
    shared_ptr(T* ptr = nullptr)
        : _ptr(ptr), _pcount(new int(1))
        {}

    ~shared_ptr()
    {
        //引用计数为0才进行析构
        (*_pcount)--;
        if (*_pcount == 0)
        {
            delete _ptr;
            delete _pcount;
            cout << "~shared_ptr" << _ptr << " " << _pcount << endl;
        }
    }

    //拷贝构造 -- 共享资源(  引用计数)
    shared_ptr(const shared_ptr<T>& sp)
        :_ptr(sp._ptr), _pcount(sp._pcount)
        {
            (*_pcount)  ;
        }

    //赋值重载 -- 先释放自身资源(),再共享资源
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        //判断自我赋值 -- 判断资源地址是否相同,而不是对象地址
        if (_ptr != sp._ptr)
        {
            //调用析构并不一定释放资源,因为资源可能由多个对象管理,析构函数里面会进行判断,但一定会--引用计数
            this->~shared_ptr();
            _ptr = sp._ptr;
            _pcount = sp._pcount;
            (*_pcount)  ;
        }

        return *this;
    }

    //支持指针的各种行为
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    T operator[](size_t pos) { return _ptr[pos]; }

    int use_count() { return *_pcount; }
    T* get() const { return _ptr; }

    private:
    T* _ptr;
    int* _pcount;  //引用计数
};
5.2 shared_ptr 的线程安全问题

我们上面的模拟实现的 shared_ptr 在多线程环境下可能会发生线程安全问题,而库中的 shared_ptr 则不会,如下:

可以看到,我们自己实现的 shared_ptr 在多线程环境下运行后引用计数的值是错误且随机的 (正确应该为0),而库中的 shared_ptr 则是正确的,其原因如下:

  • 我们使用当前对象拷贝构造一个新的对象来共同管理当前资源时,资源的引用计数会 ,当局部对象出作用域销毁时引用计数会–;但是语言级别的 以及–的操作都 非原子 的,因为它们都对应着多条汇编指令;而在多线程环境下,可能一条语句只执行了部分汇编指令该线程就被挂起了,而此时其他线程再来对 count 进行操作就可能会引发线程安全问题;
  • 比如当前count为10,线程A要 count,但是线程A在未将 后的结果即11写入到内存覆盖掉原来的count之前,它就被阻塞了;后面假设又来了50个线程都对count进行了 操作,那么count的值变为了60;但是现在,线程A被重新调度了,那么它会继续完成它之前未做完的事情 (临时数据保存在线程的上下文中),即将count的值覆盖为11,此时count的值就错乱了,也就是发生了线程安全问题;count–同理。
  • 而库中的 shared_ptr 的引用计数之所以是线程安全的,是因为它使用了 互斥锁 对引用计数的 和 – 操作进行了保护,即通过加锁使得多线程只能串行的修改引用计数的值,而不能并行或并发的修改引用计数。

注:加锁和解锁的过程是原子的 (有特殊的一条汇编指令来完成锁状态的修改),所以锁本身是线程安全的,我们不需要担心锁的安全性。

我们也可以使用互斥锁将模拟实现的 shared_ptr 改造为引用计数线程安全版本,需要注意的是:

  • 和引用计数一样,使用互斥锁的方式也是在类中增加一个互斥锁指针类型的成员变量,该变量指向堆上的一块空间;因为我们要保证的是同一份资源中的同一个引用计数只能被多线程串行访问,而不同资源中的两个无关引用计数是可以被并发/并行操作的。

下面是线程安全版本的 shared_ptr 的模拟实现:

代码语言:javascript复制
template<class T>
class shared_ptr
{
    public:
    //RAII
    //引用计数指向堆上的一块空间
    shared_ptr(T* ptr = nullptr)
        : _ptr(ptr), _pcount(new int(1)), _pmtx(new mutex)
        {}

    ~shared_ptr()
    {
        //引用计数为0才进行析构
        //使用互斥锁来保证引用计数只能被线程串行访问
        _pmtx->lock();
        (*_pcount)--;
        _pmtx->unlock();

        if (*_pcount == 0)
        {
            delete _ptr;
            delete _pcount;
            delete _pmtx;
            cout << "~shared_ptr" << _ptr << " " << _pcount << endl;
        }
    }

    //拷贝构造 -- 共享资源(  引用计数)
    shared_ptr(const shared_ptr<T>& sp)
        :_ptr(sp._ptr), _pcount(sp._pcount), _pmtx(sp._pmtx)
        {
            _pmtx->lock();
            (*_pcount)  ;
            _pmtx->unlock();
        }

    //赋值重载 -- 先释放自身资源(),再共享资源
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        //判断自我赋值 -- 判断资源地址是否相同,而不是对象地址
        if (_ptr != sp._ptr)
        {
            //调用析构并不一定释放资源,因为资源可能由多个对象管理,析构函数里面会进行判断,但一定会--引用计数
            this->~shared_ptr();
            _ptr = sp._ptr;
            _pcount = sp._pcount;

            _pmtx->lock();
            (*_pcount)  ;
            _pmtx->unlock();
        }

        return *this;
    }

    //支持指针的各种行为
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    T operator[](size_t pos) { return _ptr[pos]; }

    int use_count() { return *_pcount; }
    T* get() const { return _ptr; }

    private:
    T* _ptr;
    int* _pcount;  //引用计数
    mutex* _pmtx;  //互斥锁
};

需要注意的是,shared_ptr 的引用计数是安全的,因为有互斥锁的包含,但是 shared_ptr 的数据资源是不安全的,因为对堆上的数据资源的访问是人处理的,shared_ptr 无法对其进行保护,如下:

代码语言:javascript复制
struct Date {
	int year = 0;
	int month = 0;
	int day = 0;
};

//测试shared_ptr的线程安全问题
void shared_ptr_test2()
{
	thj::shared_ptr<Date> sp = new Date;

	int N = 5000;

	thread t1([&]()
		{
			for (int i = 0; i < N; i  )
			{
				sp->year  ;
				sp->month  ;
				sp->day  ;
			}
		});

	thread t2([&]()
		{
			for (int i = 0; i < N; i  )
			{
				sp->year  ;
				sp->month  ;
				sp->day  ;
			}
		});

	t1.join();
	t2.join();

	cout << sp->year << endl;
	cout << sp->month << endl;
	cout << sp->day << endl;
}

大家可以用 std 的 shared_ptr 进行测试,结果也是错误的;所以,对于数据资源的安全我们需要自己手动加锁对其进行保护,如下:

代码语言:javascript复制
void shared_ptr_test2()
{
	mutex mtx;
	thj::shared_ptr<Date> sp = new Date;

	int N = 5000;

	thread t1([&]()
		{
			for (int i = 0; i < N; i  )
			{
				//加锁,只运行线程串行访问公共数据,访问完毕后再解锁
				mtx.lock();
				sp->year  ;
				sp->month  ;
				sp->day  ;
				mtx.unlock();
			}
		});

	thread t2([&]()
		{
			for (int i = 0; i < N; i  )
			{
				mtx.lock();
				sp->year  ;
				sp->month  ;
				sp->day  ;
				mtx.unlock();
			}
		});

	t1.join();
	t2.join();

	cout << sp->year << endl;
	cout << sp->month << endl;
	cout << sp->day << endl;
}
5.3 shared_ptr 的循环引用问题

shared_ptr 在绝大多数情况下都可以说表现非常完美,但是它在一些特殊场景下还是存在缺陷,如下:

在没有智能指针时对于 new 出来的节点我们需要手动 delete,但是有了智能指针后,我们就可以节点资源交给智能指针对象来管理:

但是我们发现,当我们让 n1 的 next 指向 n2,n2 的 prev 指向 n1 后,程序发生了内存泄露:

这是因为在当前场景下发生了 shared_ptr 的循环引用 (假设将 n1 管理的资源称为资源1,将 n2 管理的资源称为资源2):

  1. 我们 new 出来两个节点分别赋值给交给智能指针对象 n1 和 n2 管理,此时它们的引用计数都为1;
  2. 然后我们让 n1->_next = n2,由于n1->_next 也是智能指针类型,所以资源2现在由两个对象管理 – n2 n1->_next;n2->_prev = n1 同理,此时资源1也由两个对象管理 – n1 n2->_prev;
  3. 现在程序执行完毕,n1、n2 自动销毁,则资源1和资源2的引用计数分别减为1,而当引用计数为0时资源才会释放,所以发生内存泄露。图示如下:

注:上面示例中,资源其实就是 ListNode 类型的一个节点,而节点 (资源) 释放,节点里面的变量 _prev 和 _next 才会释放;而要让节点释放,节点的引用计数必须先减为0,所以就出现了下面这种情况:

  • 节点1(资源1)要释放就必须让 n2 里面的 _prev 不再指向自己,并且只有节点1释放后节点2的引用计数才能减到0;
  • 节点2(资源2)要释放就必须让 n1 里面的 _next 不再指向自己,并且只有节点2释放后节点1的引用计数才能减到0;
  • 最后,只有当节点的引用计数变为0时节点才会释放。

所以,节点1和节点2就会相互等待对方释放,从而满足自身释放的条件,这就是传说中的循环引用 (这里和死锁有点类型,大家可以和死锁发生的四个条件对比一下)。

为了弥补 shared_ptr 的缺陷,即解决 shared_ptr 存在的循环引用问题,C 设计出了 weak_ptr

6、weak_ptr

weak_ptr 是为了解决 shared_ptr 循环引用问题而专门设计出来的一款智能指针,weak_ptr 解决循环引用的方式很简单 – 不增加资源的引用计数;所以它需要程序员自己在合适的地方来使用它。

weak_ptr 的简单模拟实现如下:

代码语言:javascript复制
template<class T>
class weak_ptr
{
    public:
    //RAII
    weak_ptr(T* ptr = nullptr)
        :_ptr(ptr)
        {};

    ~weak_ptr() {}  //析构函数不释放资源

    //不增加引用计数
    weak_ptr(const shared_ptr<T>& sp)
        :_ptr(sp.get())
        {}

    weak_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        if (_ptr != sp.get())
        {
            _ptr = sp.get();
        }
        return *this;
    }

    //支持指针的各种行为
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    T operator[](size_t pos) { return _ptr[pos]; }

    private:
    T* _ptr;
};

三、定制删除器

前面我们都是一次申请一份资源,即 new int / new Date,所以我们析构时可以直接使用 delete;但如果是一次申请多份资源,比如 new int[10] / new vector<int>[10],此时我们释放时就需要使用 delete[] 了,否则程序就会崩溃:

C 中通过定制删除器来解决 delete 和 delete[] 的问题,定制删除器本质上是一个仿函数/函数对象,它的思想和我们之前学习的 Less/Great/HashFunc 等仿函数的思想是一样的。

C 标准库中定义的 shared_ptr 允许我们将函数对象作为构造函数的参数进行传递,这是因为 shared_ptr 必须通过引用计数的方式来管理所指向的资源,对于一个 shared_ptr 对象来说,它所管理的资源是由其内部包含的指针 (ptr && pcount && pmutex) 和对应的删除器共同负责管理的,当最后一个 shared_ptr 对象被销毁时,就会调用删除器来释放所指向的内存。所以 shared_ptr 底层实现中是有一个类来专门管理引用计数和删除器的。

shared_ptr 的这种将删除器作为构造函数参数进行传递的方式让我们可以搭配 lambda 表达式进行使用,非常方便:

但是对于其他不需要引用计数的智能指针来说,就只能通过模板参数来传递仿函数进行定制删除了,只是模板参数只能传递类型,而不能传递函数对象,所以就无法配合 lambda 表达式或者是包装器对象进行使用。

当然,我们也可以对我们模拟实现的 shared_ptr 进行改造,不过为了简单起见,这里我们就将其改造为支持通过模板参数来传递仿函数进行定制删除的版本,而不再实现支持通过构造函数传递函数对象进行定制删除的版本了。如下:

代码语言:javascript复制
//默认使用delete进行释放
template<class T>
struct default_delete {
    void operator()(T* ptr)
    {
        delete ptr;
    }
};

template<class T, class D = default_delete<T>>
class shared_ptr
{
    public:
    //RAII
    //引用计数指向堆上的一块空间
    shared_ptr(T* ptr = nullptr)
        : _ptr(ptr), _pcount(new int(1)), _pmtx(new mutex)
        {}

    ~shared_ptr()
    {
        //引用计数为0才进行析构
        //使用互斥锁来保证引用计数只能被线程串行访问
        _pmtx->lock();
        (*_pcount)--;
        _pmtx->unlock();

        if (*_pcount == 0)
        {
            //delete _ptr;
            //delete _pcount;

            D del;
            del(_ptr);
            delete _pcount;
            delete _pmtx;

            cout << "~shared_ptr" << _ptr << " " << _pcount << endl;
        }
    }

    //拷贝构造 -- 共享资源(  引用计数)
    shared_ptr(const shared_ptr<T>& sp)
        :_ptr(sp._ptr), _pcount(sp._pcount), _pmtx(sp._pmtx)
        {
            _pmtx->lock();
            (*_pcount)  ;
            _pmtx->unlock();
        }

    //赋值重载 -- 先释放自身资源(),再共享资源
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        //判断自我赋值 -- 判断资源地址是否相同,而不是对象地址
        if (_ptr != sp._ptr)
        {
            //调用析构并不一定释放资源,因为资源可能由多个对象管理,析构函数里面会进行判断,但一定会--引用计数
            this->~shared_ptr();
            _ptr = sp._ptr;
            _pcount = sp._pcount;

            _pmtx->lock();
            (*_pcount)  ;
            _pmtx->unlock();
        }

        return *this;
    }

    //支持指针的各种行为
    T& operator*() { return *_ptr; }
    T* operator->() { return _ptr; }
    T operator[](size_t pos) { return _ptr[pos]; }

    int use_count() { return *_pcount; }
    T* get() const { return _ptr; }

    private:
    T* _ptr;
    int* _pcount;  //引用计数
    mutex* _pmtx;  //互斥锁
};

0 人点赞