二进制兼容性问题C++策略

1. 定义

如果与旧版库动态链接的程序无需重新编译就可以与新版本库一起运行,则这个库就是二进制兼容性的。

如果为了和新版本库一起运行而需要将程序重新编译,但是不需要任何进一步的修改,则该库就是源码兼容

二进制兼容性解决了很多问题。 它使为特定平台开发的软件变得更加容易。 如果不能确保发行版之前的二进制兼容性, 人们将会被迫提供静态链接的二进制文件。

静态二进制文件不好的原因如下

  • 浪费资源(尤其是存储)
  • KDE 项目中,不允许程序从库的bug修复或者扩展中收益,我们将在核心库 (kdelibs, kdepimlibs)的主要版本的生命周期内提供二进制兼容性

2. 关于ABI的注意事项

本文适用于被编译器(可以构建KDE)使用的大多数C++ ABI。 ABI 主要基于Itanium C ++ ABI草案,自GCC C++编译器从其支持的所有平台中的3.4版开始使用。 有关Microsoft Visual C++处理方案的信息主要来自于这篇约定的本文(这是迄今为止有关MSVC ABI和名称处理的最完整信息)。

此处指定的某些约束可能不适用于给定的编译器。 此处的目标是列出编写跨平台C ++代码时最严格的一组条件,这些代码打算用几种不同的编译器进行编译。

3. 能做与不能做

能做

  • 添加新的非虚函数,包括信号和插槽以及构造函数。
  • 为一个类添加新的枚举
  • 为现有的枚举添加新的枚举元素
    • 例外:如果这导致编译器为枚举选择了一个过大的基础类型(underlying type),那么更改后将与二进制不兼容。不幸的是,编译器有一些选择基础类型的余地,因此从API设计的角度来看,建议添加一个例如 Max 的枚举元素,该枚举元素具有明确的足够大的值(=255,=1<<15等),这样创建了一个枚举值的区间,该区间确保可以适合所选的基础类型,无论是哪种类型。
  • 重新实现在主基类层次结构中定义的虚函数(即,在第一个非虚基类或该类的第一个非虚基类中定义的虚函数,依此类推)如果可以肯定的是,与先前版本的库链接的程序是在基类中调用的虚函数而不是派生类。
    • 例外:如果重载函数具有协变返回类型(Covariant returns type:基类中虚函数原来的返回类型是指向类的指针或引用,派生类重载次虚函数,新的返回类型是指向派生类的指针或引用,重载的方法就可以改变返回类型).
  • 改变一个内联函数或者使内联函数变成非内联函数, 如果可以安全的确保链接旧版本库的程序调用的是旧的实现。三思后行
  • 删除私有的非虚函数,如果他们没有被任何内联函数调用或者从未使用过
  • 删除私有的静态成员,如果他们没有被任何内联函数调用或者从未使用过
  • 添加新的静态成员
  • 更改方法的默认参数。但是,他需要重新编译才能使用实际的新的默认参数值
  • 添加新的类
  • 导出以前未导出的类
  • 在类中添加或删除友元声明
  • 重命名保留的成员类型
  • 扩展保留的位字段, 前提是这不会导致位字段超过其基础类型的范围 (8 bits for char & bool, 16 bits for short, 32 bits for int, 等等)
  • 如果该类已经从 QObject 继承,则将 Q_OBJECT 宏添加到该类中
  • 添加 Q_PROPERTYQ_ENUMSQ_FLAGS 宏,因为它仅修改由moc生成的元对象,而不修改类本身

不能做

对于已有的类

  • 取消导出或者删除导出的类
    unexport class
    原因:取消导出之后,类的符号信息不会被添加到库的导出符号列表中,因此其他的库和程序将不能发现并使用它
  • 以任何方式更改类层次结构(添加、删除或者重新排序父类)
    change_class_hiuerarchy
    原因:类中成员数据的大小和/或顺序更改,导致现有代码分配过多或过少的内存,以错误的偏移量读取/写入数据。
  • 移除 final
    remove_class_finality
    官方这里提供的例子有点bug
    原因:final 修饰符标识的允许编译器在调用虚函数的时候使用非虚拟化优化,也就是它将绕过虚函数表直接调用该函数。移除 final 会删除此优化选项,但是已经构建的程序已经根据 final 进行了优化
    不懂虚函数表的可以看一下这篇文章,介绍的很清晰,看完下面这篇文章,也能理解更改类层次结构的原因
    C++中的虚函数(表)实现机制以及用C语言对其进行的模拟实现

对于模板类

  • 以任何形式更改模板的参数(添加、删除或者重新排序)
    change_template_class_arg

