More Effective C++ 读书摘要(五、技巧3)Item30 - 31

本文探讨了C++中使用代理类实现多维数组和区分读写操作的方法,以及如何通过模拟虚函数表来实现基于多个对象的虚函数调用,解决了多重分派问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Item30. 代理类:

 

实现二维数组

 

对于C++内置的数组,如data[][],第一个[]返回的是一个数组,第二个[]从这个返回的数组中再去取一个元素。


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

现在,它合法了:
Array2D<float> data(10, 20);
...
cout << data[3][6];          // fine

每个Array1D对象扮演的是一个一维数组,而这个一维数组没有在使用Array2D的程序中出现。扮演其它对象的对象通常被称为代理类。在这个例子里,Array1D是一个代理类

 

通过代理区分operator[]的读操作和写操作

 

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

 

operator[]可以在两种不同的情况下调用:读一个字符或写一个字符。读是个右值操作;写是个左值操作。
cout << s1[5];           // read s1
s2[5] = 'x';             // write s2
s1[3] = s2[8];           // write s1, read s2

如果把自己限制在只做可能做的事情,生命还有什么乐趣可言?

将读或写的判断推迟到operator[]返回之后。(这是lazy原则(Item17)的一个例子)因为我们可以修改operator[]让它返回一个(代理字符的)proxy对象而不是字符本身。我们可以等着看这个proxy怎么被使用。

 

首先要理解我们使用的proxy类。在proxy类上只能做三件事:
* 创建它,也就是指定它扮演哪个字符。
* 将它作为赋值操作的目标,在这种情况下可以将赋值真正作用在它扮演的字符上。这样被使用时,proxy类扮演的是左值。
* 用其它方式使用它。这时,代理类扮演的是右值。

 

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

String的opertator[]函数的代码:

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

 

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

 

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

 

同理,语句
s1[3] = s2[8];
调用作用于两个CharProxy对象间的赋值操作,在此操作内部,我们知道左边一个是作左值,右边一个作右值。


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

注意,operator[]的const版本返回一个const的proxy对象。因为CharProxy::operator=是个非const的成员函数,这样的proxy对象不能作赋值的目标使用。因此,从operator[]的const版本返回的proxy对象,和它所扮演的字符都不能作左值使用。

 

同样要注意在const的operator[]返回而创建CharProxy对象时,对*this使用的const_cast(见Item2)。这使得它满足了CharProxy类的构造函数的需要,它的构造函数只接受一个非const的String类。

 

通过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];
}


因为这个函数返回了一个字符的值,并且又因为C++限定这样通过值返回的对象只能作右值使用,所以这个转换函数只能出现在右值的位置。

 

CharProxy的赋值操作实现如下:

顺便提一句,这个函数需要访问string的私有数据成员value。这是前面将CharProxy申明为string的友元的原因。

 

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

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

 

局限性

 

①右值不只是出现在赋值运算的情况下,那时,proxy对象的行为就和实际的对象不一致了。如果String::operator[]返回一个CharProxy而不是char &,下面的代码将不能编译:
String s1 = "Hello";
char *p = &s1[1];            // error!


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

 

需要重载CharProxy类的取地址运算符

这些函数很容易实现。const版本返回其扮演的字符的const型的指针:
const char * String::CharProxy::operator&() const
{
  return &(theString.value->data[charIndex]);
}

 

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

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

 

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

看一下这个数组可能被怎样使用:
Array<int> intArray;
...
intArray[5] = 22;                    // fine
intArray[5] += 5;                    // error!
++intArray[5];                       // error!


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

 

③另一个类似的问题是:当通过proxy对象调用实际对象的成员函数时同样会有问题。想避开它是不大可能的。

例如,假设我们用带引用计数的数组处理有理数。我们将定义一个Rational类,然后使用前面看到的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()的返回值),根据Item19的解释,拒绝将临时对象绑定为非const的引用的形参是有道理的。

 

⑤最后一种proxy对象不能无缝替换实际对象的情况是隐式类型转换

借助于int到TVStation的隐式类型转换(见Item5),我们可以这么做:
watchTV(10, 2.5);                       // watch channel 10 for
                                                // 2.5 hours

然而,当使用那个用proxy类区分operator[]作左右值的带引用计数的数组模板时,我们就不能这么做了:
Array<int> intArray;
intArray[4] = 10;
watchTV(intArray[4], 2.5);              // error! no conversion
                                                    // from Proxy<int> to
                                                    // TVStation

由于问题发生在隐式类型转换上,它很难解决(thy:你既不能要求使用方为你的proxy类做一个构造函数,也不可能在proxy类中为每个可能的使用者做一个类型转换函数)。实际上,更好的设计应该是申明它的构造函数为explicit,以使得即使第一次调用watchTV()都会编译失败。

 

小结:优点->Proxy类可以完成一些其它方法很难甚至不可能实现的行为。多维数组是一个例子,左/右值的区分是第二个,限制隐式类型转换(见Item5)是第三个
缺点->proxy类也有缺点。作为函数返回值的proxy对象是临时对象(见Item19),它们必须被构造和析构。这不是免费的,虽然能够区分读写操作使我们得到了更多的补偿。Proxy对象的存在增加了软件的复杂度,因为额外增加的类使得事情更难设计、实现、理解和维护。

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

 

Item31. 基于多个对象的虚函数

 

假设要编写一个飞船、太空站和小行星的游戏。
class GameObject { ... };
class SpaceShip: public GameObject { ... };
class SpaceStation: public GameObject { ... };
class Asteroid: public GameObject { ... };


现在,假设你开始进入程序内部,写代码来检测和处理物体间的碰撞。你会提出这样一个函数:

问题来了。当你调用processCollision()时,你知道object1和object2正好相撞,并且你知道发生的结果将取决于object1和object2的真实类型,但你并不知道其真实类型是什么;你所知道的就只有它们是GameObject对象。如果碰撞的处理过程只取决于object1的动态类型,你可以将processCollision()设为虚函数,并调用object1.processColliion(object2)。如果只取决于object2的动态类型,也可以同样处理。但现在,取决于两个对象的动态类型。虚函数体系只能作用在一个对象身上,它不足以解决问题。

 

用虚函数和RTTI

 

我在这里只写了派生类SpaceShip的情况,SpaceStation和Asteroid的形式完全一样的。
实现双重分派(double dispatch)的最常见方法就是和虚函数体系格格不入的if...then...else链。我们首先找出otherObject的实际类型,然后测试所有的可能:

注意,我们需要检测的只是一个对象的类型。另一个是*this,它的类型由虚函数体系判断。因为上述代码处于SpaceShip的成员函数中,所以*this肯定是一个SpaceShip对象,因此我们只需找出otherObject的类型。

 

RTTI有一点令人不安:最后一个else语句抛出了一个异常。
我们的代价是几乎放弃了封装,因为每个collide函数都必须知道所有其它继承自GameObject的兄弟类的存在。尤其是,如果增加一个新的类时,我们必须更新每一个基于RTTI的if...then...else链以处理这个新的类型。即使只是忘了一处,程序都将有一个bug,而且它还不显眼。编译器也没办法帮助我们检查这种疏忽,因为它们根本不知道我们在做什么(参见Item39)。这样的程序本质上是不可维护的

 

只使用虚函数


其基本原理就是用两个单一分派实现二重分派,也就是说有两个单独的虚函数调用:第一次决定第一个对象的动态类型,第二次决定第二个对象动态类型。

 

 

 

实现是极为简单:
void SpaceShip::collide(GameObject& otherObject)
{
  otherObject.collide(*this);
}

 

 


因为是在SpaceShip的成员函数中,所以*this肯定是SpaceShip类型。调用的将是接受SpaceShip参数的collide函数,而不是带GameOjbect类型参数的collide函数。

 

这个缺陷和前面看到的RTTI方法一样:每个类都必须知道它的兄弟类。当增加新类时,所有的代码都必须更新。情况将会更糟:你必须为每个现存类增加一个collide函数。而修改现存类经常是你做不到的。

 

模拟虚函数表

 

I should mention that the meek may inherit the earth, but the meek of heart may wish to take a few deep breaths before reading what follows.

 

对GameObjcet继承体系中的函数作一些修改:

(thy:这里不再用同名函数的重载,是因为后面要用不同的函数名来实现映射)

 

在SpaceShip::collide中,我们需要一个方法来映射参数otherObject的动态类型到一个碰撞处理成员函数指针。一个简单的方法是创建一个映射表,给定的类名对应恰当的成员函数指针。直接使用一个这样的映射表来实现collide是可行的,但如果增加一个中间函数lookup时,将更好理解。lookup函数接受一个GameObject参数,返回相应的成员函数指针。

既然有了lookup,collide的实现如下:

lookup很容易实现,但创建、初始化和析构这个映射数组是个有意思的问题。这样的数组应该在它被使用前构造和初始化,并在不再被需要时析构。我们可以使用new和delete来手工创建和析构它,但这时怎么保证在初始化以前不被使用呢?更好的解决方案是让编译器自动完成,在lookup中把这个数组申明为静态就可以了。这样,它在第一次调用lookup前构造和初始化,在main退出后的某个时刻被自动析构。

 

而且,我们可以使用标准模板库提供的map模板来实现映射表,因为这正是map的功能:

译注:要指出的是,类名不是那么可完全确定的。C++标准并没有规定type_info::name的返回值,不同的实现,其行为会有区别。(例如,对于类Spaceship,type_info::name的一个实现返回“class SpaceShip”;而另一种实现可能是“Spaceship”)更好的设计是通过它所关联的type_info对象的地址来鉴别一个类,因为每个类关联的type_info对象肯定是唯一的。HitMap于是应该被申明为map<cont type_info *, HitFunctionPtr>。

lookup的代码很简单明了。

最后一句是return (*mapEntry).second而不是习惯上的mapEntry->second以满足STL库在实现上的差异性。具体原因见Item M18。

 

初始化模拟虚函数表

 

将所有的函数都改为接受GameObject类型:

所有的碰撞处理函数都有着相同的参数类型,所以必须要给它们以不同的名字。


现在,我们可以以我们一直期望的方式来写initializeCollisionMap函数了:

很遗憾,我们的碰撞函数现在得到的是一个更宽泛的CameObject参数而不是期望中的派生类类型。要想得到我们所期望的东西,必须在每个碰撞函数开始处采用dynamic_cast(见Item M2):

如果转换失败,dynamic_cast会抛出一个bad_cast异常。当然,它们从不会失败,因为碰撞函数被调用时不会带一个错误的参数类型的。只是,谨慎一些更好

使用非成员函数处理碰撞

 

我们现在知道了怎么构造一个类似vtbl的映射表以实现二重调度的第二部分。因为这张表包含的是指向成员函数的指针,所以在增加新的GameObject类型时仍然需要修改类的定义,这还是意味着所有人都必须重新编译

 

如果将碰撞处理函数从类里移出来,我们在给用户提供类定义的头文件时,就不用带上任何碰撞处理函数。我们可以将实现碰撞处理函数的文件组织成这样:

注意,这里用了无名的命名空间来包含实现碰撞处理函数所需要的函数。无名命名空间中的东西是当前编译单元(其实就是当前文件)私有的--很象被申明为文件范围内static的函数一样。有了命名空间后,文件范围内的static已经不赞成使用了,你应该尽快让自己习惯使用无名的命名空间(只要编译器支持)。

 

这个实现与使用成员函数的版本有三个细微的区别:
第一,HitFunctionPtr现在是一个指向非成员函数的指针类型的typedef。第二,异常类CollisionWithUnknownObject被改叫UnknownCollision,第三,其构造函数需要两个对象作参数而不再是一个了。这也意味着我们的映射需要三个消息了:两个类型名,一个HitFunctionPtr。

 

但map的一个node只能放置两个元素,因此需要借助makeStringPair的帮助,initializeCollisionMap的实现如下:


lookup函数也必须被修改以处理pair<string,string>对象,并将pair作为映射表的第一部分:

thy:但是在这样的设计中collision(SpaceShip&, Asteroid&)与collision(Asteroid&, SpaceShip&)将是不同的

 

继承与模拟虚函数表

 

如果有一个Commercial Ship和一个MIlitary Ship均继承自SpaceShip,在一个MilitaryShip对象和一个Asteroid对象碰撞时,我们期望调用
void shipAsteroid(GameObject& spaceShip,
                  GameObject& asteroid);
但它实际上是不会被调用的,而是抛出了一个UnknownCollision的异常。因为lookup在根据类型名“MilitaryShip”和“Asteroid”在collisionMap中查找函数时没有找到。虽然MilitaryShip可以被转换为一个SpaceShip,但lookup却不知道这一点

 

而且,没有什么简单的办法来告诉lookup。如果你需要实现二重分派,并且需要支持基于继承的参数转换,你只能采用我们前面讨论的二次虚函数调用的方法(同时也意味着往继承体系中增加新类的时候,所有人都必须重新编译,你必须要忍受这一切,有时候生活就是这样)。

 

再论初始化模拟虚函数表

 

如果我们想在游戏运行过程中增加、删除或修改碰撞处理函数,将怎么办?


我们可以将映射表做成一个类,并由它提供动态修改映射关系的成员函数。例如:

借助于CollisionMap类,每个想增加映射关系的用户可以直接这么做:

必须确保在发生碰撞前就将映射关系加入了映射表。一个方法是让GameObject的子类在构造函数中进行确认。这将导致在运行期的一个小小的性能开销。另外一个方法是创建一个RegisterCollisionFunction 类

用户于是可以使用此类型的一个全局对象来自动地注册他们所需要的函数:

因为这些全局对象在main被调用前就构造了,它们在构造函数中注册的函数也在main被调用前就加入映射表了。如果以后增加了一个派生类
class Satellite: public GameObject { ... };
以及一个或多个碰撞处理函数
void satelliteShip(GameObject& satellite,
                   GameObject& spaceShip);
void satelliteAsteroid(GameObject& satellite,
                       GameObject& asteroid);

 


这些新函数可以用同样方法加入映射表而不需要修改现存代码:
RegisterCollisionFunction cf4("Satellite", "SpaceShip",
                              &satelliteShip);
RegisterCollisionFunction cf5("Satellite", "Asteroid",
                              &satelliteAsteroid);

 

thy:实际上是在《代码大全2nd》中提到的表格驱动法的一种应用

 

 这不会改变实现多重分派没有完美解决方法的事实。但它使得提供数据给基于map的实现更为容易。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值