6、构造函数与虚函数:构造函数中调用虚函数的行为、为什么构造函数不能是虚函数、析构函数与虚函数的关系

好,咱们今天聊一个让很多C++开发者头疼的话题——构造函数和虚函数之间的“爱恨情仇”。

说实话,我刚开始学C++那会儿,也在这上面栽过跟头。有一次写一个多态的基类,在构造函数里调了个虚函数,结果跑出来的结果完全不是我想象的那样。调试了半天,最后发现是虚函数机制在构造函数里“失效”了。嗯,今天咱们就把这块彻底讲透。

6.1 构造函数中调用虚函数:为什么“虚”不起来?

先看一个例子:

class Base {
public:
    Base() {
        print();  // 在构造函数中调用虚函数
    }
    virtual void print() {
        std::cout << "Base::print" << std::endl;
    }
};

class Derived : public Base {
public:
    Derived() : Base() {
        // 先调基类构造,再调派生类构造
    }
    virtual void print() override {
        std::cout << "Derived::print" << std::endl;
    }
};

int main() {
    Derived d;  // 猜猜输出什么?
    // 输出:Base::print
}

你可能会想:“我创建的是Derived对象,虚函数应该调用Derived::print才对啊?”

但结果偏偏是Base::print。为什么会这样?

核心原因:对象构造期间,虚函数表(vtable)还没“长全”。

我给大家拆解一下构造过程:

  1. 当执行Derived d时,先调用Derived的构造函数
  2. Derived构造函数的第一件事:调用基类Base的构造函数
  3. 在Base构造函数执行时,对象的类型是Base,不是Derived
  4. 此时对象的虚函数表指针(vptr)指向的是Base的虚函数表
  5. 所以调用print()时,走的是Base的版本

说白了,在基类构造期间,派生类部分还没初始化呢。你想想看,如果这时候能调用派生类的虚函数,派生类的成员变量都还没构造好,那不就出大问题了?

重要结论:在构造函数中调用虚函数,不会发生动态绑定。调用的是当前正在构造的类对应的虚函数版本。

避坑指南:我曾经在一个项目中看到同事在基类构造函数里调用了纯虚函数,结果程序直接崩溃。因为纯虚函数连实现都没有,调用它等于在访问一个空指针。记住:不要在构造函数中调用纯虚函数,这是未定义行为!

6.2 为什么构造函数不能是虚函数?

这个问题其实挺有意思的。我经常被问到:“既然析构函数可以是虚函数,为什么构造函数不行?”

答案其实很简单:虚函数机制依赖于对象已经存在,而构造函数是用来创建对象的。

咱们捋一捋逻辑:

  • 虚函数调用需要访问对象的虚函数表指针(vptr)
  • vptr是在构造函数执行期间才被初始化的
  • 如果构造函数本身是虚函数,那调用它之前对象还不存在
  • 没有对象,就没有vptr,就无法完成虚函数调用

这就成了一个“先有鸡还是先有蛋”的问题。你没法通过虚函数机制去调用一个还不存在的对象的构造函数。

另外,从语法层面看,构造函数没有返回值,而虚函数机制需要返回一个指针或引用。这两者天然不兼容。

个人经验:我习惯用“工厂模式”来解决需要“虚构造”的场景。比如定义一个虚函数virtual Base* create() = 0;,让每个派生类自己实现创建逻辑。这样既保持了多态性,又绕开了构造函数不能虚的限制。

6.3 析构函数与虚函数的关系

这个知识点,说白了就是一句话:如果类会被继承,析构函数一定要声明为虚函数。

为什么?看个例子你就明白了:

class Base {
public:
    ~Base() {
        std::cout << "~Base()" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "~Derived()" << std::endl;
    }
};

int main() {
    Base* p = new Derived();
    delete p;  // 输出:~Base()
    // 派生类的析构函数没有被调用!
}

看到问题了吗?delete p时,因为p是Base*类型,析构函数又不是虚函数,所以只调用了Base的析构函数。Derived的资源没有被释放,内存泄漏了。

改成虚函数就对了:

class Base {
public:
    virtual ~Base() {
        std::cout << "~Base()" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() override {
        std::cout << "~Derived()" << std::endl;
    }
};

int main() {
    Base* p = new Derived();
    delete p;  // 输出:~Derived()  ~Base()
    // 先调派生类析构,再调基类析构,完美!
}

这里有个细节:派生类的析构函数会自动调用基类的析构函数,顺序是先派生后基类。这和构造函数的调用顺序正好相反。

我曾经踩过的坑:有一次写一个工具类,觉得它不会被继承,就没加virtual析构函数。结果后来需求变更,有人继承了它,还通过基类指针删除了派生类对象。内存泄漏查了好几天才定位到问题。从那以后,我给自己定了个规矩:只要类中有虚函数,析构函数就一定是虚的。哪怕现在没有派生类,也先加上,防患于未然。

6.4 知识体系总览

下面这张图把构造函数、析构函数和虚函数的关系梳理清楚了:

构造函数、析构函数与虚函数的关系 构造函数 析构函数 虚函数 不能是虚函数 建议声明为虚 构造函数中调用虚函数 • 调用的是当前类的版本 • 不会发生动态绑定 • 派生类部分尚未初始化 • vptr指向当前类的vtable ⚠ 禁止调用纯虚函数 析构函数中调用虚函数 • 同样不会动态绑定 • 调用当前类的版本 • 派生类部分已销毁 • 先派生后基类的析构顺序 ⚠ 派生类资源已释放 最佳实践总结 • 有虚函数 → 虚析构 • 构造函数避免调虚函数 • 析构函数也避免调虚函数 • 用工厂模式替代虚构造 ✅ 安全第一

6.5 总结与避坑清单

好,咱们把今天的内容捋一捋:

场景 行为 建议
构造函数中调用虚函数 调用当前类的版本,非动态绑定 尽量避免,改用非虚函数或模板方法模式
构造函数声明为虚函数 语法不允许,编译报错 用工厂模式替代
析构函数中调用虚函数 调用当前类的版本,非动态绑定 尽量避免,析构时对象已不完整
基类析构函数非虚 通过基类指针删除派生类对象时,只调基类析构 有继承关系的类,析构函数必须为虚

我的个人习惯:写类的时候,我会先问自己三个问题:

  1. 这个类会被继承吗?→ 是 → 析构函数加virtual
  2. 构造函数里有没有调用虚函数?→ 有 → 重构掉
  3. 析构函数里有没有调用虚函数?→ 有 → 重构掉

这三个问题问完,基本就能避开90%的坑了。

嗯,今天的内容就到这儿。构造函数和虚函数的关系,说白了就是一句话:对象还没完全成型的时候,别指望虚函数能正常工作。记住这个原则,你就能少踩很多坑。


公众号:蓝海资料掘金营,微信deep3321