理解虚析构函数

6

我一直在尝试熟悉面向对象编程(OOP)的概念,但是并没有完全理解virtual的概念。

  1. 我们可以创建一个virtual destructor但不能创建virtual constructor。为什么?
  2. virtual destructors是如何在内部处理的?我的意思是链接Virtual Destructors说明了这个概念,但是我的问题是如何调用两个vtable(Derived和Base)的vptr?(在虚成员函数的情况下,当出现这种情况时,通常只调用vptr指向的派生类的函数)
  3. 还有其他场景需要使用virtual destructor 吗?

请问有人可以帮助我理解上述概念,并提供链接/示例吗?


3
  1. 在C++中为什么没有虚拟构造函数?
  2. C++中Vptr和Vtable的机制是什么?
  3. 何时使用虚拟析构函数?
- Sufian Latif
6个回答

9
首先,让我们简单介绍一下虚函数和非虚函数之间的区别:
在代码中,每个非虚函数调用都可以在编译或链接期间解决。
通过“解决”,我们是指编译器或链接器可以计算出函数的地址。
因此,在创建的目标代码中,函数调用可以替换为跳转到内存中该函数地址的操作码。
使用虚函数,您可以调用只能在运行时解决的函数。
不解释了,让我们通过一个简单的场景来演示。
class Animal
{
    virtual void Eat(int amount) = 0;
};

class Lion : public Animal
{
    virtual void Eat(int amount) { ... }
};

class Tiger : public Animal
{
    virtual void Eat(int amount) { ... }
};

class Tigon : public Animal
{
    virtual void Eat(int amount) { ... }
};

class Liger : public Animal
{
    virtual void Eat(int amount) { ... }
};

void Safari(Animal* animals[], int numOfAnimals, int amount)
{
    for (int i=0; i<numOfAnimals; i++)
        animals[i]->Eat(amount);
    // A different function may execute at each iteration
}

如您所知,Safari 函数允许您灵活地喂不同的动物。
但由于每种动物的确切类型直到运行时才知道,因此要调用精确的 Eat 函数也是如此。
类的构造函数不能是虚拟的,因为:
调用对象的虚拟函数是通过对象类的 V-Table 实现的。
每个对象都持有指向其类的 V-Table 的指针,但该指针只在运行时创建对象时初始化。
换句话说,只有在调用构造函数时才会初始化该指针,因此构造函数本身不能是虚拟的。
除此之外,构造函数本身也没有必要是虚拟的。
虚拟函数的思想在于,可以在不知道调用它们的对象的确切类型的情况下调用它们。
当您创建对象(即隐式调用构造函数)时,您确切地知道正在创建的对象的类型,因此不需要此机制。
基类的析构函数必须是虚拟的,因为:
当您静态分配一个继承自基类的对象时,然后在函数结束时(如果对象是局部的)或程序结束时(如果对象是全局的),类的析构函数会自动调用,并依次调用基类的析构函数。
在这种情况下,析构函数是虚拟的事实上没有意义。
另一方面,当您动态分配(new)一个继承自基类的对象时,您需要在程序执行的某个后期点上动态地释放(delete)它。 delete 运算符接受指向对象的指针,该指针的类型可以是基类本身。
在这种情况下,如果析构函数是虚拟的,则 delete 运算符调用类的析构函数,然后依次调用基类的析构函数。
但是,如果析构函数不是虚拟的,则 delete 运算符仅调用基类的析构函数,并且实际类的析构函数永远不会被调用。
请考虑以下示例:
class A
{
    A() {...}
    ~A() {...}
};

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

void func()
{
    A* b = new B(); // must invoke the destructor of class 'B' at some later point
    ...
    delete b; // the destructor of class 'B' is never invoked
}

3
可以创建虚析构函数,但不能创建虚构造函数。为什么?
虚函数根据调用它们的对象类型进行分派。当调用构造函数时,没有对象 - 构造函数的工作是创建一个对象。如果没有对象,就不可能进行虚分派,因此构造函数不能是虚函数。
虚析构函数在内部如何处理?
虚分派的内部细节是实现定义的;语言不指定实现,只指定行为。通常,析构函数像任何虚函数一样通过vtable调用。
两个vtables(Derived和Base)的vptr如何调用?
只有最派生类的析构函数将被虚拟调用。所有析构函数,无论是否虚拟,都将隐式调用所有成员和直接基类子对象的析构函数。(在存在虚继承的情况下,情况稍微复杂; 但这超出了本问题的范围)。
还有其他场景需要使用虚析构函数吗?
您需要一个以支持多态删除; 也就是说,能够通过指向基本类型的指针删除派生类型的对象。如果没有基本类型的虚拟析构函数,那是不允许的,并且将导致未定义的行为。

