c++中的虚析构函数是如何工作的

How Does Virtual Destructor work in C++

本文关键字:工作 何工作 析构函数 c++      更新时间:2023-10-16

我将键入一个示例:

class A
{
public:
virtual ~A(){}
};
class B: public A
{
public:
~B()
{
}
};

int main(void)
{
A * a =  new B;
delete a;
return 0;
}

现在在上面的例子中,析构函数将被递归地从底向上调用。我的问题是编译器如何做这个魔术

在你的问题中有两个不同的魔术。第一个问题是编译器如何调用析构函数的最终重写,第二个问题是编译器如何依次调用所有其他析构函数。

免责声明:该标准没有规定执行此操作的任何特定方式,它只规定了更高级别操作的行为。这些是实现细节,对于不同的实现是通用的,但不是标准强制要求的。

编译器如何分派到最终的重写?

第一个答案很简单,用于其他virtual函数的动态分派机制也用于析构函数。为了刷新它,每个对象存储一个指针(vptr)到它的每个vtable s(在多重继承的情况下,可以有多个),当编译器看到对任何虚函数的调用时,它遵循指针的静态类型的vptr找到vtable,然后使用该表中的指针转发调用。在大多数情况下,调用可以直接调度,在其他情况下(多重继承),它调用一些中间代码(thunk),该代码将this指针固定为指向该函数的最终重写的类型。

编译器如何调用基类析构函数?

析构对象的过程比在析构函数体中编写的操作要多。当编译器为析构函数生成代码时,它会在用户定义的代码之前和之后添加额外的代码。

在调用用户定义析构函数的第一行之前,编译器会注入代码,使对象的类型与被调用的析构函数的类型相同。也就是说,在输入~derived之前,编译器添加代码,将vptr修改为引用derivedvtable,因此,对象的运行时类型有效地变为 derived (*)

在用户定义代码的最后一行之后,编译器注入对成员析构函数和基类析构函数的调用。这是通过禁用动态分派来执行的,这意味着它将不再一直到刚刚执行的析构函数。它相当于在析构函数的末尾为对象的每个基类加上this->~mybase();(与基类声明的顺序相反)。

对于虚拟继承,事情变得有点复杂,但总的来说它们遵循这个模式。

EDIT(忘记了(*)):(*)§12/3中的标准要求:

当从构造函数(包括从数据成员的初始化式)或析构函数直接或间接调用虚函数时,调用的对象是正在构造或析构的对象,调用的函数是在构造函数或析构函数自己的类或其基类中定义的函数,而不是在从构造函数或析构函数的类派生的类中覆盖它的函数。或者在最派生对象的其他基类中重写它。

该要求意味着对象的运行时类型是此时正在构造/析构的类的类型,即使正在构造/析构的原始对象是派生类型。验证此实现的一个简单测试可以是:

struct base {
   virtual ~base() { f(); }
   virtual void f() { std::cout << "base"; }
};
struct derived : base {
   void f() { std::cout << "derived"; }
};
int main() {
   base * p = new derived;
   delete p;
}

析构函数的处理方式与其他virtual函数相同。我注意到您已经正确地将基类的析构函数设置为virtual。因此,就动态调度而言,它与任何其他virtual函数没有任何不同。最派生的类析构函数通过动态分派调用,但它也自动导致调用类1的基类析构函数。

大多数编译器使用vtablevptr实现此特性,尽管语言规范没有强制要求它。可以有不同的编译器,不使用vtablevptr

无论如何,对于大多数编译器来说,知道vtable是什么是值得的。因此,vtable是一个包含类定义的所有虚函数指针的表,编译器将vptr添加到类中,作为指向正确的vtable隐藏指针,因此编译器在编译时对vtable使用正确的索引,从而在运行时调度正确的虚函数

<一口> 1。斜体文本取自@Als的注释。多亏了他。

编译器可能使用的(虚)析构函数的合适实现是(在伪代码中)

class Base {
...
  virtual void __destruct(bool should_delete);
...
};
void Base::__destruct(bool should_delete)
{
  this->__vptr = &Base::vtable; // Base is now the most derived subobject
  ... your destructor code ...
  members::__destruct(false); // if any, in the reverse order of declaration
  base_classes::__destruct(false); // if any
  if(should_delete)
    operator delete(this);  // this would call operator delete defined here, or inherited
}

即使没有定义析构函数,也会定义该函数。在这种情况下,您的代码将只是空的。

现在所有的派生类都将(自动)重写这个虚函数:

class Der : public Base {
...
  virtual void __destruct(bool should_delete);
...
};
void Der::__destruct(bool should_delete)
{
  this->__vptr = &Der::vtable;
  ... your destructor code ...
  members::__destruct(false);
  Base::__destruct(false);
  if(should_delete)
    operator delete(this);
}

调用delete x,其中x是指向类类型的指针,将被翻译为

x->__destruct(true);

和任何其他析构函数调用(由于变量超出作用域而隐式调用,显式调用x.~T())将是

x.__destruct(false);

结果是

  • 总是被调用的派生最多的析构函数(对于虚析构函数)
  • 操作符删除被调用的最派生的对象
  • 所有成员和基类的析构函数被调用。

HTH。如果您了解虚函数,这应该是可以理解的。

与虚函数一样,通常会有一些实现机制(如虚函数表指针)让编译器根据对象的类型找到首先运行哪个析构函数。一旦运行了最派生类的析构函数,它将依次运行基类析构函数,依此类推。

这取决于编译器如何实现它,通常使用与其他虚方法相同的机制。换句话说,析构函数没有什么特别之处,它需要与普通方法不同的虚拟方法分派机制。

虚析构函数和其他虚函数一样,在虚表中有一个表项。当析构函数被调用时——无论是手动调用还是自动调用delete——都会调用最派生的版本。析构函数还会自动调用其基类的析构函数,因此,与虚拟分派结合使用会导致魔术

与其他虚函数不同,当重写虚析构函数时,对象的虚析构函数在继承的虚析构函数之外还被调用

从技术上讲,这可以通过编译器选择的任何方式实现,但几乎所有编译器都通过称为虚表的静态内存实现,该静态内存允许函数和析构函数上的多态性。对于源代码中的每个类,都会在编译时为其生成一个静态常量虚函数表。当在运行时构造类型T的对象时,对象的内存用一个隐藏的虚值表指针初始化,该指针指向ROM中T的虚值表。虚值表内部是成员函数指针列表和析构函数指针列表。当具有虚值表的任何类型的变量超出作用域或被delete或delete[]删除时,对象所指向的虚值表中的所有析构函数指针都将被调用。(有些编译器选择只在表中存储派生最多的析构函数指针,然后在每个虚析构函数(如果存在)的函数体中包含对超类析构函数的隐藏调用。这将导致等效的行为。) 虚拟和非虚拟多重继承需要额外的魔力。假设我要删除一个指针p,其中p是基类的类型。我们需要用this=p来调用子类的析构函数。但是使用多重继承时,p和派生对象的起始点可能不一样!有一个固定的偏移量必须应用。对于每个继承的类,在虚函数表中存储一个这样的偏移量,以及一组继承的偏移量。

当你有一个指针指向一个对象时,它指向一个内存块,其中既有该对象的数据,也有一个'虚表指针'。在microsoft编译器中,虚函数表指针是对象中的第一个数据块。在Borland编译器中,它是最后一个。无论哪种方式,它都指向一个虚函数表,该虚函数表表示一组函数向量,这些函数向量对应于可以为该对象/类调用的虚方法。虚析构函数只是函数指针向量列表中的另一个向量。