在C++中调用非虚基方法时,虚继承是否有代价?

oalqel3c  于 2023-02-06  发布在  其他
关注(0)|答案(9)|浏览(129)

当我们从基类调用一个 * 正则函数 * 成员时,在C++中使用虚继承会在编译代码中产生运行时损失吗?示例代码:

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 ();
j5fpnvbx

j5fpnvbx1#

如果你通过一个指针或引用来调用成员函数,而编译器不能绝对确定指针或引用所指向或引用的对象类型,那么可能会有这样的问题。

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

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

假设对f的调用不是内联的,编译器需要生成代码来查找A虚拟基类子对象的位置,以便调用foo
但是,如果编译器知道要调用函数的对象的类型(正如您的示例中的情况),应该没有开销,因为函数调用可以静态调度在您的示例中,已知bar的动态类型为D(它不能是其他任何东西),因此虚拟基类子对象A的偏移量可以在编译时计算。

shstlldc

shstlldc2#

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

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子对象,因为任何给定的B*都可以在运行时动态地指向B或D,那么你需要动态地改变指针。意味着您必须生成代码以通过某种方式找到该值,而不是在编译时将该值预存,这是单继承的情况。

ckocjqey

ckocjqey3#

至少在一个典型的实现中,虚继承携带(小!)处罚(至少是一些)访问数据成员。特别是,您通常需要额外的间接层来访问您虚拟派生的对象的数据成员。这是因为(至少在正常情况下)两个或多个单独的派生类不仅具有相同的基类,而且具有相同的基类 * 对象 *。为了实现这一点,两个派生类都具有指向最大派生对象中的相同偏移的指针,并且经由该指针访问那些数据成员。
尽管从技术上讲,这不是虚拟继承的结果,但可能值得注意的是,(同样,很小)通常多重继承的代价。在 single 继承的典型实现中,您在对象中的某个固定偏移处有一个vtable指针(通常是最开始的位置)。在多重继承的情况下,显然不能在相同的偏移量上有两个vtable指针,因此您最终得到了许多vtable指针,每个指针位于对象中的单独偏移量处。
因此,具有单继承的vtable指针通常只是static_cast<vtable_ptr_t>(object_address),但是具有多继承的vtable指针是static_cast<vtable_ptr_t>(object_address+offset)
从技术上讲,这两者是完全独立的--但当然,虚拟继承几乎唯一的用途是与多重继承结合使用,因此它是半相关的。

pkmbmrz7

pkmbmrz74#

具体地说,在Microsoft Visual C++中,指向成员的指针大小存在实际差异。请参见#pragma pointers_to_members。正如您在该列表中所看到的,最常用的方法是“虚拟继承”,它不同于多重继承,而多重继承又不同于单一继承。
这意味着在存在虚拟继承的情况下,需要更多的信息来解析指向成员的指针,并且如果仅仅通过CPU缓存中占用的数据量,它将对性能产生影响--尽管也可能在成员查找的长度或所需的跳转次数方面。

vuktfyat

vuktfyat5#

我认为,虚继承没有运行时损失。不要混淆虚继承和虚函数。两者是两回事。
虚继承确保了在D的示例中只有一个子对象A,所以我不认为它单独会有运行时损失。
然而,也可能出现子对象在编译时未知的情况,因此在这种情况下,虚拟继承会导致运行时损失。James在他的回答中描述了一个这样的情况。

7xzttuei

7xzttuei6#

您的问题主要集中在调用虚拟基类的 regular 函数上,而不是(远)更有趣的虚拟基类(示例中的类A)的 virtual 函数的情况--但是,是的,这是有代价的,当然一切都依赖于编译器。
当编译器编译A::foo时,它假设“this”指向A的数据成员在内存中驻留的位置的开始,此时,编译器可能不知道类A将是任何其他类的虚基类,但它很高兴地生成代码。
现在,当编译器编译B时,实际上不会有任何更改,因为虽然A是虚拟基类,但它仍然是单继承,并且在典型情况下,编译器将通过将类A的数据成员紧接着类B的数据成员来布局类B--因此B * 可以立即强制转换为A *,而无需更改值,因此,不需要进行任何调整。编译器可以使用相同的“this”指针调用A::foo(即使它是B * 类型),这没有坏处。
类C也是同样的情况--它仍然是单继承的,典型的编译器会将A的数据成员放在C的数据成员之后,这样C * 就可以立即转换为A ,而不需要改变值。因此,编译器可以简单地用相同的“this”指针调用A::foo(即使它是C 类型),这没有什么坏处。
然而,对于类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”指针添加位移或修复来处理所有事情,以说明基类在派生类的示例中“嵌入”的位置。有时需要重写函数。2这完全取决于被调用的成员函数(甚至是非虚拟的)访问了哪些数据成员。
例如,如果类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中定义的成员变量。
57hvy0tb

