第五章,技术

30-Proxy classes(替身类、代理类)

实现二维数组

C++中数组的大小必须在编译期已知,因此如下的代码就不会通过编译

void processInput(int dim1, int dim2)
{
int data[dim1][dim2];//错误!
int *data = new int[dim1][dim2];//错误!
}

要解决上面不能直接定义的问题,往往可以通过定义一个class来实现二维数组

template<class T>
class Array2D{
public:
Array2D(int dim1, int dim2);
...
};

//定义二维数组
Array2D<int> data(10,30); //没问题

Array2D<float> *data = new Array2D<float>(10,20); //没问题

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

那么怎么像内置类型的数组那样通过[][]得到数组元素?重载operator [][] ? 但是根本没有operator [][] 这种东西。那么用operator()来替代呢?就像下面这样

template<class T>
class Array2D{
public:
T& operator()(int index1, int index2);
const T& operator()(int index1, int index2) const;
...
}

//使用
Array2D<int> data(10,30);
...
cout << data(3,6); //可以,但是看起来像函数调用,而不像内建数组

嗯,重载operator()在调用的时候更像函数调用,而不是内建数组;或者回到以方括号为索引操作符的思路上,虽然没有operatpr[][],但下面的写法是合法的:

int data[10][20];
...
cout << data[3][6]; //没问题

/*为什么这样写没问题?

变量data并非一个真正的二维数组,而是由10个元素组成的一维数组,
每一个元素本身又是由20个元素组成的一维数组,
所以data[3][6]真正的含义是data中第4个元素数组中的第7个元素,(data[3])[6]
*/

按照这个思路,回到Array2D class的设计,将operator[]重载,令它返回一个Array1D对象,然后再对Array1D重载operator[],令它返回原先二维数组的一个元素:

template<class 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;
...
};

//实现和内建数组一样的二维数组元素调用!
Array2D<float> data(10,20);
...
cout << data[3][6]; //没问题
/*
data[3]会得到一个Array1D对象,再调用Array1D对象的operatpr[](6)得到二维数组中(3,6)所在元素;
Array2D class的用户不需要知道Array1D的存在,用户使用的就好像是真正的二维数组那样。
*/

在这个例子中,Array1D就是一个替身对象proxy objects;替身对象: “临时占位的假对象”,它本身不做实际业务功能,只用来占位置、帮真正的对象完成构造、初始化、内存分配、类型匹配等工作

区分operator[]的读写动作

哇,在原著”开口”前,我要说,又是这家伙,上次说是没法区分,现在终于可以看看怎么区分了…

在proxy classes的各种用途之中,最先驱的当属协助区分operator[]的读写动作了。

String s1, s2;
...
cout << s1[5]; // 读s1
s2[5] = 'x'; // 写s2
s1[3] = s2[8]; //读s2,写s1

/*如何区分operator[]读写?

读取动作的右值运用(rvalue usages),写动作是左值运用(lvalue usages),
lvalue意指赋值动作的左手边,rvalue意指右手边。

这里插一句,原著那个时候还没有移动语义,所以左右值的定义比较简单直接,现代C++诞生了move移动语义后,左/右值的概念发生了一些变化:
左值:可以取地址、有持久身份的表达式,代表一个具体的位置/对象,可以出现在赋值符号的左边和右边(变量、函数返回的引用、解引用结果)
右值:代表值本身,而非持久的位置(字面量、临时对象、函数返回的非引用值)
ok,补充完毕,回到原著;

一般而言,以一个对象作为lvalue,意为它可被修改;而作为rvalue就意为不能修改。
这么说区分operator[]读写操作好像有点头绪了,但实际上,在operator[]内仍然不能区分是左值运用还是右值运用。

“等等!你说什么?你说,不需要区分,我们可以直接利用常量性来对operator[]重载,那使我们得以区分读和写,换句话说,你建议我这样解决问题:”(嗯,原著是这么说的,但是我发誓我没有说过...他也不可能穿越时空读心)
*/

class String{
public:
const char& operator[](int index) const; //针对读取
char& operator[](int index); //针对写
...
};

//哦!不行,编译器只会根据调用函数的对象是否为const为基准来选择调用哪一个operator[]重载函数;(唉,我就说我没说过嘛...)
//也就是说,再多写一个operator[]重载函数也不能区分读写;实际,我们之前的条款就是放任的态度,对于这个区分读写的问题,但是现在我们有了proxy这个概念...


