繁体   English   中英

调用非虚拟基方法时,C++ 中的虚拟 inheritance 是否有任何惩罚/成本?

[英]Is there any penalty/cost of virtual inheritance in C++, when calling non-virtual base method?

当我们从其基数 class 调用常规 function成员时,在 C++ 中使用虚拟 inheritance 是否会在编译代码中产生运行时惩罚? 示例代码:

class A {
    public:
        void foo(void) {}
};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};

// ...

D bar;
bar.foo ();

可能有,是的,如果您通过指针或引用调用成员函数,并且编译器无法绝对确定该指针或引用指向或引用的对象类型。 例如,考虑:

void f(B* p) { p->foo(); }

void g()
{
    D bar;
    f(&bar);
}

假设对f的调用不是内联的,编译器需要生成代码来查找A虚拟基类子对象的位置,以便调用foo 通常这个查找涉及检查 vptr/vtable。

如果编译器知道您调用函数的对象的类型(如您的示例中的情况),则应该没有开销,因为可以静态调度函数调用(在编译时)。 在您的示例中, bar的动态类型已知为D (它不能是其他任何东西),因此可以在编译时计算虚拟基类子对象A的偏移量。

是的,虚拟继承有运行时性能开销。 这是因为对于任何指向对象的指针/引用,编译器在编译时都找不到它的子对象。 相比之下,对于单继承,每个子对象都位于原始对象的静态偏移处。 考虑:

class A { ... };
class B : public A { ... }

B 的内存布局看起来有点像这样:

| B's stuff | A's stuff |

在这种情况下,编译器知道 A 在哪里。 但是,现在考虑 MVI 的情况。

class A { ... };
class B : public virtual A { ... };
class C : public virtual A { ... };
class D : public C, public B { ... };

B的内存布局:

| B's stuff | A's stuff |

C的内存布局:

| C's stuff | A's stuff |

可是等等! 当 D 被实例化时,它看起来不是那样的。

| D's stuff | B's stuff | C's stuff | A's stuff |

现在,如果你有一个 B*,如果它真的指向一个 B,那么 A 就在 B 的旁边——但是如果它指向一个 D,那么为了获得 A*,你真的需要跳过 C sub -object,并且由于任何给定的B*都可以在运行时动态指向 B 或 D,因此您需要动态更改指针。 这至少意味着您将不得不通过某种方式生成代码以找到该值,而不是在编译时将值嵌入,这是单继承发生的情况。

至少在典型的实现中,虚拟继承对(至少一些)数据成员的访问会带来(小!)惩罚。 特别是,您通常会获得额外的间接级别来访问您从中虚拟派生的对象的数据成员。 这是因为(至少在正常情况下)两个或多个单独的派生类不仅具有相同的基类,而且具有相同的基类object 为了实现这一点,两个派生类都有指向最派生对象的相同偏移量的指针,并通过该指针访问这些数据成员。

尽管从技术上讲这不是由于虚拟继承,但可能值得注意的是,一般来说,多重继承有一个单独的(同样是小的)惩罚。 继承的典型实现中,您在对象中的某个固定偏移处有一个 vtable 指针(通常是最开始的)。 在多重继承的情况下,显然不能在相同的偏移量处有两个 vtable 指针,因此最终会得到许多 vtable 指针,每个指针在对象中的单独偏移量处。

IOW,具有单继承的 vtable 指针通常只是static_cast<vtable_ptr_t>(object_address) ,但是通过多重继承,您会得到static_cast<vtable_ptr_t>(object_address+offset)

从技术上讲,两者是完全独立的——当然,虚拟继承几乎唯一的用途是与多重继承结合使用,所以无论如何它都是半相关的。

具体而言,在 Microsoft Visual C++ 中,指向成员的指针大小存在实际差异。 请参阅#pragma pointers_to_members 正如您在该清单中所看到的 - 最通用的方法是“虚拟继承”,它与多重继承不同,而多重继承又与单继承不同。

这意味着在存在虚拟继承的情况下需要更多信息来解析指向成员的指针,并且如果仅通过 CPU 缓存中占用的数据量就会对性能产生影响 - 尽管也可能在成员查找的长度或所需的跳转次数。

您的问题主要集中在调用虚拟基类的常规函数上,而不是虚拟基类(在您的示例中为 A 类)的虚拟函数的(远)更有趣的情况——但是,是的,可能会有成本。 当然,一切都依赖于编译器。

当编译器编译 A::foo 时,它假定“this”指向 A 的数据成员在内存中的起始位置。 此时,编译器可能不知道类 A 将是任何其他类的虚拟基类。 但它很高兴地生成了代码。

