1. 引子
有一个同事C,在一个原来的C++动态库的基础上,为一个现场添加一个新的功能,需要在原来的class接口提供一个新的虚函数,以实现该功能。功能很简单,很快开发完毕并提交代码到主分支,该现场运行也没问题,该需求顺利关闭。当QA把该最新的库发布到其他现场,嗯哼,问题出现了。QA在发布该最新库时,问同事C,是否可以发布该最新库到其他现场。同事C想,我的修改是添加一个功能,并没有修改其他功能和接口,其他现场还是用原来的功能,肯定没有问题,就同意了发布最新库,这样可以在一个分支上进行维护。
那么,为什么一个现场没有问题,而另一个现场有问题呢?
这就是动态库的兼容性(ABI)问题。
没有出现问题的现场,是因为该现场为使用最新的功能,必然会重新编译链接使用该库的程序,所以不会出现问题;出现问题的现场,是因为该现场没有新的需求增加,使用该库的程序没有重新编译链接,导致使用该最新的库出现问题。
如果使用动态库,那么动态库发生变化,都需要重新编译链接一遍吗?如果答案为真,那么使用动态库优势少了一半,使用动态库仅仅是节约一点内存,在内存价格低廉的今天,好像该优势不大。其实不然,如果我们能够编写二进制兼容的库,我们可以不用重新编译链接一遍。
2. 分析
使用C++语言做开发,有许多'坑'需要我们时刻注意,ABI就是一个'坑'.
C++为什么会出现ABI问题呢?
看例子:
点击(此处)折叠或打开
-
// 我们新添加并实现一个虚函数 new_f(),其他没有修改。
-
// foo.h
-
class Foo {
-
public:
-
Foo(int x, int y);
-
virtual void f();
-
virtual void new_f(); // 新添加的功能
-
virtual void g();
-
-
// ...
-
-
private:
-
// ...
-
};
-
// foo.cpp
-
Foo::Foo(int x, int y) {
-
this.x = x;
-
this.y = y;
-
}
-
void Foo::f() {
-
// ...
-
}
-
void Foo::new_f() {
-
// ...
-
}
-
void Foo::g() {
-
// ...
-
}
-
// end
-
-
-
-
// main.cpp
-
int main() {
-
// ...
-
-
Foo foo(1,2);
-
-
foo.f();
-
foo.g();
-
-
// ...
-
-
return 0;
- }
使用foo.so库的程序a.out,在foo.so为添加 void new_f() 前编译。现在foo.so中添加了new_f(),但程序a.out不使用foo.so库的new_f(),从表面上看好像不用重新编译链接。我们看一下,如果不重新编译链接,会出现什么问题。
我们知道,C++虚函数一般使用虚函数表vtbl来实现,如下图:
Foo.vtbl-->---------
0| |
---------
1| f() |
---------
2| g() |
---------
3| ... |
我们假设f()在虚函数表vtbl的第二项(从0开始), 所以调用foo.f(),实际上是foo.vtbl[1]();而调用foo.g(),实际上是foo.vtbl[2]()。
但是,当添加虚函数void new_f(), foo.so库中的Foo的虚函数表发生了变化,
Foo.vtbl-->---------
0| |
---------
1| f() |
---------
2|new_f()|
---------
3| g() |
---------
4| ... |
a.out在使用新的foo.so库时,foo.g()实际调用了foo.new_f().
所以出现问题。
其实,在例中,把新添加的 virtual void new_f()放到所有的virtual之后,在大多数情况下(该类未被继承),应该就没有问题了。
3. 总结
知道了二进制兼容性后,应该如何避免问题?或者对动态库代码的修改,什么的修改会保持二进制兼容,什么样的修改会破坏二进制兼容呢?
3.1 可能破坏二进制兼容的情况
1. 增加/减少虚函数
修改虚函数表内的排列顺序,即使把新增加的虚函数放到最后一个,也可能会引起问题,如该类作为父类被其他类继承等;
2. 修改函数的参数列表,无论是全局函数,类成员函数等,没有加extern "C"
由于C++支持同名函数重载,C++编译时,会对函数名字进行name mangling,如果修改了函数的参数列表,经过C++编译器编译后,函数的名称就变了;
3. 虚函数从有变无,或者从无改为有
改变该类的对象的大小
4. 增加减少成员变量,
这会改变该类的对象的大小,当在使用该库的程序中有如下代码:
pfoo = new Foo;
由于sizeof(Foo)发生了变化,分配的内存可能不够。另外当使用pfo->member_variable访问成员变量时,也可能会出错;当使用 inline setxxx(x)时,也可能会出错,因为inline函数可能已经编译进使用该库的程序代码中。
5. 应该还有,目前没有想到。
有那么多可能破坏二进制兼容性,我们是不是不要使用动态库了呢?其实这要看具体情况.
3.2 静态库/动态库
无论是动态库还是静态库,其根本目的是复用已有的、经过测试的成熟代码。我们看一下,静态库和动态库的优缺点.
静态库
优点:
使用静态库,主程序会把该库编译链接进程序本身,以后就与该库没有关系了,部署简单。
缺点:
1. 如果一台机器上有多个程序使用该库,会浪费一部分空间资源。
2. 修改了库,使用该库的程序必须重新编译连接。
动态库
优点:
1. 介意一部分空间资源,如果一台机器上只有一个程序使用该库,也谈不上节约空间资源了。
2. 动态库修改后,是二进制兼容的,可以不用重新编译。这样当发布升级新的功能时,只更新部分动态库。
缺点:
1. 部署相对复杂;
2. 要注意二进制的兼容性。
3.3 结论
如果使用静态库,就不会有二进制兼容性了。如果可以,使用静态库吧,呵呵。
如果必须使用动态库,那么最好做到如下几点:
1. 尽可能的不要使用虚函数作为接口;
2. 增加一个间接层,使用pimpl。如下:
点击(此处)折叠或打开
-
// foo.h
-
class Foo {
-
public:
-
Foo();
-
~Foo();
-
void f1();
-
void f2();
-
void f3(); // new memeber func
-
// ...
-
-
private:
-
class FooImpl;
-
FooImpl *impl;
-
}
-
-
// foo.cpp
-
#include "foo.h"
-
class Foo::FooImpl {
-
public:
-
virtual void f1();
-
virtual void f2();
-
virtual void f3();
-
// ...
-
-
private:
-
int x;
-
int y;
-
int z; // new member data
-
};
-
void Foo::FooImpl::f1() {
-
// ...
-
}
-
void Foo::FooImpl::f2() {
-
// ...
-
}
-
void Foo::FooImpl::f3() {
-
// ...
-
}
-
-
// 可以放到头文件中,作为 inline func
-
Foo::Foo() : impl(new FooImpl) {
-
}
-
Foo::~Foo() {
-
delete impl;
-
}
-
-
void Foo::f1() {
-
impl->f1();
-
}
-
void Foo::f2() {
-
impl->f2();
-
}
-
void Foo::f3() {
-
impl->f3();
- }