对象构造与析构顺序全解:声明顺序、继承链与逆序析构

「对象是怎么被拼出来、又怎么被拆掉的」是 C++ 里最容易想错的一件事,偏偏它贯穿所有资源管理。一个常见的坑:你在初始化列表里把成员 b 写在 a 前面,就以为 b 先构造。其实不是,先后只认声明顺序 。再比如通过基类指针 delete 一个派生对象,若基类析构不是 virtual,派生部分的析构函数压根不会被调用。这篇把这几条顺序规则一条条用实跑代码钉死,配上时序图,半年后翻回来照着图就能复现。

初始化列表顺序的错觉

先看一段「看起来 b 先构造」的代码,运行结果会颠覆直觉:

cpp 复制代码
#include <iostream>
struct Member {
    const char* name;
    Member(const char* n) : name(n) { std::cout << "构造成员 " << name << "\n"; }
};
struct Order {
    Member a;   // 声明顺序:a
    Member b;   // 声明顺序:b
    Member c;   // 声明顺序:c
    Order() : c("c"), b("b"), a("a") {}   // 初始化列表故意写成 c,b,a
};
int main() {
    std::cout << "开始构造 Order\n";
    Order o;
    std::cout << "Order 构造结束\n";
}
text 复制代码
开始构造 Order
构造成员 a
构造成员 b
构造成员 c
Order 构造结束

尽管初始化列表写的是 c,b,a,实际构造顺序仍是 a,b,c。成员永远按声明顺序初始化 ,初始化列表的顺序只影响「你给哪个成员传什么值」,不影响先后。gcc 开 -Wall 会对此发 -Wreorder 警告,别忽略它。

官方文档:成员初始化顺序

核心规则一:基类先于成员,析构完全逆序

构造顺序的总纲是:先祖先,后自己 ;析构则是严格的逆序。

text 复制代码
构造(自顶向下)              析构(自底向上,完全逆序)
┌─────────────┐              ┌─────────────┐
│ 基类成员     │──┐        ┌─│ 派生类析构   │
│ 基类构造体   │  │        │  │ 派生成员     │
├─────────────┤  │        │  ├─────────────┤
│ 派生成员     │  └───────▶│  │ 基类析构     │
│ 派生构造体   │           │  │ 基类成员     │
└─────────────┘           └─└─────────────┘

把这几条规则压成一张速查表,后面每一节都在验证表里的某一行:

场景 构造顺序 析构顺序
派生类对象 基类成员 → 基类构造体 → 派生成员 → 派生构造体 派生析构体 → 派生成员 → 基类析构体 → 基类成员
同一个类的多个成员 按声明顺序(与初始化列表书写顺序无关) 严格逆序
同一作用域里的多个局部对象 按声明出现的先后 严格逆序
数组 T a[N] a[0] → a[N-1] a[N-1] → a[0]
同一函数内的多个 static 局部对象 各自首次执行到声明处 main 返回之后,按构造逆序
表达式里的临时对象 在出现点构造 整个表达式结束时逆序析构
构造中途抛异常的对象 只有已构造完成的部分 已构造的部分逆序析构;自身析构体不执行

表里那条规律其实就一句:构造从外到内、从基到派,析构是构造的严格逆序。

继承关系里,基类子对象一定先于派生类构造;出了作用域,顺序整个翻转:派生类析构先跑,再调基类析构。

cpp 复制代码
#include <iostream>
struct Base {
    Base() { std::cout << "Base 构造\n"; }
    ~Base() { std::cout << "Base 析构\n"; }
};
struct Derived : Base {
    Derived() { std::cout << "Derived 构造\n"; }
    ~Derived() { std::cout << "Derived 析构\n"; }
};
int main() {
    std::cout << "--- 进入 ---\n";
    Derived d;
    std::cout << "--- 离开 ---\n";
}
text 复制代码
--- 进入 ---
Base 构造
Derived 构造
--- 离开 ---
Derived 析构
Base 析构

为什么析构必须逆序?因为派生类可能用到基类子对象里的资源,只有等派生类「用完」了,基类才能安全拆。这条规则对所有嵌套对象都成立。