/*终极方案:
修改operator[],令它返回字符串中字符的proxy,而是字符本身。然后等待,看看这个proxy被如何使用,如果它被读,我们就将operator[]的调用动作视为读取动作;如果被写,就将operator[]视为写动作。

首先,重要的是了解我们即将使用的proxies。对于这个proxy,有3个要点:
1、产生它,本例也就是指定它代表哪一个字符串中的哪一个字符
2、以它作为赋值动作的目标(接受端),这种情况下是对它所代表的字串内的字符做赋值动作。如果这样使用,proxy代表的将是“调用operator[] 函数”的那个字符串的左值运用。
3、以其他方式使用之。如果这么使用,proxy表现的是“调用operator[]函数”的那个字符串的右值运用。
*/

//reference-counted String class,其中利用proxy class的赋值操作符重载来区分operator[]的左值运用和右值运用
class String{
public:
class CharProxy{
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 Strings
CharProxy operator[](int index); //针对non-const Strings
...
friend class CharProxy;

private:
RCPtr<StringValue> value;
};

String s1,s2;
cout << s1[5]; //读

/*读取是如何运作的?
s1[5]产生一个CharProxy对象。由于该对象没有定义output操作符,编译器需要将CharProxy进行隐式转型,
于是调用CharProxy内部的operator char() const,打印出operator char() const返回的字符,这个过程发生在所有“被用来作为右值”的CharProxy对象身上。
*/

s2[5] = 'x'; //写
/*写操作是如何运作的?
s2[5]导出一个CharProxy对象,由于CharProxy作为接收端,会调用 CharProxy& operator=(const CharProxy& rhs);
被用来作为一个左值;
*/

s1[3] = s2[8]; //s2读,s1写
/*s1[3]这个CharProxy对象调用CharProxy& operator=(const CharProxy& rhs),作为左值;
s2[8]这个CharProxy对象调用operator char() const进行隐式转换为char,作为右值
*/

//具体实现:

//String::operator[]只返回被请求字符的proxy,并没有多余的动作,等到调用proxy重载的operator=或隐式转换operator char() const时就能知道是作为左值运用还是作为右值运用,从而实现读写操作的区分!
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);
}

//Proxy的constructor实现
String::CharProxy::CharProxy(String& str, int index)
: theString(str), charIndex(index) {}

//proxy隐式转换为char,作为右值运用(读取)
String::CharProxy::operator char() const
{
return theString.value->data[charIndex];
}

//Proxy的赋值操作符,作为左值运用(写操作)
String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs)
{
//如果与其他String共享实值,自己复制备份,自己单独使用(COW写时复制)
if(theString.value->isShared()){
theString.value = new StringValue(theString.value->data);
}

//修改写入指定位置字符
theString.value->data[charIndex] = rhs.theString.value->data[rhs.charIndex];//需要调用private的data,这就是CharProxy被声明为friend的原因
return *this
}

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
}

//原著说:“作为一个合格的软件工程师,你当然会将重复的代码抽出来放在一个private CharProxy member function中,然后让两个操作符都去调用它,是吧!”

//好的,我当然是合格的软件工程师,我现在就做,主打一个听劝
class String{
public:
class CharProxy{
public:
...
CharProxy& operator=(const CharProxy& rhs); //左值运用
CharProxy& operator=(char c);

private:
void cow();
...
};
...
};

void String::CharProxy::cow()
{
if(theString.value->isShared()){
theString.value = new StringValue(theString.value->data);
}
}

String::CharProxy& String::CharProxy::operator=(char c)
{
//Copy-on-write
cow();
theString.value->data[charIndex] = c;
return *this
}

String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs)
{
theString.value->data[charIndex] = static_cast<char>(rhs);//调用operator=(char),极致复用
return *this
}

限制

Proxy class很适合用于区分operator[]的左值运用和右值运用,但是这项技术仍有一些缺点。

String s1 = "Hellow";
char *p = &s1[1]; //编译错误!
/*
如果String::operator[]返回的是一个CharProxy,而非char&,那么上面的代码将无法通过编译。
因为,s1[1]返回CharProxy,取地址就CharProxy*,但是没有将CharProxy*转换为char*的类型转换。

所以这个缺点就是:
“对proxy取地址得到的指针类型和对真实对象取址所取得的指针类型不同”

好在这个缺点是可以解决的:重载取地址操作符
*/

