More Effective C++之 Item M30:代理类

虽然你和你的亲家可能住在同一地理位置,但就整个世界而言,通常不是这样的。很不幸,C++还没有认识到这个事实。至少,从它对数组的支持上可以看出一些迹象。在FORTRAN、BASIC甚至是COBOL中,你可以创二维、三维乃至n维数组(OK,FORTRAN只能创最多7维数组,但别过于吹毛求疵吧)。但在C++中呢?只是有时可以,而且也只是某种程度上的。

这是合法的:

int data[10][20];                          // 2D array: 10 by 20

而相同的结构如果使用变量作维的大小的话,是不可以的:

void processInput(int dim1, int dim2)

{

  int data[dim1][dim2];                     // error! array dimensions

  ...                                       // must be known during

}  

甚至,在堆分配时都是不合法的:

int *data =

  new int[dim1][dim2];                      // error!

l         实现二维数组

多维数组在C++中的有用程度和其它语言相同,所以找到一个象样的支持方法是很重要的。常用方法是C++中的标准方法:用一个类来实现我们所需要的而C++语言中并没有提供的东西。因此,我们可以定义一个类模板来实现二维数组:

template

class Array2D {

public:

  Array2D(int dim1, int dim2);

  ...

};

现在,我们可以定义我们所需要的数组了:

Array2D data(10, 20);             // fine

Array2D *data =

  new Array2D(10, 20);          // fine

void processInput(int dim1, int dim2)

{

  Array2D data(dim1, dim2);       // fine

  ...

}

然而,使用这些array对象并不直接了当。根据C和C++中的语法习惯,我们应该能够使用[]来索引数组:

cout << data[3][6];

但我们在Array2D类中应该怎样申明下标操作以使得我们可以这么做?

我们最初的冲动可能是申明一个operator[][]函数:

template

class Array2D {

public:

  // declarations that won't compile

  T& operator[][](int index1, int index2);

  const T& operator[][](int index1, int index2) const;

  ...

};

然而,我们很快就会中止这种冲动,因为没有operator[][]这种东西,别指望你的编译器能放过它。(所有可以重载的运算符见Item M7。)我们得另起炉灶。

如果你能容忍奇怪的语法,你可能会学其它语言使用()来索引数组。这么做,你只需重载operator():

template

class Array2D {

public:

  // declarations that will compile

  T& operator()(int index1, int index2);

  const T& operator()(int index1, int index2) const;

  ...

};

用户于是这么使用数组:

cout << data(3, 6);

这很容易实现,并很容易推广到任意多维的数组。缺点是你的Array2D对象看起来和内嵌数组一点都不象。实际上,上面访问元素(3,6)的操作看起来相函数调用。

如果你拒绝让访问数组行为看起来象是从FORTRAN流窜过来的,你将再次会到使用[]上来。虽然没有operator[][],但写出下面这样的代码是合法的:

int data[10][20];

...

cout << data[3][6];          // fine

说明了什么?

说明,变量data不是真正的二维数组,它是一个10元素的一维数组。其中每一个元素又都是一个20元素的数组,所以表达式data[3][6]实际上是(data[3])[6],也就是data的第四个元素这个数组的第7个元素。简而言之,第一个[]返回的是一个数组,第二个[]从这个返回的数组中再去取一个元素。

我们可以通过重载Array2D类的operator[]来玩同样的把戏。Array2D的operator[]返回一个新类Array1D的对象。再重载Array1D的operator[]来返回所需要的二维数组中的元素:

template

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 data(10, 20);

...

cout << data[3][6];          // fine

这里,data[3]返回一个Array1d对象,在这个对象上的operator[]操作返回二维数组中(3,6)位置上的浮点数。

Array2D的用户并不需要知道Array1D类的存在。这个背后的“一维数组”对象从概念上来说,并不是为Array2D类的用户而存在的。其用户编程时就象他们在使用真正的二维数组一样。对于Array2D类的用户这样做是没有意义的:为了满足C++的反复无常,这些对象必须在语法上兼容于其中的元素是另一个一维数组的一个一维数组。

每个Array1D对象扮演的是一个一维数组,而这个一维数组没有在使用Array2D的程序中出现。扮演其它对象的对象通常被称为代理类。在这个例子里,Array1D是一个代理类。它的实例扮演的是一个在概念上不存在的一维数组。(术语代理对象(proxy object)和代理类(proxy classs)还不是很通用;这样的对象有时被叫做surrogate。)

l         区分通过operator[]进行的是读操作还是写操作

使用代理来实现多维数组是很通用的的方法,但代理类的用途远不止这些。例如,Item M5中展示了代理类可以怎样用来阻止单参数的构造函数被误用为类型转换函数。在代理类的各中用法中,最神奇的是帮助区分通过operator[]进行的是读操作还是写操作。

考虑一下带引用计数而又支持operator[]的string类型。这样的类的细节见于Item M29。如果你还不了解引用记数背后的概念,那么现在就去熟悉Item M29中的内容将是个好主意。

支持operator[]的string类型,允许用户些下这样的代码:

String s1, s2;           // a string-like class; the

                         // use of proxies keeps this

                         // class from conforming to

                         // the standard string

...                      // interface

cout << s1[5];           // read s1

s2[5] = 'x';             // write s2

s1[3] = s2[8];           // write s1, read s2

注意,operator[]可以在两种不同的情况下调用:读一个字符或写一个字符。读是个右值操作;写是个左值操作。(这个名词来自于编译器,左值出现在赋值运算的左边,右值出现在赋值运算的右边。)通常,将一个对象做左值使用意味着它可能被修改,做右值用意味着它不能够被修改。

我们想区分将operator[]用作左值还是右值,因为,对于有引用计数的数据结构,读操作的代价可以远小于写操作的代价。如Item M29解释的,引用计数对象的写操作将导致整个数据结构的拷贝,而读不需要,只要简单地返回一个值。不幸的是,在operator[]内部,没有办法确定它是怎么被调用的,不可能区分出它是做左值还是右值。

 “但,等等,”你叫道,“我们不需要。我们可以基于const属性重载operator[],这样就可以区分读还是写了。”换句话说,你建议我们这么解决问题:

class String {

public:

  const char& operator[](int index) const;       // for reads

  char& operator[](int index);                   // for writes

  ...

};

唉,这不能工作。编译器根据调用成员函数的对象的const属性来选择此成员函数的const和非const版本,而不考虑调用时的环境。因此:

String s1, s2;

...

cout << s1[5];                        // calls non-const operator[],

                                      // because s1 isn't const

s2[5] = 'x';                          // also calls non-const

                                      // operator[]: s2 isn't const

s1[3] = s2[8];                        // both calls are to non-const

                                      // operator[], because both s1

                                      // and s2 are non-const objects

于是,重载operator[]没能区分读还是写。

在Item M29中,我们屈从了这种不令人满意的状态,并保守地假设所有的operator[]调用都是写操作。这次,我们不会这么轻易放弃的。也许不可能在operator[]内部区分左值还是右值操作,但我们仍然想区分它们。于是我们将去寻找一种方法。如果让你自己被其可能性所限制,生命还有什么快乐?

我们的方法基于这个事实:也许不可能在operator[]内部区分左值还是右值操作,但我们仍然能区别对待读操作和写操作,如果我们将判断读还是写的行为推迟到我们知道operator[]的结果被怎么使用之后的话。我们所需要的是有一个方法将读或写的判断推迟到operator[]返回之后。(这是lazy原则(见Item M17)的一个例子。)

proxy类可以让我们得到我们所需要的时机,因为我们可以修改operator[]让它返回一个(代理字符的)proxy对象而不是字符本身。我们可以等着看这个proxy怎么被使用。如果是读它,我们可以断定operator[]的调用是读。如果它被写,我们必须将operator[]的调用处理为写。

我们马上来看代码,但首先要理解我们使用的proxy类。在proxy类上只能做三件事:

l         创建它,也就是指定它扮演哪个字符。

l         将它作为赋值操作的目标,在这种情况下可以将赋值真正作用在它扮演的字符上。这样被使用时,proxy类扮演的是左值。

l         用其它方式使用它。这时,代理类扮演的是右值。

这里是一个被带引用计数的string类用作proxy类以区分operator[]是作左值还是右值使用的例子:

class String {                    // reference-counted strings;

public:                           // see Item 29 for details

  class CharProxy {               // proxies for string chars

  public:

    CharProxy(String& str, int index);                // creation

    CharProxy& operator=(const CharProxy& rhs);       // lvalue

    CharProxy& operator=(char c);                     // uses

    operator char() const;                            // rvalue

                                                      // use

  private:

    String& theString;            // string this proxy pertains to

    int charIndex;                // char within that string

                                  // this proxy stands for

  };

  // continuation of String class

  const CharProxy

    operator[](int index) const;   // for const Strings

  CharProxy operator[](int index); // for non-const Strings

  ...

friend class CharProxy;

private:

  RCPtr value;

};

除了增加的CharProxy类(我们将在下面讲解)外,这个String类与Item M29中的最终版本相比,唯一不同之处就是所有的operator[]函数现在返回的是CharProxy对象。然而,String类的用户可以忽略这一点,并当作operator[]返回的仍然是通常形式的字符(或其引用,见Item M1)来编程:

String s1, s2;           // reference-counted strings

                         // using proxies

...

cout << s1[5];           // still legal, still works

s2[5] = 'x';             // also legal, also works

s1[3] = s2[8];           // of course it's legal,

                         // of course it works

有意思的不是它能工作,而是它为什么能工作。

先看这条语句:

cout << s1[5];

表达式s1[5]返回的是一CharProxy对象。没有为这样的对象定义输出流操作,所以编译器努力地寻找一个隐式的类型转换以使得operator<<调用成功(见Item M5)。它们找到一个:在CahrProxy类内部申明了一个隐式转换到char的操作。于是自动调用这个转换操作,结果就是CharProxy类扮演的字符被打印输出了。这个CharProxy到char的转换是所有代理对象作右值使用时发生的典型行为。

作左值时的处理就不一样了。再看:

s2[5] = 'x';

和前面一样,表达式s2[5]返回的是一个CharProxy对象,但这次它是赋值操作的目标。由于赋值的目标是CharProxy类,所以调用的是CharProxy类中的赋值操作。这至关重要,因为在CharProxy的赋值操作中,我们知道被赋值的CharProxy对象是作左值使用的。因此,我们知道proxy类扮演的字符是作左值使用的,必须执行一些必要的操作以实现字符的左值操作。

同理,语句

s1[3] = s2[8];

调用作用于两个CharProxy对象间的赋值操作,在此操作内部,我们知道左边一个是作左值,右边一个作右值。

 “呀,呀,呀!”你叫道,“快给我看。”OK,这是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);

}

每个函数都创建和返回一个proxy对象来代替字符。根本没有对那个字符作任何操作:我们将它推迟到直到我们知道是读操作还是写操作。

注意,operator[]的const版本返回一个const的proxy对象。因为CharProxy::operator=是个非const的成员函数,这样的proxy对象不能作赋值的目标使用。因此,不管是从operator[]的const版本返回的proxy对象,还是它所扮演的字符都不能作左值使用。很方便啊,它正好是我们想要的const版本的operator[]的行为。

同样要注意在const的operator[]返回而创建CharProxy对象时,对*this使用的const_cast(见Item M2)。这使得它满足了CharProxy类的构造函数的需要,它的构造函数只接受一个非const的String类。类型转换通常是领人不安的,但在此处,operator[]返回的CharProxy对象自己是const的,所以不用担心String内部的字符可能被通过proxy类被修改。

通过operator[]返回的proxy对象记录了它属于哪个string对象以及所扮演的字符的下标:

String::CharProxy::CharProxy(String& str, int index)

: theString(str), charIndex(index) {}

    将proxy对象作右值使用时很简单--只需返回它所扮演的字符就可以了:

String::CharProxy::operator char() const

{

  return theString.value->data[charIndex];

}

如果已经忘了string对象的value成员和它指向的data成员的关系的话,请回顾一下Item M29以增强记忆。因为这个函数返回了一个字符的值,并且又因为C++限定这样通过值返回的对象只能作右值使用,所以这个转换函数只能出现在右值的位置。