核心规则二:局部对象出作用域逆序析构

同一个作用域里 successive 声明的多个对象,构造按出现顺序,析构按逆序(后构造的先拆,类似栈):

cpp 复制代码
#include <iostream>
struct L {
    const char* n;
    L(const char* s) : n(s) { std::cout << "局部对象 " << n << " 构造\n"; }
    ~L() { std::cout << "局部对象 " << n << " 析构\n"; }
};
void scope() {
    L a("a");
    L b("b");
    L c("c");
    std::cout << "scope 内:a,b,c 已构造\n";
}  // 逆序析构:c,b,a
int main() { scope(); std::cout << "scope 已返回\n"; }
text 复制代码
局部对象 a 构造
局部对象 b 构造
局部对象 c 构造
scope 内:a,b,c 已构造
局部对象 c 析构
局部对象 b 析构
局部对象 a 析构
scope 已返回

这保证了「后进场、先退场」:若 b 依赖 a 活着,等 b 走了 a 才走,依赖关系不会被破坏。

核心规则三:静态局部对象在 main 之后逆序析构

函数内的 static 局部对象有两点反直觉:① 第一次执行到它时才构造(不是程序启动);② 它的析构发生在 main 返回之后,且多个静态对象按「构造的逆序」析构。

cpp 复制代码
#include <iostream>
struct S {
    const char* n;
    S(const char* s) : n(s) { std::cout << "静态局部 " << n << " 构造\n"; }
    ~S() { std::cout << "静态局部 " << n << " 析构\n"; }
};
void f() { static S s1("s1"); static S s2("s2"); }
void g() { static S s3("s3"); }
int main() {
    std::cout << "main 开始\n";
    f();   // 构造 s1, s2
    g();   // 构造 s3
    std::cout << "main 结束(之后才析构静态对象)\n";
}
text 复制代码
main 开始
静态局部 s1 构造
静态局部 s2 构造
静态局部 s3 构造
main 结束(之后才析构静态对象)
静态局部 s3 析构
静态局部 s2 析构
静态局部 s1 析构

注意 s3 最后构造、却最先析构,印证「逆序」。这种对象适合做单例、日志器之类「活到程序结束」的资源。

官方文档:静态局部变量生命周期

关键陷阱:经基类指针删除,基类析构必须是 virtual

当用基类指针指向派生对象并 delete 时,如果基类析构不是 virtual,C++ 只认指针的静态类型。它只调基类析构,派生类的析构函数被整个跳过,派生部分持有的资源(文件、锁、内存)全部泄漏,而且这是未定义行为。这种泄漏当时看不出来,往往要压测跑上几个小时才浮出水面。

cpp 复制代码
// 反例,不要这么写:基类析构非 virtual,经基类指针 delete 派生对象
struct Base { ~Base() { std::puts("Base 析构"); } };          // 没有 virtual
struct Derived : Base { ~Derived() { std::puts("Derived 析构"); } };
Base* p = new Derived();
delete p;   // 只调 Base 析构,"Derived 析构" 永不执行 -> 派生部分泄漏(UB)

正确写法:基类析构标 virtual(派生类用 override),delete 会沿虚表找到最派生析构,再自动逆序回退调基类析构:

cpp 复制代码
#include <iostream>
struct Base {
    Base() { std::cout << "Base 构造\n"; }
    virtual ~Base() { std::cout << "Base 虚析构\n"; }   // 关键:virtual
};
struct Derived : Base {
    Derived() { std::cout << "Derived 构造\n"; }
    ~Derived() override { std::cout << "Derived 析构\n"; }
};
int main() {
    Base* p = new Derived();   // 基类指针指向派生对象
    delete p;                  // virtual 析构 -> 先 Derived 后 Base,正确释放
}
text 复制代码
Base 构造
Derived 构造
Derived 析构
Base 虚析构

官方文档:C++ Core Guidelines C.35:有虚函数的基类,析构函数应为 public 且 virtual(或 protected 且非 virtual)。

边界情况:构造中途抛异常,已构造的成员仍会被析构

