C++ 勿在构造和析构函数里调用虚函数

292 阅读4分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

一个子类的构建过程

我们这里有两个class,

  • Animal, 表示动物
  • Fish , 继承自 Animal,表示鱼

他们的代码是这样的:

class Animal {
    public:
    Animal() { 
        std::cout << "Animal 构建!" << std::endl;
    }
    ~Animal() {
        std::cout << "Animal 析构!" << std::endl;
    }
};

class Fish : public Animal {
    public:
    Fish() {
        std::cout << "Fish 构建!" << std::endl;
    }
    ~Fish() {
        std::cout << "Fish 析构!" << std::endl;
    }
};

好了我们来搞一些测试代码,来观察Fish这个子类的示例是如何初始化的:

void test() { 
    Fish xiaoming{};
}

当我们执行test函数,我们的输出应该如下:

Animal 构建!
Fish 构建!
Fish 析构!
Animal 析构!

我们可以得知,

  • 一个子类在【构建】的时候,应该是先将其包含的基类构建完毕,然后再轮到自己的构建。

  • 一个子类在【析构】的时候,先将自己析构完毕,然后再轮到其包含的基类析构。

这里作为经典八股,应该是C++程序员都熟知的,但是……

顺序为什么是这样?

用一个盖房子的过程来做比喻是最合适不过的。

首先来说构建部分(constructor)

盖房子的时候,是不是得先打地, 然后再在这个地上,才能盖一楼,二楼,等等。

用程序的话来说,就是一个子类构建的过程里,含两个部分,一个是内部包含的基类部分,第二个才是自己额外的部分。

关键就是,在构建这个子类的额外的部分的时候,往往都会把基类的部分看成是一个已经OK的状态,并且还会依赖于其中(基类)某些字段的值。所以从构造函数的调用顺序来说,肯定是基类的构造函数先调用的。

再来说析构部分(destructor)

还是盖房子,不过这次是拆房子。

如果用传统的方式来拆房子,那么应该是从最高层慢慢拆解,毕竟里面可能有一些东西是可以回收的,比如说什么家具电器什么的。一层一层的往下回收,一层结束,就把这一层消除掉,很容易理解。

从析构函数来说,相应的就是从子类一层一层的析构,直到最底下的基类。这里还是可以用依赖性来解释:毕竟子类的某些字段或者逻辑是依赖于基类的字段的,子类析构的时候可能也会有这种依赖关系,所以必须要从子类开始析构,这样才不会产生依赖矛盾。

所谓依赖矛盾就是指,用到一个字段了,但是这个字段本身的内存已经被析构过,处于一种不可用不合法状态了。

点题:构造函数和析构函数不要调用虚函数

设想一个情景: 一个基类构造函数里调用了一个虚函数,按照虚函数的概念来讲,应该是调用了某个子类的函数,但是子类的函数很可能会使用到子类的一些字段,但是此时,子类本身还没有经过构建过程,也就是说,子类的字段处于一种非法状态,很明显,我们不应该去调用这个子类的函数的。所以C++标准里规定了,在构造函数里调用虚函数,不会有什么多态的行为,而是直接绑定,静态绑定到本身的函数。

析构的情况差不多,如果在基类析构函数里调用了虚函数,我们知道,此时子类的字段已经经过子类析构函数的清理了,这些字段处于非法状态,如果虚函数调用到了子类的函数,那么也是不太好的。所以C++标准依然规定,此时也不会有多态的行为。

为什么不要在里面使用虚函数,就是因为可能会产生认知上的差异,所以避免使用即可。

核心点总结

构造函数和析构函数的调用顺序,决定了,构造函数和析构函数里不会产生虚函数的多态行为,所以我们尽量不要在里面使用虚函数,而造成认知上的错误。