一、问题引入
关于C++中的new和delete操作符,
我们知道这两个操作符必须成对存在,才能避免内存泄漏。
这一点在学习的时候被认为是常识,然而,在实际编写代码的过程中,却常常很难做到。
下面有3种情况:
1、代码很长。
当需要用到delete的地方离使用与之对应的new操作符距离非常远时,我们很容易忘记delete。当然,这种情况是完全可以避免的。
2、如下面代码:
void Test() { int *pi = new int(1); if(1) { return; } delete pi; //程序并没有执行到这一步 } int main() { void Test(); return 0; }
这里我们在Test函数中,开辟了一段长度为 1个int类型大小 的动态内存,
但接下来,进入if语句,直接return掉了,因此之前开辟的那段内存没有得到回收,导致内存泄漏。
对于这种情况,我们可以这样修改:
void Test() { int *pi = new int(1); if(1) { delete pi; return; } delete pi; //程序并没有执行到这一步 } int main() { void Test(); return 0; }
3、让情况再复杂一些,看看这段代码:
void DoSomeThing() { if(1) { throw 1; } } void Test() { int *pi = new int(1); DoSomeThing(); delete pi; } int main() { try { Test1(); } catch(...) { ; } return 0; }
在Test()函数中,看似new和delete是成对存在的,中间也只有一行代码,
然而当中间这个DoSomeThing()抛出异常导致这个程序结构不是按部就班地进行时,
仍然没有执行delete pi 这一步。
这种情况我们仍然可以做如下修正:
void DoSomeThing() { if(1) { throw 1; } } void Test() { int *pi = new int(1); try { DoSomeThing(); } catch(...) { delete pi; throw; } delete pi; } int main() { try { Test1(); } catch(...) { ; } return 0; }
在Test中加上一个try catch语句,作为DoSomeThing()函数抛出异常的“中介”,处理动态内存。
以上3种情况都是完全可以解决的。
但特别是当程序是类似情况3的结构,甚至更复杂的时候,我们不得不加上一大堆的代码,却仅仅是为了处理动态内存的回收。
这是十分影响开发效率的,同时程序也变得难以阅读。
二、简单的智能指针
我们知道,类的成员函数中,析构函数的存在似乎能解决动态内存回收的问题:当程序出了类的作用域,会自动调用该类的析构函数。
带着这个思想,我们定义一个名为AutoPtr的类模板
template<typename T> class AutoPtr { public: AutoPtr(T *ptr = NULL) :_ptr(ptr) {} AutoPtr(AutoPtr<T> &ap) :_ptr(ap._ptr) { ap._ptr = NULL; } ~AutoPtr() { if (_ptr != NULL) { cout << "delete:" << _ptr << endl; delete _ptr; _ptr = NULL; } } AutoPtr<T> operator=(AutoPtr<T> &ap) { if(this != &ap) { if (_ptr != ap._ptr) { delete _ptr; } _ptr = ap._ptr; ap._ptr = NULL; } return *this; } T &operator*() { return *_ptr; } T* operator->() { return _ptr; } protected: T* _ptr;
值得注意的是,当这个模板类的模板类型为结构体或类时,我们需要访问该类的成员,因此需要一个
"->"操作符的重载,举个例子:
已知结构体stru的定义如下:
struct stru { void PrintTest() { std::cout << "hi" << std::endl; } };
对于这样的类,当我们执行如下代码
stru st1; stru *ps1 = &st1; AutoPtr<stru> aps1(ps1); aps1->PrintTest();//操作符的重载
最后一行发生 "->"操作符的重载,但根据之前关于 "->"操作符的定义严格意义上,访问到的是
_ps1; 这行代码实际上经过重载后,应该为:_ps1PrintTest()
其实这里是编译器为了保证代码的可读性,把_ps1PrintTest优化为 _ps1->PrintTest()。
(这个优化在g++和VS2015中都是存在的)
当然,这个智能指针并不完美,仔细观察我的拷贝构造函数和赋值操作符的重载,你会发现,每当我们进行拷贝或者赋值的时候,永远都是让源智能指针置空,也就是说,同一时间一段动态内存只能由一个智能指针来维护。
这样做是有原因的:如果有若干个个智能指针指向同一块动态空间,那么在析构的时候,将会对这块空间析构若干次,程序必然崩溃。所以我只能让这个类具有“同一时间一段动态内存只能由一个智能指针来维护”的特性。
总之,尽管不完美,但我们还是实现类一个简单的智能指针AutoPtr。有了它,我们可以不用手动delete,将释放内存的工作全部交给析构函数来处理。
上面说的“不完美”主要体现为以下几点,也是我接下来要解决的问题:
1、“同一时间一段动态内存只能由一个智能指针来维护”的特性:这个特性让它和普通的指针“不太像”,不符合普遍的编程习惯,
此外如果手动构造多个指向同一内存的智能指针,仍会导致析构函数的时候对同一内存析构多次,仍会令程序崩溃,因此这样的做法并没有根本上解决问题;
2、智能指针只能指向动态内存,如果指向静态内存,在析构的过程中必然崩溃。
最初AutoPtr还有另外一种实现方式,
代码如下:
template <class T> class OldAutoPtr { public: OldAutoPtr(T *ptr) :_ptr(ptr) ,_IsHost(true) { std::cout << "构造" << std::endl; } OldAutoPtr(OldAutoPtr<T> &oap) :_ptr(oap._ptr) ,_IsHost(true) { oap._IsHost = false; } ~OldAutoPtr() { if (_IsHost) { std::cout << "释放" << std::endl; delete _ptr; } } public: T &operator*() { return *_ptr; } T* operator->() { return _ptr; } OldAutoPtr<T>& operator=(OldAutoPtr<T> &oap) { if (_IsHost) { cout << "释放" << endl; delete _ptr; } _ptr = oap._ptr; _IsHost = true; oap._IsHost = false; return *this; } void Test() { cout << "test" << endl; } protected: T* _ptr; bool _IsHost; };
成员变量_IdHost需要保证对于多个指向同一个动态内存的指针,只能有一个指针为ture。
这样,在析构时,只对_IsHost值为ture的指针进行内存释放。
在这种实现方式下,允许多个指针指向同一块动态内存。
但这种方式存在隐患,如下面这种情况:
int a = 0; int b = 1; auto_ptr<int> ap1(new int(1)); if (a < b) { auto_ptr<int> ap2(ap1); }
这时候,ap1指向的空间已经释放,但仍然可以访问ap1指向的空间,这样就成为了野指针。
因此这种实现方式存在严重漏洞。
三、防拷贝措施(ScopedPtr)
我之前写的AutoPtr有种种不完美之处,需要对其进行一些改进,其中由于拷贝和赋值的不合理导致同一时间在特定的一段动态内存,只能存在一个AutoPtr维护,针对这个问题,ScopedPtr 和 SharedPtr都是对AutoPtr的改进
ScopedPtr类模板的定义如下:
template<typename T> class ScopedPtr { public: ScopedPtr(T* ptr = NULL) :_ptr(ptr) {} ~ScopedPtr() { if (_ptr) { cout << "delete:" << _ptr << endl; delete _ptr; _ptr = NULL; } } T &operator*() { return *_ptr; } T* operator->() { return _ptr; } protected: ScopedPtr(ScopedPtr<T> &sp); ScopedPtr<T> &operator=(ScopedPtr<T> &sp); protected: T* _ptr; };
这里跟AutoPtr的区别在于
1:拷贝构造函数和赋值操作符重载函数只声明,没有定义
2:并且以上两个函数的声明放在了protected限定符内
采用这样的做法,当我们想对ScopedPtr类型的变量进行拷贝构造或者赋值时,由于并没有定义相应的函数,程序是无法编译通过的,因此根本上就防止了赋值行为的发生
此外,将这两个函数放在protected限定符内也是有意义的:假如我们声明这两个函数为public,那么的确可以起到同样防止调用的效果。
但是一旦这样做,造成的后果就是,其他人在读这样的代码时,有可能会误解为我们没来得及定义这两个函数,然后“画蛇添足”地加上相应的定义,如此一来,这个ScopedPrt类就与之前我们定义的AutoPtr类没两样了。
此外,心怀恶意的“捣乱者”一旦看到这样的漏洞,破坏掉你的程序也将会轻而易举——只需要给这两个函数写上定义就可以了。
总之,将这两个函数声明为protected是有意义的,它可以防止其他人对程序的破坏行为。
四、进一步改进(SharedPtr)
到这里,简单的ScopedPtr类已经实现了,它可以防止让多个指针对同一片动态内存的行为。
但前提是你必须按照规范,一旦像下面这段代码的方式使用ScopedPtr,那么我们刚才做的一系列保护就失去作用了:
int *pi = new int(1); ScopedPtr<int> sp1(pi); ScopedPtr<int> sp2(pi);
执行上面这段代码,仍然可以让两个ScopedPtr类型的智能指针指针指向同一片动态内存,因此这两个
ScopedPtr指针在退出作用域自动调用析构函数时,必然会对同一块内存释放2次,程序崩溃。
看来,ScopedPtr仍不是最佳的解决方式。我们希望实现一种新的智能指针,它能够允许多个该智能指针指向同一块内存。
于是,我们试着实现一个名为 SharedPtr 的类,要想实现允许多个智能指针指向同一块内存,需要修改一下我们之前定义的析构函数中的那个delete语句的条件。
举个例子,有2个智能指针sp1 sp2指向同一块动态内存,那么在这两个指针的析构函数调用时,delete的行为应该只发生一次。
也就是说 假设当析构sp1时,我们应该在delete之前检测sp1所指向的内存是否还有被其他指针维护,
若有,则不delete,
若无,才发生delete。
因此,我们还需要一个计数器变量_pCount,能够告诉我们指定的内存有几个智能指针在维护。
我们可以将_pCount定义在SharedPtr类内,SharedPtr类的定义如下:
template<typename T> class SharedPtr { public: SharedPtr(T *ptr = NULL) :_ptr(ptr) ,_pCount(new long long(1)) {} SharedPtr(const SharedPtr<T> &sp) :_ptr(sp._ptr) ,_pCount(sp._pCount) { ++*(_pCount); } ~SharedPtr() { if (_ptr) { if (--(*_pCount) == 0) { delete _ptr; delete _pCount; } } } SharedPtr<T> &operator=(SharedPtr<T> &sp) { if (this != &sp) { if (_ptr != sp._ptr) { if (--(*_pCount) == 0) { delete _ptr; delete _pCount; } _ptr = sp._ptr; _pCount = sp._pCount; ++(*_pCount); } } return *this; } T& operator*() { return *_ptr; } T* operator->() { return _ptr; } long long UseCount() { return *(_pCount); } protected: T *_ptr; long long* _pCount; };
对于类内的这个_ptr指针成员,,每个我们在拷贝构造和赋值的时候,需要不断维护它,以保证每个指向相同内存的智能指针也同时指向相同的计数器。
有了这个计数器,在析构的时候,就可以通过计数器的值判断是否应该delete掉相应的内存了:
如果只有析构的这一个智能指针在维护这段内存,则将这段内存delete掉,同时别忘了将计数器_pCount指向的内存也delete掉。
如果判断出除了当前析构的智能指针,还有其他智能指针也在维护相应的内存,则不delete,但计数器的值要减1。
当然,在该类的赋值操作符重载函数中,为了减少程序执行的步骤,我们也可以用如下的现代式写法的方式进行定义,具体实现如下:
SharedPtr<T> &operator=(SharedPtr<T> &sp) { --(*_pCount); SharedPtr<T> tmp(sp); swap(_ptr, tmp._ptr); _pCount = sp._pCount; ++(*_pCount); return *this; }
五、智能数组
我们还可以参照这些智能指针类的设计思路,设计出智能数组。
智能数组用来存放动态开辟的数组,因此析构函数释放内存采用delete[]操作符。
此外,智能数组不需要* 和 -> 操作符,但需要重载[]操作符,具体的实现如下:
template<typename T> class SharedArr { public: SharedArr(T *ptr = NULL) :_ptr(ptr) , _pCount(new long long(1)) {} SharedArr(const SharedPtr<T> &sp) :_ptr(sp._ptr) , _pCount(sp._pCount) { ++*(_pCount); } ~SharedArr() { if (_ptr) { if (--(*_pCount) == 0) { delete[] _ptr; delete _pCount; } } } SharedArr<T> &operator=(SharedArr<T> &sp) { if (this != &sp) { if (_ptr != sp._ptr) { if (--(*_pCount) == 0) { delete _ptr; delete _pCount; } _ptr = sp._ptr; _pCount = sp._pCount; ++(*_pCount); } } return *this; } T &operator[](size_t index) { return _ptr[index]; } long long UseCount() { return *(_pCount); } protected: T *_ptr; long long* _pCount; };
可以增加一个指针成员变量保存数组的长度,修改相应的构造、拷贝构造、赋值操作符等,从而实现数组的打印等操作。
但缺点是在调用这个类的构造函数时,必须传入准确的数组长度,否则程序会出错
实现代码如下:
template<typename T> class SharedArr { public: SharedArr(T *ptr = NULL, long long size = 0) :_ptr(ptr) , _pSize(new long long(size)) , _pCount(new long long(1)) {} SharedArr(const SharedPtr<T> &sp) :_ptr(sp._ptr) ,_pSize(sp._pSize) , _pCount(sp._pCount) { ++*(_pCount); } ~SharedArr() { if (_ptr) { if (--(*_pCount) == 0) { delete[] _ptr; delete _pCount; delete _pSize; } } } SharedArr<T> &operator=(SharedArr<T> &sp) { if (this != &sp) { if (_ptr != sp._ptr) { if (--(*_pCount) == 0) { delete _ptr; delete _pCount; delete _pSize; } _ptr = sp._ptr; _pCount = sp._pCount; _size = sp._pSize; ++(*_pCount); } } return *this; } T &operator[](size_t index) { return _ptr[index]; } long long UseCount() { return *(_pCount); } void Print() { for (int i = 0; i < *(_pSize); i++) { cout << _ptr[i] << " "; } cout << endl; } protected: T *_ptr; long long* _pSize; long long* _pCount; };
六、定置删除器
在我之前实现的SharedPtr中,在一些需要释放内存的地方(如析构函数、赋值操作符重载),采用了delete操作符,然而,对于非new操作符开辟的内存,这会导致程序崩溃。
比如:
int a = 10; int *p = &a; SharedPtr<int> sp1(p);
此外,当智能指针用于文件操作时,我们希望成员方法中用到delete的地方能产生类似fopen的效果,因此需要用到定置删除器。实现方式如下:
struct DefaultDel { void operator() (void *ptr) { std::cout << "DefaultDel::operator()" << std::endl; delete ptr; } }; struct Free { void operator() (void *ptr) { std::cout << "Free::operator()" << std::endl; free(ptr); } }; struct Fclose { void operator() (void *ptr) { std::cout << "Fclose::operator()" << std::endl; fclose((FILE*)ptr); } }; struct Stack { void operator() (void *ptr) { std::cout << "Stack::operator()" << std::endl; } }; //基于定置删除器的类模板 template<class T, class D = DefaultDel> class SharedPtr { public: SharedPtr(T *ptr) :_ptr(ptr) , _pCount(new long long(1)) {} SharedPtr(T *ptr, D del) :_ptr(ptr) ,_pCount(new long long(1)) ,_del(del) {} SharedPtr(const SharedPtr<T, D> &sp) :_ptr(sp._ptr) , _pCount(sp._pCount) { ++*(_pCount); } void _Release() { _del(_ptr); delete _pCount; } ~SharedPtr() { if (_ptr) { if (--(*_pCount) == 0) { _Release(); } } } SharedPtr<T, D> &operator=(SharedPtr<T, D> &sp) { if (this != &sp) { if (_ptr != sp._ptr) { if (--(*_pCount) == 0) { _Release(); } _ptr = sp._ptr; _pCount = sp._pCount; ++(*_pCount); } } return *this; } T &operator[](size_t index) { return _ptr[index]; } long long UseCount() { return *(_pCount); } protected: T *_ptr; long long* _pCount; D _del; };
在这种实现方式中,我定义了四种类:DefaultDel、Free、Fclose、Stack,它们分别可以对应new、malloc开辟的动态内存,以及用于文件操作(指向fopen类型数据)的指针、指向栈空间(静态内存)的指针。
然后在每个类中实现()操作符的重载,重载函数实现了它们对于指定内存类型的处理方式。这样就可以用类似函数的方式调用这些重载函数。
为了方便起见,在SharedPtr类中,我还声明了D类型的成员变量_del,这样调用_del()就可以实现相应类型的结构体中的()重载函数,也就是定置删除的功能。
使用方式如下:
int a = 10; int *p = &a; SharedPtr<int, Stack> sp1(p);
由于p指向的内存不是new操作符开辟的,因此模板参数不能用缺省值,
Stack是针对栈内存的定置删除器,因此采用Stack
七、梳理
智能指针的实现,是利用了类的析构函数“已构造的对象最终会销毁,即它的析构函数最终会被调用”的特性,即RAII。
智能指针的意义在于让我们不用关心指针指向对象的回收、处理。
因此对于特定的内存,我们需要写特定的定置删除器进行处理。
以上是我实现的AutoPtr ScopedPtr SharedPtr,
实际上在boost库中,已经实现同名的智能指针,只不过命名方式为下划线法(如shared_ptr)
此外,在C++新标准中,这些智能指针也是标准库中的成员,我们只需要引头文件 memory即可使用这些智能指针(其中,scoped_ptr被“改名”为 unique_ptr)。
//关于boost库的编译,这是我的步骤
http://zhweizhi.blog.51cto.com/10800691/1760312