第26章 理解智能指针
管理堆(或自由存储区)中的内存时,C++程序员并非一定要使用常规指针,而可使用智能指针。
在本章中,您将学习:
• 什么是智能指针以及为什么需要智能指针;
• 智能指针是如何实现的;
• 各种智能指针;
• 为何不应使用已摒弃的std::auto_ptr;
• C++标准库提供的智能指针类 std::unique_ptr;
• 深受欢迎的智能指针库。
26.1 什么是智能指针
简单地说,C++智能指针是包含重载运算符的类,其行为像常规指针,但智能指针能够及时妥善地销毁动态分配的数据,并实现了明确的对象生命周期,因此更有价值。
与其他现代编程语言不同,C++在内存分配、释放和管理方面向程序员提供了全面的灵活性。不幸的是,这种灵活性是把双刃剑,一方面,它使 C++成为一种功能强大的语言,另一方面,它让程序员能够制造与内存相关的问题,如动态分配的对象没有正确地释放时将导致内存泄漏。
例如:
在上述代码中,没有显而易见的方法获悉pData指向的内存:
• 是否是从堆中分配的,因此最终需要释放;
• 是否由调用者负责释放;
• 对象的析构函数是否会自动销毁该对象。
虽然这种不明确性可通过添加注释以及遵循编码实践来部分缓解,但这些机制太松散,无法有效地避免因滥用动态分配的数据和指针而导致的错误。
鉴于使用常规指针以及常规的内存管理方法存在的问题,当C++程序员需要管理堆(自由存储区)中的数据时,并非一定要使用它们,而可在程序中使用智能指针,以更智能的方式分配和管理内存:
智能指针的行为类似常规指针(这里将其称为原始指针),但通过重载的运算符和析构函数确保动态分配的数据能够及时地销毁,从而提供了更多有用的功能。
26.2 智能指针是如何实现的
这个问题暂时可以简化为:“智能指针 spData 是如何做到像常规指针的?”答案如下:智能指针类重载了解除引用运算符(*)和成员选择运算符(->),让程序员可以像使用常规指针那样使用它们。运算符重载在第12章讨论过。
另外,为让您能够在堆中管理各种类型,几乎所有良好的智能指针类都是模板类,包含其功能的泛型实现。由于是模板,它们是通用的,可以根据要管理的对象类型进行具体化。
程序清单26.1是一个简单智能指针类的实现。
程序清单26.1 智能指针类最基本的组成部分
分析:
该智能指针类实现了两个运算符:*和->,如第14~17行及第19~22行所示,它们让这个类能够用作常规意义上的“指针”。例如,如果有一个 Tuna 类,则可这样对该类型的对象使用智能指针:
这个smart_pointer类还没有实现使其非常智能,从而胜于常规指针的功能。构造函数(如第6行所示)接受一个指针,并将其保存到该智能指针类内部的一个指针对象中。析构函数释放该指针,从而实现了自动内存释放。
使智能指针真正“智能”的是复制构造函数、赋值运算符和析构函数的实现,它们决定了智能指针对象被传递给函数、赋值或离开作用域(即像其他类对象一样被销毁)时的行为。介绍完整的智能指针实现前,需要了解一些智能指针类型。
26.3 智能指针类型
内存资源管理(即实现的内存所有权模型)是智能指针类与众不同的地方。智能指针决定在复制和赋值时如何处理内存资源。最简单的实现通常会导致性能问题,而最快的实现可能并非适合所有应用程序。因此,在应用程序中使用智能指针前,程序员应理解其工作原理。
智能指针的分类实际上就是内存资源管理策略的分类,可分为如下几类:
• 深复制;
• 写时复制(Copy on Write,COW);
• 引用计数;
• 引用链接;
• 破坏性复制。
下面首先简要地介绍一下这些策略,再探索C++标准库提供的智能指针std::unique_ptr。
在实现深复制的智能指针中,每个智能指针实例都保存一个它管理的对象的完整副本。每当智能指针被复制时,将复制它指向的对象(因此称为深复制)。每当智能指针离开作用域时,将(通过析构函数)释放它指向的内存。
虽然基于深复制的智能指针看起来并不比按值传递对象优越,但在处理多态对象时,其优点将显现出来。如下所示,使用智能指针可避免切除(slicing)问题:
如果程序员选择使用深复制智能指针,便可解决切除问题,如程序清单26.2所示。
程序清单26.2 使用基于深复制的智能指针将多态对象作为基类对象进行传递
分析:
可以看到,deepcopy_smart_pointer 在第 9~13 行实现了一个复制构造函数,使得能够通过函数Clone()函数对多态对象进行深复制——对象必须实现函数Clone()。另外,它还实现了复制赋值运算符,如第16~22行所示。为简单起见,这里假设基类Fish实现的虚函数为Clone。通常,实现深复制模型的智能指针通过模板参数或函数对象提供该函数。
下面是deepcopy_smart_pointer的一种用法:
构造函数实现的深复制将发挥作用,确保传递的对象不会出现切除问题——虽然从语法上说,目标函数MakeFishSwim()只要求基类部分。
基于深复制的机制的不足之处在于性能。对有些应用程序来说,这可能不是问题,但对于其他很多应用程序来说,这可能导致程序员不使用智能指针,而将指向基类的指针(常规指针Fish*)传递给函数,如MakeFishSwim()。其他指针类型以各种方式试图解决这种性能问题。
写时复制机制(Copy on Write,COW)试图对深复制智能指针的性能进行优化,它共享指针,直到首次写入对象。首次调用非const函数时,COW指针通常为该非const函数操作的对象创建一个副本,而其他指针实例仍共享源对象。
COW深受很多程序员的喜欢。实现const和非const版本的运算符*和->,是实现COW指针功能的关键。非const版本用于创建副本。
重要的是,选择 COW 指针时,在使用这样的实现前务必理解其实现细节。否则,复制时将出现复制得太少或太多的情况。
引用计数是一种记录对象的用户数量的机制。当计数降低到零后,便将对象释放。因此,引用计数提供了一种优良的机制,使得可共享对象而无法对其进行复制。如果读者使用过微软的COM技术,肯定知道引用计数的概念。
这种智能指针被复制时,需要将对象的引用计数加1。至少有两种常用的方法来跟踪计数:
• 在对象中维护引用计数;
• 引用计数由共享对象中的指针类维护。
前者称为入侵式引用计数,因为需要修改对象以维护和递增引用计数,并将其提供给管理对象的智能指针。COM采取的就是这种方法。后者是智能指针类将计数保存在自由存储区(如动态分配的整型),复制时复制构造函数将这个值加1。
因此,使用引用计数机制,程序员只应通过智能指针来处理对象。在使用智能指针管理对象的同时让原始指针指向它是一种糟糕的做法,因为智能指针将在它维护的引用计数减为零时释放对象,而原始指针将继续指向已不属于当前应用程序的内存。引用计数还有一个独特的问题:如果两个对象分别存储指向对方的指针,这两个对象将永远不会被释放,因为它们的生命周期依赖性导致其引用计数最少为1。
引用链接智能指针不主动维护对象的引用计数,而只需知道计数什么时候变为零,以便能够释放对象。
之所以称为引用链接,是因为其实现是基于双向链表的。通过复制智能指针来创建新智能指针时,新指针将被插入到链表中。当智能指针离开作用域进而被销毁时,析构函数将把它从链表中删除。与引用计数的指针一样,引用链接指针也存在生命周期依赖性导致的问题。
破坏性复制是这样一种机制,即在智能指针被复制时,将对象的所有权转交给目标指针并重置原来的指针。
虽然破坏性复制机制使用起来并不直观,但它有一个优点,即可确保任何时刻只有一个活动指针指向对象。因此,它非常适合从函数返回指针以及需要利用其“破坏性”的情形。
程序清单26.3是一种破坏性复制指针的实现,它没有采用推荐的标准C++编程方法。
std::auto_ptr是最流行(也可以说是最臭名昭著,取决于您如何看)的破坏性复制指针。被传递给函数或复制给另一个指针后,这种智能指针就没有用了。C++11 摒弃了std::auto_ptr,您应使用std::unque_ptr,这种指针不能按值传递,而只能按引用传递,因为其复制构造函数和复制赋值运算符都是私有的。
程序清单26.3 一个破坏性复制智能指针
分析:
程序清单26.3演示了基于破坏性复制的智能指针实现的最重要部分。第10~17行和第20~28行分别是复制构造函数和赋值运算符。这些函数实际上使源指针在复制后失效,即复制构造函数在复制后将源指针设置为NULL,这就是“破坏性复制”的由来。赋值运算符亦如此。因此在第34行被赋给另一个指针后,pNumber就不再有效,这种行为不符合赋值操作的目的。
对破坏性复制智能指针的实现来说,程序清单26.3所示的复制构造函数和复制赋值运算符至关重要,但也深受诟病。不同于大多数C++类,该智能指针类的复制构造函数和赋值运算符不能接受 const 引用,因为它在复制源引用后使其无效。这不仅不符合传统复制构造函数和赋值运算符的语义,还让智能指针类的用法不直观。复制或赋值后销毁源引用不符合预期。鉴于这种智能指针销毁源引用,这也使得它不适合用于STL容器,如std::vector或其他任何动态集合类。这些容器需要在内部复制内容,这将导致指针失效。由于种种原因,很多程序员像躲避瘟疫一样避免使用破坏性复制智能指针。
C++标准一直支持auto_ptr,它是一种基于破坏性复制的智能指针。C++11终于摒弃了该智能指针,现在您应使用std::unique_ptr。
C++11
使用std::unique_ptr
std::unique_ptr是C++11新增的,与auto_ptr稍有不同,因为它不允许复制和赋值。
要使用std:unique_ptr,必须包含头文件<memory>:
unique_ptr 是一种简单的智能指针,类似于程序清单 26.1 所示的智能指针,但其复制构造函数和赋值运算符被声明为私有的,因此不能复制它,即不能将其按值传递给函数,也不能将其赋给其他指针。程序清单26.4演示了其用法。
程序清单26.4 使用std::unique_ptr
输出:
分析:
从输出可知,虽然smartFish指向的对象是在main()中创建的,但它被自动销毁,您无需调用delete运算符。这是 unique_ptr 的行为:当指针离开作用域时,将通过析构函数释放它拥有的对象。注意到第23行将smartFish作为参数传递给了MakeFishSwim(),这样做不会导致复制,因为MakeFishSwim()的参数为引用,如第 13 行所示。如果删除第 13 行的引用符号&,将出现编译错误,因为复制构造函数是私有的。同样,不能像第26行那样将一个unique_ptr对象赋给另一个unique_ptr对象,因为复制赋值运算符是私有的。
总之,unique_ptr比C++11已摒弃的auto_ptr更安全,因为复制和赋值不会导致源智能指针对象无效。它在销毁时释放对象,可帮助您进行简单的内存管理。
26.4 深受欢迎的智能指针库
显然,C++标准库提供的智能指针并不能满足所有程序员的需求,这就是还有很多其他智能指针库的原因。
Boost(www.boost.org)提供了一些经过测试且文档完善的智能指针类,还有很多其他的实用类。有关Boost智能指针的更详细信息,请访问 http://www.boost.org/libs/smart_ptr/ smart_ptr.htm;在这里还可下载相关的库。
同样,在 Windows 平台上编写 COM 应用程序的程序员应使用 ATL 框架中的智能指针类(如CComPtr和CComQIPtr)来管理COM对象,而不要使用原始指针。
26.5 总结
本章介绍了使用正确的智能指针有助于编写使用指针的代码,并有助于减少与内存分配和对象拥有权相关的问题。本章还介绍了各种智能指针类型,并指出在应用程序中使用智能指针类前务必要了解其行为。现在您知道,不应使用std::auto_ptr,因为它在复制和赋值时导致源指针无效。您还学习了最新的智能指针类std::unique_ptr,这是C++11新增的。
26.6 问与答
问:在需要指针vector时,是否应将auto_ptr作为vector存储的对象类型?
答:通常,不应使用std::auto_prt,因为它已被摒弃。另外,复制或赋值操作将导致源对象不可用。
问:要成为智能指针类,需要实现哪两个运算符?
答:运算符*和->,这两个运算符使得可像使用常规指针那样使用类的对象。
问:假设有一个应用程序,其中的Class1和Class2类分别包含一个指向Class2对象和Class1对象的成员属性。在这种情况下,是否应使用引用计数指针?
答:不应该。由于生命周期依赖性,引用计数将不会减少到零,导致两个类的对象永久性地留在堆中。
问:智能指针有多少种?
答:成千上万,甚至数百万。程序员应只使用文档完善且来源可靠(如来自Boost)的智能指针。
问:string类在自由存储区中动态地管理字符数组,它也是智能指针吗?
答:不是。string类通常没有实现运算符*和->,因此不属于智能指针。
26.7 作业
作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录 D 的答案。在继续学习下一章前,请务必弄懂这些答案。
1.为应用程序编写自己的智能指针前应查看什么地方?
2.智能指针是否会严重降低应用程序的性能?
3.引用计数智能指针在什么地方存储引用计数?
4.引用链接指针使用的链表机制是单向链表还是双向链表?
1.查错:指出下述代码中的错误:
2.使用unique_ptr类实例化一个Carp对象,而Carp类继承了Fish类。将该对象作为Fish指针传递时是否会出现切除问题。
3.查错:指出下述代码中的错误: