虚拟类的每个对象都有一个指向虚函数表的指针吗?

13

每个虚类对象都有一个指向虚函数表的指针吗?

或者只有具有虚函数的基类对象才有?

vtable 存储在哪里?代码段还是进程的数据段?


重复?https://dev59.com/nHVD5IYBdhLWcg3wE3Ro - Anonymous
在C++中并不存在所谓的“虚拟类”。 - curiousguy
9个回答

19

所有具有虚方法的类都会拥有一个单独的vtable(虚函数表),这个vtable会被该类的所有对象共享。

每个对象实例都会有一个指向该vtable的指针(这就是如何找到vtable),通常称为vptr。编译器会隐式生成代码,在构造函数中初始化vptr。

注意,C++语言没有强制要求以上内容——如果愿意,实现可以使用其他方式处理虚函数调用。然而,这种实现方式是我熟悉的每个编译器都在使用的。Stan Lippman的书《深度探索C++对象模型》很好地描述了这个过程。


2
+1 请问为什么虚拟指针是每个对象而不是每个类?谢谢。 - Viet
1
@Viet 你可以将vPtr视为对象运行时定义的引导程序。只有在设置完vPtr之后,对象才能知道其实际类型。从这个角度来看,为每个类(静态)创建一个vPtr是没有意义的。 换句话说,如果一个对象不需要vPtr,那么它必须在编译时就已经了解其运行时定义,这与它被动态解析的对象相矛盾。 - Johnson Wong

14

正如其他人所说,C ++标准并不强制要求使用虚拟方法表,但允许使用。 我已经使用gcc和这段代码以及最简单的可能情况进行了测试:

class Base {
public: 
    virtual void bark() { }
    int dont_do_ebo;
};

class Derived1 : public Base {
public:
    virtual void bark() { }
    int dont_do_ebo;
};

class Derived2 : public Base {
public:
    virtual void smile() { }
    int dont_do_ebo;
};

void use(Base* );

int main() {
    Base * b = new Derived1;
    use(b);

    Base * b1 = new Derived2;
    use(b1);
}

添加数据成员是为了防止编译器将基类大小指定为零(这被称为空基类优化)。这是GCC选择的布局:(使用-fdump-class-hierarchy打印)

Vtable for Base
Base::_ZTV4Base: 3u entries
0     (int (*)(...))0
4     (int (*)(...))(& _ZTI4Base)
8     Base::bark

Class Base
   size=8 align=4
   base size=8 base align=4
Base (0xb7b578e8) 0
    vptr=((& Base::_ZTV4Base) + 8u)

Vtable for Derived1
Derived1::_ZTV8Derived1: 3u entries
0     (int (*)(...))0
4     (int (*)(...))(& _ZTI8Derived1)
8     Derived1::bark

Class Derived1
   size=12 align=4
   base size=12 base align=4
Derived1 (0xb7ad6400) 0
    vptr=((& Derived1::_ZTV8Derived1) + 8u)
  Base (0xb7b57ac8) 0
      primary-for Derived1 (0xb7ad6400)

Vtable for Derived2
Derived2::_ZTV8Derived2: 4u entries
0     (int (*)(...))0
4     (int (*)(...))(& _ZTI8Derived2)
8     Base::bark
12    Derived2::smile

Class Derived2
   size=12 align=4
   base size=12 base align=4
Derived2 (0xb7ad64c0) 0
    vptr=((& Derived2::_ZTV8Derived2) + 8u)
  Base (0xb7b57c30) 0
      primary-for Derived2 (0xb7ad64c0)

每个类都有一个虚函数表,你可以看到。前两个条目是特殊的。第二个指向类的RTTI数据。第一个 - 我知道它但忘记了。在更复杂的情况下它还是有些用处的。好吧,正如布局所示,如果你有一个Derived1类的对象,那么vptr(虚函数表指针)当然会指向Derived1类的虚函数表,其中仅有一个条目指向Derived1版本的函数bark。Derived2的vptr指向Derived2的虚函数表,其中有两个条目。另一个是它添加的新方法smile的条目。它重复了Base :: bark的条目,当然会指向Base版本的函数,因为它是最派生的版本。

我还使用-fdump-tree-optimized对GCC进行了一些优化(构造函数内联等)之后生成的树,输出使用GCC的中间端语言GIMPL,这是与前端无关的,并缩进为类似C的块结构:

;; Function virtual void Base::bark() (_ZN4Base4barkEv)
virtual void Base::bark() (this)
{
<bb 2>:
  return;
}

;; Function virtual void Derived1::bark() (_ZN8Derived14barkEv)
virtual void Derived1::bark() (this)
{
<bb 2>:
  return;
}

;; Function virtual void Derived2::smile() (_ZN8Derived25smileEv)
virtual void Derived2::smile() (this)
{
<bb 2>:
  return;
}

