More Effective C++ 05 技术 - 下

5. 技术 - 下

条款 29:reference counting(引用计数)

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-write

copy-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 count

RCObject 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 T>
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<class T>
RCPtr<T>::RCPtr(T* realPtr) : pointee(realPtr) {
    init();
}

template<class T>
RCPtr<T>::RCPtr(const RCPtr& rhs) : pointee(rhs.pointee) {
    init();
}

template<class T>
void RCPtr<T>::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<T> template 的行为正确,前提是 T 必须拥有一个可对 T 的实值进行深层复制(deep copy)的 copy constructor:

String::StringValue::StringValue(const StringValue& rhs) {
    data = new char[strlen(rhs.data) + 1];
    strcpy(data, rhs.data);
}

可执行深层复制行为的 copy constructor,并非是 RCPtr<T> 对 T 的唯一要求。它还要求 T 必须继承自 RCObject,或至少提供 RCObject 的所有机能。最后就是 RCPtr<T> 对象所指的对象,类型必须为 T,而不能是 T 的派生。

再看它的复制构造函数:

template<class T>
RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs) {
    if (pointee != rhs.pointee) {  // 如果没有实值变化
        if (pointee) {
            pointee->removeReference();  // 移除当前实值的引用次数
        } 
        pointee = rhs.pointee;  // 指向新值
        init();  // 如果可能,共享;否则做一份属于自己的副本
    }
    return *this;
}

再看它的析构函数,当一个 RCPtr 被销毁,仅仅只需移除 reference-counted 对象的引用次数即可:

template<class T>
RCPtr<T>::~RCPtr() {
    if (pointee)pointee->removeReference();
}

如果这个 RCPtr 是目标对象的最后一个引用这,该对象将会被销毁。

将所有努力放在一起

每一个 reference-counted 字符串均以此数据结构实现出来:

在这里插入图片描述

架构出上述数据结构的 class 定义如下。

RCPtr:

template<class T> // 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<StringValue> 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<class T>
RCPtr<T>::RCPtr(T* realPtr)
    : pointee(realPtr) {
    init();
}
template<class T>
RCPtr<T>::RCPtr(const RCPtr& rhs)
    : pointee(rhs.pointee) {
    init();
}
template<class T>
RCPtr<T>::~RCPtr() {
    if (pointee)pointee->removeReference();
}
template<class T>
RCPtr<T>& RCPtr<T>::operator=(const RCPtr& rhs) {
    if (pointee != rhs.pointee) {
        if (pointee) pointee->removeReference();
        pointee = rhs.pointee;
        init();
    }
    return *this;
}
template<class T>
T* RCPtr<T>::operator->() const { return pointee; }
template<class T>
T& RCPtr<T>::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 T>
class Array2D {
public:
    Array2D(int dim1, int dim2);
    ...
};

此时我们就可以定义我们想要的数组了:

Array2D<int> data(10, 20);

Array2D<float> *data =new Array2D<float>(10, 20); 

void processInput(int dim1, int dim2) {
    Array2D<int> data(dim1, dim2);
    ...
}

但是这样带来的问题是,我们对数组对象的使用与 C 和 C++ 的传统有所区别,我们希望能够以方括号表现数组索引:

cout << data[3][6];

但是我们不能重载 operator[][],这是无法通过编译的,因为 C++ 没有 operator[][] 这样的东西。

有一种方法是重载 operator() 操作符:

template<class T>
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<float> 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<StringValue> 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<String&>(*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<class T>  // reference-counted 数组
class Array {  // 运用 proxy
public:
    class Proxy {
    public:
        Proxy(Array<T>& array, int index);
        Proxy& operator=(const T& rhs);
        operator T() const;
        ...
    };
    const Proxy operator[](int index) const;
    Proxy operator[](int index);
    ...
};

考虑这些使用数组的方式:

Array<int> 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<SpaceShip&>(otherObject);
    }
    else if (objectType == typeid(SpaceStation)) {
        SpaceStation& ss = static_cast<SpaceStation&>(otherObject);
    }
    else if (objectType == typeid(Asteroid)) {
        Asteroid& a = static_cast<Asteroid&>(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<string, HitFunctionPtr> 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<string, HitFunctionPtr> 中的第二个部分
    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<HitMap> collisionMap(initializeCollisionMap());
    ...
}

你可能会这样实现:

SpaceShip::HitMap * SpaceShip::initializeCollisionMap() {
    HitMap *phm = new HitMap;
    
    (*phm)["SpaceShip"] = reinterpret_cast<HitFunctionPtr>(&hitSpaceShip);
    (*phm)["SpaceStation"] = reinterpret_cast<HitFunctionPtr>(&hitSpaceStation);
    (*phm)["Asteroid"] = reinterpret_cast<HitFunctionPtr>(&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&>(spaceShip);
}
void SpaceShip::hitSpaceStation(GameObject& spaceStation) {
    SpaceStation& station = dynamic_cast<SpaceStation&>(spaceStation);
}
void SpaceShip::hitAsteroid(GameObject& asteroid) {
    Asteroid& theAsteroid = dynamic_cast<Asteroid&>(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<string, string>, HitFunctionPtr > HitMap;
    
    pair<string, string> 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<string, string> makeStringPair(const char *s1, const char *s2) {
	    return pair<string, string>(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<HitMap> 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 参数转换,那么唯一可用的方法就是使用之前提过的双虚函数调用机制。但是这也扩大了继承体系,且每次修改所有人都要重新编译。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值