制作派生的 C++ class "final" 会改变 ABI 吗?

Does making a derived C++ class "final" change the ABI?

我很好奇如果将现有的派生 C++ class 标记为 final 以允许去虚拟化优化,那么在使用 C++11 时是否会更改 ABI。我的期望是它应该没有任何效果,因为我认为这主要是向编译器提示它如何优化虚函数,因此我看不出它会以任何方式改变结构或 vtable 的大小,但是也许我遗漏了什么?

我知道这里发生了变化 API,因此从这个派生的 class 进一步派生的代码将不再有效,但我只关心这种特殊情况下的 ABI。

我相信添加 final 关键字不应该破坏 ABI,但是从现有的 class 中删除它可能会使某些优化无效。例如,考虑这个:

// in car.h
struct Vehicle { virtual void honk() { } };
struct Car final : Vehicle { void honk() override { } };

// in car.cpp

// Here, the compiler can assume that no derived class of Car can be passed,
// and so `honk()` can be devirtualized. However, if Car is not final
// anymore, this optimization is invalid.
void foo(Car* car) { car->honk(); }

如果 foo 是单独编译的,例如在共享库中发布,删除 final(因此使用户可以从 Car 派生)可能会使优化无效。

虽然我不是 100% 确定,但其中一些是猜测。

函数声明的 Final X::f() 意味着该声明不能被覆盖,因此所有调用该声明的名称都可以尽早绑定(不是那些在基 class 中命名声明的调用): 如果虚函数在 ABI 中是 final,则生成的 vtable 可能与生成的几乎相同 class 而没有 final 的 vtable 不兼容:调用名称声明标记的虚函数可以假定 final 是直接的:尝试使用 vtable 条目(应该存在于 final-less ABI 中)是非法的。

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

为覆盖不是(固有地,总是)主基的函数或非平凡协变 return 类型(非主基上的 return 类型协变)的声明添加了条目.

固有主基class:多态继承的最简单情况

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

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

考虑 return 类型不是协变的情况;或:

平凡协方差

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

struct B { virtual B *f(); };
struct D : B { virtual D *f(); }; // trivial covariance

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

只要是这种情况(微不足道 up-cast),协方差就会在代码生成级别消失。

结论

在这些情况下,覆盖函数的声明类型与基类的类型略有不同:

  • 所有参数几乎相同(只有 this 类型上的细微差别)
  • return 类型几乎相同(只有 returned pointer(*) 类型的可能不同)

(*) 因为 return 引用与 return 在 ABI 级别引用指针完全相同,所以不专门讨论引用

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

(因此使 class 最终化不会是 vtable 简化。)

从不作为主基地

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

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

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

struct B1 { virtual void f(); };
struct B2 { virtual void f(); };
struct D : B1, B2 { };

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

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

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

  1. vtable 有不同的条目:

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

  3. B1::f()B2::f() 的最终覆盖器并非天生(总是,不变地)等效(*):它们可以有不同的最终覆盖器来做不同的事情。(***)

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

(**) 可调用的运行时函数是任何入口点:可以是 called/jumped 处的任何地址;它可以是一个普通的函数代码,一个thunk/trampoline,一个多重入口函数中的特定入口。可调用的运行时函数通常没有可能的 C++ 声明,例如 "final overrider called with a base class pointer".

(***) 它们有时在进一步派生的 class:

中具有相同的最终覆盖
struct DD : D { void f(); }

对于定义 D 的 ABI 没有用处。

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

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

所以D的成员函数的参数不能等价于B2的成员函数的参数,因为隐含的this不是简单可转换的;所以:

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

因为 D_B1_vtable 是从 B1_vtable 构建的,指向 D_B1_vtable 的指针通常是指向 B1_vtable 的指针,并且 vptr 值是相同的。

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

