题11-需要以及可以有“虚”的构造函数吗?
上一节课我们讲了“虚”的构造函数,是C++中面向对象设计中的一个很重要的惯用法。它的作用是让一个声明为是基类的,但实际是派生的对象,可以“死得其所”。
那么,构造函数有没有必要是“虚”的呢?答:没有必要,所以C++语法也不允许。
请对比:
析构时,用的是这种语法:
delete obj; // delete 施加在一个变量(对象、数据)身上
而构造时:
auto obj = new Type(); // new 施加在一个类型身上,这个类型就是要构造的最终目标
如注释所示:delete 施加在一个变量(即:对象、数据)身上。而该变量的类型,有可能是名实不一的,比如:
Base *obj = new Derive(); // 名:Base,实:Derive
所以,才会有担心这个问题: delete obj,是直接调用 ~Base(),还是安全地先调用 ~Derive(),再(按C++的语法规定,自动地)调动 ~Base()。
而在构造时,new 直接施加在一个明确的类型身上,如是该类是派生类,比如 Derive,则将(按C++的语法规定,自动地)先调用基类 Base()构造,再调用派生类 Derive()构造。
换句话说:我们要构造一个什么类型的对象,在写代码时,就是清楚的,无需借助“虚”函数机制来,来实现在运行时,再动态决定需要构造哪种类型的对象。
这句话很快会被现实需求给“打脸”:假设一个游戏中,有两种怪兽:火龙、水龙、土龙,它有一个共同的基类:怪兽龙。再假设,玩家在路上跑时,会遇到三个并排站的女生,穿着风格依次为:暴露、普通 、保守。玩家需要做出选择要攻击哪一位女生,而被选择的女生会按以下对应关系,化身为怪兽:
- 暴露 --> 火龙
- 普通 --> 水龙
- 保守 --> 土龙
于是,我们大概就会需要写下面这样的“工厂”函数,用于动态决定要创建什么怪兽:
enum class 风格 { 暴露, 普通, 保守 };
怪兽龙* 创建怪兽龙 (风格 选择)
{
switch(选择)
{
case 暴露: return new 火龙();
case 普通: return new 水龙();
case 保守:return new 土龙();
default: return nullptr;
}
}
这就是一个需要运行时期——因为要依据用户的选择——动态创建不同类型对象的常见需求,以及对应的基本解决思路。这个思路也和C++语言内置的“虚”函数机制无关,倒是和“设计模式”中的“工厂方法”很接近——事实上它就是。
题12-为什么不要在构造中调用虚函数?
构造过程中,调用的虚函数,此时虚机制“失灵”了。也就是说:在基类的构造函数中,所调用的函数,一定走基类自己的函数,而不可能走派生类的同名函数,哪怕这个函数是虚的,并且在派生类中有重写版本(override)。
C++这样设计的理由也很充分:在构造基类时,所有派生类的数据都还没有准备好,此时调用派生类的函数,万一该函数需要使用到派生类才有的数据,怎么办?
要知道,当我们要创建(new)一个派生类对象时,构造过程是:先构造基类的内容,再逐级构造派生类的内容。就像要盖一个三层楼,得先把一楼(基类)盖好,再盖好二楼(派生类),最后才盖三楼(最终派生类)。如果允许在盖一楼时,就调用三楼才有的数据,逻辑反倒奇怪:三楼现在还没有盖好,怎么就可以使用它里面的东西(数据)?
下面是一个极简例子:
#include <iostream>
using namespace std;
struct FirstFloor
{
FirstFloor() { this->Where(); } // 不好:基类构造调用了虚函数
virtual void Where() { cout << "您的位置是:一楼\n"; }
};
struct SecondFloor: FirstFloor
{
string place;
explicit SecondFloor(string place) : place(place) {}
void Where() override { cout << "您的位置是:二楼的" << place << "\n"; }
};
int main()
{
SecondFloor sf("男厕所");
}
例中,构造 sf 并不会在屏幕上打印出 “您的位置是:二楼的男厕所”。
作者的本意可能是:不管构造哪一楼层的最终对象,都输出楼层位置信息,既然如此,干脆让基类调用,这样后面的各级派生类就不用重复地在构造过程中调用 Where()方法。
如果我们在每一层,比如上面的 “SecondFloor”的构造函数,也主动加上对 Where() 的调用呢?这时屏幕的输出,就会像你去商城爬楼梯一样:每到一层都有个指示牌告诉你位置,于是:
- “您的位置是:一楼”
- “您的位置是:二楼的男厕所”
很有可能,这也不是我们想要的结果……最好的方法,还是执行最简原则:构造就负责构造,指示就负责指示。业务上真需要做紧绑定,可以再写一个“工厂方法”:make_floor_and_say_where() ……
强烈不建议在构造过程中调用虚函数的另一个原因:虚函数在基类中有可能是纯虚的,亦即是一个没有实现的方法,这样就危险了。
题13-为什么不要在析构中调用虚函数
和在构造过程中调用虚函数道理一样,只不过过程相反:析构过程是先调用派生类的析构,再调用基类的析构。第一步不会有问题,倒到第二步时,如果基类的析构调用了某个虚函数,而该虚函数在派生类有重写版本(override),由于此时派生类独有的数据,已经被摧毁了,因此自然也不可能还敢去调用派生类的函数。
注意,以上解释并不适用于所有编程语言。不少同样支持面向对象的编程语言,比如 Java,允许在基类构造过程中,调用派生类版本的虚函数。主要原因于,这类语言的构造过程往往是直接先准备好最终类(派生类)的所有数据,这里不展现讲。