替身对象(Proxy Objects)指的是用来代表其他对象的对象,而用以表现替身对象的就是替身类(Proxy Class)。在 C++ 当中,替身类的主要作用有三个
- 禁止隐式转换
- 实现多维数组的访问
- 实现缓式评估(lazy evaluation)
作用一、禁止隐式转换
C++ 允许编译器在不同的类型之间(内置类型和自定义类型)之间进行隐式转换。对于自定义类型而言,有两种函数会允许编译器执行这样的转换:单自变量的构造函数和隐式类型转换操作符。在程序中对自定义类型允许隐式类型转换常常是弊大于利,因为隐式类型转换通常发生在你看不到的地方,而这很可能会导致你的程序出现无法预期的行为。例如:
//Array 是一个针对数组结构而写的 class template, 允许用户指定索引的上下限
template <typename T>
class Array{
public:
Array(int lowBound, int highBound);
Array(int size);
T& operator[](int index);
...
};
bool operator==(const Array<int>& lhs, const Array<int>& rhs);
...
int main(void){
Array<int> a(10);
Array<int> b(10);
...
for(int i = 0;i < 10;++i){
if(a == b[i]){ //原本应该为 a[i] == b[i]
...
}else{
...
}
}
...
return 0;
}
在上述代码中,对于 a == b[i] 语句,编译器并没有发挥纠错功能,而是通过编译。因为编译器看到的是一个 operator== 函数的调用,它传入了类型是 Array<int> 的参数 a 和类型是 int 的参数 b。由于 Array<int> 提供了一个单参数的构造函数,因此编译器会自动利用 b[i] 构造一个 Array<int> 类型的变量和 a 进行比较。这样的操作不仅降低了程序运行的效率,更完全偏离了开发者的预期。
对于自定义类型而言,要禁止隐式类型转换,除了不去定义隐式类型转换操作符外,还要特别注意单参数的构造函数。禁止单参数构造函数的隐式类型转换,通常有以下两种做法:
- 使用 explicit 关键字对单参数的构造函数进行声明 【要求 C++ 11 版本】
- 利用 Proxy Class 的方法,让编译器无法进行隐式类型转换
C++ 中对于隐式类型转换有一个明确的规则:编译器只能对参数进行一次隐式转换,且一次隐式转换只能实现从一个类型到另一个类型的转换。将这条规则应用于前面所说的 Array 中,如下:
template <typename T>
class Array{
public:
class ArraySize{
public:
ArraySize(int numElements):theSize(numElements){}
int size() const{return theSize;}
private:
int theSize;
};
Array(int lowBound, int highBound);
Array(ArraySize size);
T& operator[](int index);
...
};
bool operator==(const Array<int>& lhs, const Array<int>& rhs);
...
int main(void){
Array<int> a(10);
Array<int> b(10);
...
for(int i = 0;i < 10;++i){
if(a == b[i]){ //原本应该为 a[i] == b[i]
...
}else{
...
}
}
...
return 0;
}
在添加了替身类 ArraySize 后,编译器便可以检查出 a == b[i] 的错误。这是因为要执行 a == b[i] 需要先将 b[i] 隐式转换为一个 ArraySize 类型,然后再将 ArraySize 隐式转换为 Array 类型。由于其中需要进行两次隐式转换,编译器会拒绝为其进行隐式转换,从而检查出错误。
作用二、实现二维数组对象的访问
在某些情况下,我们需要根据变量来生成相应的二维数组,而这种行为在 C++ 中却是不被允许的,因为 C++ 中要求内建数据类型的数组大小必须在编译期确定。为了达成这一目的,我们可以定义一个二维数组的 class 来表现我们有需要而语言又不支持的对象,例如:
template <typename T>
class Array2D{
public:
Array2D(int dim1, int dim2);
...
};
void processInput(int dim1, int dim2){
Array2D<int> data(dim1, dim2); //根据变量产生一个二维数组
}
为了使这一对象的表现更加接近于一般数组,我们需要为其提供取下标操作符以支持如下用法:
Array2D<int> arr(5, 6);
cout << arr[3][4] << endl;
由于 C++ 中对于多维数组采用了递归定义,因而 arr 实际上代表的是一个长度为 5 的一维数组,而该数组中的元素又是长度为 6 的一维数组。所以 arr[3][4] 的实际意义其实是 (arr[3])[4],也就是 arr 中第 4 个元素所代表的数组中的第 5 个元素。因此,我们可以在 Array2D 中实现 operator[] 的重载,令其返回一个 Array1D 对象,然后再对 Array1D 对象重载其 operator[],令其返回位于二维数组中的某个元素,具体如下:
template <typename T>
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;
...
};
int main(void){
...
Array2D arr(4,6);
cout << arr[3][2] << endl; //合法操作
return 0;
}
作用三、作为实现 Lazy evaluation 的手段之一
在实际开发过程当中,我们常常会面临一种情景:程序需要存储大量的同值对象。为了节省空间,同时避免不必要的构造和析构过程,通常会利用引用计数结合写时复制(COW)的方法来提升程序效率。举个例子
class String{
public:
...
const char& operator[](int index) const;
char& operator[](int index);
...
private:
char* p_arr;
};
String s1 = "Hello World!";
String s2 = "Hello World!";
String s3 = "Hello World!";
String s4 = "Hello World!";
String s5 = "Hello World!";
当执行上述 5 条语句后,会产生 5 个不同的 String 对象,它们都拥有相同的值
原始版本
在实际应用当中,我们只关心一个 String 对象当中所包含的值,而不去考虑包含相同值的 String 对象之间的差异。在这种情况下,我们可以对 String 类进行改造,在原有基础上引入引用计数和写时复制的技术
class String{
public:
...
const char& operator[](int index) const;
char& operator[](int index);
...
private:
RCPtr<StringValue> value; //RCPtr是一个引用计数器对象,对象当中包含了一个计数值和字符串值
};
String s1("Hello World!");
String s2("Hello World!");
String s3("Hello World!");
String s4("Hello World!");
String s5("Hello World!");
s5[0] = 'h';
s5[6] = 'w';
通过改造,将具有相同值的 s1 ~ s4 指向一个引用计数对象。而在修改 s5 的时候需要将 s5 复制一份,以避免影响其他对象。
引用计数版
显然,通过了引用计数和写时复制的方式,我们一方面让多个具有相同值的对象共享了内存从而节省了空间,另一方面对于多个同值对象也只需构造和析构一次,从而节省了时间,极大地提高了程序效率。一切到目前为止都似乎运作良好,但我们却忽略一个重要的事实,我们先来看下面的两行代码:
cout << s4[0] << endl; //将 s4[0] 作为右值使用,代表读操作。但因为 operator[] 无法区分自己到底是在右值还是左值的情况下被调用,因此只能按照写操作处理,将 s4 复制一遍
s4[0] = 'h'; //将 s4[0] 作为左值使用,代表写操作
在上述的两行代码中,operator[] 分别被用于读操作和写操作。为了避免执行 "s4[0] = 'h' " 对其他对象的影响,我们必须在 operator[] 中实现写时复制。而这又会导致另外一个问题,那便是使用 operator[] 对 non-const 的 String 对象进行读操作时也会触发写时复制,这使得我们为提升效率做付出的努力化为泡影。导致出现这一问题的关键是作为类的设计者,我们无法判断用户会在何种情况下使用 operator[]。然而幸运的是,即便无法预知用户会以何种形式调用 operator[] ,我们依然可以通过将 operator[] 中的操作延后处理,直到我们可以根据下一运算符确定用户的意图。这种延后处理的技术被称为缓式评估(lazy evaluation),而要实现它离不开我们今天的主角 —— 替身类(Proxy Class)。具体代码如下:
class String{
public:
class CharProxy{
public:
CharProxy(String& str, int idx):theString(str),charIndex(idx){}
CharProxy& operator=(const CharProxy& rhs){
copy_on_write(rhs.theString.value->data[rhs.charIndex]);
return *this;
}
CharProxy& operator=(char c){
copy_on_write(c);
return *this;
}
operator char() const{
return theString.value->data[charIndex];
}
private:
void copy_on_write(char c){
if(theString.value->isShared()){
theString.value = new StringValue(theString.value->data);
}
theString.value->data[charIndex] = c;
}
String& theString;
int charIndex;
};
const CharProxy operator[](int idx) const{
return CharProxy(const_cast<CharProxy>(*this),idx);
}
CharProxy operator[](int idx){
return CharProxy(*this,idx);
}
...
friend class CharProxy;
private:
RCPtr<StringValue> value;
};
在上述代码中,我们引入了一个替身类 CharProxy,在这个替身类当中,我们需要完成以下三个任务:
- 构造,利用 CharProxy 来表示 String[idx]
- 将它作为赋值动作的左值,以此种方式调用代表的是写操作
- 提供一个隐式类型转换 char(),当调用此函数时代表的是读操作。
我们先来看 String 的 operator[] 函数的实现,分 const 对象和 non-const 对象来进行讨论
-
当 String 对象 s1 是 const 对象时,它调用的 operator[] 的实现如下
const String::CharProxy String::operator[](int idx) const{ return CharProxy(const_cast<CharProxy>(*this),idx); } int main(void){ ... const String s1("Hello World!"); cout << s1[0] << endl; ... return 0; }
在上述代码中,operator[] 返回的是一个 const CharProxy 对象,因此只能调用 CharProxy 中的 char() 函数,在执行 “cout << s1[0] << endl” 时会进行隐式类型转换,将其变成一个 char 字符。
-
当 String 对象 s1 是 non-const 对象时,它调用的 operator[] 的实现如下
String::CharProxy String::operator[](int idx){ return CharProxy(*this,idx); } int main(void){ ... const String s1("Hello World!"); cout << s1[0] << endl; s1[6] = s5[6] = 'h'; ... return 0; }
在上述代码中,当 s1 调用 operator[] 时将返回一个 non-const CharProxy 对象(后面记为 obj)。当执行语句 "cout << s1[0] << endl" 会发生隐式类型转换,将 s1[0] 转换为 char 字符,而这并不会触发写时复制。当执行语句 “s1[6] = s5[6] = 'h';” 则会先调用 operator= 函数,最后会调用到 copy_on_write 函数从而触发写时复制的操作。
替身类的缺点
使用替身类能够允许我们完成一些十分困难或几乎不可能完成的行为。但它也有相应的缺点:
-
替身类的引入可能会改变类中部分运算符的原有语义,因此往往需要对替身类重载相应的运算符,而这个工作量往往不小。例如对于前面提到的 String 对象执行下列操作:
char* p = &s1[0]; //编译错误,因为 CharProxy* 无法隐式转换为 char*
因为 s1[0] 返回的是一个 CharProxy 对象,因此要通过编译必须为 CharProxy 提供一个 char*() 的类型转换函数
-
当我们将替身对象传递给接受 non-const 引用参数的函数时,将会引发编译错误,例如:
void swap(char& a, char& b); String str = "hello world"; swap(str[0], str[1]); //编译错误,无法将引用绑定至临时对象上
由于 str[0] 和 str[1] 返回的是 CharProxy 对象,在执行隐式类型转换时会产生一个临时对象,而 non-const 引用无法绑定到临时对象上。
-
替身类的引入增加了软件系统的复杂度,因为替身类会软件变得更难设计、实现、了解和维护
参考资料:《More Effective C++》 —— Scott Meyers
网友评论