C++箴言:避免构造或析构函数中调用虚函数

如果你已经从另外一种语言如C#或者Java转向了C++,你会觉得,避免在类的构造函数或者析构函数中调用虚函数这一原则有点违背直觉。但是在C++中,违反这个原则会给你带来难以预料的后果和无尽的烦恼。 # 正文 # 我想以重复本文的主题开篇:不要在类的构造或者析构函数中调用虚函数,因为这种调用不会如你所愿,即使成功一点,最后还会使你沮丧不已。如果你以前是一个Java或者C#程序员,请密切注意本节的内容-这正是C++与其它语言的大区别之一。

假设你有一个为股票交易建模的类层次结构,例如买单,卖单,等等。为该类交易建立审计系统是非常重要的,这样的话,每当创建一个交易对象,在审计登录项上就生成一个适当的入口项。这看上去不失为一种解决该问题的合理方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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; //怎样实现这种类型交易的登录?
 ...
};

现在,请分析执行下列代码调用时所发生的事情:

1
BuyTransaction b;

明显,一个BuyTransaction类构造器被调用。但是,首先调用的是Transaction类的构造器-派生类对象的基类部分是在派生类部分 之前被构造的。Transaction构造器的最后一行调用了虚函数logTransaction,但是奇怪的事情正是在此发生的。被调用函数 logTransaction的版本是Transaction中的那个,而不是BuyTransaction中的那个-即使现在产生的对象的类型是 BuyTransaction,情况也是如此。在基类的构造过程中,虚函数调用从不会被传递到派生类中。代之的是,派生类对象表现出来的行为好象其本身就 是基类型。不规范地说,在基类的构造过程中,虚函数并没有被“构造”。

对上面这种看上去有点违背直觉的行为可以用一个理由来解释-因为基类构造器是在派生类之前执行的,所以在基类构造器运行的时候派生类的数据成员还没有被 初始化。如果在基类的构造过程中对虚函数的调用传递到了派生类,派生类对象当然可以参照引用局部的数据成员,但是这些数据成员其时尚未被初始化。这将会导 致无休止的未定义行为和彻夜的代码调试。沿类层次往下调用尚未初始化的对象的某些部分本来就是危险的,所以C++干脆不让你这样做。

事实上还有比这更具基本的要求。在派生类对象的基类对象构造过程中,该类的类型是基类类型。不仅虚函数依赖于基类,而且使用运行时刻信息的语言的相应部分(例如,dynamic_cast(参见Item 27)和typeid)也把该对象当基类类型对待。在我们的示例中,当Transaction的构造器正运行以初始化BuyTransaction对象的 基类部分时,该对象是Transaction类型。在C++编程中处处都这样处理,这样做很有意义:在基类对象的初始化中,派生类对象 BuyTransaction相关部分并未被初始化,所以其时把这些部分当作根本不存在是最安全的。 在一个派生类对象的构造器开始执行之前,它不会成为一个派生类对象的。

在对象的析构期间,存在与上面同样的逻辑。一旦一个派生类的析构器运行起来,该对象的派生类数据成员就被假设为是未定义的值,这样以来,C++就把它们 当做是不存在一样。一旦进入到基类的析构器中,该对象即变为一个基类对象,C++中各个部分(虚函数,dynamic_cast运算符等等)都这样处理。

在上面的示例代码中,Transaction构造器直接调用了一个虚函数-这明显地破坏了本文所强调的原则。这种破坏性非常容易觉察,一些编译器对此发 出警告(注意:另外一些编译器并不给出警告,请参考Item 53有关警告的讨论)。即使没有给出警告,该问题在代码运行时刻也是相当明显的,因为函数logTransaction是类Transaction中的纯 虚函数。除非该函数被定义了(可能性不太大,但确实存在这种情况-参见Item 34),否则程序不会进行链接:链接器没法找到Transaction::logTransaction的必需的实现代码。

在类的构造或者析构函数中进行虚函数调用并非总是那么容易被发现。如果Transaction类有多个构造器且其中每个必须执行一些相同的任务,也许只 有优秀的软件工程师才能够避免代码的重复,这可以通过把相同的初始化代码(包括调用logTransaction)放到一个私有的且非虚的初始化函数中实 现,譬如下面的init:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Transaction {
 public:
  Transaction()
  { init(); } //调用非虚函数...
  virtual void logTransaction() const = 0;
  ...
 private:
  void init()
  {
   ...
   logTransaction(); //注意这里调用了虚函数
  }
};

这段代码从概念上看与前面的版本一样,但是却更具有潜在的危险性,因为典型情况下,该代码会被成功地编译与链接。在这种情况下,因为 logTransactionTransaction类中的纯虚函数,绝大多数的运行时刻系统会在该纯虚函数被调用时(典型地是通过发送一个带有调用该 函数意义的消息实现)流产掉程序。然而,如果logTransaction是一个“正常的”虚函数(也就是,不是纯虚的),并在Transaction中 有它的实现部分,该代码段将被调用而且程序会顺利地运行一段时间,这让你考虑为什么在一个派生类对象被创建时调用了logTransaction的错误版 本。唯一避免该问题的办法是确保没有任何一个构造器或者析构器在正被产生或毁坏的对象上调用了虚函数,而且所有其调用的函数都要遵循同样的约束。

但是,每当有一个对象在Transaction类层次结构中产生时,如何保证调用的是logTransaction的正确版本呢?很明显,从Transaction的构造器中调用对象上的虚函数是错误的做法。

有几种不同的办法可以解决这个问题。一种办法就是在Transaction中把函数logTransaction改变为一个非虚函数,然后要求派生子类 的构造器要把必要的登录信息传递给Transaction的构造器。如此以来,上面的函数就能够安全地调用非虚函数logTransaction了。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Transaction {
 public:
  explicit Transaction(const std::string& logInfo);
  void logTransaction(const std::string& logInfo) const;//现在是一个非虚函数
  ...
};
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的使用方法。通过使用帮助函数来创建一个值并把它传递 到基类构造器中,这种方式比起在成员初始化列表中实现基类所需的操作要更方便和更具有可读性。这里我们把该函数创建为static型,这对于偶尔参照引用 一下刚产生的BuyTransaction对象的尚未初始化的数据成员是没有危险的。这一点很重要,因为那些数据成员还处于一种未定义的状态中,这一事实 解释了为什么在基类的构造或者析构函数中对于虚函数的调用不能首先传递到派生子类中去。

结论

不要在类的构造或者析构过程中调用虚函数,因为这样的调用永远不会沿类继承树往下传递到子类中去。

热评文章