KDE设区--C++的二进制兼容问题


定义
一个动态库的二进制兼容性指的是,一个依赖该动态库的可执行程序,在不重新编译的情况下,直接替换上该动态库的更新版本也能正确运行。

译者解析:C++动态库的二进制兼容性,从本质上来看,分为如下两个层次:
1,对外可见的动态符号(函数签名/符号修饰)的一致性。这个相对容易理解。对于函数签名不匹配的问题,C++上要比C更容易排查,因为C++中有符号修饰,一般出问题后会直接提示找不到某符号。
2,对象内存布局的一致性。这个最容易被忽视,也比较难排查,因为会导致各种莫名其妙的死机。

关于ABI
本文适用于编译KDE使用的编译器所遵循的大多数C++ ABI。主要基于 Itanium C++ ABI Draft ,该标准被用于所有平台下的GCC C++编译器3.4及以上版本。关于微软 Visual C++的名称修饰部分的信息,主要源自  this article on calling conventions (这是目前能找到的关于MSVC ABI名称修饰的最全面文章)。
本文提及的有些要求,在部分编译器上不一定适用。本文的目标是为跨平台的C++代码,提出仅可能全面的约束条件,以使得这些代码能兼容不同的编译器。

能做的和不能做的
能做的
1,添加新的非虚函数,包括构造函数。
2,向类中添加新的枚举类型。
3,向现有枚举类型中添加新的枚举值。
例外:如果这导致编译器要选择一个更大的存储类型来存储这个枚举,那么会产生二进制不兼容。由于编译器有自己的潜在方法来选择存储大小,所以一种普遍的做法是,在枚举定义的末尾设定一个足够大的MAX值,以预留好足够的扩展空间。
4,重新实现第一基类(多重继承时的第一个基类)中的虚函数,前提是链接该动态库的老版本的程序,能够安全的访问基类(而非派生类)中的该函数实现(危险,不建议)。
5,重新实现内联函数,或将内联函数非内联化。(危险,不建议)
6,删除一个没有被任何内联函数调用过的私有非虚函数。
7,删除一个没有被任何内联函数调用过的私有静态成员变量。
8,添加新的静态成员变量。
9,改变一个函数的默认参数值(但强烈建议重新编译程序以使得新默认值生效)。
10,添加一个新的类。
11,导出一个以前没有被导出的类。
12,添加或删除友元类的声明。
13, 重命名预留成员的类型。
14,扩展位域的预留位(不得导致总长度的变化)。
15,如果一个类继承自QObject,向其添加Q_OBJECT的宏
不能做的
1,对于已存在的类:
  • 删除一个已导出类或不再导出已导出类。
  • 改变类的继承关系(继承树)。
2,对于模板类:
  • 修改模板参数。(译者点评:修改模板参数会导致生成的模板类或模板函数发生变化)
3,对于任何类型的现有函数:
  • 隐藏它(unexport)
  • 删除它
  • 将其内联化(这包括将成员函数体移动到类定义中,不管有没有inline关键字,会导致该函数从符号表中消失)
  • 重载一个从没被重载的函数(BC but bot SC,makes &func ambiguous,导致 “&func“ 语句产生歧义);然而重载一个已经被重载过的函数则没有问题,因为这种情况下,任何类似 &func 的语句都会要求进行强制类型转换(cast)。(译者:这里SC我猜应该是“semantic compatible“,语义兼容)
  • 改变函数的签名。这包括:修改参数类型,包括const/volatile修饰;改变函数的const/volatile修饰;改变函数的访问权限,例如从private改成public(因为有些编译器会将此作为签名的一部分);改变成员函数的CV修饰;用追加参数的方式来扩展函数,即使该参数有默认值;改变返回值类型;
  • 例外:用extern "C" 来修饰的非成员函数,改变其参数类型(需小心)。
