5

在我看来,对于 C++ 虚拟调用,它需要:

  1. 从符号表中获取对象的类型
  2. 从类型表中获取 v-table
  3. 使用 v-table 中的函数签名搜索函数
  4. 调用函数。

而对于非虚拟(例如在 C 中)调用,只需要 #4。

我认为#3应该是最耗时的。鉴于 C++ 中实时覆盖的性质,我看不到上述步骤的编译时间优化潜力。因此,对于具有长函数签名的复杂类继承,C++ 虚拟调用应该比非虚拟调用慢得多。

但是所有的说法都是相反的,为什么?

4

6 回答 6

7
  1. 从符号表中获取对象的类型
  2. 从类型表中获取 v-table
  3. 使用 v-table 中的函数签名搜索函数
  4. 调用函数。

这是对基于 v-table 的调度如何工作的理解不足。这要简单得多:

  1. 从对象指针获取 v-table。为所讨论的函数选择正确的 v-table(如果使用多个基类)。
  2. 向这个 v-table 指针添加一个特定的偏移量,由编译时确定,从而获取一个特定的函数指针。
  3. 调用该函数指针。

每个对象都有一个 v-table 指针,它指向该对象原始类型的 v-table。所以没有必要从“符号表”中获取类型。不需要搜索 v 表。根据编译时提供的函数签名,编译时可准确确定需要访问 v-table 中的哪个指针。这完全是关于编译器如何索引类中的每个虚函数。它可以确定每个虚函数的特定顺序,因此当编译器去调用它时,它可以确定调用哪个函数。

所以总体来说还是挺快的。

处理虚拟基类时稍微复杂一些,但大体思路还是一样的。

于 2012-12-05T04:41:30.533 回答
4

与普通函数调用相比,虚函数调用的开销是两个额外的fetch操作(一个是获取 v 指针的值,第二个是获取方法的地址)。
在大多数情况下,这种开销不足以在性能分析中显示出来。

此外,在某些情况下,如果virtual可以在编译时确定要调用的函数,则智能编译器会这样做,而不是在运行时进行。

于 2012-12-05T04:34:31.077 回答
4

1 & 2) 它不需要从任何“符号表”中检索对象的类型。v 表通常由对象中的隐藏字段指向。所以检索 v-table 基本上是一个指针间接。

3) v-table 未被“搜索”。每个虚函数在 v 表中都有一个固定的索引/偏移量,在编译时确定。所以这基本上是从指针的偏移量中提取的。

因此,虽然它比直接的 C 风格调用要慢,但并不像您建议的那样费力。它类似于 C 中的类似内容:

struct MyObject_vtable {
    int (*foo)();
    void (*bar)(const char *arg);
};

struct MyObject {
    int m_instanceVariable1;
    int m_instanceVariable2;
    struct MyObject_vtable *__vtable;
};

struct MyObject * obj = /* ... construct a MyObject instance */;

// int result = obj->foo();
int result = (*(obj->__vtable.foo))();

// obj->bar("Hello");
(*(obj->__vtable.bar))("Hello");

另外,虽然这可能有点超出问题的范围,但值得注意的是,编译器通常可以在编译时确定要调用的函数,在这种情况下,它可以直接调用该函数,而无需经过虚拟呼叫机制。例如:

MyObject obj1;
int result1 = obj1.foo();

MyObject *obj2 = getAMyObject();
int result2 = obj2->foo();

在这种情况下,在编译时就知道foo()第一次调用要调用哪个,因此可以直接调用它。对于第二次调用,可能会getAMyObject()返回派生类的某个对象,该类的派生对象MyObject已被覆盖foo(),因此必须使用虚拟调用机制。

于 2012-12-05T04:39:51.847 回答
2

实际上,这是一个瓶颈问题...


...但让我们首先用图表(64 位)修改您的假设。虽然对象模型是特定于实现的,但在 Itanium ABI(gcc、clang、icc、...)中使用的虚拟表的想法在 C++ 中相对普遍。

class Base { public: virtual void foo(); int i; };

+-------+---+---+
| v-ptr | i |pad|
+-------+---+---+

class Derived: public Base { public: virtual void foo(); int j; };

