代理类其实就是代理模式的应用。Proxy模式为其他对象提供一种代理以控制这个对象的访问。在面向对象系统中,有些对象由于某些原因(比如对象创建开销很大,或者某些操作需要安全控制,或者需要进程外的访问),直接访问会给使用者或者系统结构带来很多麻烦,我们可以在访问此对象时加上一个对此对象的访问层,这个访问层也叫代理。Proxy模式是最常见的模式,在我们生活中处处可见。
1、实现二维数组。
C++中数组各个维的大小必须在编译期确定。要在运行期确定数组大小,我们可以开发一个数组类来代替内建的数组,这样就可以在运行期指定数组的大小。例如对二维数组,开发一个Array2D<T>模板。对二维数组元素的访问用arr[4][6]的形式,但是类对象没有operator[][]这样的重载运算符,因此怎样才能使之与内建数组的行为一致呢?如果在Array2D<T>中直接存储二维数组,我们只有operator[]运算符,它只带一个索引参数,因此不能访问二维数组中的元素。但是一维数组可以通过operator[]直接访问数组的元素,而二维数组实际上是一个一维数组,其中每个元素又是一个一维数组。比如arr[4][6]实际上是(arr[4])[6],先取出arr的第4个元素arr[4],这个元素是一维数组,然后在arr[4]这个元素中取出第6个元素。可见,访问Array2D<T>中的元素分两步,我们可以先开发一个一维数组类Array1D<T>,它的operator[]可以直接访问元素,然后在Array2D<T>中存储一个Array1D<T>数组(而不是原始的二维数组数据),Array2D<T>的operator[]只是返回一个一维数组对象,这是第一步访问,第二步访问则直接代理给了这个一维数组对象,通过它的operator[]最终得到数组中的元素值。如下:
//用placement new调用构造函数初始化各个元素的内存
for(std::size_t i=0;i<length2;++i)
new(data+i) Array1D<T>(length1);
}
Array2D(Array2D<T> const& rhs)
:length2(rhs.length2),length1(rhs.length1),data(0){ //拷贝构造:深拷贝
//为Array1D<T>数组分配原始的内存
void* raw=::operator new[](length2*sizeof(Array1D<T>));
data=static_cast<Array1D<T>*>(raw);
//用placement new调用拷贝构造函数来初始化各个元素的内存
for(std::size_t i=0;i<length2;++i)
new(data+i) Array1D<T>(rhs.data[i]);
}
Array2D<T>& operator=(Array2D<T> const& rhs){ //赋值运算符:要深拷贝
if(this==&rhs)
return *this;
//如果有一维不相等,则数组不能赋值,抛出异常
if((length2!=rhs.length2)||(length1!=rhs.length1))
throw NotEqualLength();
else{ //否则进行深拷贝
for(std::size_t i=0;i<length2;++i)
data[i]=rhs.data[i];
}
return *this;
}
~Array2D(){ //没有用new来创建data数组,就不能直接用delete[]来删除data
for(std::size_t i=0;i<length2;++i)
data[i].~Array1D<T>(); //显式调用析构函数销毁各个对象
::operator delete[](static_cast<void*>(data)); //释放内存
}
Array1D<T> const& operator[](std::size_t index) const{ //const版本
return data[index]; //返回索引处的一维数组对象
}
Array1D<T>& operator[](std::size_t index){ //非const版本
return data[index]; //返回索引处的一维数组对象
}
std::size_t getLength2() const{ //返回数组第2维的大小
return length2;
}
std::size_t getLength1() const{ //返回数组第1维的大小
return length1;
}
long getElemSum() const{ //返回数组中的元素总个数
return length1*length2;
}
};
#endif
Array1D<int> yourarr(myarr); //测试拷贝构造函数
std::cout<<"yourarr[2]: "<<yourarr[2]<<std::endl;
Array1D<int> herarr(4);
herarr=yourarr; //测试赋值操作符
std::cout<<"herarr[2]: "<<herarr[2]<<std::endl;
Array2D<int> arr(a1,a2);
std::cout<<"arr's length2: "<<arr.getLength2()<<std::endl; //输出二维数组各个维的长度
std::cout<<"arr's length1: "<<arr.getLength1()<<std::endl;
for(std::size_t i=0;i<arr.getLength2();++i)
for(std::size_t j=0;j<arr.getLength1();++j)
arr[i][j]=i+j; //下标访问与内置数组一样
std::cout<<"arr[2][3]: "<<arr[2][3]<<std::endl; //输出arr[2][3]=5
std::cout<<"arr's elem-numbers: "<<arr.getElemSum()<<std::endl; //输出元素总个数
Array2D<int> arr2(arr); //测试拷贝构造函数
std::cout<<"arr2[2][3]: "<<arr2[2][3]<<std::endl;
Array2D<int> arr3(4,5);
arr3=arr2; //测试赋值操作符
std::cout<<"arr3[2][3]: "<<arr3[2][3]<<std::endl;
Array2D<int> arr4(3,5);
arr4=arr3; //抛出异常
return 0;
}
解释:
(1)Array1D<T>是一维数组模板,它直接存储了一个指向数组的指针data,因此在拷贝和赋值时都要进行深拷贝(对data指向的数据进行拷贝),赋值时还要检查数组长度是否一致,若不一致,则不能赋值,抛出异常。由于要创建T类型的数组,因此T必须要有默认构造函数。当然我们可以用容器比如vector来存放数据,而不用data数组,这样就可以不要求T必须有默认构造函数。Array1D<T>的operator[]直接返回数组中元素的引用。
(2)Array2D<T>是二维数组模板,它并没有存储二维数组数据,而存储了一个由一维数组对象组成的数组data。注意因为Array1D<T>没有默认的构造函数,它只有一个单参数的构造函数,因此不能直接用new Array1D<T>[len2]来初始化data。当要创建数组但没有默认构造函数时,我们可以用operator new[]来为数组分配原始的内存,然后用placement new表达式调用显式的构造函数来初始化各个元素的内存。对拷贝构造也类似,只不过调用的是拷贝构造函数。这里创建数组并没有用new操作符,因此在析构时不能用delete操作符(用delete是未定义行为,在Linux下出现segmentation fault错误),必须对数组的各个元素显式地调用析构函数来销毁对象,然后调用operator delete[]来释放整个数组内存。
(3)Array2D<T>的operator[]只是返回下标处的一维数组对象的引用,相当于arr[2],这样对数组元素的访问被代理给了这个一维数组对象arr[2],用它的operator[]最终可以获取到元素的值,即arr[2][3],从测试代码中我们可以看出这个结果。可见,通过代理类Array1D<T>,我们最终实现了与内建行为一致的元素访问语法。这种思想可以推广到多维数组上去。
2、区分operator[]的读操作和写操作。
对于前面“引用计数实现”中介绍的String类,我们通过共享开关实现了一定程度的写时拷贝,但并不完美,它导致有时在读的时候也进行了拷贝。这主要是由于共享开关并不能让operator[]区分读操作和写操作,opeartor[]函数里只是返回下标处字符的引用,然后我们才对这个字符进行读(作右值)或写(作左值)操作,如cout<<s2[2]是读操作,s2[2]='x'是写操作。可见读或写操作并不是在operator[]里面完成的,operator[]内部并不能区分是读还是写。通过代理类技术,我们可以将读或写的判断推迟到operator[]返回之后。修改operator[],让它返回一个字符的代理类对象(而不是字符本身),然后看看这个代理对象是被读(比如赋值其他对象),还是被写(比如被赋值),是写则需要写时拷贝。
theString.value->data[charIndex]=
rhs.theString.value->data[rhs.charIndex]; //写入操作
return *this;
}
CharProxy& operator=(char c){ //原始字符到代理对象的写操作:写时拷贝
if(theString.value->isShared())
theString.value=new StringValue(theString.value->data);
theString.value->data[charIndex]=c; //写入操作
return *this;
}
operator char() const{ //对代理对象的读操作:直接转型为底部字符,无需拷贝
return theString.value->data[charIndex];
}
};
String(char const* initValue="")
:value(new StringValue(initValue)){ //构造函数
}
CharProxy const operator[](int index) const{ //const版本:对返回的代理对象只能进行读操作
//因为返回的对象是const的
//对要获取的字符创建一个代理对象返回
return CharProxy(const_cast<String&>(*this),index);
}
CharProxy operator[](int index){ //非const版本:对返回的代理对象可读可写
return CharProxy(*this,index);
}
friend class CharProxy; //要访问String的私有成员value
friend std::ostream& operator<<(std::ostream&,String const&);
};
inline std::ostream& operator<<(std::ostream& os,String const& str){
os<<(str.value)->data;
return os;
}
#endif
解释:
(1)CharProxy类是字符的代理类,它记录了字符的下标和字符属于哪个String对象,用它来控制对字符的访问。String的const版本的operator[]返回const的CharProxy对象,这样我们对这个字符代理对象只能进行读操作。由于CharProxy构造函数的参数为String&,而operator[]中的*this是const的,因此要用const_cast去掉const属性。String的非const版本的operator[]返回非const的CharProxy对象(而不引用,因此不能通过operator[]来修改String的内容),这样我们对这个字符代理对象可读可写。现在,s2[2],s2[3]等操作得到的是CharProxy对象,而不是原始的字符。
(2)现在来看对s2[2]的操作,它是一个CharProxy对象,而不是原始的字符,当进行读操作cout<<s2[2]时,直接用CharProxy中的转型运算符operator char,转型为底部字符,无需拷贝,并且是const的,不能修改String中的这个字符,因此读操作区分出来了,没有拷贝。当进行写操作s2[2]='x',调用CharProxy的第二个赋值运算符,把char型字符赋给CharProxy对象,这需要写时拷贝。对另一种写操作形式s2[2]=s2[3],则调用第一个赋值运算符,也进行了写时拷贝。赋值运算符的左边是从String的operator[]返回的CharProxy对象,作为左值肯定是写操作,因此写操作也区分出来了。可见,通过代理对象,我们区分出了String的operator[]是读操作还是写操作,并且在写操作时进行写时拷贝。注意代理类CharProxy要访问String的私有成员value,因此要声明为友元类。
(3)使用代理类技术并不是没有缺点的。比如原来的s2[2]返回的是直接的字符,现在返回的是CharProxy对象。当然通过一次用户自定义的转型,s2[2]可以当作char型字符来用,但我们不能对s2[2]实施类似于char型的其他运算。比如不能把&s2[2]赋给char*型指针,因为CharProxy没有重载operator&,&s2[2]的结果是CharProxy*型指针。同理还有+=、++、<<=等很多运算,以及需要char&型作为函数参数而不能把s2[2]传过去等,你要使用这些操作,就必须在CharProxy中重载各个运算符。总之,代理对象不可能与它所代理的字符有完全相同的行为,而如果不使用代理对象的话,我们就不存在这样的问题。