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

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

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

如果你已经从C#或Java等其他语言转到C++,你会觉得在类构造函数或析构函数中避免调用虚函数的原则有点违反直觉。但在C++中,违背这个原则会给你带来不可预知的后果和无尽的烦恼。

主体

我想先重复一下本文的主题:不要在类的构造或析构中调用虚函数,因为这样的调用不会如你所愿,即使成功了,最后也会让你灰心丧气。如果你曾经是Java或者C#程序员,请密切关注这一节——这是C++和其他语言的一大区别。

假设您有一个对股票交易建模的类层次结构,比如买入订单、卖出订单等等。为此类交易建立一个审计系统是非常重要的,这样每创建一个交易对象,都会在审计条目中生成一个合适的条目。这似乎是解决这个问题的合理方法:

Class Transaction {//所有事务的基类
public:
Transaction();
virtual void log transaction()const = 0;//创建依赖于具体交易类型的登录项
...
};
transaction::transaction()//实现基类的构造函数
{
...
log transaction();//最后登录事务
}
class buy transaction:public transaction {
/派生类
public:
virtual void log transaction()const;//如何登录这类交易?
...
};
class sell transaction:public transaction {
/派生类
public:
virtual void log transaction()const;//如何登录这类交易?
...
};

现在,请分析执行以下代码调用时会发生什么:

buy transaction b;

显然,调用了BuyTransaction类构造函数。但是,首先调用事务类的构造函数——派生类对象的基类部分是在派生类部分之前构造的。事务构造函数的最后一行调用了虚函数logTransaction,但是奇怪的事情在这里发生了。被调用函数logTransaction的版本是Transaction中的版本,而不是BuyTransaction中的版本——即使现在生成的对象类型是BuyTransaction。在构造基类的过程中,虚函数调用永远不会传递给派生类。相反,派生类对象的行为就像它是基类型一样。非标准的,在构造基类的过程中,虚函数不是“构造”出来的。

这种看似违反直觉的行为可以用一个原因来解释——因为基类构造函数是在派生类之前执行的,基类构造函数运行时派生类的数据成员还没有初始化。如果在构造基类的时候把对虚函数的调用传递给派生类,那么派生类对象当然可以引用本地数据成员,但是这些数据成员当时还没有初始化。这将导致无休止的未定义行为和整夜的代码调试。调用类层次结构中未初始化对象的某些部分本身就很危险,所以C++不会让你这么做。

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

在对象的销毁过程中,存在与上述相同的逻辑。一旦派生类的析构函数运行,对象的派生类数据成员就被假定为未定义的值,这样C++就把它们当作不存在。一旦在基类的析构函数中,对象就变成了基类对象,C++(虚函数,dynamic_cast运算符等)的所有部分。)都是这样处理的。

在上面的示例代码中,事务构造函数直接调用了一个虚函数——这显然违反了本文强调的原则。这种破坏性是非常值得注意的,有些编译器会对此进行警告(注:其他编译器不给出警告,关于警告的讨论请参考第53项)。即使没有给出警告,这个问题在代码运行时也是相当明显的,因为函数logTransaction在类Transaction中是一个纯虚函数。除非定义了函数(不太可能,但确实存在——见第34项),否则程序不会链接:链接器找不到必要的Transaction::logTransaction的实现代码。

在类的构造函数或析构函数中找到虚函数调用并不总是容易的。如果Transaction类有多个构造函数,每个构造函数都必须执行一些相同的任务,也许只有优秀的软件工程师才能避免代码重复,这可以通过将相同的初始化代码(包括调用logTransaction)放入私有的非虚拟初始化函数来实现,比如下面的init:

类Transaction {
public:
Transaction()
{ init();}//调用非虚函数...
虚拟void log transaction()const = 0;
...
private:
void init()
{
...
log transaction();//注意这里调用的是虚函数
}
};
这段代码在概念上与上一个版本相同,但更具潜在危险性,因为在典型情况下会被成功编译和链接。在这种情况下,由于logTransaction是Transaction类中的一个纯虚函数,所以大多数运行时系统在调用纯虚函数时都会中止程序(通常是通过发送一个带有调用该函数意思的消息)。但是,如果logTransaction是一个“正常”的虚函数(即不是纯虚函数),并且在Transaction中有它的实现部分,那么代码段就会被调用,程序就会流畅运行一段时间,这就让你考虑为什么在创建派生类对象的时候调用了错误版本的logTransaction。避免这个问题的方法是确保没有一个构造函数或析构函数在被产生或被销毁的对象上调用虚函数,并且它们调用的所有函数都必须遵循相同的约束。

但是,每当在事务类层次结构中生成一个对象时,如何确保调用正确版本的logTransaction呢?显然,从Transaction的构造函数调用对象上的虚函数是错误的。

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

class Transaction {
public:
explicit Transaction(const STD::string & log info);
void log transaction(const STD::string & log info)const;//现在它是非虚函数
...
};

Transaction::Transaction(const STD::string & log info)
{
...
log transaction(log info);//现在被调用的是非虚函数
}

class buy Transaction:public Transaction {
public:
buy Transaction(parameters)
:Transaction(createLogString(parameters)){...}//将登录信息传递给基类的构造函数
...
Private:
static STD::stringcreatelogstring(参数);
};

换句话说,由于不能在基类的构造函数中沿着类继承层次结构调用虚函数,因此可以通过将必要的构造信息传递给派生类的类层次结构中的基类的构造函数来弥补这一点。

在这个例子中,请注意BuyTransaction中私有静态函数createLogString的使用。通过使用helper函数创建一个值并将其传递给基类构造函数,这种方式比在成员初始化列表中实现基类所需的操作更方便、可读性更好。这里,我们将函数创建为静态类型,偶尔引用新生成的BuyTransaction对象的未初始化数据成员并不危险。这一点很重要,因为那些数据成员还处于未定义状态,这也解释了为什么基类的构造或析构中对虚函数的调用不能先传递给派生的子类。

结论

不要在类的构造或销毁过程中调用虚函数,因为这样的调用永远不会通过类继承树传递给子类。

位律师回复
DABAN RP主题是一个优秀的主题,极致后台体验,无插件,集成会员系统
白度搜_经验知识百科全书 » C++箴言:避免构造或析构函数中调用虚函数

0条评论

发表评论

提供最优质的资源集合

立即查看 了解详情