【问题标题】:Does every object of virtual class have a pointer to vtable?虚拟类的每个对象是否都有指向 vtable 的指针?
【发布时间】:2010-10-08 09:04:50
【问题描述】:

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

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

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

【问题讨论】:

标签: c++ inheritance vtable


【解决方案1】:

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

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

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

【讨论】:

  • +1 你能解释一下为什么虚拟指针是每个对象而不是每个类吗?谢谢。
  • @Viet 您可以将 vPtr 视为对象运行时定义的引导程序。只有在设置了 vPtr 之后,对象才能知道它的实际类型是什么。在这个概念中,为每个类(静态)创建一个 vPtr 是没有意义的。换一种方式考虑,如果一个对象不需要 vPtr,那么它必须在编译期间已经知道它的运行时定义,这与它是一个动态解析的对象相矛盾。
【解决方案2】:

正如其他人所说,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 SVN 源代码中的gcc/tree.def 中)

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

含义:在对象b 上使用表达式*b-&gt;_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 函数中的最后一条语句。

【讨论】:

    【解决方案3】:

    Vtable 是每个类的实例,也就是说,如果我有一个类的 10 个对象,并且它有一个虚方法,那么只有一个 vtable 在所有 10 个对象之间共享。

    本例中的所有 10 个对象都指向同一个 vtable。

    【讨论】:

    • Vptr 怎么样,每个对象会有 10 个 vptr 关联,还是像单个 vtable 一样只有一个 vptr?
    【解决方案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";
    }
    

    【讨论】:

    • 得出必要的结论是 OP 的 left as an assignment ;)
    • 这些数字是典型的,但不是必需的。它没有说明存在多少个 vtable,或者这 4 个字节用于什么。
    • 在 64 位机器上,它将是 1,8,8。由于空结构的大小为 1 个字节,而另外两个每个包含一个指针(在 64 位机器的情况下为 8 个字节),这可能会有些混乱
    【解决方案5】:

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

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

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

    【讨论】:

      【解决方案6】:

      要回答有关哪些对象(从现在开始的实例)具有 vtable 以及在何处具有 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 指针的数量。

      【讨论】:

      • 当您投反对票时,请指出答案中有什么问题。否则,我们没有办法改进内容!谢谢。
      【解决方案7】:

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

      【讨论】:

        【解决方案8】:

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

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

        【讨论】:

          【解决方案9】:

          不一定

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

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

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

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

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

          【讨论】:

          • 嗯。我一直在尝试找到一个可以消除 v-table 指针的编译器。目前好像没有。但是,编译器和链接器之间的信息共享越来越高,以至于它们正在合并在一起。随着不断发展,这种情况可能会发生。
          • 这可能是因为实际上消除 vptr 将意味着严重违反 ABI - 这需要确保在模块之外永远不会看到相关类的任何对象 - 仅 4 个字节内存,甚至可能没有实际保存
          • OTOH,只是不通过虚拟调度调用方法只会破坏该特定方法的接口,编译器可以通过发出具有完整虚拟调度的另一个版本的代码来解决这个问题。它还提供了更大的优势,特别是如果函数可以被内联
          • 是的,看看我下面的例子。省略 v-table 指针会导致一些令人头疼的问题得到解决。但是,省略 vtables 可能很容易,但是 RTTI 条目将被省略 - gcc 使用 vtable 来引用 RTTI 数据。
          猜你喜欢
          • 2012-09-21
          • 2013-10-13
          • 1970-01-01
          • 2023-02-09
          • 1970-01-01
          • 1970-01-01
          • 2016-10-27
          • 1970-01-01
          • 2013-10-24
          相关资源
          最近更新 更多