如果我把一个基类的析构函数从非虚改为虚,会发生什么呢?

If I change the destructor of one base class from non-virtual to virtual, what will happen?

本文关键字:什么 析构函数 基类 一个 如果      更新时间:2023-10-16

我遇到一个基类,它的析构函数是非虚函数,尽管这个基类有一个虚函数fv()。这个基类也有许多子类。这些子类中有许多定义了自己的fv()

我不知道程序中如何使用基类和子类的细节。我只知道程序工作良好,即使基类的析构函数应该是虚的。

我想把基类的析构函数从非虚函数改为虚函数。但我不确定后果。那么,会发生什么呢?我还需要做些什么来确保程序在我更改后正常工作?

跟进:在我将基类的析构函数从非虚改为虚之后,程序在一个测试用例中失败了。
结果让我很困惑。因为如果基类的析构函数不是虚函数,那么程序就不会使用基类的多态性。因为如果不是,就会导致未定义行为。例如:Base *pb = new Sub
因此,我认为如果我将析构函数从非虚函数改为虚函数,应该不会导致更多的bug。

除非存在其他问题,否则析构函数的虚拟性不会破坏现有代码中的任何内容。它甚至可能解决一些问题(见下文)。然而,类可能不是被设计为多态的,所以将virtual添加到它的析构函数中使它成为可多态的,这可能是不可取的。然而,你应该能够安全地将虚拟性添加到析构函数中,并且它本身应该不会引起问题。

多态性允许这样做:

class A 
{
public:
    ~A() {}
};
class B : public A
{
    ~B() {}
    int i;
};
int main()
{
    A *a = new B;
    delete a;
}

您可以获取指向类的A类型对象的指针,该对象实际上是B类型。这对于拆分接口(如A)和实现(如B)是很有用的。但是delete a;会发生什么呢?

类型为A的对象a的部分被销毁。但是B类型的部分呢?另外,那部分有资源,需要释放。这就是内存泄漏。通过调用delete a;,您调用了A类型的析构函数(因为a是指向A类型的指针),基本上您调用了a->~a();。永远不会调用B类型的析构函数。如何解决这个问题?

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

通过在A的析构函数中添加虚拟分派(注意,通过声明基类析构函数为virtual,它会自动使所有派生类的析构函数为virtual,即使没有这样声明)。然后,对delete a;的调用将把析构函数的调用分派到虚表中,以找到要使用的正确析构函数(在本例中为B类型)。该析构函数将像往常一样调用父析构函数。整洁的,对吧?

可能出现的问题

如您所见,这样做本身不会破坏任何东西。然而,在您的设计中可能存在不同的问题。例如,可能存在一个"依赖于"通过将析构函数设置为虚函数而暴露的非虚函数调用的bug,请考虑:
int main()
{
    B *b = new B;
    A *a = b;
    delete a;
    b->i = 10; //might work without virtual destructor, also undefined behvaiour
}

基本上是对象切片,但由于在此之前没有虚拟析构函数,因此创建对象的B部分没有被销毁,因此对i的赋值可能有效。如果你把析构函数设为虚函数,那么它就不存在了,它可能会崩溃或做其他事情(未定义的行为)。

像这样的事情可能会发生,在复杂的代码中可能很难找到。但是如果你的析构函数在你把它设为虚函数之后导致了崩溃那么很可能在程序的某个地方就有这样的bug你一开始就有这样的bug因为我说过,把析构函数设为虚函数本身不能破坏任何东西

看这里,

struct Component {
    int* data;
    Component() { data = new int[100]; std::cout << "data allocatedn"; }
    ~Component() { delete[] data; std::cout << "data deletedn"; }
};
struct Base {
    virtual void f() {}
};
struct Derived : Base {
    Component c;
    void f() override {}
};
int main()
{
    Base* b = new Derived;
    delete b;
}
输出:

数据分配

但未删除

