找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5299

积分

0

好友

700

主题
发表于 昨天 19:10 | 查看: 4| 回复: 0

在使用 Godbolt 探究 C++ 汇编输出时,我发现了一个很有意思的现象:对于一个简单的 C++ 类,编译器竟然生成了两个析构函数。这难免让人困惑——源码中我们明明只写了一个析构函数,为什么汇编里会出现两个呢?

下面我们就逐步解释这一现象背后的原因,以及这两个析构函数各自的分工。

示例代码如下:

class Base {
public:
    Base() {};
    virtual ~Base() = default;
    virtual int print(){ }
};

当用 Clang 或 GCC 编译并查看汇编输出时,内容如下:

Base::~Base() [base object destructor]:
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], rdi
  mov edx, OFFSET FLAT:vtable for Base+16
  mov rax, QWORD PTR [rbp-8]
  mov QWORD PTR [rax], rdx
  nop
  pop rbp
  ret
  .set Base::~Base() [complete object destructor],Base::~Base() [base object destructor]
Base::~Base() [deleting destructor]:
  push rbp
  mov rbp, rsp
  sub rsp, 16
  mov QWORD PTR [rbp-8], rdi
  mov rax, QWORD PTR [rbp-8]
  mov rdi, rax
  call Base::~Base() [complete object destructor]
  mov rax, QWORD PTR [rbp-8]
  mov esi, 8
  mov rdi, rax
  call operator delete(void*, unsigned long)
  leave
  ret

那么问题来了:我们只写了一个析构函数,为什么生成了两个?

答案是 Itanium C++ ABI ——它定义了 C++ 在二进制层面如何实现虚函数、对象布局等机制。GCC 与 Clang 在多数类 Unix 系统上都遵循这一 ABI。

GCC follows the Itanium ABI

Starting with GCC 3.2, GCC binary conventions for C++ are based on a written, vendor-neutral C++ ABI that was designed to be specific to 64-bit Itanium ...

Itanium ABI 指定了不同的析构函数命名方式:

<ctor-dtor-name> ::= C1   # complete object constructor
                  ::= C2   # base object constructor
                  ::= C3   # complete object allocating constructor
                  ::= D0   # deleting destructor
                  ::= D1   # complete object destructor
                  ::= D2   # base object destructor

也就是说,许多编译器会为一个类生成两种不同用途的析构函数:一种用于销毁动态分配的对象(后文称为 删除析构函数 deleting destructor),另一种用于销毁非动态对象(后文称为 基对象析构函数 base object destructor,包括静态对象、局部对象、基类子对象或成员子对象)。前者会在内部调用 operator delete,后者则不会。部分编译器通过给其中一个析构函数添加隐藏参数来实现(旧版 GCC 和 MSVC++ 采用此方式),另一些编译器则会直接生成两个独立的析构函数(新版 GCC 就是这样)。

需要在析构函数内部调用 operator delete,这源于 C++ 规范的要求:必须选择合适的 operator delete,“如同” 是在最终派生类的(可能是虚的)析构函数内部查找得到的一样。因此,可实现为静态成员函数的 operator delete,其行为应当如同它是一个虚函数

大多数实现会 “严格” 遵循这一要求:它们不仅会在析构函数内部查找合适的 operator delete,还会直接在那里调用它。

当然,只有在销毁最终派生类对象、且该对象是动态分配时,才需要调用 operator delete。这也正是隐藏参数(或两种版本析构函数)存在的意义。

下面,我们通过简单示例加以说明。

首先是基对象析构函数,它主要用来销毁非动态对象:

{
  Base b;
}

其对应的汇编代码为:

call Base::~Base() [complete object destructor]

b 离开作用域时,会调用基对象析构函数。由于栈内存会自动回收,因此析构函数不会调用 operator delete

这类析构函数的职责是:

  • 销毁对象成员
  • 执行清理工作
  • 必要时调整 vtable 指针
  • 不会释放内存,因为其本身并非动态分配

再看下一个例子:

Base* b = new Base();
delete b;

继续看其汇编:

  call Base::~Base() [complete object destructor]
  mov rax, QWORD PTR [rbp-8]
  mov esi, 8
  mov rdi, rax
  call operator delete(void*, unsigned long)

该版本用于通过 delete 销毁对象。

其职责:

  • 调用基对象析构函数
  • 调用 operator delete 释放内存

在汇编输出中看到多个析构函数并非编译器错误或冗余,而是精心设计的 ABI 特性,它使 C++ 能够安全高效地支持多态删除。

这些底层细节正是编译器设计的精妙之处。如果你对这类话题感兴趣,欢迎加入云栈社区与广大开发者一同探讨。




上一篇:C++防御性编程:用void()防止逗号运算符重载
下一篇:C++ Lambda 避坑指南:7个容易踩雷的捕获细节(附 C++17/20 对比)
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-25 01:39 , Processed in 0.797391 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表