More Effective C++ 05 技术 - 下

More Effective C++ 05 技术 - 下,第1张

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 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 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 必须继承自 RCObject,或至少提供 RCObject 的所有机能。最后就是 RCPtr 对象所指的对象,类型必须为 T,而不能是 T 的派生。

再看它的复制构造函数:

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 参数转换,那么唯一可用的方法就是使用之前提过的双虚函数调用机制。但是这也扩大了继承体系,且每次修改所有人都要重新编译。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/921254.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-16
下一篇 2022-05-16

发表评论

登录后才能评论

评论列表(0条)

保存