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 ¶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
,就常見這種用法。
到此這篇關於C++ 回調接口設計和二進制兼容詳細的文章就介紹到這瞭,更多相關C++ 回調接口設計和二進制兼容內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!