虚表和_vptr存储方案

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();
}

提前感谢您!


Wikipedia - Barmar
4
虚函数表(vtable)的具体实现方式取决于编译器,在标准库中没有访问它的标准方法。 - Barmar
那么,@Barmar,我们有没有办法访问vtable或查看vtable使用了多少内存? - Vineet Jain
2
没有任何可移植的方式。 - Barmar
你是否真的有兴趣在程序内部检查虚函数表(vtable)?例如,你是想在运行时改变它吗?还是只是想弄清楚虚函数表占用了多少空间,以评估在程序中使用虚函数的成本? - Ben S.
5个回答

10
首先需要注意的是免责声明:标准并不保证这一切。标准规定了代码应该长什么样子,应该如何工作,但并没有具体说明编译器需要如何实现它。
话虽如此,所有的C++编译器在这方面都非常相似。
所以,让我们从非虚函数开始。它们分为两类:静态和非静态。
其中简单的是静态成员函数。静态成员函数几乎就像是一个类的友元全局函数,只不过还需要类名作为函数名的前缀。
非静态成员函数稍微复杂一些。它们仍然是直接调用的普通函数,但是会传递一个指向调用它们的对象实例的隐藏指针。在函数内部,您可以使用关键字“this”来引用该实例数据。因此,当您调用像a.func(b)这样的东西时,生成的代码与您为func(a, b)获得的代码非常相似。
现在让我们考虑虚函数。这里涉及到虚表和虚表指针。我们有足够多的间接性,可能最好画一些图来看看它们是如何布局的。这里几乎是最简单的情况:一个类的一个实例有两个虚函数。

enter image description here

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

enter image description here

请注意每个类的实例都有自己的数据,但它们共享同一个虚表和相同的代码 - 如果我们有更多的实例,它们仍然会在所有同一类实例之间共享同一个虚表。
现在,让我们考虑派生/继承。例如,让我们将现有的类重命名为“Base”,并添加一个派生类。由于我感到有创意,我会将其命名为“Derived”。与上面一样,基类定义了两个虚函数。派生类覆盖其中一个(但不是另一个):

enter image description here

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

enter image description here

现在让我们更深入地探讨一下。派生的有趣之处在于,我们可以将指向派生类对象的指针/引用传递给一个函数,该函数被编写为接收指向基类的指针/引用--但如果调用虚函数,则会得到实际类的版本,而不是基类的版本。那么,这是如何工作的呢?我们如何将派生类的实例视为基类的实例,并使其正常工作?为了做到这一点,每个派生对象都有一个“基类子对象”。例如,让我们考虑以下代码:
struct simple_base { 
    int a;
};

struct simple_derived : public simple_base {
    int b;
};

在这种情况下,当您创建一个simple_derived的实例时,您将获得一个包含两个int的对象:aba(基类部分)位于内存中的对象开头,而b(派生类部分)紧随其后。因此,如果将对象的地址传递给期望基类实例的函数,则该函数仅使用存在于基类中的部分,编译器将它们放置在与基类对象中相同的偏移量处,因此函数可以操纵它们,甚至不知道它正在处理派生类的对象。同样,如果调用虚函数,所有它需要知道的就是虚表指针的位置。就它而言,像Base::func1这样的东西基本上只意味着它跟随虚表指针,然后使用从那里的某个指定偏移量(例如第四个函数指针)的函数指针。

至少目前为止,我将忽略多重继承。它给图景增加了相当多的复杂性(特别是涉及到虚继承时),而且您根本没有提到它,所以我怀疑您真的关心吗。

至于访问任何这些内容或以任何方式使用它们而不仅仅是调用虚函数:您可能能够为特定编译器想出一些东西,但不要指望它具有可移植性。尽管像调试器这样的东西经常需要查看这些东西,但涉及的代码往往非常脆弱且特定于编译器。


我认为你的带有Derived::vtable的图片是误导性的,因为它看起来好像你需要查找Base::vtable以获取func2,但是(正如你的文本描述的那样)func1和func2必须内联存储在Derived::vtable中。此外,该图片可能表明Derived::vtable中的func1/func2仍然可以指向A::*(如果Derived没有覆盖它)。 - Stefan
如果所有对象的vptr都指向同一个对象,那么为什么不将vptr作为整个类的一个变量呢? - ajaysinghnegi

4
虚函数表应该在类实例之间共享。更确切地说,它存在于“类”级别,而不是实例级别。如果在其层次结构中存在虚函数和类,则每个实例都有实际指向虚函数表的指针开销。
表本身至少具有容纳每个虚函数指针所需的大小。除此之外,如何定义它实际上是一项实现细节。请查看这里以获取有关此的更多详细信息。

3
首先,以下答案包含了你想了解的有关虚拟表的几乎所有内容: https://dev59.com/qm025IYBdhLWcg3wChKb#16097013 如果您正在寻找更具体的内容(通常需要注意平台、编译器和CPU架构之间的差异):
1. 当需要时,为类创建一个虚拟表。该类将只有一个虚拟表实例,并且该类的每个对象都将具有一个指针,该指针将指向此虚拟表的内存位置。虚拟表本身可以被视为简单的指针数组。
2. 当您将派生指针分配给基础指针时,它还包含指向虚拟表的指针。这意味着基础指针指向派生类的虚拟表。编译器将把此调用定向到虚拟表中的偏移量,该偏移量将包含来自派生类的实际函数地址。
3. 不是真的。通常,在对象的开头处,有一个指向虚拟表本身的指针。但是这对您没有太大帮助,因为它只是一个指针数组,没有实际大小的指示。
4. 简而言之,要获得精确的大小,您可以在可执行文件中找到此信息(或在从可执行文件加载到内存的段中找到)。可执行文件通常是一个ELF文件,这种文件包含运行程序所需的信息。其中一部分信息是各种语言结构的符号,例如变量、函数和虚拟表。对于每个符号,它都包含其在内存中占用的大小。因此,您将需要虚拟表的符号名称以及足够的ELF知识才能提取所需信息。

1
Jerry Coffin所提供的答案非常好,解释了虚函数指针如何实现C++中的运行时多态性。然而,我认为它在回答vtable存储在内存中的位置方面不足。正如其他人指出的那样,这并不是标准规定的。
然而,Martin Kysel撰写的博客文章非常出色,详细介绍了虚表存储的位置。总结一下博客文章:
1.为每个具有虚函数的类(而不是实例)创建一个vtable。该类的每个实例都指向内存中相同的vtable。 2.每个vtable都存储在生成的二进制文件的只读内存中。 3.每个vtable中的函数的反汇编代码存储在生成的ELF二进制文件的文本部分中。 4.尝试写入只读内存中的vtable会导致段错误(如预期的那样)。

-1
每个类都有一个指向函数列表的指针,对于派生类来说,它们的顺序相同,然后在列表中被覆盖的特定函数会在该位置发生变化。
当您使用基础指针类型进行指向时,所指向的对象仍然具有正确的_vptr。
基类的
 Base::function1()
 Base::function2()

D1的

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

D2的

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

从D1或D2进一步派生的类只会在当前两个虚函数列表下方添加它们的新虚函数。

调用虚函数时,我们只需调用相应的索引,function1将是索引0

因此,您的调用

 dPtr->function1();

实际上是

 dPtr->_vptr[0]();

你能指出标准中规定这一部分的章节吗? - Toby Speight

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