+-------+---+---+
| v-ptr | i | j |
+-------+---+---+

在单个(非虚拟)基类的情况下,v-ptr是对象的第一个成员。因此,获取 v-ptr 很容易。从那时起,偏移量是已知的(在编译时),因此这只是一些指针算术,然后是通过指针取消引用的函数调用。

多亏了 LLVM,让我们来看看它:

%class.Base = type { i32 (...)**, i32 }
                     ~~~~~~~~~~^  ^~~
                     v-ptr          i

%class.Derived = type { [12 x i8], i32 }
                        ~~~~~~~~^  ^~~
                        Base         j

define void @_Z3fooR4Base(%class.Base* %b) uwtable {
  %1 = bitcast %class.Base* %b to void (%class.Base*)***
  %2 = load void (%class.Base*)*** %1, align 8
  %3 = load void (%class.Base*)** %2, align 8
  tail call void %3(%class.Base* %b)
  ret void
}
  • %1: 指向 v-table 的指针(通过 bitcast 获得,对 CPU 来说是透明的)
  • %2: v-table 本身
  • %3: 指向Derived::foo(表的第一个元素)的指针
于 2012-12-05T08:19:22.337 回答
1

它基本上是两次读取(一次从对象实例获取 vtable ptr,一次从 vtable 获取函数指针)和一个函数调用。内存通常很热并且停留在缓存中,并且由于没有任何分支,CPU 可以非常好地流水线化以隐藏大量开销。

于 2012-12-05T04:36:04.943 回答
0

也许 C 中的动态多态性示例可能有助于说明这些步骤。假设您在 C++ 中有这些类:

struct Base {
  int someValue;
  virtual void bar();
  virtual int foo();
  void foobar();
};

struct Derived : Base {
  double someOtherValue;
  virtual void bar();
};

好吧,在 C 中,您可以通过这种方式实现相同的层次结构:

struct Base {
  void** vtable;
  int someValue;
};

void Base_foobar(Base* p);
void Base_bar_impl(Base* p);
int Base_foo_impl(Base* p);

void* Base_vtable[] = {(void*)&Base_bar_impl, (void*)&Base_foo_impl};

void Base_construct(Base* p) {
  p->vtable = Base_vtable;
  p->someValue = 0;
};

void Base_bar(Base* p) {
  (void(*)())(p->vtable[0])();  // this is the virtual dispatch code for "bar".
};

int Base_foo(Base* p) {
  return (int(*)())(p->vtable[1])();  // this is the virtual dispatch code for "foo".
};


struct Derived {
  Base base;
  double someOtherValue;
};

void Derived_bar_impl(Base* p);

void* Derived_vtable[] = {(void*)&Derived_bar_impl, (void*)&Base_foo_impl};

void Derived_construct(Derived* p) {
  Base_construct(&(p->base));
  p->base.vtable = Derived_vtable;  // setup the new vtable as part of derived-class constructor.
  p->someOtherValue = 0.0;
};

显然,C++ 中的语法要简单得多(呃!),但正如您所见,动态调度并不复杂,只需在函数指针的(静态)表中简单查找,其中的 vtable 指针是设置在对象的构造。另外,上面没有什么难于让编译器自动完成(即编译器可以很容易地获取上面的C++代码并在下面生成相应的C代码)。在多重继承的情况下,同样简单,每个基类都有自己的 vtable 指针,派生类必须为其每个基类设置这些指针,就是这样,您现在需要的唯一关键点在向上或向下转换层次结构时应用指针偏移量(因此使用 C++ 风格的转换运算符很重要!)。

总的来说,当严肃的人讨论虚函数的开销时,他们并不是在谈论进行函数调用所需的“复杂”步骤(因为这相当微不足道,有时甚至会被优化掉)。他们很可能在谈论与缓存相关的问题,例如抛弃预取器(通过难以预测的分派调用)并阻止编译器将函数打包到最终可执行文件中需要它们的位置附近(甚至内联)(或 DLL)。到目前为止,这些问题是虚函数的主要开销,而且这些问题并不那么重要,并且一些编译器足够聪明,可以很好地缓解这些问题。

于 2012-12-05T05:07:10.303 回答