Effective C++ 读书笔记 Item9 绝不在构造和析构过程中调用 virtual 函数

假如你在为某证券公司设计股市交易软件,需要有一个类用来表示股市交易:

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"的构造函数。但因为在C++中,当子类开始构造时,它所包含的父类的部分要先完成构造,所以率先调用的构造函数是它的父类"Transaction"的构造函数。现在问题来了,因为父类的构造函数调用了一个纯虚函数,这就会导致即使你创建的是它的子类对象,这个虚函数也不会绑定到子类的版本上,而是使用的父类版本。

这一点很好理解,因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化, 因此调用子类的函数是不安全的,因此C++不允许这样做。除此之外, 在子类对象的父类构造期间,对象类型为父类而非子类。 不仅虚函数会被解析至父类,运行时类型信息也为父类(dynamic_cast, typeid)。
 

这就"有悖常理"了,虚函数不是会自动绑定到继承层级里对应的一个类吗?其实C++这样做是有一个很好的原因的。我们在第4章讲过,使用未初始化的数据可能会给程序带来风险。因为在创建一个子类对象时,它的父类部分会先被创建。当父类的构造函数刚刚调用完成时,我们只能保证父类部分的数据被初始化,而不能保证衍生部分的被初始化。如果现在让这个虚函数去对应子类的版本,就可能会因为使用未初始化的数据而导致程序运行时错误

事实上,正是由于这个原因,当一个子类对象在完成它自己全部成员的构造之前,C++只会把它当成父类,除了虚函数还包括typeid,dynamic_cast等,都会把当前对象当做父类,用来规避使用未初始化数据可能带来的风险。


同样的原理,我们也不要让析构函数调用虚函数。我们在第7章讲过,析构函数的调用顺序是从子类到父类,与构造函数的调用顺序是相反的。当子类部分的成员数据被删除时,C++同样会把当前的对象认为是父类,如果这时调用了虚函数,也会导致错误版本的虚函数被调用。


实际上这样在构造函数或者析构函数里直接调用虚函数,在某些编译器中是会发出警告的。不过即使无视掉这些警告,因为调用的是一个纯虚函数,通常是不会有定义的,所以在之后的链接过程中,链接器也会报错。

但如下的代码做了完全一样的事,编译器和链接器却都不会发出警告或报错。这样的代码相比前面的就更具有潜在危害性:

class Transaction{
  public:
    Transaction(){ init(); }                  //调用了一个专门的init()函数
    virtual void logTransaction const =0;
    ...
  private:
    void init(){                              //这个函数不是虚函数,而且有定义
      ...                                     //编译器和链接器就都不会报错
      logTransaction();                       //但里面却包含了虚函数的代码
    }
  ...
};

即使init()函数有定义而且不是虚函数,它却调用了没有定义的纯虚函数logTransaction(),这就会导致在运行过程中,一旦使用了这里的代码,程序就会崩溃。

就算logTransaction()函数是一个有定义的"普通的"虚函数,即没有"=0"关键字来修饰,程序虽然不会因为缺少定义而闪退,但却一样会在子类的构造过程中调用错误版本的虚函数。所以万全的解决方法就是,不管是纯虚的还是普通的,就不要在构造或者析构函数中调用虚函数。


但如果一定想要对象在初始化的时候完成某些任务呢?那我们就需要在父类Transaction中,把虚函数logTransaction去掉virtual关键字,变成普通的函数,然后在子类构造过程中,把某些信息传递到父类的构造函数中。

class Transaction{
  public:
    explicit Transaction(const std::string& info);      //explicit关键字用来防止隐式转换
    void logTransaction(const std::string& info) const; //增加一个传递参数,就可以从子类获得信息了
    ...
};

Transaction::Transaction(const std::string& info){
  ...
  logTransaction(info);  
}

现在就可以在子类对象中如下定义构造函数了,这样就能把子类的信息传递到父类中,让父类构造函数去完成子类构造函数想做的事:

class BuyTransaction : public Transaction{
  public:
    BuyTransaction(...) : Transaction(createLog(...)) { ...}
    ...
  private:
    static std::string createLog(...);
};

这里createLog()就是一个辅助函数(helper function),用来将某函数的一部分功能封装成另一个小函数,减少代码的复杂性,使代码更加可读。此外,因为这是一个子类的私有成员,父类构造函数被调用时不能保证它被初始化,所以使用static关键字可以避免意外使用了未初始化的成员数据。

总结:

  • 不要在构造函数或者析构函数中调用虚函数,因为这样的虚函数只会对应起来当前构造或析构的类,不会上升到它的任何子类。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值