现在,当编译器编译 B 时,实际上不会有任何变化,因为虽然 A 是虚拟基类,但它仍然是单继承的,在典型情况下,编译器将通过将类 A 的数据成员紧跟其后来布局类 B由 B 类的数据成员 - 因此 B * 可以立即转换为 A * 而不改变值,因此不需要进行任何调整。 编译器可以使用相同的“this”指针(即使它是 B * 类型)调用 A::foo 并且没有任何危害。

类 C 的情况相同——它仍然是单继承,典型的编译器将 A 的数据成员紧跟在 C 的数据成员之后放置,因此 C * 可以立即转换为 A * 而不改变任何值。 因此,编译器可以使用相同的“this”指针(即使它是 C* 类型)简单地调用 A::foo 并且没有任何危害。

但是,D 类的情况完全不同。D 类的布局通常是 A 类的数据成员,然后是 B 类的数据成员,然后是 C 类的数据成员,然后是 D 类的数据成员。

使用典型的布局,D * 可以立即转换为 A *,因此 A::foo 没有损失——编译器可以调用它为 A::foo 生成的相同例程,而无需对“this”进行任何更改一切都很好。

但是,如果编译器需要调用诸如 C::other_member_func 之类的成员函数,即使 C::other_member_func 是非虚拟的,情况也会改变。 原因是当编译器为 C::other_member_func 编写代码时,它假定“this”指针引用的数据布局是 A 的数据成员,紧接着是 C 的数据成员。 但对于 D 的实例则不然。编译器可能需要重写并创建一个(非虚拟的)D::other_member_func,只是为了处理类实例内存布局的差异。

请注意,当使用多重继承时,这是一种不同但相似的情况,但在没有虚基的多重继承中,编译器可以通过简单地向“this”指针添加位移或修正来解决所有问题,以说明基类的位置“嵌入”在派生类的实例中。 但是对于虚拟基础,有时需要重写函数。 这一切都取决于被调用的(甚至是非虚拟的)成员函数访问了哪些数据成员。

例如,如果类 C 定义了一个非虚成员函数 C::some_member_func,编译器可能需要这样写:

  1. C::some_member_func 从 C 的实际实例(而不是 D)调用时,在编译时确定(因为 some_member_func 不是虚函数)
  2. C::some_member_func 当从类 D 的实际实例调用相同的成员函数时,在编译时确定。 (从技术上讲,这个例程是 D::some_member_func。尽管这个成员函数的定义是隐式的,并且与 C::some_member_func 的源代码相同,但生成的目标代码可能略有不同。)

如果 C::some_member_func 的代码碰巧使用了在类 A 和类 C 中定义的成员变量。

我认为,虚拟继承没有运行时惩罚。 不要将虚继承与虚函数混淆。 两者是两种不同的东西。

虚拟继承确保您在D实例中只有一个子对象A 所以我不认为单独会有运行时惩罚。

但是,可能会出现在编译时无法知道此子对象的情况,因此在这种情况下,虚拟继承会导致运行时惩罚。 詹姆斯在他的回答中描述了一个这样的案例。

虚拟继承必须有成本。

证明是虚拟继承的类占用的空间大于部分的总和。

典型:

struct A{double a;};

struct B1 : virtual A{double b1;};
struct B2 : virtual A{double b2;};

struct C : virtual B1, virtual B2{double c;}; // I think these virtuals are not strictly necessary
static_assert( sizeof(A) == sizeof(double) ); // as expected

static_assert( sizeof(B1) > sizeof(A) + sizeof(double) ); // the equality holds for non-virtual inheritance
static_assert( sizeof(B2) > sizeof(A) + sizeof(double) );  // the equality holds for non-virtual inheritance
static_assert( sizeof(C) > sizeof(A) + sizeof(double) + sizeof(double) + sizeof(double) );
static_assert( sizeof(C) > sizeof(A) + sizeof(double) + sizeof(double) + sizeof(double) + sizeof(double));