对于任何类型的现有函数

  • 取消导出
    unexport_function
  • 删除它
    删除现有声明的函数实现,符号来自函数的实现,因此实际上是函数。
  • 转换成内联(这包括将成员函数的主体移至类定义中, 即使没有使用 inline 关键字)
    inline_function
  • 添加一个重载(BC, 但不是SC:使 &func 二义性), 将重载添加到已经重载的函数中就可以(对 &func 的任何使用都已经需要强制转换)
  • 更改签名(就是把函数名字去掉以后剩下的东西,例如返回值、参数、调用方式等)
    • 更改参数列表中参数的任何类型,包括更改现有参数的const / volatile限定符(相反,添加新方法)
      change_the_parameters_of_function
    • 更改函数的const / volatile限定词
    • 更改某些函数或数据成员的访问权限,例如从私有到公共。 对于某些编译器,此信息可能是签名的一部分。 如果需要使私有函数受保护甚至是公开,则必须添加一个新函数来调用私有函数。
      change_access_rights
    • 更改成员函数的CV限定词:应用于函数本身的const和/或volatile。
      change_cv_quakifiers
    • 即使此参数具有默认参数,也可以使用其他参数扩展函数。 请参阅以下有关如何避免此问题的建议
    • 以任何方式更改返回类型
      change_return_type
    • 例外:用extern“ C”声明的非成员函数可以更改参数类型(非常小心)。

对于虚成员函数

这一部分,只要明白了之前提供的关于虚函数表的链接内容,基本都很好理解

  • 向没有任何虚函数或虚基的类中添加虚函数。
    add_virtualmember
  • 向非尾端类(non-leaf classes)添加新的虚函数,因为这会破坏子类。
    add_new_virtuals
  • 无论出于什么原因,都不能添加新的虚函数,甚至仅仅只是向尾端类添加虚函数, 这样做都可能会重新排序现有的虚函数表, 从而破坏二进制兼容性
  • 在类声明中更改虚拟函数的顺序。
    change_order_virtual
  • 重载基类中的虚函数(之前没有重载的)
    override_virtual
  • 重载一个存在虚函数,如果该重载函数具有协变返回类型
    override_virtual2
  • 删除虚函数,即使它是从基类重新实现的虚函数

对于静态非私有成员或非静态非私有公共数据

  • 删除或者取消导出
  • 改变它的类型
    change_datatype
  • 改变它的 CV-限定符
    change_cv

对于非静态成员

  • 将新的数据成员添加到现有的类中。
  • 更改类中非静态数据成员的顺序。
  • 更改成员的类型(签名除外)
  • 从现有的类中删除现有的非静态数据成员。

应该做的

如果需要添加扩展/修改现有函数的参数列表,则需要添加新函数而不是新参数。 在这种情况下,您可能需要添加一条简短说明,即这两个函数应在库的更高版本中与默认参数合并:

void functionname( int a );
void functionname( int a, int b ); //BCI: merge with int b = 0

为了使将来可以扩展的类,您应该遵循以下规则:

  • 添加d指针。 见下文。
  • 基类的析构函数要设计为非内联的 virtual 函数
  • QObject 派生类中的重新实现 event 事件,即使该函数的主体只是调用基类的实现。 这是专门为避免由于添加重新实现的虚函数而引起的问题。
  • 使所有构造函数都是非内联的。
  • 编写复制构造函数和赋值运算符的非内联实现,除非无法通过值复制该类(例如,不能继承自QObject的类)

4. 开发库的技术

编写库时最大的问题是,不能安全地添加数据成员,因为这会改变包含类(包括子类)的对象的每个类,结构或数组的大小和布局。

4.1 位标志

这一部分不是很懂, 截一下原图,之后再好好理解

bitflags

4.2 使用d指针

位标记和预定义的保留变量是不错的选择,但还远远不够。 这就是d指针技术发挥作用的地方。 “d指针” 的名称源于Trolltech的Arnt Gulbrandsen,他首先将该技术引入了Qt,使其成为最早的C ++ GUI库之一,即使在更大的发行版之间也保持二进制兼容性。 看到它的每个人都迅速将该技术用作KDE库的通用编程模式。 能够在不破坏二进制兼容性的情况下将新的私有数据成员添加到类中是一个绝妙的技巧。

备注:d指针模式已在计算机科学历史上以不同的名称多次描述,例如 pimpl, handle/body or cheshire cat

实现

在类 Foo 的类定义中,添加一个前置声明

class FooPrivate;

和一个私有的 d-pointer

