将一个派生的C++类声明为"final",会改变ABI吗?

13

我想知道,将现有的派生C++类标记为final以允许进行非虚函数化优化,是否会在使用C ++ 11时改变ABI。我的预期是它不应该产生任何影响,因为我认为这主要是对编译器有关如何优化虚函数的提示。因此我看不出它会改变结构体或虚函数表的大小,但也许我漏掉了什么?

我知道这会改变API,因此进一步派生于此派生类的代码将不再起作用,但在这种情况下,我只关心ABI。


2
虽然不太规范,但GCC似乎充分利用了这一点提示。我认为它不会影响ABI。通过基类指针或引用进行的调用仍然必须正常工作。 - StoryTeller - Unslander Monica
4
ABI 不属于 C++ 标准,因此这将是实现定义(或未定义)。仍然是一个有效的问题,询问编译器在实践中会做什么。我想,由于许多其他因素也能影响它(例如将某些内容从 public 更改为 private),这也可能会影响它。 - HostileFork says dont trust SE
2
对于 itanium C++ abi,每个可能的最终派生对象都有一组虚表:A 的虚表,B 中 A 的虚表等等……因此编译器总是知道它生成的所有虚表的最终覆盖者是什么。因此,final 关键字不会改变这一点。 - Oliv
1
在我看来,这更接近于一种猜测。我对这个主题没有很好的掌握。 - Oliv
2
@Oliv "_对于Itanium C++ ABI,(...)" 可能适用于所有现有的C++实现。也可能适用于所有使用虚函数表的C++实现。 - curiousguy
显示剩余3条评论
3个回答

5

函数声明中的final关键字X::f()表示该声明不能被覆盖,因此所有调用该声明的函数都可以提前绑定(不包括那些调用基类中声明的函数):如果一个虚函数在ABI中被标记为final,则生成的虚表可能与没有final标记的几乎相同类别生成的虚表不兼容:可以假定命名标记为final的声明的虚函数调用是直接的,试图使用虚表条目(在没有final标记的ABI中应该存在)是非法的。

编译器可以利用final保证来减少虚表的大小(有时会增长很多),通过不添加通常会添加的新条目,并且必须符合非final声明的ABI。

条目是针对覆盖函数声明而添加的,而不是针对(固有的)主基类或具有非平凡协变返回类型(在非主基类上协变的返回类型)的情况。

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

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

这些属性对于派生类是完整对象(不是子对象)、最派生对象或基类的情况都是正确的。(它们是为未知来源的指针在ABI级别上保证的类不变式。)

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

平凡协变

例如:与*this具有相同类型的情况下协变;如:

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

在所有创建的D(子)对象中,B固有且不变地是主要的:在同一地址上存在B。因此D*B*转换很简单,所以协变性也很简单:这是一个静态类型问题。
每当这种情况发生时(简单向上转型),协变性会在代码生成级别消失。
结论
在这些情况下,覆盖函数的声明类型与基类的类型存在微小差异:
  • 所有参数几乎相同(只有this类型上存在微小差异)
  • 返回类型几乎相同(只有指针(*)类型返回值可能存在差异)
(*) 因为按引用返回与按指针返回在ABI级别上完全相同,因此不会特别讨论引用。
因此,对于派生声明不会添加vtable条目。
(使类变为final不会简化vtable。)
永远不要选择主基类
显然,类只能有一个子对象,包含特定的标量数据成员(如vptr (*)),偏移为0。具有标量数据成员的其他基类将位于非平凡偏移量处,需要指针进行非平凡的导出到基础类的转换。因此,多个有趣(**)的继承将创建非主要基类。
(*) vptr不是用户级别的普通数据成员;但在生成的代码中,它几乎是一个已知编译器的普通标量数据成员。
(**) 非多态基类的布局在此处不很有趣:为了vtable ABI的目的,非多态基类被视为成员子对象,因为它不会以任何方式影响vtables。
概念上最简单的有趣示例是非主要且非平凡指针转换:
struct B1 { virtual void f(); };
struct B2 { virtual void f(); };
struct D : B1, B2 { };

每个基类都有自己的vptr标量成员,这些vptr有不同的用途:
- `B1::vptr`指向`B1_vtable`结构 - `B2::vptr`指向`B2_vtable`结构
这两者具有相同的布局(因为类定义是可叠加的,ABI必须生成可叠加的布局),但它们严格不兼容,因为:
- vtables具有不同的条目: - `B1_vtable.f_ptr`指向`B1::f()`的最终覆盖函数 - `B2_vtable.f_ptr`指向`B2::f()`的最终覆盖函数 - `B1_vtable.f_ptr`必须与`B2_vtable.f_ptr`在相同的偏移量上(从其各自的vptr数据成员中)。
`B1::f()`和`B2::f()`的最终覆盖函数本质上并非始终等效(***):它们可以具有执行不同操作的不同最终覆盖函数。
(*) 如果两个可调用运行时函数具有在ABI级别上相同的可观察行为,则它们是等效的。 (等效的可调用函数可能没有相同的声明或C ++类型。)
(**) 可调用的运行时函数是任何入口点:任何可以被调用/跳转的地址;它可以是正常的函数代码、thunk/trampoline或多个入口函数中的特定入口。可调用的运行时函数通常没有可能的C++声明,例如“使用基类指针调用最终覆盖函数”。
(***) 有时它们在进一步派生的类中具有相同的最终覆盖函数:
struct DD : D { void f(); }

在定义 D 的 ABI 方面,这并不是有用的。

因此我们可以看到,D 可证明地 需要一个非主多态基类;通常情况下,它将是 D2;第一个指定的多态基类 (B1) 将成为主要的。

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

因此,D 的成员函数的参数不能与 B2 的成员函数的参数相等,因为隐式的 this 并不是平凡可转换的;所以:

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

由于 D_B1_vtable 是从 B1_vtable 构建的,指向 D_B1_vtable 的指针自然也是指向 B1_vtable 的指针,并且虚指针值相同。

请注意,在理论上,如果通过 B2 基类使所有 D::g() 的虚拟调用负担得起(只要不使用非平凡协变),则可以省略 D_B1_vtableD::g() 条目。

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

不是固有的主要基类

普通(非虚拟)继承就像成员一样简单:

  • 非虚拟基对象是一个对象的直接基类(这意味着当不使用虚拟继承时,任何虚拟函数都有恰好一个最终覆盖者);
  • 非虚拟基类的放置是固定的;
  • 没有虚拟基类子对象的基类对象,就像数据成员一样精确地构造为完整对象(对于每个定义的 C++ 构造函数,它们都有恰好一个运行时构造函数代码)。
更微妙的继承情况是虚拟继承: 虚基类子对象可以是许多基类子对象的直接基类。这意味着虚拟基类的布局仅在最派生类级别确定:虚拟基类在最派生对象中的偏移量是众所周知的,并且是编译时常量; 在任意派生类对象(可能是或可能不是最派生对象)中,它是在运行时计算的值。
由于C++支持统一和重复的继承,因此无法知道该偏移量:
- 虚拟继承是统一的:在最派生对象中,给定类型的所有虚拟基类都是同一个子对象; - 非虚拟继承是重复的:所有间接非虚拟基类在语义上是不同的,因为它们的虚拟成员不需要具有公共的最终覆盖者(与Java相比,这是不可能的(据我所知))。
在这里,DD有两个不同的B :: f()的最终覆盖者:
- DD :: D1 :: f()是DD :: D1 :: B :: f()的最终覆盖者 - DD :: D2 :: f()是DD :: D2 :: B :: f()的最终覆盖者
分别存储在两个不同的vtable条目中。
重复继承,其中您间接地从给定类多次派生,意味着多个vptrs、vtables和可能不同的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 { };

只有一个DDD::VB,但在DDD中有两个可观察到的不同的D子对象,它们对于D::g()具有不同的最终覆盖者。无论C++风格的语言(支持虚拟和非虚拟继承语义)是否保证不同的子对象具有不同的地址,DDD::DD1::D的地址不能与DDD::DD2::D的地址相同。

因此,在支持基类统一和重复的任何语言中,D中的VB的偏移量都无法固定。

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

然而,对于具有非不变量标量成员的虚基类,即具有标识符的常规数据成员,即占用不同字节范围的成员:这些“真实”数据成员不能叠加在其他任何东西上。因此,具有数据成员的虚基类子对象(具有一个由C++或任何您正在实现的其他不同C++风格的语言保证的地址不同的成员)必须放置在不同的位置:具有数据成员的虚基类通常具有固有的非平凡偏移量。