结论

当一个类层次结构有状态时,在纯粹的技术层面上,你需要一个从头到脚的虚析构函数。

一旦您向类中添加了虚析构函数,就可能触发了未经测试的析构逻辑。这里同样的选择是保留已添加的虚析构函数,并修复逻辑。否则,您将在进程中出现资源和/或内存泄漏。

技术细节

示例中发生的情况是,虽然Base虚函数表,但它的析构函数本身不是虚函数,这意味着无论何时调用Base::~Base(),它都不会经过vptr。换句话说,它只是调用Base::Base(),就这样。

main()函数中,分配了一个新的Derived对象并将其赋值给Base*类型的变量。当下一个delete语句运行时,它实际上首先尝试调用直接传递的类型(即Base*)的析构函数,然后释放该对象占用的内存。现在,由于编译器发现Base::~Base()不是虚函数,所以它不会尝试遍历对象dvptr。这意味着任何人都不会调用Derived::~Derived()。但是由于Derived::~Derived()是编译器生成Component Derived::c的销毁的地方,因此该组件也永远不会被销毁。因此,我们从来没有看到数据删除打印。

如果Base::~Base()是虚的,那么delete d语句将遍历对象dvptr,调用析构函数Derived::~Derived()。根据定义,该析构函数首先调用Base::~Base()(这是由编译器自动生成的),然后销毁其内部状态,即Component c。因此,整个销毁过程将按预期完成。

这显然取决于你的代码在做什么。

一般来说,只有在有类似 这样的用法时,才需要创建基类virtual的析构函数。
 Base *base = new SomeDerived;
    // whatever
 delete base;

Base中使用非虚析构函数会导致上述未定义的行为。使析构函数为虚函数可以消除未定义行为。

然而,如果你做了像

这样的事情
{   // start of some block scope
     Derived derived;
      //  whatever
}

则析构函数不必为虚函数,因为行为定义得很好(Derived的析构函数及其基类的调用顺序与构造函数相反)。

如果将析构函数从非virtual更改为virtual导致测试用例失败,那么您需要检查测试用例以了解原因。一种可能性是测试用例依赖于一些特定的未定义行为——这意味着测试用例是有缺陷的,在不同的情况下可能不会成功(例如,用不同的编译器构建程序)。但是,在没有看到测试用例(或代表它的MCVE)的情况下,我不愿意声称它确实依赖于未定义的行为

如果从基类派生的人改变了类资源的所有权策略,则可能会破坏一些测试:

struct A 
{ 
    int * data; // does not take ownership of data
    A(int* d) : data(d) {}
     ~A() { }
};
struct B : public A // takes ownership of data
{
    B(int * d) : A (d) {}
    ~B() { delete data; }
};

和用法:

int * t = new int(8);
{
    A* a = new B(t);
    delete a;
}
cout << *t << endl;

将A的析构函数设为虚函数将导致UB。不过,我不认为这样的用法可以称为良好的实践。

您可以"安全地"virtual添加到析构函数

如果调用等价的delete base,可以修复未定义行为(UB),然后调用正确的析构函数。如果子类析构函数有bug,那么您可以通过另一个bug更改UB。

可以改变的是类的布局类型。添加虚析构函数可以将类从标准布局类型更改为非标准布局类型。所以你所依赖的类是一个POD或标准布局类型,例如

  • 内存或围绕
  • 的内存类
  • 传递给C函数

为UB。

我只知道一个类型是

  • 用作基类
  • 有虚函数
  • 析构函数不是虚函数
  • 使析构函数虚化会破坏东西

,即对象符合外部ABI。Windows上的所有COM接口都满足这四个标准。这不是未定义的行为,而是关于虚拟调度机制的不可移植的特定于实现的保证。

不管你的操作系统是什么,它都归结为"单一定义规则"。除非根据新的定义重新编译使用该类型的每段代码,否则不能修改该类型。