在使用 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++ 能够安全高效地支持多态删除。
这些底层细节正是编译器设计的精妙之处。如果你对这类话题感兴趣,欢迎加入云栈社区与广大开发者一同探讨。