主流RAII class的存在价值——不存在能够完全替代Dumb Pointer的RAII class 布满荆棘的人生 2021-06-24 15:57 201阅读 0赞 摘自:51CTO酋长([http://clement.blog.51cto.com/2235236/d-1][http_clement.blog.51cto.com_2235236_d-1]) 前言 前几天在很多地方老是碰到RAII(Resouce Acqusition Is Initialition)相关的话题,对于这一块,由于自己以前在代码中很少用到,从来都习惯于使用内置指针(dumb pointer),所以从没仔细去研究过。当它足够频繁的出现在我的眼前时,我渐渐意识到,是时候该做个了断了(说“了断”貌似有些夸张,其实也只是想把它研究透,以免以后老出现在我的眼前而不知其内部原理。。)。事实上,我当早该写这篇博文了,只是当我在看标准库的auto\_ptr源码时,又发现里面的exception handling声明很多,困惑的地方总有该了结的时候,情急之下,又去钻透了exception handling(可以看看我之前的一篇博文:[C++华丽的exception handling(异常处理)背后隐藏的阴暗面及其处理方法][C_exception handling])。 在诸多大师书籍中,关于smart pointer的话题,《effective c++》中在讨论resource management时有涉及到,但仅仅是简单的一点用法,实质性原理方面没涉及到;《c++ primer》同样很少;《the c++ standard library》中倒是对auto\_ptr讲解的很透彻,对于同样在TR1标准库中存在的shard\_ptr却一笔带过,究其原因是由于作者在写书时,tr1库还未纳入C++标准;而Scott Meyers在《more effective c++》中对于smart pointer的原理性剖析非常详细,以至于像是在教我们如何设计一个良好的auto\_ptr class和shard\_ptr class。事实上,当我在不清楚smart pointer原理的时候,我开始在想:以后的代码中一定要用smart pointer代替dumb pointer,但当我真正了解了其内部机制后,却多少有些胆怯,因为相对于smart pointer所带来的方便性而言,由于使用其而带来的负面后果着实让人望而生畏。 auto\_ptr并非一个四海通用的指针 对于auto\_ptr在解决exception handling时内存管理方面所作出的贡献是值得肯定的,这方面我不再想阐述具体内容,可以看看[这里][C_exception handling],在此我只想讨论起所带来的负面性后果。总结起来,值得注意的地方有以下几点: 1.auto\_ptrs不能共享拥有权; 2.并不存在针对array而设计的auto\_ptr; 3.auto\_ptr不满足STL容器对其元素的要求; 4.派生类dumb pointer所对应的auto\_ptr对象不能转换为基类dumb pointer所对应的auto\_ptr对象; 我将逐个详细阐述说明这4点,为此,先来看标准库中auto\_ptr的一段源码: template<class _Ty> class auto_ptr { // wrap an object pointer to ensure destruction public: typedef _Ty element_type; explicit auto_ptr(_Ty *_Ptr = 0) _THROW0() : _Myptr(_Ptr) { // construct from object pointer } auto_ptr(auto_ptr<_Ty>& _Right) _THROW0() : _Myptr(_Right.release()) { // construct by assuming pointer from _Right auto_ptr } auto_ptr(auto_ptr_ref<_Ty> _Right) _THROW0() { // construct by assuming pointer from _Right auto_ptr_ref _Ty *_Ptr = _Right._Ref; _Right._Ref = 0; // release old _Myptr = _Ptr; // reset this } template<class _Other> operator auto_ptr<_Other>() _THROW0() { // convert to compatible auto_ptr return (auto_ptr<_Other>(*this)); } template<class _Other> operator auto_ptr_ref<_Other>() _THROW0() { // convert to compatible auto_ptr_ref _Other *_Cvtptr = _Myptr; // test implicit conversion auto_ptr_ref<_Other> _Ans(_Cvtptr); _Myptr = 0; // pass ownership to auto_ptr_ref return (_Ans); } template<class _Other> auto_ptr<_Ty>& operator=(auto_ptr<_Other>& _Right) _THROW0() { // assign compatible _Right (assume pointer) reset(_Right.release()); return (*this); } template<class _Other> auto_ptr(auto_ptr<_Other>& _Right) _THROW0() : _Myptr(_Right.release()) { // construct by assuming pointer from _Right } auto_ptr<_Ty>& operator=(auto_ptr<_Ty>& _Right) _THROW0() { // assign compatible _Right (assume pointer) reset(_Right.release()); return (*this); } auto_ptr<_Ty>& operator=(auto_ptr_ref<_Ty> _Right) _THROW0() { // assign compatible _Right._Ref (assume pointer) _Ty *_Ptr = _Right._Ref; _Right._Ref = 0; // release old reset(_Ptr); // set new return (*this); } ~auto_ptr() { // destroy the object delete _Myptr; } _Ty& operator*() const _THROW0() { // return designated value #if _HAS_ITERATOR_DEBUGGING if (_Myptr == 0) _DEBUG_ERROR("auto_ptr not dereferencable"); #endif /* _HAS_ITERATOR_DEBUGGING */ __analysis_assume(_Myptr); return (*get()); } _Ty *operator->() const _THROW0() { // return pointer to class object #if _HAS_ITERATOR_DEBUGGING if (_Myptr == 0) _DEBUG_ERROR("auto_ptr not dereferencable"); #endif /* _HAS_ITERATOR_DEBUGGING */ return (get()); } _Ty *get() const _THROW0() { // return wrapped pointer return (_Myptr); } _Ty *release() _THROW0() { // return wrapped pointer and give up ownership _Ty *_Tmp = _Myptr; _Myptr = 0; return (_Tmp); } void reset(_Ty* _Ptr = 0) { // destroy designated object and store new pointer if (_Ptr != _Myptr) delete _Myptr; _Myptr = _Ptr; } private: _Ty *_Myptr; // the wrapped object pointer }; _STD_END 对于第一点,容易犯的一个错误是很多时候试图将同一个dumb pointer赋给多个auto\_ptr,不管是不知道auto\_ptr的用法而导致或是因为忘记了是否已经将一个dumb pointer之前移交给了一个auto\_ptr管理,结果将是灾难性的(尽管编译能通过)。比如这样: class BaseClass{}; int test() { BaseClass *pBase = new BaseClass; auto_ptr<BaseClass> ptrBaseClass1(pBase); auto_ptr<BaseClass> ptrBaseClass2(pBase); return 0; } auto\_ptr源码中看出来对于对象的\_Mypt在constructor中进行了初始化,而在destructor中对\_Mypt又进行了delete,这意味着在上述代码中,test函数执行完时对同一个pBase连续delete了两次。在WIN32下会出现assert然后终止运行。如果想让多个RAII对象共享同一个dumb pointer,却依然不想考虑由谁来释放pointer的内存,那么在通盘考虑合适的情况下可以去用shard\_ptr(后面会详细讲解)。 另外一个比较容易犯的错误是:试图将auto\_ptr以by value或by reference方式传递给一个函数形参,其结果同样是灾难性的,因为这样做这意味着所有权进行了移交,比如试图这样做: void test(auto_ptr<int> ptrValue1) { if (ptrValue1.get()) { cout<<*ptrValue1<<endl; } } int main() { auto_ptr<int> ptrValue(new int); *ptrValue = 100; test(ptrValue); *ptrValue = 10; cout<<*ptrValue<<endl; return 0; } 如果用习惯了dumb pointer,或许会以为这样做没有任何错误,但实际结果却是跟上述例子一样:出现assert然后teminate了当前程序。test之后ptrValue已经成为NULL值,对一个NULL进行引用并赋值,结果是未定义的。。倘若以by reference方式替代by value方式呢?那么将test改为如下: void test(auto_ptr<int> &ptrValue1) { if (ptrValue1.get()) { cout<<*ptrValue1<<endl; } } 如此以来,所得出的结果也正是我们所期望的,看起来貌似不错,但倘若有人这样做: void test(auto_ptr<int> &ptrValue1) { auto_ptr<int> ptrValue2 = ptrValue1; if (ptrValue2.get()) { cout<<*ptrValue2<<endl; } } 结果会和之前的by value方式一样,同样是灾难性的。如果非要让auto\_ptr通过参数传递进一个函数中,而且不影响其后续时候,那么只有一种方式:by const reference。如此的话,如果试图这样做: void test(const auto_ptr<int> &ptrValue1) { auto_ptr<int> ptrValue2 = ptrValue1; if (ptrValue2.get()) { cout<<*ptrValue2<<endl; } } 将不会通过编译,因为ptrValue2 = ptrValue1试图在改变const reference的值。对于第二点,从源码中看出来,destructor只执行delete \_Mypt,而不是delete \[\]\_Mypt;所以如果试图这样做: class BaseClass{}; int test() { BaseClass *pBase = new BaseClass[5]; auto_ptr<BaseClass> ptrBaseClass1(pBase); return 0; } 注意如此会发生内存泄露,pBase实际指向的是数组首元素,这意味着只有pBase\[0\]被正常释放了,其它对象均没被释放。标准库中至今不存在一个可以管理动态分配数组的auto\_ptr或shared\_ptr,这方面,boost::scoped\_array和boost::shared\_array可以提供这样的功能,或许以后在适当的时候我会再次深入讲解这两个RAII class。 对于第三点,auto\_ptr在=操作符和copy constructor中的行为可从源码中看出来,其实质是进行了\_Mypt管理权限的交接,这也正是auto\_ptr一开始奉行的遵旨:只让一个RAII class object来管理同一个dumb pointer,若非如此,那么auto\_ptr的存在是毫无意义的。而STL容器对其元素的值语意的要求是:可拷贝构造意味着其元素与被拷贝元素的值相同。事实上,诸如vector等容器经常push\_back,pop\_back或之类的操作会返回一个副本或拷贝一个副本,所以要求其值语意为拷贝后与原元素值还要保持相同就理所当然了。auto\_ptr进行拷贝后,元素值就会发生改变,如此即不符合STL的值语意要求。 对于第四点而言,dumb poiter的派生类可以自由的转换为其所对应的基类的dumb pointer,而auto\_ptr却不能,因为auto\_ptr是个单独的类,意味着任何两个auto\_ptr对象不能像普通指针那样进行这类转换,比如这样做: class BaseClass{}; class DerivedClass{}; int test() { auto_ptr<BaseClass> ptrBaseClass; auto_ptr<DerivedClass> ptrDerivedClass(new DerivedClass); ptrBaseClass = ptrDerivedClass; return 0; } 是个错误的做法,这段代码将不会通过编译;事实上,这也正是所有现行RAII class存在的瓶颈,除非自己去设计一个RAII class,可以自由定义隐式转换操作符,比如这样做: template<typename T> class SmartPointBaseClass { private: T* ptr; public: SmartPointBaseClass(T* point = NULL):ptr(point){} ~SmartPointBaseClass() { delete ptr; } }; template<typename T> class SmartPointDerivedClass:public SmartPointBaseClass<T> { private: T* ptr; public: SmartPointDerivedClass(T* point = NULL):ptr(point) {} operator SmartPointBaseClass() { SmartPointBaseClass basePtr(ptr); ptr = NULL; return basePtr; }; ~SmartPointDerivedClass() { delete ptr; } }; int test() { SmartPointBaseClass<int> ptrBaseClass; SmartPointDerivedClass<int> ptrDerivedClass(new int); ptrBaseClass = ptrDerivedClass; return 0; } 这里我重载了SmartPointBaseClass的隐式转换操作符,从而得以让派生类auto\_ptr可以隐式转换为基类的auto\_ptr。这是一个很简陋的RAII class,简陋到我自己都不敢用了^\_^。。其实只是用来说明原理而用(这段代码在VS下测试通过),倘若真想设计一个良好的通用性强的RAII class,个人认为要仔细看看《more effective c++》中的条款28和29了,另外还得考虑到结合自身需求制定出性能和功能都比较折中或更良好的auto\_ptr。All in all,auto\_ptr绝对不是一个四海通用的指针。 auto\_ptr的替代方案——shared\_ptr 对于shared\_ptr,其在很多方面能解决auto\_ptr的草率行为(如以by value或by reference形式传递形参的灾难性后果)和限制性行为(如当做容器元素和多个RAII object共同拥有一个dumb pointer主权),它通过reference counting来使得多个对象同时拥有一个主权,当所有对象都不在使用其时,它就自动释放自己。如此看来,Scott Meyers称其为一个垃圾回收体系其实一点也不为过。由于TR1中的shared\_ptr代码比较多,而且理解起来很困难。那么看看下面代码,这是《the c++ stantard library》中的一个简易的reference counting class源码,其用来说明shared\_ptr原理来用是足够了的: template<typename T> class CountedPtr { private: T* ptr; long *count; public: explicit CountedPtr(T* p = NULL):ptr(p),count(new long(1)) {} CountedPtr(const CountedPtr<T> &p)throw():ptr(p.ptr),count(p.count) { ++*count; } ~CountedPtr()throw() { dispose(); } CountedPtr<T>& operator = (const CountedPtr<T> &p)throw() { if (this != &p) { dispose(); ptr = p.ptr; count = p.count; ++*count; } return *this; } T& operator*() const throw() { return *ptr; } T* operator->()const throw() { return ptr; } private: void dispose() { if (--*count == 0) { delete count; delete ptr; } } }; TR1的shared\_ptr比这复杂很多,它的counting机制由一个专门的类来处理,因为它还得保证在多线程环境中counting的线程安全性;另外对于对象的析构工作具体处理形式,其提供了一个函数对象来供用户程序员来自己控制,在构造时可以通过参数传递进去。 shared\_ptr在构造时,引用计数初始化为1,当进行复制控制时,对于shared\_ptr先前控制的资源进行引用计数减1(为0时销毁先前控制的资源),因为此时当前shared\_ptr要控制另外一个dumb pointer,所以其又对新控制的shared\_ptr引用计数加1。 这样好了,由于其支持正常的赋值操作,所以能做容器的元素使用,也因此可以放心的用来进行函数形参的传递而不用担心像auto\_ptr那样的权利转交所带来的灾难性后果了。但事实不尽如此,auto\_ptr的权利转交所带来的便利性就是:永远不会存在循环引用的对象而导致内存泄露,而shared\_ptr却开始存在这样的问题了,比如下面代码: class BaseClass; class DerivedClass; class BaseClass { public: tr1::shared_ptr<DerivedClass> sptrDerivedClass; }; class DerivedClass { public: tr1::shared_ptr<BaseClass> sptrBaseClass; }; void InitData() { tr1::shared_ptr<BaseClass> baseClass(new BaseClass); tr1::shared_ptr<DerivedClass> derivedClass(new DerivedClass); baseClass->sptrDerivedClass = derivedClass; derivedClass->sptrBaseClass = baseClass; } int test() { InitData(); return 0; } smart pointer用来做class的data member的话,比起dumb pointer来方便很多:如不用担心因此而产生的野指针的存在,也不用担心资源的管理操作。 这段看似正常的代码在test完了后的结果就是InitData中的类对象都在程序结束前一直不会被正常释放,因为其baseClass和derivedClass一直占用着对方而使其引用计数永远不会为0。如果因此而试图将所有的shared\_ptr改为auto\_ptr,那么结果会更惨,比如将上述部分代码改为这样: class BaseClass { public: auto_ptr<DerivedClass> sptrDerivedClass; }; class DerivedClass { public: auto_ptr<BaseClass> sptrBaseClass; }; void InitData() { auto_ptr<BaseClass> baseClass(new BaseClass); auto_ptr<DerivedClass> derivedClass(new DerivedClass); baseClass->sptrDerivedClass = derivedClass; derivedClass->sptrBaseClass = baseClass; } 在InitData的这一句:derivedClass->sptrBaseClass = baseClass 时候其实derivedClass所管理的指针已经为NULL了,试图对NULL进行引用会Teminate了当前程序。但至少在debug状态下,teminate前出现的assert信息能帮助我们知道自己不小心进行了循环引用,如此便能改正错误。倘若非要使用这样的操作而且还想避免循环引用,那么使用weak\_ptr可以进行完美改善(后面会讲到)。 对于shared\_ptr,说到这里就差不多了,最后对于面试中常问到的shared\_ptr的线程安全性,boost类库实现的shared\_ptr的文档中有这么一句: shared\_ptr objects offer the same level of thread safety as built-in types. A shared\_ptr instance can be "read " (accessed using only const operations) simultaneously by multiple threads. Different shared\_ptr instances can be "written to " (accessed using mutable operations such as operator= or reset) simultaneosly by multiple threads (even when these instances are copies, and share the same reference count underneath.) Any other simultaneous accesses result in undefined behavior 即可以放心的像内置类型数据一样在线程中使用shared\_ptr。究其源码,我看到的结果是只对counting机制实现了线程安全性,VS下的tr1库counting机制的线程安全实现宏如下: #ifndef _DO_NOT_DECLARE_INTERLOCKED_INTRINSICS_IN_MEMORY extern "C" long __CLRCALL_PURE_OR_CDECL _InterlockedIncrement(volatile long *); extern "C" long __CLRCALL_PURE_OR_CDECL _InterlockedDecrement(volatile long *); extern "C" long __CLRCALL_PURE_OR_CDECL _InterlockedCompareExchange(volatile long *, long, long); #pragma intrinsic(_InterlockedIncrement) #pragma intrinsic(_InterlockedDecrement) #pragma intrinsic(_InterlockedCompareExchange) #endif /* _DO_NOT_DECLARE_INTERLOCKED_INTRINSICS_IN_MEMORY */ #define _MT_INCR(mtx, x) _InterlockedIncrement(&x) #define _MT_DECR(mtx, x) _InterlockedDecrement(&x) #define _MT_CMPX(x, y, z) _InterlockedCompareExchange(&x, y, z) 如此的话,回答安全或者不安全都是含糊不清的。在我看来只能这样说:shared\_ptr对counting机制实现了线程安全,在多线程中使用多个线程共享的shared\_ptr而不做其它任何安全管理机制,同样会存在抢占资源而导致的一系列问题,但reference counting是永远正常进行的。。 相应于shared\_ptr所引发的循环引用而生的weak\_ptr 对于打破shared\_ptr的循环引用的一个最好的方法就是使用weak\_ptr,它所提供的功能类似shared\_ptr,但相对于shared\_ptr来说,其功能却弱很多,如同它的名字一样。以下是一份主流weak\_ptr所应有的接口声明: template<class Ty> class weak_ptr { public: typedef Ty element_type; weak_ptr(); weak_ptr(const weak_ptr&); template<class Other> weak_ptr(const weak_ptr<Other>&); template<class Other> weak_ptr(const shared_ptr<Other>&); weak_ptr& operator=(const weak_ptr&); template<class Other> weak_ptr& operator=(const weak_ptr<Other>&); template<class Other> weak_ptr& operator=(shared_ptr<Other>&); void swap(weak_ptr&); void reset(); long use_count() const; bool expired() const; shared_ptr<Ty> lock() const; }; weak\_ptr中没有重载\*和->操作符,因而不能通过它来访问元素。看起来更像是一个shared\_ptr的观察者,如果用MVC架构来解释的话,weak\_ptr就充当了view层,而shared\_ptr充当了model层,因为对于shared\_ptr的任何操作后的状态信息都可以通过其对应的weak\_ptr来观察出来,而观察的同时自身却并不做任何具体操作(例如访问元素或进行counting),事实上,倘若weak\_ptr也提供类似的访问操作的话,那么意味着每次访问都会改变count的值,如此以来,weak\_ptr也就失去了其自身存在的价值。。 如果用weak\_ptr来改善在上面阐述shared\_ptr中的问题的话,只需将BaseClass或DerivedClass中任何一个类中的shared\_ptr改为weak\_ptr即可(注意不能全部改成weak\_ptr),看起来情况好了很多,但如此所带来的问题是:程序员需提前预知将会发生的循环引用,如果不能提前预知呢?那就等待着内存泄露时刻的到来而自己却全无所知,因为表象上看起来程序的确没有任何异常情况。 后记 对于auto\_ptr这样的RAII class的使用,不得不说其所带来的繁琐程度不亚于其所带来的便利性,而对于其是否值得使用,Scott Meyers在《more effective c++》中给出的建议是:“灵巧指针应该谨慎使用, 不过每个C++程序员最终都会发现它们是有用的”,对于这一点,虽然我没有过由于大量使用其而带来很多束手无策的经验,但对于其内部原理的剖析足以让我望而生畏。。相对来说,shared\_ptr却显得更人性些,但通过使用一个类来管理普通的dumb pointer,方便的同时所带来的资源消耗也不可小视,毕竟任何一个dumb pointer只占一个字节,而一个shared\_ptr所造就的资源消耗却大了很多。通常情况下,对于一些经常使用的相同资源而却有很多pointer访问的情况,使用shared\_ptr无疑是最好的适用场景了。对于由于使用shared\_ptr所带来的环状引用而造就的内存泄露,weak\_ptr确实能帮助全然解决困难,但当我们面对或写下成千上万行的代码时,我想没人能保证绝对能提前知晓所存在的所有环状引用。 无论如何,不存在一个足够通用的RAII class能完全替代dumb pointer,唯有在能预知使用其而所带来的便利性远远大于其所带来的繁琐度的情况下,其使用价值也就值得肯定了。而reference counting的思想在现在主流的跨平台2d游戏引擎cocos2d-x中已被展现的淋漓至尽。或许我以后的博客中,会有更多cocos2d-x方面的文章。 [http_clement.blog.51cto.com_2235236_d-1]: http://clement.blog.51cto.com/2235236/d-1 [C_exception handling]: http://clement.blog.51cto.com/2235236/768514
还没有评论,来说两句吧...