57hvy0tb7#

虚拟继承必然要付出代价。
证据是虚拟继承类占用的空间比各部分的总和还要多。
典型案例:

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
额外存储的是什么?我不太明白。我认为它类似于一个虚拟表,但用于访问单个成员。

ghg1uchk

ghg1uchk8#

这需要额外的内存。例如,GCC 7在x86-64上会产生以下结果:

#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

正如您所看到的,使用虚拟继承时会添加一些额外的字节。

dl5txlt9

dl5txlt99#

好吧,在解释了许多很好的答案之后,虽然查找虚拟基类在内存中的确切位置会导致性能下降,但还有一个后续问题:幸运的是,有一个final关键字形式的部分解决方案。特别是,从原始示例的类D到最内层的基类A的调用通常(几乎)是无惩罚的,但只有在一般情况下,如果你将final大小化D
为什么这是必要的,让我们看看一个多级类层次结构:

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应用程序类可以使用我们基类的各种Ext扩展类,所以这些Ext扩展类中没有一个可以在编译时知道Base子对象将位于对象中的什么位置,而它们必须在运行时查询虚拟表才能找到。因为各种X1 M9 N1 X和X1 M10 N1 X类都可以在不同的翻译单元中定义。
但是App应用程序类也存在同样的问题:因为App2App3通过Ext扩展类继承了虚拟化的Base(es),他们在编译时不知道,其中Base子对象位于它们自己的对象内。因此App2App3的每个方法都必须查询虚拟表,以找到Base子对象在其本地对象中的位置。因为稍后进一步合并那些X1 M20 N1 X类在语法上是法律的的,如以上分层结构中的X1 M21 N1 X类所示。
还要注意的是,如果Base类调用任何定义在Ext扩展或App应用级别上的虚方法,则会有进一步的惩罚。这是因为虚方法将在this指向Base对象时被调用。但是它们必须通过再次查询虚拟表来将其调整到它们自己的对象的开头。如果Ext扩展或App应用层(虚拟或非虚拟)方法调用在Base类上定义的虚拟方法时,将发生两次该惩罚:首先查找Base子对象,然后再次查找相对于Base子对象的真实的对象。
然而,如果我们知道,一个组合了几个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应用类的方法不再需要通过虚拟表来查找Base子对象,它们只需要将已知常量offet添加到this指针即可。并且在App应用层的虚拟回调可以通过减去一个已知的常量偏移量来轻松地再次修复this指针(或者甚至根本不修改它,而是从对象中间引用各个字段)Base类的方法也不会对自身造成任何影响,因为在该类内部,一切都正常工作,所以在这个三层场景中,final化的类位于最外层,只有在X1 M44 N1 X版本级别上的方法的执行较慢,如果它们需要引用X1 M45 N1 X类的字段或方法,或者如果它们是从X1 M46 N1 X虚拟调用的。
关键字final的缺点是,它不允许所有扩展。你不能再从App2派生App2a,即使它不需要任何Ext扩展。声明一个非finalApp2Base,然后从它派生finalApp2aApp2bApp2Base中引用原始Base的所有方法将再次招致惩罚。不幸的是,C++之神并没有给予我们一种方法来取消基类的虚拟化,而是让非虚拟扩展成为可能。他们也没有给我们一种方法来声明一个“主”Ext扩展类,其布局保持固定,即使还添加了具有相同虚拟X1 M60 N1 X类的其他X1 M59 N1 X扩展(在这种情况下,所有非主X1 M61 N1 X扩展将引用主X1 M63 N1 X扩展内的X1 M62 N1 X子对象)。
这种虚拟继承的替代方法通常是将所有扩展添加到Base类中,根据应用程序的不同,这可能需要大量额外的且通常未使用的字段和/或大量额外的虚拟方法调用和/或大量dynamic_cast,所有这些也会带来性能损失。

还应注意,在现代CPU中,预测错误的虚拟函数调用后的惩罚比预测错误的this指针修复后的惩罚高很多。前者需要丢弃在错误执行路径上获得的所有结果,并在正确路径上重新启动。后者仍然需要重复直接或间接依赖于this的所有操作码。但不需要再次加载和解码指令。带有未知指针修正的推测性执行是CPU易受Spectre/Meltdown类型数据泄漏攻击的原因之一。

相关问题