构造与析构的“隐藏代码“

发布时间:2026/9/29 11:21:25
构造与析构的“隐藏代码“ ① 钩子你写的两行编译器生成了四行你写Outer() : a(1), b(2) {}只写了两行初始化列表编译器却生成了两个成员构造调用 两个析构调用而且析构顺序还和构造完全相反。更隐蔽的是Outer dst src;这一句拷贝编译器偷偷生成了一段逐成员复制的代码——你从没写过它。这一集我们关掉优化-O0把编译器插进你代码里的隐藏代码全部照出来。为什么要-O0因为 E01/E19 讲过-O2会把小函数内联抹平——隐藏调用会被优化没了反而看不见。想看隐藏调用就用-O0想看真实性能就用-O2这是一体两面。② 源码 vs 汇编对照structInner{intv;Inner(intx):v(x){}~Inner(){}};structOuter{Inner a;Inner b;Outer():a(1),b(2){}// 你只写了两行初始化列表~Outer(){}};voidf(){Outer o;// 离开作用域 → 析构(void)o;}voidcopy_test(Outersrc){Outer dstsrc;// 拷贝构造编译器隐式生成(void)dst;}Outer::Outer()—— 构造按声明顺序调用成员构造Outer::Outer(): call Inner::Inner(a, 1) ; 先构造 a偏移 0 addq $4, %rax ; 跳到下一个成员 call Inner::Inner(b, 2) ; 再构造 b偏移 4Outer::~Outer()—— 析构与构造完全相反的顺序Outer::~Outer(): addq $4, %rax call Inner::~Inner(b) ; 先析构 b call Inner::~Inner(a) ; 再析构 af()—— 编译器自动插入构造/析构调用f(): call Outer::Outer(o) ; 进入作用域构造 ... call Outer::~Outer(o) ; 离开作用域析构你从没写这句copy_test()—— 隐式拷贝构造 逐成员复制copy_test(Outer): movq (%rax), %rax ; a.v 和 b.v 连续一次搬 8 字节 ... call Outer::~Outer ; 副本离开作用域也自动析构③ 为什么这么设计成员构造顺序 声明顺序不是初始化列表的顺序析构顺序正好相反。规则一句话先构造的后析构——这样才能保证析构一个对象时它依赖的东西还活着。编译器的工作构造函数体真正执行之前先插入按顺序构造每个成员的代码析构函数体之后对称地插入按逆序析构每个成员的代码。你只声明了成员构造/析构的真实形态由编译器决定。隐式拷贝构造 逐成员复制这里a、b在内存里连续编译器把它合并成一条 8 字节的movq。如果成员是裸指针、数组就会生成对应大小的复制。为什么 -O0 才能看到-O2 下这些非虚小调用全部内联隐藏代码消失进调用者了。这正是 E01 的镜像教训——想看隐藏调用就用 -O0想看真实性能用 -O2。④ 深挖一构造顺序的完整链条一个对象的构造编译器按这个顺序生成代码虚基类最优先E09 详述直接基类按声明顺序成员对象按声明顺序构造函数体你写的{}里的代码。析构完全镜像析构函数体成员对象逆序直接基类逆序虚基类最后。这个链条的工程含义初始化列表的顺序不影响执行顺序——执行永远按声明顺序。如果初始化列表把b写在a前面但声明里a在前编译器警告-Wreorder但仍按声明顺序先构造a。所以初始化列表顺序要和声明顺序一致否则用一个还没初始化的成员去初始化另一个的 bug 就会出现。异常安全如果构造中途抛出异常比如第 3 个成员构造 throw编译器自动析构已经构造好的部分后面的还没构造不析构。这就是析构顺序 构造逆序的另一个理由——保证每个被析构的对象都是完整构造的。⑤ 深挖二三/五法则的汇编理由Rule of Three / Five如果类里有裸指针管理堆资源必须自己写拷贝构造、拷贝赋值、析构C11 后还有移动构造、移动赋值。汇编层面的理由是隐式拷贝 逐成员复制 浅复制。structBad{int*p;Bad(intn):p(newint(n)){}~Bad(){deletep;}// 没写拷贝构造};Bada(5);Bad ba;// 编译器逐成员复制b.p a.p同一个指针// 析构时 a 和 b 都 delete 同一个 p → double freeBad b a;的隐式拷贝汇编就是movq (%rax), %rax; movq %rax, ...——把指针值复制不复制它指向的内容。结果a.p和b.p指向同一块堆内存两个对象析构时各自delete→双重释放double freeUB。正确的做法写拷贝构造深复制或禁用拷贝 delete、实现移动语义E12。需要自定义析构几乎总是需要自定义拷贝/移动的信号——这条经验法则的机器基础就是隐式拷贝只是搬指针。⑥ 常见误区误区 1“初始化列表顺序就是执行顺序”执行顺序是声明顺序列表顺序只是给谁传什么参数。编译器会-Wreorder警告列表与声明顺序不一致。误区 2“成员构造和析构没有成本”每个非平凡成员都是一次真实调用-O0下看得清清楚楚深继承树的对象析构是一长串调用。误区 3“ default的拷贝就是安全的”对含裸指针的类 default仍是浅复制照样 double free。误区 4“-O2下隐藏调用不存在了”不是不存在是被内联进调用者了。换成大对象、跨编译单元调用又会出现。成本从未消失只是换了个形态。误区 5“构造抛异常就不用管”编译器只自动析构已构造好的成员/基类你手动管理的资源new 了还没放进成员仍要自己处理RAII 容器/智能指针是答案E13 见。误区 6“ default的移动就是最快的” default移动对每个成员逐成员 move若成员是 POD移动退化为复制没省任何东西。真正的收益来自内部持有堆资源的成员如std::string/std::vector。⑦ 实战启示初始化列表顺序要和声明顺序一致初始化按声明顺序执行若列表顺序与声明相反可能出现用一个还没初始化的成员去初始化另一个的诡异 bug。深继承树的析构是串串每个有析构的基类/成员都要调用一次对象的销毁成本 沿继承链串起的一串析构调用。有裸指针就必须自己写拷贝/移动隐式拷贝是浅复制两个对象会指向同一块堆内存析构时 double free。这就是 Rule of Three / Five 的由来。临时对象的构造析构是隐藏流量每多一次不必要的拷贝/临时对象就多一串构造析构这也是 E05 里 RVO 帮你省掉的东西。用-O0 -S当隐藏代码显微镜想知道一个类到底有多少隐藏构造/析构调用-O0 -S一眼看穿。⑧ 扩展专题三临时对象的出生入死std::stringconcat(){std::string ahello;std::string ba, world;// a , world 生成临时 stringreturnb;// NRVO / 移动}a , world这一行如果不用-O2/RVO会经历构造临时stringa的拷贝 拼接用临时构造b拷贝或移动临时对象离开语句 → 析构return b再 NRVO/移动。一次拼接最多出现两次临时对象。现代 C 用移动语义把这些搬指针操作压到很低但临时对象本身的存在构造析构仍是隐藏流量。这也是为什么少造临时对象如reserve、emplace_back是性能优化常客——E14 讲容器时你会看到emplace省掉的正是这些临时对象的构造析构。⑨ 扩展专题四编译期就看到隐藏代码的两种姿势除了-O0 -S还有两个工具能可视化隐藏代码-fdump-tree-originalGCC输出 GCC 前端展开后的原始中间表示——你会看到编译器把Outer() : a(1), b(2)展开成了对Inner::Inner的显式调用、把 default拷贝展开成逐成员复制。这是源码到 IR层面的隐藏代码。-fdump-tree-optimized-O2优化后的 IR能看到内联/省略之后还剩什么。实际用法g-O0-fdump-tree-original E07_ctor.cppcatE07_ctor.cpp.005t.original# 看展开后的调用序列这是比读汇编更接近源码的一层特别适合看构造函数/拷贝/析构到底展开了什么。⑩ 扩展 FAQQexplicit构造函数是干什么的A禁止隐式转换。void f(Inner x); f(5);若不 explicit5会隐式构造Inner隐藏构造调用explicit 后必须显式f(Inner(5))。它控制隐式构造这个隐藏行为是否允许发生。QOuter o;和Outer o{};有区别吗A有。若Outer没有默认构造前者编译错o{}是值初始化先零初始化再若有默认构造。对内置类型成员{}会保证清零裸()可能不。这是零初始化 vs 默认初始化的经典差异。Q为什么析构要 noexceptA析构默认 noexcept(true)。若析构能抛异常且异常在栈展开时再次抛出会std::terminate。所以析构里别让可能 throw 的操作逃逸E10 展开异常机制。Q delete和 default在汇编里有区别吗A default生成的是编译器默认实现逐成员复制 delete让该成员函数不存在——调用它的代码直接编译报错没有对应汇编。这是禁用 vs 生成的区别。Q成员按值初始化还是按声明初始化编译器怎么保证A中间表示里构造函数被显式展开成调用成员构造的语句序列顺序由声明顺序决定与初始化列表书写顺序无关。这就是-Wreorder能检测的原因。⑪ 扩展实验数调用g -O0 -S E07_ctor.cpp数f()里有几个call应有构造 析构各一。看顺序在Inner构造/析构里printf运行程序观察构造顺序a→b和析构顺序b→a的反转。触发 -Wreorder把初始化列表写成: b(2), a(1)编译看-Wreorder警告再反汇编确认仍按声明顺序构造。浅复制实验跑Bad的例子Bad b a;后析构——观察 double free或未定义行为的表现理解 Rule of Three。看临时对象-O0 -S编译一个std::string a x std::string(y);数临时 string 的构造/析构调用次数。⑬ 扩展专题五隐式拷贝/移动的逐成员到底是逐什么 default的拷贝构造编译器会按成员逐个复制但具体怎么复制取决于成员类型内置类型int、指针直接搬值一条mov类类型调用该成员的拷贝构造数组逐元素复制或合并成块拷贝引用成员拷贝构造会重新绑定引用不复制引用本身const 成员拷贝时给副本一个同值的新 const 成员。 default的移动构造则更讲究每个成员用std::move移动——内置类型还是搬值类类型调用其移动构造。这就是为什么移动对 POD 退化成复制E12 会看到 6 条 mov。重点隐式拷贝/移动的语义是逐成员而不是逐字节。对含std::string、std::vector的对象逐成员复制 复制每个成员string 会深拷贝内容或按 SSO 复制这才是安全的而memcpy整个对象是不安全的只搬内部指针。这也是trivially copyable 才能 memcpyE06 讲过的落地原因。⑭ 扩展专题六什么时候编译器会跳过析构调用不是所有对象离开作用域都会调用析构。有几个场景编译器会合法地省掉析构被省略elisionRVO 下临时对象直接被构造在最终位置那个临时对象的析构随之消失E05 已讲。对象本身 trivial 析构~Inner()如果什么都不做trivial编译器可以直接不生成调用——你在-O2下看到析构消失了多半是它 trivial。[[maybe_unused]] 没副作用优化器判断析构无可观察行为时整体消除。反过来必须调用的场景对象有非 trivial 析构、且生命周期跨越异常栈展开时析构一个都不能少。异常安全的核心就建立在这条保证析构必被调用的机制上E10 展开。一个实战联想析构里做日志/刷盘不可靠正因为析构可能被省略、可能在异常展开时被调、可能在abort时根本没机会跑。重要的收尾逻辑应该用显式函数调用如shutdown()而不是依赖析构的保证。⑮ 扩展专题七const与限定符在汇编里的形态C11 起成员函数可以带const//限定符structX{voidread()const;// 只读操作voidwrite();// 可写操作};这些限定符在汇编里几乎不改变调用形态——它们主要通过重载影响哪个函数被调用const对象只能调const版本非 const 对象调非 const 版本。反汇编里你会看到编译器生成两个同名但 mangling 不同的函数E23 讲 mangling 时你会看到_ZNK1X4readEv里的K就是 const 限定符。const限定符的真正价值不在快它不改变指令而在语义约束编译器能据此证明这个函数不改对象从而允许某些优化如缓存成员值。这是帮助编译器的写法的一例E19 详述。⑯ 扩展实验第二轮验证 trivial 析构被省把~Inner() {}改成不写析构trivial-O0 -S看析构调用是否消失。看 const 重载给X写void read() const;和void read();nm目标文件看两个 mangled 符号名不同K表示 const。模拟异常中途构造Inner构造第二个时throw看编译器生成的清理已构造部分的代码用-O0 -S会看到异常展开表与析构调用。数临时对象-O0 -S编译含std::string拼接的代码数构造/析构调用次数再-O2对比少了多少。⑱ 扩展专题八从隐藏代码看 RAII 的全部意义本集看到的编译器自动插入构造/析构调用正是 C 最强大的设计——RAIIResource Acquisition Is Initialization——的机器基础。RAII 的思想把资源的生命周期绑定到对象生命周期。voidprocess(constchar*path){std::ifstreamf(path);// 构造打开文件资源获取// ... 读文件可能 return / throw ...}// 析构关闭文件资源释放编译器保证调用如果中途return或throw析构照样执行栈展开会调用所有存活对象的析构E10 详述——所以文件一定被关。对比 C 风格fopen后必须手工fclose漏了就是资源泄露异常路径更是灾难。汇编层面process的开头有一次ifstream构造调用、函数所有出口包括异常路径都有析构调用。这就是编译器保证 RAII的具体形态。为什么 RAII 如此重要它把正确性从程序员记得变成编译器保证。文件、锁E16、智能指针E13、容器——一切资源都能用 RAII 管理。本系列后面你会在 E10异常、E13智能指针、E16锁里反复看到构造获取资源、析构释放资源这条主线。⑲ 扩展专题九构造/析构与虚函数的安全陷阱这是每个 C 开发者都要知道的经典陷阱在构造函数和析构函数里调用虚函数不会多态。structBase{Base(){f();}// 这里调的是 Base::f不是 Derived::fvirtualvoidf(){puts(Base);}};structDerived:Base{voidf()override{puts(Derived);}};Derived d;// 输出 Base不是 Derived汇编层面的解释构造函数执行时vptr 指向当前正在构造的类的 vtable。构造Derived时先执行Base的构造函数——此刻 vptr 指向Base的 vtable所以f()解析到Base::f。只有当Base构造完成后、进入Derived自己的构造阶段vptr 才被更新为指向Derived的 vtable。析构相反析构Derived时vptr 先指向Derived的 vtable调 Derived 部分等到析构Base部分时vptr 已切回Base的 vtable。所以构造/析构期间对象的真实类型是未完成的——vptr 的指向如实反映了构造到哪一层。这既是安全的保证不会调到还没构造好的派生类成员也是一个反直觉陷阱E08 会看 vptr 在汇编里的具体赋值时机。⑳ 扩展 FAQ第二轮Qstd::vectorOuter扩容时元素怎么构造/析构A扩容 新分配内存 移动/拷贝每个旧元素 析构旧元素 释放旧内存。每一次push_back导致扩容就有一串构造析构。这就是提前reserve能大幅提速的原因E14 详述。Q为什么编译器不警告隐藏的拷贝A拷贝本身合法只是可能不符合你的意图。想发现意外拷贝可以临时把拷贝构造 delete看哪里编译报错或开-Wdeprecated-copy等诊断。QOuter没有析构但成员有析构Outer析构还存在吗A存在——只要有一个非 trivial 成员编译器就会隐式生成~Outer()调用成员析构。“没有写析构不代表没有析构”。Q能不能让析构完全不生成A可以让所有成员和基类都 trivial 析构或把析构 delete那对象不能正常销毁只能placement new管理。Q构造顺序会影响性能吗A会。深层继承 大量成员的构造是一串调用减少继承深度、用平凡类型成员、reserve容器都能减少隐藏调用。㉑ 扩展实验第三轮看 vptr 赋值时机给Base/Derived写带虚函数的类-O0 -S看构造函数里 vptr 被写入 vtable 地址的那条指令通常形如movq $vtable, (%this)确认先置 vptr 再执行函数体。验证构造期虚调用运行上面Base/Derived的例子输出 “Base”——亲眼确认构造期无多态。数扩容构造次数std::vectorOuter逐个push_back并打印构造/析构日志观察扩容发生时元素被移动/拷贝的次数。RAII 实验写一个在构造函数fopen、析构fclose的类在函数中间throw确认文件仍被关闭析构在异常路径也执行。㉒ 悬念成员构造时如果类里有虚函数编译器还得先初始化一个隐藏指针——vptr。它指向的虚表里存着函数地址虚调用全靠它。下一集 E08我们解剖虚函数与虚表。。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询