使派生的C++类"最终"改变ABI吗?

Dan*_*Dan 13 c++ virtual-functions abi vtable c++11

我很好奇,如果标记现有的派生C++类final以允许去虚拟化优化将在使用C++ 11时改变ABI.我的期望是,它应该有没有影响,因为我认为这主要是提示编译器有关它如何能够优化虚函数,因此我看不到任何方式,它会改变结构或V表的大小,但也许我错过了什么?

我知道这里更改API,以便从这个派生类进一步派生的代码将不再起作用,但我只关注这个特殊情况下的ABI.

cur*_*guy 5

函数声明上的 FinalX::f()意味着该声明不能被重写,因此所有调用该声明的名称都可以提前绑定(不是那些在基类中命名声明的调用):如果虚函数在ABI 中是 Final 的,则生成的vtable 可能与生成的几乎相同的类(没有final)不兼容:对名称声明标记为final 的虚拟函数的调用可以被认为是直接的:尝试使用vtable 条目(应该存在于无final ABI 中)是非法的。

编译器可以使用最终保证来减少 vtable 的大小(有时可能会增长很多),方法是不添加通常会添加的新条目,并且必须根据非最终声明的 ABI。

添加条目用于重写函数而不是(本质上,始终)主基的函数或非平凡协变返回类型(非主基上的返回类型协变)。

本质上的主基类:多态继承的最简单情况

多态继承的简单情况,即派生类非虚拟地从单个多态基类继承,是始终主基的典型情况:多态基子对象位于开头,派生对象的地址与地址相同对于基本子对象,可以直接使用指向其中任何一个的指针进行虚拟调用,一切都很简单。

无论派生类是完整对象(不是子对象)、最派生对象还是基类,这些属性都是 true。(它们是在 ABI 级别保证未知来源指针的类不变量。)

考虑返回类型不是协变的情况;或者:

微不足道的协方差

*this一个例子:它与;具有相同类型的协变的情况 如:

struct B { virtual B *f(); };
struct D : B { virtual D *f(); }; // trivial covariance
Run Code Online (Sandbox Code Playgroud)

本质上,这B始终是主要的D:在所有D创建的(子)对象中, aB驻留在同一地址: toD*转换B*是微不足道的,因此协方差也是微不足道的:这是一个静态类型问题。

只要是这种情况(简单的向上转换),协方差就会在代码生成级别消失。

结论

