8

有人可以解释这个不同类的虚拟表是如何存储在内存中的吗?当我们使用指针调用函数时,他们如何使用地址位置调用函数?我们可以使用类指针获得这些虚拟表内存分配大小吗?我想看看一个类的虚拟表使用了多少内存块。我怎么能看到它?

class Base
{
public:
    FunctionPointer *__vptr;
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    virtual void function1() {};
};

class D2: public Base
{
public:
    virtual void function2() {};
};
int main()
{
    D1 d1;
    Base *dPtr = &d1;
    dPtr->function1();
}

谢谢!提前

4

5 回答 5

8

要记住的第一点是免责声明:标准实际上并没有保证这些。该标准说明了代码需要是什么样子以及它应该如何工作,但实际上并没有具体说明编译器需要如何实现这一点。

也就是说,基本上所有 C++ 编译器在这方面的工作方式都非常相似。

所以,让我们从非虚函数开始。它们分为两类:静态和非静态。

两者中较简单的是静态成员函数。静态成员函数几乎就像是类的全局函数friend,除了它还需要类的名称作为函数名称的前缀。

非静态成员函数稍微复杂一些。它们仍然是直接调用的普通函数——但它们被传递了一个隐藏指针,指向调用它们的对象的实例。在函数内部,您可以使用关键字this来引用该实例数据。因此,当您调用类似a.func(b);的代码时,生成的代码与您获得的代码非常相似func(a, b);

现在让我们考虑虚函数。这是我们进入 vtables 和 vtable 指针的地方。我们有足够的间接性,最好画一些图表来看看它是如何布局的。这几乎是最简单的情况:一个类的一个实例具有两个虚函数:

在此处输入图像描述

因此,该对象包含其数据和指向 vtable 的指针。vtable 包含一个指向该类定义的每个虚函数的指针。然而,为什么我们需要如此多的间接性可能不是很明显。为了理解这一点,让我们看看下一个(稍微有点)更复杂的情况:该类的两个实例:

在此处输入图像描述

请注意类的每个实例都有自己的数据,但它们都共享相同的 vtable 和相同的代码——如果我们有更多实例,它们仍然会在同一类的所有实例中共享一个 vtable。

现在,让我们考虑派生/继承。例如,让我们将现有类重命名为“Base”,并添加一个派生类。由于我感觉富有想象力,我将其命名为“衍生”。如上,基类定义了两个虚函数。派生类覆盖其中一个(但不是另一个):

在此处输入图像描述

当然,我们可以将两者结合起来,每个基类和/或派生类都有多个实例:

在此处输入图像描述

现在让我们更详细地研究一下。关于派生的有趣之处在于,我们可以将派生类对象的指针/引用传递给编写用于接收基类指针/引用的函数,它仍然有效——但如果你调用虚函数,你得到的是实际类的版本,而不是基类。那么,这是如何工作的呢?我们如何将派生类的实例视为基类的实例,并且仍然可以工作?为此,每个派生对象都有一个“基类子对象”。例如,让我们考虑这样的代码:

struct simple_base { 
    int a;
};

struct simple_derived : public simple_base {
    int b;
};

在这种情况下,当您创建 的实例时simple_derived,您会得到一个包含两个ints:a和的对象b。(a基类部分)位于内存中对象的开头,b(派生类部分)紧随其后。因此,如果您将对象的地址传递给期望基类实例的函数,它将用于基类中存在的部分,编译器将其放置在对象中与它们相同的偏移处' d 在基类的对象中,因此函数可以在不知道它正在处理派生类的对象的情况下操作它们。同样,如果你调用一个虚函数,它只需要知道 vtable 指针的位置。就它而言,类似Base::func1基本上只是意味着它遵循 vtable 指针,然后使用指向某个指定偏移量处的函数的指针(例如,第四个函数指针)。

至少现在,我将忽略多重继承。它给图片增加了相当多的复杂性(尤其是当涉及到虚拟继承时)并且您根本没有提到它,所以我怀疑您是否真的在乎。

至于访问其中的任何内容,或以任何方式使用,而不是简单地调用虚拟函数:您可能能够为特定的编译器提供一些东西——但不要指望它是可移植的。尽管调试器之类的东西经常需要查看这些东西,但所涉及的代码往往非常脆弱且特定于编译器。

于 2017-11-06T04:51:10.713 回答
4

虚拟表应该在类的实例之间共享。更准确地说,它存在于“类”级别,而不是实例级别。如果在它的层次结构中有虚函数和类,每个实例实际上都有一个指向虚拟表的指针的开销。

表本身至少是为每个虚函数保存一个指针所需的大小。除此之外,它是一个实现细节,它是如何实际定义的。在此处查看有关此问题的更多详细信息的 SO 问题。

于 2017-08-25T18:44:04.153 回答
3

首先,以下答案包含您想知道的有关虚拟表的几乎所有内容: https ://stackoverflow.com/a/16097013/8908931

如果您正在寻找更具体的东西(定期免责声明这可能会在平台、编译器和 CPU 架构之间发生变化):

  1. 需要时,正在为一个类创建一个虚拟表。该类将只有一个虚拟表的实例,并且该类的每个对象都有一个指针,该指针将指向该虚拟表的内存位置。虚拟表本身可以被认为是一个简单的指针数组。
  2. 当您将派生指针分配给基指针时,它还包含指向虚拟表的指针。这意味着基指针指向派生类的虚拟表。编译器会将此调用定向到虚拟表中的偏移量,该偏移量将包含派生类中函数的实际地址。
  3. 并不真地。通常在对象的开头,有一个指向虚拟表本身的指针。但这对您没有太大帮助,因为它只是一个指针数组,并没有真正表明它的大小。
  4. 简短回答很长:对于确切的大小,您可以在可执行文件(或从它加载到内存的段中)找到此信息。对虚拟表的工作原理有足够的了解,只要您了解代码、编译器和目标体系结构,就可以获得非常准确的估计。

    对于确切的大小,您可以在可执行文件中或在从可执行文件加载的内存段中找到此信息。可执行文件通常是 ELF 文件,这类文件包含运行程序所需的信息。该信息的一部分是各种语言结构的符号,例如变量、函数和虚拟表。对于每个符号,它包含它在内存中的大小。所以按钮行,您将需要虚拟表的符号名称,以及足够的 ELF 知识,以便提取您想要的内容。
于 2017-11-09T21:08:42.057 回答
1

Jerry Coffin 给出的答案很好地解释了虚函数指针如何在 C++ 中实现运行时多态性。但是,我相信它缺乏回答 vtable 存储在内存中的位置。正如其他人指出的那样,这不是由标准决定的。

但是,Martin Kysel有一篇出色的博客文章,其中详细介绍了虚拟表的存储位置。总结博客文章:

  1. 为每个具有虚函数的类(不是实例)创建一个 vtable。此类的每个实例都指向内存中的同一个 vtable
  2. 每个 vtable 都存储在生成的二进制文件的只读存储器中
  3. vtable 中每个函数的反汇编存储在生成的 ELF 二进制文件的文本部分中
  4. 尝试覆盖位于只读内存中的 vtable 会导致分段错误(如预期的那样)
于 2017-11-08T21:33:07.477 回答
-1

每个类都有一个指向函数列表的指针,它们对于派生类的顺序相同,然后被覆盖的特定函数在列表中的该位置发生变化。

当您使用基指针类型指向时,指向的对象仍然具有正确的 _vptr。

基地的

 Base::function1()
 Base::function2()

D1的

 D1::function1()
 Base::function2()

D2的

 Base::function1()
 D2::function2()

进一步派生的 drom D1 或 D2 只会将它们的新虚拟功能添加到 2 当前下方的列表中。

调用虚函数时我们只调用对应的索引,function1的索引为0

所以你的电话

 dPtr->function1();

实际上是

 dPtr->_vptr[0]();
于 2017-08-25T18:44:02.930 回答