class String{
class CharProxy{
public:
...
char * operator&();
const char * operator&()const;
...
};
...
};

const char * String::CharProxy::operator&() const
{
return &(theString.value->data[charIndex]);
}

char * String::CharProxy::operator&()
{
if(theString.value->isShared()){
theString.value = new StringValue(theString.value->data);
}

theString.value->makeUnShareable();
return &(theString.value->data[charIndex]);
}


//现在,如果有一个template用来实现reference-counted数组,其中利用proxy classes来区分operator[]被调用时的左值运用和右值运用,那么chars和其替代身CharProxys的第二个缺点也会显而易见:

template<class T>
class Array{
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]++; //错误!

/*operator[]用于operator+=或operator++,调用式的左手边会失败,
因为proxy没有重载operator++和operator+=;
类似的情况还包括operator*=, operator<<=, operator--等;
如果希望这些操作符都能和返回的proxy合作,就需要在proxy class中一个一个定义这些函数。
是的,这工作量真的不小,但你不做,就不支持。
*/

//还有一个问题,“通过proxies调用真实对象的member functions”,如果直接这样做,会失败。
class Rational{
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
...
};

Array<Rational> array;
cout << array[4].numerator(); //错误!
int denom = array[22].denominator(); //错误!

/*我们期望可以像上面那样对象正常的调用成员函数一样使用,但operator[]返回的实际是proxy而不是Rational对象,因此无法通过编译。
为了让proxies模仿所代表对象的行为,必须将适用于真实对象的每一个函数都加以重载,使它们也适用于proxies。
*/


//还还有一个问题,“Proxies无法取代真实对象的另一种情况是,用户将他们传递给接受references to non-const objects的函数”

void swap(char& a, char& b);
String s = "+C+";
swap(s[0],s[1]); //编译失败!
/*
s[0],显然会调用隐式转换char operator(),将Proxy转换为char,
那这让我回忆起之前的条款,这种隐式转换实际是先转换为一个临时对象,而临时对象显然不能被char& 承接,因此编译失败! 而条款19正好有一些理由使得编译器拒绝将临时对象绑定到non-const reference参数身上。(修改临时对象,毫无意义)

复习一下隐式转换:
构造函数转换(从其他类型→本类)
类型转换运算符(从本类→其他类型)
*/

//proxies难以完全取代真正对象的最后一个原因(终于到最后一个原因了...)在于隐式类型转换。
//当proxy object 被隐式转换为它所代表的真正对象时,会有一个用户定制的转换函数被调用。
//🌟编译器在“将调用端自变量转换为对于的被调用端(函数)参数”过程中,运用用户定制转换函数的次数只限一次。

class TVStation{
public:
TVStation(int channel);
...
};

void watchTV(const TVStation& station, float hoursToWatch);

//由于int至TVStation之间有隐式转换
watchTV(10,2.5); //观看频道10,共2.5小时

//但是,如果适用之前的reference-counted数组的template,并以proxy classes来区分operator[]的左/右值运用,就不能这么做了:

Array<int> intArray;
intArray[4] = 10;
watchTV(intArray[4], 2.5);//错误!没有任何转换动作可以将Proxy<int>转换为TVStation

//比较好的设计是,将TVStation class的constructor声明为explicit,防止隐式转换,这样第一次调用watchTV就会编译报错。

总结

Proxy classes允许我们完成一些看似不可能的功能。多维数组的实现、左/右值的区分,压抑隐式转换。

但是也有缺点,如果扮演函数返回值的角色,那些proxy objects将是一种临时对象,需要被产生和被销毁,也就带来了构造析构的成本。此外也增加了软件系统的复杂度,使产品更难设计、实现、了解、 维护。

最后,当class的身份从“与真实对象合作”转移到“与替身对象(peoxies)合作”,往往会造成class语义的改变,因为proxy objects展现的行为常常和真正对象的行为有些隐微差异。有时会造成proxies在系统设计上的弱势,不过其实很少需要用到“proxies和真实对象有异”的那些操作行为,比如很少会有需求对一个使用proxy实现的Array1D对象取地址,它往往是不可见的,辅助实现功能;也很少会将proxy替身类对象作为参数传递给函数。

在多数情况下,proxies可以完美取代所代表的真正的对象,而两者间的隐微差异不是重点。