分类: C/C++
2009-08-26 15:29:16
l 在现存类上增加引用计数
到现在为止,我们所讨论的都假设我们能够访问有关类的源码。但如果我们想让一个位于支撑库中而无法修改的类获得引用计数的好处呢?不可能让它们从RCObject继承的,所以也不能对它们使用灵巧指针RCPtr。我们运气不好吗?
不是的。只要对我们的设计作小小的修改,我们就可以将引用计数加到任意类型上。
首先考虑如果从RCObject继承的话,我们的设计看起来将是什么样子。在这种情况下,我们需要增加一个类RCWidget以供用户使用,而所有的事情都和String/StringValue的例子一样,RCWidget和String相同,Widget和StringValue相同。设计看起来是这样的:
我们现在可以应用这句格言:计算机科学中的绝大部分问题都可以通过增加一个中间层次来解决。我们增加一个新类CountHolder以处理引用计数,它从RCObject继承。我们让CountHolder包含一个指针指向Widget。然后用等价的灵巧指针RCIPter模板替代RCPtr模板,它知道CountHolder类的存在。(名字中的“i”表示间接“indirect”。)修改后的设计为:
如同StringValue一样,CountHolder对用户而言,是RCWidget的实现细节。实际上,它是RCIPtr的实现细节,所以它嵌套在这个类中。RCIPtr的实现如下:
template
class RCIPtr {
public:
RCIPtr(T* realPtr = 0);
RCIPtr(const RCIPtr& rhs);
~RCIPtr();
RCIPtr& operator=(const RCIPtr& rhs);
const T* operator->() const; // see below for an
T* operator->(); // explanation of why
const T& operator*() const; // these functions are
T& operator*(); // declared this way
private:
struct CountHolder: public RCObject {
~CountHolder() { delete pointee; }
T *pointee;
};
CountHolder *counter;
void init();
void makeCopy(); // see below
};
template
void RCIPtr
{
if (counter->isShareable() == false) {
T *oldValue = counter->pointee;
counter = new CountHolder;
counter->pointee = new T(*oldValue);
}
counter->addReference();
}
template
RCIPtr
: counter(new CountHolder)
{
counter->pointee = realPtr;
init();
}
template
RCIPtr
: counter(rhs.counter)
{ init(); }
template
RCIPtr
{ counter->removeReference(); }
template
RCIPtr
{
if (counter != rhs.counter) {
counter->removeReference();
counter = rhs.counter;
init();
}
return *this;
}
template
void RCIPtr
{ // write (COW)
if (counter->isShared()) {
T *oldValue = counter->pointee;
counter->removeReference();
counter = new CountHolder;
counter->pointee = new T(*oldValue);
counter->addReference();
}
}
template
const T* RCIPtr
{ return counter->pointee; }
template
T* RCIPtr
{ makeCopy(); return counter->pointee; } // needed
template
const T& RCIPtr
{ return *(counter->pointee); }
template
T& RCIPtr
{ makeCopy(); return *(counter->pointee); } // COW thing
RCIPtr与RCPtr只两处不同。第一,RCPtr对象直接指向值对象,而RCIptr对象通过中间层的CountHolder对象指向值对象。第二,RCIPtr重载了operator->和operator*,当有对被指向的对象的非const的操作时,写时拷贝自动被执行。
有了RCIPtr,很容易实现RCWidget,因为RCWidget的每个函数都是将调用传递给RCIPtr以操作Widget对象。举个例子,如果Widget是这样的:
class Widget {
public:
Widget(int size);
Widget(const Widget& rhs);
~Widget();
Widget& operator=(const Widget& rhs);
void doThis();
int showThat() const;
};
那么RCWidget将被定义为这样:
class RCWidget {
public:
RCWidget(int size): value(new Widget(size)) {}
void doThis() { value->doThis(); }
int showThat() const { return value->showThat(); }
private:
RCIPtr
};
注意RCWidget的构造函数是怎么用它被传入的参数调用Widget的构造函数的(通过new操作符,见Item M8);RCWidget的doThis怎么调用Widget的doThis函数的;以及RCWidget的showThat怎么返回Widget的showThat的返回值的。同样要注意RCWidget没有申明拷贝构造函数和赋值操作函数,也没有析构函数。如同String类一样,它不需要这些函数。感谢于RCIPtr的行为,RCWidget的默认版本将完成正确的事情。
如果认为生成RCWidget的行为很机械,它应该自动进行,那么你是对的。不难写个小程序接受如Widget这样的类而输出RCWidget这样的类。如果你写了一个这样的程序,请让我知道。
l 评述
让我们从Widget、String、值、灵巧指针和引用计数基类中摆脱一下。给个机会回顾一下,在更广阔的环境下看一下引用计数。在更大的环境下,我们必须处理一个更高层次的问题,也就是什么时候使用引用计数?
实现引用计数不是没有代价的。每个被引用的值带一个引用计数,其大部分操作都需要以某种形式检查或操作引用计数。对象的值需要更多的内存,而我们在处理它们时需要执行更多的代码。此外,就内部的源代码而言,带引用计数的类的复杂度比不带的版本高。没有引用计数的String类只依赖于自己,而我们最终的String类如果没有三个辅助类(StringValue、RCObject和RCPtr)就无法使用。确实,我们这个更复杂的设计确保在值可共享时的更高的效率;免除了跟踪对象所有权的需要,提高了引用计数的想法和实现的可重用性。但,这四个类必须写出来、被测试、文档化、和被维护,比单个类要多做更多的工作。即使是管理人员也能看出这点。
引用计数是基于对象通常共享相同的值的假设的优化技巧(参见Item M18)。如果假设不成立的话,引用计数将比通常的方法使用更多的内存和执行更多的代码。另一方面,如果你的对象确实有具体相同值的趋势,那么引用计数将同时节省时间和空间。共享的值所占内存越大,同时共享的对象数目越多,节省的内存也就越大。创建和销毁这个值的代价越大,你节省的时间也越多。总之,引用计数在下列情况下对提高效率很有用:
少量的值被大量的对象共享。这样的共享通常通过调用赋值操作和拷贝构造而发生。对象/值的比例越高,越是适宜使用引用计数。
对象的值的创建和销毁代价很高昂,或它们占用大量的内存。即使这样,如果不是多个对象共享相同的值,引用计数仍然帮不了你任何东西。
只有一个方法来确认这些条件是否满足,而这个方法不是猜测或依赖直觉(见Item M16)。这个方法是使用profiler或其它工具来分析。使用这种方法,你可以发现是否创建和销毁值的行为是性能瓶颈,并能得出对象/值的比例。只有当你手里有了这些数据,你才能得出是否从引用计数上得到的好处超过其缺点。
即使上面的条件满足了,使用引用计数仍然可能是不合适的。有些数据结构(如有向图)将导致自我引用或环状结构。这样的数据结构可能导致孤立的自引用对象,它没有被别人使用,而其引用计数又绝不会降到零。因为这个无用的结构中的每个对象被同结构中的至少一个对象所引用。商用化的垃圾收集体系使用特别的技术来查找这样的结构并消除它们,但我们现在使用的这个简单的引用计数技术不是那么容易扩充出这个功能的。
即使效率不是主要问题,引用计数仍然很吸引人。如果你不放心谁应该去执行删除动作,那么引用计数正是这种让你放下担子的技巧。很多程序员只因为这个原因就使用引用计数。
让我们用最后一个问题结束讨论。当RCObject::removeReference减少对象的引用计数时,它检查新值是否为0。如果是,removeReference通过调用delete this销毁对象。这个操作只在对象是通过调用new生成时才安全,所以我们需要一些方法以确保RCObject只能用这种方法产生。
此处,我们用习惯方法来解决。RCObject被设计为只作被引用计数的值对象的基类使用,而这些值对象应该只通过灵巧指针RCPtr引用。此外,值对象应该只能由值会共享的对象来实例化;它们不能被按通常的方法使用。在我们的例子中,值对象的类是StringValue,我们通过将它申明为String的私有而限制其使用。只有String可以创建StringValue对象,所以String类的作者应该确保这些值对象都是通过new操作产成的。
于是,我们限制RCObject只能在堆上创建的方法就是指定一组满足这个要求的类,并确保只有这些类能创建RCObject对象。用户不可能无意地(或有意地)用一种不恰当的方法创建RCObject对象。我们限制了创建被引用计数对象的权力,当我们交出这个权力时,必须明确其附带条件是满足创建对象的限制条件。
l 注10
标准C++运行库中的string类型(见Item E49和Item M35)同时使用了方法2和方法3。从非const的operator[]中返回的引用直到下一次的可能修改这个string的函数的调用为止都是有效的。在此之后,使用这个引用(或它指向的字符),其结果未定义。这样就它允许了:string的可共享标志在调用可能修改string的函数时被重设为true。
虽然你和你的亲家可能住在同一地理位置,但就整个世界而言,通常不是这样的。很不幸,C++还没有认识到这个事实。至少,从它对数组的支持上可以看出一些迹象。在FORTRAN、BASIC甚至是COBOL中,你可以创二维、三维乃至n维数组(OK,FORTRAN只能创最多7维数组,但别过于吹毛求疵吧)。但在C++中呢?只是有时可以,而且也只是某种程度上的。
这是合法的:
int data[10][20]; // 2D array: 10 by 20
而相同的结构如果使用变量作维的大小的话,是不可以的:
void processInput(int dim1, int dim2)
{
int data[dim1][dim2]; // error! array dimensions
... // must be known during
}
甚至,在堆分配时都是不合法的:
int *data =
new int[dim1][dim2]; // error!
l 实现二维数组
多维数组在C++中的有用程度和其它语言相同,所以找到一个象样的支持方法是很重要的。常用方法是C++中的标准方法:用一个类来实现我们所需要的而C++语言中并没有提供的东西。因此,我们可以定义一个类模板来实现二维数组:
template
class Array2D {
public:
Array2D(int dim1, int dim2);
...
};
现在,我们可以定义我们所需要的数组了:
Array2D
Array2D
new Array2D
void processInput(int dim1, int dim2)
{
Array2D
...
}
然而,使用这些array对象并不直接了当。根据C和C++中的语法习惯,我们应该能够使用[]来索引数组:
cout << data[3][6];
但我们在Array2D类中应该怎样申明下标操作以使得我们可以这么做?
我们最初的冲动可能是申明一个operator[][]函数:
template
class Array2D {
public:
// declarations that won't compile
T& operator[][](int index1, int index2);
const T& operator[][](int index1, int index2) const;
...
};
然而,我们很快就会中止这种冲动,因为没有operator[][]这种东西,别指望你的编译器能放过它。(所有可以重载的运算符见Item M7。)我们得另起炉灶。
如果你能容忍奇怪的语法,你可能会学其它语言使用()来索引数组。这么做,你只需重载operator():
template
class Array2D {
public:
// declarations that will compile
T& operator()(int index1, int index2);
const T& operator()(int index1, int index2) const;
...
};
用户于是这么使用数组:
cout << data(3, 6);
这很容易实现,并很容易推广到任意多维的数组。缺点是你的Array2D对象看起来和内嵌数组一点都不象。实际上,上面访问元素(3,6)的操作看起来相函数调用。
如果你拒绝让访问数组行为看起来象是从FORTRAN流窜过来的,你将再次会到使用[]上来。虽然没有operator[][],但写出下面这样的代码是合法的:
int data[10][20];
...
cout << data[3][6]; // fine
说明了什么?
说明,变量data不是真正的二维数组,它是一个10元素的一维数组。其中每一个元素又都是一个20元素的数组,所以表达式data[3][6]实际上是(data[3])[6],也就是data的第四个元素这个数组的第7个元素。简而言之,第一个[]返回的是一个数组,第二个[]从这个返回的数组中再去取一个元素。
我们可以通过重载Array2D类的operator[]来玩同样的把戏。Array2D的operator[]返回一个新类Array1D的对象。再重载Array1D的operator[]来返回所需要的二维数组中的元素:
template
class Array2D {
public:
class Array1D {
public:
T& operator[](int index);
const T& operator[](int index) const;
...
};
Array1D operator[](int index);
const Array1D operator[](int index) const;
...
};
现在,它合法了:
Array2D
...
cout << data[3][6]; // fine
这里,data[3]返回一个Array1d对象,在这个对象上的operator[]操作返回二维数组中(3,6)位置上的浮点数。
Array2D的用户并不需要知道Array1D类的存在。这个背后的“一维数组”对象从概念上来说,并不是为Array2D类的用户而存在的。其用户编程时就象他们在使用真正的二维数组一样。对于Array2D类的用户这样做是没有意义的:为了满足C++的反复无常,这些对象必须在语法上兼容于其中的元素是另一个一维数组的一个一维数组。
每个Array1D对象扮演的是一个一维数组,而这个一维数组没有在使用Array2D的程序中出现。扮演其它对象的对象通常被称为代理类。在这个例子里,Array1D是一个代理类。它的实例扮演的是一个在概念上不存在的一维数组。(术语代理对象(proxy object)和代理类(proxy classs)还不是很通用;这样的对象有时被叫做surrogate。)
l 区分通过operator[]进行的是读操作还是写操作
使用代理来实现多维数组是很通用的的方法,但代理类的用途远不止这些。例如,Item M5中展示了代理类可以怎样用来阻止单参数的构造函数被误用为类型转换函数。在代理类的各中用法中,最神奇的是帮助区分通过operator[]进行的是读操作还是写操作。
考虑一下带引用计数而又支持operator[]的string类型。这样的类的细节见于Item M29。如果你还不了解引用记数背后的概念,那么现在就去熟悉Item M29中的内容将是个好主意。
支持operator[]的string类型,允许用户些下这样的代码:
String s1, s2; // a string-like class; the
// use of proxies keeps this
// class from conforming to
// the standard string
... // interface
cout << s1[5]; // read s1
s2[5] = 'x'; // write s2
s1[3] = s2[8]; // write s1, read s2
注意,operator[]可以在两种不同的情况下调用:读一个字符或写一个字符。读是个右值操作;写是个左值操作。(这个名词来自于编译器,左值出现在赋值运算的左边,右值出现在赋值运算的右边。)通常,将一个对象做左值使用意味着它可能被修改,做右值用意味着它不能够被修改。
我们想区分将operator[]用作左值还是右值,因为,对于有引用计数的数据结构,读操作的代价可以远小于写操作的代价。如Item M29解释的,引用计数对象的写操作将导致整个数据结构的拷贝,而读不需要,只要简单地返回一个值。不幸的是,在operator[]内部,没有办法确定它是怎么被调用的,不可能区分出它是做左值还是右值。
“但,等等,”你叫道,“我们不需要。我们可以基于const属性重载operator[],这样就可以区分读还是写了。”换句话说,你建议我们这么解决问题:
class String {
public:
const char& operator[](int index) const; // for reads
char& operator[](int index); // for writes
...
};
唉,这不能工作。编译器根据调用成员函数的对象的const属性来选择此成员函数的const和非const版本,而不考虑调用时的环境。因此:
String s1, s2;
...
cout << s1[5]; // calls non-const operator[],
// because s1 isn't const
s2[5] = 'x'; // also calls non-const
// operator[]: s2 isn't const
s1[3] = s2[8]; // both calls are to non-const
// operator[], because both s1
// and s2 are non-const objects
于是,重载operator[]没能区分读还是写。
在Item M29中,我们屈从了这种不令人满意的状态,并保守地假设所有的operator[]调用都是写操作。这次,我们不会这么轻易放弃的。也许不可能在operator[]内部区分左值还是右值操作,但我们仍然想区分它们。于是我们将去寻找一种方法。如果让你自己被其可能性所限制,生命还有什么快乐?
我们的方法基于这个事实:也许不可能在operator[]内部区分左值还是右值操作,但我们仍然能区别对待读操作和写操作,如果我们将判断读还是写的行为推迟到我们知道operator[]的结果被怎么使用之后的话。我们所需要的是有一个方法将读或写的判断推迟到operator[]返回之后。(这是lazy原则(见Item M17)的一个例子。)
proxy类可以让我们得到我们所需要的时机,因为我们可以修改operator[]让它返回一个(代理字符的)proxy对象而不是字符本身。我们可以等着看这个proxy怎么被使用。如果是读它,我们可以断定operator[]的调用是读。如果它被写,我们必须将operator[]的调用处理为写。
我们马上来看代码,但首先要理解我们使用的proxy类。在proxy类上只能做三件事:
l 创建它,也就是指定它扮演哪个字符。
l 将它作为赋值操作的目标,在这种情况下可以将赋值真正作用在它扮演的字符上。这样被使用时,proxy类扮演的是左值。
l 用其它方式使用它。这时,代理类扮演的是右值。
这里是一个被带引用计数的string类用作proxy类以区分operator[]是作左值还是右值使用的例子:
class String { // reference-counted strings;
public: // see Item 29 for details
class CharProxy { // proxies for string chars
public:
CharProxy(String& str, int index); // creation
CharProxy& operator=(const CharProxy& rhs); // lvalue
CharProxy& operator=(char c); // uses
operator char() const; // rvalue
// use
private:
String& theString; // string this proxy pertains to
int charIndex; // char within that string
// this proxy stands for
};
// continuation of String class
const CharProxy
operator[](int index) const; // for const Strings
CharProxy operator[](int index); // for non-const Strings
...
friend class CharProxy;
private:
RCPtr
};
除了增加的CharProxy类(我们将在下面讲解)外,这个String类与Item M29中的最终版本相比,唯一不同之处就是所有的operator[]函数现在返回的是CharProxy对象。然而,String类的用户可以忽略这一点,并当作operator[]返回的仍然是通常形式的字符(或其引用,见Item M1)来编程:
String s1, s2; // reference-counted strings
// using proxies
...
cout << s1[5]; // still legal, still works
s2[5] = 'x'; // also legal, also works
s1[3] = s2[8]; // of course it's legal,
// of course it works
有意思的不是它能工作,而是它为什么能工作。
先看这条语句:
cout << s1[5];
表达式s1[5]返回的是一CharProxy对象。没有为这样的对象定义输出流操作,所以编译器努力地寻找一个隐式的类型转换以使得operator<<调用成功(见Item M5)。它们找到一个:在CahrProxy类内部申明了一个隐式转换到char的操作。于是自动调用这个转换操作,结果就是CharProxy类扮演的字符被打印输出了。这个CharProxy到char的转换是所有代理对象作右值使用时发生的典型行为。
作左值时的处理就不一样了。再看:
s2[5] = 'x';
和前面一样,表达式s2[5]返回的是一个CharProxy对象,但这次它是赋值操作的目标。由于赋值的目标是CharProxy类,所以调用的是CharProxy类中的赋值操作。这至关重要,因为在CharProxy的赋值操作中,我们知道被赋值的CharProxy对象是作左值使用的。因此,我们知道proxy类扮演的字符是作左值使用的,必须执行一些必要的操作以实现字符的左值操作。
同理,语句
s1[3] = s2[8];
调用作用于两个CharProxy对象间的赋值操作,在此操作内部,我们知道左边一个是作左值,右边一个作右值。
“呀,呀,呀!”你叫道,“快给我看。”OK,这是String的opertator[]函数的代码:
const String::CharProxy String::operator[](int index) const
{
return CharProxy(const_cast
}
String::CharProxy String::operator[](int index)
{
return CharProxy(*this, index);
}
每个函数都创建和返回一个proxy对象来代替字符。根本没有对那个字符作任何操作:我们将它推迟到直到我们知道是读操作还是写操作。
注意,operator[]的const版本返回一个const的proxy对象。因为CharProxy::operator=是个非const的成员函数,这样的proxy对象不能作赋值的目标使用。因此,不管是从operator[]的const版本返回的proxy对象,还是它所扮演的字符都不能作左值使用。很方便啊,它正好是我们想要的const版本的operator[]的行为。
同样要注意在const的operator[]返回而创建CharProxy对象时,对*this使用的const_cast(见Item M2)。这使得它满足了CharProxy类的构造函数的需要,它的构造函数只接受一个非const的String类。类型转换通常是领人不安的,但在此处,operator[]返回的CharProxy对象自己是const的,所以不用担心String内部的字符可能被通过proxy类被修改。
通过operator[]返回的proxy对象记录了它属于哪个string对象以及所扮演的字符的下标:
String::CharProxy::CharProxy(String& str, int index)
: theString(str), charIndex(index) {}
将proxy对象作右值使用时很简单--只需返回它所扮演的字符就可以了:
String::CharProxy::operator char() const
{
return theString.value->data[charIndex];
}
如果已经忘了string对象的value成员和它指向的data成员的关系的话,请回顾一下Item M29以增强记忆。因为这个函数返回了一个字符的值,并且又因为C++限定这样通过值返回的对象只能作右值使用,所以这个转换函数只能出现在右值的位置。
回头再看CahrProxy的赋值操作的实现,这是我们必须处理proxy对象所扮演的字符作赋值的目标(即左值)使用的地方。我们可以将CharProxy的赋值操作实现如下:
String::CharProxy&
String::CharProxy::operator=(const CharProxy& rhs)
{
// if the string is sharing a value with other String objects,
// break off a separate copy of the value for this string only
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
// now make the assignment: assign the value of the char
// represented by rhs to the char represented by *this
theString.value->data[charIndex] =
rhs.theString.value->data[rhs.charIndex];
return *this;
}
如果与Item M29中的非const的String::operator[]进行比较,你将看到它们极其相似。这是预料之中的。在Item M29中,我们悲观地假设所有非const的operator[]的调用都是写操作,所以实现成这样。现在,我们将写操作的实现移入CharProxy的赋值操作中,于是可以避免非const的operator[]的调用只是作右值时所多付出的写操作的代价。随便提一句,这个函数需要访问string的私有数据成员value。这是前面将CharProxy申明为string的友元的原因。
第二个CharProxy的赋值操作是类似的:
String::CharProxy& String::CharProxy::operator=(char c)
{
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
theString.value->data[charIndex] = c;
return *this;
}