您的 C 代码符合标准吗?
开放源代码操作系统所带的 gcc 发行版本并不支持 C99 的所有新特性,不过现在已经有足够多的新特性普遍可用,因此有理由开始认真考虑在新的开发中采用 C99 特性,尤其是用在它们使得效率和清晰度本质上发生变化的那些地方。
本文回顾了近来发布的 Linux 和 BSD 上的 C99 语言和库特性的可用性。由于这些特性很多是 gcc 的标准特性,所以新版本的 gcc 在大部分其他平台上可以做同样的事情。当然,各个发行版本或者各个 OS 之间的库支持是不同的。
C99是什么
C99 标准是 ISO C 标准的最新修订版本。或许应该先介绍一些历史背景。在早期,C 语言的开发没有组织,经历了很多变化。最后,大部分厂家都接受了 Kernighan 和 Ritchie 的 The C Programming Language 第一版 (1978) 中描述的语言,但是扩展还是司空见惯。ANSI 开始致力于基于此书和现有实际应用之上的标准,到 1989-1990 时,一个标准得到了广泛的使用。这个标准就是广泛流传的“C89”;有些人戏称在 K&R 的 1978 版中描述的语言为“C78”。在接下来的十年,编译器厂商不断开发新扩展和新特性,并在 1999 年发布了修订的标准,这个标准描述了多年来所做的对众多最有用和广为支持的新特性所进行的标准化工作。这个标准经常被叫做“C99”标准。
以语言标准调用 gcc
GNU C 编译器支持许多不同版本的 C 编程语言。可以在命令行上通过 -std 选项来选择所使用的 C 标准的版本。默认选择的不是任何版本的标准,而是“GNU C”语言,这门语言有其自己的扩展集。 C 标准的常见版本用下面的选项选择:
- -std=c89或 -std=iso9899:1990
- 最初的 C89 标准
- -std=iso9899:199409
- C89,增加了 Normative Addendum 1 的变化
- -std=c99 or -std=iso9899:1999
- C99 修订版标准
使用 -pedantic 选项来强制遵从某个版本的标准。这个选项主要用于设法确保您的代码迁移到其他编译器时仍可用;例如,如果您正在与不使用 gcc 的人共享一个代码库 (codebase),您可能希望它在任何时候都能用。注意, -pedantic 标记偶而将会得到给定的标准错误的一些详细信息;例如,它可能试图在 C99 程序上强制执行 C89 规则,或者可能会在强制执行模糊的规则时失败。还是值得用它来做测试。如果您正在尝试编写可移植代码,应该好好研究一下 -std=c99 -pedantic -Wall 。
C89 标准引入了一个新概念;“独立的 (freestanding)”和“托管的 (hosted)”环境之间的区别。多数人都很熟悉托管的环境;它提供了完整的标准库,并总是从 main() 开始执行。如果您需要独立环境所包含的稍有不同的警告与行为集合,那么使用 -ffreestanding 选项。默认地是假定为托管的环境。为了解决常见的 FAQ,gcc 会故意在 main() 声明使用的参数或返回类型不是标准中所列出的类型时给出警告;而 C99 标准允许实现提供另外的声明,但是这些实现是永远不可移植的。尤其是,通常以 void 为返回类型声明main() 的习惯是完全错误的。(这就是 NetBSD 内核使用 -ffreestanding 标记来编译的原因。)
语言特性
C 编程语言有两个容易混淆的部分:语言和库。以前,有很多通用工具代码,大家都倾向于重复使用,这些代码最后被标准化为标准 C 库。最初这一区别非常容易理解:如果要进行编译,那么就是语言,如果是在附加代码中,那么就是库。事易时移,这个区别变得模糊了。例如,一些编译器会生成对外部库的调用以进行 64 位运算,同时一些库函数可能会由编译器不可思议地处理。本文遵循标准的术语进行区分,即来自标准的“库”部分的特性是库特性,在文章的下一节将讨论这些特性。本节讨论除此之外的所有内容。
C99 语言引入了许多可能会引起软件开发人员兴趣的新特性。这些特性中有很多类似于 C 扩展的 GNU C 集的特性;不幸的是,在一些情况下,它们并不是很兼容。
已经增加了一些在 C++ 中常见的特性。具体来说,// 注释、混合声明和混合代码已经成为 C99 的标准特性。这些在 GNU C 中一直都有,应该在每一个平台都可用。不过,总的来说,C 和 C++ 仍是单独的语言;C99 与 C++ 的兼容性比 C89 要稍差一些。无论何时,试图去编写混合的代码都不是好主意。好的 C 代码将是不好的 C++ 代码。
C99 增加了一些对 Unicode 字符(既包括字符串文字内的字符,也包括标识符内的字符)的支持,实际上,大部分用户并不需要系统对此的支持;现在还不要期望使用这种字符的源代码可以对其他人可用。一般而言,宽字符和 unicode 支持主要体现在编译器中,但是文本处理工具还没有达到标准。
新的变长数组(variable-length array,VLA)已经部分可用。可以用简单的 VLA。不过,这纯粹是一个巧合;实际上,GNU C 有其自己的变长数组支持。结果,虽然使用变长数组的简单代码将可以工作,但是大量的代码会遇到旧的 GNU C 对 VLA 的支持与 C99 定义之间在在差异的麻烦。可以声明长度为本地变量的数组,但是就到此为止吧。
复合文字和指定的初始化程序是非常好的代码可维护性特性。比较下面两个代码片断:
清单 1. 在 C89 中延迟 n 微秒
/* C89 */
{
struct timeval tv = { 0, n };
select(0, 0, 0, 0, &tv);
}
清单 2. 在 C99 中延迟 n 微秒
// C99
select(0, 0, 0, 0, & (struct timeval)
{
.tv_usec = n
}
);
复合文字的语法允许用大括号括起来的一系列值来初始化适当类型的自动对象。每一次运行到对象的声明时它会被初始化,所以使用可能会修改相应对象的函数(比如一些版本的 select )时这样做是安全的。指定的初始化程序语法允许您通过名字初始化成员,不用理会它们在对象中出现的顺序。当对象庞大而复杂却只有很少成员需要初始化时,这特别有用。使用普通的聚合初始化程序时,缺少的值会被认为它们已经被初始化程序指定为 0 来处理。其他初始化规则稍有修改;例如,现在您可以在 enum 声明后跟一个逗号,以更轻松地编写代码生成器。
多年来,人们一直在争论 C 的类型系统的扩展,比如 long long 。C99 引入了几个新的整数类型。应用最广的是 long long 。标准方法引入的另一种类型是 intmax_t 。这两种类型在 gcc 中都可以用。不过,整数提升规则对于比 long 更大的类型并不总是正确。可能最好用显式的类型转换。
还有很多类型,允许对期望的性质进行更为详细的描述。例如,有的类型的名字是 int_least8_t ,它至少有 8 位,还有 int32_t ,它恰好是 32 位。标准保证至少可以访问 8 位、16 位、32 位和 64 位类型。没有保证会提供精确宽度类型。不要使用这种类型,除非您肯定是实在不能接受更大的类型。另一个可选的类型是新的 intptr_t 类型,它是一个足够大的可以容纳一个指针的整数。并不是所有的系统都提供这样一种类型(尽管当前所有的 Linux 和 BSD 实现都提供)。
C 预处理程序有很多新特性;它允许空参数,支持参数数量可变的宏,有一个用于宏生成程序的 _Pragma操作符,还有一个 __func__ 宏,它的内容始终是当前函数的名字。这些特性在当前版本的 gcc 中都已经有了。
C99 增加了 inline 关键字以支持函数内联。GNU C 也支持这一关键字,但是在语义上略有不同。如果您正在使用 gcc,而且如果您期望代码有与 C99 相同的行为,您应该记住在内联函数前使用 static 关键字。这在以后的修订中可能会解决,同时,您可以将 inline 作为一个编译技巧,但不要依赖于确切的语义。
C99 引入了一个限定词 restrict ,它可以向编译器给出关于指针的优化提示。因为编译器不需要对它做任何事,所以只是因为 gcc 接受了它才引入它。优化的程度不同。用它是安全的,但是还不要希望它可以带来多大的改变。根据相关的注解,新的类型别名 (type-aliasing) 规则已经在 gcc 中得到了完全的支持。这通常意味着您必须要更加留心类型的双义性,它几乎总会去调用不明确的行为,除非您用来访问错误类别数据的类型是 unsigned char 。
作为函数参数的数组声明符现在与指针声明符有了很大意义上的不同;您可以插入类型限定词。尤其有意思的是给数组声明增加 了static 类型修饰符,这是非常古怪的优化提示。看这个声明: int foo(int a[static 10]);
用一个没有指向至少 10 个 int 类型对象的指针去调用 foo() 是不明确的行为。这是一种优化技巧。您这样做是向编译器保证传递给那个函数的参数将至少是那么大;有一些机器可能会以此来拆解循环。老手应该会很清楚,它不是一个新的 C 标准,因为它没有给予 static 关键字全新的含义。
最后一个特性是灵活的数组成员。有一个常见的声明结构体的问题,可能会期望这个结构体由一个头以及接下来的一些数据类型构成。不幸的是,由于不能给结构体一个指向独立分配区域的指针,C89 没有提供好的解决方法。两个常见的解决方案包括,声明一个成员只占一字节存储空间,然后分配额外的超出数组边界的空间,或者声明一个成员要占用比您可能需要的更多的空间,等待分配,而且要小心只去使用可用的存储空间。这两种方案对一些编译器来说都会有问题,所以 C99 为此引入了一个新语法:
清单 3. 具有灵活数组的结构体
struct header {
size_t len;
unsigned char data[];
};
这种结构体的有用之处在于,如果您分配 (sizeof(struct header) + 10) 字节的空间,您可以像处理一个 10 字节的数组一样来处理数据。这个新语法在 gcc 中也得到了支持。
库特性
这对编译器来说是很好的。那么标准库如何呢?基于现有的实践,尤其是来源于 BSD 和 Linux 社区的实践,C99 中增加了很多库特性。所以,这些特性中很多是在 Linux 和 BSD 标准库中已经可以找到的。这些特性中很多只是简单的工具函数;几乎所有特性原则上都可以在轻便的代码中完成,但有很多是特别难的。
C99 中增加的最方便的特性在 printf 函数家族中。首先, v*scanf 函数成为了标准; scanf 家族的每一个成员都有一个相应的 v*scanf 函数,这些函数使用一个 va_list 参数而不是可变的参数列表。这些函数的角色与 v*printf 函数相同,允许用户自定义获取可变参数列表的函数,并最终调用 printf 或scanf 家族中的函数来完成复杂的工作。
其次,引入了 4.4BSD 的 snprintf 函数家族。 snprintf 函数让您可以安全地输出到固定大小的缓冲区。当被告知输出不超过 n 个字节时, snprintf 保证会创建一个长度不超过 n-1 的字符串,字符串最后是一个空结束符。不过,如果 n 足够大,它的返回码是将会完成写入的字符数目。这样,您可以确切地得知您 将 需要多少缓冲区空间才可以完全格式化某些内容。这个函数随处可用,而且您应该始终使用它;很多安全漏洞都归咎于 sprintf 中的缓冲区溢出,而这个函数可以预防这个问题。
新标准中很多新的数学特性,包括复杂的数学特性和专用的函数,都是设计用来帮助优化特定浮点芯片的编译器,但不能保证所有场合都已实现。如果您需要这些函数,最好先去检查一下您的目标平台上有没有这些函数。浮点环境函数并不是总被支持,有一些平台不会支持 IEEE 运算。现在还不要依赖于这些新特性。
C99 中对 strftime() 函数进行了扩展,以提供更多常用的格式化字符。这些新字符可能在最新的 Linux 和 BSD 系统上都已经可用;但是在较老的系统上,它们还没有广泛可用。在使用新格式之前先检查文档。
据说,大部分国际化代码还没有被可靠地实现。
其他新的库特性通常还没有普遍可用;数学函数在超级计算机编译器中好像已经可用了,国际化函数在美国以外开发的编译器中可能可用。编译器厂商实现的是被要求实现的特性。
展望
一般来说,最好保守地采用新特性。不过,很多 C99 特性现在已经足够普及,因而新的开发项目可以适当地利用它们。gcc 编译器套件已被广为应用,大部分项目完全可以假定它为很多目标平台的一个可选项。如果您主要定位于 Linux 或 BSD 系统,或者两者兼有,您可以依赖于大量 C99 新特性,它们至少部分地得到了支持。这些特性是根据感觉上的需要和实际中的实现实践而采用的,您将会从中受益。
当决定您期望依赖哪些特性时,不能只是看它是否在您正在使用的机器上可用,而要考虑目标系统(一个或多个)。您想让人们将 OS 升级到更新的发行版本吗?您的目标市场介意使用一个新的编译器吗?在您决定使用一个特性之前,先在可能的目标系统上测试一下这个特性。