https://zhangrunnan.com/cpp-binary-compatibility/
https://community.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B
在深入了解前,首先我们要明确两个概念:二进制兼容和源码兼容。
-
二进制兼容:在升级库文件的时候,不必重新编译使用此库的可执行文件或其他库文件,并且程序的功能不被破坏。
-
源码兼容:在升级库文件的时候,不必修改使用此库的可执行文件或其他库文件的源代码,只需重新编译应用程序,即可使程序的功能不被破坏。
主要是动态库存在二进制的兼容问题,其中又主要是c++的虚函数作为接口会产生问题
https://blog.csdn.net/Solstice/article/details/6244905
比如现在 Graphics 库发布了 1.1.0 和 1.2.0 两个版本,这两个版本可以不必是二进制兼容。
用户的代码从 1.1.0 升级到 1.2.0 的时候要重新编译一下,反正他们要用新功能都是要重新编译代码的。
如果要原地打补丁,那么 1.1.1 应该和 1.1.0 二进制兼容,而 1.2.1 应该和 1.2.0 兼容。
如果要加入新的功能,而新的功能与 1.2.0 不兼容,那么应该发布到 1.3.0 版本。
对于用户可见的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部分,在升级库的时候就不必在意。
————————————————
ABI和API
应用二进制接口(application binary interface,缩写为 ABI)描述了应用程序(或者其他类型)和操作系统之间或其他应用程序的低级接口。
ABI
涵盖了各种细节,如:数据类型的大小、布局和对齐;调用约定等。
在了解二进制兼容和源码兼容两个定义以后,我们再看与其类似且对应的两个概念:ABI
和API
。ABI
不同于API
(应用程序接口),API
定义了源代码和库之间的接口,因此同样的代码可以在支持这个API
的任何系统中编译,然而ABI
允许编译好的目标代码在使用兼容ABI
的系统中无需改动就能运行。
举个例子,在Qt和Java两种跨平台程序中,API
像是Qt的接口,Qt有着通用接口,源代码只需要在支持Qt的环境下编译即可。ABI
更像是Jvm,只要支持Jvm的系统上,都可以运行已有的Java程序。
C++的ABI
ABI
更像是一个产品的使用说明书,同理C++的ABI
就是如何使用C++生成可执行程序的一张说明书。编译器会根据这个说明书,生成二进制代码。C++的ABI
在不同的编译器下会略有不同。
C++ABI
的部分内容举例:
- 函数参数传递的方式,比如 x86-64 用寄存器来传函数的前 4 个整数参数
- 虚函数的调用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 来调用
- struct 和 class 的内存布局,通过偏移量来访问数据成员
综上所述,如果可执行程序通过以上说明书访问动态链接库A,以及此库的升级版本A+,若按此说明书上的方法,可以无痛的使用A和A+,那么我们就称库A的这次升级是二进制兼容的。
破坏二进制兼容的几种常见方式 【改变了虚函数的offset或者成员的顺序】
- 添加新的虚函数
- 不导出或者移除一个导出类
- 改变类的继承
- 改变虚函数声明时的顺序(偏移量改变,导致调用失败)
- 添加新的非静态成员变量(类的内存布局改变,偏移量也发生变化)
- 改变非静态成员变量的声明顺序
不会破坏二进制兼容的几种常见方式
- 添加非虚函数(包括构造函数)
- 添加新的类
- 添加Qt中的信号槽
- 在已存在的枚举类型中添加一个枚举值
- 添加新的静态成员变量
- 修改成员变量名称(偏移量未改变)
- 添加
Q_OBJECT
,Q_PROPERTY
,Q_ENUMS
,Q_FLAGS
宏,添加这些宏都是修改了moc生成的文件,而不是类本身
只要我们知道了程序是以什么方式访问动态库的(C++的ABI),那么我们就很好判断,哪些操作会破坏二进制兼容。更多方式请参见Policies/Binary Compatibility Issues With C++
解决二进制兼容问题的相关方法
- 使用
Bitflags
即位域
1
2
3
|
uint m1 :
1;
uint m2 :
3;
uint m3 :
1;
|
1
2
3
4
|
uint m1 :
1;
uint m2 :
3;
uint m3 :
1;
uint m4 :
2; // new member without breaking binary compatibility.
|
- 使用
PImpl
机制,详情参见PImpl机制以及Qt的D-Pointer实现 - 使用静态库(当然也随之带来一系列弊端)
C++抽象类和Java的接口
读到这里大家也许会奇怪,作者是不是放错地方了?其实不然,只是在我们了解二进制兼容后,可以更好地理解这组概念。之前我一直认为C++的抽象类就类似于Java的接口,现在发现,如果把一个C++的抽象类作为动态库的接口发布,那将是毁灭的。因为你无法增加虚函数,无法增加成员变量,这使得这个接口变得非常的不友好。这也就是Java接口的优势所在。Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做,便不存在上述二进制兼容的问题。
理解Java二进制兼容的关键是要理解延迟绑定(Late Binding)。延迟绑定是指Java直到运行时才检查类、域、方法的名称,而不象C/C++的编译器那样在编译期间就清除了类、域、方法的名称,代之以偏移量数值——这是Java二进制兼容得以发挥作用的关键。
由于采用了延迟绑定技术, 方法、域、类的名称直到运行时才解析,意味着只要域、方法等的名称(以及类型)一样,类的主体可以任意替换。
参考
】
https://blog.csdn.net/Solstice/article/details/6233478
本文主要讨论 Linux x86/x86-64 平台,偶尔会举 Windows 作为反面教材。
C/C++ 的二进制兼容性 (binary compatibility) 有多重含义,本文主要在“头文件和库文件分别升级,可执行文件是否受影响”这个意义下讨论,我称之为 library (主要是 shared library,即动态链接库)的 ABI (application binary interface)。
至于编译器与操作系统的 ABI 留给下一篇谈 C++ 标准与实践的文章。
- 什么是二进制兼容性
在解释这个定义之前,先看看 Unix/C 语言的一个历史问题:open() 的 flags 参数的取值。open(2) 函数的原型是
int open(const char *pathname, int flags);
其中 flags 的取值有三个: O_RDONLY, O_WRONLY, O_RDWR。
与一般人的直觉相反,这几个值不是按位或 (bitwise-OR) 的关系,即 O_RDONLY | O_WRONLY != O_RDWR。如果你想以读写方式打开文件,必须用 O_RDWR,而不能用 (O_RDONLY | O_WRONLY)。为什么?因为 O_RDONLY, O_WRONLY, O_RDWR 的值分别是 0, 1, 2。它们不满足按位或 。
那么为什么 C 语言从诞生到现在一直没有纠正这个不足之处?比方说把 O_RDONLY, O_WRONLY, O_RDWR 分别定义为 1, 2, 3,这样 O_RDONLY | O_WRONLY == O_RDWR,符合直觉。而且这三个值都是宏定义,也不需要修改现有的源代码,只需要改改系统的头文件就行了。
因为这么做会破坏二进制兼容性。对于已经编译好的可执行文件,它调用 open(2) 的参数是写死的,更改头文件并不能影响已经编译好的可执行文件。比方说这个可执行文件会调用 open(path, 1) 来写 文件,而在新规定中,这表示读 文件,程序就错乱了。
以上这个例子说明,如果以 shared library 方式提供函数库,那么头文件和库文件不能轻易修改,否则容易破坏已有的二进制可执行文件,或者其他用到这个 shared library 的 library。
操作系统的 system call 可以看成 Kernel 与 User space 的 interface,kernel 在这个意义下也可以当成 shared library,你可以把内核从 2.6.30 升级到 2.6.35,而不需要重新编译所有用户态的程序。
所谓“二进制兼容性”指的就是在升级(也可能是 bug fix)库文件的时候,不必重新编译使用这个库的可执行文件或使用这个库的其他库文件,程序的功能不被破坏。
见 QT FAQ 的有关条款:http://developer.qt.nokia.com/faq/answer/you_frequently_say_that_you_cannot_add_this_or_that_feature_because_it_woul
在 Windows 下有恶名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll, mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,这是动态链接库的本质问题,怪不到 MFC 头上。
- 有哪些情况会破坏库的 ABI
到底如何判断一个改动是不是二进制兼容呢?这跟 C++ 的实现方式直接相关,虽然 C++ 标准没有规定 C++ 的 ABI,但是几乎所有主流平台都有明文或事实上的 ABI 标准。比方说 ARM 有 EABI,Intel Itanium 有 http://www.codesourcery.com/public/cxx-abi/abi.html ,x86-64 有仿 Itanium 的 ABI,SPARC 和 MIPS 也都有明文规定的 ABI,等等。x86 是个例外,它只有事实上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI 还有多个版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 编译器也得按照 Visual C++ 或 G++ 的 ABI 来生成代码,否则就不能与系统其它部件兼容。
C++ ABI 的主要内容:
函数参数传递的方式,比如 x86-64 用寄存器来传函数的前 4 个整数参数
虚函数的调用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 来调用
struct 和 class 的内存布局,通过偏移量来访问数据成员
name mangling
RTTI 和异常处理的实现(以下本文不考虑异常处理)
C/C++ 通过头文件暴露出动态库的使用方法,这个“使用方法”主要是给编译器看的,编译器会据此生成二进制代码,然后在运行的时候通过装载器(loader)把可执行文件和动态库绑到一起。如何判断一个改动是不是二进制兼容,主要就是看头文件暴露的这份“使用说明”能否与新版本的动态库的实际使用方法兼容。因为新的库必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件来调用动态库。
- 这里举一些源代码兼容但是二进制代码不兼容例子
给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
增加虚函数,会造成 vtbl 里的排列变化。(不要考虑“只在末尾增加”这种取巧行为,因为你的 class 可能已被继承。)
增加默认模板类型参数,比方说 Foo 改为 Foo >,这会改变 name mangling
改变 enum 的值,把 enum Color { Red = 3 }; 改为 Red = 4。这会造成错位。当然,由于 enum 自动排列取值,添加 enum 项也是不安全的,除非是在末尾添加。
给 class Bar 增加数据成员,造成 sizeof(Bar) 变大,以及内部数据成员的 offset 变化,这是不是安全的?通常不是安全的,但也有例外。
如果客户代码里有 new Bar,那么肯定不安全,因为 new 的字节数不够装下新 Bar。相反,如果 library 通过 factory 返回 Bar* (并通过 factory 来销毁对象)或者直接返回 shared_ptr,客户端不需要用到 sizeof(Bar),那么可能是安全的。
同样的道理,直接定义 Bar bar; 对象(无论是函数局部对象还是作为其他 class 的成员)也有二进制兼容问题。
如果客户代码里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全,因为 memberA 的新 Bar 的偏移可能会变。相反,如果只通过成员函数来访问对象的数据成员,客户端不需要用到 data member 的 offsets,那么可能是安全的。
如果客户调用 pBar->setMemberA(xx); 而 Bar::setMemberA() 是个 inline function,那么肯定不安全,因为偏移量已经被 inline 到客户的二进制代码里了。如果 setMemberA() 是 outline function,其实现位于 shared library 中,会随着 Bar 的更新而更新,那么可能是安全的。
那么只使用 header-only 的库文件是不是安全呢?不一定。如果你的程序用了 boost 1.36.0,而你依赖的某个 library 在编译的时候用的是 1.33.1,那么你的程序和这个 library 就不能正常工作。因为 1.36.0 和 1.33.1 的 boost::function 的模板参数类型的个数不一样,其中一个多了 allocator。
这里有一份黑名单,列在这里的肯定是二级制不兼容,没有列出的也可能二进制不兼容,见 KDE 的文档:http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B
- 哪些做法多半是安全的
前面我说“不能轻易修改”,暗示有些改动多半是安全的,这里有一份白名单,欢迎添加更多内容。
只要库改动不影响现有的可执行文件的二进制代码的正确性,那么就是安全的,我们可以先部署新的库,让现有的二进制程序受益。
增加新的 class
增加 non-virtual 成员函数
修改数据成员的名称,因为生产的二进制代码是按偏移量来访问的,当然,这会造成源码级的不兼容。
还有很多,不一一列举了。
欢迎补充
- 反面教材:COM
在 C++ 中以虚函数作为接口基本上就跟二进制兼容性说拜拜了。具体地说,以只包含虚函数的 class (称为 interface class)作为程序库的接口,这样的接口是僵硬的,一旦发布,无法修改。
比方说 M$ 的 COM,其 DirectX 和 MSXML 都以 COM 组件方式发布,我们来看看它的带版本接口 (versioned interfaces):
IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3
话句话说,每次发布新版本都引入新的 interface class,而不是在现有的 interface 上做扩充。这样不能兼容现有的代码,强迫客户端代码也要改写。
回过头来看看 C 语言,C/Posix 这些年逐渐加入了很多新函数,同时,现有的代码不用修改也能运行得很好。如果要用这些新函数,直接用就行了,也基本不会修改已有的代码。相反,COM 里边要想用 IXMLDOMDocument3 的功能,就得把现有的代码从 IXMLDOMDocument 全部升级到 IXMLDOMDocument3,很讽刺吧。
tip:如果遇到鼓吹在 C++ 里使用面向接口编程的人,可以拿二进制兼容性考考他。
- 解决办法
- 采用静态链接
这个是王道。在分布式系统这,采用静态链接也带来部署上的好处,只要把可执行文件放到机器上就行运行,不用考虑它依赖的 libraries。目前 muduo 就是采用静态链接。
通过动态库的版本管理来控制兼容性
这需要非常小心检查每次改动的二进制兼容性并做好发布计划,比如 1.0.x 系列做到二进制兼容,1.1.x 系列做到二进制兼容,而 1.0.x 和 1.1.x 二进制不兼容。《程序员的自我修养》里边讲过 .so 文件的命名与二进制兼容性相关的话题,值得一读。
- 用 pimpl 技法,编译器防火墙
在头文件中只暴露 non-virtual 接口,并且 class 的大小固定为 sizeof(Impl*),这样可以随意更新库文件而不影响可执行文件。当然,这么做有多了一道间接性,可能有一定的性能损失。见 Exceptional C++ 有关条款和 C++ Coding Standards 101.
- Java 是如何应对的
Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做。就不存在“不能增加虚函数”,“不能修改 data member” 等问题。在 Java 里边用面向 interface 编程远比 C++ 更通用和自然,也没有上面提到的“僵硬的接口”问题。
(待续)
https://blog.csdn.net/Solstice/article/details/6244905
以虚函数作为接口在二进制兼容性方面有本质困难:“一旦发布,不能修改”。
假如我需要给 Graphics 增加几个绘图函数,同时保持二进制兼容性。这几个新函数的坐标以浮点数表示,我理想中的新接口是:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:13:30.000000000 +0800
@@ -7,11 +7,14 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
+ virtual void drawLine(double x0, double y0, double x1, double y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
virtual void drawArc(Point p, int r);
};
受 C++ 二进制兼容性方面的限制,我们不能这么做。其本质问题在于 C++ 以 vtable[offset] 方式实现虚函数调用,而 offset 又是根据虚函数声明的位置隐式确定的,这造成了脆弱性。
我增加了 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列发生了变化,现有的二进制可执行文件无法再用旧的 offset 调用到正确的函数。
怎么办呢?有一种危险且丑陋的做法:把新的虚函数放到 interface 的末尾,例如:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:58:22.000000000 +0800
@@ -7,11 +7,15 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
+
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
};
这么做很丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数没有和原来的 drawLine() 函数呆在一起,造成阅读上的不便。
这么做同时很危险,因为 Graphics 如果被继承,那么新增虚函数会改变派生类中的 vtable offset 变化,同样不是二进制兼容的。
另外有两种似乎安全的做法,这也是 COM 采用的办法:
1. 通过链式继承来扩展现有 interface,例如从 Graphics 派生出 Graphics2
--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:58:35.000000000 +0800
@@ -7,11 +7,19 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2 : public Graphics
+{
+ using Graphics::drawLine;
+ using Graphics::drawRectangle;
+ using Graphics::drawArc;
+
+ // added in version 2
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
+};
将来如果继续增加功能,那么还会有 class Graphics3 : public Graphics2;以及 class Graphics4 : public Graphics3 等等。
这么做和前面的做法一样丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1) 函数位于派生 Graphics2 interace 中,没有和原来的 drawLine() 函数呆在一起,造成割裂。
2. 通过多重继承来扩展现有 interface,例如定义一个与 Graphics class 有同样成员的 Graphics2,再让实现同时继承这两个 interfaces。
--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:16:45.000000000 +0800
@@ -7,11 +7,32 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2
+{
+ virtual void drawLine(int x0, int y0, int x1, int y1);
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawLine(Point p0, Point p1);
+
+ virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(Point p0, Point p1);
+
+ virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
+ virtual void drawArc(Point p, int r);
+};
+
+// 在实现中采用多重接口继承
+class GraphicsImpl : public Graphics, // version 1
+ public Graphics2, // version 2
+{
+ // ...
+};
这种带版本的 interface 的做法在 COM 使用者的眼中看起来是很正常的,解决了二进制兼容性的问题,客户端源代码也不受影响。
在我看来带版本的 interface 实在是很丑陋,因为每次改动都引入了新的 interface class,会造成日后客户端代码难以管理。比如,如果代码使用了 Graphics3 的功能,要不要把现有的 Graphics2 都替换掉?
如果不替换,一个程序同时依赖多个版本的 Graphics,一直背着历史包袱。依赖的 Graphics 版本愈来愈多,将来如何管理得过来?
如果要替换,为什么不相干的代码(现有的运行得好好的使用 Graphics2 的代码)也会因为别处用到了 Graphics3 而被修改?
这种二难境地纯粹是“以虚函数为库的接口”造成的。如果我们能直接原地扩充 class Graphics,就不会有这些屁事,见本文“推荐做法”一节。
不要误认为“接口一旦发布就不能更改”是天经地义的,那不过是“以 C++ 虚函数为接口”的固有弊端,如果跳出这个框框去思考,其实 C++ 库的接口很容易做得更好。
为什么不能改?还不是因为用了C++ 虚函数作为接口。
Java 的 interface 可以添加新函数,C 语言的库也可以添加新的全局函数,C++ class 也可以添加新 non-virtual 成员函数和 namespace 级别的 non-member 函数,这些都不需要继承出新 interface 就能扩充原有接口。
偏偏 COM 的 interface 不能原地扩充,只能通过继承来 workaround,产生一堆带版本的 interfaces。有人说 COM 是二进制兼容性的正面例子,某深不以为然。COM 确实以一种最丑陋的方式做到了“二进制兼容”。脆弱与僵硬就是以 C++ 虚函数为接口的宿命。
相反,Linux 系统调用以编译期常数方式固定下来,万年不变,轻而易举地解决了这个问题。在其他面向对象语言(Java/C#)中,我也没有见过每改动一次就给 interface 递增版本号的做法。
还是应了《The Zen of Python》中的那句话,Explicit is better than implicit, Flat is better than nested.
动态库的接口的推荐做法
取决于动态库的使用范围,有两类做法。
- 比如现在 Graphics 库发布了 1.1.0 和 1.2.0 两个版本,这两个版本可以不必是二进制兼容。
用户的代码从 1.1.0 升级到 1.2.0 的时候要重新编译一下,反正他们要用新功能都是要重新编译代码的。
如果要原地打补丁,那么 1.1.1 应该和 1.1.0 二进制兼容,而 1.2.1 应该和 1.2.0 兼容。
如果要加入新的功能,而新的功能与 1.2.0 不兼容,那么应该发布到 1.3.0 版本。
对于用户可见的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部分,在升级库的时候就不必在意。
- 如果库的使用范围很广,用户很多,各家的 release cycle 不尽相同,那么推荐 pimpl 技法[2, item 43], //还是用类方法的方式来对外暴露接口
并考虑多采用 non-member non-friend function in namespace [1, item 23] [2, item 44 abd 57] 作为接口。 //或者直接采用c语言的方式,全局函数来暴露接口
这里以前面的 Graphics 为例,说明 pimpl 的基本手法。
1. 暴露的接口里边不要有虚函数,而且 sizeof(Graphics) == sizeof(Graphics::Impl*)。
class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
void drawArc(Point p, int r);
private:
class Impl;
boost::scoped_ptr<Impl> impl;
};
2. 在库的实现中把调用转发 (forward) 给实现 Graphics::Impl ,这部分代码位于 .so/.dll 中,随库的升级一起变化。
#include <graphics.h>
class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
void drawArc(Point p, int r);
};
Graphics::Graphics()
: impl(new Impl)
{
}
Graphics::~Graphics()
{
}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}
// ...
3. 如果要加入新的功能,不必通过继承来扩展,可以原地修改,且保持二进制兼容性。先动头文件:
--- old/graphics.h 2011-03-12 15:34:06.000000000 +0800
+++ new/graphics.h 2011-03-12 15:14:12.000000000 +0800
@@ -7,19 +7,22 @@
class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);
private:
class Impl;
boost::scoped_ptr<Impl> impl;
};
然后在实现文件里增加 forward,这么做不会破坏二进制兼容性,因为增加 non-virtual 函数不影响现有的可执行文件。
--- old/graphics.cc 2011-03-12 15:15:20.000000000 +0800
+++ new/graphics.cc 2011-03-12 15:15:26.000000000 +0800
@@ -1,35 +1,43 @@
#include <graphics.h>
class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);
};
Graphics::Graphics()
: impl(new Impl)
{
}
Graphics::~Graphics()
{
}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}
+void Graphics::drawLine(double x0, double y0, double x1, double y1)
+{
+ impl->drawLine(x0, y0, x1, y1);
+}
+
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}
采用 pimpl 多了一道 forward 的手续,带来的好处是可扩展性与二进制兼容性,通常是划算的。pimpl 扮演了编译器防火墙的作用。
pimpl 不仅 C++ 语言可以用,C 语言的库同样可以用,一样带来二进制兼容性的好处,比如 libevent2 里边的 struct event_base 是个 opaque pointer,客户端看不到其成员,都是通过 libevent 的函数和它打交道,这样库的版本升级比较容易做到二进制兼容。
为什么 non-virtual 函数比 virtual 函数更健壮?因为 virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加载器 (loader) 会在程序启动时做决议(resolution),通过 mangled name 把可执行文件和动态库链接到一起。就像使用 Internet 域名比使用 IP 地址更能适应变化一样。
万一要跨语言怎么办?很简单,暴露 C 语言的接口。Java 有 JNI 可以调用 C 语言的代码,Python/Perl/Ruby 等等的解释器都是 C 语言编写的,使用 C 函数也不在话下。C 函数是 Linux 下的万能接口,C 语言是最伟大的系统编程语言。
本文只谈了使用 class 为接口,其实用 free function 有时候更好(比如 muduo/base/Timestamp.h 除了定义 class Timestamp,还定义了 muduo::timeDifference() 等 free function),这也是 C++ 比 Java 等纯面向对象语言优越的地方。留给将来再细谈吧。
参考文献
[1] Scott Meyers, 《Effective C++》 第 3 版,条款 35:考虑 virtual 函数以外的其他选择;条款 23:宁以 non-member、non-friend 替换 member 函数。
[2] Herb Sutter and Andrei Alexandrescu, 《C++ 编程规范》,条款 39:考虑将 virtual 函数做成 non-public,将 public 函数做成 non-virtual;条款 43:明智地使用 pimpl;条款 44:尽可能编写 nonmember, nonfriend 函数;条款 57:将 class 和其非成员函数接口放入同一个 namespace。
[3] 孟岩,《function/bind的救赎(上)》,《回复几个问题》中的“四个半抽象”。
[4] 陈硕,《以 boost::function 和 boost:bind 取代虚函数》,《朴实的 C++ 设计》。
================
https://blog.csdn.net/enlyhua/article/details/84561222


-
5.环境变量
LD_LIBRARY_PATH :
6.1 共享库的创建