纯虚拟函数和二进制兼容性

Pure virtual functions and binary compatibility

本文关键字:二进制 兼容性 函数 虚拟      更新时间:2023-10-16

现在,我知道在非叶类中添加新的虚拟函数通常是不好的,因为这会破坏任何未重新编译的派生类的二进制兼容性。然而,我有一个稍微不同的情况:

我有一个接口类和实现类被编译到一个共享库中,例如:

class Interface {
    public:
        static Interface* giveMeImplPtr();
        ...
        virtual void Foo( uint16_t arg ) = 0;
        ...
}
class Impl {
    public:
        ...
        void Foo( uint16_t arg );
        ....
}

我的主要应用程序使用这个共享库,基本上可以写成:

Interface* foo = Implementation::giveMeImplPtr();
foo->Foo( 0xff );

换句话说,应用程序没有任何派生自Interface的类,它只是使用它

现在,假设我想用Foo( uint32_t arg )过载Foo( uint16_t arg ),我可以安全地做吗:

 class Interface {
    public:
        static Interface* giveMeImplPtr();
        ...
        virtual void Foo( uint16_t arg ) = 0;
        virtual void Foo( uint32_t arg ) = 0;
        ...
}

并且在不必重新编译应用程序的情况下重新编译我的共享库?

如果是的话,我需要注意哪些不寻常的注意事项吗?如果没有,除了对库进行更新,从而破坏向后兼容性之外,我还有其他选择吗?

简单的答案是:不。任何时候更改类在all中定义,您可能会失去二进制兼容性。添加非虚拟函数或静态成员通常是安全的在实践中,尽管在形式上仍有未定义的行为,但仅此而已。其他任何东西都可能破坏二进制兼容性。基本上取决于对象的大小和形状,包括vtable。添加一个虚拟函数肯定会更改vtable,它的更改方式取决于编译器。

在这种情况下,需要考虑的另一点是,您不仅提出了破坏ABI的更改,而且还提出了在编译时很难检测到的破坏API的更改。如果这些不是虚拟函数,ABI兼容性也不是问题,那么在您更改后,类似于:

void f(Interface * i) {
  i->Foo(1)
}

将悄悄地调用您的新函数,但前提是重新编译该代码,这可能会使调试变得非常困难。

您正试图描述流行的"使类不可派生"技术,该技术用于保持二进制兼容性,例如在Symbian C++API中使用(查找NewL工厂方法):

  1. 提供工厂功能
  2. 将C++构造函数声明为private(并且非导出的非内联,并且该类不应该具有友元类或函数),这使得该类不可派生,然后您可以:

    • 在类声明的末尾添加虚拟函数
    • 添加数据成员并更改类的大小

此技术仅适用于GCC编译器,因为它将虚拟函数的源顺序保存在二进制级别。

解释

虚拟函数由对象的v-table中的偏移量调用,而不是由损坏的名称调用。如果您只能通过调用静态工厂方法来获得对象指针,并保留所有虚拟函数的偏移量(通过保存源顺序,在末尾添加新方法),那么这将是向后二进制兼容的。

如果您的类具有公共构造函数(内联或非内联),则兼容性将被破坏:

  • inline:应用程序将复制类的旧v表和旧内存布局,这些布局将与新库中使用的布局不同;如果您调用任何导出的方法或将对象作为参数传递给该方法,则这可能会导致分段错误的内存损坏;

  • 非内联:情况更好,因为您可以通过在叶类声明的末尾添加新的虚拟方法来更改v-table,因为如果您要加载新的库版本,链接器将在客户端重新定位派生类的v-table布局;但是您仍然无法更改类的大小(即添加新字段),因为在编译时对大小进行了硬编码,并且调用新版本的构造函数可能会破坏客户端堆栈或堆上相邻对象的内存。

工具

尝试使用abi合规性检查工具来检查您的类库版本在Linux上的向后二进制兼容性。

当我处于类似的情况时,我发现MSVC颠倒了重载函数的顺序,这对我来说非常神奇。根据您的示例,MSVC将构造v_table(二进制),如下所示:

virtual void Foo( uint32_t arg ) = 0;
virtual void Foo( uint16_t arg ) = 0;

如果我们扩展一下你的例子,比如:

class Interface {
    virtual void first() = 0;
    virtual void Foo( uint16_t arg ) = 0;
    virtual void Foo( uint32_t arg ) = 0;
    virtual void Foo( std::string arg ) = 0;
    virtual void final() = 0;
}

MSVC将构造以下v_table:

    virtual void first() = 0;
    virtual void Foo( std::string arg ) = 0;
    virtual void Foo( uint32_t arg ) = 0;
    virtual void Foo( uint16_t arg ) = 0;
    virtual void final() = 0;

Borland建筑商和GCC没有改变订单,但

  1. 他们在我测试的那个版本中没有这样做
  2. 如果你的库是由GCC编译的(例如),而应用程序将由MSVC编译,那将是一个巨大的失败

结束。。。永远不要依赖二进制兼容性。类的任何更改都必须导致使用它重新编译所有代码。