reference counting 技术的发展有两个动机,第一是,为了简化 heap object 周边的记录工作,它可以消除记录对象拥有权的符合,因为当对象运用了 reference counting 技术,它便拥有它自己。一旦不再有任何人使用它,它便自动销毁自己。也因此,reference couting 构建出垃圾回收机制的一个简单形式;第二是,为了实现一种常识。如果许多对象拥有相同的值,那么将那个值多次存储是很愚蠢的,最好是让所有等值对象共享一份实质。这么做不仅节省了内存,也是程序速度更加快速,因为不再需要构造和析构多余副本。
参考下面的可能:
String a, b, c, d, e;
a = b = c = d = e = "Hello";
此时会有 5 个对象,5 个 Hello 的数据,为了防止浪费,我们希望只存一份 Hello 数据,其他对象都共享这一份数据实体。但是我们必须追踪记录有多少个对象共享此值,这就引入了 reference counting。
reference counting 的实现需要知道的是,我们是要为每一个字符串值准备一个引用次数,而不是为每一个字符串对象准备。这暗示了对象值和引用次数之间的耦合关系。所以应该设计一个 class,存储引用次数和它们所追踪的对象值。
我们设计的类看起来像这样:
class String {
public:
...
private:
struct StringValue { ... }; // 持有一个引用次数和一个字符串值
StringValue *value; // String 的值
};
下面是 StringValue 的定义:
struct StringValue {
int refCount;
char *data;
StringValue(const char *initValue);
~StringValue();
};
String::StringValue::StringValue(const char *initValue)
: refCount(1) {
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::~StringValue() {
delete[] data;
}
现在看它第一个构造函数:
String::String(const char *initValue)
: value(new StringValue(initValue)) { }
需要注意的是,如果分开构造拥有相同值的 String 对象,它们并不共享同一个数据结构:
String s1("More Effective C++");
String s2("More Effective C++");
只要令 String(或 StringValue)追踪现有的 StringValue 对象,并仅在面对真正独一无二的字符串时才产生新的 StringValue 对象,就不会发生上述情况。
现在看复制构造函数:
String::String(const String& rhs) : value(rhs.value) {
++value->refCount;
}
下面这样的代码:
s1 = s2;
就会共享同一份数据实体。它不需要分配内存给字符串第二个副本,也不用归还内存。这里只需要将指针复制一份,并将引用次数加 1。
再来看析构函数,只要某个 StringValue 的引用次数不是 0,它就一定不能被销毁。如果被析构的 String 是该值的唯一用户,String 析构函数才应该销毁 StringValue 对象:
String::~String() {
if (--value->refCount == 0) delete value;
}
现在考虑 String 的赋值 *** 作,当赋值之后,s1 和 s2 指向同一个 StringValue 对象,该对象引用次数加一。此外,s1 赋值之前的 StringValue 对象的引用次数应该减 1 :
String& String::operator=(const String& rhs) {
// 如果数值相同,什么也不做
if (value == rhs.value) {
return *this;
}
// 如果没有其他人使用,则销毁
if (--value->refCount == 0) {
delete value;
}
value = rhs.value; // 共享 rhs 数据
++value->refCount;
return *this;
}
写时才复制(copy-on-write)
现在我们考虑方括号 *** 作符,它允许字符串中的个别字符被读取或被写:
class String {
public:
const char& operator[](int index) const; // const Strings
char& operator[](int index); // non-const Strings
...
};
此函数的 const 并没有写动作,所以实现起来很简单:
const char& String::operator[](int index) const {
return value->data[index];
}
但是这个函数的 non-const 版本,可以执行读 *** 作,也可以执行写 *** 作。但是 C++ 编译器并不会告诉我们 operator[] 被用来执行什么 *** 作,所以我们必须假设 non-const operator[] 全不同于写 *** 作(条款 30 介绍的 proxy class 可以帮助我们区分)。
任何时候当我们返回一个 reference,指向 String 的 StringValue 对象内的一个字符时,我们必须确保该 StringValue 对象的引用次数为 1。可以参考下面的做法:
char& String::operator[](int index) {
// 如果该对象和其他 String 对象共享同一实质,就复制出另一个副本供自己使用
if (value->refCount > 1) {
--value->refCount; // 将目前的引用次数减 1
value = new StringValue(value->data); // 为自己做一个新副本
}
return value->data[index];
}
这个观念就是:和其它对象共享一份实值,直到我们必须对自己所拥有的那一份实值进行写动作。
pointer、reference、copy-on-writecopy-on-write 使我们几乎同时保留效率和正确性。但是会有一个问题无法解决,参考下面例子:
String s1 = "Hello";
char *p = &s1[1];
String s2 = s1;
此时 String copy constructor 会造成 s2 共享 s1 的 StringValue,指针 p 指向 Hello 中的字符 e 的地址。此时
*p = 'x'; // 同时修改 s1 和 s2
String copy constructor 侦测不出这个问题,因为它没办法知道目前存在要给指针,指向 s1 的 StringValue 对象。将 String 的 non-const operator[] 返回值的 reference 储存起来,也会有这种问题。
对于这个问题,我们可以有三种做法。
第一种做法是,无视它,假装不存在(reference-counted 字符串 的 class 程序库采用);
第二种做法是,在文件中进行警告说明,让客户不要这么做;
第三种做法是,为每一个 StringValue 对象加上一个标志变量,用以指示可否被共享。开始先竖立标志(表示对象可以被共享),直到 non-const operator[] 作用于该对象。一旦标志被清除,将永远保持这个状态。
class String {
private:
struct StringValue {
bool shareable; // 新增
};
};
String 成员函数都需要更新,来判断可共享字段 shareable:
String::String(const String& rhs) {
if (rhs.value->shareable) {
value = rhs.value;
++value->refCount;
}
else {
value = new StringValue(rhs.value->data);
}
}
non-const operator[] 是唯一将 shareable 设置为 false 的:
char& String::operator[](int index) {
if (value->refCount > 1) {
--value->refCount;
value = new StringValue(value->data);
}
value->shareable = false; // 新增
return value->data[index];
}
一个引用计数基类
base class RCObject 可以被任何想要拥有 reference couting 能力的类所继承。RCObject 定义如下:
class RCObject {
public:
RCObject();
RCObject(const RCObject& rhs);
RCObject& operator=(const RCObject& rhs);
virtual ~RCObject() = 0;
void addReference();
void removeReference();
void markUnshareable();
bool isShareable() const;
bool isShared() const;
private:
int refCount;
bool shareable;
};
注意析构函数是纯虚函数,表示此 class 只是被设计用来作为 base class 使用。RCObject 实现代码如下:
RCObject::RCObject() : refCount(0), shareable(true) { }
RCObject::RCObject(const RCObject&) : refCount(0), shareable(true) {}
RCObject& RCObject::operator=(const RCObject&) {
return *this;
}
RCObject::~RCObject() {} // 虚析构函数必须被实现出来,即使它们是纯虚函数而且什么也不做
void RCObject::addReference() { ++refCount; }
void RCObject::removeReference() {
if (--refCount == 0) delete this;
}
void RCObject::markUnshareable() {
shareable = false;
}
bool RCObject::isShareable() const {
return shareable;
}
bool RCObject::isShared() const {
return refCount > 1;
}
这里有几个地方需要注意。
第一个地方是,在两个 constructor 中都将 refCount 设置为 0,而不是 1.这是为了简化请示,使对象创建者自行将 refCount 设为 1。也就是说 refCount 的创建者有责任为 refCount 设定适当的值。
第二个地方是,在 RCObject 的赋值 *** 作中并没有任何实际行为。假设有 StringValue 的 sv1 和 sv2,在一个赋值动作中:
sv1 = sv2; // 考虑 sv1 和 sv2 的引用次数会受怎样的影响
拿 sv1 举例来说,赋值动作之前,已有某些数量的 String 对象指向 sv1;改数量不会因为这个赋值动作而有所变化,因为只有 sv1 的实值才会受此赋值动作而改变。当 RCObject 涉及赋值动作,指向左右双方的 RCObject 对象的外围对象(在本例中是 String 对象)的个数都不会受到影响,因此赋值 *** 作不应该改变引用次数。
第三个地方是,removeReference 的责任不只是将对象的 refCount 递减,也需要在 refCount 为 0 时,销毁对象。而销毁对象的方法是通过 delete 所以说,*this 必须要在 heap 中被创建。
为了运用这个 reference counting base class,我们现在对 StringValue 做如下修改:
class String {
private:
struct StringValue : public RCObject {
char *data;
StringValue(const char *initValue);
~StringValue();
};
};
这里唯一的改变就是 StringValue 的 member function 不在处理 refCount 字段,改由 RCObject 掌控。让一个嵌套类继承另一个类,而后者与外围类完全无关。
自动 *** 作 reference countRCObject class 给了我们一些成员函数,用来 *** 作引用次数,但是这些函数的调用动作需要我们手动地安插到其他 class 内。我们可以将那些调用动作移至一个可复用的 class 内,这样一来就可以让诸如 String 之类的 class 的作者就不必 *** 心 reference couting 的任何细节了。
现在再来回顾一下 String,每个 String 对象都内含一个指针,指向 StringValue 对象,后者用来表现 String 的实值:
class String {
private:
struct StringValue { ... };
StringValue *value; // 用来表现 String 的实值
};
我们需要一个可以在任何需要(指针的复制、重新赋值、销毁等等)的时候,都可以 *** 控 StringValue 对象内的 refCount 成员。如果我们能够让指针本身侦测这些事情,并自动执行对 refCount 成员的 *** 控动作,我们的愿望就达成了。而我们的选择就是,使用智能指针。
下面这个 template 用来产生智能指针,指向 reference-counted 对象:
// template class 用于 smart pointers-to-T。T 必须支持 RCObject 接口, 因此通常继承 RCObject
template
class RCPtr {
public:
RCPtr(T* realPtr = 0);
RCPtr(const RCPtr& rhs);
~RCPtr();
RCPtr& operator=(const RCPtr& rhs);
T* operator->() const;
T& operator*() const;
private:
T *pointee;
void init();
};
这个 template 让 smart pointer object 控制其构造、复制、析构期间发生的事情。
现在看它的构造函数:
template
RCPtr::RCPtr(T* realPtr) : pointee(realPtr) {
init();
}
template
RCPtr::RCPtr(const RCPtr& rhs) : pointee(rhs.pointee) {
init();
}
template
void RCPtr::init() {
if (pointee == 0) { // 如果 dumb pointer 是 null,智能指针也是
return;
}
if (pointee->isShareable() == false) { // 如果其值不可共享,就复制一份
pointee = new T(*pointee);
}
pointee->addReference(); // 现在有了一个针对实值的新接口
}
这里存在一个问题。就是当 init 需要为实值产生一份新副本时(因原有副本不可共享),它会执行以下代码:
pointee = new T(*pointee);
实际上,这是调用了 T 的 copy constructor 进行初始化工作。就本例来说,T 是 StringValue,但是 StringValue 的 copy constructor 并未被声明,所以编译器会帮我们产生一个合成的。合成的 copy constructor 只会复制 StringValue 的 data pointer,而不会复制 data pointer 所指向的 char* 字符串。
所以想让 RCPtr
String::StringValue::StringValue(const StringValue& rhs) {
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
}
可执行深层复制行为的 copy constructor,并非是 RCPtr
再看它的复制构造函数:
template
RCPtr& RCPtr::operator=(const RCPtr& rhs) {
if (pointee != rhs.pointee) { // 如果没有实值变化
if (pointee) {
pointee->removeReference(); // 移除当前实值的引用次数
}
pointee = rhs.pointee; // 指向新值
init(); // 如果可能,共享;否则做一份属于自己的副本
}
return *this;
}
再看它的析构函数,当一个 RCPtr 被销毁,仅仅只需移除 reference-counted 对象的引用次数即可:
template
RCPtr::~RCPtr() {
if (pointee)pointee->removeReference();
}
如果这个 RCPtr 是目标对象的最后一个引用这,该对象将会被销毁。
将所有努力放在一起每一个 reference-counted 字符串均以此数据结构实现出来:
架构出上述数据结构的 class 定义如下。
RCPtr:
template // template class 用来缠身 smart pointers-to-T objects;T 必须继承自 RCObject
class RCPtr {
public:
RCPtr(T* realPtr = 0);
RCPtr(const RCPtr& rhs);
~RCPtr();
RCPtr& operator=(const RCPtr& rhs);
T* operator->() const;
T& operator*() const;
private:
T *pointee;
void init();
};
RCObject:
class RCObject { // base class,用于 reference-counted objects
public:
void addReference();
void removeReference();
void markUnshareable();
bool isShareable() const;
bool isShared() const;
protected:
RCObject();
RCObject(const RCObject& rhs);
RCObject& operator=(const RCObject& rhs);
virtual ~RCObject() = 0;
private:
int refCount;
bool shareable;
};
String:
class String { // 应用性 class,这是应用程序开发人员接触的层面
public:
String(const char *value = "");
const char& operator[](int index) const;
char& operator[](int index);
private:
// 以下 struct 用来表现字符串实值
struct StringValue : public RCObject {
char *data;
StringValue(const char *initValue);
StringValue(const StringValue& rhs);
void init(const char *initValue);
~StringValue();
};
RCPtr value;
};
RCObject 的实现:
RCObject::RCObject()
: refCount(0), shareable(true) { }
RCObject::RCObject(const RCObject&)
: refCount(0), shareable(true) { }
RCObject& RCObject::operator=(const RCObject&) {
return *this;
}
RCObject::~RCObject() { }
void RCObject::addReference() { ++refCount; }
void RCObject::removeReference() {
if (--refCount == 0) delete this;
}
void RCObject::markUnshareable() {
shareable = false;
}
bool RCObject::isShareable() const {
return shareable;
}
bool RCObject::isShared() const {
return refCount > 1;
}
RCPtr 的实现:
template
RCPtr::RCPtr(T* realPtr)
: pointee(realPtr) {
init();
}
template
RCPtr::RCPtr(const RCPtr& rhs)
: pointee(rhs.pointee) {
init();
}
template
RCPtr::~RCPtr() {
if (pointee)pointee->removeReference();
}
template
RCPtr& RCPtr::operator=(const RCPtr& rhs) {
if (pointee != rhs.pointee) {
if (pointee) pointee->removeReference();
pointee = rhs.pointee;
init();
}
return *this;
}
template
T* RCPtr::operator->() const { return pointee; }
template
T& RCPtr::operator*() const { return *pointee; }
String::StringValue 的实现:
void String::StringValue::init(const char *initValue) {
data = new char[strlen(initValue) + 1];
strcpy(data, initValue);
}
String::StringValue::StringValue(const char *initValue) {
init(initValue);
}
String::StringValue::StringValue(const StringValue& rhs) {
init(rhs.data);
}
String::StringValue::~StringValue() {
delete[] data;
}
String 的实现:
String::String(const char *initValue): value(new StringValue(initValue)) { }
const char& String::operator[](int index) const {
return value->data[index];
}
char& String::operator[](int index) {
if (value->isShared()) {
value = new StringValue(value->data);
}
value->markUnshareable();
return value->data[index];
}
上述新版本代码,更加精简。
条款 30:proxy class(替身类、代理类)
你可以在 FORTRAN、BASIC 中产生多维数组,但是你并不能在 C++ 中这么做。或者说,你只能在某种情况下才可以,不过严格来说 C++ 没有多维数组。
下面这样的做法是合法的:
int data[10][20];
但如果以变量作为数组大小,就不可以:
void processInput(int dim1, int dim2) {
int data[dim1][dim2]; // 错误 数组的尺度(大小)必须在编译器一直
}
C++ 也不允许一个与二维数组相关的 heap-based 分配行为:
int *data =new int[dim1][dim2]; // 错误
实现二维数组
我们可以为二维数组定义一个 class template:
template
class Array2D {
public:
Array2D(int dim1, int dim2);
...
};
此时我们就可以定义我们想要的数组了:
Array2D data(10, 20);
Array2D *data =new Array2D(10, 20);
void processInput(int dim1, int dim2) {
Array2D data(dim1, dim2);
...
}
但是这样带来的问题是,我们对数组对象的使用与 C 和 C++ 的传统有所区别,我们希望能够以方括号表现数组索引:
cout << data[3][6];
但是我们不能重载 operator[][],这是无法通过编译的,因为 C++ 没有 operator[][] 这样的东西。
有一种方法是重载 operator() *** 作符:
template
class Array2D {
public:
// 可以通过编译
T& operator()(int index1, int index2);
const T& operator()(int index1, int index2) const;
...
};
但是使用方式却不是 C++ 传统方式:
cout << data(3, 6);
这样很容易推广到任意维度,缺点就是这个数组对象使用起来并不像内置数组。
另一种方法是,将 Array2D class 的 operator[] 返回一个 Array1D 的对象。再重载 Array1D 的 operator[] 来返回所需要的二维数组中的元素:
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 data(10, 20);
...
cout << data[3][6]; // 正确
在这里,data[3] 获得一个 Array1D 对象,而对该对象再施行 operator[],获得原二维数组中 (3, 6) 位置的数据。最重要的是 Array2D class 的用户不需要知道 Array1D class 的存在。
每一个 Array1D 对象象征一个一维数组,观念上它并不存在于 Array2D 的用户心中。凡“用来代表(象征)其他对象”的对象,常被称为 proxy object(替身对象),而用以表现 proxy object 者,我们称为 proxy class。本例的 Array1D 便是个 proxy class,其实体代表观念上并不存在的一维数组。
区分 operator[] 的读写动作proxy class 还可以用来协助区分 operator[] 的读写动作。
现在考虑一个支持 operator[] 的字符串类型,允许用户写出以下代码:
String s1, s2;
cout << s1[5]; // 读 s1
s2[5] = 'x'; // 写 s2
s1[3] = s2[8]; // 写 s1, 读 s2
operator[] 可以再两种不同情境下被调用:用来读取一个字符,或是用来写一个字符。读取动作是所谓的右值运用;写动作是所谓的左值运用。我们的愿望就是可以区分 operator[] 的读和写。
想法一,尝试通过常量性来对 operator[] 重载,从而区分读写动作:
class String {
public:
const char& operator[](int index) const; // 读
char& operator[](int index); // 写
...
};
事实上,这种做法并没有什么用。编译器再 const 和 non-const 成员函数之间的选择,指示以调用该函数的对象是否是 const 为准,并不考虑它们在什么情景下被调用:
String s1, s2;
...
cout << s1[5]; // 调用 non-const operator[],s1 不是 const
s2[5] = 'x'; // 调用 non-const operator[],s2 不是 const
s1[3] = s2[8]; // 两者都调用 non-const operator[],都是 non-const
也就是说重载 operator[] 并没有办法区分读写状态。
想法二,延缓处理动作,直到知道 operator[] 的返回结果将如何被使用为止。
我们可以修改 operator[],令它返回字符串中的 proxy,而不返回字符串本身。如果它被堵,我们可以将 operator[] 的调用视为读 *** 作。如过它被写,我们将 operator[] 视为写 *** 作。
对于 proxy,你有三件事可做:
- 产生它,本例中就是指定它代表哪一个字符串中的哪一个字符。
- 以它作为赋值动作的目标(接收端)。
- 以其他方式使用。
下面是它的定义:
class String { // reference-counted strings;
public:
class CharProxy { // proxies for string chars
public:
CharProxy(String& str, int index); // 构造
CharProxy& operator=(const CharProxy& rhs); // 左值运用
CharProxy& operator=(char c);
operator char() const; // 右值运用
private:
String& theString; // proxy 所附属的字符串
int charIndex; // proxy 所代表的字符串字符
};
const CharProxy operator[](int index) const; // 针对 const String
CharProxy operator[](int index); // 针对 non-const String
...
friend class CharProxy;
private:
RCPtr value;
};
需要注意的是 String class 中的两个 operator[] 都是返回的 CharProyx 对象,虽然 String 的用户可以忽略这一点,犹如 operator[] 返回的是字符一样。
有关右值处理的,思考这样的语句:
cout << s1[5];
s1[5] 返回一个 CharProxy 对象,但是这个对象没有定义过 output *** 作符,所以编译器将 CharProxy 隐式转型为 char(此转换函数声明于 CharProxy class 内),于是 CharProxy 所表现的字符串被打印出去。这个 CharProxy-to-char 的转换,发生在所有被用来作为右值的 CharProxy 对象身上。
左值运用的处理方式不太相同,现在思考这句话:
s2[5] = 'x';
s2[5] 同样会返回一个 CharProxy 对象,但是这次的对象是 assignment 动作的目标物,所以会调用 CharProxy class 中定义的 assignment *** 作符。
下面是 String 的 opertator[]:
const String::CharProxy String::operator[](int index) const {
return CharProxy(const_cast(*this), index);
}
String::CharProxy String::operator[](int index) {
return CharProxy(*this, index);
}
每个函数都只是产生并返回一个 CharProxy 对象来代表被请求的字符,没有任何动作施加于此字符身上。值得注意的是 const opertator[] 返回的 proxy 以及 proxy 所代表的字符都是 const,因此不能用来作为左值。
proxy 会记住它所附属的字符串,以及它所在的索引位置:
String::CharProxy::CharProxy(String& str, int index)
: theString(str), charIndex(index) { }
将 proxy 转换为右值,只需要返回该 proxy 所表现的字符副本即可:
String::CharProxy::operator char() const {
return theString.value->data[charIndex];
}
请记住:C++ 限制只能在右值情景下使用这一的 by value 返回值。
加下来实现 CharProxy 的 assignment *** 作符,需要知道的是 proxy 所代表的字符即将被当作赋值动作的目标,也就是一个左值:
String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs) {
// 如果本字符串与其他 String 共享同一个实值,将实值复制一份,供本字符串单独使用
if (theString.value->isShared()) {
theString.value = new StringValue(theString.value->data);
}
// 现在进行赋值动作:将 rhs 所代表的字符值赋予 *this 所代表的字符
theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex];
return *this;
}
此函数要求处理 String 的 private data member value,所以这也是 String 中将 CharProxy 声明为 friend 的原因。
第二个 CharProxy assignment 和上述方法类似:
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;
}
限制
proxy class 适合用来区分 opertator[] 的左值运用和右值运用,但是这项技术也有缺点。因为除了赋值以外,对象可能会在其他情况下被当作左值使用,那种情况下的 proxy 常常会有与真实对象不同的行为。
第一种情况,String::opertator[] 返回的是个 CharProyx 而非 char& 下面这段代码就无法通过编译:
String s1 = "Hello";
char *p = &s1[1]; // 错误
表达式 s1[1] 返回一个 CharProxy,于是 “=” 的右边是一个 CharProxy*。由于没有从 CharProxy* 到 char* 的转换函数,所以 p 的初始化动作无法通过编译。解决这个问题可以通过重载 CharProxy class 的取址 *** 作符解决。
第二种情况,如果有一个 reference-counted 数组:
template // reference-counted 数组
class Array { // 运用 proxy
public:
class Proxy {
public:
Proxy(Array& array, int index);
Proxy& operator=(const T& rhs);
operator T() const;
...
};
const Proxy operator[](int index) const;
Proxy operator[](int index);
...
};
考虑这些使用数组的方式:
Array intArray;
...
intArray[5] = 22; // 正确
intArray[5] += 5; // 错误
++intArray[5]; // 错误
当 opertator[] 用于 operator+= 或 operator++ 调用式左侧时,会失败。这是因为 operator[] 返回一个 proxy 对象,而它没有提供 operator+= 和 operator++ *** 作。类似的情况也存在于其他需要左值的 *** 作中,如 operator*=、operator<<=、operator–- 等等。
第三种情况,通过 proxy 调用真实对象的 member function。例如,我们希望是先出一个 reference-counted 数组来处理有理数:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
...
};
下面的 *** 作会失败:
cout << array[4].numerator(); // 错误
int denom = array[22].denominator(); // 错误
opertator[] 返回的是 Rational 对象的替身 proxy,而不是一个真正的 Rational 对象。
第四种情况,隐式类型转换。当 proxy object 被隐式转换为它所代表的真正对象时,会有一个用户定制的转换函数被调用。例如只要调用 operator char,便可将一个 CharProxy 转换为它所代表的 cahr。但是编译器一次只能调用一个用户定制转换函数。于是就可能发生这样的情况:可以以真实对象传递给函数,但是以 proxy 传给函数会失败(进行了一次以上的隐式转换)。
总结proxy class 可以帮助我们完成很多事情,比如多维数组、左值/右值的区分、压抑隐式转换等待。
当然,proxy class 也有缺点,如果扮演函数返回值的角色,那些 proxy boject 将是一种临时对象(见条款 19),需要被构造和销毁。proxy class 的存在也增加了软件系统的复杂度,因为额外的 class 使产品更难涉及、实现、了解、维护。
条款 31:让函数根据一个以上的对象类型来决定如何虚化
思考这么一个游戏,场景发生于外层空间,涉及宇宙飞船、太空站、小行星登天体。它们的碰撞规则如下:
- 如果飞船和空间站以低速接触,飞船会泊进空间站。否则飞船和太空站受到的损害与其碰撞速度成正比。
- 如果飞船和飞船碰撞,或空间站和空间站相互碰撞,碰撞双方受遭受损害,受害程序与碰撞速度成正比。
- 如果小行星和飞船或空间站碰撞,小行星会损毁。如果碰撞的是大号小行星,损毁的是飞船或空间站。
- 如果两个小行星相撞,二者将碎裂为更小的小行星,并向溅向各个方向。
一开始我们应该标出飞船、太空站、小行星三者的共同特性,并将其定义为一个被三者继承的 base class。这样的 class 在设计时,会被设计为一个抽象基类:
class GameObject { ... };
class SpaceShip : public GameObject { ... };
class SpaceStation : public GameObject { ... };
class Asteroid : public GameObject { ... };
处理碰撞的函数可能是这样的:
void checkForCollision(GameObject& object1, GameObject& object2) {
if (theyJustCollided(object1, object2)) {
processCollision(object1, object2);
}
else {
...
}
}
现在问题来了,当调用 processCollision 时,并没有办法知道 object1 和 object2 是什么类型。事实上,碰撞结果同时视 object1 和 object2 二者的动态类型而定,而不仅仅是只依 object1 的动态类型而定。显然,我们需要某种函数,其行为视一个以上的对象类型而定。但是 C++ 并未提供这样的函数。
上述需求常被称为 double-dispatch(人们把一个虚函数调用动作成为一个 message dispatch),更广泛的情况(函数更具多个参数而虚化)则被称为 multiple dispatch。
虚函数 + RTTI虚函数可以实现出 single dispatch;我们在 GameObject 中声明一个虚函数 collide,这个函数会在 derived class 中被改写:
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
...
};
class SpaceShip : public GameObject {
public:
virtual void collide(GameObject& otherObject);
...
};
最一般化的 double-dispatch 实现法,就是使用一长串的 if-then-else 来仿真虚函数:
void SpaceShip::collide(GameObject& otherObject) {
const type_info& objectType = typeid(otherObject);
if (objectType == typeid(SpaceShip)) {
SpaceShip& ss = static_cast(otherObject);
}
else if (objectType == typeid(SpaceStation)) {
SpaceStation& ss = static_cast(otherObject);
}
else if (objectType == typeid(Asteroid)) {
Asteroid& a = static_cast(otherObject);
}
else {
throw CollisionWithUnknownObject(otherObject);
}
}
这样我们只需决定碰撞中一方的类型即可,另一个对象是 *this,其类型由虚函数机制决定下来。但是每一个 collide 函数都需要知道每一个兄弟类 —— 也就是所有继承自 GameObject 的那些 class,如果由新对象加入游戏行列,我们必须修改上述程序中的每一个 if-then-else。这样的程序难以维护,再扩充时也很麻烦。
只使用虚函数另一种方法是,在derived class 内重新定义 collide。此外 collide 需要被重载,每一个重载版本对应继承体系中的一个 derived class:
class SpaceShip; // 前置声明
class SpaceStation;
class Asteroid;
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
virtual void collide(SpaceShip& otherObject) = 0;
virtual void collide(SpaceStation& otherObject) = 0;
virtual void collide(Asteroid& otherobject) = 0;
...
};
class SpaceShip : public GameObject {
public:
virtual void collide(GameObject& otherObject);
virtual void collide(SpaceShip& otherObject);
virtual void collide(SpaceStation& otherObject);
virtual void collide(Asteroid& otherobject);
...
};
基本思想是,将 double-dispatch 以两个 single dispatch (也就是两个分离的虚函数)实现出来:一个用来决定一个对象的动态类型,领域给决定第二个对象的动态类型。这个函数的实现十分简单:
void SpaceShip::collide(GameObject& otherObject) {
otherObject.collide(*this);
}
在这里,两个对象的真实类型都是清楚的,在 class SpaceShip 的成员函数中, *this 的类型一定是 SpaceShip。所有的 collide 函数都是虚函数,所以 SpaceShip::collide 内调用的是 otherObject 真实类型的 collide 函数版本。
和之前看到的 RTTI 解法一样,这种方法的缺点就是,每个类都必须知道其兄弟类。一旦有新的 class 加入,代码就必须修改。每一个 class 的定义都必须修正,含入一个新的虚函数。
总之如果你需要在程序中实现 double-dispatch,最好的方向就是修改设计,消除此项需求。如果不能,哪呢,虚函数法逼 RTTI 法安全一些,但是如果你对头文件(内含 class 的声明和定义)的权力不够,这种做法会束缚你的系统扩展性。RTTI 法,虽不需要重新编译,却往往导致软件难以维护。
自行仿真虚函数表格条款 24 曾提到,编译器通常通过函数指针数组(vtbl)来实现虚函数;当某个虚函数被调用时,编译器便索引至该数组内,取得一个函数指针。
实现如下:
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
...
};
class SpaceShip : public GameObject {
public:
virtual void collide(GameObject& otherObject);
virtual void hitSpaceShip(SpaceShip& otherObject);
virtual void hitSpaceStation(SpaceStation& otherObject);
virtual void hitAsteroid(Asteroid& otherobject);
...
};
类似 RTTI,GameObject class 只含一个碰撞处理函数,此函数执行两个必要的 dispatch 中的第一个,而后的和虚函数解法类似,只是这里并没有使用重载。这是为了在 SpaceShip::collide 内,我们需要将参数 otherObject 的动态类型映射到某个 member function 指针,指向适当的碰撞处理函数。
一种简单的方法就是,产生一个关系型数组,只要获得 class 名称,便导出适当的 member function 指针。直接使用这个数组来实现 collide,再加上一个中介函数 lookup。lookup 截获一个 GameObject 并返回适当的 member function 指针。
class SpaceShip : public GameObject {
private:
typedef void (SpaceShip::*HitFunctionPtr)(GameObject&);
static HitFunctionPtr lookup(const GameObject& whatWeHit);
typedef map HitMap;
...
};
void SpaceShip::collide(GameObject& otherObject) {
HitFunctionPtr hfp = lookup(otherObject); // 找出调用的对象(函数)
if (hfp) { // 如果找到,就调用
(this->*hfp)(otherObject);
}
else { // 如果没找到就抛出异常
throw CollisionWithUnknowObjec(otherObject);
}
}
如果关系型数组的内容能够与 GameObject 继承体系保持一致,lookup 就一定能够针对我们传入的对象,找出一个有效的函数指针。
下面是 lookup 的实现:
SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) {
static HitMap collisionMap; // 下节会有详细描述
// 为 whatWeHit 寻找碰撞处理函数
HitMap::iterator mapEntry = collisionMap.find(typeid(whatWeHit).name());
// 如果没有找到
if (mapEntry == collisionMap.end()) return 0;
// 如果找到返回 pair 中的第二个部分
return (*mapEntry).second;
}
函数最后一个语句返回的是 (*mapEntry).second,而不是 mapEntry->second,这是因为前者更具移植性。
将自行仿真的虚函数表格初始化现在我们需要初始化 collisionMap。
第一种方法是,在 lookup 中直接初始化:
SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) {
static HitMap collisionMap;
collisionMap["SpaceShip"] = &hitSpaceShip;
collisionMap["SpaceStation"] = &hitSpaceStation;
collisionMap["Asteroid"] = &hitAsteroid;
...
}
这是一个不正确的做法,这样会在每次 lookup 被调用时将 member function 指针放入 collisionMap 内,这是十分耗时且不必要的。此外上述的做法也无法通过编译(这不是重点,而且解决方法也很容易)。
第二种方法是,定制初始化函数,在 collisionMap 诞生时将其初始化:
class SpaceShip : public GameObject {
private:
static HitMap initializeCollisionMap();
...
};
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit) {
static HitMap collisionMap = initializeCollisionMap();
...
}
其实我们可以返回智能指针,这样既避免了返回对象副本时付出的成本,也可以让所指的 map 对象在适当的时候被删除,这样就不必担心资源泄露问题(当 collisionMap 被销毁时,其所指的那个 map 也会被自动销毁):
class SpaceShip : public GameObject {
private:
static HitMap* initializeCollisionMap();
...
};
SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit) {
static auto_ptr collisionMap(initializeCollisionMap());
...
}
你可能会这样实现:
SpaceShip::HitMap * SpaceShip::initializeCollisionMap() {
HitMap *phm = new HitMap;
(*phm)["SpaceShip"] = reinterpret_cast(&hitSpaceShip);
(*phm)["SpaceStation"] = reinterpret_cast(&hitSpaceStation);
(*phm)["Asteroid"] = reinterpret_cast(&hitAsteroid);
return phm;
}
但这是一个很糟糕的尝试,这是强行将其他类型(GameObject 派生类)的函数指针转换成了一个 GameObject 的函数指针。这相当于告诉编译器,SpaceShip、SpaceStation、Asteroid 都是期望获得一个 GameObject 函数。如果 GameObject 的派生类运用了多重继承或拥有虚基类时,通过 *phm 调用某些函数就会产生与预期不符的行为。
解决冲突的方法就是:改变函数的类型,使他们统统接纳 GameObject 自变量:
class GameObject {
public:
virtual void collide(GameObject& otherObject) = 0;
...
};
class SpaceShip : public GameObject {
public:
virtual void collide(GameObject& otherObject);
// 这些函数都接受一个 GameObject 参数
virtual void hitSpaceShip(GameObject& spaceShip);
virtual void hitSpaceStation(GameObject& spaceStation);
virtual void hitAsteroid(GameObject& asteroid);
...
};
这也是之前不用重载的原因了,因为所有的撞击函数都有相同的参数类型,所以它们必须要有不同的函数名称。现在我们可以写出 initializeCollisionMap 了:
SpaceShip::HitMap * SpaceShip::initializeCollisionMap() {
HitMap *phm = new HitMap;
(*phm)["SpaceShip"] = &hitSpaceShip;
(*phm)["SpaceStation"] = &hitSpaceStation;
(*phm)["Asteroid"] = &hitAsteroid;
return phm;
}
我们的撞击函数获得的都是一个一般性的 GameObject 参数,而非精确的 derived class 参数,所以我们需要将它们进行类型转换
void SpaceShip::hitSpaceShip(GameObject& spaceShip) {
SpaceShip& otherShip = dynamic_cast(spaceShip);
}
void SpaceShip::hitSpaceStation(GameObject& spaceStation) {
SpaceStation& station = dynamic_cast(spaceStation);
}
void SpaceShip::hitAsteroid(GameObject& asteroid) {
Asteroid& theAsteroid = dynamic_cast(asteroid);
}
如果 dynamic_cast 转型失败,会抛出一个 bad_cast 异常。
使用非成员函数的碰撞处理函数上面介绍的方法在添加新类型时,所有用户仍需要重新编译。但是如果关系型数组内含的指针式 non-member function,重新编译的问题便可以消除。而且使用 non-member function 可以解决两种不同类型的物体发生碰撞时,到底哪个 class 应该负责的问题。
如果将碰撞处理函数移除 class 之外,我们就可以给予用户一些不含任何碰撞处理的 class 定义式(位于头文件)。
#include "SpaceShip.h"
#include "SpaceStation.h"
#include "Asteroid.h"
namespace { // 匿名 namespace
// 主要的碰撞处理函数
void shipAsteroid(GameObject& spaceShip, GameObject& asteroid);
void shipStation(GameObject& spaceShip, GameObject& spaceStation);
void asteroidStation(GameObject& asteroid, GameObject& spaceStation);
...
// 次要的碰撞处理函数,只是为了实现对称性:对调参数位置,然后调用主要的碰撞处理函数
void asteroidShip(GameObject& asteroid, GameObject& spaceShip) {
shipAsteroid(spaceShip, asteroid);
}
void stationShip(GameObject& spaceStation, GameObject& spaceShip) {
shipStation(spaceShip, spaceStation);
}
void stationAsteroid(GameObject& spaceStation, GameObject& asteroid) {
asteroidStation(asteroid, spaceStation);
}
...
typedef void(*HitFunctionPtr)(GameObject&, GameObject&);
typedef map< pair, HitFunctionPtr > HitMap;
pair makeStringPair(const char *s1, const char *s2);
HitMap* initializeCollisionMap();
HitFunctionPtr lookup(const string& class1, const string& class2);
} // namespace 结束
void processCollision(GameObject& object1, GameObject& object2) {
HitFunctionPtr phf = lookup(typeid(object1).name(), typeid(object2).name());
if (phf) phf(object1, object2);
else throw UnknownCollision(object1, object2);
}
匿名 namespace 内的每样东西对齐所在的编译文件而言都是私有的,其效果好像在文件里头将函数声明为 static 一样。由于 namespace 的出现,文件生存空间内的 static 最好就不要继续使用了。
标准的 map class 只能持有两份信息,但是 pair template 可以将两个类型名称捆绑在一起,成为单一对象。
辅助函数 makeStringPair 定义如下:
namespace {
pair makeStringPair(const char *s1, const char *s2) {
return pair(s1, s2);
}
}
initializeCollisionMap 定义如下:
namespace {
HitMap * initializeCollisionMap() {
HitMap *phm = new HitMap;
(*phm)[makeStringPair("SpaceShip", "Asteroid")] =
&shipAsteroid;
(*phm)[makeStringPair("SpaceShip", "SpaceStation")] =
&shipStation;
...
return phm;
}
}
重新修改的 lookup 如下:
namespace {
HitFunctionPtr lookup(const string& class1, const string& class2) {
static auto_ptr collisionMap(initializeCollisionMap());
HitMap::iterator mapEntry = collisionMap->find(make_pair(class1, class2));
if (mapEntry == collisionMap->end()) return 0;
return (*mapEntry).second;
}
}
需要注意的是,makeStringPair、initializeCollisionMap 和 lookup 都被声明于匿名 namespace 内,所以它们都必须实现与相同的 namespace 中。
这样,一旦有新的 GameObject subclass 加入这个继承体系中,原有的 class 就不再需要重新编译(除非它们需要使用新的 class)。如果有新的碰撞 class 加入,我们系统只需要在 initializeCollisionMap 内为 map 增加新的项目,并在 processCollision 相应的匿名 namespace 内增加新碰撞处理函数即可。
继承 + 自行仿真的虚函数表格上述所做的事可以有效运作的前提是,在调用碰撞处理函数时不发生 inheritance-based 类型转换。
比如说,我们的宇宙飞船可能会分为商业宇宙飞船和军事宇宙飞船。我们假设二者的碰撞行为相同,我们可能希望可以调用宇宙飞船的碰撞规则,但事实上抛出一个异常,因为并没有针对二者的碰撞行为,即使二者可以被视为一个 SpaceShip 对象,但是编译器并不知道。
如果你需要实现 double-dispatch 而且需要支持 inheritance-based 参数转换,那么唯一可用的方法就是使用之前提过的双虚函数调用机制。但是这也扩大了继承体系,且每次修改所有人都要重新编译。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)