文章目录
前言:
假设你的应用程序引用的一个库某天更新了,虽然 API 和调用方式基本没变,但你需要重新编译你的应用程序才能使用这个库,那么一般说这个库是源码兼容(Source compatible);反之,如果不需要重新编译应用程序就能使用新版本的库,那么说这个库跟它之前的版本是二进制兼容的(Binary compatible)。
对于 C++ 平台的应用商店程序,怎样保证平台商店版本更新了,商店里面的应用程序能在不更新的情况下继续使用,就变成了一件十分重要的事情。
API 的生命周期
维护API 不同于维护一般软件产品,这是因为API开发具有额外的约束:不能破坏已有的客户程序。对于一般的最终用户软件产品,在代码中修改方法或类的名字时不会影响用户可见的应用特性。但如果修改了API中类或方法的名字,则可能会破坏所有已有客户的代码。API是一种契约,你必须确保遵守你的已制定的约定。
上面是一个典型 API 的生命周期简图。这个生命周期内最重要的事件是初始发布 1.0,在图中以粗竖线标记。在这个关犍点之前,对设计和接口作出重大修改是可接受的, 但是在初始发布后,一旦用户使用你的 API 编写了代码,就要承诺提供向后兼容性,你能够修改的范围受到较大的限制。把生命周期看作一个整体,API 开发有4个常见的阶段 ;
(1) 发布前:初始发布前,API 可以遵循标准的软件开发周期,包括需求收集, 计划、设计、实现和测试口如前所述,这个阶段最显著的特征是接口可以经历重大修改和重新设计。实际上可以向用户发布API的早期版本以获得反馈和建议。这些预发布版本可以使用版本号 0.x, 以便向用户表明API仍处于活跃开发中. 在1.0发布之前可能会彻底修改。
(2) 维护:API发布之后仍然可以修改,但是为了维护向后兼容性,只能增加新的方法或类,以及修复已有方法实现中的错误。换句话说,在维护阶段应该努力改进API, 而不是做出使之不兼容的修改。为确保修改不破坏向后兼容性,良好的实践方式是在新版本发布前进行回归测试和API审查。
(3)完成:在某个时间点,项目经理可以认定API已经成熟,不应对接口做进一步修改。这可能是因为API解决了预期的问题,也可能是因为团队成员转移到 了其他项目. 不能再对API提供支持。在生命周期中的这个点,稳定性是最重要的特征,因此通常只会修复错误. 这个阶段仍然可以进行API审查,但如果只是修改实现代码而非公有头文件,那么审查其实没什么必要。最终API会到达一个被认定为已经完成的点,此后不需要做任何修改。
(4)弃用:有些API最终会达到生命终结的状态,此时它们会被放弃使用,生命周期不再运转。弃用是指API不应该在任何新的开发中使用. 已有的客户程序也应该放弃这些AP, 如果API不再提供有用的服务,或者新开发的、不兼容的API取代了原有API, 就会发生弃用。
API发布后,可以改进 ( evolve ) 但不应改变 ( change )。
兼容性级别
工程师讨论到API的兼容性级别时,常常会涉及到这些级别方面:向后兼容性、向前兼容性,功能兼容性、源代码(API) 兼容性以及二进制 (ABI)兼容性。
而通常应该为API的“主、次和补丁”版本提供不同级别的兼容性承诺。例如,可以承诺补丁版本同时满足向后和向前兼容 , 或者承诺只有主版本才会破坏二进制兼容性。
向后兼容性
向后兼容性可以定义为API提供与上一个版本相同的功能。换句话说,如果一个API不需要用户作出任何改变就能够完全取代上一个版本的API, 那么它就是向后兼容的。
这暗示了新版本API是旧版本API的超集。可以添加新的功能,但是不能对旧API定义的已有功能做不兼容的修改. API维护的基本原则是绝不从接口中移除任何内容。
向后兼容性有不同的类型,包括:
- 功能兼容性
- 源代码兼容性
- 二进制兼容性
此外,还会探讨数据导向的向后兼容性问题,比如:
- 客户/服务器兼容性
- 文件格式兼容性
例如:如果API涉及网络通信,那么还需要考虑所使用的客户/服务器协议的兼容性。这是指使用旧版本API的客户仍然能够和使用新版本API的服务器进行通信。同样,使用新版本API的客户仍然能够和使用旧版本API的服务器进行通信。
此外,如果API在文件或数据库中存储数据。那么就需要考虑文件格式或数据库模式的兼容性。例如:较新版本的API需要能够读取旧版本API生成的文件。
向后兼容性意味着使用第N版本API的客户代码能够不加修改地升级到第N+1版本。
向前兼容性
使用未来版本API编写的客户代码如果无须修改就能够编译使用较老版本的API, 则API是向前兼容的。因此,向前兼容性意味着用户可以降级到之前的发布版本,代码无须修改,仍然能够正常工作。
为API增加新的功能会破坏向前兼容性,因为利用这些新特性编写的客户代码将不能编译不包含这些特性的老版本API。
例如,setImage()
函数的下面两个版本是向前兼容的:
// 版本 1.0
void setImage(Image* img, bool unused = false);
// 版本 1.1
void setImage(Image* img, bool keep_aspect);
因为使用函数的1.1版本(第二个参数必备)编写的代码,能够使用函数的1.0版本(第二个参数可选)成功编译。但是,下列两个版本不是向前兼容的:
// 版本 1.0
void setImage(Image* img);
// 版本 1.1
void setImage(Image* img, bool keep_aspect = false);
因为使用1.1版本编写的代码能够提供可选的第二个参数,如果提供了这个参数,那么使用该函数的1.0版本就不能通过编译。
向前兼容性显然是一种很难提供任何保证的特征,因为不能预料未来API会发生什么情况。不过设计者在API的1.0版本发布前考虑周详。实际上,这可以使API的生存期更长。
换句话说,你必须思考 API未来如何发展这一问题。用户可能需要什么新功能?性能优化如何影响API ? API可能会被怎样误用?将来是否需要暴露更加一般的概念?将来要实现的功能是否会对API有影响?
这里给出 一些可以使API向前兼容的方法。
- 如果你知道将来会给函数添加一个参数,那么可以使用前面第一个例子给出的技巧,也就是说,甚至可以在功能实现之前就添加参数,然后将参数标注 (并命名) 为未使用。
- 如果预计将来会改用一种不同的内置类型,那么可以使用不透明指针或者 typedef,而不要直接使用内置类型。例如,为
float
类型创建名为Real
的typedef
,这样就可以在API的未来版本中把typedef
改为double
而不会导致 API变化。
向前兼容性意味着使用第N版本API的客户代码可以不加修改地降级使用第N-1版本 。
功能兼容性
功能兼容性同实现的运行时行为有关。如果一个API的行为与上一个版本精确一致,那么它就是功能兼容的。通常,API在这方面几乎从来没有达到100%的向后兼容。即使仅修正了实现代码中一些错误的发布版本也会改变API的行为,而某些客户端可能确实依赖这些行为。
例如,如果API提供下列函数:
void setImage(Image* img);
在API的1.0版本中,这个函数也许有一个错误,传入 nullptr
指针会导致它崩溃。在1.1版本中,你修正了这个错误,使得代码在这种情况下不再会崩溃。这已经改变了API的行为,所以不是严格的功能兼容。不过,它以一种良好的方式改变行为:修改了会导致崩溃的错误。所以,功能改变不一定是坏事. 这可以作为一种度量API运行时行为改变的度量标准,大多数API更新会有意破坏功能兼容性。
这里举一个例子说明功能兼容性是有用的,考虑一个API, 它的新版本仅关注性能,这种情况下, API的行为完全没有改变。 但接口背后的算法得以改进, 能在更短时间内得到完全一致的结果。从这个角度看,新的API可认为是100%功能兼容的。
功能兼容性意味着第N+1版本API的行为和第N版本一致。
源代码兼容性
源代码兼容性是对向后兼容性较为宽松的定义。它主要是指用户可以使用新版本的API重新编译程序,而不用对代码做任何修改。这个概念不涉及编译出的程序的行为,只要能够成功编译并链接即可。源代码兼容性有时也称为API兼容性。
例如,虽然下列两个函数的函数签名不同,但它们是源代码兼容的:
// 版本 1.0
void setImage(Image* img);
// 版本 1.1
void setImage(Image* img, bool keep_aspect = true);
这是因为之前编写的所有调用1.0版本函数的用户代码也可以使用1.1版本进行编译(新参数是可选的)。相反,下列两个函数不是源代码兼容的,因为用户需要仔细检查代码,找到setImage()
方法的所有实例,添加第二个必选参数。
// 版本 1.0
void setImage(Image* img);
// 版本 1.1
void setImage(Image* img, bool keep_aspect);
所有修改只限于实现代码而不涉及公有头文件,那这显然是100%源代码兼容的,因为两个版本的接口完全一致。
源代码兼容性意味着用户使用第N版本API编写的代码可以使用第N+1版本进行编译,而不用修改源代码。
二进制兼容性
二进制兼容性意味着客户需要做的只是使用新版本的静态库重新链接他们的程序,或把新的共享库放入最终用户应用程序的安装目录。相比之下,源代码兼容性规定只要有新版本API发布,用户就必须重新编译他们的程序。
这意味着API的任何修改一定不能影响任何类、函数在库文件中的表示。API中所有元素的二进制表示,包括类型、大小、结构体对齐和所有的函数签名必须维持原样。这通常也称为应用程序二进制接口(ABI)兼容性。
使用C++很难获得二进制兼容性。在C++中,大多数对接口的修改会导致其二进制表示改变,例如,有这样两个不同函数的重整名(mangled name,即用来标识目标文件或库文件中函数的符号名):
// 版本 1.0
void setImage(Image* img);
-> _Z8setImageP5Image
// 版本 1.1
void setImage(Image* img, bool keep_aspect = false);
-> _Z8setImageP5Imageb
这两个方法是源代码兼容的,但不是二进制兼容的,因为它们各自产生了不同的重整名。这意味着使用1.0版本API编译的代码不能够直接使用1.1版本的库,因为_Z8setImageP5Image
符号的定义不复存在。
使用不同的编译选项时,API的二进制表示也会改变。这通常也是编译器相关的,原因之一是C++标准委员会没有规定名字重整的细节。因此,即使在相同的平台上,一种编译器使用的重整方案也可能不同于另一种编译器。(前面展示的重整名字是GNU C++4.3生成的。)
二进制兼容性意味着使用第N版本API编写的应用程序可以仅通过替换或重新链接API的新动态链接库,就升级到第N+1版本。
究竟是什么导致了二进制不兼容
导致源代码不兼容的因素通常显而易见,没什么好深挖的,在此就不在探究。本节重点聊聊导致二进制不一致的原因。
ABI ,本质上可以理解为编译器和链接器在生成和使用二进制接口时约定的规范,涵盖了函数调用约定、参数传递方式、内存布局、数据对齐等方面。影响的因素有自身代码的内因,也有依赖环境的外因。
外部因素主要有:
- 编译器的不一致。编译器不管是升级或者更换,都可能导致生成二进制文件的代码布局或指令顺序发生变化,从而导致与之前版本的二进制文件不兼容。
- 操作系统的更新。操作系统的更新,可能引入对系统调用、库函数、系统数据结构或其他底层接口的更改。这些变化可能导致原来的二进制程序或动态库在新操作系统上发生异常。
- 编译选项、链接选项的改变。当这些改变涉及到函数调用约定、符号解析、函数名修饰等的改变时,会导致二进制不兼容。
- 使用了STL 库文件。因为不同的 C++ 编译器、不同版本的 C++ 编译器携带的 STL 不具备二进制兼容性,甚至同一个版本的 C++ 编译器用户也可能使用不同的 STL 替代自带的 STL。
- 环境变化导致内存对齐方式发生变化。
内部因素主要有:
- 内存管理上面,内存分配和释放跨越了 DLL Boundary。也就是说,一个模块创建的对象被其它模块销毁了。
- DLL 文件内存布局的改变。这主要发生在新增加类成员变量的场景。这可能导致旧版本的二进制文件无法正确解析新版本的类定义,或者在编译时发生大小不匹配的错误。
- 已有的类/函数发生了修改、移动或删除。其中有一点需要关注的是,通常开发者在后续版本中为了兼容前一版本的接口,会在原函数的基础上,增加带有默认值的参数,这可以使 API 兼容,但是在汇编逻辑中,增加默认值参数后的函数会被赋予另一个名字,原名字已经不复存在,这会导致 ABI 不兼容。
- 在类派生体系中,虚函数表发了变化,虚表指针指向的地址发生了变化。导致在程序汇编过程中虚表指针访问虚表中特定偏移量的函数发生了变化,导致程序运行中可能得异常。这点是很常见的因素,也是不好避免的。在**《补充原则自证》**中会对其进行深度探究。
二进制不兼容可能导致的现象
- 程序在不重新编译情况下无法启动。
- 程序可以运行,但是会报错,甚至导致崩溃。
- 程序可以运行,也不报错,不崩溃,就是执行不正常。
导致上述现象的原因有很多种,但是最主要集中在虚表上面。通常C++的程序编译运行时会进过4个过程:预处理——编译——汇编——链接。
在汇编时,虚表指针只会访问虚函数表中特定偏移量的函数。那么当程序二进制变了之后,虚表重新排布了,虚表指针指到原来位置,就可能会遇到错误的地址,导致第一种情况,直接报错了;
有可能指到了另一函数,正好返回值,参数都一样,那就会程序正常运行,但是另一函数如果内部有校验逻辑,就有可能发生第二种情况,报错;
如果没有这种逻辑,又传出去给其他接口,也没校验逻辑,就会导致第三种情况,不崩溃,不报错,就是正常客户端操作得不到正确结果。
更有甚者,如果提供的 lib 文件是以组件形式进行管理,就算重新编译源码也找不到问题,这个 ABI 不兼容,不崩溃,不报错,程序就是执行不正常。这种情况下,公司维护人员的心理状态可想而知。
怎样维护源代码兼容
添加功能
在源代码兼容性方面,给API添加新功能通常是安全的。添加新类、新成员函数或新的全局函数不会改变已有API元素的接口,所以不会破坏已有代码。
但这条经验法则也有例外,给抽象基类添加新的纯虚成员函数就不是 API 兼容的,如下所示:
class ABC {
public:
virtual ~ABC();
virtual void existingCall() = 0;
virtual void