「对象是怎么被拼出来、又怎么被拆掉的」是 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 析构
[析构] 基类成员
延伸阅读
- cppreference · 构造函数初始化顺序:声明顺序、初始化列表、委托构造的权威说明。
- cppreference · 析构函数:何时被调用、逆序规则。
- C++ Core Guidelines · C.35/C.128:基类析构为何要 virtual、派生为何用 override。
- isocpp.org FAQ · 构造与析构顺序:数组元素、成员、基类的销毁次序。
收个尾
成员按声明顺序初始化,基类先于派生类构造,析构则是构造的完全逆序。这三条是所有资源管理代码的地基,记牢了能省掉大量调试时间。
真正会在半夜把你叫醒的是另一条:只要存在「基类指针指向派生对象」的可能,基类析构就得标 virtual。漏了它,派生部分被悄悄跳过,程序还一脸正常地跑着,泄漏却一直在累积。