读【深度探索C++对象模型】【下】

【Template】
Template的出现大大改变了C++的编程方式,甚至在传统的面向对象编程方式的基础上派生出了泛型编程方式。简单的理解泛型,可以看成是以平行级别的类(相对于继承来说)对代码依照算法逻辑进行复用。比如有一个template <class Type> A。当你分别使用A<int>, A<double>, A<aClass &>对其具现化的时候。可以简单的看成编译器为你生成了三个类A_int, A_double, A_aClassR(名字是虚拟的)。这三个类的算法逻辑完全一致,只是其中核心数据类型(即以Type标识的类型)不一样。这种重用方式,摆脱了传统的通过继承了实现的弊端,增加了重用的简单性降低了耦合性。

但是,泛型编程可能会造成代码的膨胀。比如你A类中有50个方法(数据成员若干),你分别用10种类型对其进行具现化,编译器就需要生成10个带有50个方法的类,大大浪费了空间。因此,为了避免这个问题,编译器采取了很多的优化手段。比如,在具现化的过程中,生成的具体类只拥有用到过的方法,其他的都不会被插入到该具现的类中。再比如,对于不同的指针类型(A<class1 *>, A<class2 *>),其存储方式和运行特点都一样,编译器可能会对所有指针类型生成一个类,这样也能够避免代码膨胀现象。

 
【异常处理】

结构化异常机制(try...catch)的引入对C++的影响是巨大的。一方面它使得C++的错误处理变得清晰而统一;另一方面人们会很顾虑异常处理的引用会在时空两方面影响程序性能。从编译器来看,异常处理的引入对其影响很大,它需要增加结构对程序进行分段,保证异常处理能够进行。看下面这段代码:

 void Test()
{
 A *a1;
 A a2;

 try
 {
  ...
 }
 catch(...)
 {
  ...
  throw;
 }
}

这个程序被分成三个区段,A *a1是一个,A a2是另外一个,try中部分是第三个。当try中某句代码执行发生异常时,需要摧毁该函数的栈信息。对于A *a1来说只需要简单的释放空间就好;而对于A a2来说不仅要释放空间还要调用A的析构函数,执行析构处理。 而对这个抛出异常而言,首先需要判断它属于哪个区段(方法可以很多,有一种被称为program counter的)。如果它不属于某个try区段,将继续被抛出;当它属于某个try区段时,异常将与catch的内容比较。如果不存在匹配的catch,异常将被继续抛出;否则,执行catch中的内容。

如果这个catch段不能完全处理该异常,我们通常的做法是用throw将其继续抛出,为了保证异常中包含的信息可以正确跟踪和理解,重新抛出的异常舍弃所有对它的操作,保持原始的模样(如果想改变,可以抛出另外一个异常,而不是继续抛出捕获的异常)。

从上面的描述来看,异常处理的引入会增加时间和空间的负担(因为要将代码分段)。这种负担不表现在异常发生的时候(事实上,这部分负担往往不需要太在意,因为异常不总是会发生的),而是表现在程序正常执行时。书中的数据显示,在任何编译器上,异常的引入都会使程序体积增加(5%左右)。并且在不触发异常的情况下,执行时间增加(也是5%吧)。

但通常,这种代价与异常处理带来的价值相比是可以接受的。对于我们来说,也许应该考虑的,不是是否使用异常处理(个人观点是除非对时空有着苛刻的要求,都最好使用),更值得考虑的是如何更好的使用异常处理。大量的C++实践书籍中都会提到该问题,在任何语言中,这都是一个很有艺术性的设计问题。

 

【RTTI】

 RTTI就是运行时的类型识别。它是类型比较(比如异常处理中catch的类型和异常的类型比较)和安全向下转型(由基类向派生类转型)。RTTI的实现方法有很多,C++常用的一种手段是将类型信息安插在虚表的第一个slot内(在前面那副图里可以看到)。这个类型信息包括本身和基类的信息。

很显然,由于RTTI的引入,在一个继承体系内,无论是否存在虚方法,都需要额外付出一个虚指针和虚表的代价。同时,在向下转型时,由于需要判断类型,也没有原来那么高效。但和看待异常处理一样,我们要看到它带来的好处通常是大于坏处的。RTTI式类型比较(没有它几乎没办法实现异常处理)和安全转型变成了可能,这些付出也算是有所回报了吧。

