1. 程式人生 > 其它 >stb 作者教你如何編寫單檔案 (Header-only) 的 C/C++ 庫

stb 作者教你如何編寫單檔案 (Header-only) 的 C/C++ 庫

技術標籤:C++c++c語言

說明

所謂單檔案(Header-only)庫,就是整個庫只有一個頭檔案,用起來就像引入標準庫一樣簡單。最近需要寫這種形式的工具庫,於是參考了 stb 庫 作者提供的指南,感覺真不綽。下面是我對這篇文件的翻譯版本,由於個人水平所限,難免有所紕漏,因此給出了英文原文作為對照。

英文版文件包含於 stb 庫的 docs 資料夾,也可以 在這裡檢視

正文

關於如何建立單檔案庫,我所學到的

—— Sean Barrett 於 2013 年 9 月

當建立形式類似於 stb 庫的單檔案庫時,你應該做的,以及為什麼要這樣做:

Lessons learned about how to make a header-file library

September 2013 Sean Barrett

Things to do in an stb-style header-file library, and rationales:

1 - #define 庫名英文大寫字母_IMPLEMENTATION

使用類似上述形式的符號,以(允許使用者)控制何時在其程式碼中引入你庫中的實現(在我最初的一些單檔案庫中,所用的巨集名並沒有上面給出的這麼清晰;隨著我建立多個這樣的庫,我開始明白這樣做是錯誤的)。

在你的庫中用保護性程式碼來包圍所有庫函式的 宣告 段落,而對於這些函式的 定義 則用上述巨集保護起來,而不是用 標頭檔案名_H。這樣一來,如果使用者在自己的標頭檔案 X 中包含了你的庫,他仍可以正常地將 X 包含在他們的原始檔中;當你用上述巨集保護了庫中的實現部分時,在檔案 X 中,庫函式的宣告部分就能夠被正常聯入,而實現部分被忽略。

譯者注:這段的大意是,庫中的程式碼應該分兩部分,分別用兩種巨集來包圍。第一部分是所有庫函式的宣告,就像寫普通的標頭檔案一樣:

#ifndef 標頭檔案名_H
#define 標頭檔案名_H
/* 所有要暴露出來的庫函式宣告 */
#endif // 標頭檔案名_H

第二部分則要用條件包圍,也就是僅判斷該巨集是否被定義,而不真正定義這個巨集(這個巨集是要留給使用者去定義的):

#ifdef 庫名_IMPLEMENTATION
/* 所有實現程式碼 */
#endif // 庫名_IMPLEMENTATION

這個巨集的存在是實現單檔案效果的關鍵。當該巨集沒被定義時,庫就和一個普通標頭檔案沒有任何區別;當用戶在某 1 個原始檔中定義了該巨集時,庫中的實現部分才被聯入那個原始檔中,從而避免了函式的重複定義。

1 - #define LIBRARYNAME_IMPLEMENTATION

Use a symbol like the above to control creating the implementation. (I used a far-less-clear name in my first header-file library; it became clear that was a mistake once I had multiple libraries.)

