美思 [C 語言] 程式設計教學:如何撰寫 C 函式庫 (Library)

C 語言函式庫
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

函式庫和套件的差異

C 語言對於函式庫 (library) 的概念相對簡單,C 函式庫是由標頭檔 (.h) 和二進位檔 (靜態函式庫: .a, .lib ,動態函式庫: .so, .dylib, .dll) 所組成。使用 C 函式庫時不需要原始碼,只要有二進位檔即可使用。近年來流行的開放原始碼是軟體授權的模式,對使用 C 函式庫這件事不是必要的。

相對來說,C 語言沒有套件 (package) 的概念。我們在類 Unix 系統上看到的套件管理程式 (如 yumapt 等) 算是後設的概念,而非 C 語言本身的功能。

早期的 Windows 並不注重 C (或 C++) 套件的議題,在分享 C (或 C++) 函式庫時就沒有那麼方便,有些第三方方案,像是 Conan,企圖解決套件相關的議題;近年來 C++ 重新抬頭,微軟推出 vcpkg,也是另一個 C (或 C++) 套件的方案。

標準函式庫和第三方函式庫

一般 C 入門教材對於函式庫的概念僅止於 C 標準函式庫,而不注重第三方 C 函式庫的使用,但實際上我們不會每個函式庫都自己刻,而會藉由使用預先寫好的函式庫,減少重造輪子的時間,專注在我們想要實作的核心功能上。

不過這也不全然是教科書的錯,比起標準函式庫,第三方函式庫的 API 相對沒那麼穩定,也有可能會在缺乏維護下逐漸凋亡,比較不適合放在教科書中。

註:使用外部函式庫要注意授權範圍,初學者往往直接忽視這一塊就任意地使用外部函式庫。

C 函式庫的檔案格式

C 函式庫包括標頭檔和二進位檔兩個部分,標頭檔存有該套件的公開界面,包括型別、函式、巨集等項目的宣告;二進位檔則是編譯後的套件實作內容。

註:Windows 中,有一部分函式庫會額外使用 .def 檔案,.def 也可視為函式庫的公開界面。

二進位檔又依其發布方式分為靜態函式庫 (static library) 和動態函式庫 (dynamic library) 兩種;這兩種函式庫格式會影響應用程式發布的方式,靜態函式庫會直接將程式碼包進主程式中而動態函式庫會在執行時才去呼叫。

分享 C 函式庫時可以不公開 C 程式碼,只要有標頭檔和二進位檔即可使用該套件。近年來流行的開放原始碼運動算是一種軟體授權的策略或模式,對執行函式庫本身不是必備的。

至於 Unix 或類 Unix 系統上常見的 /usr/include/usr/lib 等存放標頭檔或二進位檔的位置是由系統另外定義的,而非 C (或 C++) 內建的特性。

實例:二元搜尋樹

在本文中,我們使用一個小範例來說明如何撰寫 C 函式庫。由於 C 沒有規範專案如何安排,我們按照常見的 C 函式庫的專案架構來安排我們的程式碼。

在此專案中,我們撰寫以 GNU Make 來管理編譯流程,使用 Make 的好處是不用綁定特定的 IDE,只要編輯器支援 C 就可以使用此專案。

這個範例專案位於這裡,這是一個二元搜尋樹的練習,但我們重點會放在如何以 C 撰寫套件,不會深入探討二元樹的實作,也不會額外講解 Makefile 的語法。

註:讀者可到這裡觀看 GNU Make 的教學,或是自行找尋其他的線上教材。

以本例來說,其中一個標頭檔如下:

#ifndef ALGO_BSTREE_H                                        /*  1 */
#define ALGO_BSTREE_H                                        /*  2 */

#ifndef __cplusplus                                          /*  3 */
    #include <stdbool.h>                                     /*  4 */
#endif                                                       /*  5 */

#ifdef __cplusplus                                           /*  6 */
extern "C" {                                                 /*  7 */
#endif                                                       /*  8 */

typedef struct bstree_int_t bstree_int_t;                    /*  9 */

bstree_int_t * algo_bstree_int_new(void);                    /* 10 */
bool algo_bstree_int_is_empty(bstree_int_t *self);           /* 11 */
bool algo_bstree_int_find(bstree_int_t *self, int value);    /* 12 */
int algo_bstree_int_min(bstree_int_t *self);                 /* 13 */
int algo_bstree_int_max(bstree_int_t *self);                 /* 14 */
bool algo_bstree_int_insert(bstree_int_t *self, int value);  /* 15 */
bool algo_bstree_int_delete(bstree_int_t *self, int value);  /* 16 */
void algo_bstree_int_free(void *self);                       /* 17 */

#ifdef __cplusplus                                           /* 18 */
}                                                            /* 19 */
#endif                                                       /* 20 */