private:
    FooPrivate* d;

FooPrivate 类本身仅在类实现的文件(通常为 *.cpp)中定义,例如:

class FooPrivate {
public:
    FooPrivate()
        : m1(0), m2(0)
    {}
    int m1;
    int m2;
    QString s;
};

您现在要做的就是在构造函数或init函数中使用以下方法创建私有数据:

d = new FooPrivate;

并且在析构中删除

delete d;

在大多数情况下,您将需要使dpointer不变,以捕获意外修改或复制它的情况,从而使您失去对私有对象的所有权并造成内存泄漏:

private:
    FooPrivate* const d;

这使您可以修改d指向的对象,但不能在初始化后修改指针的值。

但是,您可能不希望所有成员变量都存在于私有数据对象中。 对于经常使用的成员,将它们直接放入类中会更快,因为内联函数无法访问d指针数据。 还应注意,尽管在d指针本身中已声明为公共,但d指针涵盖的所有数据均为“私有”。 对于公共或受保护的访问,请同时提供set和get函数。 例

QString Foo::string() const
{
    return d->s;
}

void Foo::setString( const QString& s )
{
    d->s = s;
}

也可以将d指针的私有类声明为嵌套的私有类。 如果使用此技术,请记住,嵌套的私有类将继承包含的导出类的公共符号可见性。 这将导致在动态库的符号表中命名私有类的功能。 您可以在嵌套私有类的实现中使用 Q_DECL_HIDDEN 来手动重新隐藏符号。 从技术上讲,这是对ABI的更改,但不会影响KDE开发人员支持的公共ABI,因此可能会隐藏隐藏错误的私有符号,而不会发出进一步的警告。

5. 常见问题解决方案

5.1 在没有d指针的情况下将新数据成员添加到类中

如果您没有可用的位标志,保留的变量且也没有d指针,但是您绝对必须添加一个新的私有成员变量,那么仍然存在一些可能性。 如果您的类继承了QObject,则可以例如将其他数据放在一个特殊的子对象中,并通过遍历子对象列表进行查找。 您可以使用QObject :: children()访问子级列表。 但是,更简便,通常更快的方法是使用哈希表来存储对象与额外数据之间的映射。 为此,Qt提供了一个基于指针的字典,称为QHash(或Qt3中的Template:Qt3)。

Foo类的类实现中的基本技巧是:

  • 创建一个私有数据类FooPrivate。
  • 创建一个静态 QHash <Foo *,FooPrivate *>
  • 请注意,有些编译器/链接器(不幸的是,几乎所有的)都无法在共享库中创建静态对象。 他们只是忘了调用构造函数。 因此,您应该使用Q_GLOBAL_STATIC宏来创建和访问该对象:

    // BCI: Add a real d-pointer
    typedef QHash<Foo *, FooPrivate *> FooPrivateHash;
    Q_GLOBAL_STATIC(FooPrivateHash, d_func)
    static FooPrivate *d(const Foo *foo)
    {
        FooPrivate *ret = d_func()->value(foo);
        if ( ! ret ) {
            ret = new FooPrivate;
            d_func()->insert(foo, ret);
        }
        return ret;
    }
    static void delete_d(const Foo *foo)
    {
        FooPrivate *ret = d_func()->value(foo);
        delete ret;
        d_func()->remove(foo);
    }
    

现在,您可以像以前的代码一样简单地在类中使用d指针,只需对d(this)进行函数调用即可。 例如:

d(this)->m1 = 5;

析构中添加一行:

delete_d(this);
  • 不要忘记添加一个BCI注释,以便可以在库的下一版本中删除该hack。
  • 不要忘记在下一类中添加d指针。

5.2 添加重新实现的虚函数

如前所述,只有在与前一版本链接的程序可以安全地重新实现在基类之一中定义的虚函数的情况下,才可以安全地重新实现在基类之一中定义的虚函数。 这是因为,如果编译器可以确定要调用的函数,则有时会直接调用虚拟函数。 例如,如果您有

void C::foo()
{
    B::foo();
}

然后直接调用 B::foo()。 如果 类B 继承自实现 foo()类A,而 B 本身没有重新实现,则 C::foo() 实际上将调用 A::foo()。 如果该库的较新版本添加了 B::foo() ,则 C::foo() 仅在重新编译后才调用它。

另一个更常见的示例是:

B b;      // B derives from A
b.foo();

那么对 foo() 的调用将不会使用虚函数表。 这意味着如果库中不存在 B::foo() ,但现在存在,则使用较早版本编译的代码仍将调用 A::foo()