【虚继承】

引入虚继承,是为了避免在多继承中出现多个父类派生自同一个祖先,以至于其数据重复,很难操作。如下面这个虚继承体系,Vectex和Point3d有共同的父类Point2d,而Vectex3d多继承自Vectex和Point3d。这时候,就需要利用虚继承,使得共同基类的数据被所有虚继承自其的子类共享。显然,这种共享关系,使得数据在存放上无法保证继承体系中的每个类的数据都是挨着的,如果还采取普通多继承中的数据存放方式,就使得在类型转换的时候无法正确获取数据。

class Point2d
{
public:
 // ...
protected:
 float _x, _y;
};

class Vertex :
 public virtual Point2d
{
public:
 // ...
protected:
 Vertex *next;
};

class Point3d :
 public virtual Point2d
{
public:
 // ...
protected:
 float _z;
};

class Vertex3d :
 public Vertex, public Point3d
{
public:
 // ...
protected:
 float mumble;
};

为了保证转型的正常进行,就需要在存放中安插一些辅助数据,来维系这种动态性。通常所有的辅助数据包括指针和偏移量。这是代表了两种风格的设计。采用指针,是一种加层的思想,在两个很难联系的东西之间添加一层,往往能起到很好的效果;而利用偏移量,是一种利用计算来节省空间、减少间接性的做法。在很多时候采用它可以大大减少算法的空间占用。对于我们来说,应该好好的掌握这两种设计手段。而对于这个问题而言,不同的编译器的实现各有千秋,其效果也是各具特色,希望仔细了解的人可参考原书中的叙述。

但无论哪一种方法,但难以避免的增加时间和空间的复杂度。比如函数:

void Point3d::operator += (const Point3d &rhs)
{
 _x += rhs._x;
 _y += rhs._y;
 _z += rhs._z;
}

经由编译器处理后(指针方式),其中实现内容可能变成了如下这个样子:

__vbcPoint2d->_x += rhs.__vbcPoint2d->_x;
__vbcPoint2d->_y += rhs.__vbcPoint2d->_y;
_z += rhs._z;

可以看到,虚继承的引入导致了存取的间接性。因此,对虚继承最好的应用就是,不在虚基类中放置任何数据(就像C#中接口所做的事情一样)。这一点,需要在我们设计程序中好好把握。

此外,在虚继承体系下,构造函数的处理与前面所叙述的也有所不同。为了避免继承与同一个祖先的基类重复调用其构造函数,需要添加一些信息,保证共同的祖先的构造函数只被调用一次。上面例子中的Point3d的构造函数有可能被扩展成这个样子Point3d* Point3d::Point3d(Point3d* this, bool __most_derived, float x, float y, float z)。注意__most_derived这个变量。它只有在最子类(就是显性调用的那个)的构造函数调用中才被设为true,在其基类中调用都会被设为false。比如,当程序中调用Vertex3d v(x, y, z)时,相当于调用Vertex3d(&v, true, x, y, z),并且该构造函数可能被展开成如下样子:

 Vertex3d* Vertex3d::Vertex3d(Vertex3d *this, bool __most_derived, float x, float y, float z)
{
 if(__most_derived != false)
  this->Point::Point(x, y);

 this->Point3d::Point3d(false, x, y, z);
 this->Vertex::Vertex(false, x, y);

 // ...
}

其中,Point::Point只被调用一次。由于调用Point3d::Point3d和Vertex::Vertex时,__most_derived值均为false,因此在其中不会在调用Point::Point。这样不但保证了所有基类的构造函数有且仅有被调用一次,还保证了调用的顺序。

 

【结束语】

终于写完了这本书的笔记。读的比较细致,所以写的也比较多。知其然有时候也要知其所以然,这样在编程时才能更加的胸有成足。总体上写的比较boring,争取下次写的时候开心一点:)。

posted on 2007-05-21 01:12  duguguiyu  阅读(1301)  评论(0编辑  收藏  举报

导航