所以我们看到,当作为虚基类使用时,“几乎为空”的类(没有数据成员但有vptr的类)是特殊情况:

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

这意味着当在虚基类中覆盖虚函数时,始终假定需要调整,但在某些情况下将不需要调整。

一个"morally virtual base"是一个基类关系,涉及虚继承(可能还包括非虚继承)。进行从派生到基类的转换,特别是将指向派生类"D"的指针"d"转换为指向基类"B"的指针时,需要进行转换...
- 非"morally virtual base"在任何情况下都是可逆的: - 一个"D"对象的子对象"B"的身份和"D"(可能是自身的)之间存在一对一的关系; - 反向操作可以使用一个"static_cast((B*)d)"来执行: "static_cast((B*)d)" 就等于 "d"; - 在完全支持统一和重复继承的任何类C ++语言中,"morally virtual base"本质上是不可逆的(尽管在具有简单层次结构的常见情况下是可逆的)。注意: - "static_cast((B*)d)" 是不允许的; - 对于简单情况,"dynamic_cast((B*)d)" 可以工作。
因此,我们称基于"morally virtual base"的返回类型协变为"virtual covariance"。当使用"virtual covariance"进行覆盖时,调用约定不能假定基类将位于已知的偏移量处。因此,"virtual covariance"固有地需要一个新的虚表条目,无论覆盖声明是否在固有主体中。
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
};

在类型为D的子对象中,VB可能在偏移量为零的位置上,不需要进行调整(例如对于类型为D的完整对象),但是在指向D的指针处理中,并不能确定是否是这种情况。
当Da::g()用虚协变重写Ba::g()时,必须假定是一般情况,因此Da::g()需要严格的新vtable条目,因为在一般情况下,无法从VB到D的指针进行反向指针转换。
Ba是Da中固有的主要部分,因此共享/增强了Ba::vptr的语义:
- 对该标量成员有附加的保证/不变量,扩展了vtable; - Da不需要新的vptr。
因此,Da_vtable(与Ba_vtable天然兼容)需要两个不同的条目来调用g()的虚拟调用:
- 在vtable的Ba_vtable部分:Ba::g() vtable条目:使用隐式this参数Ba*调用Ba::g()的最终覆盖程序并返回VB*值。 - 在vtable的新成员部分:Da::g() vtable条目:使用隐式this参数Da*调用Da::g()的最终覆盖程序(这在本质上与C++中Ba::g()的最终覆盖者相同),并返回D*值。
请注意,这里实际上没有ABI自由:vptr/vtable设计的基本原理及其内在属性意味着必须有这些多个条目,即使在高层语言中是唯一的虚函数。
请注意,将虚函数体设置为内联并对ABI可见(以便让具有不同内联函数定义的类的ABI成为不兼容的,从而允许更多信息来指导内存布局)也无济于事,因为内联代码只定义调用未覆盖的虚函数时会发生什么。ABI决策不能基于可能被派生类覆盖的选择。
[例如,虚协变的示例最终只是平凡的协变,因为在完整的D中,VB的偏移量是平凡的,在这种情况下不需要任何调整代码:
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中是主要的。
通用情况下的调用约定是这样的,这种代码生成将在返回不同类的对象的代码中失败。
--示例结束
但是,如果Da :: g()在ABI中是final的,则只能通过VB * g();声明进行虚拟调用:协变纯静态,派生到基础转换将在虚拟thunk的最后一步在编译时完成,就好像从未使用虚拟协变一样。
可能扩展final
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
}; 

现在支持两种继承形式的C++变体不必在所有派生类中具有几乎直接访问的C++语义:
struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D, virtual final VB {
  // DD () : VB() { } // implicit definition
}; 

这里,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"
}; 

