C++ 回調(diào)接口設(shè)計(jì)和二進(jìn)制兼容詳細(xì)
1、疑問(wèn)
我們?cè)陂_(kāi)發(fā)一個(gè)視頻編輯 SDK
。SDK 的回調(diào)接口設(shè)計(jì)成 C 風(fēng)格,結(jié)構(gòu)中放著一些函數(shù)指針
struct SKYMEDIA_API SkyEncodingCallback final { // PS: 為達(dá)到完全的二進(jìn)制兼容,這里還應(yīng)該有個(gè) structSize 的字段。見(jiàn)最后一小節(jié) 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);
有同事乍一看,會(huì)有疑問(wèn),既然對(duì)外接口是 C++,為什么不直接使用 C++ 的虛函數(shù)?
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 風(fēng)格的回調(diào)設(shè)計(jì),主要考慮兩個(gè)原因
- 更容易做到庫(kù)接口的二進(jìn)制兼容。
- 更容易跟 C 對(duì)應(yīng),方便綁定到各種不同的語(yǔ)言實(shí)現(xiàn)。(比如 Flutter 的封裝會(huì)使用 ffi 直接調(diào)用 C)
這里不討論語(yǔ)言綁定,只討論接口的二進(jìn)制兼容。
2、二進(jìn)制兼容
編譯好的 C/C++
庫(kù),會(huì)提供一些頭文件和動(dòng)態(tài)連接庫(kù)(或者靜態(tài)庫(kù))。主程序(或其他庫(kù))使用頭文件調(diào)用接口,之后去鏈接庫(kù)(動(dòng)態(tài)或靜態(tài)鏈接)。
假如主程序在編譯時(shí),看到的頭文件,跟庫(kù)代碼不匹配,就會(huì)可能產(chǎn)生了兼容問(wèn)題。為方便描述,我們假設(shè)
- 主程序?yàn)?
skyeditor.exe
- 庫(kù)為
skymedia.dll
- 庫(kù)的頭文件為
skymedia.h
有些人會(huì)奇怪,既然 skymedia.h
和 skymedia.dll
是一起提供的,自然會(huì)匹配。怎么可能出現(xiàn)頭文件跟庫(kù)不一致呢?
3、編譯環(huán)境
首先注意到,skymedia.dll
和 skyeditor.exe
是分開(kāi)編譯的。庫(kù)的開(kāi)發(fā)者跟主程序的開(kāi)發(fā)者有可能會(huì)不同,或者編譯時(shí)間上會(huì)錯(cuò)開(kāi)。
于是就可能出現(xiàn),編譯 skymedia.dll
和 skyeditor.exe
所用到的編譯器和編譯選項(xiàng)不一致。
比如 skymedia.dll
用了編譯器 A 預(yù)先編譯,而編譯 skyeditor.exe
時(shí)用了編譯器 B。同一個(gè)標(biāo)準(zhǔn)庫(kù)類(lèi),比如 std::string,雖然是相同的名字,但編譯器 A 和編譯器 B,自帶 std::string
的實(shí)現(xiàn)卻有可能不同。假如 skymedia.h
出現(xiàn)了一些 STL 的類(lèi),就算 skymedia.h 源碼完全一樣,但在編譯 skymedia.dll
和 編譯 skyeditor.exe
時(shí),編碼器對(duì)頭文件本身的解釋卻會(huì)有不同。
于是在編譯 skyeditor.exe
時(shí),看到的頭文件 skymedia.h,就跟 skymedia.dll
不匹配了。
C++ 并沒(méi)有規(guī)定一致的二進(jìn)制標(biāo)準(zhǔn)。對(duì)標(biāo)準(zhǔn)庫(kù),以及某些 C++ 語(yǔ)法的支持,不同的編譯器是可以不同的。有時(shí)就算是相同名字的編譯器,只是升級(jí)了版本,編譯出來(lái)的二進(jìn)制布局有可能不同。C++ 所謂的跨平臺(tái),只是源碼上的跨平臺(tái),并不是二進(jìn)制級(jí)別的跨平臺(tái)。
假如幸運(yùn)的話,不同編譯器編譯出來(lái)的鏈接符號(hào)不一樣,在鏈接階段能即時(shí)發(fā)現(xiàn)問(wèn)題。但假如鏈接符號(hào)一致,但二進(jìn)制布局不一致,到執(zhí)行階段才會(huì)出問(wèn)題,就難以發(fā)現(xiàn)了。
另外就算是編譯器和標(biāo)準(zhǔn)庫(kù)完全一致,因編譯選項(xiàng)不同也有可能引起不匹配。比如
struct Test { int a; int b; #ifdef CONFIG_DEBUG int64_t debugTimestamp; #endif };
假如編譯 skymedia.dll
和編譯 skyeditor.exe
時(shí),對(duì)宏 CONFIG_DEBUG
的定義不同。也會(huì)引起頭文件和庫(kù)不匹配。
將編譯器和編譯選項(xiàng),統(tǒng)稱(chēng)編譯環(huán)境。因編譯環(huán)境的不同,就有可能產(chǎn)生二進(jìn)制兼容問(wèn)題。
4、動(dòng)態(tài)鏈接庫(kù)
現(xiàn)在假設(shè)編譯器和編譯選項(xiàng),在編譯 skymedia.dll
和 skyeditor.exe
時(shí)完全一樣,仍然有可能產(chǎn)生不兼容。
就是 skymedia.dll
動(dòng)態(tài)升級(jí)了。
比如 skyeditor.exe
現(xiàn)在編譯好了,已發(fā)布了出去。skymedia.dll
出現(xiàn)了 bug,或者更新了功能,需要讓用戶(hù)單獨(dú)下載更新 skymedia.dll。
或者 skyeditor.exe
同時(shí)依賴(lài)了 skymedia.dll
和 plugin.dll。而 plugin.dll 也依賴(lài)了 skymedia.dll。但 skyeditor.exe 和 plugin.dll 所用到的 skymedia.dll
的版本不一致。于是就可能出現(xiàn) plugin.dll
所用的 skymedia.dll
版本,被 skymedia.exe 無(wú)意中被覆蓋掉了。
一個(gè)程序依賴(lài)的組件越多,獨(dú)立開(kāi)發(fā)的團(tuán)隊(duì)就越多,也就越難以協(xié)調(diào)同步每個(gè)團(tuán)隊(duì)所用的庫(kù)(以及版本)。能預(yù)先發(fā)現(xiàn)版本不一致自然最好,但有時(shí)明明規(guī)定好開(kāi)發(fā)準(zhǔn)則,但還是可能出現(xiàn)失誤,不一致就偷偷溜進(jìn)來(lái)了。
動(dòng)態(tài)庫(kù)跟靜態(tài)不同,動(dòng)態(tài)庫(kù)并不用強(qiáng)制 skyeditor.exe 重新編譯,也可以單獨(dú)更新。于是 skyeditor.exe
在編譯時(shí),看到的 skymedia.h 頭文件,跟新版本的 skymedia.dll
有可能不同。
假設(shè)在更新 skymedia.dll
時(shí),修改了 skymedia.h 的結(jié)構(gòu)。就可能引起了二進(jìn)制兼容問(wèn)題。
單獨(dú)更新了動(dòng)態(tài)庫(kù),也有可能產(chǎn)生二進(jìn)制兼容問(wèn)題。
5、C++ 風(fēng)格,虛函數(shù)接口例子
現(xiàn)在我們來(lái)實(shí)際分析一下代碼。假如舊版 skymedia.dll 接口使用虛函數(shù),會(huì)產(chǎn)生什么問(wèn)題。類(lèi)似這樣子
// old skymedia.h struct SkyCallback { virtual ~SkyCallback() {} virtual void callback0() = 0; }; // old skymedia.dll void sky_dosomthing(SkyCallback* callback) { // 做一些事情 callback->callback0(); // 做一些事情 }
而 skymedia.exe
在編譯時(shí)候,所用到的是舊版 skymedia.dll
,調(diào)用如下
class MyCallback : public SkyCallback { virtual ~MyCallback() {} virtual void callback0() { // 做一些事情 } virtual void onKeyboard() { // 做一些事情 } }; MyCallback* callback = new MyCallback(); // 做一些事情 void sky_dosomthing(SkyCallback* callback);
現(xiàn)在更新了 skymedia.dll
,新版本的 SkyCallback
添加了一個(gè)接口
// 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
這時(shí)并沒(méi)有被重新編譯(因?yàn)橹粏为?dú)更新了 dll),但它動(dòng)態(tài)鏈接了新的 sky_dosomthing
。于是就出現(xiàn)了用舊的 MyCallback
去調(diào)用新版本的 sky_dosomthing
。而新版本的 sky_dosomthing
代碼中,又調(diào)用了 MyCallback
的 callback1
,但舊版的 MyCallback
是沒(méi)有這個(gè) callback1
的。C++ 沒(méi)有類(lèi)似 OC 的反射,沒(méi)有很好方法去動(dòng)態(tài)判斷 callback1 是否存在。
于是就出現(xiàn)問(wèn)題了,調(diào)用之后,就不知執(zhí)行到哪里了。假如這里的代碼只偶然被執(zhí)行,問(wèn)題就會(huì)隱藏得很深。
PS: C++ 常見(jiàn)的虛函數(shù)實(shí)現(xiàn),調(diào)用虛函數(shù)會(huì)查表。調(diào)用新版本的 callback1
,相當(dāng)于調(diào)用表格第二項(xiàng)(或第三項(xiàng)?)的函數(shù)。對(duì)于 skymedia.exe
來(lái)說(shuō),表格第二項(xiàng)對(duì)應(yīng)于 onKeyboard
。于是只是更新了 dll,可能就莫名其妙地觸發(fā)了 onKeyboard
了。
在這種虛函數(shù)的設(shè)計(jì)下,要完全二進(jìn)制兼容,會(huì)比較麻煩。常見(jiàn)的做法是,SkyCallback 每加一個(gè)接口,就定義新的名字,保持 SkyCallback
接口完全不變。于是隨著時(shí)間推移,要保證二進(jìn)制兼容,就產(chǎn)生一系列的 SkyCallback
、SkyCallback2
、SkyCallback3
。用戶(hù)在更新庫(kù)版本后,要用新功能,也相應(yīng)使用新名字的接口類(lèi)。這種做法,我個(gè)人并不喜歡。
PS: 作為對(duì)比,在 C 風(fēng)格的回調(diào),如何做二進(jìn)制兼容,參考最后一小節(jié)。
6、進(jìn)一步討論二進(jìn)制兼容
要完全做到二進(jìn)制兼容,是一件很麻煩的事情。是否值得花力氣,要看具體場(chǎng)合。假設(shè)編譯環(huán)境可控,還能做到一旦庫(kù)被修改,強(qiáng)制使用庫(kù)的所有程序都重新編譯。有這樣的理想環(huán)境,就不一定要達(dá)到二進(jìn)制兼容。
但我們不能假設(shè)有這樣理想的環(huán)境,設(shè)想一些情況
多個(gè)不同的庫(kù),同時(shí)使用了 skymedia.dll
。假如 skymedia.dll
能做到二進(jìn)制兼容,某個(gè)庫(kù)就可以獨(dú)自升級(jí)而不用跟其他團(tuán)隊(duì)協(xié)調(diào)。不然難以推動(dòng)其他團(tuán)隊(duì)一起升級(jí),所用的庫(kù)就被鎖死在某個(gè)版本。
發(fā)布程序后,主程序不變,讓用戶(hù)獨(dú)立升級(jí) skymedia.dll
,比如 fix bug
或者更新功能。(某些大型程序,會(huì)使用 dll 作為插件機(jī)制。能獨(dú)立升級(jí) dll,也就能獨(dú)立升級(jí)插件)
用于調(diào)試。比如只在某個(gè)測(cè)試(更只在某個(gè)用戶(hù))的機(jī)器上出現(xiàn)問(wèn)題,但不知道崩潰在那里。這時(shí)可以本地編譯一個(gè)帶調(diào)試信息的本地 dll,讓測(cè)試(或用戶(hù))替換掉原來(lái)的 dll。崩潰之后就有出現(xiàn)一些調(diào)試信息。
庫(kù)的對(duì)外接口,需要仔細(xì)考慮。而庫(kù)的內(nèi)部實(shí)現(xiàn),肯定是一起編譯的,就不需要那樣講究。SkyMedia C++ API
考慮到二進(jìn)制兼容,做了一些取舍,但還沒(méi)有做到完全的二進(jìn)制兼容(要完全做到,還是有點(diǎn)麻煩的),只是盡量往這目標(biāo)靠近。
不出現(xiàn)任何 STL 的類(lèi)。(比如不使用 std::string
)。
impl 手法,復(fù)雜的類(lèi),內(nèi)部只包括一個(gè) void*,隱藏掉內(nèi)部全部實(shí)現(xiàn)。
接口不使用任何實(shí)現(xiàn)上不標(biāo)準(zhǔn) C++ 特性,比如虛函數(shù),多重繼承等等。(這里不標(biāo)準(zhǔn)特性,是指不同的編譯器,編譯出來(lái)的二進(jìn)制布局可能不一致)。
有些人可能還是問(wèn),既然 C++ 的接口這樣麻煩,為什么還是提供 C++ 的接口,而不是 C 的接口。
確實(shí),有些庫(kù)就算內(nèi)部采用 C++ 開(kāi)發(fā),也是導(dǎo)出純 C 接口。采用 C++ 接口的,主要是考慮到純 C 的接口用起來(lái)麻煩。
比如 C++ API,可以類(lèi)似這樣用
SkyResource res("/helloworld/test.mp4"); SkyVideoTrack *track = timeline->appendVideoTrack(); track->appendClip(res, SkyTimeRange(0, 10));
假如是純 C API, 就類(lèi)似這樣了
SkyResource *res = SkyResource_create("/helloworld/test.mp4"); SkyVideoTrack *track = SkyTimeline_appendVideoTrack(timeline); SkyVideoTrack_appendClip(res, SkyTimeRange(0, 10)); SkyResource_release(res);
大量寫(xiě)這種純 C 代碼,很繁瑣,也容易忘記初始化,和釋放資源。
7、C 風(fēng)格的回調(diào),如何做二進(jìn)制兼容
最后,作為補(bǔ)充,我們回到最開(kāi)始的問(wèn)題。類(lèi)似這種 C 風(fēng)格的結(jié)構(gòu),如何做二進(jìn)制兼容呢?比如下面結(jié)構(gòu)
struct SkyCallback { void *userData = nullptr; void (*callback0)(void *userData) = nullptr; };
這種結(jié)構(gòu),就跟我們最開(kāi)始的 SkyEncodingCallback
很像了。
要做到完全二進(jìn)制兼容,最初的 SkyCallback
必須稍微改一下的,預(yù)埋一個(gè) structSize
字段,初始化成結(jié)構(gòu)的大小。
// old skymedia.h struct SkyCallback { int structSize = sizeof(SkyCallback); // 增加這個(gè)字段 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 這樣調(diào)用
// skyeditor.exe void my_callback0(void* userData) { // 做一些事情 } SkyCallback callback; callback.userData = xxx; callback.callback0 = callback0; sky_dosomthing(callback);
現(xiàn)在 skymedia.dll
更新版本,為保證兼容,可以寫(xiě)成
// 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
中那個(gè)對(duì) callback1 的判斷。
當(dāng) skyeditor.exe
使用舊版本的 skymedia.dll
編譯時(shí),SkyCallback
是沒(méi)有 callback1 字段的結(jié)構(gòu),structSize 的值也相應(yīng)小了。于是舊版的 skyeditor.exe
調(diào)用了新的 sky_dosomthing
,那個(gè)判斷就不會(huì)成立, callback1
的調(diào)用就不會(huì)被觸發(fā)。
structSize
放在最前面,而新加的字段 callback1 放在結(jié)構(gòu)的最后。通過(guò) structSize
可以方便地判斷新增的字段是否存在。這樣自然就兼容舊版本,SkyCallback
` 的結(jié)構(gòu)名字也不用修改。
目前 SkyEncodingCallback
,還沒(méi)有添加 structSize
字段。主要是目前我們二進(jìn)制兼容的需求還不算緊急,但在 API 設(shè)計(jì)上,已經(jīng)留了條后路,要改起來(lái)也很容易,在源碼級(jí)別也是完全兼容的。假如一開(kāi)始就采用 C++ 的虛函數(shù)接口,以后就難以修改了。
類(lèi)似這種結(jié)構(gòu)當(dāng)中添加 structSize
字段的設(shè)計(jì),在 C 接口中,還是比較常見(jiàn)的。比如 Win32 API
,就常見(jiàn)這種用法。
到此這篇關(guān)于C++ 回調(diào)接口設(shè)計(jì)和二進(jìn)制兼容詳細(xì)的文章就介紹到這了,更多相關(guān)C++ 回調(diào)接口設(shè)計(jì)和二進(jìn)制兼容內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++實(shí)現(xiàn)的大數(shù)相乘算法示例
這篇文章主要介紹了C++實(shí)現(xiàn)的大數(shù)相乘算法,結(jié)合實(shí)例形式分析了C++大數(shù)相乘的概念、原理及代碼實(shí)現(xiàn)技巧,需要的朋友可以參考下2017-08-08C語(yǔ)言左旋轉(zhuǎn)字符串與翻轉(zhuǎn)字符串中單詞順序的方法
這篇文章主要介紹了C語(yǔ)言左旋轉(zhuǎn)字符串與翻轉(zhuǎn)字符串中單詞順序的方法,給出了相關(guān)的兩道算法題目作為例子,需要的朋友可以參考下2016-02-02C語(yǔ)言關(guān)于include順序不同導(dǎo)致編譯結(jié)果不同的問(wèn)題
這篇文章主要介紹了在日常調(diào)試C語(yǔ)言中include的順序不同從而影響最后編譯結(jié)果不同的問(wèn)題,究其原因是寫(xiě)代碼的習(xí)慣所導(dǎo)致,下面跟小編一起來(lái)看看吧2022-04-04C語(yǔ)言實(shí)現(xiàn)簡(jiǎn)單登錄操作
這篇文章主要為大家詳細(xì)介紹了C語(yǔ)言實(shí)現(xiàn)簡(jiǎn)單登錄操作,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-06-06C語(yǔ)言詳細(xì)分析講解關(guān)鍵字goto與void的作用
我們?cè)贑語(yǔ)言中經(jīng)常會(huì)見(jiàn)到void,也會(huì)偶爾見(jiàn)到goto,那么C語(yǔ)言中既然有g(shù)oto,為什么我們?cè)诖a中見(jiàn)的很少呢?在以前很多的項(xiàng)目經(jīng)驗(yàn)中,我們得到這樣一條潛規(guī)則:一般項(xiàng)目都是禁用goto的,程序質(zhì)量與goto的出現(xiàn)次數(shù)成反比。自后也就造成了我們一般不會(huì)使用goto2022-04-04深入學(xué)習(xí)C語(yǔ)言中常見(jiàn)的八大排序
排序編程中非?;A(chǔ)的的理論方法,雖然排序的方法多,但是理解起來(lái)并不難,它是最基本的,初學(xué)者一定要掌握的東西。本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值2021-11-11