Include a “header-file” section with header-file guards and declarations for all the functions, but only guard the implementation with LIBRARYNAME_IMPLEMENTATION, not the header-file guard. That way, if client’s header file X includes your header file for declarations, they can still include header file X in the source file that creates the implementation; if you guard the implementation too, then the first include (before the #define) creates the declarations, and the second one (after the #define) does nothing.

2 - 避免依賴

不要依賴除標準庫之外的任何庫。

如果你建立的庫就是用於包裹或引入其他庫的,那麼你當然可以依賴那個庫;但如果你要建立的庫是開放許可證的,那麼最好是把要依賴的庫也直接嵌入到你的庫中,以減少使用者需要依賴的庫數目。這樣做時,一旦被依賴的庫有了更新,你就應該同步更新。

如果你要依賴標準庫,就應該考慮把程式碼中所有對標準庫的呼叫定義成巨集,再將所依賴的標準庫用巨集條件包圍起來,這樣可以允許使用者用自己寫的函式來替換對標準庫的呼叫。

如果你庫中某些函式具有附加效應,例如分配了記憶體,就應當考慮允許使用者傳入一個自行定義的上下文,並在“不使用標準庫”版本的巨集中間引入該上下文;否則,使用者就可能被迫使用全域性變數(或執行緒內的區域性變數)來實現這一點。

譯者注:這段的大意是:

  1. 儘量避免依賴非標準庫,如果是開源專案且許可證相容,最好在程式碼中直接嵌入要依賴的第三方庫,不過這樣做夠麻煩的;
  2. 有些使用者(如嵌入式開發者)可能不想用標準庫,你的庫最好能允許使用者通過定義巨集來拒絕這些依賴,比如用 #ifndef 不_要_用_STDLIB_的_巨集 來包圍 #include <stdlib.h>,再由使用者根據需要自行定義這些巨集;這樣一來,標準庫的呼叫就需要再定義一個巨集來包裹一下,比如:
    #ifndef NO_STDLIB
    #include <stdlib.h>
    #endif // NO_STDLIB
    ...
    #ifndef NO_STDLIB
    #define ALLOCATE malloc
    #endif // NO_STDLIB
    
    這樣一來,當用戶定義了 NO_STDLIB 時,就可以向 ALLOCATE 這個巨集注入他自己實現的 malloc,從而實現了可定製性;

2 - AVOID DEPENDENCIES

Don’t rely on anything other than the C standard libraries.

(If you’re creating a library specifically to leverage/wrap some other library, then obviously you can rely on that library. But if that library is public domain, you might be better off directly embedding the source, to reduce dependencies for your clients. But of course now you have to update whenever that library updates.)

If you use stdlib, consider wrapping all stdlib calls in macros, and then conditionally define those macros to the stdlib function, allowing the user to replace them.

For functions with side effects, like memory allocations, consider letting the user pass in a context and pass that in to the macros. (The stdlib versions will ignore the parameter.) Otherwise, users may have to use global or thread-local variables to achieve the same effect.

3 - 避免 malloc

有時候你是迫不得已,但如果你有得選,那就不要用,嵌入式開發者會很感激的。然而我自己基本上不會規避 malloc,因為麻煩死了(而且有時這樣做會降低實用性,詳見這裡)—— 但我的懶惰顯然是阻止某些潛在的使用者使用 stb 庫的因素之一。

譯者注:我感覺這段基本上是無效資訊 _(:з」∠)_

3 - AVOID MALLOC

You can’t always do this, but when you can, embedded developers will appreciate it. I almost never bother avoiding, as it’s too much work (and in some cases is pretty infeasible; see http://nothings.org/gamedev/font_rendering_malloc.txt ). But it’s definitely something one of the things I’ve gotten the most pushback on from potential users.

4 - 允許靜態實現

#define 以使函式宣告和定義變為靜態。這樣做可以讓實現程式碼(在邏輯上)對使用者的原始檔不可見,從而允許使用者在其工程中多次使用你的庫而不發生衝突(只有你的庫中包含設定性的巨集或全域性狀態時,或當你的庫有多個不能向下相容的版本時,才有必要這樣做。這兩種情況我自己都遇到過)。

譯者注:我們知道,對於通常的庫而言,用 static 修飾的函式是對其他檔案不可見的。但由於單檔案庫的特殊性,其實現部分聯入使用者的原始檔時,在該原始檔內,static 原本的作用就被破壞了。因此,當庫中需要使用函式(或全域性狀態),但不想暴露這些函式給使用者時,就不能用簡單的 static 做弱封裝,而是要將函式定義成巨集來使用,並在該函式的所有呼叫結束後 undef 這個巨集。讀 stb 的原始碼可以發現作者經常使用這個技巧。

—— 純屬個人理解,如有錯誤,歡迎指正。

4 - ALLOW STATIC IMPLEMENTATION

Have a #define which makes function declarations and function definitions static. This makes the implementation private to the source file that creates it. This allows people to use your library multiple times in their project without collision. (This is only necessary if your library has configuration macros or global state, or if your library has multiple versions that are not backwards compatible. I’ve run into both of those cases.)

5 - 保持對 C 的相容性

相容 C 而不是 C++(例如,直接以 C 編寫庫,或使用 extern "C"),可以讓使用者在 C 和其他相容 C 注入的語言中使用你的庫(我在谷歌 stb_image 時得到的最早的結果之一,就是為其寫的一個 Haskell wrapper);否則,別人要包裹你的庫,就得寫一大堆函式呼叫。我們寫庫的初衷就是為了方便他人,對不?

(以下省略作者對自己程式設計偏好的陳述)

5 - MAKE ACCESSIBLE FROM C

Making your code accessible from C instead of C++ (i.e. either coding in C, or using extern “C”) makes it more straightforward to be used in C and in other languages, which often only have support for C bindings, not C++. (One of the earliest results I found in googling for stb_image was a Haskell wrapper.) Otherwise, people have to wrap it in another set of function calls, and the whole point here is to make it convenient for people to use, isn’t it? (See below.)

I prefer to code entirely in C, so the source file that instantiates the implementation can be C itself, for those crazy people out there who are programming in C. But it’s probably not a big hardship for a C programmer to create a single C++ source file to instantiate your library.

6 - 將函式限定在名稱空間中

盡力避免在你的程式碼中使用可能與使用者程式碼衝突的名稱。為此,你可以使用 C++ 名稱空間,而在 C 中你可以為所有函式新增庫名作為字首。

一般而言,我使用統一的字首來命名 API 函式和內部使用的符號,如“stbtt_”意為“stb_truetype”,以此來最小化與使用者的屎山裡的某個破名衝突的概率(譯者放飛自我ing)。尤其噁心的情況是,可能使用者在使用你庫上一個版本時,自己“成功”使用了“stbtt_foo”這個名字,然而升級到你的下一個版本時,發現你的程式碼里居然有也個“stbtt_foo”或“stbtt__foo”。

需要注意:雙下劃線是編譯器的保留符號,但是 ① 沒有什麼為“中介軟體”保留的符號,庫想要避免與使用者發生命名衝突,卻又別無他法;② 實際上並沒有哪個編譯器會用到單詞中間出現的雙下劃線,用的都是在符號首尾出現的,所以我們憑啥不用呢?(不幸的是,至少有 1 個遊戲主機專用的傻 * 編譯器會預設對雙下劃線報警)

譯者注:這段的大意是,在庫中儘量使用不容易引起命名衝突的名稱,可以使用雙下劃線來提高獨特性,雙下劃線儘量只出現在名稱中間。

6 - NAMESPACE PRIVATE FUNCTIONS

Try to avoid having names in your source code that will cause conflicts with identical names in client code. You can do this either by namespacing in C++, or prefixing with your library name in C.

In C, generally, I use the same prefix for API functions and private symbols, such as “stbtt_” for stb_truetype; but private functions (and static globals) use a second underscore as in “stbtt__” to further minimize the chance of additional collisions in the unlikely but not impossible event that users write wrapper functions that have names of the form “stbtt_”. (Consider the user that has used “stbtt_foo” successfully, and then upgrades to a new version of your library which has a new private function named either “stbtt_foo” or “stbtt__foo”.)

Note that the double-underscore is reserved for use by the compiler, but (1) there is nothing reserved for “middleware”, i.e. libraries desiring to avoid conflicts with user symbols have no other good options, and (2) in practice no compilers use double-underscore in the middle rather than the beginning/end. (Unfortunately, there is at least one videogame-console compiler that will warn about double-underscores by default.)

7 - 挑一個容易用的許可

(略)