[译文] 编写 C 库:Part 1(上)

[译者前言] 本文译自 David Zeuthen 编写的 Writing a C library 系列文章,原文载于作者在 blogspot 上的博客。该系列文章的版权属于原文作者所有,本文是该系列文章的第一篇的上半部分:Writing a C library: Part 1

基础库

libc 是处于相当底层的一套库,因此存在一些高层的库使得使用 C 语言进行编程可以获得更愉快的体验,这包括GLib 以及 GTK+ 里的一些库。即使下面的阐述多少有些围绕着 GLib 以及 GTK+ 进行,这些阐述对任何 C 代码来说都是有用的,不管代码是基于 libc,GLib 还是其它的库如 NSPRAPR 或 Samba 的一些库

大多数程序员都会同意的一点是实现那些基本的数据类型(比如字符串处理,内存分配,链表,数组,哈希表,队列)并不是一个好的想法,特别是仅仅因为你能够实现,因为这只会让别人理解以及维护你的代码更加困难。这些地方正是C 库如 GLib 与 GTK+ 发挥作用的地方,它们提供了大部分这样的功能。此外,当你最终需要那些并不简单的功能时(相当可能的情况是你会),如操作 Unicode解释复杂的脚本D-Bus 支持,或计算校验和,问问自己(更糟的情况:当你的经理或同伴问起你时)回避使用一个有着良好测试良好维护的库是否是一个好的决定。

特别地,对于类似密码算法这样的功能,选择自己实现通常是一个糟糕的想法(更糟的是自己发明一个新的算法);相反,更好的选择应该是使用那些已知的经过良好测试的库,比如 NSS(如果你这样做了,要注意正确地使用它)。需要提及的是,该库还经过了 FIPS-140 的认证,如果你想跟美国联邦政府做生意的话,这是一个必要的需求。

类似地,对于事件通知来说,虽然使用 epoll 比 poll 效率要高,但如果你的应用程序需要处理的文件描述符只在 10 个这样的数量级上,使用哪个并不重要。另一方面,如果你知道你需要处理上千个文件描述符,只需要在专门的线程里使用 epoll,你仍然可以在你的库或应用里大量使用如 GLib 等库。同理,如果你需要从链表里删除的节点数量级为 O(1),那么也许你应该用嵌入式链表,而不是 GList

最重要的是,不管你最终使用了什么库或代码,至少要确保自己对所用库的相关数据类型,概念,以及实现细节有一个基本的理解。举例来说,GLib 里面的一些高层构造如 GHashTableg_timeout_add() 或g_file_set_contents() 都非常易用而不需要知道它们是怎么实现的或理解文件描述符是什么。比如,当存储数据时,你希望这个操作是个原子操作(避免数据丢失),并且知道 g_file_set_contents() 可以实现这个操作通常就足够了(通常阅读 API 文档会告诉你那些你需要知道的内容)。此外,确保你能理解你最终使用的数据类型的算法复杂度,以及他们在现代硬件上是怎么工作的

最后,不要在网上与那些不相干的人较真讨论关于膨胀(bloated)的库,通常这是对时间与资源的浪费。

备忘清单

  • 不要重新发明那些基本的数据类型(除非性能是主要的考虑)。
  • 不要回避那些标准的库,仅仅因为它们是可移植的。
  • 使用多个库并且功能有重叠时要警惕。
  • 在一定程度上可能的话,将库的使用作为一个私有的实现细节。
  • 为正确的工作选择正确的工具 – 不要把时间浪费在无用的讨论上。

库的初始化与关闭

有些库会要求在调用其它库函数前,调用某个特殊的库函数完成一些初始化工作,通常这个函数叫做 foo_init(),它一般用来初始化该库用到的全局变量以及数据结构。此外,有些库还会提供一个关闭函数,典型的名字是 foo_shutdown()(其它名字如 foo_cleanup(),foo_fini(),foo_exit(),甚至 foo_deinit() 也有使用的),该函数一般用来释放库用到的所有资源。提供一个 shutdown() 函数的主要理由是可以让 Valgrind(用来发现内存泄漏)更好用,或者当使用了 dlopen() 系列函数时用以释放资源。

通常,应该避免库的初始化函数与关闭函数,因为它们有可能导致应用程序依赖链上的两个不相关的库的冲突;也就是说,如果你不在使用它们的地方调用它们,你将强制应用程序在 main() 函数里调用一个 init() 函数,仅仅因为某些深藏在依赖链里的库使用了该库而没有初始化它。

然而,如果一个库不提供初始化函数的话,这个库里的每个库函数都将不得不调用一个内部的初始化函数来完成必要的初始化,这并不总是可行的,并且有可能成为性能上的负担。实际中,这个初始化检测只需要在某些函数中执行,因为一个库里的大部分函数依赖于从该库其它库函数获取的某个对象或结构进行操作。所以实际上,检测只需要在那些 _new() 函数里或不操作任何对象的函数里执行。

例如,使用 GLib 的类型系统的程序都需要调用 g_type_init() 函数,这包括那些基于 libgobject-2.0 的库如libpolkit-gobject-1,也就是说,如果你不在调用 polkit_authority_get_sync() 之前调用 g_type_init(),那么你的程序很有可能会 segfault。自然地,这是大部分使用 GLib 的新人都会出错的地方,并且你真的不能责怪他们。如果有的话,g_type_init() 可以说是为什么应该尽可能避免 init() 函数的典型代表。

库的初始化函数需要存在的一个可能的理由是库的配置,要么是应用程序需要的特殊配置(应用程序也许希望使用库的某个特殊行为),要么是终端用户需要的配置(通过操作 argv 与 argc),一个可供查看的例子是 gtk_init()。对于这个问题最好的解决方法是避免配置,但如果实在不太可行的话,使用环境变量来控制库的行为会更好一些。作为例子,可以查看 libgtk-3.0 支持的环境变量与 libgio-2.0 支持的环境变量

如果你的库提供了初始化函数,确保该函数是幂等的(idempotent),并且线程安全,也就是说,该函数可以同时从多个线程里被调用多次。如果你的库也提供了关闭函数,确保你的库使用了某种方式的“初始化计数(initialization count)”,从而保证当该库的所有用户都调用完它的 shutdown() 函数后,该库只关闭一次。同样,如果可能的话,确保你的库的 init/shutdown 函数也调用了它所依赖的库的 init/shutdown 函数。

通常,一个库的 init() 与 shutdown() 可以通过引入上下文对象(context object)来移除掉 – 这可同时解决诸如全局状态(令人讨厌并且有可能破坏同一进程里的多个库的用户),锁(将可以基于上下文实例),以及回调/通知(可以回调并且将事件推到分离的线程里)等方面的问题。作为例子,可以参见 libudev 的 struct udev_monitor

备忘清单

  • 避免 init()/shutdown() 函数 – 如果无法避免它们,确保它们是幂等的,线程安全的,以及引用计数的。
  • 使用环境变量来传递库的初始化参数,而不是 argc 与 argv。
  • 你很容易会在同一个进程里遇到两个不相关的库的用户 – 通常在主应用程序不知道的情况下。确保你的库能处理这种情况。
  • 避免不安全的 API 如 atexit(3),以及当需要考虑移植性时,避免不可移植的构造方式如库的构造符与析构符(例如,gcc 的 __attribute__(constructor) 与 __attribute__(destructor))。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。