1.问题的引入
假设现在你正在为一家证券公司设计一个股市交易软件,需要有一个类Transaction用来表示股市交易。如下所示:
1class Transaction{ // 表示股市交易的基类 2public: 3 Transaction(); 4 virtual void logTransaction() const = 0; // 一个纯虚函数,用于记录股市交易的历史 5 // ... 6}; 7 8Transaction::Transaction(){ 9 // ... 10 logTransaction(); // 在基类的构造函数中调用这个纯虚函数 11}
同时,又有表示股票"买进"和"卖出"的类,继承自上面的Transaction股市交易类。如下所示:
1class BuyTransaction: public Transaction{ 2public: 3 virtual void logTransaction() const; 4 // ... 5}; 6 7class SellTransaction: public Transaction{ 8public: 9 virtual void logTransaction() const; 10 // ... 11};
现在,你在某处定义了一个"买进"类的对象b,如下所示:
1BuyTransaction b;
显然,定义买进类的对象b时,会调用买进类BuyTransaction的构造函数。但是,请注意C++中当子类开始构造时,它所包含的基类部分先开始构造。换句话说,C++中是先调用基类的构造构造函数,再调用子类的构造函数(析构过程恰恰相反)。
2.带来的问题现在问题来了:
由于基类Transaction的构造函数中调用了一个纯虚函数logTransaction(),这就会导致即使你创建的是子类对象b,这个虚函数也不会绑定到子类的版本上,而是会绑定到使用的基类版本。上面的过程违背了虚函数根据运行期间动态绑定到继承层级中对应的一个类。产生上例现象的根本原因是:如前面的文章确定对象使用前已先被初始化中所述,使用未初始化的数据可能会给程序带来风险。因为在创建一个子类对象b时,它的基类部分会优先被创建。当基类的构造函数刚刚调用完成时,我们只能保证基类部分的成员变量被初始化,但是并不能保证子类部分的成员变量被初始化。如果现在让这个虚函数绑定对应子类的版本,就可能会因为使用未初始化的成员变量而导致程序运行时的错误。
正是由于上面的这个原因,当一个子类对象在完成它自己全部成员的构造之前,C++只会把它当成基类。除了虚函数,此外还包括typeid,dynamic_cast等,都会把当前子类对象当做基类,用来避开由于使用未初始化数据可能带来的风险。同样的原理,也不要让析构函数去调用虚函数。如前面的文章C++中为多态基类声明虚析构函数所述,析构函数的调用顺序:先调用子类的析构函数再调用基类的析构函数,与构造函数的调用顺序是相反的。当子类部分的成员数据被删除时,C++同样会把当前的对象认为是基类。如果此时调用了虚函数,也会导致错误的调用了基类版本的虚函数。
3.隐藏更深的bug
实际上,在构造函数或者析构函数中直接调用虚函数,在某些编译器中会发出警告。即使无视这些警告,由于上例中调用的是一个纯虚函数logTransaction(),而纯虚函数通常是不会有定义的,所以在之后的链接过程中,链接器也会报错。
但是,下面的程序中编译器和链接器却都不会发出警告或报错。这样的代码相比于之前版本具有更隐藏的bug了。
1class Transaction{ // 表示股市交易的基类 2public: 3 Transaction(); 4 virtual void logTransaction() const = 0; // 一个纯虚函数,用于记录股市交易的历史 5 // ... 6private: 7 void init(){ // 这个函数不是虚函数,而且有定义。编译器和链接器都不会报错!但里面却包含了虚函数的代码 8 logTransaction(); 9 } 10}; 11 12Transaction::Transaction(){ 13 init(); // 调用了一个初始化函数,便于将多个构造函数中的相同代码封装在一起,避免代码重复 14}
上面的程序中,即使初始化函数init()有定义且不是虚函数,但它却调用了没有定义的纯虚函数logTransaction(),这就会导致在运行过程中,一旦使用了这里的代码,程序就会崩溃。
退一步来说,即使logTransaction()函数是一个有定义的"普通"虚函数(没有用"=0"关键字来修饰),程序虽然不会因缺少定义而中断退出,但也会在子类对象的构造过程中调用错误版本的虚函数。所以,完美的解决方法是:不管是纯虚函数还是普通虚函数,都不要在构造或者析构函数中调用它们。
4.特殊需求
如果一定想要对象在初始化的时候完成某些任务呢?其中的一种做法是在基类Transaction中,将虚函数logTransaction()去掉virtual关键字,变成普通的函数。然后,在子类的构造函数中把某些信息传递到基类的构造函数。最后,基类的构造函数便可以安全地调用普通函数logTransaction()。如下例所示:
1class Transaction 2{ // 表示股市交易的基类 3public: 4 explicit Transaction(const std::string &logInfo); // explicit关键字用来防止隐式转换 5 void logTransaction(const std::string &logInfo) const; // 增加一个传递参数,就可以从子类获得信息 6 7 // ... 8}; 9 10// 基类构造函数定义 11Transaction::Transaction(const std::string &logInfo) 12{ 13 // ... 14 logTransaction(logInfo); 15} 16 17class BuyTransaction : public Transaction 18{ 19public: 20 BuyTransaction(...) : Transaction(createLogString(...)) // 让基类构造函数去完成子类构造函数想做的事 21 { 22 // ... 23 } 24 // ... 25private: 26 static std::string createLogString(...); 27};
注意:createLogString()是一个辅助函数(helper function),用来将某函数的一部分功能封装成另一个小函数,减少代码的复杂性。此外,它还是子类BuyTransaction的一个私有成员。基类构造函数被调用时不能保证它被初始化,所以使用static关键字可以避免意外使用了未初始化的成员变量。
5.总结
[1] 不要在构造函数或析构函数中调用虚函数,因为这样的虚函数只对应于当前构造或析构的类,不会上升到它的任何子类。