C++ 回调接口设计和二进制兼容详细

再开发视频编辑 SDK,SDK的回调接口设计成 C 风格,结构中放着一些函数指针,既然对外接口是 C++,为什么不直接使用 C++ 的虚函数?这篇文章便对这一问题做个详细介绍,需要的朋友可以参考一下

1、疑问

我们在开发一个视频编辑 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 &params, 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 &params, SkyEncodingCallback *callback);

使用 C 风格的回调设计,主要考虑两个原因

  • 更容易做到库接口的二进制兼容。
  • 更容易跟 C 对应,方便绑定到各种不同的语言实现。(比如 Flutter 的封装会使用 ffi 直接调用 C)

这里不讨论语言绑定,只讨论接口的二进制兼容。

2、二进制兼容

编译好的 C/C++ 库,会提供一些头文件和动态连接库(或者静态库)。主程序(或其他库)使用头文件调用接口,之后去链接库(动态或静态链接)。

假如主程序在编译时,看到的头文件,跟库代码不匹配,就会可能产生了兼容问题。为方便描述,我们假设

  • 主程序为 skyeditor.exe
  • 库为 skymedia.dll
  • 库的头文件为 skymedia.h

有些人会奇怪,既然 skymedia.hskymedia.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 接口完全不变。于是随着时间推移,要保证二进制兼容,就产生一系列的 SkyCallbackSkyCallback2SkyCallback3。用户在更新库版本后,要用新功能,也相应使用新名字的接口类。这种做法,我个人并不喜欢。

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,就常见这种用法。

到此这篇关于C++ 回调接口设计和二进制兼容详细的文章就介绍到这了,更多相关C++ 回调接口设计和二进制兼容内容请搜索编程学习网以前的文章希望大家以后多多支持编程学习网!

本文标题为:C++ 回调接口设计和二进制兼容详细

基础教程推荐