欢迎来到博主的专栏:c++编程
博主ID:代码小豪
文章目录
- 智能指针
- 什么是智能指针?
- auto_ptr
- unique_ptr
- share_ptr
- shared_ptr缺陷
- weak_ptr
智能指针
什么是智能指针?
智能指针是c++中关于动态内存管理的重要一环,在智能指针之前,管理动态内存的方式都是使用new、delete关键字。那么只能指针的作用是什么呢?
首先,智能指针是一个具有指针特性的对象,而非指针,其主要功能是利用对象的生命周期来管理资源。该技术有个专业名词,为RAII。
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内
存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在
对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做
法有两大好处:
不需要显式地释放资源。
采用这种方式,对象所需的资源在其生命期内始终保持有效。
简而言之,该技术的原理是利用对象的生命周期结束时调用析构函数的特性,在析构函数中将资源进行销毁。
到这也许就有人感到疑问了,我自己new出来的资源,自己delete释放不就好了吗?何必搞出一个指针呢?这个方法在以前确实是即高效,又节省空间的方法(因为创建对象是有开销的)。但自从c++推出异常机制之后,这个方法就不那么好用了。
在一个现代一个较大的编程项目当中,对异常进行处理是非常重要的,而由于一旦异常被抛出,程序会直接退出当前的函数栈帧,不再执行后续的程序,
这就会导致一个非常尴尬的事情发生。即我们可能会在函数头new了一个资源,在函数结束的末尾delete掉了这些资源,但是由于函数中间部分出现了异常,程序直接退出了该函数,导致无法执行到函数末尾。那么这个new出来的资源就没有进行释放,导致了内存泄漏。
而智能指针则是为了解决由抛异常导致的内存泄漏的救世主。由于抛异常会退出函数栈帧,而函数栈帧退出时又会顺带将局部对象进行析构,智能指针的析构又会顺带将资源进行销毁,这就完美的解决了问题。
auto_ptr
auto_ptr是c++98标准推出的第一个智能指针类,其定义在标准库<memory>当中。智能指针无论怎么讲解其作用,无非也就是讲RAII而已,因此博主采取模拟标准库中的常见智能指针的方式,细讲这些智能指针的不同。
auto_ptr是c++标准库推出的第一个智能指针类,其主要作用就是模仿指针的功能(解引用(*)和解引用(->))。以及RAII特性,并且采用了管理权转移的方式拷贝智能指针。
我们先写一个自定义类,以突出auto_ptr的各个功能。
class data
{
public:~data(){cout << "~data()" << endl;}int _year;int _month;int _day;
};
data类对象在析构时,会在屏幕当中打印“~data()”,以检测data类的资源是否被正确释放。
template<class T>class auto_ptr{public:auto_ptr(T* ptr=nullptr){_ptr = ptr;}T& operator*(){return *_ptr;}T* operator->(){return &(*_ptr);}~auto_ptr(){cout << "delte" << endl;delete _ptr;}private:T* _ptr;};
由于auto_ptr实现了operator*和operator->。因此我们可以像操作指针一样操作auto_ptr。
void test_auto_ptr(){auto_ptr<data> ap1(new data);(*ap1)._year = 2024;ap1->_month = 9;(*ap1)._day = 10;cout << ap1->_year<<" " << ap1->_month <<" " << ap1->_day << endl;//2024 9 10}
运行程序,可以观察到data类的资源在函数结束时被释放。
c++的指针允许指针之间进行拷贝,那么auto_ptr也理应可以这么做。但如果我们尝试让auto_ptr之间进行拷贝,会发现程序运行会发生报错。
报错的原因也很简单,由于ap1,ap2,ap3共同指向同一块资源,当程序结束后,ap1,ap2,ap3会发生析构,由于ap3析构时,会将delete掉指向的资源,此时轮到ap2进行析构了,ap2将指向的资源调用delete,此时尴尬的事情就发生了,由于ap2指向的资源在ap3析构时就已经释放了,对已释放的资源重复delete会导致程序报错。
auto_ptr的解决方案也非常简单,既然多个对象指向同一块资源会报错。那就在拷贝的时候,让被拷贝的对象转让出资源的管理权就行了,实现如下:
auto_ptr(auto_ptr<T>& aptr)
{delete _ptr;_ptr = aptr._ptr;aptr._ptr = nullptr;
}auto_ptr& operator=(auto_ptr<T>& aptr)
{delete _ptr;_ptr = aptr._ptr;aptr._ptr = nullptr;return *this;
}
从上面的代码可以得知,当auto_ptr对象发生拷贝时,被拷贝的对象会转移资源,给拷贝的对象。这样就只有拷贝的对象才能管理这片资源。当生命结束,调用析构时,也只有一个指针释放管理的资源。
但是这么做有一个缺点,那就是之前创建的智能指针不能用了。
auto_ptr<data> ap1(new data);
auto_ptr<data> ap2(ap1);//拷贝ap1
auto_ptr<data> ap3;
ap3 = ap2;//拷贝ap2
(*ap1)._year = 2024;//errpr.ap1已丢失管理权
ap1->_month = 9;//error
(*ap1)._day = 10;//error
unique_ptr
总体而言,auto_ptr并不好用,因为既然auto_ptr不允许多个对象指向同一个资源,那么又何必设计出auto_ptr的拷贝构造呢?总给人一种画蛇添足的感觉,而且如果auto_ptr一不注意发生了拷贝(比如传参调用函数)。那么源对象就会丢失资源,
于是c++11推出了unique_ptr,该对象就和它的名字一样,禁止其他unique_ptr对其进行拷贝。由于unique_ptr的功能和auto_ptr除了不能拷贝之外,区别并不是很大,因此博主就不模拟实现unique_ptr了。
尝试运行下面的程序(unique_ptr是标准库中的)
void test_unique_ptr()
{unique_ptr<data>(new data[5]);
}
此时程序会因为调用了unique_ptr的析构函数而报错。
分析原因,原来是因为释放数组资源时,要用detele[]。而unique_ptr的析构函数在默认情况下使用的是delete,这就是报错的原因。
解决方法:
在c++标准库中,存在unique_ptr模板关于数组类型的特化版本。
non-specialized
template <class T, class D = default_delete<T>> class unique_ptr;
array specialization
template <class T, class D> class unique_ptr<T[],D>;
使用该特化版本可以解决delete关键字无法释放数组资源的问题。
void test_unique_ptr()
{unique_ptr<data[]>up1(new data[5]);
}
那么这种情况呢?
unique_ptr<FILE>up2(fopen("test.txt", "r"));
虽然运行这段代码没有问题,但是up2的释放方式不对,对于file*的指针使用delete是不对的,而是应该fclose才行,但是我们不能对库进行修改,那么该怎么办呢?
c++允许我们自定义一个释放资源的仿函数,称之为定制删除器。这个仿函数通过unique_ptr的第二模板参数传给unique_ptr。
array specialization
template <class T, class D> class unique_ptr<T[],D>;
我们写一个针对FILE*类型的指针的定制删除器,并将其命名为closefile。
class closefile
{
public:void operator()(FILE* ptr){fclose(ptr);}
};
最后将closefile传递给unique_ptr。
unique_ptr<FILE,closefile> up2(fopen("test.txt", "w"));
share_ptr
share_ptr允许拷贝其他智能指针,而且采用的策略与auto_ptr不同,因此具有不同的特性。
share_ptr采用的是引用计数的内存管理方法,其思想如下:
(1)每一块资源都有对应的计数,比如,指针a和指针b同时管理一块资源,则其计数为1
(2)每增加一个管理资源的指针,对应的计数就加1
(3)没减少一个管理资源的指针,对应的计数就减1
(4)当资源的对应计数变为0时,将该资源进行释放
由于标准库中的shared_ptr拥有定制删除器,因此我们要在shared_ptr的成员加多一个定制删除器,并且实现一个默认情况下使用的定制删除删除器。
template<class T>
struct default_deletor//默认删除器
{void operator()(T* ptr){delete ptr;}
};template<class T>
struct default_deletor<T[]>//默认删除器的针对释放数组的特化版本
{void operator()(T* ptr){delete[] ptr;}
};
template<class T>
class shared_ptr
{
public:template<class D>shared_ptr(T* ptr,const D&deletor=default_deletor<T*>):_ptr(ptr),_pcount(new int(1)),_del(deletor){}T& operator*() { return *_ptr; }T* operator->() { return &this->operator*(); }
private:int* _pcount;//计数T* _ptr;function<void(T*)> _del;//定制删除器
};
首先是share_ptr的拷贝构造函数,其方法如下:
与被拷贝者共同管理同一块资源,该资源计数加1.
shared_ptr(const shared_ptr& sptr):_ptr(sptr->_ptr),_pcount(sptr->_pcount)//与被拷贝者共用同一个计数
{(*_pcount)++;//计数++
}
析构函数的思路如下:
当shared_ptr被析构时,实际上就是shared_ptr不再管理对应资源,那么该资源相应的计数-1,如果该资源的计数变为0,就代表没有智能指针继续管理该资源,为了防止丢失该资源导致内存泄漏,我们就需要对该资源进行释放。
~shared_ptr()
{remove();
}
void remove()
{--(*_pcount);if (*_pcount == 0){//如果计数为0,就要释放资源_del(_ptr);delete _pcount;_pcount = nullptr;_ptr = nullptr;}
}
赋值拷贝的思路如下:
(1)由于赋值之前,shared_ptr有可能已经存在管理的资源了,因此要先移除该shared_ptr管理的资源
(2)和拷贝构造一样,让新管理的资源的计数加1
要注意,shared_ptr要判断一下是否重复赋值,重复赋值是指,将管理相同资源的shared_ptr进行赋值操作,一方面是避免增加不必要的时间开销,第二则是有可能导致资源被释放。
shared_ptr& operator=(const shared_ptr& sptr)
{if (_ptr != sptr._ptr)//避免重复赋值{remove();_ptr = sptr._ptr;_pcount = sptr._pcount;(*_pcount)++;return *this;}
}
然后我们可以设计以count函数,方便查看shared_ptr的计数。
shared_ptr缺陷
shared_ptr在循环指向的情况下,会导致无法正确析构智能指针的情况出现。比如我们可以尝试让shared_ptr来作为链表的指针。
template<class T>
struct listnode
{T _val;shared_ptr<listnode> next;shared_ptr<listnode> prev;
};
设计一个节点left和节点right,让它们头尾相接。
void test_list()
{shared_ptr<listnode<data>> left(new listnode<data>);shared_ptr<listnode<data>> right(new listnode<data>);left->next = right;right->prev = left;
}
运行这段代码,可以发现left和right没有正常析构,因为data在析构时,会在屏幕上打印“”~data“”。
原因在于:
left指针指和right->prev共同指向同一块资源,因此该资源的计数为2。右边资源也同理。
当left指针和right指针被析构时,这两个资源的计数都会变为1.
此时,如果left资源想要释放,就要让right资源调用right->prev的析构函数。(因为right->prev是一个smart-ptr)。此时尴尬的事情发生了,控制right资源的智能指针已经被销毁了,也就说明我们没有其他方式可以控制right资源。因此left和right彼此之间都无法释放空间,这就导致了内存泄漏。
weak_ptr
为了解决这个问题,标准库又设计了一个weak_ptr。当weak_ptr的生命周期结束时,不会去释放指向的空间。它仅仅只是作为一个指针,指向一个由shared_ptr管理的资源。将weak_ptr指向shared_ptr时,不会增加shared_ptr管理的资源的引用计数。
这么做的好处在于,如果用weak_ptr代替shared_ptr作为链表之间的链接时(不仅是链表,循环指向的场景都能使用weak_ptr解决问题。)。不会增加shared_ptr的引用计数,因此当shared_ptr被销毁时,管理的资源也会被正确释放(上面的情况则是由于循环指向导致计数增加,不会被正确释放)
因此如果使用智能指针来控制链表,正确的做法是让weak_ptr作为节点之间的链接,而非shared_ptr。
template<class T>
struct listnode
{T _val;weak_ptr<listnode<T>> next;weak_ptr<listnode<T>> prev;
};
此时再让节点之间循环指向。情况则会变成
void test_list()
{std::shared_ptr<listnode<data>> left(new listnode<data>);std::shared_ptr<listnode<data>> right(new listnode<data>);left->next = right;right->prev = left;
}
运行这段代码,发现left和right被成功析构。