如果对象构造到一半就抛了异常,那么这个对象「从未存在过」,它自己的析构函数不会被调用。但已经构造完成的那部分(基类子对象、已经初始化好的成员)都算独立构造完成的对象,编译器会把它们逐个逆序销毁。这条规则是「部分构造也能安全清理」的全部依据:

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <cstdio>
#include <stdexcept>

struct Part {
    const char* n;
    Part(const char* s) : n(s) { std::printf("构造 %s\n", n); }
    ~Part() { std::printf("析构 %s\n", n); }
};

struct Job {
    Part a{"成员a"};
    Part b{"成员b"};
    Part c{"成员c"};
    explicit Job(int fail_at) {
        std::printf("Job 构造体开始\n");
        if (fail_at <= 2) throw std::runtime_error("构造失败");
        std::printf("Job 构造体结束\n");
    }
    ~Job() { std::printf("~Job\n"); }
};

int main() {
    try {
        Job j(1);
        (void)j;
    } catch (const std::exception& e) {
        std::printf("捕获: %s\n", e.what());
    }
    std::printf("---- 上面那个 Job 对象从未完整存在过 ----\n");
    Job ok(3);
    (void)ok;
}
text 复制代码
构造 成员a
构造 成员b
构造 成员c
Job 构造体开始
析构 成员c
析构 成员b
析构 成员a
捕获: 构造失败
---- 上面那个 Job 对象从未完整存在过 ----
构造 成员a
构造 成员b
构造 成员c
Job 构造体开始
Job 构造体结束
~Job
析构 成员c
析构 成员b
析构 成员a

对比两段输出:抛异常那次只析构了三个成员,~Job 完全没出现;正常那次才多出 Job 构造体结束 和 ~Job。三条推论值得记下来:

  • Job 的析构函数不能用来清理「构造体里手动申请的资源」,因为构造失败时它根本不被调用。所以资源要挂在成员对象上(RAII),构造体里最多只做「不可能失败」的赋值;这也正是 Rule of Zero 省心的地方。
  • 已经构造完成的成员一定会被逆序析构,所以成员对象不必关心「构造我的那个对象有没有构造完」,各自的清理职责是独立的。
  • 这条规则还解释了为什么移动构造要标 noexcept:std::vector 扩容时若元素的移动构造可能抛异常,容器为了保证强异常安全(出错时原数据完好),只能退而求其次逐元素拷贝 而不是移动。一个 noexcept 写少了,性能就从移动退化成了拷贝。

两个销毁顺序陷阱,加一个内存视角

全局 / 静态对象的销毁顺序,跨翻译单元同样「未指定」

构造顺序跨翻译单元(translation unit,即单个 .cpp)未指定,销毁顺序同样未指定。标准只保证「同一翻译单元内按构造的逆序」。于是「在全局对象的析构函数里访问另一个 .cpp 里的全局对象」就很危险:对方可能已经被销毁了。

这和静态初始化顺序问题(SIOF)是同一个根源的两个方向:构造时可能读到「还没初始化」的对象,析构时可能读到「已经销毁」的对象。修法也一样,把跨对象的依赖收进函数内静态局部对象(首次使用才构造),或者干脆让析构函数不依赖任何外部状态。另外,thread_local 对象的析构发生在线程退出时,它与 main 之后的静态析构孰先孰后没有规定,两者互相引用同样不可靠。

成员声明顺序不只决定初始化顺序,还决定内存布局

声明顺序是「双重身份」的:它既决定初始化顺序,也决定成员在内存里的排列,进而决定 padding 有多少。同样四个成员,换个顺序就多占 4 字节:

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <cstddef>
#include <cstdio>

struct Sloppy {      // 声明顺序没考虑对齐:char 夹在 int 中间,padding 变多
    int  a;
    char b;
    int  c;
    char d;
};

struct Tight {       // 同样 4 个成员,按对齐从大到小排,padding 最少
    int  a;
    int  c;
    char b;
    char d;
};

int main() {
    std::printf("sizeof(Sloppy) = %zu\n", sizeof(Sloppy));
    std::printf("sizeof(Tight)  = %zu\n", sizeof(Tight));
}
text 复制代码
sizeof(Sloppy) = 16
sizeof(Tight)  = 12