;; Function int main() (main)
int main() ()
{
  void * D.1757;
  struct Derived2 * D.1734;
  void * D.1756;
  struct Derived1 * D.1693;

<bb 2>:
  D.1756 = operator new (12);
  D.1693 = (struct Derived1 *) D.1756;
  D.1693->D.1671._vptr.Base = &_ZTV8Derived1[2];
  use (&D.1693->D.1671);
  D.1757 = operator new (12);
  D.1734 = (struct Derived2 *) D.1757;
  D.1734->D.1682._vptr.Base = &_ZTV8Derived2[2];
  use (&D.1734->D.1682);
  return 0;    
}

正如我们可以清晰地看到的那样,它只是设置一个指针 - vptr - 它将指向我们在创建对象时看到的适当的vtable。我还使用c++filt工具对其中的名称进行了解码,并转储了Derived1的创建和调用use的汇编代码($4是第一个参数寄存器,$2是返回值寄存器,$0始终为零寄存器):)

      # 1st arg: 12byte
    add     $4, $0, 12
      # allocate 12byte
    jal     operator new(unsigned long)    
      # get ptr to first function in the vtable of Derived1
    add     $3, $0, vtable for Derived1+8  
      # store that pointer at offset 0x0 of the object (vptr)
    stw     $3, $2, 0
      # 1st arg is the address of the object
    add     $4, $0, $2
    jal     use(Base*)

如果我们想调用 bark 会发生什么?:

void doit(Base* b) {
    b->bark();
}

GIMPL代码:

;; Function void doit(Base*) (_Z4doitP4Base)
void doit(Base*) (b)
{
<bb 2>:
  OBJ_TYPE_REF(*b->_vptr.Base;b->0) (b) [tail call];
  return;
}

OBJ_TYPE_REF是GIMPL结构体,它被漂亮地打印出来(在gcc SVN源代码的gcc/tree.def中有文档记录)。

OBJ_TYPE_REF(<first arg>; <second arg> -> <third arg>)

这意味着:在对象 b 上使用表达式 *b->_vptr.Base,并存储前端 (c++) 特定值 0(它是 vtable 的索引)。最后,将 b 作为“this”参数传递。如果我们调用出现在 vtable 中第二个索引处的函数(注意,我们不知道是哪种类型的 vtable!),则 GIMPL 将如下所示:

OBJ_TYPE_REF(*(b->_vptr.Base + 4);b->1) (b) [tail call];

当然,这里是汇编代码(堆栈帧截断):

  # load vptr into register $2 
  # (remember $4 is the address of the object, 
  #  doit's first arg)
ldw     $2, $4, 0
  # load whatever is stored there into register $2
ldw     $2, $2, 0
  # jump to that address. note that "this" is passed by $4
jalr    $2

请记住,vptr 恰好指向第一个函数。 (在该条目之前,存储了 RTTI 插槽)。 因此,无论出现什么在该插槽中被称为。 它也标记调用为尾调用,因为它发生在我们的 doit 函数的最后一个语句。


4

在家尝试一下:

#include <iostream>
struct non_virtual {}; 
struct has_virtual { virtual void nop() {} }; 
struct has_virtual_d : public has_virtual { virtual void nop() {} }; 

int main(int argc, char* argv[])
{
   std::cout << sizeof non_virtual << "\n" 
             << sizeof has_virtual << "\n" 
             << sizeof has_virtual_d << "\n";
}

1
从必要的结论中得出结论是“留给OP作为任务”的;) - dirkgently
这些数字是典型的,但不是必需的。它并没有说明有多少个vtable存在,或者这4个字节花在了什么地方。 - jalf
在64位机器上,它将是1、8、8。这可能会让人有点困惑,因为空结构的大小为1字节,而另外两个结构体分别包含一个指针(在64位机器上为8字节)。 - Anoop

4

Vtable(虚函数表)是每个类的实例,即如果我有一个类的10个对象,其中有一个虚方法,则只有一个vtable,它在所有10个对象之间共享。

在这种情况下,所有10个对象指向相同的vtable。


关于Vptr,每个对象会有10个与之相关联的vptr,还是像单个vtable一样只有一个vptr? - Rndp13
如果vptr也与类相关联而不是对象,那么在运行时如何解析对重写函数的调用?而且,既然我们已经在vtable中拥有所有所需信息,那么在这里也有vptr的意义何在?@Rndp13 - Elmar Zander

2
为了回答哪些对象(从现在开始称为实例)具有虚函数表以及它们位于何处的问题,有助于思考何时需要虚函数表指针。
对于任何继承层次结构,您需要为该层次结构中特定类定义的每组虚函数都需要一个虚函数表。换句话说,给定以下内容:
class A { virtual void f(); int a; };
class B: public A { virtual void f(); virtual void g(); int b; };
class C: public B { virtual void f(); virtual void g(); virtual void h(); int c; };
class D: public A { virtual void f(); int d; };
class E: public B { virtual void f(); int e; };