(#) 或者如果出现非平凡的协方差,则不使用 "virtual covariance"(涉及虚拟继承的派生到基础关系中的协方差)

不是天生的主要基地

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

  • 非虚基类子对象是一个对象的直接基类(这意味着当不使用虚继承时,任何虚函数总是有一个最终覆盖);
  • 非虚拟基地的放置是固定的;
  • 没有虚拟基子对象的基子对象,就像数据成员一样,被构造得与完整对象完全一样(对于每个定义的 C++ 构造函数,它们只有一个运行时构造函数代码)。

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

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

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

    struct B { virtual void f(); }; struct D1 : B { virtual 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 条目中。

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

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

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 { };

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

的地址相同

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

在那个特定的例子中,一个真正的 VB 对象(运行时的对象)除了 vptr 没有具体的数据成员,并且 vptr 是一个特殊的标量成员,因为它是一个类型 "invariant" (not const) shared member: 它固定在构造函数上(完成构造后不变)并且它的语义在基础和派生 classes 之间共享。因为 VB 没有非类型不变的标量成员,所以在 DDDVB 子对象可以覆盖 DDD::DD1::D,只要 DVB.

的 vtable 的匹配项

然而,对于具有非不变标量成员的虚拟基,情况并非如此,即具有标识的常规数据成员,即占用不同字节范围的成员:这些 "real" 数据成员不能覆盖在其他任何东西上。因此,具有数据成员的虚拟基础子对象(具有保证由 C++ 或您正在实现的任何其他不同的 C++ 类语言不同的地址的成员)必须放在不同的位置:通常具有数据成员的虚拟基础(## ) 具有固有的非平凡偏移量。

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

所以我们看到 "almost empty" classes(classes 没有数据成员但有一个 vptr)在用作虚拟基础 classes 时是特殊情况: 这些虚拟基础是叠加在派生 classes 上的候选对象,它们是潜在的初选但不是固有的初选:

  • 它们所在的偏移量只会在最派生的 class;
  • 中确定
  • 偏移量可能为零,也可能不为零;
  • nul offset 意味着基础的覆盖,因此每个直接派生的 vtable class 必须与基础的 vtable 匹配;
  • 非 nul 偏移量意味着非平凡的转换,因此 vtables 中的条目必须将指向虚拟基的指针的转换视为需要运行时转换(除非明显覆盖,因为它没有必要不可能) .

这意味着当覆盖虚拟基中的虚拟函数时,总是假设可能需要进行调整,但在某些情况下不需要调整。

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

  • ...a non-morally 虚拟基在任何情况下本质上都是可逆的:

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

    • static_cast<D*>((B*)d)格式错误;
    • dynamic_cast<D*>((B*)d) 适用于简单的情况。

所以我们称虚拟协方差 return 类型的协方差基于道德虚拟基础的情况。当用虚拟协方差覆盖时,调用约定不能假定基数将处于已知偏移量。所以一个 new vtable entry 本质上是虚拟协变所需要的,无论被覆盖的声明是否在一个固有的主节点中:

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
};

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

Da::g() 使用虚拟协方差覆盖 Ba::g() 时,必须假设一般情况,因此 [=110= 严格需要 新 vtable 条目 ] 因为在一般情况下,从 VBD 的向下指针转换不可能反转 DVB 指针转换。

BaDa 中固有的主所以 Ba::vptr 的语义是 shared/enhanced:

  • 在该标量成员上有额外的 guarantees/invariants,并且扩展了 vtable;
  • Da不需要新的 vptr。

所以 Da_vtable(本质上与 Ba_vtable 兼容)需要两个不同的条目来虚拟调用 g():

  • 在vt的Ba_vtable部分ble: Ba::g() vtable entry: 调用 Ba::g() 的最终覆盖器,隐含的 this 参数为 Ba* 和 returns 一个 VB* 值。
  • 在 vtable 的新成员部分:Da::g() vtable 条目:调用 Da::g() 的最终覆盖器(本质上与 C++ 中的 Ba::g() 的最终覆盖器相同) Da* 的隐式 this 参数和 return 的 D* 值。

请注意,这里实际上没有任何 ABI 自由度:vptr/vtable 设计的基本原理及其内在属性意味着存在这些多个条目,用于高级语言级别的独特虚函数。

请注意,使虚函数主体内联并由 ABI 可见(这样 class 具有不同内联函数定义的 ABI 可以变得不兼容,从而允许更多信息告知内存布局)可能没有帮助,因为内联代码只会定义对非覆盖虚函数的调用的作用:不能将 ABI 决策基于可以在派生 classes 中覆盖的选择。

[虚拟协方差的示例最终只是平凡的协变,因为在完整的 DVB 的偏移量是平凡的,在这种情况下不需要调整代码:

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
};

请注意,在该代码中,使用 Ba_vtable 条目调用 g() 的错误编译器为虚拟调用生成的错误代码实际上会起作用,因为协方差最终变得微不足道,因为VB 是完整的 D.

的主要内容

调用约定适用于一般情况,这样的代码生成会因 return 是不同 class 的对象的代码而失败。

--例子结束]

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

final 的可能扩展

C++中有两种virtual-ness:成员函数(通过函数签名匹配)和继承(通过class名称匹配)。如果 final 停止覆盖虚函数,它是否可以应用于类 C++ 语言中的基础 classes?

首先我们需要定义什么是覆盖虚拟基础继承:

一个"almost direct"子对象关系意味着一个间接子对象几乎像一个直接子对象一样被控制:

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

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

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

形式化虚基覆盖的一种方法是在每个派生 class 中创建一个虚构的继承声明来覆盖基 class 虚继承声明:

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
}; 

现在支持两种继承形式的 C++ 变体不必在所有派生的 classes:

中具有几乎直接访问的 C++ 语义
struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D, virtual final VB {
  // DD () : VB() { } // implicit definition
}; 

此处 VB 基的 virtual-ness 被冻结,不能在进一步派生的 classes 中使用; virtual-ness 对派生的 classes 不可见且无法访问,并且 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"
}; 

virtual-ness 冻结使得统一 Diamond::DD::VBDiamond::DD2::VB 是非法的,但是 VB 的 virtual-ness 需要统一,这使得 Diamond自相矛盾,非法的 class 定义:没有 class 可以从 DDDD2 派生 [analog/example:就像没有有用的 class 可以直接派生派生自 A1A2:

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
};

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

-- 结束analog/example]

这将提供一种冻结虚拟继承的方法,为 classes 提供具有虚拟基础的有意义的私有继承(没有它派生的 classes 可以篡夺 class 及其私人基地 class).

final 的这种使用当然会冻结派生的 class 及其进一步派生的 class 中的虚拟基址的位置,避免仅因为虚拟基地不固定。

如果你不在你的 final class 中引入新的虚拟方法(只覆盖 parent class 的方法)你应该没问题(虚拟 table 将与 parent object 相同,因为它必须能够用指向 parent 的指针调用),如果你引入虚拟方法,编译器确实可以忽略 virtual 说明符并仅生成标准方法,例如:

class A {
    virtual void f();
};

class B final : public A {
    virtual void f(); // <- should be ok
    virtual void g(); // <- not ok
};

这个想法是每次在 C++ 中你可以调用方法 g() 你有一个 pointer/reference 其静态和动态类型是 B: static 因为方法没有除了 B 和他的 children 之外存在,动态的因为 final 确保 B 没有 children。出于这个原因,您永远不需要进行虚拟分派来调用 right g() 实现(因为只能有一个),并且编译器可能(并且应该)不添加它对于 B 的虚拟 table - 如果可以覆盖该方法,则强制这样做。据我所知,这基本上就是 final 关键字存在的全部要点