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