Sloppy 里 int a 占 4 字节,紧跟的 char b 只占 1 字节,但为了让后面的 int c 落在 4 字节边界上,中间要补 3 字节 padding,末尾再补 3 字节,4 个成员硬生生占掉 16 字节。把两个 int 排到前面,padding 只剩末尾 2 字节,总共 12 字节。成员越多、对齐跨度越大(如 double 与 char 混排),这个差距越明显。

但这里有个真实的取舍:重排成员会同时改掉初始化顺序 。如果成员之间存在初始化依赖(b 用 a 的值初始化),就不能为了省 padding 随意挪动位置。正确的做法不是硬改声明顺序,而是把依赖显式写进初始化列表(值本来就该由构造者提供),或者把强相关的成员合成一个子对象,让「紧凑布局」和「依赖关系」各自有明确的归属。为了 4 个字节把初始化顺序搞乱,怎么算都不划算。

把四条顺序叠在一起

下面一个程序同时覆盖「成员声明顺序 + 基类/派生 + 逆序析构 + virtual」,对照第 2 节的图食用:

cpp 复制代码
#include <iostream>
struct Logger {
    const char* n;
    Logger(const char* s) : n(s) { std::cout << "  [构造] " << n << "\n"; }
    ~Logger() { std::cout << "  [析构] " << n << "\n"; }
};
struct Base {
    Logger bmem{"基类成员"};
    Base() { std::cout << "Base 构造\n"; }
    virtual ~Base() { std::cout << "Base 析构\n"; }
};
struct Derived : Base {
    Logger dmem{"派生成员"};            // 声明在 Base 之后
    Derived() { std::cout << "Derived 构造\n"; }
    ~Derived() override { std::cout << "Derived 析构\n"; }
};
int main() {
    std::cout << "=== 构造阶段 ===\n";
    Derived d;                          // 基类成员 -> 基类体 -> 派生成员 -> 派生体
    std::cout << "=== 析构阶段(出作用域)===\n";
}  // 派生体 -> 派生成员 -> 基类体 -> 基类成员(完全逆序)
text 复制代码
=== 构造阶段 ===
  [构造] 基类成员
Base 构造
  [构造] 派生成员
Derived 构造
=== 析构阶段(出作用域)===
Derived 析构
  [析构] 派生成员
Base 析构
  [析构] 基类成员

延伸阅读

收个尾

成员按声明顺序初始化,基类先于派生类构造,析构则是构造的完全逆序。这三条是所有资源管理代码的地基,记牢了能省掉大量调试时间。

真正会在半夜把你叫醒的是另一条:只要存在「基类指针指向派生对象」的可能,基类析构就得标 virtual。漏了它,派生部分被悄悄跳过,程序还一脸正常地跑着,泄漏却一直在累积。

相关推荐
殷色玫瑰1 小时前
C/C++ 内存管理详解:从内存分布到 new/delete 底层原理
java·linux·c语言·c++
C++ 老炮儿的技术栈1 小时前
不一样的数值交换
数据结构·c++·人工智能·算法·c·csdn开发云
C++ 老炮儿的技术栈1 小时前
main函数之后的调用
c语言·c++·人工智能·qt·mfc·c
知识分享小能手2 小时前
C++ 学习教程,从入门到精通,C++ 友元、异常和其他特性 — 详细知识点(15)
开发语言·c++·学习
ebiobiz2 小时前
极海 APM32 使用 Nimmake 编译指南
c++·python·单片机·嵌入式硬件·mcu
朝朝辞暮i2 小时前
C++ 第 26 课:对象、指针与 ->
java·开发语言·c++
殷色玫瑰3 小时前
C++入门基础复习:从命名空间到引用与nullptr,一篇重新捡回C++基础
java·开发语言·c++
(Charon)4 小时前
【C++面试】手写线程池:任务队列、工作线程与优雅退出
开发语言·c++
无名猿4 小时前
拷贝构造与拷贝赋值:调用时机、深浅拷贝与复制消除
c++·内存管理·现代c++·踩坑记录