request-free-img

C++ 类的析构函数底层实现原理:虚析构函数与内存释放机制详解

引言:带着疑问深入理解析构函数

C++类的析构函数是如何释放类对象占用的内存的?

为什么子类的析构函数能自动调用父类的虚析构函数?

今天带着这些疑问,我们一起来透视C++类析构函数的底层实现原理。

示例代码分析

直接看示例代码,在我们的示例代码中,有一个父类Base

它有一个构造函数,在构造函数中它申请一片内存

它还有一个虚析构函数,在虚析构 函数中它释放了内存

然后有一个叫Derived的子类,它继承自Base类,在Derived类中

它也有一个构造函数和一个析构函数

在main函数中,我们采用动态方式创建了一个Derived类的对象basePtr

然后释放了类对象

class Base {
public:
// 动态分配的内存
int* data;
Base() {
// 动态分配内存
data = new int[10];
std::cout << "基类构造函数:分配内存" << std::endl;
}
virtual ~Base()
{
delete[]data;
std::cout << "基类析构函数" << std::endl;
}
};

class Derived : public Base { public: // 派生类中额外的动态分配内存 int* extraData; Derived() { // 动态分配内存 extraData = new int[5]; std::cout << “子类构造函数:分配内存” << std::endl; } ~Derived() { delete []extraData; std::cout << “子类析构函数” << std::endl; } };

int main() { Base* basePtr = new Derived(); // 使用基类指针指向派生类对象 delete basePtr; // 销毁对象 return 1; }

汇编层面的调试与观察

我们在这里设置断点,然后进入汇编模式,

不要被这些晦涩的汇编代码给吓到了,跟着我一行行的来,你一定能看懂的

delete basePtr; // 销毁对象
00007FF630F11EC0 mov rax,qword ptr [basePtr]
00007FF630F11EC4 mov qword ptr [rbp+128h],rax
00007FF630F11ECB cmp qword ptr [rbp+128h],0
00007FF630F11ED3 je main+0B6h (07FF630F11EF6h)
00007FF630F11ED5 mov rax,qword ptr [rbp+128h]
00007FF630F11EDC mov rax,qword ptr [rax]
00007FF630F11EDF mov edx,1
00007FF630F11EE4 mov rcx,qword ptr [rbp+128h]
00007FF630F11EEB call qword ptr [rax]
00007FF630F11EED mov qword ptr [rbp+138h],rax
00007FF630F11EF4 jmp main+0C1h (07FF630F11F01h)
00007FF630F11EF6 mov qword ptr [rbp+138h],0

前边这几句是获取对象的地址,也就是basePtr指向的地址

真正调用析构函数的是这行

call qword ptr [rax]

虚函数表与析构函数调用机制

如果你看过我之前的视频你一定知道,第一个参数RCX传递的是类的this指针,没看过的建议翻回去再看看哈

这里可以看到,它还通过EDX将1 传给了析构函数作为第二个参数

这是为什么呢? 其实这个叫作删除标志deleting flag用于控制是否删除类对象占用的内存

标量删除析构函数(Scalar Deleting Destructor)

我们继续看代码,我们跟进去这个调用,看到来到了一个叫做scalar deleting destructor也就是标量删除析构函数中

标量删除析构函数是由编译器自动生成的特殊函数,它的作用就是通过 delete 销毁对象时,正确地执行析构并释放内存

我们继续跟进去,首先它保存edx传入的值,也就是1,然后保存rcx的值,也就是this指针

然后是一些寄存器的保护代码,以及分配用于函数局部变量的栈空间

接着它调用了子类Derived的析构函数Derived::~Derived

子类析构函数如何调用父类析构函数

我们跟进去看一下,前边都是子类的析构函数代码,我们跳过不看,直接来到最后这里

00007FF630F12461 mov rcx,qword ptr [this]
00007FF630F12468 call Base::~Base (07FF630F11348h)

看到了吗,这里首先将this指针传给rcx,然后调用父类的析构函数

也就是说子类析构函数调用完成后,之所以会自动调用父类的析构函数,其实只不过是编译器自动添加的代码

删除标志(Deleting Flag)的作用

我们再返回到子类的标量删除析构函数中去

接着重点来了哈,它将之前保存到局部变量中的1加载到eax

然后这3行的作用是,检查deleting_flag是否为0,如果是0就跳转到这个地址执行

如果deleting_flag是0就不会执行删除类内存,如果deleting_flag是1就会调用delete操作,将类占用的内存释放掉

设计原因与不同创建方式的差异

到这里就已经很明确了,

在子类的析构函数调用结束时,编译器会自动插入调用父类的析构函数的代码,使得父类的析构函数得到调用

同时,在子类的析构函数调用时,会传入一个删除标志deleting_flag,如果传入1就会将类占用的内存释放掉

如果是传入的是0,则不会删除类占用的内存

为什么要这样设计呢,因为其实析构函数本身也是一个函数,因此存在单独调用析构函数而不需要释放类占用的内存的情况

当然上边我们说的是动态创建类对象的情况下

如果是采用静态创建类对象时,那么它会直接调用子类的析构函数,再自动调用父类的析构函数。

而此时对象因为是保存在栈空间上的,作用域结束后,类占用的内存会自动释放

也就不存在需要调用标量删除析构函数的情况了

int main()
{
  Derived obj;
return 1;
}

总结思考

关于类的析构函数的底层原理,你现在明白了吗?

关键要点回顾:

  • 虚析构函数通过虚函数表实现多态析构
  • 编译器自动生成标量删除析构函数处理内存释放
  • 删除标志控制是否执行operator delete
  • 子类析构函数结束时编译器自动插入父类析构调用

更多问题探讨,请关注公众号:程序员角