让我们直切正题:在程序进行构造或析构期间,你绝不能调用虚函数,这是因为这样的调用并不会按你所期望的执行,即使能够顺利执行,你也不会觉得十分舒服。如果你曾经是一个Java或C#的程序员,并且在最近期望返回C++的怀抱,那么请你格外留意本条目,因为在这一问题上,C++与其他语言走的是完全不同的两条路线。
假设有一个股票交易模拟系统,你为它编写了一个类的层次化结构,其中包括实现购买、抛售等功能的类。这类交易应该是可以审计的,这一点很重要,所以说每创建一次交易时,都应该在日志中创建一条审计相关内容的记录。下面是一个看似合理的解决方案:
class Transaction { // 所有交易的基类
public:
Transaction();
virtual void logTransaction() const = 0; // 作类型相关的记录
...
};
Transaction::Transaction() // 基类构造函数的实现
{
...
logTransaction(); // 最后,记录这次交易
}
class BuyTransaction: public Transaction { // 派生类
public:
virtual void logTransaction() const; // 当前类型交易是如何记录的
...
};
class SellTransaction: public Transaction { // 派生类
public:
virtual void logTransaction() const; // 当前类型交易是如何记录的
...
};
请考虑一下在下边的代码运行时会发生什么:
很明显的是此时BuyTransaction的构造函数将被调用,但是,首先必须调用Transaction的构造函数。对于一个派生的对象,其基类那一部分会首先得到构造,然后才是派生类的部分。Transaction的构造函数中最后一行调用了虚函数logTransaction,意外的事情就从这里发生了:此处调用的是Translation版本的logTransaction函数,而不是BuyTransaction版本的——即使此处创建的对象是BuyTransaction类型的。在基类部分的构造过程中,虚函数永远也不会尝试去匹配派生类部分。取而代之的是,对象仍然保持基类的行为。更随意一点的说法是,在基类部分构造的过程中,虚函数并不会被构造。
这一行为看上去匪夷所思,但是这里有很充足的理由来解释它。由于基类的构造函数先于派生类运行,在基类构造函数运行的时候,派生类的数据成员还没有被初始化。如果在基类构造函数向下匹配派生类时调用了虚函数,那么基类的函数几乎一定会调用局部数据成员,但此时这些数据成员此时尚未得到初始化。你的程序将会出现无尽的未定义行为,你也会在整夜受到琐碎的调试工作的折磨。当一个对象中某些部分尚未初始化的时候,此时对其进行调用会存在内在的危险,所以C++不允许你这样做。
实际情况比上文介绍的更为基础。对于一个派生类的对象来说,在其进行基类部分构造工作的时候,这一对象的类型就是基类的。不仅仅虚函数会解析为基类的,而且C++中“使用运行时类型信息”的部分(比如dynamic_cast(参见条目27)和typeid)也会将其看作基类类型的对象。在我们的示例中,当调用Transaction的构造函数以初始化一个BuyTransaction对象的基类部分时,这一对象是Transaction类型的。C++的任何一部分都会这样处理,这种处理方式是有意义的:由于这个对象的BuyTransaction部分尚未得到初始化,所以假定它们不存在才是最安全的处理方法。对于一个派生类对象来说,只有派生类的构造函数开始执行,这个对象才会变成该派生类的对象。
对于析构过程可以应用同样的推理方式。一旦派生类的析构函数运行完毕,对象中派生类的那一部分数据成员将取得未定义的值,所以C++会认为它们不再存在。在进入基类的析构函数时,这个对象将成为一个基类对象,C++的所有部分——包括虚函数、dynamic_cast等等——都会这样对待该对象。
在上文的示例代码中,Transaction的构造函数对一个虚函数进行了一次直接调用,很显然这样做是违背本条中的指导方针的。这样的违规实在太容易发现了,一些编译器都会对其做出警告。(其他一些则不会。参见条目53对编译器警告信息的讨论)即使没有警告,问题也一定会在运行之前变得很明显,这是因为Transaction中的logTransaction函数是纯虚函数,除非它得到了定义(不像是真的,但存在这种可能,参见条目34),程序才有可能会得到连接,其他情况都会报错:连接器无法找到必要的Transaction::logTransaction的具体实现。
查找构造或析构过程中对虚函数的调用并不总是一帆风顺的。如果Transaction拥有多个构造函数,它们所进行的工作中有一部分是相同的,那么可以将这些公共的初始化代码(包括对logTransaction的调用)放入一个私有的非虚拟的初始化函数中,这样做可以避免代码重复,从软件工程角度来讲这似乎是一个很好的做法,我们将这一函数命名为init:
class Transaction {
public:
Transaction()
{ init(); } // 调用非虚函数...
virtual void logTransaction() const = 0;
...
private:
void init()
{
...
logTransaction(); // ...而它却调用一个虚函数!
}
};
这样的代码与前文中的版本使用的是同一理念,但是这样做所带来的危害更为隐蔽和严重,这是因为这样的代码会得到正常的编译和连接而不会报错。这种情况下,由于logTransaction是Transaction中的一个纯虚函数,大多数运行时系统将会在调用这个纯虚函数时中止程序(通常情况下会针对这一结果显示出一个消息)。然而如果logTransaction是一个“正常的”虚函数(也就是说,不是纯虚的),并且在Transaction中给出了一些实现,那么此时将调用这一版本的logTranscation,程序将会“愉快地一路小跑”下去,至于为什么在创建派生类对象时会调用错误的logTransaction版本,程序可就不管这一套了。避免这类问题的唯一途径就是:在正在创建或销毁的对象的构造函数和析构函数中,确保永远不要调用虚函数,对于构造函数和析构函数所调用的所有函数都应遵守这一约定。
那么,每当创建一个Transaction层次结构中的对象时,如何确保去调用正确的logTransaction版本呢?显然地,在Transaction的构造函数中调用一个虚函数是一个错误的做法。
为解决这一问题我们可以另辟蹊径。方案之一就是:将Transaction中的logTransaction变为一个非虚函数,然后要求派生类的构造函数把必要的日志记录传递给Transaction的构造函数。这个构造函数对于非虚logTransaction的调用就是安全的。就像这样:
class Transaction {
public:
explicit Transaction(const std::string& logInfo);
void logTransaction(const std::string& logInfo) const;
// 现在logTransaction是非虚函数
...
};
Transaction::Transaction(const std::string& logInfo)
{
...
logTransaction(logInfo); // 现在调用的是一个非虚函数
}
class BuyTransaction: public Transaction {
public:
BuyTransaction( parameters )
: Transaction(createLogString( parameters ))
{ ... } // 将记录传递给基类构造函数
...
private:
static std::string createLogString( parameters );
};
换句话说,你不能使用虚函数在基类构造过程中向下调用派生类的部分,作为一种补偿,你可以让派生类将一些必要的构造信息向上传递给基类的构造函数。
请注意上述示例里BuyTransaction类中(私有的)静态函数createLogString的使用。这里使用了一个辅助函数创建一个值来传递给基类的构造函数,通常情况下这样做更为方便(而且更具备可读性),这样做使为基类提供所需信息的成员初始化表变得更加直观。这是因为这样做解决了“为基类提供所需信息的成员初始化表”不直观的问题。意外调用初生的BuyTransaction对象中那些尚未初始化的数据成员是十分危险的,由于createLogString是静态的,此处便不存在这一危险。这一点很重要,因为这些数据成员正处于未定义的状态,这一事实便解释了为什么“在基类部分构造或析构期间调用虚函数,不会在第一时间向下匹配派生类”。
时刻牢记
l 不要在构造和析构的过程中调用虚函数,因为这样的调用永远不会转向当前执行的析构函数或构造函数更深层的派生类中执行。