4,对于虚成员函数:
  • 向一个没有任何虚函数或虚基类的类中添加一个虚函数(这会导致本来没有虚表的类产生一个虚表,其对象的内存布局发生变化)。
  • 向一个非叶子(non-leaf)类中添加虚函数(译者解析:会导致应用中该类的虚表和新动态库中该类的虚表的大小不匹配,在x86_64上测试,程序运行时loader会报警告,但还能运行成功)。
  • 如果该动态库要考虑在windows系统中的二进制兼容,则甚至不能在叶子(leaf)类中添加虚函数,因为这会导致虚表内现有函数的排列顺序发生变化。
  • 改变虚函数之间的声明顺序。(译者解析:会导致应用调用接口错乱,crash)
  • 当子类是多重继承时,子类中重写(override)不在第一基类(primary base class)(第一个非虚基类)中定义的虚函数,会导致二进制不兼容。(因为此时会将子类的该函数放入第1个虚表中,并且会破坏子类的对应非第1基类的虚表的原内容!详见我另一篇文章分析:)
  • override an existing virtual function if the overriding function has a covariant return type for which the more-derived type has a pointer address different from the less-derived one (usually happens when, between the less-derived and the more-derived ones, there's multiple inheritance or virtual inheritance).
  • 删除一个虚函数,即使该虚函数是对基类虚函数的重新实现。(译者:场景?当该虚函数是重写的基类的话,PC测试没问题呢)
5,对于静态非私有成员(static non-private members)或者 非静态非类内成员的公共数据(non-static non-member public data)
  • 删除或不再导出(unexport)
  • 改变其类型
  • 改变其CV修饰符(const/volatile)
6,对于非静态成员(non-static members)
  • 向现有类中添加新的成员变量
  • 改变非静态成员变量之间的声明顺序
  • 改变成员变量的类型(无符号除外)
  • 删除已有的非静态成员变量

如果你向将某个类导出,那么你应该遵循如下规则:
  • 添加“d指针“,详见下文。
  • 添加非内联的虚析构函数,即便函数体为空实现(译者:目的应该是提前在虚表内占位,并保证delete 指向子类对象的基类指针时,不产生内存泄漏)。
  • 在QObject的派生类中重新实现event,即使函数体内仅仅是调用基类的event函数。
  • 让所有构造函数非内联化。
  • 以非内联的形式来实现拷贝构造函数(copy constructor)和赋值运算符,除非该类不支持值拷贝(copy by value)。

动态库开发的相关技术
在开发动态库时,最大的难题就是,对类成员的修改是不能确保安全的,因为这可能导致类对象的内存布局发生变化,甚至会影响到子类。
位域标志(bitflags)
使用位域标志的方法,一定程度上可以保证二进制兼容。位域标志可以用来存储枚举或布尔类型,你可以在保证二进制兼容的情况下,将位域变量扩展到下一个字节减一的位置。例如,
扩展前:
uint m1 : 1;uint m2 : 3;uint m3 : 1;
扩展后:
uint m1 : 1;uint m2 : 3;uint m3 : 1;uint m4 : 2; // new member

需要注意的是,对于一个字节的空间来说,请尽量只使用前7 bits(或者如果它原来就超过了8 bits,请仅使用前15bits)。因为对于一些编译器来说,使用最后一个bit可能会引起问题。

D指针
位域标志和预留定义变量的方法,虽然用起来很简便,但却是远远不能满足需求的。所以D指针登场了。D指针源于Trolltech's Arnt Gulbrandsen,是他最初把这项技术引入了QT,使得QT成为最先满足二进制兼容的C++ GUI库之一,即便是重大的版本发布也不例外。这项技术在KDE库中很快就成为一个通用的编程模式。这是一个非常巧妙的能在确保二进制兼容的情况下向现有类中添加私有成员数据的方法。
备注:截止目前,D指针模式也多次被用一些其它命名方式来描述,如pimpl、handle/body或者cheshire cat
举例,假设你要定义一个对外接口类 class Foo,
常规的做法是,在一个头文件中定义如下:
class Foo
{
public:
Foo();
virtual ~Foo();
virtual void func(void);
private:
int m1;
int m2;
QString s;
};
这种定义方式,按照上文中我们的分析,若要保证二进制兼容,将来就无法再扩展成员变量。
下面是D指针的方式:
你应先声明一个“私有“类 class FooPrivate,然后在Foo的私有成员中添加一个FooPrivate类型的指针,如下:
class FooPrivate;
class Foo
{
public:
Foo();
virtual ~Foo();
virtual void func(void);
private:
FooPrivate* d;
};
然后,要在一个“cpp“文件内部来定义class FooPrivate,将class Foo中的接口所有使用到的成员变量都放到FooPrivate中,如下所示,
class FooPrivate
{
public:
FooPrivate():m1(0),m2(0){}
private:
int m1;
int m2;
QString s;
};
然后要在Foo.cpp中实现Foo的构造析构函数(不得在对外头文件中new FooPrivate):
Foo::Foo()
{
d = new FooPrivate;
}
Foo::~Foo()
{
delete d;
}

由于d指针指向的对象,对于Foo的外部使用者来说是不可见的,如果让从外部访问FooPrivate中的成员变量,那么在Foo中添加访问接口就好了:
QString Foo::string() const { return d->s;}void Foo::setString( const QString& s ){ d->s = s;}

还有一个需要注意的是,class FooPrivate对于动态库的使用者来说,应该是不可见的,但默认情况下,FooPrivate会继承Foo的可见性,导致FooPrivate也会被放到动态库的动态符号表中,这在一些情况下,可能会引起全局符号介入问题,因此,最好将FooPrivate类隐藏起来,方法如下:
#define _SYM_HIDDEN_ __attribute__ ((visibility("hidden")))
class _SYM_HIDDEN_ FooPrivate
{
......
};
问题解答
向没有D指针的类中添加新成员变量
假设class Foo中没有bitflags、reserved members或者d-pointer,而你又不得不向该类中添加私有成员数据,那怎么办?现在提供一个比较通用的方法。QT中有一个基于指针的字典类叫做QHash。
对class Foo修改如下。
  • 创建一个私有类 FooPrivate
  • 创建一个静态哈希表 static 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);}
  • 现在你就可以在Foo类中潇洒自如的使用D指针了,如下,
