有人认为虚函数比非虚函数更根本,所有成员函数都应该缺省为虚。更有甚者,有些人建议说根本没有理由不使用虚函数,所有成员函数都必须自动地为虚函数。争论背后的理论似乎非常吸引人,值得仔细研究以便理解问题之所在。
1. 适用的情况
首先,我们注意到,如果只是关注程序的行为,同时没有继承关系,那么函数是否为虚函数根本无关紧要。因此,即使存在争论,没有使用继承的程序员仍可以不假思索地把他们所有的函数设为虚函数。只有当涉及到继承时,才有必要考虑一些问题。
可是在使用到继承的程序中,争论仍然继续存在,说是把所有函数都设为虚函数可以获得更大的灵活性。作为一个简化了的例子,考虑一个表示整数数组的 IntArray 类:
class IntArray{
public:
// ...
unsigned size() const;
int & operator[] (unsigned n);
};
我们可以写一个函数来将数组的所有元素设置为零:
void zero(IntArray & x)
{
for (int i = 0; i < x.size(); i++)
x[n] = 0;
}
在类似这样的情况下,上述观点认为 IntArray::operator[] 和 IntArray::size() 应该为虚函数。例如,有人希望从 IntArray 派生出一个类 IntFileArray,该类在文件中而不是直接在内存中保存这些函数。如果成员函数 IntArray::operator[] 和 IntArray::size() 是虚函数,那么 zero 函数在 IntFileArray 对象中也能正常运行;如果不是虚函数,则 zero 函数将不能正常运行。
2. 不适用的情况
尽管这个观点很吸引人,但是仍然有一些问题:
* 虚函数的代价并不是十分高昂,但也不是免费午餐;在使用它们之前,要认真考虑其开销,这一点十分重要。
* 有些情况下非虚函数能够正确运行,而虚函数却不行。
* 不是所有类都是为了继承而设计的。
2.1 效率
如果一个程序调用某个显示提供的对象的虚拟成员函数,那么优秀的编译器不应该带来任何额外的开销;例如
T x;
x.f();
然而一旦通过指针或者引用进行调用,那就不是没意义的了:
void call_f(T* tp)
{
tp->f();
}
这里,tp 指向 T 类的一个对象,也可能是某个 T 类派生类的对象,所以,如果 T::f 是虚函数,则必须运用虚函数机制进行调用。虚函数的查找开销值得我们关注吗?
这要视情况而定。想要知道某个程序在这方面的实际开销,必须在不同机器上测量开销,不过通过对内存引用(memory reference)进行计数来获得一个大概值还是可能的。例如,让我们回顾一下 IntArray::operator[] 成员函数。实现一个数组类的典型方法是令它的构造函数分配适当数量的内存,在这种情况下 operator[] 就类似于下面的代码:
int& IntArray::operator[] (unsigned n)
{
if(n >= arraysize)
throw "subscript out of range";
return data[n];
}
除了调用函数的开销外,这个函数还需要 3 个内存引用,以便分别获得 n、arraysize 和 data 的值。怎样将这个开销与调用虚函数的开销进行比较?此外,怎样将这个开销与调用非虚成员函数的开销进行比较?
因为我们假设开销足够大,所以将这个函数内联。因此,一个好的实现在直接通过对象使用 operator[] 时根本不会引入新的开销。通过指针或者引用调用 operator[] 的开销可能与 3 个内存引用有关:一个是对指针本身的,另一个是为这个成员函数初始化 this 指针的,还有一个是用于调用返回序列的。因此,当我们通过指针或者引用来调用这个小成员函数时,所花的时间应该差不多是直接为某个对象调用这个函数所花时间的两倍。调用一个虚函数通常由 3 个内存引用取出:一个从对象取出描述对象类型的表的地址值,一个取出虚函数的地址,第三个则在可能的较大外围对象中,取出本对象的偏移量。在这样的实现中,把一个函数变成虚函数需要 3 倍于执行时间,而非是两倍的执行时间。
这个开销值得关注吗?这要取决于具体应用。显然,成员函数越大,变为虚函数就越不会是问题。实际上,同样的观点也适用于边界检查:去掉它就会减掉函数本身的 3 个内存引用中的一个,所以我们有理由说边界检查使函数慢了 50%。而且一个好的优化的编译器可能会使我们所有的估算落空。如果你关注到底要花多长时间,就应当进行测量。不过,这种粗略的分析还是说明了虚函数的开销可能相当大。
有时候只需要稍加思索,我们就可以在不明显增加任何开销的情况下获得类似虚函数的灵活性。例如,考虑一个表示输入缓冲区的类。跟 C 有一个库函数 getc 类似,我们希望自己的类有一个叫做 get 的成员函数,返回一个 int,返回值将包含一个字符或者 EOF。另外,我们还希望人们能够从我们的类中派生出新的类来实现不同的缓冲策略。
一种明显的方法如下编写代码
class InputBuffer{
public:
// ...
virtual int get();
// ...
};
这样凡是这个类的派生类只要需要都可以改写 get,但是这个方法的潜在开销很大。考虑下面这个计算缓冲区的行数的函数:
int countlines(InputBuffer& b)
{
int n = 0;
int c;
while ((c = b.get()) != EOF){
if (c == '\n')
n++;
}
return n;
}
从这个函数对于所有 InputBuffer 的派生类都有效这一点来说,它是很灵活的。但是,每个对 get 的调用都是虚函数调用,所以要消耗大约 6 个内存引用(3 个是函数固有的,多出的 3 个是虚函数开销)。因此,函数调用开销很可能是主导循环执行时间的主要因素。
如果我们认识到,使用缓冲的应用程序很可能是要一次性地访问多个字符,那么就可以把 InputBuffer 类的设计做得好很多。比如,假设我们编写如下代码:
class InputBuffer{
public:
// ...
int get(){
if(next >= limit)
return refill();
return *next++;
}
protected:
virtual int refill();
private:
char* next;
char* limit;
};
我们还假设在缓冲区中有一定数量的字符处于等待状态。数据成员 next 指向第一个这样的字符;数据成员 limit 指向最后一个字符之后的首个内存位置。因此对
next >= limit 的测试判断了是否已经没有可用的字符了。如果没有可用字符了,我们就调用 refill 函数,以获得更多字符。如果调用成功,这个函数将重新适当地设置 next 和 limit,并返回第一个这样的字符;如果失败,这返回 EOF。
我们假设在大多数普通情况下都有字符存在;此时我们简单地返回 *next++。这样将获得下一个可用的字符并转到下一步操作。
关键在于 get 现在是内联的,而不是虚函数的。如果存在一个可用字符,执行时就需要大约 4 个内存引用:两个用于比较,一个取出字符,剩下的一个保存 next 的新值。如果我们必须调用 refill,消耗当然也会更大,但是如果 refill 从它的输入那儿获得了一个足够大的内存块,那么就没有什么需要担心的了。
因此,在这个例子中,我们将 get 通常情况下的开销从 6 个内存引用,加上虚拟 get 函数的代码存储,减小到总共只比 4 个内存引用稍微多一点的开销。如果我们假设 get 的函数版本和非虚函数版本所做的工作一样多(很能想象如何能做得更少),那么在决策上的改变就使 get 的开销从 10 个内存引用减少到 4 个内存引用,速度增加两倍多。
2.2 你想要什么样的行为
派生类总是严格地扩展其基类的行为。也就是说,通常派生类对象可以在不改变程序行为的情况下替代基类对象。但是,也有一些不属于此类的情况;在这些情况下,虚函数可能导致非预期的行为。
我们可以在 IntArray 类的基础上创建一个例子来说明这种情况。首先,我们稍微充实一下它的声明:
class IntArray{
public:
IntArray(unsigned);
int& operator[] (unsigned);
unsigned size() const;
// ...
};
假设我们通过给定 IntArray 的大小来构造其对象,并且支持下标操作。
假设现在我们从这个类派生类一个 IntBlock 类,该类与 IntArray 类似,但是它的初始元素的下标不必为零:
class IntBlock: public IntArray{
public:
IntBlock(int l, int h): low(1), high(h);
IntArray(l > h ? 0: h - l + 1) {}
int & operator[] (int n){
return IntArray::operator[](n - low);
}
private:
int low, high;
};
这个类定义相当明确:要构造一个下边界为 l、上边界为 h 的 IntBlock,我们就要构造一个有 h - l + 1 个元素的数组,如果元素个数为负数就令其为零。下标操作也很简单:我们使用适当的索引值调用基类的下标操作符。
现在考虑一个将 IntArray 中的所有元素相加的函数:
int sum(IntArray & x)
{
int result = 0;
for (int i = 0; i < x.size(); i++)
result += x[i];
return result;
}
如果我们传给这个函数的类型是 IntBlock 而不是 IntArray,情况又会怎样?
答案是只有 operator[] 是非虚函数,才可以正确地将 IntBlock 的元素相加!关键在于 sum 把它的参数当作一个 IntArray,这样它就可以真正从参数中得到 IntArray 的行为了。它还特别期望第一个元素的下标为 0,而且还期望 size 函数要返回元素的个数。由于 IntBlock 不是 IntArray 的严格扩展,所以只有 operator[] 不是虚函数,这个行为才会出现。
2.3 不是所有的类都是通用的
不使用虚函数的第三个原因是有些函数只是为特定的有限制的用途而设计的。
我们常常认为,类的接口由公有成员组成,类的实现由其他东西组成。而接口是一种与用户交流的方式,类可以有两种用户:使用该类对象的人和从这个类派生新类的人。
每个类都有第一种用户,即使这个唯一的用户是类设计者本人,但是有的类则绝对不允许有第二种用户。换句话说,有的时候我们在设计一个类时,会故意不考虑其他人如何通过继承改变它的行为。
写到这里,我可以想象人们指着我的鼻子,指责我鼓励刻意的不良设计。尽管如此,我还是忍不住想起曾经听说过的一个项目,这个项目要求它的开发者对他们写的每个子例程配备说明文档,并且要使这些子例程对于项目的其他程序来说都是可以复用的。其基本思想是,如果子例程对某个开发者是有用的,那么可能对其他人也有用。为什么不让整个项目从中受益呢?
显然,随后发生的事情不难预测:除非绝对必要,所有的程序员都极力避免编写子例程。开发者用文本编辑器来复制代码块,然后随心所欲地进行局部修改。结果产生一个难以维护、理解和修改的系统。
类似的,我们有时会为某些有限用途设计类。例如,我记得曾经写过一个很小的类作为第一章介绍的 ASD 系统的一部分,这个类计算传递给它的数据的校验和。它有一个构造函数,一个成员函数给它提供数据,另一个成员函数提取校验和--差不多就是这些。如果我花时间考虑其他人将如何扩展这个类的话,就会占用本来应该花在其他设计上的时间。我不知道提供这样一个类会使谁的生活更轻松,没有人向我询问关于那个类的情况。
3. 析构函数很特殊
如果打算让你的类支持继承,那么即使你不使用其他虚函数,可能还是需要一个虚析构函数。
记住虚函数和非虚函数之间的区别只有在下面这种特定的环境下才会体现出来:当使用一个基类指针或引用来指向或者引用一个派生来对象时。下面的情况便是其中一种:
class Base{
public:
void f();
virtual void g();
};
class Derived: public Base{
public:
void f();
virtual void g();
};
现在我们可以创建 Base 类的对象和 Derived 类的对象,还可以获得指向它们的指针:
Base b;
Derived d;
Base* bp = &b;
Base* bq = &d;
Derived* dp = &d;
这里,bp 指向一个 Base 对象,bq 和 dp 指向 Derived 对象。如果我们用这些指针调用成员函数 f 和 g 会出现什么情况呢?
bp->f(); /* Base::f */ bp->g(); /* Base::g */
bq->f(); /* Base::f */ bq->g(); /* Derived::g */
dp->g(); /* Derived::g */ dp->f(); /* Derived::f */
你会发现只有指针的静态类型与它所指向的实际对象的类型不同时,非虚函数 f 和虚函数 g 运行起来才会有所差别。当下面两件事情同时发生时就需要虚析构函数了:
* 有需要析构函数的事情发生。
* 它发生在这样一种上下文中:指向一个基类的指针或者引用都有一个静态类型,并且实际上都指向一个派生类的对象。
析构函数只在销毁对象时才需要。通过指针销毁对象的唯一方法就是使用一个 delete 表达式。因此,只有当使用指向基类的指针来删除派生类的对象时,虚析构函数才真正有意义。因此,例如:
Base* bp;
Derived* bp;
bp = new Derived;
dp = new Derived;
delete bp; // Base 必须有一个虚析构函数
delete dp; // 这里虚析构函数可要可不要
我们在这里用 bp,一个基类指针,来删除一个派生类对象。因此,为了使这个例子能正常运行,Base 必须有一个虚析构函数。
有些实现可能会使这个例子正确--但是别做指望。注意,即使你的类根本没有虚函数,可能也要用到虚析构函数。
如果需要一个虚析构函数,定义一个空的虚析构函数就行了:
class Base{
public:
// ...
virtual ~Base() {}
};
另外,如果一个类的基类有一个虚析构函数,那么这个类本身也自动获得一个虚析构函数,所以完整的类继承层次结构中有一个虚析构函数就足够了。
4. 小结
虚函数是 C++ 的基本组成部分,也是面向对象编程说必需的。然而,即使是一个有用的东西,我们也应该思考使用的合适时机。
关于虚函数为什么不总是适用,我们已经知道了 3 个原因:其一是虚函数有时会带来很大的消耗,其二是虚函数不总是提供所需的行为,其三是有时我们写一个类时,可能不想考虑派生问题。
另一方面,我们还知道了一种必须使用虚函数的情况。当你想删除一个表面上指向基类对象、实际却是指向派生类对象的指针,就需要虚析构函数。
更为常见的是,写程序时我们必须考虑自己正在做什么。仅仅根据规则和习惯思维行事是不够的。