ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/10/4 16:35:47 拓冰建站 浏览量
对象构造与析构顺序全解:声明顺序、继承链与逆序析构 「对象是怎么被拼出来、又怎么被拆掉的」是 C 里最容易想错的一件事偏偏它贯穿所有资源管理。一个常见的坑你在初始化列表里把成员b写在a前面就以为b先构造。其实不是先后只认声明顺序。再比如通过基类指针delete一个派生对象若基类析构不是virtual派生部分的析构函数压根不会被调用。这篇把这几条顺序规则一条条用实跑代码钉死配上时序图半年后翻回来照着图就能复现。初始化列表顺序的错觉先看一段「看起来 b 先构造」的代码运行结果会颠覆直觉#includeiostreamstructMember{constchar*name;Member(constchar*n):name(n){std::cout构造成员 name\n;}};structOrder{Member a;// 声明顺序aMember b;// 声明顺序bMember c;// 声明顺序cOrder():c(c),b(b),a(a){}// 初始化列表故意写成 c,b,a};intmain(){std::cout开始构造 Order\n;Order o;std::coutOrder 构造结束\n;}开始构造 Order 构造成员 a 构造成员 b 构造成员 c Order 构造结束尽管初始化列表写的是c,b,a实际构造顺序仍是a,b,c。成员永远按声明顺序初始化初始化列表的顺序只影响「你给哪个成员传什么值」不影响先后。gcc 开-Wall会对此发-Wreorder警告别忽略它。官方文档成员初始化顺序核心规则一基类先于成员析构完全逆序构造顺序的总纲是先祖先后自己析构则是严格的逆序。构造自顶向下 析构自底向上完全逆序 ┌─────────────┐ ┌─────────────┐ │ 基类成员 │──┐ ┌─│ 派生类析构 │ │ 基类构造体 │ │ │ │ 派生成员 │ ├─────────────┤ │ │ ├─────────────┤ │ 派生成员 │ └───────▶│ │ 基类析构 │ │ 派生构造体 │ │ │ 基类成员 │ └─────────────┘ └─└─────────────┘把这几条规则压成一张速查表后面每一节都在验证表里的某一行场景构造顺序析构顺序派生类对象基类成员 → 基类构造体 → 派生成员 → 派生构造体派生析构体 → 派生成员 → 基类析构体 → 基类成员同一个类的多个成员按声明顺序与初始化列表书写顺序无关严格逆序同一作用域里的多个局部对象按声明出现的先后严格逆序数组T a[N]a[0]→a[N-1]a[N-1]→a[0]同一函数内的多个 static 局部对象各自首次执行到声明处main返回之后按构造逆序表达式里的临时对象在出现点构造整个表达式结束时逆序析构构造中途抛异常的对象只有已构造完成的部分已构造的部分逆序析构自身析构体不执行表里那条规律其实就一句构造从外到内、从基到派析构是构造的严格逆序。继承关系里基类子对象一定先于派生类构造出了作用域顺序整个翻转派生类析构先跑再调基类析构。#includeiostreamstructBase{Base(){std::coutBase 构造\n;}~Base(){std::coutBase 析构\n;}};structDerived:Base{Derived(){std::coutDerived 构造\n;}~Derived(){std::coutDerived 析构\n;}};intmain(){std::cout--- 进入 ---\n;Derived d;std::cout--- 离开 ---\n;}--- 进入 --- Base 构造 Derived 构造 --- 离开 --- Derived 析构 Base 析构为什么析构必须逆序因为派生类可能用到基类子对象里的资源只有等派生类「用完」了基类才能安全拆。这条规则对所有嵌套对象都成立。核心规则二局部对象出作用域逆序析构同一个作用域里 successive 声明的多个对象构造按出现顺序析构按逆序后构造的先拆类似栈#includeiostreamstructL{constchar*n;L(constchar*s):n(s){std::cout局部对象 n 构造\n;}~L(){std::cout局部对象 n 析构\n;}};voidscope(){La(a);Lb(b);Lc(c);std::coutscope 内a,b,c 已构造\n;}// 逆序析构c,b,aintmain(){scope();std::coutscope 已返回\n;}局部对象 a 构造 局部对象 b 构造 局部对象 c 构造 scope 内a,b,c 已构造 局部对象 c 析构 局部对象 b 析构 局部对象 a 析构 scope 已返回这保证了「后进场、先退场」若b依赖a活着等b走了a才走依赖关系不会被破坏。核心规则三静态局部对象在 main 之后逆序析构函数内的static局部对象有两点反直觉① 第一次执行到它时才构造不是程序启动② 它的析构发生在main返回之后且多个静态对象按「构造的逆序」析构。#includeiostreamstructS{constchar*n;S(constchar*s):n(s){std::cout静态局部 n 构造\n;}~S(){std::cout静态局部 n 析构\n;}};voidf(){staticSs1(s1);staticSs2(s2);}voidg(){staticSs3(s3);}intmain(){std::coutmain 开始\n;f();// 构造 s1, s2g();// 构造 s3std::coutmain 结束之后才析构静态对象\n;}main 开始 静态局部 s1 构造 静态局部 s2 构造 静态局部 s3 构造 main 结束之后才析构静态对象 静态局部 s3 析构 静态局部 s2 析构 静态局部 s1 析构注意s3最后构造、却最先析构印证「逆序」。这种对象适合做单例、日志器之类「活到程序结束」的资源。官方文档静态局部变量生命周期关键陷阱经基类指针删除基类析构必须是 virtual当用基类指针指向派生对象并delete时如果基类析构不是virtualC 只认指针的静态类型。它只调基类析构派生类的析构函数被整个跳过派生部分持有的资源文件、锁、内存全部泄漏而且这是未定义行为。这种泄漏当时看不出来往往要压测跑上几个小时才浮出水面。// 反例不要这么写基类析构非 virtual经基类指针 delete 派生对象structBase{~Base(){std::puts(Base 析构);}};// 没有 virtualstructDerived:Base{~Derived(){std::puts(Derived 析构);}};Base*pnewDerived();deletep;// 只调 Base 析构Derived 析构 永不执行 - 派生部分泄漏UB正确写法基类析构标virtual派生类用overridedelete会沿虚表找到最派生析构再自动逆序回退调基类析构#includeiostreamstructBase{Base(){std::coutBase 构造\n;}virtual~Base(){std::coutBase 虚析构\n;}// 关键virtual};structDerived:Base{Derived(){std::coutDerived 构造\n;}~Derived()override{std::coutDerived 析构\n;}};intmain(){Base*pnewDerived();// 基类指针指向派生对象deletep;// virtual 析构 - 先 Derived 后 Base正确释放}Base 构造 Derived 构造 Derived 析构 Base 虚析构官方文档C Core Guidelines C.35有虚函数的基类析构函数应为 public 且 virtual或 protected 且非 virtual。边界情况构造中途抛异常已构造的成员仍会被析构如果对象构造到一半就抛了异常那么这个对象「从未存在过」它自己的析构函数不会被调用。但已经构造完成的那部分基类子对象、已经初始化好的成员都算独立构造完成的对象编译器会把它们逐个逆序销毁。这条规则是「部分构造也能安全清理」的全部依据// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includecstdio#includestdexceptstructPart{constchar*n;Part(constchar*s):n(s){std::printf(构造 %s\n,n);}~Part(){std::printf(析构 %s\n,n);}};structJob{Part a{成员a};Part b{成员b};Part c{成员c};explicitJob(intfail_at){std::printf(Job 构造体开始\n);if(fail_at2)throwstd::runtime_error(构造失败);std::printf(Job 构造体结束\n);}~Job(){std::printf(~Job\n);}};intmain(){try{Jobj(1);(void)j;}catch(conststd::exceptione){std::printf(捕获: %s\n,e.what());}std::printf(---- 上面那个 Job 对象从未完整存在过 ----\n);Jobok(3);(void)ok;}构造 成员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 省心的地方。已经构造完成的成员一定会被逆序析构所以成员对象不必关心「构造我的那个对象有没有构造完」各自的清理职责是独立的。这条规则还解释了为什么移动构造要标noexceptstd::vector扩容时若元素的移动构造可能抛异常容器为了保证强异常安全出错时原数据完好只能退而求其次逐元素拷贝而不是移动。一个noexcept写少了性能就从移动退化成了拷贝。两个销毁顺序陷阱加一个内存视角全局 / 静态对象的销毁顺序跨翻译单元同样「未指定」构造顺序跨翻译单元translation unit即单个.cpp未指定销毁顺序同样未指定。标准只保证「同一翻译单元内按构造的逆序」。于是「在全局对象的析构函数里访问另一个.cpp里的全局对象」就很危险对方可能已经被销毁了。这和静态初始化顺序问题SIOF是同一个根源的两个方向构造时可能读到「还没初始化」的对象析构时可能读到「已经销毁」的对象。修法也一样把跨对象的依赖收进函数内静态局部对象首次使用才构造或者干脆让析构函数不依赖任何外部状态。另外thread_local对象的析构发生在线程退出时它与main之后的静态析构孰先孰后没有规定两者互相引用同样不可靠。成员声明顺序不只决定初始化顺序还决定内存布局声明顺序是「双重身份」的它既决定初始化顺序也决定成员在内存里的排列进而决定 padding 有多少。同样四个成员换个顺序就多占 4 字节// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includecstddef#includecstdiostructSloppy{// 声明顺序没考虑对齐char 夹在 int 中间padding 变多inta;charb;intc;chard;};structTight{// 同样 4 个成员按对齐从大到小排padding 最少inta;intc;charb;chard;};intmain(){std::printf(sizeof(Sloppy) %zu\n,sizeof(Sloppy));std::printf(sizeof(Tight) %zu\n,sizeof(Tight));}sizeof(Sloppy) 16 sizeof(Tight) 12Sloppy里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 节的图食用#includeiostreamstructLogger{constchar*n;Logger(constchar*s):n(s){std::cout [构造] n\n;}~Logger(){std::cout [析构] n\n;}};structBase{Logger bmem{基类成员};Base(){std::coutBase 构造\n;}virtual~Base(){std::coutBase 析构\n;}};structDerived:Base{Logger dmem{派生成员};// 声明在 Base 之后Derived(){std::coutDerived 构造\n;}~Derived()override{std::coutDerived 析构\n;}};intmain(){std::cout 构造阶段 \n;Derived d;// 基类成员 - 基类体 - 派生成员 - 派生体std::cout 析构阶段出作用域\n;}// 派生体 - 派生成员 - 基类体 - 基类成员完全逆序 构造阶段 [构造] 基类成员 Base 构造 [构造] 派生成员 Derived 构造 析构阶段出作用域 Derived 析构 [析构] 派生成员 Base 析构 [析构] 基类成员延伸阅读cppreference · 构造函数初始化顺序声明顺序、初始化列表、委托构造的权威说明。cppreference · 析构函数何时被调用、逆序规则。C Core Guidelines · C.35/C.128基类析构为何要 virtual、派生为何用 override。isocpp.org FAQ · 构造与析构顺序数组元素、成员、基类的销毁次序。收个尾成员按声明顺序初始化基类先于派生类构造析构则是构造的完全逆序。这三条是所有资源管理代码的地基记牢了能省掉大量调试时间。真正会在半夜把你叫醒的是另一条只要存在「基类指针指向派生对象」的可能基类析构就得标virtual。漏了它派生部分被悄悄跳过程序还一脸正常地跑着泄漏却一直在累积。