【在主画面加入捷径】
       
【选择语系】
繁中 简中

[C 语言] 程序设计教学:如何撰写 C函数库 (Library)

【赞助商连结】

    前言

    C 语言对于模块 (module) 的概念相对简单,C 模块是由头文件 (header) 和二进位文件 (.a, .so, .dll 等) 所组成。C 模块不需要提供源代码,只要提供二进位档即可使用:近年来流行的开放源代码是软件授权的模式,对使用 C 模块这件事不是必要的。

    相对来说,C 语言没有套件 (package) 的概念。我们在类 Unix 系统上看到的套件管理程序 (如 yumapt 等) 算是后设的概念,而非 C 语言本身的功能。早期的 Windows 并不注重 C (或 C++) 套件的议题,在分享 C (或 C++)函数库时就没有那么方便,有些第三方方案,像是 Conan,企图解决套件相关的议题;近年来 C++ 重新抬头,微软推出 vcpkg,也是另一个 C (或 C++) 套件的方案。

    一般 C 入门教材对于函式库的概念仅止于 C 标准函式库,而不注重第三方 C函数库的使用,但实际上我们不会每个函式库都自己刻,而会藉由使用预先写好的函式库,减少重造轮子的时间,专注在我们想要实现的核心功能上。不过这也不全然是教科书的错,比起标准函式库,第三方函式库的 API 相对没那么稳定,也有可能会在缺乏维护下逐渐凋亡,比较不适合放在教科书中。

    注:使用外部函式库要注意授权范围,初学者往往直接忽视这一块就任意地使用外部函式库。

    实例:二元搜寻树

    在本文中,我们使用一个小范例来说明如何撰写 C函数库。由于 C 没有规范项目如何安排,我们按照常见的 C函数库的项目架构来安排我们的程序代码。在此项目中,我们撰写以 GNU Make 来管理编译流程,使用 Make 的好处是不用绑定特定的 IDE,只要编辑器支援 C 就可以使用此项目。这个范例项目位于这里,这是一个二元搜寻树的练习,但我们重点会放在如何以 C 撰写套件,不会深入探讨二元树的实现,也不会额外讲解 Makefile 的语法。

    注:读者可到这里观看 GNU Make 的教学,或是自行找寻其他的在线教材。

    C函数库包括头文件 (header) 和二进位档两个部分,头文件存有该套件的公开界面,包括类型、函式、宏等项目的声明;二进位档则是编译后的套件实现内容。二进位档又依其发布方式分为静态函式库 (static library) 和动态函式库 (dynamic library) 两种;这两种函式库格式会影响应用程序发布的方式,静态函式库会直接将程序代码包进主程序中而动态函式库会在执行时才去调用。

    分享 C函数库时可以不公开 C 程序代码,只要有头文件和二进位档即可使用该套件。近年来流行的开放源代码运动算是一种软件授权的策略或模式,对执行函式库本身不是必备的。至于类 Unix 系统上常见的 /usr/include/usr/lib 等存放头文件或二进位档的位置是由系统另外定义的,而非 C (或 C++) 内建的特性。

    注:Windows 中,有一部分函式库会额外使用 .def 文件,基本上这也可视为函式库的公开界面。

    以本例来说,其中一个头文件如下:

    #ifndef BSTREE_H
    #define BSTREE_H
    
    #ifndef __cplusplus
        #include <stdbool.h>
    #endif
    
    #ifdef __cplusplus
    extern "C" {
    #endif
    
    typedef struct bstree_int BSTreeInt;
    
    BSTreeInt * algo_bstree_int_new(void);
    bool algo_bstree_int_is_empty(BSTreeInt *self);
    bool algo_bstree_int_find(BSTreeInt *self, int value);
    int algo_bstree_int_min(BSTreeInt *self);
    int algo_bstree_int_max(BSTreeInt *self);
    bool algo_bstree_int_insert(BSTreeInt *self, int value);
    bool algo_bstree_int_delete(BSTreeInt *self, int value);
    void algo_bstree_int_free(void *self);
    
    #ifdef __cplusplus
    }
    #endif
    
    #endif // BSTREE_H
    

    头文件中放的是函式库的声明部分,实现则会另外放在 C 程序代码中。依照 C 语言的惯例,一般会用 .h 做为头文件的扩展名。

    一开始时会用 include guard 的手法,避免重覆 include 时引发错误;另外,如果这个函式库要从 C++ 调用,也要加入一些样板程序代码。接着,就会开始写声明,包括引用的外部函式库、类型、函式定义、宏等。在这个例子中,我们不想暴露结构的内部实现,使用了 forward declaration 的小技巧。

    接着,来看 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 ...
    

    在我们这个例子中,重要的并不是二元树如何实现,而是观察文件间的连动关系。细心的读可应该可以发现,我们将 BSTreeIntNodeInt 两个类型部分的程序代码拉出来,这是因为我们将此套件的实现拆开在 bstree.cbstiter.c 两个文件中,为了避免重覆的程序代码,我们将共同需要的部分拉出来,放在 *bstree_internal.h*、bstnode.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 Interfaces and Implementations, Addison-Wesley Professional (1996) 。这本书算老书了,但 C 的核心语法相对稳定,故仍有一定参考价值。

    【赞助商连结】