龙空技术网

为什么要避免使用 libc

CSDN 6736

前言:

目前咱们对“输入未知行字符串”大约比较关心,各位老铁们都需要分析一些“输入未知行字符串”的相关内容。那么小编同时在网上搜集了一些关于“输入未知行字符串””的相关内容,希望我们能喜欢,看官们一起来学习一下吧!

【CSDN 编者按】libc 是 Linux 下的标准 C 库,也是初学者写 hello world 包时含有的头文件 #include < stdio.h> 定义的地方,后来其逐渐被 glibc 给取代,本文作者列出了为什么要避免使用 libc 的 20 个理由。

作者 |Chris Wellons 译者 | 弯月出品 | CSDN(ID:CSDNnews)

一般,在使用 C 语言时,我会尽可能避免使用标准库 libc。如果有可能,我甚至不会连接这个库。可能有些人对于这种工作及思考方式有点不太理解。这不是重新发明轮子吗。但对于我来说,libc 就是一个不值得使用的轮子,其接口和实现上有太多的缺陷。幸运的是,如果你了解实际情况,制造一个更好、更简单的轮子也非难事。

在本文中,我打算回顾一下 C 标准库的函数和类似函数的宏,并讨论我在实际中遇到的问题。

幸运的是,C 语言拥有很大的灵活性,这弥补了标准库的不足。我已经拥有了工作中所需的一切工具,且不依赖任何运行时。

如何在不依赖 libc 的情况下编写可移植软件?首先,大部分程序的实现都需要做到与平台无关,且代码不依赖 libc;然后,针对每个目标(平台层),在各自的源文件中编写特定于平台的代码。相比之下,平台代码量很少,这部分代码大多是不可移植的,包括原始系统调用、图形函数,甚至是汇编代码。在一些平台上,我们必须连接 libc,有时是因为其中包含一些特定于平台的功能,有时是因为连接libc是强制性的。

下面的讨论只针对标准 C。有些平台提供了特殊的解决方法,可以克服其标准函数的缺点,但这不是本文的讨论重点。如果需要使用非标准函数,我会编写特定于平台的代码,也可以通过直接调用平台的功能来完美地绕过原本的问题。

下面,我们按照 C18 草案中列出的顺序,逐个浏览标准库。

断言和中止

虽然 C 的断言比其他我了解的任何语言都要好,但并不意味着就没有问题。本质上 C 的断言就是一个不需要展开栈的陷阱,但常见的实现并不会在宏内触发陷阱,这就给使用造成了不便。更糟的是,有时完全不会触发陷阱,而是直接以非零状态码结束进程。C 的断言并没有针对调试器进行优化。

下面的这个小程序就是一个非常简单的实现,如果有需要,我可以到后面再调整:

#define ASSERT(c) if (!(c)) __builtin_trap()

它不会输出诊断信息,但一般情况下也不需要。绝大多数时候,这个断言会被调试器捕捉,因此诊断信息是不必要的。

我不反对 static_assert,但它也不是运行时的一部分。

数学函数

我指的是 math.h、complex.h 等文件中的所有函数。在实践中,这些都是伪内置函数,也是 libc 中比较难替换掉的部分之一。这些函数对精度的重视超出了日常需要,但这是很合理的。

字符分类和映射

包括 isalnum、isalpha、isascii、isblank、iscntrl、isdigit、isgraph、islower、isprint、ispunct、isspace、isupper、isxdigit、tolower 和 toupper。这个接口具有误导性,而且这些函数经常被错误使用。如果在源文件中看到 #include <ctype.h>,那么几乎可以肯定代码有问题。我自己也为此感到内疚。在我的日常工作中,这些函数无一例外都禁止使用。

它们的原型大致如下:

int isXXXXX(int);

然而,输入的取值范围是 unsigned char 加上 EOF。除了 EOF 之外的任何负参数都是未定义的行为,显然字符串会产生错误结果。所以,如下写法是不正确的:

char *s = ...;if (isdigit(s[0])) { // WRONG!...}

如果 char 是有符号的,就像在 x86 上一样,那么对于任意字符串 s,isdigit的行为都是未定义的。某些实现在遇到此类输入时甚至会崩溃。

如果参数是 unsigned char,那么至少还能将参数截取到定义域内,因此通常也能得出所需的结果。(但传递 Unicode 代码点会出现错误结果,真是一个奇怪的错误。)唯一的例外是它能够处理 EOF。为什么?因为这些函数是为 fgetc定义的,而不是字符串!

如果想修复这个问题,你可以使用掩码:

if (isdigit(s[0] & 255)) {...}

然而,你依然会遇到 locales 问题。locales 有点像全局状态,它能改变一些 libc 函数的行为方式,包括字符分类。虽然locales有一些特别的用途,但大多数时候这种行为出人意料,也没有人喜欢。这对性能也很不利。我的习惯就是利用LC_ALL=C运行所有GNU程序,这样才能保证它们实现原本的行为。如果你需要解析的格式不适用于locales(绝大多数情况都是如此),那你肯定不希望字符分类按照locales的方式工作。

由于大多数时候这个接口和行为都不合适,因此你最好自己编写范围检查或查找表。在命名的时候,请避免以 is 开头,因为这些函数名是保留字。

_Bool xisdigit(char c){return c>='0' && c<='9';}

我使用了char,遇到简单的 UTF-8 解析仍然有效。

errno

如果没有 libc,你就不必使用这个全局的、线程局部的、伪变量。甩掉包袱,返回自己定义的错误,并在必要时使用结构。

locales

如上所述,locales 有一些特别的用途,比如格式化日期,但这些用途被困在 setlocale 设置的全局状态后面,因此有时无法正确使用。

在 Windows 上,我改为使用 GetLocaleInfoW 来获取“当前月份的本地名称是什么?”之类的信息。

setjmp 和 longjmp

有时很难正确使用,尤其是将局部变量限定为 volatile 时。它可以与基于区域的分配相结合,自动且及时地释放 set 和 jump 之间创建的所有对象。这些宏很好,但不要过度使用。

可变参数

有时,可变参数函数有一定的帮助,这要感谢宏 va_start/va_end。不幸的是,这些函数非常复杂,因为调用惯例并没有降低使用难度。它们需要编译器的帮助,实际上它们是作为编译器的一部分而实现的,不是 libc。这些函数虽好,但没有它们我依然可以正常工作。

信号

虽然在类 Unix 系统上信号很重要,但 C 标准库中定义的信号其实并没有什么用。如果你正在处理信号,甚至是信号之类的东西,那么肯定要编写超出 C 标准库、特定于平台的代码。

原子

我的一些示例中使用了 _Atomic 限定符,因为可以提高简洁度,但我很少在实践中使用它。部分原因是它对 API 和 ABI 有不利影响。与 volatile 一样,C 使用类型系统来间接地实现某个目标。类型不是原子的,但加载和存储是原子的。在标准化之前,C 实现一直使用内联函数、函数或宏来表达这些加载和存储。

但是,我不认为原子函数需要 _Atomic 限定的参数,所以看似鱼与熊掌我可以兼得。除了一个主要实现(MSVC)仍然不支持它们。在考虑使用 C 原子的地方,我都使用了更丰富的 GCC 内置函数集,Clang 也支持这些函数。如果需要编写适用于 Windows 的代码,我会使用互锁宏,因为这种方法适用于该平台的所有编译器。

标准输入输出

标准输入输出库 stdio 是我决定放弃 libc 的主要驱动因素。几乎每个程序都需要某种输入或输出,但使用 stdio 会让实现变得更加困难。

要想读取或写入文件,首先必须打开它,即调用 fopen。然而,某个平台的所有实现都不允许 fopen 访问大部分文件系统,因此使用 libc 会限制该平台上程序的功能。

标准库会区分“文本”和“二进制”流。虽然在类 unix 平台上这两者没有区别,但在其他需要对输入和输出进行转换的平台上却有区别。除了数据会遭到破坏外,文本流的性能也很糟糕。以二进制模式打开一切是一个非常简单的解决方法,但是标准输入、输出和错误都是作为文本流打开的,而且没有将它们改为二进制流的标准函数。

使用 fread 时,某些实现会将整个缓冲区作为临时工作空间,即便返回的长度远小于整个缓冲区也是如此。所以以下代码无法正常运行:

char buf[N] = {0};fread(buf, N-1, 1, f);puts(buf);

这段代码除了会输出预期的结果外,还会输出垃圾,因为 fread 会覆盖范围之外的零。

流是缓冲的,并且没有可靠地访问未缓冲的输入和输出的方法,例如当应用程序已经缓冲时,或许这只是其工作方式带来的必然结果。虽然我们有 setvbuf 和 _IONBF(“无缓冲”),但至少在某些情况下,这只意味着“一次一个字节”。使用 libc 的程序最终都会变成双缓冲,这种现象很常见,因为我无法可靠地关闭 stdio 缓冲。

常见的实现假设流将被多个线程使用,因此每次访问都需要使用互斥锁。这会导致小型读取和写入的性能变差,而这些情况本应是缓冲最能发挥作用的地方。这不仅不正常,而且这样的程序肯定会出问题,因此 stdio 实际上是牺牲了最常见的需求,对一些不太常见且极端的情况进行了优化。

我们没有可靠的方式通过交互式输入和显示 Unicode 文本。C 标准中处理“宽字符”方面做出了一些妥协,但在实践中毫无用处。我已经试过了。我最常见的需求是输出标准错误的路径,以便正确地显示给用户。

seek 函数的参数 offset 只能为 long 类型,一些实现甚至无法打开大于 2GiB 的文件。

我不想处理这些问题,只是向平台层添加了几个无缓冲的 I/O 函数,然后在应用程序中放置了一个小的缓冲流实现,这个实现支持将缓冲刷新到平台。文本的输入和输出使用了 UTF-8,如果平台层检测到它连接到了终端或控制台,就会进行适当的转换。获得比标准输入输出更可靠的方法并不需要付出太多努力。

数值转换

浮点数转换是一个常见的难题,尤其是当你需要保证转换后的字符串与浮点数一一对应时。浮点数转换是 libc 中最好用的一个部分。尽管使用 libc 仍然很难获得最简单或最短的一一对应的表示。此外,这也是修改 locales 可能会引发灾难性后果的领域。

那么,问题来了:在你的应用程序上下文中,浮点数转换很关键吗?也许,你只需要向用户显示一个四舍五入、低精度的浮点表示,比如在调试窗口中显示玩家的位置等。或者你只需要解析一个中等精度的、格式非常简单的、非整数输入。这些都不难。

解析函数(atoi、strtol、strtod 等)需要以 结尾的字符串,通常这很不方便。这些整数的来源可能并不像文件一样以空值结尾,所以我需要先附加一个空值终止符。我无法把映射到内存的文件直接传递给它们。即使在使用 libc 时,我也经常编写自己的整数解析器,因为 libc 解析器缺少合适的接口。

格式化整数很容易。解析范围有限的整数(比如小于一百万)很容易。但解析触及数字类型极限的整数很棘手,因为无论有符号还是无符号,每个操作都必须防止溢出。幸运的是前两个很常见,最后一个不太会遇到。

随机数

我们有 rand、srand 和 RAND_MAX函数。虽然我很热衷于伪随机数生成器,但我不推荐在任何情况下使用它。rand 函数是一个并不太优秀的伪随机数生成器,性能不佳,并且持有全局状态。我们无法提前预知 RAND_MAX,因此很难有效利用 rand。只需几行代码,你就可以构建一个全方位碾压的实现。

更糟糕的是,常见的实现希望可以利用多个线程并发访问,因此它们将这些方法包装在互斥锁中。同样,随机数的优化针对的都是一些不太常见且极端的情况,各个线程会因为确定性的 伪随机数生成器给出的非确定性结果而相互竞争,代价是牺牲最常见的需求。依赖这种互斥锁的程序都会出问题。

内存分配

相关函数包括 malloc、calloc、realloc、free 等。在实际的工作中,我们使用的函数过于细化,而且过多,以至于许多 C 程序的生命周期都纠结在一起。有时,我希望有一个标准的区域分配器,这样许多独立编写的库就可以使用一个通用、合理、可由调用者控制的分配接口。

此处,没能实现标准化的一个主要原因是,分配器并不负责计算大小。calloc 是一个开始:你需要说明大小和数量,它计算出总分配量,检查溢出。但需要的工作远不止如此,哪怕只是不鼓励独立分配、鼓励成组分配,情况也要好很多。

关于大小为零的内存分配,有一些边缘情况,比如 malloc(0),标准对于行为的规定有点过于开放。但是,如果你的程序结构很糟糕,可能会将零传递给 malloc,那么届时你就会遇到更大的问题。

访问环境

getenv 很简单,但我更喜欢直接访问环境块,就像 main 的第三个非标准参数一样。

exit 没问题,但是 atexit 是垃圾。

system 在实践中基本上没有任何用处。

排序和搜索

qsort 很差劲,因为它缺少上下文参数。质量参差不齐。如有必要,从头开始实现也不难。我很少需要排序。

bsearch 的情况大致相同。尽管如果我需要对数组进行二分搜索,只用 bsearch 可能还不够,因为通常我需要找到范围的下限和上限。

多字节编码和宽字符

mblen、mbtowc、mbtowc、wctomb、mbstowcs 和 wcstombs 与 locale 系统紧密关联,而且并不会处理任何特别的编码(如UTF-8),因此导致它们不可靠。所有其他宽字符函数的情况都很类似。幸运的是,我只需要在一个平台上使用宽字符,可移植代码中不涉及这个问题。

最近还推出了 mbrtoc16、c16rtomb、mbrtoc32 和 c32rtomb,其中只规定了“宽”的方面(UTF-16、UTF-32),但没有规定多字节的方面。实现对于标准的支持很有限,所以没有太大用处。

字符串

与 ctype.h 一样,string.h 中的所有函数都很糟糕,有些函数总是被错误使用。

memcpy、memmove、memset 和 memcmp 都很好,但有一个问题:将空指针传递给这些函数,则行为是未定义的,即使大小为零。这很荒谬。空指针合法地指向大小为零的对象。如前所述,就连 malloc(0) 也允许以这种方式运行。如果没有这个缺陷,这些函数还是可以使用的。

strcpy、strncpy、strcat 和 strncat 根本无法正常使用,每次使用都会引发混乱。因此,任何调用这些函数的代码都有问题,应该进一步审核。事实上,我没有见过在实际程序中正确使用 strncpy 的例子。在我看来,这些函数无一例外都应被禁止。这些函数的非标准版本(如 strlcpy)亦是如此。

strlen 有合理的用途,但使用过于频繁。它应该仅在接收未知大小的字符串(例如 argv、getenv)时出现在系统边界,而且永远不应用于静态字符串。

在看到 strchr、strcmp 或 strncmp 时,我就在想为什么使用者会不知道字符串的长度。另一方面,strcspn、strpbrk、strrchr、strspn 和 strstr 没有对应的 mem 系列函数,尽管空终止符的要求降低了它们的实用性。

strcoll 和 strxfrm 依赖于语言环境,因此用途很有限。而且不可预测,因此应避免。

memchr 很好,除了前面提到的空指针限制,尽管它出现的频率较低。

strtok 具有隐藏的全局状态。除此之外,返回的令牌有多长?strtok 在返回之前知道令牌的长度。为什么不能直接告诉我?所以不用它。

strerror 有一个明显、简单、可靠的解决方案:返回一个指针,指向查找表中与错误编号相对应的静态字符串。没有全局状态,线程安全,可重入,而且返回的字符串直到程序退出前都可以正常使用。有些实现就采用了这种方式,但不幸的是,有的实现并不这么想,它会写入一个共享的全局缓冲区。希望你没有使用errno。

线程

C11 引入了线程,但一直没有太大水花。凡是可以使用 C 线程的地方都可以使用 pthreads,而且 pthreads 更好。

此外,线程的创建属于平台层的操作。

时间函数

使用非常有限,除了使用 time 和 clock 生成随机数种子之外,我不记得其他地方使用过这些函数。

总结

我略过了一大堆宽字符函数,除此之外几乎 C 标准库的所有函数都提到了。在完全不使用这些函数时,唯一令我怀念的就是数学函数,偶尔还有 setjmp/longjmp。其余的函数我都可以轻松构建出更好的实现。

上述所有提到的 C 实现都非常古老。它们很少发生变化,即便有变化,也不过是岁月的沉淀。这个领域没有太多创新,这一点很好,因为我喜欢稳定的目标。

评论

评论1:我完全同意作者的看法,通常我也会这么做。libc 是 C 语言中最薄弱的环节,过时、糟糕的命名规则,其 API 有时也有害。当然它也有好处:简单、随时可用,因此可以作为备选,但我认为,使用的框架最好能覆盖所有 API,包括最简单的 printf/malloc/fopen 等,而且应该在所有代码中维护相似的命名规则。

评论2:我最不敢相信的是 C 语言中的保留字规则。我甚至不知道有这条规则,我认为它比我想象得还要糟糕,它几乎覆盖了所有代码中能用到的常用单词。但就像 C 语言中的其他陷阱一样,早期的语言发明者并没有考虑到命名空间的概念,所以才导致了这个结果。至于 GNU tools 中的有关 locales 的行为不一致,也可以理解,毕竟 GNU 项目在历史上是从 POSIX 标准发展而来的。

标签: #输入未知行字符串