虚拟函数是C++语言引入的一个很重要的特性,它提供了"动态绑定"机制,正是这一机制使得继承的语义变得相对明晰。
(1)基类抽象了通用的数据及操作,就数据而言,如果该数据成员在各派生类中都需要用到,那么就需要将其声明在基类中;就操作而言,如果该操作对各派生类都有意义,无论其语义是否会被修改或扩展,那么就需要将其声明在基类中。
(2)有些操作,如果对于各个派生类而言,语义保持完全一致,而无需修改或扩展,那么这些操作声明为基类的非虚拟成员函数。各派生类在声明为基类的派生类时,默认继承了这些非虚拟成员函数的声明/实现,如同默认继承基类的数据成员一样,而不必另外做任何声明,这就是继承带来的代码重用的优点。
(3)另外还有一些操作,虽然对于各派生类而言都有意义,但是其语义并不相同。这时,这些操作应该声明为基类的虚拟成员函数。各派生类虽然也默认继承了这些虚拟成员函数的声明/实现,但是语义上它们应该对这些虚拟成员函数的实现进行修改或者扩展。另外在实现这些修改或扩展过程中,需要用到额外的该派生类独有的数据时,将这些数据声明为此派生类自己的数据成员。
再考虑更大背景下的继承体系,当更高层次的程序框架(继承体系的使用者)使用此继承体系时,它处理的是一个抽象层次的对象集合(即基类)。虽然这个对象集合的成员实质上可能是各种派生类对象,但在处理这个对象集合中的对象时,它用的是抽象层次的操作。并不区分在这些操作中,哪些操作对各派生类来说是保持不变的,而哪些操作对各派生类来说有所不同。这是因为,当运行时实际执行到各操作时,运行时系统能够识别哪些操作需要用到"动态绑定",从而找到对应此派生类的修改或扩展的该操作版本。
也就是说,对继承体系的使用者而言,此继承体系内部的多样性是"透明的"。它不必关心其继承细节,处理的就是一组对它而言整体行为一致的"对象"。即只需关心它自己问题域的业务逻辑,只要保证正确,其任务就算完成了。即使继承体系内部增加了某种派生类,或者删除了某种派生类,或者某某派生类的某个虚拟函数的实现发生了改变,它的代码不必任何修改。这也意味着,程序的模块化程度得到了极大的提高。而模块化的提高也就意味着可扩展性、可维护性,以及代码的可读性的提高,这也是"面向对象"编程的一个很大的优点。
下面通过一个简单的实例来展示这一优点。
假设有一个绘图程序允许用户在一个画布上绘制各种图形,如三角形、矩形和圆等,很自然地抽象图形的继承体系,如图2-2所示。
这个图形继承体系的设计大致如下:
{
public :
Shape();
virtual ~ Shape();
virtual void Draw();
virtual void Rotate();
private :
...
};
class Triangle : class Shape
{
public :
Triangle();
~ Triangle();
void Draw();
void Rotate( int angle);
...
};
class Circle : class Shape
{
public :
Circle();
~ Circle();
void Draw();
void Rotate( int angle);
...
};
class Rectangle : class Shape
{
public :
Rectangle();
~ Rectangle();
void Draw();
void Rotate( int angle);
...
};
{
...
}
void Circle::Draw()
{
...
}
void Rectangle::Draw()
{
...
}
void Triangle::Rotate( int angle)
{
...
}
void Circle::Rotate( int angle)
{
...
}
void Rectangle::Rotate( int angle)
{
...
}
{
public :
Canvas();
~ Canvas();
void Paint();
void RotateSelected( int angle);
...
private :
ShapeList shapes;
};
...
void Canvas::Paint()
{
while (shapes.GetNext())
{
Shape * sh = shapes.GetNext();
sh -> Draw(); ①
shapes.Next();
}
...
}
void RotateSelected( int angle)
{
Shape * select_shape = GetCurrentSelected();
if (select_shape)
select_shape -> Rotate(angle); ②
...
}
Canvas类中维护一个包含所有图形的shapes,Canvas类在处理自己的业务逻辑时并不关心shapes实际上都是哪些具体的图形;相反,如①处和②处所示,它只将这些图形作为一个抽象,即Shape。在处理每个Shape时,调用每个Shape的某个操作即可。
这样做的一个好处是当图形继承体系发生变化时,作为图形继承体系的使用者Canvas而言,它的改变几乎没有,或者很小。
比如说,在程序的演变过程中发现需要支持多边型(Polygon)和贝塞尔曲线(Bezier)类型,只需要在图形继承体系中增加这两个新类型即可:
{
public :
Polygon();
~ Polygon();
void Draw();
void Rotate( int angle);
...
};
void Polygon::Draw()
{
...
}
void Polygon::Rotate( int angle)
{
...
}
class Bezier : class Shape
{
public :
Bezier();
~ Bezier();
void Draw();
void Rotate( int angle);
...
};
void Bezier::Draw()
{
...
}
void Bezier::Rotate( int angle)
{
...
}
而不必修改Canvas的任何代码,程序即可像以前那样正常运行。同理,如果以后发现不再支持某种类型,也只需要将其从图形继承体系中删除,而不必修改Canvas的任何代码。可以看到,从对象继承体系的使用者(Canvas)的角度来看,它只看到Shape对象,而不必关心到底是哪一种特定的 Shape,这是面向对象设计的一个重要特点和优点。
虚拟函数的"动态绑定"特性虽然很好,但也有其内在的空间以及时间开销,每个支持虚拟函数的类(基类或派生类)都会有一个包含其所有支持的虚拟函数指针的"虚拟函数表"(virtual table)。另外每个该类生成的对象都会隐含一个"虚拟函数指针"(virtual pointer),此指针指向其所属类的"虚拟函数表"。当通过基类的指针或者引用调用某个虚拟函数时,系统需要首先定位这个指针或引用真正对应的"对象"所隐含的虚拟函数指针。"虚拟函数指针",然后根据这个虚拟函数的名称,对这个虚拟函数指针所指向的虚拟函数表进行一个偏移定位,再调用这个偏移定位处的函数指针对应的虚拟函数,这就是"动态绑定"的解析过程(当然C++规范只需要编译器能够保证动态绑定的语义即可,但是目前绝大多数的C++编译器都是用这种方式实现虚拟函数的),通过分析,不难发现虚拟函数的开销:
- 空间:每个支持虚拟函数的类,都有一个虚拟函数表,这个虚拟函数表的大小跟该类拥有的虚拟函数的多少成正比,此虚拟函数表对一个类来说,整个程序只有一个,而无论该类生成的对象在程序运行时会生成多少个。
- 空间:通过支持虚拟函数的类生成的每个对象都有一个指向该类对应的虚拟函数表的虚拟函数指针,无论该类的虚拟函数有多少个,都只有一个函数指针,但是因为与对象绑定,因此程序运行时因为虚拟函数指针引起空间开销跟生成的对象个数成正比。
- 时间:通过支持虚拟函数的类生成的每个对象,当其生成时,在构造函数中会调用编译器在构造函数内部插入的初始化代码,来初始化其虚拟函数指针,使其指向正确的虚拟函数表。
- 时间:当通过指针或者引用调用虚拟函数时,跟普通函数调用相比,会多一个根据虚拟函数指针找到虚拟函数表的操作。
内联函数:因为内联函数常常可以提高代码执行的速度,因此很多普通函数会根据情况进行内联化,但是虚拟函数无法利用内联化的优势,这是因为内联函数是在"编译期"编译器将调用内联函数的地方用内联函数体的代码代替(内联展开),但是虚拟函数本质上是"运行期"行为,本质上在"编译期"编译器无法知道某处的虚拟函数调用在真正执行的时候会调用到那个具体的实现(即在"编译期"无法确定其绑定),因此在"编译期"编译器不会对通过指针或者引用调用的虚拟函数进行内联化。也就是说,如果想利用虚拟函数的"动态绑定"带来的设计优势,那么必须放弃"内联函数"带来的速度优势。
根据上面的分析,似乎在采用虚拟函数时带来和很多的负面影响,但是这些负面影响是否一定是虚拟函数所必须带来的?或者说,如果不采用虚拟函数,是否一定能避免这些缺陷?
还是分析以上图形继承体系的例子,假设不采用虚拟函数,但同时还要实现与上面一样的功能(维持程序的设计语义不变),那么对于基类Shape必须增加一个类型标识成员变量用来在运行时识别到底是哪一个具体的派生类对象:
{
public :
Shape();
virtual ~ Shape();
int GetType() { return type; } ①
void Draw(); ③
void Rotate(); ④
private :
int type; ②
...
};
如①处和②处所示,增加type用来标识派生类对象的具体类型。另外注意这时③处和④处此时已经不再使用virtual声明。
其各派生类在构造时,必须设置具体类型,以Circle派生类为例:
{
public :
Circle() : type(CIRCLE) {...} ①
~ Circle();
void Draw();
void Rotate( int angle);
...
};
{
while (shapes.GetNext())
{
Shape * sh = shapes.GetNext();
// sh->Draw();
switch (sh -> GetType())
{
case (TRIANGLE)
((Triangle * )sh) -> Draw();
case (CIRCLE)
((Circle * )sh) -> Draw();
case (RECTANGLE)
((Rectangle * )sh) -> Draw();
...
}
shapes.Next();
}
...
}
void RotateSelected( int angle)
{
Shape * select_shape = GetCurrentSelected();
if (select_shape)
{
// select_shape->Rotate(angle);
switch (select_shape -> GetType())
{
case (TRIANGLE)
((Triangle * )select_shape) -> Rotate(angle);
case (CIRCLE)
((Circle * )select_shape) -> Rotate(angle);
case (RECTANGLE)
((Rectangle * )select_shape) -> Rotate(angle);
...
}
}
...
}
因为要实现相同的程序功能(语义),已经看到,每个对象虽然没有编译器生成的虚拟函数指针(析构函数往往被设计为virtual,如果如此,仍然免不了会隐含增加一个虚拟函数指针,这里假设不是这样),但是还是需要另外增加一个type变量用来标识派生类的类型。构造对象时,虽然不必初始化虚拟函数指针,但是仍然需要初始化type。另外,图形继承体系的使用者调用函数时虽然不再需要一次间接的根据虚拟函数表找寻虚拟函数指针的操作,但是再调用之前,仍然需要一个switch语句对其类型进行识别。
综上所述,这里列举的5条虚拟函数带来的缺陷只剩下两条,即虚拟函数表的空间开销及无法利用"内联函数"的速度优势。再考虑虚拟函数表,每一个含有虚拟函数的类在整个程序中只会有一个虚拟函数表。可以想像到虚拟函数表引起的空间开销实际上是非常小的,几乎可以忽略不计。
这样可以得出结论,即虚拟函数引入的性能缺陷只是无法利用内联函数。
可以进一步设想,非虚拟函数的常规设计假如需要增加一种新的图形类型,或者删除一种不再支持的图形类型,都必须修改该图形系统所有使用者的所有与类型相关的函数调用的代码。这里使用者只有Canvas一个,与类型相关的函数调用代码也只有Paint和RotateSelected两处。但是在一个复杂的程序中,其使用者很多。并且类型相关的函数调用很多时,每次对图形系统的修改都会波及到这些使用者。可以看出不使用虚拟函数的常规设计增加了代码的耦合度,模块化不强,因此带来的可扩展性、可维护性,以及代码的可读性方面都极大降低。面向对象编程的一个重要目的就是增加程序的可扩展性和可维护性,即当程序的业务逻辑发生变化时,对原有程序的修改非常方便。而不至于对原有代码大动干戈,从而降低因为业务逻辑的改变而增加出错的可能性。根据这点分析,虚拟函数可以大大提升程序的可扩展性及可维护性。
因此在性能和其他方面特性的选择方面,需要开发人员根据实际情况进行权衡和取舍。当然在权衡之前,需要通过性能检测确认性能的瓶颈是由于虚拟函数没有利用到内联函数的优势这一缺陷引起;否则可以不必考虑虚拟函数的影响。