如果不能保证无需重新编译就能继续工作,请将功能从 A::foo() 移至新的受保护函数 A::foo2() 并使用以下代码:

void A::foo()
{
    if( B* b = dynamic_cast< B* >( this ))
        b->B::foo(); // B:: is important
    else
        foo2();
}
void B::foo()
{
    // added functionality
    A::foo2(); // call base function with real functionality
}

对类型B(或继承)的对象的 A::foo() 的所有调用将导致调用 B::foo()。 唯一无法正常工作的情况是对 A::foo() 的调用,该调用显式指定了 A::foo(),但 B::foo() 则调用了 A::foo2(),并且不应有其他地方 这样做。

5.3 使用新的类

一种相对简单的“扩展”类的方法可以编写一个替换类,该替换类还将包括新功能(并且可以从旧类继承来重用代码)。 当然,这需要使用该库来适应和重新编译应用程序,因此,这种方法不可能修复或扩展针对旧版本库而编译的应用程序所使用的类的功能。 但是,特别是对于小型和/或对性能至关重要的类,编写它们可能会更简单,而不必确保它们将来会易于扩展;如果以后有需要,请编写一个新的替代类,以提供新的功能或更好的功能 性能。

5.4 向子类添加新的虚函数

这种技术是使用新类的一种情况,这种新类可以在需要向应该保持二进制兼容性的类中添加新的虚拟函数,并且没有继承自该类的也可以保持二进制兼容性的类时提供帮助(即,所有继承的类) 在应用程序中)。 在这种情况下,可以添加一个继承自原始类的新类,并将其添加进来。 使用新功能的应用程序当然必须进行修改以使用新类。

class A {
public:
    virtual void foo();
};
class B : public A { // newly added class
public:
    virtual void bar(); // newly added virtual function
};
void A::foo()
{
    // here it's needed to call a new virtual function
    if( B* this2 = dynamic_cast< B* >( this ))
        this2->bar();
}

当还有其他继承的类也必须保持二进制兼容性时,则无法使用此技术,因为它们必须从新类继承。

5.5 使用信号代替虚函数

Qt的信号和槽是使用由Q_OBJECT宏创建的特殊虚方法调用的,它存在于从QObject继承的每个类中。 因此,添加新的信号和槽不会影响二进制兼容性,并且可以使用信号/槽机制来模拟虚函数。

class A : public QObject {
Q_OBJECT
public:
    A();
    virtual void foo();
signals:
    void bar( int* ); // added new "virtual" function
protected slots:
    // implementation of the virtual function in A
    void barslot( int* );
};

A::A()
{
    connect(this, SIGNAL( bar(int*)), this, SLOT( barslot(int*)));
}

void A::foo()
{
    int ret;
    emit bar( &ret );
}

void A::barslot( int* ret )
{
    *ret = 10;
}

函数 bar() 的作用类似于虚函数,barslot() 实现其实际功能。 由于信号的返回值无效,因此必须使用参数返回数据。 由于只有一个槽连接到从槽返回信号的信号,因此这种方式可以正常工作。 注意,要使Qt4起作用,连接类型必须为 Qt::DirectConnection

如果继承的类要重新实现 bar() 的功能,则必须提供自己的槽:

class B : public A {
Q_OBJECT
public:
    B();
protected slots: // necessary to specify as a slot again
    void barslot( int* ); // reimplemented functionality of bar()
};

B::B()
{
    disconnect(this, SIGNAL(bar(int*)), this, SLOT(barslot(int*)));
    connect(this, SIGNAL(bar(int*)), this, SLOT(barslot(int*)));
}

void B::barslot( int* ret )
{
    *ret = 20;
}

现在,B::barslot() 的作用类似于 A::bar() 的虚拟实现。 请注意,有必要再次将 barslot() 指定为B中的槽,并且在构造函数中,有必要先断开连接,然后再次连接,这将断开 A :: barslot() 并连接 B::barslot()

注意:可以通过实现虚拟槽来实现相同目的。

6. 自我总结

这边主要总结在翻译这篇文章过程中的,为了看懂查找的一些资料链接

C++中的虚函数(表)实现机制以及用C语言对其进行的模拟实现 值得好好看一下,吃透
Qt之Q_GLOBAL_STATIC创建全局静态对象 使用QT提供的宏 Q_GLOBAL_STATIC,可以快速创建全局静态对象,很安全和方便,建议之后可以在代码中使用
绝不重新定义继承而来的non-virtual函数 解释了基类的析构函数为什么要设计为virtual函数
合理使用 inline来优化程序 比较详细的介绍了inline 的优点和以及优点的原因