加载中...
大厂题第1辑——虚函数七题精讲之6:构造、析构、虚函数
第1节:C++的“友元”是否会破坏类的封装?
第2节:大厂题第1辑——虚函数七题精讲之1:虚函数的作用
第3节:大厂题第1辑——虚函数七题精讲之2:虚函数的作用机制
第4节:大厂题第1辑——虚函数七题精讲之3:纯虚函数
第5节:大厂题第1辑——虚函数七题精讲之4:override 的作用
第6节:大厂题第1辑——虚函数七题精讲之5:虚析构(virtual destructor)
第7节:大厂题第1辑——虚函数七题精讲之6:构造、析构、虚函数
课文封面
  1. 需要以及可以有“虚”构造吗?
  2. 在构造过程中,调用虚函数,会怎样?
  3. 在析构过程中,调用虚函数,会怎样?

题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,允许在基类构造过程中,调用派生类版本的虚函数。主要原因于,这类语言的构造过程往往是直接先准备好最终类(派生类)的所有数据,这里不展现讲。