前言:
假设你的应用程序引用的一个库某天更新了,虽然 API 和调用方式基本没变,但你需要重新编译你的应用程序才能使用这个库,那么一般说这个库是源码兼容(Source compatible);反之,如果不需要重新编译应用程序就能使用新版本的库,那么说这个库跟它之前的版本是二进制兼容的(Binary compatible)。
对于 C++ 平台的应用商店程序,怎样保证平台商店版本更新了,商店里面的应用程序能在不更新的情况下继续使用,就变成了一件十分重要的事情。
维护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向前兼容的方法。
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 ,本质上可以理解为编译器和链接器在生成和使用二进制接口时约定的规范,涵盖了函数调用约定、参数传递方式、内存布局、数据对齐等方面。影响的因素有自身代码的内因,也有依赖环境的外因。
外部因素主要有:
内部因素主要有:
导致上述现象的原因有很多种,但是最主要集中在虚表上面。通常C++的程序编译运行时会进过4个过程:预处理——编译——汇编——链接。
在汇编时,虚表指针只会访问虚函数表中特定偏移量的函数。那么当程序二进制变了之后,虚表重新排布了,虚表指针指到原来位置,就可能会遇到错误的地址,导致第一种情况,直接报错了;
有可能指到了另一函数,正好返回值,参数都一样,那就会程序正常运行,但是另一函数如果内部有校验逻辑,就有可能发生第二种情况,报错;
如果没有这种逻辑,又传出去给其他接口,也没校验逻辑,就会导致第三种情况,不崩溃,不报错,就是正常客户端操作得不到正确结果。
更有甚者,如果提供的 lib 文件是以组件形式进行管理,就算重新编译源码也找不到问题,这个 ABI 不兼容,不崩溃,不报错,程序就是执行不正常。这种情况下,公司维护人员的心理状态可想而知。
在源代码兼容性方面,给API添加新功能通常是安全的。添加新类、新成员函数或新的全局函数不会改变已有API元素的接口,所以不会破坏已有代码。
但这条经验法则也有例外,给抽象基类添加新的纯虚成员函数就不是 API 兼容的,如下所示:
class ABC {
public:
virtual ~ABC();
virtual void existingCall() = 0;
virtual void newCall() = 0; // 在 API 的新发布版本中添加
};
这是因为现存的所有客户代码此时必须重写这个新方法,否则它们的派生类就不能被具象化,代码也无法通过编译。变通方案是把添加到抽象基类的每个新函数都提供默认实现,即从纯虚函数变为虚函数。
class ABC {
public:
virtual ~ABC();
virtual void existingCall() = 0;
virtual void newCall(); // 在 API 的新发布版本中添加
}
在 API 初始版本发布之后,不要为抽象基类添加新的纯虚成员函数。
修改功能而不破坏已有客户代码是一项技巧性更强的工作。如果只关心源代码兼容性,那么可以给函数添加新参数,它们放在所有现存的参数之后,并声明为可选的。这意味着不强制要求用户为添加新参数,更新所有已有的调用。(但是这种做法不是二进制兼容的。)
// 版本 1.0
void setImage(Image* img);
// 版本 1.1
void setImage(Image* img, bool keep_aspect = true);
修改那些先前返回 void 类型的已有方法的返回类型也是源代码兼容的,因为已有的代码不会检查返回类型。
// 版本 1.0
void setImage(Image* img);
// 版本 1.1
bool setImage(Image* img);
如果想添加一个参数,但它并不出现在所有已有参数之后,或者正在编写的是不支持可选参数的纯C API。那么可以引人一个名字不同的函数,同时重构旧函数的实现,使之调用新函数。
在模板使用方面,为 API 添加新的显式模板实例化会潜在地破坏向后兼容性,因为客户也许已经为该类型添加了显式实例化。如果是这种情况,客户在试图编译代码时,会遇到重复显式实例化的错误。
弃用的功能是指强烈建议客户不要使用的某个特性,通常是因为特性更新,或有新的替代特性。由于弃用的特性仍然存在于API中,所以用户仍然可以调用,但这样做可能会产生某种类型的警告。理想的状态是把弃用的特性从API的未来版本中完全移除。
弃用功能是启动移除持性的过程,为的是给客户留以时间,以便使用推荐的新语法更新代码。
弃用功能可能出于多种原因,包括解决安全缺陷、引入更强大的特性、简化 API,或者支持 API 功能的重构。
关于弃用声明,开发者通常有如下几项需要注意:
弃用已有函数时,应当在该函数的文档中标明这一事实,同时说明可以取代它的新功能口。应对于平台代码,建议在该API的标准接口注释中予以说明,这样在生成 reference 文档时,文档中会有体现。
采取一些措施,使函数使用时产生警告消息。大多数编译器提供了将类、函数或变量标记为“已弃用”的方法,只要程序访问了带有这种标记的符号,就会输出编译时警告。对于 Visual Studio C++,可在方法声明前添加__declspec(deprecated)
前缀,而对于GNU C++ 编译器,可使用__attribute__((deprecated))
。下列代码定义了 DEPRECATED
宏,它对两种编译器都适用:
// deprecated.h
#ifdef __GNUC__
#define DEPRECATED __attribute__((deprecated))
#elif define(_MSC_VER)
#define DEPRECATED __declspec(deprecated)
#else
#define DEPRECATED
#pragma message("DEPRECATED is not defined for this compiler")
#endif
使用这种定义,可以通过下列方式把特定方法标记为已弃用:
#include "deprecated.h"
#include
class MyClass {
public:
DEPRECATED std::string getName();
std::string getFullName();
}
如果用户调用getName
方法,编译器就会输出警告消息说明方法已经弃用。例如,如果警告是 MSVC 编译器输出的,则会输出 C4996 告警。
除了提供编译时告警,还可以编写在运行时给出弃用警告的代码。这样做的理由之一是能够在警告消息中提供更多的信息,比如说明可替代使用的方法。例如,可以声明下面这个函数,把它作为每个需要弃用的函数的第一条语句。
void deprecated(const std::string oldfunc, const std::string newfunc="");
...
std::string MyClass::getName()
{
deprecated("MyClass::getName", "MyClass::getFullName");
...
}
deprecated()
的实现可以维护一个std::set
,其中包含所有已经输出过警告的函数名,同时加一个UI告警弹窗。它支持只在第一次调用弃用的函数时才输出警告,从而避免方法被调用很多次时污染终端。
有些功能在至少一个发布版本中,标记弃用声明后,可能最终会从 API 中移除。移除特性会破坏所有依赖该特性的现有客户程序。因此,应该首先把特性标记为已弃用,并通过警告向用户说明你要移除该功能的意图。
从 API 中移除功能是一项极端的操作。但如果某个功能不再继续维护,或者限制了API的改进能力,出于安全原因希望它不再被调用,那么就需要将其移除。
移除功能的同时仍然允许老用户访问旧功能的一种办法是提升主版本号,并声明新版本不是向后兼容的。 然后从API的最新版本中彻底移除该功能,但仍然提供 API 的旧版本下载,让用户认识到这些功能已被弃用、不再支持。 应当只在遗留应用中使用。有些平台设计者把 API 的头文件存储在不同的目录,井重命名库,使得两个 API 互不冲突。这是一个大动作,不要经常这样做。没有什么事比让 API 的生命周期处于最佳状态更重要了。
下面是两个详细的列表,分别列举了需要用户重新编译代码的API修改以及不破坏二进制兼容性、可以安全执行的API修改。
二进制不兼容的API修改:
二进制兼容的API修改:
把API修改限制在第二个列表中列出的修改,就能够维持API发布版本之间的二进制兼容性。
下面给出一些有助于实现二进制兼容性的实用性技巧:
不要给已有函数增加参数,可以定义该函数新的重载版本。这确保原有符号继续存在,同时也能提供新的调用约定。在.cpp
文件内部,老方法的实现可以直接调用新的重载方法。( 注意:本处重载函数只是指非虚函数,虚函数重载情况特殊,另外讨论。)
// 版本 1.0
void setImage(Image* img);
// 版本 1.1
void setImage(Image* img);
void setImage(Image* img, bool keep_aspect);
(注意:如果函数尚未重载,那么这项技巧可能会影响源代码(API)兼容性,因为如果没有显式转换,客户代码将不再能够引用函数指针&setImage
。)(即添加重载函数注意不要给新加参数赋予默认惨,避免隐式转换,导致 API 不兼容。)
Pimpl模式可以用来帮助保持接口的二进制兼容性,因为它把那些将来很可能发生变化的实现细节移进了.cpp
文件,使之不会影响公有的.h
文件。
在一个主版本内选择固定的编译器、编译选项、链接选项。
在 API 层禁止使用 STL 。
约定各个接口文件的内存对齐方式:#pragma pack(push, 8)
。
非必须,不要在API里面新增成员变量,如必须,在不破坏内存布局的前提下,添加在类的末尾。
尽量避免采用派生的结构来提供 API 接口,除非开发者及审查者能够很好的理解虚表是如何影响ABI兼容,以及其严格的执行相关标准。
注意内存管理,内存分配和释放不应该跨越 DLL Boundary。
采用纯C风格的API可以更容易地获得二进制兼容性,原因很简单,C不提供诸如继承、可选参数、重载、异常及模板等特性。为了利用C和C++各自的优势,可以选择使用面向对象的C++风格开发API, 然后用纯C风格封装C++API。
如果确实需要做二进制不兼容的修改,那么可以考虑为新库换个不同的名字,这样就不会破坏已有的应用程序。
对于每一处修改,审查人员都应该询问一些问题,比如:
C++的类只要有一个虚函数,就会生成一张虚表:
class A
{
};
class B
{
public:
virtual void vfunc1();
}
sizeof(A) = 1 // 空类1个字节用于地址定位
sizeof(B) = 4 // 有虚表指针,占sizeof(void*)字节
Visual Studio 可以使用自带的命令行工具查看类的内存布局。在 Visual Studio 2022 中是如下工具:
命令是:cl /d1 reportSingleClassLayout
例如:cl /d1 reportSingleClassLayoutA demo.cpp
即,在 demo.cpp
中查看 class A 的内存布局。
class A
{
public:
virtual void vfunc1();
private:
int a;
};
class B
{
public:
virtual void vfunc2();
private:
int b;
};
class C1 : public A
{
public:
virtual void vfunc3();
private:
int c;
};
class C2 : public A, public B
{
public:
virtual void vfunc3();
private:
int c;
};
class C1 的内存布局是:
class C1 size(12):
+---
0 | +--- (base class A)
0 | | {vfptr}
4 | | a
| +---
8 | c
+---
C1::$vftable@:
| &C1_meta
| 0
0 | &A::vfunc1
1 | &C1::vfunc3
class C2 的内存布局是:
class C2 size(20):
+---
0 | +--- (base class A)
0 | | {vfptr}
4 | | a
| +---
8 | +--- (base class B)
8 | | {vfptr}
12 | | b
| +---
16 | c
+---
C2::$vftable@A@:
| &C2_meta
| 0
0 | &A::vfunc1
1 | &C2::vfunc3
C2::$vftable@B@:
| -8
0 | &B::vfunc2
举例:
class A
{
public:
virtual void vfunc1() = 0;
virtual void vfunc2() = 0;
virtual void vfunc1(int x) = 0;
virtual void vfunc3() = 0;
void vfunc4();
void vfunc4(int x);
virtual void vfunc1(int x, int y) = 0;
};
class B : public A
{
public:
virtual void vfunc1(int x) = 0;
virtual void vfunc4() = 0;
void vfunc5();
virtual void vfunc2(int x) = 0;
}
请问B的虚表是应该是什么样的?
遍历A中的虚函数
void A::vfunc1();
由于 vfunc1
有两个重载,按照第 2
条规则,依次提前重载函数:
void A::vfunc1();
void A::vfunc1(int x);
void A::vfunc1(int x, int y);
继续遍历A中的虚函数
void A::vfunc1();
void A::vfunc1(int x);
void A::vfunc1(int x, int y);
void A::vfunc2();
void A::vfunc3();
由于B
重写了 A
的 void vfunc1(int x)
函数,所以将表中对应的函数替换
void A::vfunc1();
void B::vfunc1(int x);
void A::vfunc1(int x, int y);
void A::vfunc2();
void A::vfunc3();
添加 B::vfunc4()
到虚表中
void A::vfunc1();
void B::vfunc1(int x);
void A::vfunc1(int x, int y);
void A::vfunc2();
void A::vfunc3();
void B::vfunc4();
由于 B::vfunc2(int x)
没有重写A中的函数,按照规则 1
添加到虚表中
void A::vfunc1();
void B::vfunc1(int x);
void A::vfunc1(int x, int y);
void A::vfunc2();
void A::vfunc3();
void B::vfunc4();
void B::vfunc2(int x);
答:为了ABI兼容
举例:
某工程师写了这样一个 SDK:
// awesome.h
class IAwesomeSDK
{
public:
virtual void foo() = 0;
virtual void bar(int x) = 0;
};
extern "C" {
// 创建SDK实例
IAwesomeSDK *createAwesomeInstance();
// 销毁SDK实例
void destroyAwesomeInstance();
} // extern "C"
// 二次开发用户这样对其进行使用:
// demo.cpp
int main(int argc, char **argv)
{
IAwesomeSDK *sdk = createAwesomeInstance();
sdk->foo();
sdk->bar();
destroyAwesomeInstance();
return 0;
}
如果保证新发布的动态库可以兼容之前的程序(集成DLL的程序不需要重新编译,就可以使用新DLL),那么动态库中添加功能需要注意:
只能在类最后添加新的虚函数
class IAwesomeSDK
{
public:
virtual void feature1() = 0; // 错误
virtual void foo() = 0;
virtual void bar(int x) = 0;
};
添加的新函数不可以与旧函数重名(重载)
class IAwesomeSDK
{
public:
virtual void foo() = 0;
virtual void bar(int x) = 0;
virtual void bar() = 0; // 错误
};
不可以修改旧函数的签名(参数,返回值,限定符等)
class IAwesomeSDK
{
public:
virtual void foo(int x = 0) = 0; // 错误
virtual void bar(int x) = 0;
};
不可以重新排序旧函数
class IAwesomeSDK
{
public:
virtual void bar(int x) = 0; // 错误
virtual void foo() = 0; // 错误
};
这时你要添加一个新功能,还希望旧程序可以不重新编译替换新DLL,你可以这么做:
class IAwesomeSDK
{
public:
virtual void foo() = 0;
virtual void bar(int x) = 0;
virtual void feature() = 0; // 正确
};
申请和释放内存保持在同一模块。
最好不要在接口处使用STL库,除非编译器选项一致、STL实现一致、系统平台一致。
class IAwesomeSDK
{
public:
virtual void foo() = 0;
virtual void bar(int x) = 0;
virtual std::string feature() = 0; // 错误,模块内申请,模块外释放
};
先说结论:如果类中含有虚析构函数,其受约束和普通虚函数一致:
数据测试如下:(环境:Visual Studio 2022,默认配置)
测试项1:没有虚析构函数时,虚表中的排布情况如下图:
测试项2:虚析构函数位于类首时:
测试项3:虚析构函数位于有重载的虚函数前面时:
测试项4:虚析构函数位于非重载的虚函数前面时:
先说结论:不会。
数据测试如下: