13

虚拟类的每个对象是否都有指向 vtable 的指针?

还是只有带有虚函数的基类对象才有?

vtable 存储在哪里?进程的代码部分或数据部分?

4

9 回答 9

18

所有具有虚方法的类都将有一个由该类的所有对象共享的 vtable。

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

请注意,这些都不是 C++ 语言强制要求的——如果需要,实现可以以其他方式处理虚拟调度。但是,这是我熟悉的每个编译器都使用的实现。Stan Lippman 的书“深入 C++ 对象模型”描述了它如何很好地工作。

于 2009-02-18T16:02:24.703 回答
13

就像其他人所说的那样,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)

如您所见,每个类都有一个 vtable。前两个条目是特殊的。第二个指向类的RTTI数据。第一个 - 我知道但忘记了。它在更复杂的情况下有一些用处。好吧,正如布局所示,如果你有一个 Derived1 类的对象,那么 vptr(v-table-pointer)当然会指向 Derived1 类的 v-table,它的函数 bark 正好有一个条目指向Derived1 的版本。Derived2 的 vptr 指向 Derived2 的 vtable,它有两个条目。另一个是它添加的新方法,微笑。它重复了 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。我还转储了用于创建 Derived1 和调用使用的汇编程序代码($4 是第一个参数寄存器,$2 是返回值寄存器,$0 是始终为 0 的寄存器),然后使用c++filt工具对其中的名称进行解构:)

      # 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/tree.defgcc SVN 源代码中)

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

它的含义:使用*b->_vptr.Base对象上的表达式b,并存储前端(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函数中的最后一条语句。

于 2009-02-18T18:18:53.080 回答
4

Vtable 是每个类的实例,即,如果我有一个类的 10 个对象,该类具有一个虚拟方法,则只有一个 vtable 在所有 10 个对象之间共享。

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

于 2009-02-18T15:57:51.827 回答
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";
}
于 2009-02-18T16:04:10.707 回答
2

要回答有关哪些对象(从现在开始的实例)具有 vtable 以及在哪里的问题,考虑何时需要 vtable 指针会很有帮助。

对于任何继承层次结构,您都需要为该层次结构中特定类定义的每组虚函数创建一个 vtable。换句话说,给定以下内容:

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

因此,您需要五个 vtable:A、B、C、D 和 E 都需要它们自己的 vtable。

接下来,您需要知道在给定特定类的指针或引用的情况下使用什么 vtable。例如,给定一个指向 A 的指针,您需要对 A 的布局有足够的了解,这样您才能获得一个告诉您在哪里调度 A::f() 的 vtable。给定一个指向 B 的指针,您需要对 B 的布局有足够的了解才能分派 B::f() 和 B::g()。等等等等。

一种可能的实现可以将 vtable 指针作为任何类的第一个成员。这意味着 A 实例的布局将是:

A's vtable;
int a;

B 的一个实例是:

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

您可以从此布局生成正确的虚拟调度代码。

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

B's vtable;
int a;
int b;

因为 B 的 vtable 是 A 的超集。B 的 vtable 有 A::f 和 B::g 的条目,A 的 vtable 有 A::f 的条目。

为了完整起见,这是您将如何布局我们迄今为止看到的所有 vtable:

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 指针的数量。

于 2009-02-18T20:37:57.083 回答
2

VTable 是一个实现细节,语言定义中没有任何内容表明它存在。事实上,我已经阅读了实现虚拟功能的替代方法。

但是:所有常见的编译器(即我所知道的)都使用 VTabels。
好的。任何具有虚方法或从具有虚方法的类(直接或间接)派生​​的类都将具有带有指向 VTable 的指针的对象。

您提出的所有其他问题将取决于编译器/硬件,这些问题没有真正的答案。

于 2009-02-18T17:28:15.777 回答
1

所有虚拟类通常都有一个 vtable,但 C++ 标准并不要求它,存储方法取决于编译器。

于 2009-02-18T15:55:04.720 回答
0

每个多态类型的对象都会有一个指向 Vtable 的指针。

VTable 的存储位置取决于编译器。

于 2009-02-18T15:59:03.900 回答
0

不必要

几乎每个具有虚函数的对象都会有一个 v-table 指针。对于每个具有派生对象的虚函数的类,不需要有一个 v-table 指针。

不过,在某些情况下,对代码进行充分分析的新编译器可能能够消除 v-tables。

例如,在一个简单的情况下:如果您只有一个抽象基类的具体实现,编译器知道它可以将虚拟调用更改为常规函数调用,因为每当调用虚拟函数时,它总是会解析为相同的功能。

此外,如果只有几个不同的具体函数,编译器可能会有效地更改调用位置,以便它使用“if”来选择要调用的正确具体函数。

因此,在这种情况下,不需要 v-table,并且对象最终可能没有 v-table。

于 2009-02-18T16:03:41.813 回答