C 箴言:绝不在构造或析构期调用虚函数

2008-02-23 05:40:47来源:互联网 阅读 ()

新老客户大回馈,云服务器低至5折

  您不应该在构造或析构期间调用虚函数,因为这样的调用不会如您想象那样工作,而且他们做的事情确保会让您很郁闷。假如您转为 Java 或 C# 程式员,也请您密切关注本文,因为在 C 急转弯的地方,那些语言也紧急转了一个弯。

  假设您有一套模拟股票处理的类层次结构,例如,购入流程,出售流程等。对这样的处理来说能够核查是很重要的,所以随时会创建一个 Transaction 对象,将这个创建记录在核查日志中是个适当的需要。下面是个看起来似乎合理的解决问题的方法:

class Transaction { // base class for all
 public: // transactions
  Transaction();

  virtual void logTransaction() const = 0; // make type-dependent
  // log entry
  ...
};

Transaction::Transaction() // implementation of
{
 // base class ctor
 ...
 logTransaction(); // as final action, log this
} // transaction

class BuyTransaction: public Transaction {
 // derived class
 public:
  virtual void logTransaction() const; // how to log trans-
  // actions of this type
  ...
};

class SellTransaction: public Transaction {
// derived class
public:
 virtual void logTransaction() const; // how to log trans-
 // actions of this type
...
};

  考虑执行这行代码时会发生什么:

BuyTransaction b;

  很明显 BuyTransaction 的构造函数会被调用,但是首先,Transaction 的构造函数必须先被调用,派生类对象中的基类部分先于派生类部分被构造。Transaction 的构造函数的最后一行调用虚函数 logTransaction,但是结果会让您大吃一惊,被调用的 logTransaction 版本是在 Transaction 中的那个,而不是 BuyTransaction 中的——即使被创建的对象类型是 BuyTransaction。基类构造期间,虚函数从来不会向下匹配(go down)到派生类。取而代之的是,那个对象的行为就似乎他的类型是基类。非正式地讲,基类构造期间,虚函数禁止。 这个表面上看起来匪夷所思的行为存在一个很好的理由。因为基类的构造函数在派生类构造函数之前执行,当基类构造函数运行时,派生类数据成员还没有被初始化。假如基类构造期间调用的虚函数向下匹配(go down)到派生类,派生类的函数理所当然会涉及到本地数据成员,但是那些数据成员还没有被初始化。这就会为未定义行为和悔之晚矣的调试噩梦开了一张通行证。调用涉及到一个对象还没有被初始化的部分自然是危险的,所以 C 告诉您此路不通。

  在实际上更有比这更多的更深层次的原理。在派生类对象的基类构造期间,对象的类型是那个基类的。不但虚函数会解析到基类,而且语言中用到运行时类型信息(runtime type information)的配件(例如,dynamic_cast和 typeid),也会将对象视为基类类型。在我们的例子中,当 Transaction 构造函数运行初始化 BuyTransaction 对象的基类部分时,对象的类型是 Transaction。C 的每一个配件将以如下眼光来看待他,并对他产生这样的感觉:对象的 BuyTransaction 特有的部分还没有被初始化,所以安全的对待他们的方法就是视若无睹。在派生类构造函数运行之前,一个对象不会成为一个派生类对象。

  同样的原因也适用于析构过程。一旦派生类析构函数运行,这个对象的派生类数据成员就被视为未定义的值,所以 C 就将他们视为不再存在。在进入基类析构函数时,对象就成为一个基类对象,C 的任何配件——虚函数,dynamic_casts 等——都如此看待他。

  在上面的示例代码中,Transaction 的构造函数直接调用了虚函数,对本 Item 的规则的违例是显而易见的。这一违例是如此显见,以致一些编译器会给出警告。(其他的则不会)甚至除了这样的警告之外,这一问题几乎肯定会在运行之前暴露出来,因为 logTransaction 函数在 Transaction 中是个纯虚函数。除非他被定义(看似不可能,但确实可能),否则程式将无法连接:连接程式无法找到 Transaction::logTransaction 的必需的实现。
在构造函数和析构函数中调用虚函数的问题并不总是如此容易被察觉。假如 Transaction 有多个构造函数,每一个都必须完成一些相同的工作,好的软件工程为避免代码重复,会将共用的初始化代码,包括对 logTransaction 的调用,放入一个私有的非虚的初始化函数,叫做 init:

class Transaction {
public:
 Transaction()
 { init(); } // call to non-virtual...

 virtual void logTransaction() const = 0;
 ...

private:
 void init()
 {
  ...
  logTransaction(); // ...that calls a virtual!
 }
};

  这个代码在概念上和早先那个版本相同,但是他更阴险,因为他很具代表性地会躲过编译器和连接程式的抱怨。在这种情况下,因为 logTransaction 在 Transaction 中是纯虚函数,大多数运行时系统在纯虚函数被调用时,程式会异常中止(典型的结果就是给出一条信息)。然而,假如 logTransaction 是个“常规的”虚函数(也就是说,非纯的虚函数),而且在 Transaction 中有其实现,那个版本被调用,程式会继续一路小跑,让您想象不出为什么派生类对象创建的时候会调用 logTransaction 的错误版本。避免这个问题的唯一办法就是确保在您的构造函数和析构函数中,决不在您创建或销毁的对象上调用虚函数,构造函数和析构函数所调用的函数也要听从同样的约束。

  但是,如何确保在任何时间 Transaction 层次结构中的对象被创建时,都能调用 logTransaction 的正确版本呢?显然,在 Transaction 的构造函数中在这个对象上调用虚函数的做法是错误的。

  有不同的方法来解决这个问题。其中之一是将 Transaction 中的 logTransaction 转变为一个非虚函数,这就需要派生类的构造函数将必要的日志信息传递给 Transaction 的构造函数。那个函数就能够安全地调用非虚的 logTransaction。如下:

标签:

版权申明:本站文章部分自网络,如有侵权,请联系:west999com@outlook.com
特别注意:本站所有转载文章言论不代表本站观点,本站所提供的摄影照片,插画,设计作品,如需使用,请与原作者联系,版权归原作者所有

上一篇: C 箴言:声明为非成员函数时机

下一篇: C 箴言:理解inline的介入和排除