在这些情况下,重写函数的声明类型与基类的类型有很大不同:

  • 所有参数几乎相同(只有类型上的细微差别this
  • 返回类型几乎相同(仅返回的指针(*)类型的类型可能有所不同)

(*) 由于返回引用与在 ABI 级别返回指针完全相同,因此不专门讨论引用

因此,没有为派生声明添加 vtable 条目。

(因此,使类成为最终类不会是 vtable 简化。)

从来没有主要基地

显然,一个类只能有一个子对象,其中包含特定标量数据成员(如 vptr (*)),偏移量为 0。其他具有标量数据成员的基类将处于非平凡的偏移量,需要非平凡的派生到基类转换的指针。因此,多个有趣的(**)继承将创建非主要基础。

(*) vptr 不是用户级别的普通数据成员;但在生成的代码中,它几乎是编译器已知的普通标量数据成员。(**) 非多态基的布局在这里并不有趣:出于 vtable ABI 的目的,非多态基被视为成员子对象,因为它不会以任何方式影响 vtable。

从概念上讲,非主要且非平凡的指针转换的最简单有趣的示例是:

struct B1 { virtual void f(); };
struct B2 { virtual void f(); };
struct D : B1, B2 { };
Run Code Online (Sandbox Code Playgroud)

每个基都有自己的 vptr 标量成员,并且这些 vptr 有不同的用途:

  • B1::vptr指向一个B1_vtable结构体
  • B2::vptr指向一个B2_vtable结构体

并且它们具有相同的布局(因为类定义是可叠加的,ABI 必须生成可叠加的布局);并且它们是严格不兼容的,因为

  1. vtable 有不同的条目:

    • B1_vtable.f_ptr指向最终的覆盖者B1::f()
    • B2_vtable.f_ptr指向最终的覆盖者B2::f()
  2. B1_vtable.f_ptrB2_vtable.f_ptr必须与(来自B1和中各自的 vptr 数据成员B2)具有相同的偏移量

  3. B1::f()和的最终重写器B2::f()本质上(总是,总是)等效(*):它们可以具有不同的最终重写器来执行不同的操作。(***)

(*) 如果两个可调用运行时函数 (**) 在 ABI 级别具有相同的可观察行为,则它们是等效的。(等效的可调用函数可能不具有相同的声明或 C++ 类型。)

(**) 可调用的运行时函数是任何入口点:可以调用/跳转的任何地址;它可以是普通的函数代码、thunk/trampoline、多入口函数中的特定入口。可调用运行时函数通常没有可能的 C++ 声明,例如“使用基类指针调用的最终重写器”。

(***) 它们有时在进一步的派生类中具有相同的最终重写器:

struct DD : D { void f(); }
Run Code Online (Sandbox Code Playgroud)

对于定义 的 ABI 来说没有用D

所以我们看到D 可证明需要一个非主要多态性碱基;按照惯例,它将是D2;第一个指定的多态碱基 ( B1) 成为主要的。

因此B2必须处于非平凡的偏移量,并且D转换B2也并非平凡:它需要生成代码。

因此 的成员函数的参数D不能与 的成员函数的参数等效B2,因为隐式函数this不能简单地转换;所以:

  • D必须有两个不同的 vtable:一个 vtable 对应于B1_vtable和一个 with B2_vtable(它们实际上放在一个大 vtable 中,D但从概念上讲它们是两个不同的结构)。
  • B2::g被覆盖的虚拟成员的 vtable 条目D需要两个条目,一个在D_B2_vtable(这只是B2_vtable具有不同值的布局)中,另一个D_B1_vtable是增强的B1_vtableB1_vtable用于 的新运行时功能的加号条目D

因为 是D_B1_vtable从 a 构建的B1_vtable,所以指向的指针D_B1_vtable本质上就是指向 a 的指针B1_vtable,并且 vptr 值是相同的。

请注意,理论上,如果通过基进行所有虚拟调用的负担(只要不使用非平凡协方差(#))也是一种可能性,则可以省略D::g()in的D_B1_vtable条目。D::g()B2

(#) 或者如果发生非平凡协方差,则不使用“虚拟协方差”(涉及虚拟继承的派生到基关系中的协方差)

不是固有的主要基地

常规(非虚拟)继承就像成员资格一样简单:

  • 非虚拟基子对象是一个对象的直接基类(这意味着当不使用虚拟继承时,任何虚拟函数总是有一个最终重写器);
  • 非虚拟底座的位置是固定的;
  • 没有虚拟基子对象的基子对象,就像数据成员一样,其构造方式与完整对象完全相同(它们对于每个定义的 C++ 构造函数都只有一个运行时构造函数代码)。

继承的一个更微妙的情况是虚拟继承:虚拟基子对象可以是许多基类子对象的直接基类。这意味着虚拟基的布局仅在最派生类级别确定:最派生对象中虚拟基的偏移量是众所周知的并且是编译时常量;在任意派生类对象(可能是也可能不是最派生对象)中,它是在运行时计算的值。

该偏移量永远无法得知,因为 C++ 支持统一继承和重复继承:

  • 虚拟继承是统一的:大多数派生对象中给定类型的所有虚拟基都是同一个子对象;
  • 非虚拟继承是重复的:所有间接非虚拟基类在语义上都是不同的,因为它们的虚拟成员不需要具有共同的最终重写器(与 Java 相比,这是不可能的(AFAIK)):

    struct B { 虚拟 void f(); }; struct D1 : B { 虚拟 void f(); }; // 最终重写器 struct D2 : B { virtual void f(); }; // 最终重写结构 DD : D1, D2 { };

这里DD两个不同的最终重写器B::f()

  • DD::D1::f()是最终覆盖者DD::D1::B::f()
  • DD::D2::f()是最终覆盖者DD::D2::B::f()

在两个不同的 vtable 条目中。

复制继承,即从给定类间接派生多次,意味着多个 vptr、vtable 和可能不同的 vtable 最终代码(使用 vtable 条目的最终目标:调用虚函数的高级语义 - 而不是入口点) 。

不仅 C++ 支持两者,而且允许事实组合:使用统一继承的类的重复继承:

struct VB { virtual void f(); };
struct D : virtual VB { virtual void g(); int dummy; };
struct DD1 : D { void g(); };
struct DD2 : D { void g(); };
struct DDD : DD1, DD2 { };
Run Code Online (Sandbox Code Playgroud)

只有一个DDD::VB,但有两个明显不同的D子对象,DDD具有不同的最终重写器D::g()。无论类C++语言(支持虚拟和非虚拟继承语义)是否保证不同的子对象具有不同的地址, 的地址DDD::DD1::D不能与 的地址相同DDD::DD2::D

VB因此 a在 a 中的偏移量D无法固定(在任何支持基数统一和复制的语言中)。

在该特定示例中,真实VB对象(运行时的对象)除了 vptr 之外没有具体的数据成员,并且 vptr 是一个特殊的标量成员,因为它是类型“不变”(非 const)共享成员:它固定在构造函数(完成构造后不变)及其语义在基类和派生类之间共享。因为VB没有不是类型不变的标量成员,所以在 a 中,子DDD对象VB可以是 的覆盖DDD::DD1::D,只要 的 vtableD与 的 vtable 匹配VB

然而,对于具有非不变标量成员的虚拟基来说,情况并非如此,即具有标识的常规数据成员,即占用不同字节范围的成员:这些“真实”数据成员不能覆盖在其他任何内容上。因此,具有数据成员的虚拟基子对象(其地址保证由 C++ 或您正在实现的任何其他不同的类似 C++ 的语言保证不同的成员)必须放置在不同的位置:具有数据成员的虚拟基通常(## )本质上具有重要的偏移量。

(##) 可能是一种非常狭窄的特殊情况,派生类没有数据成员,而虚拟基具有一些数据成员

因此,我们看到“几乎空”类(没有数据成员但具有 vptr 的类)在用作虚拟基类时是特殊情况:这些虚拟基类是覆盖派生类的候选者,它们是潜在的主类,但不是固有的主类:

  • 它们所在的偏移量仅在最派生的类中确定;
  • 偏移量可能为零,也可能不为零;
  • null 偏移意味着基类的覆盖,因此每个直接派生类的 vtable 必须与基类的 vtable 匹配;
  • 非空偏移量意味着重要的转换,因此 vtable 中的条目必须将指向虚拟基的指针的转换视为需要运行时转换(除非明显重叠,因为它不是必需的,也是不可能的)。

这意味着当覆盖虚拟库中的虚拟功能时,始终假定可能需要进行调整,但在某些情况下不需要调整。

道德虚拟基是涉及虚拟继承(可能加上非虚拟继承)的基类关系。执行派生到基数的转换,特别是将指针转换d为 派生D、 到 基B、转换为...

  • ...非道德虚拟基础在任何情况下本质上都是可逆的:

    • Ba 的子对象的标识D与 a D(可能是子对象本身)之间存在一对一的关系;
    • 可以使用static_cast<D*>: static_cast<D*>((B*)d)is执行相反的操作d
  • (在任何完全支持统一和复制继承的类似 C++ 的语言中)...道德虚拟基在一般情况下本质上是不可逆的(尽管在具有简单层次结构的常见情况下它是可逆的)。注意:

    • static_cast<D*>((B*)d)形状不正;
    • dynamic_cast<D*>((B*)d)适用于简单的情况。

因此,我们将返回类型的协方差基于道德虚拟基础的情况称为虚拟协方差。当用虚拟协方差覆盖时,调用约定不能假设基数将位于已知偏移处。因此,虚拟协变本质上需要一个新的 vtable 条目,无论被覆盖的声明是否位于固有的主声明中:

struct VB { virtual void f(); }; // almost empty
struct D : virtual VB { }; // VB is potential primary

struct Ba { virtual VB * g(); };
struct Da : Ba { // non virtual base, so Ba is inherent primary
  D * g(); // virtually covariant: D->VB is morally virtual
};
Run Code Online (Sandbox Code Playgroud)

这里VB可能位于偏移量零处D并且可能不需要调整(例如对于类型为 的完整对象D),但在子对象中并不总是这种情况D:在处理指向 的指针时D,人们无法知道是否是这种情况。

当用虚拟协方差Da::g()覆盖时Ba::g(),必须假设一般情况,因此严格需要新的 vtable 条目,因为在一般情况下Da::g()不可能存在从VB到 的向下指针转换D来反转到D指针转换。VB

Ba是 的固有主项,Da因此 的语义Ba::vptr是共享/增强的:

  • 该标量成员有额外的保证/不变量,并且 vtable 被扩展;
  • 不需要新的 vptr Da

因此Da_vtable(本质上兼容Ba_vtable)需要两个不同的条目来进行虚拟调用g()

  • Ba_vtablevtable 部分: vtable 条目:使用隐式 this 参数Ba::g()调用最终重写器并返回一个值。Ba::g()Ba*VB*
  • 在 vtable 的新成员部分中:Da::g()vtable 条目:使用隐式 this 参数调用最终重写器Da::g()(本质上与 C++ 中的最终重写器相同Ba::g()Da*并返回一个D*值。

请注意,这里实际上没有任何 ABI 自由:vptr/vtable 设计的基础及其内在属性意味着这些多个条目的存在,这些条目是高级语言级别的唯一虚拟函数。

请注意,使虚拟函数体内联并通过 ABI 可见(以便具有不同内联函数定义的类的 ABI 可能不兼容,从而允许更多信息来通知内存布局)可能没有帮助,因为内联代码只会定义对非重写虚拟函数的调用的作用:不能将 ABI 决策基于可以在派生类中重写的选择。

[虚拟协方差的示例最终只是微不足道的协变,因为在完整的D偏移中 forVB是微不足道的,在这种情况下不需要调整代码:

struct Da : Ba { // non virtual base, so inherent primary
  D * g() { return new D; } // VB really is primary in complete D
                            // so conversion to VB* is trivial here
};
Run Code Online (Sandbox Code Playgroud)

请注意,在该代码中,由有缺陷的编译器使用该条目进行调用的虚拟调用的错误代码生成Ba_vtable实际上g()会起作用,因为协方差最终变得微不足道,就像VB完整中的primary 一样D

调用约定适用于一般情况,如果代码返回不同类的对象,则此类代码生成将会失败。

--结束示例]

但是,如果Da::g()在 ABI 中是最终的,则只能通过声明进行虚拟调用VB * g();:协变是纯静态的,派生到基数的转换是在编译时作为虚拟 thunk 的最后一步完成的,就好像虚拟协变从未存在过一样用过的。

决赛可能延长

C++ 中有两种类型的虚拟性:成员函数(通过函数签名匹配)和继承(通过类名匹配)。如果 Final 停止重写虚函数,它是否可以应用于类 C++ 语言中的基类?

首先我们需要定义什么是重写虚拟基继承:

“几乎直接”子对象关系意味着间接子对象几乎像直接子对象一样被控制:

  • 几乎直接子对象可以像直接子对象一样初始化;
  • 访问控制从来都不是访问的真正障碍(可以酌情使不可访问的私有几乎直接子对象可访问)。

虚拟继承提供了几乎直接的访问:

  • 每个虚拟基的构造函数必须由最底层派生类的构造函数的 ctor-init-list 调用;
  • 当虚拟基类由于在基类中声明为私有或在基类的私有基类中公开继承而无法访问时,派生类可以自行决定将虚拟基类再次声明为虚拟基,使其可访问。

形式化虚拟基重写的一种方法是在每个重写基类虚拟继承声明的派生类中进行虚拟继承声明:

struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D
  // , virtual VB  // imaginary overrider of D inheritance of VB
  {
  // DD () : VB() { } // implicit definition
}; 
Run Code Online (Sandbox Code Playgroud)

现在,支持两种继承形式的 C++ 变体不必在所有派生类中具有几乎直接访问的 C++ 语义:

struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D, virtual final VB {
  // DD () : VB() { } // implicit definition
}; 
Run Code Online (Sandbox Code Playgroud)

这里基类的虚拟性VB被冻结,不能在进一步的派生类中使用;虚拟性对于派生类来说是不可见且无法访问的,并且 的位置VB是固定的。

struct DDD : DD {
  DD () : 
    VB() // error: not an almost direct subobject
  { } 
}; 
struct DD2 : D, virtual final VB {
  // DD2 () : VB() { } // implicit definition
}; 
struct Diamond : DD, DD2 // error: no unique final overrider
{                        // for ": virtual VB"
}; 
Run Code Online (Sandbox Code Playgroud)

虚拟性冻结使得统一变得非法Diamond::DD::VBDiamond::DD2::VB但虚拟性VB需要统一,这产生了Diamond矛盾的、非法的类定义:没有类可以从两者派生DD[DD2模拟/示例:就像没有有用的类可以直接从 派生A1一样A2:

struct A1 {
  virtual int f() = 0;
};
struct A2 {
  virtual unsigned f() = 0;
};
struct UselessAbstract : A1, A2 {
  // no possible declaration of f() here
  // none of the inherited virtual functions can be overridden
  // in UselessAbstract or any derived class
};
Run Code Online (Sandbox Code Playgroud)

这里UselessAbstract是抽象的,没有派生类也是抽象的,这使得 ABC(抽象基类)非常愚蠢,因为任何指向的指针都UselessAbstract可以证明是空指针。

-- 结束模拟/示例]

这将提供一种冻结虚拟继承的方法,以提供具有虚拟基类的有意义的私有继承(没有它,派生类可以篡夺类与其私有基类之间的关系)。

这样使用 Final 当然会冻结派生类及其进一步派生类中的虚拟基的位置,从而避免仅因为虚拟基的位置不固定而需要的额外 vtable 条目。

  • 你能为此做一个 tl;dr 吗? (2认同)