通常情况下,析构函数与任何虚函数一样,都是通过虚表调用的。这似乎意味着一般情况下虚函数调用并不是实现定义的,但我记得它们确实是实现定义的。我相信这就是你的意思,但这句话有些含糊不清。 - rjnilsson
@Cwan:这可能意味着,如果我没有刚刚说“语言不指定实现,只指定行为”,并用词“通常”进行了限定。但是,为了防止其他人觉得含糊不清,我又加了更多的限定。 - Mike Seymour
是的,由于英语不是我的母语,我可能会犯错。但在我看来现在更清楚了。 - rjnilsson

2
  1. 因为虚函数是在运行时调用的,而构造函数是在初始化阶段调用的,对象还没有被构造。因此拥有虚构造函数是没有意义的。

  2. a. 在您的链接中只调用基类析构函数的原因是,析构函数没有标记为虚函数,因此在编译/链接时,析构函数地址链接到基类析构函数,显然指针类型在编译时是Base而不是Derived。

    b. 如果将Base析构函数添加虚关键字后,为什么同时调用了Base和Derived构造函数。这与以下行为相同: Derived d; //当d退出生命周期时,Derived和Base的析构函数都将被调用。

  3. 假设您至少有一个虚函数,则应该有一个虚析构函数。


2
可以创建虚拟析构函数,但不能创建虚拟构造函数。为什么?
我会用通俗易懂的语言解释这个问题。在C++中,一个类只有在构造函数完成后才存在。每个基类都存在于派生类及其成员初始化之前(包括虚函数表链接)。因此,创建虚拟构造函数没有意义(因为要构造,需要知道类型)。此外,在C++中,从构造函数调用虚函数是行不通的(因为派生类的虚函数表部分还没有设置)。如果仔细思考一下,允许从构造函数调用虚函数会引发一系列问题(例如,在成员初始化之前调用派生类的虚函数会怎样)。
至于析构函数,当销毁时,虚函数表是“完整的”,我们(C++运行时)完全了解类型(可以这么说)。找到类型最派生部分的析构函数(如果是虚拟的,则通过虚函数表),因此可以调用该析构函数以及所有基类的析构函数。
虚拟析构函数的内部处理方式与普通虚函数相同(也就是说,它们的地址在虚函数表中查找,如果它们是虚拟的,则会产生一种或者两种额外的间接级别的开销)。此外,C++保证在派生析构函数完成后,按照相反的构造顺序(依赖于声明顺序)执行所有基类析构函数。
可以通过使用原型模式(或克隆)或工厂来模拟虚拟构造。在这种情况下,要么存在实际类型的实例(以便进行多态性使用),要么存在一个工厂(从抽象工厂派生),该工厂根据提供的某些信息创建一个类型(通过虚函数)。
希望这可以帮助您。

0

假设我们有一个基类A,它派生出B。

1.:您可以通过A指针删除B,然后正确的方法是也调用B析构函数。 但是,您不能仅仅调用A构造函数时就说要创建一个B对象。这种情况是不存在的。 您可以这样说:

A* a = new B ();

或者

B b;

但是两者都直接调用B的构造函数。

2.:嗯,我不完全确定,但我猜它会迭代类层次结构的相关部分,并搜索最接近该函数调用的位置。如果一个函数不是虚函数,它就停止迭代并调用它。

3.:如果你想从那个类继承东西,你应该总是使用虚析构函数。如果它是一个最终类,你不应该这样做。


0

我浪费了几天的时间,试图发现为什么我的派生虚析构函数没有被调用,直到发现答案,希望我可以通过这个回复节省其他人很多痛苦。

在我的项目中,我开始使用三级和四级深度的派生类。虚函数似乎工作得很好,但后来我发现由于我的析构函数没有被调用,我有大量的内存泄漏。没有编译器或运行时错误 - 析构函数只是没有被调用。

网络上有大量关于此的文档和示例,但它们都没有用,因为我的语法是正确的。

我决定,如果编译器不打算调用我的析构函数,我需要创建自己的虚拟析构方法来调用。然后我得到了解决问题的编译器错误 - “类是前向引用”。在基类中添加派生类头文件的包含解决了这个问题。编译器需要类定义来调用析构函数!

我建议在创建新的派生类时,在基类和中间类中包含头文件。可能还有一个好主意是在您的析构函数中添加条件调试代码,以检查它们是否被调用。

Bob Rice


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接