回头再看CahrProxy的赋值操作的实现,这是我们必须处理proxy对象所扮演的字符作赋值的目标(即左值)使用的地方。我们可以将CharProxy的赋值操作实现如下:

String::CharProxy&

String::CharProxy::operator=(const CharProxy& rhs)

{

  // if the string is sharing a value with other String objects,

  // break off a separate copy of the value for this string only

  if (theString.value->isShared()) {

    theString.value = new StringValue(theString.value->data);

  }

  // now make the assignment: assign the value of the char

  // represented by rhs to the char represented by *this

  theString.value->data[charIndex] =

    rhs.theString.value->data[rhs.charIndex];

  return *this;

}

如果与Item M29中的非const的String::operator[]进行比较,你将看到它们极其相似。这是预料之中的。在Item M29中,我们悲观地假设所有非const的operator[]的调用都是写操作,所以实现成这样。现在,我们将写操作的实现移入CharProxy的赋值操作中,于是可以避免非const的operator[]的调用只是作右值时所多付出的写操作的代价。随便提一句,这个函数需要访问string的私有数据成员value。这是前面将CharProxy申明为string的友元的原因。

第二个CharProxy的赋值操作是类似的:

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;

}

作为一个资深的软件工程师, 你当然应该消除这两个赋值操作中的代码重复,应该将它们放入一个私有成员函数中供二者调用。你不是这样的人吗?

l         局限性

使用proxy类是个区分operator[]作左值还是右值的好方法,但它不是没有缺点的。我们很喜欢proxy对象对其所扮演的对象的无缝替代,但这很难实现。这是因为,右值不只是出现在赋值运算的情况下,那时,proxy对象的行为就和实际的对象不一致了。

再看一下来自于Item M29的代码,以证明我们为什么为每个StringValue对象增加一个共享标志。如果String::operator[]返回一个CharProxy而不是char &,下面的代码将不能编译:

String s1 = "Hello";

char *p = &s1[1];            // error!

表达式s1[1]返回一个CharProxy,于是“=”的右边是一个CharProxy *。没有从CharProxy *到char *的转换函数,所以p的初始化过程编译失败了。通常,取proxy对象地址的操作与取实际对象地址的操作得到的指针,其类型是不同的。

要消除这个不同,你需要重载CharProxy类的取地址运算:

class String {

public:

  class CharProxy {

  public:

    ...

    char * operator&();

    const char * operator&() const;

    ...

  };

  ...

};

这些函数很容易实现。const版本返回其扮演的字符的const型的指针:

const char * String::CharProxy::operator&() const

{

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

}

非const版本有多一些操作,因为它返回的指针指项的字符可以被修改。它和Item M29中的非const的String::operator[]行为相似,实现也很接近:

char * String::CharProxy::operator&()

{

  // make sure the character to which this function returns

  // a pointer isn't shared by any other String objects

  if (theString.value->isShared()) {

    theString.value = new StringValue(theString.value->data);

  }

  // we don't know how long the pointer this function

  // returns will be kept by clients, so the StringValue

  // object can never be shared

  theString.value->markUnshareable();

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

}

其代码和CharProxy的其它成员函数有很多相同,所以我知道你将把它们封入一个私有成员函数。

char和代理它的CharProxy的第二个不同之处出现带引用计数的数组模板中如果我们想用proxy类来区分其operator[]作左值还是右值时:

template                        // reference-counted array

class Array {                            // using proxies

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;                    // fine

intArray[5] += 5;                    // error!

++intArray[5];                       // error!

如我们所料,当operator[]作最简单的赋值操作的目标时,是成功的,但当它出现operator+=和operator++的左侧时,失败了。因为operator[]返回一个proxy对象,而它没有operator+=和operator++操作。同样的情况存在于其它需要左值的操作中,包括operator*=、operator<<=、operator--等等。如果你想让这些操作你作用在operator[]上,必须为Arrar::Proxy类定义所有这些函数。这是一个极大量的工作,你可能不愿意去做的。不幸的是,你要么去做这些工作,要么没有这些操作,不能两全。

一个类似的问题必须面对:通过proxy对象调用实际对象的成员函数。想避开它是不可能的。例如,假设我们用带引用计数的数组处理有理数。我们将定义一个Rational类,然后使用前面看到的Array模板:

class Rational {

public:

  Rational(int numerator = 0, int denominator = 1);

  int numerator() const;

  int denominator() const;

  ...

};

Array array;

这是我们所期望的使用方式,但我们很失望:

cout << array[4].numerator();                     // error!

int denom = array[22].denominator();              // error!

现在,不同之处很清楚了;operator[]返回一个proxy对象而不是实际的Rational对象。但成员函数numerator()和denominator()只存在于Rational对象上,而不是其proxy对象。因此,你的编译器发出了抱怨。要使得proxy对象的行为和它们所扮演的对象一致,你必须重载可作用于实际对象的每一个函数。

另一个proxy对象替代实际对象失败的情况是作为非const的引用传给函数:

void swap(char& a, char& b);                      // swaps the value of a and b

String s = "+C+";                                 // oops, should be "C++"

swap(s[0], s[1]);                                 // this should fix the

                                                  // problem, but it won't

                                                  // compile

String::operator[]返回一个CharProxy对象,但swap()函数要求它所参数是char &类型。一个CharProxy对象可以印式地转换为一个char,但没有转换为char &的转换函数。而它可能转换成的char并不能成为swap的char &参数,因为这个char是一个临时对象(它是operator char()的返回值),根据Item M19的解释,拒绝将临时对象绑定为非const的引用的形参是有道理的。

最后一种proxy对象不能无缝替换实际对象的情况是隐式类型转换。当proxy对象隐式转换为它所扮演的实际对象时,一个用户自定义的转换函数被调用了。例如,一个CharProxy对象可以转换为它扮演的char,通过调用operator char()函数。如Item M5解释的,编译器在调用函数而将参数转换为此函数所要的类型时,只调用一个用户自定义的转换函数。于是,很可能在函数调用时,传实际对象是成功的而传proxy对象是失败的。例如,我们有一个TVStation类和一个函数watchTV():

class TVStation {

public:

  TVStation(int channel);

  ...

};

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

借助于int到TVStation的隐式类型转换(见Item M5),我们可以这么做:

watchTV(10, 2.5);                       // watch channel 10 for

                                        // 2.5 hours

然而,当使用那个用proxy类区分operator[]作左右值的带引用计数的数组模板时,我们就不能这么做了:

Array intArray;

intArray[4] = 10;

watchTV(intArray[4], 2.5);              // error! no conversion

                                        // from Proxy to

                                        // TVStation

由于问题发生在隐式类型转换上,它很难解决。实际上,更好的设计应该是申明它的构造函数为explicit,以使得第一次的调用watchTV()的行为都编译失败。关于隐式类型转换的细节和explicit对此的影响见Item M5。

l         评述

Proxy类可以完成一些其它方法很难甚至不可能实现的行为。多维数组是一个例子,左/右值的区分是第二个,限制隐式类型转换(见Item M5)是第三个。

同时,proxy类也有缺点。作为函数返回值,proxy对象是临时对象(见Item 19),它们必须被构造和析构。这不是免费的,虽然此付出能从具备了区分读写的能力上得到更多的补偿。Proxy对象的存在增加了软件的复杂度,因为额外增加的类使得事情更难设计、实现、理解和维护。

最后,从一个处理实际对象的类改换到处理proxy对象的类经常改变了类的语义,因为proxy对象通常表现出的行为与实际对象有些微妙的区别。有时,这使得在设计系统时无法选择使用proxy对象,但很多情况下很少有操作需要将proxy对象暴露给用户。例如,很少有用户取上面的二维数组例子中的Array1D对象的地址,也不怎么有可能将下标索引的对象(见Item M5)作参数传给一个期望其它类型的函数。在很多情况下,proxy对象可以完美替代实际对象。当它们可以工作时,通常也是没有其它方法可采用的情况。

你可能感兴趣的:(Scott,Meyers系列)