在C++中,覆盖现有的虚函数是否会破坏ABI?

13
我的库有两个类:一个基类和一个派生类。在当前版本的库中,基类有一个虚函数foo(),而派生类没有覆盖它。在下一个版本中,我想让派生类覆盖它。这会破坏ABI吗?我知道引入新的虚函数通常会破坏ABI,但这似乎是一个特殊情况。我的直觉是,这应该只是改变vtbl中的偏移量,而不是实际更改表的大小。
显然,由于C++标准没有强制要求特定的ABI,因此这个问题在某种程度上与平台有关,但实践中什么会破坏和维护ABI在大多数编译器中都是相似的。我对GCC的行为很感兴趣,但是如果更多的编译器可以回答这个问题,那就更有用了;)

1
如果你正在进行ABI,为什么不使用纯C作为接口呢? - sashoalm
3
因为这是一个旨在以惯用的 C++ 方式使用的 C++ 库。你认为 QT 应该仅提供 C 的 API 吗? :P - Joseph Garvin
5个回答

11

可能会有影响。

关于偏移量,你的观点是错误的。在vtable中的偏移量已经被确定了。会发生的事情是,派生类构造函数将使用新的v-table将该偏移处的函数指针替换为派生类的覆盖函数(通过切换类内的v-pointer)。因此,通常来说,它是ABI兼容的。

但是,由于优化,特别是函数调用的去虚拟化,可能会出现问题。

通常情况下,当你调用一个虚函数时,编译器会通过vpointer在vtable中进行查找。然而,如果它能够静态地推断出对象的确切类型,它也可以推断出确切的函数调用并删减掉虚拟的查找过程。

例如:

struct Base {
  virtual void foo();
  virtual void bar();
};

struct Derived: Base {
  virtual void foo();
};

int main(int argc, char* argv[]) {
  Derived d;
  d.foo(); // It is necessarily Derived::foo
  d.bar(); // It is necessarily Base::bar
}

在这种情况下,仅仅链接你的新库并不会捕获Derived::bar


1
这就是为什么虚拟方法应该始终是私有的原因。 - p12
1
@grumm143:不过这还是不够的。内联仍然可能发生。 - Matthieu M.
如何?虚方法仅能通过公共的、非虚拟的接口调用,而该接口不依赖于类的具体类型。虚调用只能在库内联编,但这显然不是问题。 - p12
@grumm143:啊,那你的意思是公共方法定义不会被暴露出来(以避免内联)。 - Matthieu M.
1
“派生类构造函数将使用派生类的重载函数,替换该偏移量处的函数指针”,构造函数不会干扰虚函数表。实际上,虚函数表是常量(但可能需要在加载时由动态链接器修复)。虚函数表从未被动态修改过。 - curiousguy
显示剩余2条评论

7

总的来说,这似乎不是一个普遍可靠的事情 - 就像你所说的C++ ABI非常棘手(甚至包括编译器选项)。

话虽如此,我认为在进行更改之前和之后可以使用g++ -fdump-class-hierarchy来查看父类或子类vtable的结构是否发生变化。如果它们没有变化,那么可以“相对”安全地假设您没有破坏ABI。


3
如果编译器能对一些函数调用进行虚函数优化,那么它就有效果:/ - Matthieu M.

3

是的,在某些情况下,添加虚函数的重新实现 更改虚函数表的布局。如果您正在从不是第一个基类(多重继承)的基类重新实现虚函数,则是这种情况:

// V1
struct A { virtual void f(); };
struct B { virtual void g(); };
struct C : A, B { virtual void h(); }; //does not reimplement f or g;

// V2
struct C : A, B {
    virtual void h();
    virtual void g();  //added reimplementation of g()
};

这将通过为 g() 添加一个条目来更改 C 的虚函数表的布局(感谢“Gof”在http://marcmutz.wordpress.com/2010/07/25/bcsc-gotcha-reimplementing-a-virtual-function/中的评论中首先引起我的注意)。
此外,正如其他地方提到的那样,如果你重写函数的类被你库的用户以静态类型等于动态类型的方式使用,则会出现问题。这可能是在你使用 new 后的情况:
MyClass * c = new MyClass;
c->myVirtualFunction(); // not actually virtual at runtime

或者将其创建在堆栈上:

MyClass c;
c.myVirtualFunction(); // not actually virtual at runtime

这是一种被称为“去虚拟化”的优化方法。如果编译器在编译时能够证明对象的动态类型,它将不会通过虚函数表发出间接引用,而是直接调用正确的函数。
现在,如果用户针对您库的旧版本进行编译,编译器将插入对虚方法的最终实现的调用。如果在您库的新版本中覆盖此虚函数,并在更高派生类中执行,则针对旧库编译的代码仍将调用旧函数,而无法在编译时证明对象的动态类型的新代码或代码将通过虚函数表进行处理。因此,给定类的实例可能会在运行时面临无法拦截的基类函数调用,从而可能创建类不变式的违规问题。

感谢分享这个信息。在我看来,Mark建议使用g++ -fdump-class-hierarchy应该是最好的选择,当然前提是有适当的回归测试;) - sehe

1
我的直觉是应该更改vtbl中的偏移量,而不实际更改表的大小。
好吧,你的直觉显然是错误的:
要么在vtable中有一个新条目用于覆盖者,所有后续条目都会移动并且表会增长,
要么没有新条目,vtable表示不会改变。
哪个是真的取决于许多因素。
无论如何:不要指望它。

0

注意:请参考在C++中,重写现有的虚函数是否会破坏ABI?这个案例,了解这个逻辑不成立的情况;

在我看来,马克的建议使用g++ -fdump-class-hierarchy将是赢家,在进行适当的回归测试之后。


覆盖应该不会改变 vtable 布局[1]。vtable 条目本身应该在库的 数据段中,所以对 的更改不应该造成问题。

当然,应用程序需要重新链接,否则如果消费者一直在使用对 &Derived::overriddenMethod 的直接引用,则存在 潜在的 破坏风险;我不确定编译器是否允许将其解析为 &Base::overriddenMethod,但最好还是小心为妙。

[1] 解释一下:这假定该方法一开始就是虚拟的


"覆盖重写东西不应该改变虚函数表的布局"是错误的。这取决于情况。 - curiousguy
@curiousguy 我认为我已经非常清楚地表达了。此外,我会链接相关资源以根据实际情况做出裁决。因为...它取决于实际情况。 - sehe
“我认为我已经非常清楚地表达了。” 对我来说不太清楚,抱歉。您是在说,对于单一继承,添加覆盖程序不会更改vtable的布局吗? - curiousguy
@curiousguy 是的,那是我的理解。如果不正确的话,为什么你不提供一个描述这种情况的答案,这样我们就可以点赞它? - sehe

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