虚拟性冻结使得合并 Diamond::DD::VBDiamond::DD2::VB 变得非法,但是 VB 的虚拟性要求合并,这使得 Diamond 成为了一个矛盾、非法的类定义:没有一个类可以同时从 DDDD2 派生 [类比/例子:就像没有一个有用的类可以直接从 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是抽象的,并且没有派生类,使得该ABC(抽象基类)非常愚蠢,因为任何指向UselessAbstract的指针都可以成为空指针。

-- 结束模拟/示例

这将提供一种冻结虚继承的方式,以提供具有虚基类的类的有意义的私有继承(否则派生类可以夺取类与其私有基类之间的关系)。

当然,final的这种使用会冻结虚基类在派生类及其进一步派生类中的位置,避免了仅因虚基类的位置未固定而需要额外的vtable条目。


2
你能为这个做一个tl;dr吗? - Mark Ransom
1
@MarkRansom 理论上,final 关键字允许在一些特殊情况下缩短 vtable,因此不排除可能导致 ABI 不兼容的情况。 - curiousguy
我甚至没有考虑过显然毫无用处的虚拟性情况,比如声明一个新的虚拟final函数或新的虚拟final继承。 - curiousguy

1
我认为添加 final 关键字不会破坏ABI,然而从现有类中删除它可能会使一些优化无效。例如,请考虑以下内容:
// 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%确定,其中一些是推测。

2
移除 final 不会违反ODR吗?此时您将获得未定义的行为。 - Mark Ransom
2
你不能有两个相冲突的同一对象的定义。你的例子正是为什么不允许这样做的原因。你说的是编译两次,一次使用旧的定义,一次使用新的定义。 - Mark Ransom
标准库希望您在获取新的头文件集时重新编译所有内容。特别是如果添加了新的私有成员,您将获得不同的对象大小,这对于编译器非常重要。 - Mark Ransom
1
类的大小在添加私有成员函数时不会改变。标准库并不希望您在获取新的头文件集时重新编译所有内容,因为这将是一个完全不合理的要求。大多数人无法重新编译所有内容,例如如果他们向用户提供二进制文件。来源:我维护libc++,我们不希望人们重新编译。这被称为ABI兼容性,我们努力不破坏它。 - Louis Dionne
1
ABI兼容性是供应商可以自由保证(或不保证)的内容。顺便说一句,这非常难以保证,并且它高度限制了我们在库中可以进行哪些更改。我不知道有多少个具有C++接口并提供超出标准库的ABI稳定性的库。标准没有指定与ABI相关的任何内容。然而,关心ABI稳定性的供应商确保我们不规范需要实现ABI断裂的更改...尽管有时会出现问题,我们会度过难关。 - Louis Dionne
显示剩余6条评论

0
如果您的final类中没有引入新的虚方法(只重写父类的方法),那么应该没问题(虚表将与父对象相同,因为它必须能够使用指向父对象的指针进行调用)。如果您引入了虚方法,则编译器确实可以忽略virtual修饰符并仅生成标准方法,例如:
class A {
    virtual void f();
};

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

这个想法是,每次在 C++ 中调用方法 g() 时,您都有一个指向或引用的静态和动态类型为 B 的指针:静态是因为该方法除了 B 及其子类以外不存在,动态是因为 final 确保 B 没有子类。因此,您永远不需要进行虚拟调度来调用 正确的 g() 实现(因为只能有一个),编译器可能(并且应该)不会将其添加到 B 的虚拟表中 - 如果该方法可以被覆盖,则必须这样做。就我所知,这基本上就是 final 关键字存在的全部意义。


请注意,即使函数已经存在,也不能保证ABI不会改变,尽管合理的做法是不会改变,因为每个编译器如何实现“virtual”都没有指定。 - pqnet
@curiousguy,我确实喜欢父/子隐喻,并且没有给出正式和完整的答案,因为这里已经有一个相当不错的答案了。 - pqnet
@curiousguy 是的,没错。基本上我期望表达 A 接口的 B 虚拟表格是正常的,但如果 B 接口与 A 不同,那么情况可能就不太一样了。 - pqnet
1
好的,那我删除之前的评论以减少混乱。 - curiousguy
1
@curiousguy 这就是关键所在。他可能依赖于它们存在于虚拟方法表中,在某些其他方法(例如,COM)中调用它们,因为他写了 virtual,但编译器可能会聪明地优化掉它(因为它知道它是 final)。 - pqnet
现在我理解了。也许你可以在答案中添加意图说明。“_present in the virtual method table to invoke them in some other method_” 的翻译是“在虚方法表中存在以便在其他方法中调用它们”。 - curiousguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接