Item28. 智能(smart)指针:
当你使用智能指针替代C++的内建指针(dumb pointer),你就能控制下面这些方面的指针的行为:
构造和析构(Construction and destruction)、拷贝和赋值(Copying and assignment)、解引用(Dereferencing)
大多数智能指针模板看起来都象这样:
(题外话:当面对异常时,让对象自己开始(constructor)和结束(destructor)日志记录比显示地调用函数可以使的程序更健壮、更容易。)
智能指针的构造、赋值和析构
如果一个智能指针拥有它所指向的对象,当它被销毁时必须负责删除这个对象。
auto_ptr模板的实现大致如下:
auto_ptr采用了更为灵活的解决方案:当auto_ptr被拷贝或赋值时,同时转移对象的所有权给新的auto_ptr。
注意赋值操作符在接受新对象的所有权以前必须删除原来拥有的对象。如果不这样做,原来拥有的对象将永远不会被删除。记住:除了auto_ptr对象,不会有其它人拥有auto_ptr所指向的对象。
因为当调用auto_ptr的拷贝构造函数时,对象的所有权被传递出去,所以通过传值方式传递auto_ptr对象是一个很糟糕的方法。即:
通过const引用传递(Pass-by-reference-to-const)才是合适的做法:
实现解引用操作符
注意返回类型是一个引用。如果返回对象,尽管编译器允许这么做,这也将会导致灾难性后果。必须时刻牢记:pointee不用必须指向T类型对象;它也可以指向T的派生类对象。如果在这种情况下operator*函数返回的是T类型对象而不是派生类对象的引用,你的函数实际上返回的是一个错误类型的对象!(这是一个slicing问题,参见Effective C++条款22和本书条款13)。
测试智能指针是否为空
为了达到这个目的,传统上采取的是转换为void* 类型:
它有一个缺点:允许灵巧指针与完全不同的类型之间进行比较:
SmartPtr<Apple> pa;
SmartPtr<Orange> po;
...
if (pa == po) ... // 这也能够被成功编译!
即使在SmartPtr<Apple> 和 SmartPtr<Orange>之间没有operator= 函数,也能够编译,因为灵巧指针被隐式地转换为void*指针。
有一种折衷之策可以提供合理的测试空值的语法形式,同时把不同类型的灵巧指针之间进行比较的可能性降到最低。这就是在智能指针类中重载operator!,当且仅当灵巧指针是一个空指针时,operator!返回true:
客户端程序如下所示:
但是这样就不正确了:
if (ptn == 0) ... // 仍然错误
if (ptn) ... // 也是错误的
仅在这种情况下会存在不同类型之间进行比较:
SmartPtr<Apple> pa;
SmartPtr<Orange> po;
...
if (!pa == !po) ... // 仍然能够编译
幸好程序员不会经常这样编写代码。
把智能指针转变成dumb指针
并且这个函数也消除了测试空值的问题:
if (pt == 0) ... // 正确, 把pt转变成
// Tuple*
if (pt) ... // 同上
if (!pt) ... // 同上 (reprise)
然而,它也有类型转换函数所具有的缺点(几乎总是这样,看条款5)。它使得客户端能够很容易地直接访问dumb指针,绕过“类指针(pointer-like)”对象所提供的“智能”特性。
提供到dumb指针的隐式类型转换的灵巧指针类也暴露了一个非常讨厌的bug。考虑这个代码:
DBPtr<Tuple> pt = new Tuple;
...
delete pt;
这段代码应该不能被编译,pt不是指针,它是一个对象,你不能删除一个对象。只有指针才能被删除,对么?
当然对了。但是回想一下条款5:编译器使用隐式类型转换来尽可能使函数调用成功,再回想一下条款8:使用delete会调用析构函数和operator delete,两者都是函数。编译器欲使在delete语句里的两个函数成功调用,就把pt隐式转换为Tuple*,然后删除它。这样做必然会破坏你的程序。
如果pt拥有它指向的对象,对象就会被删除两次,一次在调用delete时,第二次在pt的析构函数被调用时。如果pt不拥有对象,而是其他人拥有,拥有者可以删除pt,但是如果pt指向对象的拥有者不是删除pt的人,有删除权的拥有者以后还会再次删除该对象。不论是前者还是后者都会导致一个对象被删除两次,这样做会产生不能预料的后果。
所以底线很简单:除非有一个让人非常信服的理由,否则绝对不要提供转换到dumb指针的隐式类型转换操作符。
智能指针和继承类到基类的类型转换
这个函数能够这样使用:
Cassette *funMusic = new Cassette("Alapalooza");
CD *nightmareMusic = new CD("Disco Hits of the 70s");
displayAndPlay(funMusic, 10);
displayAndPlay(nightmareMusic, 0);
这并没有什么值得惊讶的东西,但是当我们用智能指针替代dumb指针,会发生什么呢:
void displayAndPlay(const SmartPtr<MusicProduct>& pmp,
int numTimes);
SmartPtr<Cassette> funMusic(new Cassette("Alapalooza"));
SmartPtr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));
displayAndPlay(funMusic, 10); // 错误!
displayAndPlay(nightmareMusic, 0); // 错误!
幸运的是,有办法避开这种限制,这种方法的核心思想(不是实际操作)很简单:对于可以进行隐式转换的每个智能指针类都提供一个隐式类型转换操作符。但这种方法有两个缺点。第一,你必须手工特化(specialize)SmartPtr类,但是这种做法在很大程度上也就破坏了模板的通用性。第二,你可能需要添加许多类型转换符,因为你指向的对象可以位于继承层次中很深的位置,你必须为直接或间接继承的每一个基类提供一个类型转换符。
所幸C++最近的语言扩展,让我们能够做到这一点,这个扩展能声明(非虚)成员函数模板(通常就叫成员模板(member template)),你能使用它来生成智能指针类型转换函数,如下:
假设编译器有一个指向T对象的智能指针,它要把这个对象转换成指向“T的基类”的智能指针。编译器首先检查SmartPtr<T>类的定义,看其有没有声明相应的类型转换符,结果它没有声明。(这不意味着在上面的模板没有声明类型转换符)编译器然后检查是否存在一个成员函数模板,可以被实例化,用来进行它所期望的类型转换。它发现了一个这样的模板(带有形式类型参数newType),所以它把newType绑定到T的基类类型上,来实例化模板。
这种方法可以成功地用于任何合法的指针类型转换。如果你有dumb指针T1*和另一种dumb指针T2*,当且仅当你能隐式地把T1*转换为T2*时,你就能够隐式地把指向T1的智能指针类型转换为指向T2的智能指针类型。
但是:如何能够让智能指针在基于继承的类型转换时的行为与dumb指针一样呢?答案很简单:不可能。正如Daniel Edelson所说,智能指针固然灵巧,但不是指针。最好的方法是使用成员模板生成类型转换函数,在会产生二义性结果的地方使用casts。
智能指针和const
可以建立const和non-const对象和指针的四种不同组合:
SmartPtr<CD> p; // non-const 对象
// non-const 指针
SmartPtr<const CD> p; // const 对象,
// non-const 指针
const SmartPtr<CD> p = &goodCD; // non-const 对象
// const指针
const SmartPtr<const CD> p = &goodCD; // const 对象
// const 指针
但是美中不足的是,如下代码:
CD *pCD = new CD("Famous Movie Themes");
const CD * pConstCD = pCD;
在dumb指针身上都是正确的,但是如果我们试图把这种方法用在灵巧指针上,情况立刻变了:
SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtr<const CD> pConstCD = pCD; // 正确么?
因为SmartPtr<CD> 与SmartPtr<const CD>是完全不同的类型。在编译器看来,它们是毫不相关的,所以没有理由相信它们是赋值兼容的。
如果你使用的编译器支持成员模板,就可以利用前面所说的技巧自动生成你需要的隐式类型转换操作。
Item29. 引用计数:
这个技巧有两个常用动机。第一个是简化跟踪堆中的对象的过程。第二个动机是由于一个简单的常识。如果很多对象有相同的值,将这个值存储多次是很无聊的。
实现引用计数
基本设计是这样的:
嵌套在String类的私有区内的StringValue类来保存引用计数及其跟踪的值,它唯一的用处就是帮助我们实现String类。
下面是StringValue的实现:
StringValue一没有拷贝构造函数和赋值运算;二没有提供对refCount的操作。不过少掉的功能将由String类提供。StringValue的主要目的是提供一个空间将一个特定的值和共享此值的String对象的数目联系起来。StringValue给了我们这个,这就足够了。
以下是String类的拷贝构造函数:
以下是String类的析构函数:
以下是赋值运算符:
写时拷贝(Copy-on-Write)
考虑一下数组下标操作[],它允许字符串中的单个字符被读或写:
这个函数的const版本的实现很容易,因为它是一个只读操作,String对象的值不受影响:
非const的operator[]版本就是另一回事了。它可能是被用来读一个字符,也可能用来写一个字符。不幸的是,C++编译器没有办法告诉我们一个特定的operator[]是用作读的还是写的,所以我们必须保守地假设“所有”调用非const operator[]的行为都是为了写操作。(Proxy类可以帮助我们区分读还是写,见Item30)
写时拷贝是延时求值(lazy evaluation)的一个特例,而延时求值是提高效率的一个更宽泛方法。
指针、引用与定时拷贝
大部分情况下,写时拷贝可以同时保证效率和正确性。只有一个挥之不去的问题。看一下这样的代码:
String s1 = "Hello";
char *p = &s1[1];
现在看增加一条语句:
String s2 = s1;
下面这样的语句将有一个讨厌的BUG:
*p = 'x'; // modifies both s1 and s2!
解决方案是:在每个StringValue对象中增加一个标志以指出它是否可共享。
拷贝构造函数的实现:
非const的operator[]版本是唯一将共享标志设为false的地方:
如果使用Item30中的proxy类的技巧以区分operator[]读写操作,你通常可以降低必须被设为不可共享的StringValue对象的数目。
用于引用计数的基类
引用计数不只适用于字符串,只要是多个对象具有相同值的类理论上说都可以使用引用计数。
第一步是构建一个基类RCObject,任何需要引用计数的类都必须从它继承。RCObject封装了引用计数功能,以及增加和减少引用计数的函数。它还包含了当这个值不再被用到时(也就是引用计数为0时)销毁对象的代码。最后,它包含了一个字段以跟踪这个值对象是否可共享,并提供查询这个值和将它设为false的函数。不需将可共享标志设为true的函数,因为所有的值对象默认都是可共享的。一旦一个对象变成了不可共享,将没有办法使它再次成为可共享。
RCObject的定义如下:
注意这里的虚析构函数,它明确表明这个类是被设计了作基类使用的。同时要注意这个析构函数是纯虚的,它明确表明这个类只能作基类使用。
RCOject的实现代码:
在两个构造函数中都将refCount设成了0。实际上REObjects的创建者会把refCount设为1。
另外拷贝构造函数也将refCount设成了0,而不管被拷贝的RCObject对象的refCount的值。这是因为我们正在创建新对象以代表这个值,而这个新的对象总是未被共享的,并且只被它的创建者引用。
!!!注意,创建者将必须负责将refCount设为正确的值。
RCObject的赋值运算看起来完全是颠覆性的:它不做任何事情。
为了使用新写的引用计数基类,将StringValue修改为从RCObject继承而得到引用计数功能:
这个版本的StringValue和前面的几乎一样,唯一改变的就是StringValue的成员函数不再处理refCount字段。RCObject现在接管了这个工作。
引用计数操作的自动化
供引用计数对象使用的智能指针模板:
这个模板可以让智能指针对象控制在构造、赋值、析构时控制所发生的事情。
它有两个构造函数:
因为init()中的pointee = new T(*pointee)会创建一个新的T对象,并用拷贝构造函数进行了初始化。所以我们必须在StringValue中增加这样的一个构造函数:
RCPtr<T>不仅仅假设T类型有深拷贝的构造函数,它还要求T从RCObject继承,或至少提供了RCObject的所提供的函数。
RCPtr<T>的最后一个假设是它所指向的对象类型为T。这似乎是显然的。毕竟pointee的类型被申明为T*。但pointee可能实际上指向T的一个派生类。我们可以提供使用虚拷贝构造函数(见Item25)来实现这一点。
用这种方式实现了RCPtr的构造函数后,类的其它函数实现得很轻快。赋值运算很简洁明了:
析构函数很容易。当一个RCPtr被销毁时,它只是简单地将它对引用计数对象的引用移除:
最后,RCPtr的模拟指针的操作就是在Item28中看到的智能指针的部分:
合在一起
终于可以这些零散的代码放在一起,使用可重用的RCObject和RCPtr类构建带引用计数的String类。
类的定义是:
这里有一个重大的不同:这个String类的公有接口和本条款开始处我们使用的版本不同。拷贝构造函数在哪里?赋值运算在哪里?析构函数在哪里?这儿明显有问题。实际上,没问题。它工作得很好。如果你没看出为什么,prepare yourself for a C++ epiphany。
String对象仍然可以被拷贝,并且,这个拷贝可以正确处理后台被引用计数的StringValue对象,但String类不需要写下哪怕一行代码。因为编译器为String自动生成的拷贝构造函数将自动调用其RCPtr成员的拷贝构造函数,而这个拷贝构造函数完成所有的对StringValue对象的必要操作,包括它的引用计数。RCPtr是一个智能指针,所以这是它的本职工作。它同样处理赋值和析构,所以String类同样不需要写出这些函数。
下面是RCObject的实现:
下面是RCPtr的实现:
下面是String::StringValue的实现:
最后是String,它的实现是:
为既有类添加引用计数
到现在为止,我们所讨论的都假设我们能够访问有关类的源码。但如果我们想让一个位于不能被修改的程序库中的类获得引用计数的好处呢?
可以套用这句格言:计算机科学中的绝大部分问题都可以通过增加一个中间层次来解决。
增加一个新类CountHolder以处理引用计数,它从RCObject继承。我们让CountHolder包含一个指针指向Widget。然后用等价的智能指针RCIPter模板替代RCPtr模板,它知道CountHolder类的存在。(名字中的“i”表示间接“indirect”。)
RCIPtr的实现如下:
RCIPtr与RCPtr只两处不同。第一,RCPtr对象直接指向值对象,而RCIptr对象通过中间层的CountHolder对象指向值对象。第二,RCIPtr重载了operator->和operator*,当有对被指向的对象的非const的操作时,写时拷贝自动被执行。
有了RCIPtr,很容易实现RCWidget,因为RCWidget的每个函数都是将调用传递给RCIPtr以操作Widget对象。举个例子,如果Widget是这样的:
那么RCWidget将被定义为这样:
注意RCWidget的构造函数是怎么用它被传入的参数调用Widget的构造函数的;RCWidget的doThis怎么调用Widget的doThis函数的;以及RCWidget的showThat怎么返回Widget的showThat的返回值的。同样要注意RCWidget没有申明拷贝构造函数和赋值操作函数,也没有析构函数。
小结:引用计数通常假设对象共享相同的值。如果假设不成立的话,引用计数将比通常的方法使用更多的内存和执行更多的代码。另一方面,如果你的对象确实有具体相同值的趋势,那么引用计数将同时节省时间和空间。
总之,引用计数在下列情况下对提高效率很有用:
①少量的值被大量的对象共享。这样的共享通常通过调用赋值操作和拷贝构造而发生。对象/值的比例越高,越是适宜使用引用计数。
②对象的值的创建和销毁代价很高昂,或它们占用大量的内存。即使这样,如果不是多个对象共享相同的值,引用计数仍然帮不了你任何东西。
使用profiler或其它工具来分析才能确认这些条件是否满足。
即使上面的条件满足了,使用引用计数仍然可能是不合适的。有些数据结构(如有向图)将导致自我引用或环状结构。这样的数据结构可能导致孤立的自引用对象,它没有被别人使用,而其引用计数又绝不会降到零。因为这个不被外部对象用到的结构中的每个对象都被该结构内部的至少一个对象所引用。
让我们用最后一个问题结束讨论。当RCObject::removeReference减少对象的引用计数时,它检查新值是否为0。如果是,removeReference通过调用delete this销毁对象。这个操作只在对象是通过调用new生成的时才安全,所以我们需要一些方法以确保RCObject只能用这种方法产生。
此处,我们用约定的方法来解决。RCObject被设计为只作被引用计数的值对象的基类使用,而这些值对象应该只通过智能指针RCPtr引用。此外,值对象应该只能由值会共享的对象来实例化;它们不能被按通常的方法使用。
于是,我们可以指定一组满足这个要求的类,并确保只有这些类能创建RCObject对象,从而限制RCObject只能在堆上创建。