d( this ) -> m1 = 5 ;
  • 在Foo的析构函数中添加如下一行:
delete_d( this );
  • 不要忘了添加一个BCI备注(Binary Compatible I?),以便在下一个动态库版本中删除这种临时方法。
  • 不要忘了在以后的新类中添加D指针!

译者解析:这里的例子是基于QT的,对于普通的C++类来说,我们可以改用容器hash_map(甚至map)来实现。

添加一个重新实现的虚函数
As already explained, you can safely reimplement a virtual function defined in one of the base classes only if it is safe that the programs linked with the prior version call the implementation in the base class rather than the derived one. This is because the compiler sometimes calls virtual functions directly if it can determine which one to call.
之前已经解释过了,如果链接老版本动态库的程序,能够安全的访问基类(而非派生类)中某个虚函数,你才可以在派生类中重写这个虚函数。 (如何理解?)这是因为有时候,如果编译器能判断出该调哪个虚函数的话,它会直接调直接调用这个虚函数(而不是通过虚表调用),举个栗子,
void C :: foo(){ B :: foo();}

这时B::foo()就是被直接调用的。如果class B是继承自class A,并且A中有foo()的实现而B中没有重写foo(),那么C::foo()实际上就会直接调用A::foo(). 假设在新版本的动态库中新增了B::foo()的实现,那么如果不重新编译C类,那C::foo()仍然是调不到B::foo()的。

另一个更常见的例子:
B b; // B derives from A b.foo();
这时对foo的调用并不是通过虚表实现的。这也就意味着,如果老版本库中没有B::foo()的实现但新版本库中有了,那么按老版本库编译的程序,换上新版本库后调到的实际上仍然是A::foo(),除非重新编译程序。
在仅替换动态库但不重新编译用户程序的情况下,如果你无法保证程序能否按预期执行,那么你需要将A::foo()中的功能代码移动到一个新的proected函数A::foo2()中,并将原A::foo()修改成如下:

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(),但这里B::foo()中调用了A::foo2(),其它地方应该是没有这么做的了。

使用一个新类
扩展一个类时,相对简单的方法就是写一个新类,新类里实现新的功能(出于代码复用的考虑,新类当然要继承老类)。这当然需要重新修改和编译应用程序,否则无法将新的功能扩展到依赖老库的应用程序上。然而,对于那些体量比较小或者性能要求非常高的类来说,扩展新类并且修改和重新编译应用程序,反倒是一个更简单的方法。

向叶子类中添加虚函数
This technique is one of cases of using a new class that can help if there's a need to add new virtual functions to a class that should stay binary compatible and there is no class inheriting from it that should also stay binary compatible (i.e. all classes inheriting from it are in applications). In such case it's possible to add a new class inheriting from the original one that will add them. Applications using the new functionality will of course have to be modified to use the new class.
该项技术适用的条件:
  • 需要向 class A 中添加一个虚函数。
  • 要保证class A的二进制兼容。
  • 没有A的派生类需要保持二进制兼容(或者说,所有A的派生类都在应用程序中)。
在这条件下,可以新建一个A的派生类 class B,然后将新的虚函数放到B中实现。如果应用程序需要用到这个新的虚函数,那么应用程序需要重新修改和编译。

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();}
It is not possible to use this technique when there are other inherited classes that should also stay binary compatible because they'd have to inherit from the new class.
如果class A的派生类中有需要保持二进制兼容的,那就不能用此技术,因为这种派生类只能重新继承这个新类 class B。

译者解析:
假设应用中之前这么调用:
A* a = new A();
a->foo();
如果应用要调用新函数,则需要改成:
A* a = new B();
a->foo();

用信号取代虚函数
QT相关的内容,暂不翻译了。

不兼容问题举例
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值