结果是,您需要五个虚表:A、B、C、D 和 E 都需要自己的虚表。
接下来,您需要知道在给定指向特定类的指针或引用时使用哪个虚表。例如,给定对 A 的指针,您需要了解关于 A 的布局足够多,以便获取一个虚表,告诉您在哪里分派 A::f()。给定对 B 的指针,您需要了解关于 B 的布局足够多,以便分派 B::f() 和 B::g()。以此类推。
一种可能的实现方法是将虚表指针作为任何类的第一个成员。这意味着 A 的实例的布局将是:
A's vtable;
int a;

一个B类的实例会是这样:

A's vtable;
int a;
B's vtable;
int b;

您可以从这个布局中生成正确的虚拟分派代码。

您还可以通过组合具有相同布局或其中一个是另一个子集的vtables的vtable指针来优化布局。因此,在上面的示例中,您也可以将B布局为:

B's vtable;
int a;
int b;

由于B的虚函数表是A的超集。B的虚函数表中有A::f和B::g的条目,而A的虚函数表中有A::f的条目。

为了完整起见,这是目前我们所见过的所有虚函数表的布局:

A's vtable: A::f
B's vtable: A::f, B::g
C's vtable: A::f, B::g, C::h
D's vtable: A::f
E's vtable: A::f, B::g

实际的条目将会是:

A's vtable: A::f
B's vtable: B::f, B::g
C's vtable: C::f, C::g, C::h
D's vtable: D::f
E's vtable: E::f, B::g

对于多重继承,您需要进行相同的分析:

class A { virtual void f(); int a; };
class B { virtual void g(); int b; };
class C: public A, public B { virtual void f(); virtual void g(); int c; };

最终的布局如下:

A: 
A's vtable;
int a;

B:
B's vtable;
int b;

C:
C's A vtable;
int a;
C's B vtable;
int b;
int c;

您需要一个指向与A兼容的vtable的指针和一个指向与B兼容的vtable的指针,因为对C的引用可以转换为对A或B的引用,并且您需要将虚函数分派给C。
从这里可以看出,特定类所拥有的vtable指针数量至少是它从根类(直接或由于超类)继承的根类数。根类是具有不继承自另一个也具有vtable的类的vtable的类。
虚继承会在混合中增加另一位间接性,但您可以使用相同的度量标准来确定vtable指针的数量。

请在您给答案投反对票时指出其中的问题所在。否则,我们无法改进内容!谢谢。 - Validus Oculus

2
一个 VTable 是一个实现细节,语言定义中没有提到它的存在。事实上,我已经阅读过有关实现虚函数的替代方法的文章。
但是,所有常见的编译器(即我知道的那些)都使用 VTables。
因此,任何具有虚方法或从具有虚方法的类(直接或间接)派生的类都将具有指向 VTable 的指针的对象。
您提出的所有其他问题都取决于编译器/硬件,这些问题没有真正的答案。

1

所有虚拟类通常都有一个虚函数表,但这并不是C++标准所要求的,存储方式取决于编译器。


0
每个多态类型的对象都会有一个指向虚函数表的指针。
虚函数表存储位置取决于编译器。

0

不一定

几乎每个具有虚函数的对象都将有一个虚表指针。并不需要为每个具有虚函数的类派生出来的对象都有一个虚表指针。

新的编译器可以充分分析代码,在某些情况下可能能够消除虚表。

例如,在一个简单的情况下:如果您只有一个抽象基类的具体实现,编译器知道它可以将虚调用更改为常规函数调用,因为无论何时调用虚函数,它都将始终解析为完全相同的函数。

另外,如果只有几个不同的具体函数,编译器可以有效地更改调用站点,以便使用“if”选择正确的具体函数进行调用。

因此,在这种情况下,并不需要虚表,对象可能最终没有虚表。


嗯,我刚刚一直在尝试寻找一个可以进行虚表指针消除的编译器。看起来目前还没有这样的编译器。但是,编译器和链接器之间的信息共享正在变得越来越高,以至于它们正在合并。随着不断的发展,这可能会发生。 - Scott Langham
这可能是因为实际上消除vptr将意味着严重违反ABI - 这将需要确保任何该类的对象永远不会在模块外部看到 - 仅为了4个字节的内存,这些内存可能实际上甚至不会被保存。 - jpalecek
另一方面,仅仅不通过虚拟调度调用方法只会破坏特定方法的接口,编译器可以通过发出另一个带有完整虚拟调度的代码版本来解决这个问题。这也带来了更大的优势,特别是如果该函数可以被内联。 - jpalecek
是的,看看我下面的例子。省略v-table指针将导致一些难以解决的头痛问题。然而,省略vtable可能很容易,但这样RTTI条目也会被省略 - gcc使用vtable引用RTTI数据。 - Johannes Schaub - litb

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