#endif  /* ALGO_BSTREE_H */                                  /* 21 */

標頭檔中放的是函式庫的宣告部分,實作則會另外放在 C 程式碼中。依照 C 語言的慣例,一般會用 .h 做為標頭檔的副檔名。

一開始時會用 include guard 的手法,避免重覆 include 時引發錯誤。include guard 的動作位於第 1 行、第 2 行、第 21 行。

從巨集的觀點來看,include guard 本身是一段條件編譯的敘述,當第一次引入此標頭檔時,ALGO_BSTREE_H 是未定義的,所以會讀完整個標頭檔。而在第二次後再度引入此標頭檔時,ALGO_BSTREE_H 是已定義的,這時候不會再讀一次此標頭檔,藉此避開重覆宣告的問題。

另外,如果這個函式庫要從 C++ 呼叫,也要加入一些樣板程式碼。這段動作位於第 6 行至第 8 行及第 18 行至第 20 行。

從巨集的觀點來看,使用 C++ 讀入此標頭檔時,其等效宣告如下:

extern "C" {

/* Declarations */

}

其實就是一塊 extern "C" 區塊。

使用純 C 讀入此標頭檔時,不會有 extern "C" 的部分,故可以相容於 C 程式碼。

接著,就會開始寫宣告,包括引用的外部函式庫、型別、函式定義、巨集等。在這個例子中,我們不想暴露結構的內部實作,使用了 forward declaration 的小技巧,這個動作位於第 9 行。

標頭檔和程式碼間的連動

接著,來看 bstree.c 的程式碼 (節錄):

#include <assert.h>
#include <stdbool.h>
#include <stdlib.h>
#include <stdio.h>
#include "algo/bstree.h"
#include "bstree_internal.h"
#include "bstnode.h"

/* More code ... */

在我們這個例子中,重要的並不是二元樹如何實作,而是觀察檔案間的連動關係。觀察標頭檔就可以知道原始碼之間的相依關係。

bstree.c 的程式碼中,會去讀取 algo/bstree.h ,兩者間就產生了連動。實作公開界面 (標頭檔) 時,程式碼要引入想實作的界面。

細心的讀可應該可以發現,我們將 bstree_int_tnode_int_t 兩個型別部分的程式碼拉出來,這是因為我們將此套件的實作拆開在 bstree.cbstiter.c 兩個檔案中,為了避免重覆的程式碼,我們將共同需要的部分拉出來,放在 bstree_internal.hbstnode.hbstnode.c 中。

再看 bstiter.c 的程式碼 (節錄):

#include <assert.h>
#include <stdlib.h>
#include <stdio.h>
#include "algo/bstree.h"
#include "bstree_internal.h"
#include "bstnode.h"
#include "algo/bstiter.h"

/* More code ... */

從這裡可看出,這兩個模組都有用到共同的程式碼。這些程式碼會編譯成二進位檔案,而不會隨標頭檔發布出去,所以我們沒有放在 include 資料夾而放在 src 資料夾中。

如果讀者有看 bstiter.c 的程式碼,可發現我們在程式碼中額外塞入一個佇列,這是為了實作迭代器所需的資料結構,由於我們沒有想要公開這個資料結構,從我們的程式碼可看出在標頭檔中完全都沒有這個佇列相關的資訊。

基本上,只要知道標頭標和實作程式碼間的關係,用模組化的概念寫 C 程式碼並不會太困難。

至於多個檔案在編譯時要如何串連,這就牽涉到編譯器指令的操作,這也就是為什麼我們要在先前的文章中介紹 C 編譯器的指令。一開始覺得編譯器指令太難的話,不妨先用 IDE 現有的功能來完成這一部分,待熟悉這個流程後再轉用 CMake 或 GNU Make 等跨 IDE 的專案管理程式。

我們在這篇文章對 C 專案有更多的介紹,可以和本文交互觀看。

延伸閱讀

本文對於 C 函式庫僅是淺白的介紹,如果想要深入學習如何設計 C 函式庫,可參考 C Interfaces and Implementations, Addison-Wesley Professional (1996) 。這本書算老書了,但 C 的核心語法相對穩定,故仍有一定參考價值。

關於作者

身為資訊領域碩士,美思認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

美思喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,美思將所學寫成文章,放在這個網站上和大家分享。