14. 构造与析构的调用顺序:继承体系下的构造顺序、成员对象的构造顺序、析构顺序与构造相反

大家好,我是你们的老朋友。今天我们来聊一个看似简单、实则暗藏杀机的话题——构造与析构的调用顺序。

说实话,我见过不少写了三五年C++的工程师,在这个问题上翻过车。我自己刚入行时也踩过坑,有一次线上服务莫名其妙崩溃,查了两天才发现是析构顺序导致的野指针问题。嗯,从那以后,我对这个知识点就格外上心。

你想想看,一个对象从出生到消亡,C++到底按什么顺序调用构造函数和析构函数?说白了,就两条铁律:

  • 构造顺序:先基类,后派生类;先成员对象,后自身。
  • 析构顺序:与构造顺序完全相反。

听起来很简单对吧?但实际项目中,继承层次一深、成员对象一多,就容易乱。我们一条一条拆开讲。

14.1 继承体系下的构造顺序

先看一个最基础的例子:

class Base {
public:
    Base() { std::cout << "Base构造\n"; }
    ~Base() { std::cout << "Base析构\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived构造\n"; }
    ~Derived() { std::cout << "Derived析构\n"; }
};

int main() {
    Derived d;
    return 0;
}

输出结果:

Base构造
Derived构造
Derived析构
Base析构

为什么会这样?因为C++编译器在生成Derived的构造函数时,会自动插入对Base构造函数的调用,而且是在执行Derived构造函数体之前。析构时正好反过来:先执行Derived析构函数体,再自动调用Base析构函数。

核心原则:构造从最根部的基类开始,一层层往下;析构从最末端的派生类开始,一层层往上。

我在项目中遇到过这样一个坑:有人想在Derived构造函数里调用基类的虚函数,以为能多态分发。结果呢?基类构造时派生类还没构造完,虚函数表指向的是基类版本。这就是典型的「构造期间不要调用虚函数」陷阱。

14.2 成员对象的构造顺序

除了继承,类里还有成员对象。它们的构造顺序怎么定?

记住一句话:成员对象按它们在类中声明的顺序构造,与初始化列表中的顺序无关。

看个例子:

class MemberA {
public:
    MemberA() { std::cout << "MemberA构造\n"; }
    ~MemberA() { std::cout << "MemberA析构\n"; }
};

class MemberB {
public:
    MemberB() { std::cout << "MemberB构造\n"; }
    ~MemberB() { std::cout << "MemberB析构\n"; }
};

class Container {
    MemberB b;  // 先声明
    MemberA a;  // 后声明
public:
    Container() : a(), b() {  // 初始化列表顺序是a先b后
        std::cout << "Container构造\n";
    }
    ~Container() { std::cout << "Container析构\n"; }
};

输出结果:

MemberB构造   // 按声明顺序,b先
MemberA构造   // 然后a
Container构造
Container析构
MemberA析构   // 析构顺序相反,a先
MemberB析构   // 然后b

看到了吗?初始化列表里我把a写在b前面,但实际构造时还是按声明顺序——b先、a后。我个人习惯是让初始化列表顺序和声明顺序保持一致,这样读代码的人不会困惑。

警告:如果成员对象的初始化依赖于另一个成员对象,一定要确保声明顺序正确。我曾经见过一个同事把两个成员声明顺序搞反,结果一个对象初始化时引用了另一个尚未构造的对象,程序直接崩溃。

14.3 完整的多层继承+成员对象场景

把继承和成员对象组合起来,才是真正的考验。我们来看一个综合例子:

class GrandBase {
public:
    GrandBase() { std::cout << "GrandBase构造\n"; }
    ~GrandBase() { std::cout << "GrandBase析构\n"; }
};

class Base : public GrandBase {
    MemberA a;
public:
    Base() { std::cout << "Base构造\n"; }
    ~Base() { std::cout << "Base析构\n"; }
};

class Derived : public Base {
    MemberB b;
public:
    Derived() { std::cout << "Derived构造\n"; }
    ~Derived() { std::cout << "Derived析构\n"; }
};

构造顺序:

  1. GrandBase(最顶层基类)
  2. Base的成员对象MemberA(按声明顺序)
  3. Base自身
  4. Derived的成员对象MemberB
  5. Derived自身

析构顺序完全反过来:

  1. Derived自身
  2. Derived的成员对象MemberB
  3. Base自身
  4. Base的成员对象MemberA
  5. GrandBase

为了让你更直观地理解,我画了一张流程图:

构造与析构调用顺序 构造顺序(从上到下) ① GrandBase 构造 ② MemberA 构造(成员) ③ Base 构造 ④ MemberB 构造(成员) ⑤ Derived 构造 析构顺序(从下到上) ① Derived 析构 ② MemberB 析构(成员) ③ Base 析构 ④ MemberA 析构(成员) ⑤ GrandBase 析构 构造与析构 顺序完全相反

14.4 避坑指南与最佳实践

讲了这么多理论,来点实战经验。我这些年总结了几条铁律:

  • 不要在构造函数或析构函数中调用虚函数。 构造时虚函数表还没准备好,析构时虚函数表已经被破坏了。调用结果大概率不是你想要的。
  • 成员对象的声明顺序就是它们的生死顺序。 如果成员A依赖成员B,一定要把B声明在A前面。
  • 析构函数中释放资源时,注意成员对象的存活状态。 我曾经在析构函数里释放了一个智能指针,结果那个智能指针指向的对象已经被另一个成员析构时释放了——double free,程序直接挂掉。

小技巧:如果你不确定构造/析构顺序,可以在每个构造函数和析构函数里加打印日志。调试阶段这招特别管用。我个人习惯在开发初期就加上这类日志,等逻辑稳定了再删掉。

14.5 总结

构造与析构的顺序,说白了就是「先来后到」四个字。构造时,基类先来,成员对象按声明顺序来,最后才轮到派生类自己。析构时,谁最后构造,谁最先析构。

这个规则在单继承、多继承、虚继承下都适用。多继承的情况稍微复杂一点,但核心逻辑不变——基类按继承声明顺序构造,析构时反向。

嗯,今天就聊到这里。记住这些规则,你在写C++代码时就能少踩很多坑。下次我们聊聊更深层的构造细节。


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