( https://godbolt.org/z/zTcfoY )

额外存储什么? 我不完全明白。 我认为它类似于虚拟表,但用于访问单个成员。

有额外的 memory 成本。 例如,x86-64 上的 GCC 7 给出以下结果:

#include <iostream>

class A { int a; };
class B: public A { int b; };
class C: public A { int c; };
class D: public B, public C { int d; };
class BV: virtual public A { int b; };
class CV: virtual public A { int c; };
class DV: public BV, public CV { int d; };


int main()
{
    std::cout << sizeof(A) << std::endl;
    std::cout << sizeof(B) << std::endl;
    std::cout << sizeof(C) << std::endl;
    std::cout << sizeof(D) << std::endl;
    std::cout << sizeof(BV) << std::endl;
    std::cout << sizeof(CV) << std::endl;
    std::cout << sizeof(DV) << std::endl;
    return 0;
}

这打印出来:

4
8
8
20
16
16
40

如您所见,使用虚拟 inheritance 时添加了一些额外的字节。

好吧,在许多好的答案解释之后,在 memory 中查找虚拟基地 class 的确切 position 会导致性能损失,有一个后续问题:“可以减少这种损失吗?” 幸运的是,有一个(尚未提及的) final关键字形式的部分解决方案。 特别是,从原始示例的 class D到最里面的基数A的调用通常(几乎)没有惩罚,但仅在一般情况下,如果您final D

为什么这是必要的,让我们看一下多级 class 层次结构:

class Base {};

class ExtA : public virtual Base {};
class ExtB : public virtual Base {};
class ExtC : public virtual Base {};

class App1 : public Base {};
class App2 : public ExtA {};
class App3 : public ExtB, public ExtC {};

class SuperApp : public App2, public App3 {};

因为我们的App lication 类可以使用我们的基类 class 的各种Ext类,所以这些Ext类都无法在编译时知道Base子对象将位于 object 中的何处,它们将被调用。 相反,他们必须在运行时查询虚拟表才能找到答案。 这是因为各种ExtApp类都可以在不同的翻译单元中定义。

但是App lication 类也存在同样的问题:因为App2App3通过Ext ension 类继承了一个虚拟化的Base ,所以它们在编译时不知道Base子对象在它们自己的对象中的位置。 因此App2App3的每个方法都必须查询虚拟表以找到Base子对象在其本地对象中的位置。 这是因为稍后进一步组合这些App类在语法上是合法的,如上面层次结构中的SuperApp class 所示。

另请注意,如果Base class 调用在Ext ension 或App lication 级别上定义的任何虚拟方法,则会有进一步的惩罚。 那是因为将调用虚方法时this指向一个Base object,但他们必须通过再次查询虚表将其调整为自己的 object 的开头。 如果ExtApp层(虚拟或非虚拟)方法调用在Base class 上定义的虚拟方法,则该惩罚会发生两次:第一次是为了找到Base子对象,然后再次是为了从Base找到真正的 object 相对对象子对象。

但是,如果我们知道,不会创建结合多个AppSuperApp ,我们可以通过将App类声明为 final 来改进很多事情:

class App1 final : public Base {};
class App2 final : public ExtA {};
class App3 final : public ExtB, public ExtC {};

// class SuperApp : public App2, public App3 {};   // illegal now!

因为final使布局不可变, App lication 类的方法不再需要通过虚拟表 go 来查找Base子对象。 在调用任何Base方法时,它们只是将已知常量偏移量添加到this指针。 App层的虚拟回调可以通过减去一个常量已知偏移量轻松地再次修复this指针(或者甚至根本不修复它,而是从 object 的中间引用各个字段)。 Base class 的方法本身也不会受到任何惩罚,因为在 class 内部,一切正常。 所以在这个最外层finalized classes的三层场景中,只有Ext final层的方法执行比较慢,如果他们需要引用Base class的字段或方法,或者如果他们是虚拟调用Base

final关键字的缺点是,它不允许所有扩展。 您不能再从App2派生App2a ,即使它不需要任何这些Ext 并声明一个非finalApp2Base然后从中声明finalApp2aApp2b ,将再次对App2Base中引用原始Base的所有方法产生惩罚。 不幸的是,C++ 大神们并没有给我们一个方法来对一个基数 class 进行非虚拟化,而是让非虚拟扩展成为可能。 他们也没有给我们声明“主” Ext class 的方法,其布局保持固定,即使还添加了具有相同虚拟Base class 的其他Ext (在这种情况下,所有非主Ext将引用主Ext ension 中的Base子对象)。

虚拟 inheritance 的替代方法通常是将所有扩展内容添加到Base class。根据应用程序的不同,这可能需要大量额外且经常未使用的字段和/或大量额外的虚拟方法调用和/或很多dynamic_cast s,它们都会带来性能损失。

另请注意,在现代 CPU 中,错误预测虚拟 function 调用后的惩罚高于错误预测this指针修正后的惩罚。 第一个需要丢弃在错误的执行路径上获得的所有结果,并在正确的路径上重新开始。 后者仍然需要重复直接或间接依赖于this的所有操作码,但不需要再次加载和解码指令。 顺便说一句:使用未知指针修复的推测执行是 CPU 易受 Spectre/Meltdown 类型数据泄漏影响的原因之一。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM