C++ 回调接口设计和二进制兼容 C++ 回调接口设计和二进制兼容详细
黄兢成 人气:01、疑问
我们在开发一个视频编辑 SDK
。SDK 的回调接口设计成 C 风格,结构中放着一些函数指针
struct SKYMEDIA_API SkyEncodingCallback final { // PS: 为达到完全的二进制兼容,这里还应该有个 structSize 的字段。见最后一小节 void *userData = nullptr; bool (*shouldBeCancelled)(void *userData) = nullptr; void (*onProgress)(void *userData, double currentTime, double totalTime) = nullptr; void (*onFinish)(void *userData) = nullptr; void (*onError)(void *userData, SkyError error) = nullptr; }; bool exportVideo(const char *filePath, const SkyEncodingParams ¶ms, const SkyEncodingCallback &callback);
有同事乍一看,会有疑问,既然对外接口是 C++,为什么不直接使用 C++ 的虚函数?
struct SkyEncodingCallback { virtual ~SkyEncodingCallback() {} virtual bool shouldBeCancelled() = nullptr; virtual void onProgress(double currentTime, double totalTime) = nullptr; virtual void onFinish() = nullptr; virtual void onError(SkyError error) = nullptr; }; bool exportVideo(const char *filePath, const SkyEncodingParams ¶ms, SkyEncodingCallback *callback);
使用 C 风格的回调设计,主要考虑两个原因
- 更容易做到库接口的二进制兼容。
- 更容易跟 C 对应,方便绑定到各种不同的语言实现。(比如 Flutter 的封装会使用 ffi 直接调用 C)
这里不讨论语言绑定,只讨论接口的二进制兼容。
2、二进制兼容
编译好的 C/C++
库,会提供一些头文件和动态连接库(或者静态库)。主程序(或其他库)使用头文件调用接口,之后去链接库(动态或静态链接)。
假如主程序在编译时,看到的头文件,跟库代码不匹配,就会可能产生了兼容问题。为方便描述,我们假设
- 主程序为
skyeditor.exe
- 库为
skymedia.dll
- 库的头文件为
skymedia.h
有些人会奇怪,既然 skymedia.h
和 skymedia.dll
是一起提供的,自然会匹配。怎么可能出现头文件跟库不一致呢?
3、编译环境
首先注意到,skymedia.dll
和 skyeditor.exe
是分开编译的。库的开发者跟主程序的开发者有可能会不同,或者编译时间上会错开。
于是就可能出现,编译 skymedia.dll
和 skyeditor.exe
所用到的编译器和编译选项不一致。
比如 skymedia.dll
用了编译器 A 预先编译,而编译 skyeditor.exe
时用了编译器 B。同一个标准库类,比如 std::string,虽然是相同的名字,但编译器 A 和编译器 B,自带 std::string
的实现却有可能不同。假如 skymedia.h
出现了一些 STL 的类,就算 skymedia.h 源码完全一样,但在编译 skymedia.dll
和 编译 skyeditor.exe
时,编码器对头文件本身的解释却会有不同。
于是在编译 skyeditor.exe
时,看到的头文件 skymedia.h,就跟 skymedia.dll
不匹配了。
C++ 并没有规定一致的二进制标准。对标准库,以及某些 C++ 语法的支持,不同的编译器是可以不同的。有时就算是相同名字的编译器,只是升级了版本,编译出来的二进制布局有可能不同。C++ 所谓的跨平台,只是源码上的跨平台,并不是二进制级别的跨平台。
假如幸运的话,不同编译器编译出来的链接符号不一样,在链接阶段能即时发现问题。但假如链接符号一致,但二进制布局不一致,到执行阶段才会出问题,就难以发现了。
另外就算是编译器和标准库完全一致,因编译选项不同也有可能引起不匹配。比如
struct Test { int a; int b; #ifdef CONFIG_DEBUG int64_t debugTimestamp; #endif };
假如编译 skymedia.dll
和编译 skyeditor.exe
时,对宏 CONFIG_DEBUG
的定义不同。也会引起头文件和库不匹配。
将编译器和编译选项,统称编译环境。因编译环境的不同,就有可能产生二进制兼容问题。
4、动态链接库
现在假设编译器和编译选项,在编译 skymedia.dll
和 skyeditor.exe
时完全一样,仍然有可能产生不兼容。
就是 skymedia.dll
动态升级了。
比如 skyeditor.exe
现在编译好了,已发布了出去。skymedia.dll
出现了 bug,或者更新了功能,需要让用户单独下载更新 skymedia.dll。
或者 skyeditor.exe
同时依赖了 skymedia.dll
和 plugin.dll。而 plugin.dll 也依赖了 skymedia.dll。但 skyeditor.exe 和 plugin.dll 所用到的 skymedia.dll
的版本不一致。于是就可能出现 plugin.dll
所用的 skymedia.dll
版本,被 skymedia.exe 无意中被覆盖掉了。
一个程序依赖的组件越多,独立开发的团队就越多,也就越难以协调同步每个团队所用的库(以及版本)。能预先发现版本不一致自然最好,但有时明明规定好开发准则,但还是可能出现失误,不一致就偷偷溜进来了。
动态库跟静态不同,动态库并不用强制 skyeditor.exe 重新编译,也可以单独更新。于是 skyeditor.exe
在编译时,看到的 skymedia.h 头文件,跟新版本的 skymedia.dll
有可能不同。
假设在更新 skymedia.dll
时,修改了 skymedia.h 的结构。就可能引起了二进制兼容问题。
单独更新了动态库,也有可能产生二进制兼容问题。
5、C++ 风格,虚函数接口例子
现在我们来实际分析一下代码。假如旧版 skymedia.dll 接口使用虚函数,会产生什么问题。类似这样子
// old skymedia.h struct SkyCallback { virtual ~SkyCallback() {} virtual void callback0() = 0; }; // old skymedia.dll void sky_dosomthing(SkyCallback* callback) { // 做一些事情 callback->callback0(); // 做一些事情 }
而 skymedia.exe
在编译时候,所用到的是旧版 skymedia.dll
,调用如下
class MyCallback : public SkyCallback { virtual ~MyCallback() {} virtual void callback0() { // 做一些事情 } virtual void onKeyboard() { // 做一些事情 } }; MyCallback* callback = new MyCallback(); // 做一些事情 void sky_dosomthing(SkyCallback* callback);
现在更新了 skymedia.dll
,新版本的 SkyCallback
添加了一个接口
// skymedia.h struct SkyCallback { virtual ~SkyCallback() {} virtual void callback0() = 0; virtual void callback1() = 0; // 新加 }; // skymedia.dll void sky_dosomthing(SkyCallback* callback) { // 做一些事情 callback->callback0(); // 做一些事情 callback->callback1(); }
注意 skymedia.exe
这时并没有被重新编译(因为只单独更新了 dll),但它动态链接了新的 sky_dosomthing
。于是就出现了用旧的 MyCallback
去调用新版本的 sky_dosomthing
。而新版本的 sky_dosomthing
代码中,又调用了 MyCallback
的 callback1
,但旧版的 MyCallback
是没有这个 callback1
的。C++ 没有类似 OC 的反射,没有很好方法去动态判断 callback1 是否存在。
于是就出现问题了,调用之后,就不知执行到哪里了。假如这里的代码只偶然被执行,问题就会隐藏得很深。
PS: C++ 常见的虚函数实现,调用虚函数会查表。调用新版本的 callback1
,相当于调用表格第二项(或第三项?)的函数。对于 skymedia.exe
来说,表格第二项对应于 onKeyboard
。于是只是更新了 dll,可能就莫名其妙地触发了 onKeyboard
了。
在这种虚函数的设计下,要完全二进制兼容,会比较麻烦。常见的做法是,SkyCallback 每加一个接口,就定义新的名字,保持 SkyCallback
接口完全不变。于是随着时间推移,要保证二进制兼容,就产生一系列的 SkyCallback
、SkyCallback2
、SkyCallback3
。用户在更新库版本后,要用新功能,也相应使用新名字的接口类。这种做法,我个人并不喜欢。
PS: 作为对比,在 C 风格的回调,如何做二进制兼容,参考最后一小节。
6、进一步讨论二进制兼容
要完全做到二进制兼容,是一件很麻烦的事情。是否值得花力气,要看具体场合。假设编译环境可控,还能做到一旦库被修改,强制使用库的所有程序都重新编译。有这样的理想环境,就不一定要达到二进制兼容。
但我们不能假设有这样理想的环境,设想一些情况
多个不同的库,同时使用了 skymedia.dll
。假如 skymedia.dll
能做到二进制兼容,某个库就可以独自升级而不用跟其他团队协调。不然难以推动其他团队一起升级,所用的库就被锁死在某个版本。
发布程序后,主程序不变,让用户独立升级 skymedia.dll
,比如 fix bug
或者更新功能。(某些大型程序,会使用 dll 作为插件机制。能独立升级 dll,也就能独立升级插件)
用于调试。比如只在某个测试(更只在某个用户)的机器上出现问题,但不知道崩溃在那里。这时可以本地编译一个带调试信息的本地 dll,让测试(或用户)替换掉原来的 dll。崩溃之后就有出现一些调试信息。
库的对外接口,需要仔细考虑。而库的内部实现,肯定是一起编译的,就不需要那样讲究。SkyMedia C++ API
考虑到二进制兼容,做了一些取舍,但还没有做到完全的二进制兼容(要完全做到,还是有点麻烦的),只是尽量往这目标靠近。
不出现任何 STL 的类。(比如不使用 std::string
)。
impl 手法,复杂的类,内部只包括一个 void*,隐藏掉内部全部实现。
接口不使用任何实现上不标准 C++ 特性,比如虚函数,多重继承等等。(这里不标准特性,是指不同的编译器,编译出来的二进制布局可能不一致)。
有些人可能还是问,既然 C++ 的接口这样麻烦,为什么还是提供 C++ 的接口,而不是 C 的接口。
确实,有些库就算内部采用 C++ 开发,也是导出纯 C 接口。采用 C++ 接口的,主要是考虑到纯 C 的接口用起来麻烦。
比如 C++ API,可以类似这样用
SkyResource res("/helloworld/test.mp4"); SkyVideoTrack *track = timeline->appendVideoTrack(); track->appendClip(res, SkyTimeRange(0, 10));
假如是纯 C API, 就类似这样了
SkyResource *res = SkyResource_create("/helloworld/test.mp4"); SkyVideoTrack *track = SkyTimeline_appendVideoTrack(timeline); SkyVideoTrack_appendClip(res, SkyTimeRange(0, 10)); SkyResource_release(res);
大量写这种纯 C 代码,很繁琐,也容易忘记初始化,和释放资源。
7、C 风格的回调,如何做二进制兼容
最后,作为补充,我们回到最开始的问题。类似这种 C 风格的结构,如何做二进制兼容呢?比如下面结构
struct SkyCallback { void *userData = nullptr; void (*callback0)(void *userData) = nullptr; };
这种结构,就跟我们最开始的 SkyEncodingCallback
很像了。
要做到完全二进制兼容,最初的 SkyCallback
必须稍微改一下的,预埋一个 structSize
字段,初始化成结构的大小。
// old skymedia.h struct SkyCallback { int structSize = sizeof(SkyCallback); // 增加这个字段 void *userData = nullptr; void (*callback0)(void *userData) = nullptr; }; // old skymedia.dll void sky_dosomthing(SkyCallback callback) { if (callback.callback0) { callback.callback0(callback.userData); } }
skyeditor.exe 这样调用
// skyeditor.exe void my_callback0(void* userData) { // 做一些事情 } SkyCallback callback; callback.userData = xxx; callback.callback0 = callback0; sky_dosomthing(callback);
现在 skymedia.dll
更新版本,为保证兼容,可以写成
// new skymedia.h struct SkyCallback { int structSize = sizeof(SkyCallback); void *userData = nullptr; void (*callback0)(void *userData) = nullptr; void (*callback1)(void *userData) = nullptr; }; // new skymedia.dll void sky_dosomthing(SkyCallback callback) { if (callback.callback0) { callback.callback0(callback.userData); } // 做一些事情 // 兼容旧版本 if (offsetof(SkyCallback, callback1) + sizeof(callback.callback1) <= callback.structSize) { if (callback.callback1) { callback.callback1(callback.userData); } } }
注意 sky_dosomthing
中那个对 callback1 的判断。
当 skyeditor.exe
使用旧版本的 skymedia.dll
编译时,SkyCallback
是没有 callback1 字段的结构,structSize 的值也相应小了。于是旧版的 skyeditor.exe
调用了新的 sky_dosomthing
,那个判断就不会成立, callback1
的调用就不会被触发。
structSize
放在最前面,而新加的字段 callback1 放在结构的最后。通过 structSize
可以方便地判断新增的字段是否存在。这样自然就兼容旧版本,SkyCallback
` 的结构名字也不用修改。
目前 SkyEncodingCallback
,还没有添加 structSize
字段。主要是目前我们二进制兼容的需求还不算紧急,但在 API 设计上,已经留了条后路,要改起来也很容易,在源码级别也是完全兼容的。假如一开始就采用 C++ 的虚函数接口,以后就难以修改了。
类似这种结构当中添加 structSize
字段的设计,在 C 接口中,还是比较常见的。比如 Win32 API
,就常见这种用法。
加载全部内容