阐述重点:在程序进行构造或析构期间,绝不能调用虚函数,这是因为这样调用并不会按你所期望的执行,即使能你也不会觉得舒服。如果你同时是一个 Java 或 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的构造函数。对于一个派生类对象,其基类那一部分会首先得到构造,然后才是派生类的部分。 Transaction 的构造函数中最后一行调用了虚函数 logTransaction ,意外的事情就从这里发生了:此处调用的是 Translation 版本的 logTransaction 函数,而不是 BuyTransaction 版本——即使此处创建是 BuyTransaction 类型的对象。在基类构造时,虚函数永远也不会尝试去匹配派生类。对象仍然保持基类的行为。更随意一点的说法是,在基类得到构造的过程中,虚函数并不会转换为派生类的版本。(基类构造期间,virtual函数不是virtual函数)
这一行为看起来奇怪,但有充足的理由解释。由于基类构造函数先于派生类运行,在基类构造函数运行的时候,派生类的数据成员尚未被初始化。如果此时调用的virtual函数下降至derived class阶层,要知道derived class的函数几乎必然取用local成员变量,而此时这些成员变量尚未被初始化。这样程序将会出现不可预知的行为。“要求使用对象内部尚未初始化的成分”是危险的,所以 C++ 不允许此类行为。
其实还有更为根本的原因:对于一个派生类的对象来说,在其基类的构造函数运行的时候,这一对象的类型就是基类的。不仅仅虚函数会解析为基类的,而且 C++ 中使用运行时类型信息(runtime typeinfomation)部分(比如 dynamic_cast (参见第 27 条)和 typeid )也会将这个对象的类型看作基类。本例中,初始化 BuyTransaction 对象中的基类部分而调用 Transaction 的构造函数,此时这一对象类型是 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 中给出了一些实现,这一版本就会被调用,程序将会“愉快地一路小跑”下去,而在创建一个派生类对象时,则会调用错误的 logTransaction 版本,为什么会这样呢?这个问题只能由你自己来解决了。避免这类问题的唯一方法就是:确定构造函数和析构函数(在对象被创建和被销毁期间)都没有调用virtual函数,而它们调用的所有函数都应遵守这一约定。
那么,如何确保Transaction 继承体系上的对象被创建时就会调用正确的 logTransaction 版本呢?显然,在 Transaction 的构造函数中调用一个虚函数是一个错误的做法。
其他方法可以解决这个问题。方案之一:将 Transaction 中的 logTransaction 变为一个非虚函数,然后要求派生类的构造函数把必要信息传递给 Transaction 的构造函数。这个构造函数对于非虚 logTransaction 的调用就是安全的。就像这样:class Transaction { public: explicit Transaction(conststd::string& logInfo); void logTransaction(conststd::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::stringcreateLogString( parameters ); };
换句话说,由于在构造过程中,你不能在基类部分调用派生部分时使用虚函数,此时,作为一种补偿,可以让派生类为基类的构造函数传递一些必要的构造信息。
请注意上述示例里 BuyTransaction 类中静态函数 createLogString 的使用。这里使用了一个辅助函数来创建一个值,然后将这个值传递给基类构造函数,通常情况下这样做更为方便(而且更具备可读性)。由于这个函数被声明static,将不会指向“对象中未初始化的成员变量”。这很重要,因为这些数据成员处于为定义,这一事实便解释了为什么在基类部分构造或析构期间调用虚函数将不会在第一时间匹配到派生类。
需要记住的
不要在构造和析构的过程中调用虚函数,因为这样的调用永远不会转向当前执行的析构函数或构造函数更深层的派生类中执行。(比起当前执行构造函数和析构函数的那层,这类调用从不下降至derived class)
第9条:绝不在构造或析构的过程中调用虚函数
最新推荐文章于 2023-06-19 14:06:34 发布