C Programming Language

Expertise #01: Overview of C Standards

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-07-01


前置知识

在阅读本文档前,请先阅读本教程的整体介绍,详见 Introduction


概述

大部分编程语言都会有自己的标准,这其中就包括 C 语言。当然,编程语言的初学者无需非常深入地去了解语言的标准,只需要有个大概的认识即可,当你对这门语言有了足够的领悟之后再去深入了解也不迟。

C 语言标准在几十年中没有经历过巨大的变革,毕竟和五花八门的新兴语言不同, C 语言是一门传统的、接近底层的、面向过程的语言,这使得 C 语言标准的制定者无需去考虑那些复杂的面向对象相关的问题。

C 语言被绝大多数大学选择作为本科计算机专业的入门语言,并且归功于那本广为流传的毒瘤教材(《C程序设计》,作者谭浩强),大多数初学者,甚至是一些教师,都搞不清楚 C 语言标准,甚至是弄错弄混。这篇文章将帮助你弄清楚 C 语言标准到底是怎么一回事,也会对几个常见的主流标准之间的差异进行详细的介绍。

不要认为这是没有意义的,即便你对这些东西不感兴趣(其实我自己也不怎么感兴趣),你也至少应当对 C89/C90 和 C99 有一个基本的认识,因为它们最为常见,否则你在以后的学习和编程中可能会陷入困惑。


变迁历程

总览

我们首先对 C 语言标准的变迁历程进行一个大致的介绍,帮读者确立一个基本的认识。

本节中的内容主要是对 Wikipedia 的 C 语言条目中的内容的翻译和精简,如果读者有兴趣可以前往本文底部的链接自行查阅。

下表列出了 C 语言标准所经历的主要时间阶段。不过我并不打算和读者探讨这门语言的详细的发展史,毕竟这没什么意义。这张表只是为了提供一个总览而已。

时间节点创立的 C 语言标准
1972最初的诞生
1978K&R C
1989/1990C89/C90
1999C99
2011C11
2017/2018C18

早期的发展

C 语言诞生于1972年,它是从 B 语言发展衍生出来的。这其中的历史故事我不打算赘述。

1978年, Brian Kernighan 和 Dennis Ritchie 发布了《The C Programming Language》的第一版,这本书被大众认为是 C 语言的母书之一,也是被极力推荐的 C 语言入门教材之一。这本书的第一版中所介绍的 C 语言被称为 K&R C ,这是最早的、经过精心设计的 C 语言标准。

很多在今天看来非常普通的语法都是在 K&R C 中被首次定义的,但我不打算在本文中介绍具体是哪些,因为现在几乎不会使用比 K&R C 更早的 C 语言标准了,所以初学者没必要特地去区分出 K&R C 中追加的特性。

时至今日, K&R C 仍然被认为是 C 语言标准的“最低公分母”。如果你在以后的开发中使用 C 语言,并希望你的程序具有最大的可移植性,你就应当使用 K&R C 标准,以使得程序能在绝大多数运行环境下正常工作。当然,如果你对可移植性没有那么高的要求,就不必使用古老的 K&R C 了,可能 C89 就足以满足要求,这要根据实际情况来判断。

不过在初学阶段你不需要在意这些,毕竟大多数人将来并不会从事 C 语言的开发,而本科教学通常也不会使用这么古老的标准,你只需要能够知道 K&R C 的存在即可。

C89/C90

正如其名, C89/C90 标准诞生于1989/1990年。

1989年, 美国国家标准协会 (American National Standards Institute, ANSI) 建立了 C 语言的标准规范,并被批准为 ANSI X3.159-1989 ,通常称为 ANSI C ,有时也称为 Standard CC89

1990年, 国际标准化组织 (International Organization for Standardization, ISO) 采用了 ANSI C 标准,对其格式有所更改。该标准的正式名称为 ISO/IEC 9899:1990 ,简称为 ISO C ,有时也称为 C90

国际标准化组织仅对 C89 的格式进行了一些更改,而技术内容本身没有任何区别,因此 C89 和 C90 指的是相同的编程语言标准。我们常常见到形如 C89/C90 的记法,就是因为 C89 和 C90 在本质上是一样的。

从 C90 以后, ANSI 与其他国家标准机构一样,不再单独开发 C 标准,而是遵循由工作组 ISO/IEC JTC1/SC22/WG14 维护的国际 C 标准。

后来 ANSI 和 ISO 分别撤销了 C89 和 C90 的标准,但是它们至今仍然被广泛使用。

大多数现代 C 语言代码都是基于 C89 的。因此我也不打算在这篇面向初学者的文章里介绍 C89 相比于 K&R C 的新特性,这对初学者而言不是必要的内容。

C95

1995年, ISO 针对 C90 标准发布了一个扩展,称为修订1,它的正式名称为 ISO/IEC 9899:1990/AMD1:1995 ,简称为 C95

除此以外, ISO 还针对 C90 发布了两个技术勘误,包括1994年的 ISO/IEC 9899:1990/Cor 1:1994 TCOR1 和1996年的 ISO/IEC 9899:1990/Cor 2:1996 。

C95 主要是更正了一些 C90 中的细节,并为国际字符集增加更多的支持。由于 C95 相对于 C89/C90 而言并没有大的变化,多数情况下我们不会提及 C95 ,甚至有时只把它当作是 C90 的一个修订版。

C99

1999年, ISO 发布了 ISO/IEC 9899:1999 ,通常称为 C99

后来 ISO 针对 C99 发布了三个技术勘误,包括 ISO/IEC 9899:1999/Cor 1:2001(E) 、 ISO/IEC 9899:1999/Cor 2:2004(E) 和 ISO/IEC 9899:1999/Cor 3:2007(E) 。

C99 相对于 C89 而言引入了不少新特性,算得上是 C 语言标准的一次大规模变化,其中有些是为了适应计算机软件的发展,有些则是引入了 C++ 中出现的一些有益的内容。

事实上,有些 C99 引入的新特性已经被许多编译器无形地实现在 C89 中,不需要在编译器中启用 C99 就能使用。例如 // 的单行注释事实上是 C99标准才引入的,这一特性来自 C++ 语言,在最初的 C89 中并没有这种语法,但是在许多编译器中无需启用 C99 也可以合法使用单行注释。也有些新特性被实现为扩展功能。

由于 C89 和 C99 的差异相对较大,你需要对 C99 引入的新特性有所了解,知道哪些语法和功能是 C89 不支持的。

C11 和 C18

考虑到 C11 和 C18 的普及程度远不如 K&R C 、 C89 和 C99 ,并且本科课程中也通常不会使用,我们就不在本文中过多地介绍了。


在 gcc 编译器设置 C 语言标准

如果需要设置标准为 C89 ,可以使用编译参数 -std=c89 。如果需要设置标准为带有 GNU 扩展的 C89 ,可以使用编译参数 -std=gnu89

如果需要设置标准为 C99 ,可以使用编译参数 -std=c99 。如果需要设置标准为带有 GNU 扩展的 C99 ,可以使用编译参数 -std=gnu9x

gcc 编译器默认使用带有 GNU 扩展的 C89 作为标准,即以 -std=gnu89 作为默认值。

实际上,即便你指定了所用的标准,一些不属于所用标准的语法事实上会被编译通过,这是编译器在背地里干的好事。为了保证兼容性,可以使用编译参数 -pedantic-errors 要求编译器对所有不遵循标准 C 的代码严格地报告编译错误,拒绝兼容任何当前标准不支持的语法以及任何扩展语法。


C99 的部分新特性

C99 引入的新特性仅仅是一条条罗列出来都能占据两页纸,因此我不打算在面向初学者的文章中逐一介绍,这里只会给出一些初学者需要注意的变化。并不是说这里没有列出的新特性就不重要,只是它们可能相对来说不是特别常用,对于初学者而言不需要去关注而已。

注意,许多 C99 特性已经被编译器模糊地支持了(即便是在 C89 标准下),因此如果你的编译器能编译通过也不要感到奇怪。你可以使用编译参数 -pedantic-errors ,那么编译器就会关闭所有的扩展支持,并严格地遵循你所设置的 C 语言标准。

移除隐式 int 声明

在大多数情况下 C99 都是向后兼容 C89 的,但在这一方面变得更为严格了。在 C99 中,缺少类型说明符的声明不再隐式地假定为 int 类型,包括函数返回值和参数的声明。

也就是说,在 C89 中下列代码可以正确编译,而在 C99 中则不行:

f1(int x) {
    return (x + 1);
}
int f2(x) {
    return (x * 2);
}
int main(void) {
    int x = 1;
    int y = f1(x), z = f2(x);
    return 0;
}

在 gcc 编译器中使用编译参数 -std=c99 -pedantic-errors 会得到如下报错:

|1| error: return type defaults to 'int'
| | In function 'f2':
|4| error: type of 'x' defaults to 'int'

C 标准委员会认为,对于编译器而言,相比于默默地处理那些依赖于隐式 int 声明的历史遗留代码,诊断出类型说明符的无意遗漏更为重要。

允许混合声明和代码

在 C99 之前, C 语言标准要求函数体内的声明语句全部放在其他语句之前,显然这是一个不太人性化的规定,只要不是太老的编程语言几乎都没有这样的语法规定。

从 C99 开始, C 语言允许程序中混合声明和代码,换句话说,在 C99 中下列代码可以正确编译,而在 C89 中则不行:

#include <stdio.h>
 
int main(void) {
    int x;
    x = 1;
    int y;
    y = 2;
    return 0;
}

在 gcc 编译器中使用编译参数 -std=c89 -pedantic-errors 会得到如下报错:

| | In function 'main':
|6| error: ISO C90 forbids mixed declarations and code [-Wpedantic]

支持变长数组

在计算机编程中, 变长数组 (variable length array, or runtime-sized array) 指的是长度在运行时确定(而不是在编译时确定)的数组。变长数组的意思并不是说数组的长度可以随意变化,不要被它的名字误导了。

在 C99 之前,数组的长度必须在编译时确定。通俗地讲,你不能使用一个变量的值作为数组的长度,哪怕这个变量是由 const 修饰的。如果希望将多个数组的长度统一用一个量来表示,我们只能使用宏定义来达成目的:

#define n 5
 
int main(void) {
    int a[n], b[n], c[n];
    return 0;
}

从 C99 开始, C 语言支持使用变长数组,换句话说,在 C99 中下列代码可以正确编译,而在 C89 中则不行:

int main(void) {
    int n = 5;
    int a[n], b[n];
    const int m = 10;
    int c[m], d[m];
    return 0;
}

在 gcc 编译器中使用编译参数 -std=c89 -pedantic-errors 会得到如下报错:

| | In function 'main':
|3| error: ISO C90 forbids variable length array 'a' [-Wvla]
|5| error: ISO C90 forbids variable length array 'b' [-Wvla]

注:在 C 语言的类型系统中,变长数组被归类为 variably modified type 。目前我没有找到该类型的一个合适的中文翻译。

支持单行注释

从 C99 开始, C 语言支持使用 // 来实现单行注释。你可能会对此感到惊讶,但这确实是 C99 才提出的,虽然绝大多数编译器都会允许你在 C89 设置下使用单行注释,但如果使用严格的编译参数,编译器就会毫不留情地给出一个编译错误。

在 C99 中下列代码可以正确编译,而在 C89 中则不行:

int main(void) {
    // this is an one-line comment
    return 0;
}

在 gcc 编译器中使用编译参数 -std=c89 -pedantic-errors 会得到如下报错:

| | In function 'main':
|2| error: expected expression before '/' token

支持 for 循环初始化声明

在 C99 之前,我们无法在 for 循环的初始化语句中声明临时变量,从程序设计的角度讲这样并不是好事,因为除了代码的可读性和美观问题以外,从逻辑上讲,变量 ii 应当仅供 for 循环内部使用,但是现在,循环外部的代码可以随意地访问 ii 的值:

int main(void) {
    int sum = 0, t;
    int n = 5;
    int i;
    for (i = 1; i <= n; i ++) {
        sum += i;
    }
    t = i; /* we can access i everywhere */
    return 0;
}

在上例中,这当然不会有什么问题,但是在有些时候就未必了。

从 C99 开始, C 语言支持 for 循环初始化声明,我们可以让 ii 成为真正意义上的临时变量:

int main(void) {
    int sum = 0, t;
    int n = 5;
    for (int i = 1; i <= n; i ++) {
        sum += i;
    }
    // t = i /* this will get a compile error */
    return 0;
}

或许作为初学者的你还并不能理解,那么请你记住:在编译环境允许的情况下,尽可能将 i, ji,\ j 这类循环体内使用的临时变量声明为真正的临时变量。

支持复合字面量

C99 新增了对 复合字面量 (compound literal) 的支持。

下面这段代码演示了数组的复合字面量的使用方法。程序的第 22 行声明并初始化了一个指针 pp ,它指向一个匿名的、长度为 33 的数组的首元素。

int * p = (int []){1, 2, 3};

下面这段代码演示了结构体的复合字面量的使用方法。在 C89 中,只有第 66 行能通过编译,而第 77 行将报告错误,因为 C89 只允许在初始化时使用 {} 语法,从 C99 开始才提供了对复合字面量的支持。

struct Block {
    int x;
    char str[10];
};
struct Block block = {1, "aaa"};
block = (struct Block){2, "xyz"};

支持指定初始值

C99 新增了对 指定初始值 (designated initializer) 的支持。

在 C89 中,如果你想初始化一个稀疏数组,可能只能这么做:

int a[10] = {0, 0, 3, 0, 0, 0, 0, 0, 5, 0};

或者这么做:

int a[10] = {0};
a[2] = 3;
a[8] = 5;

而指定初始值的特性允许你以这样的语法对数组进行初始化,这无疑带来了一些便捷:

int a[10] = {[2] = 3, [8] = 5};

类似的特性也存在于结构体的初始化和赋值中,其语法如下所示:

#include <stdio.h>
 
struct Block {
    int a;
    double b;
    char c;
};
struct Block block = (struct Block){.a = 1, .c = 'x'};
printf("%d, %.1f, %c\n", block.a, block.b, block.c);
block = (struct Block){.b = 0.5, .c = 'z'};
printf("%d, %.1f, %c\n", block.a, block.b, block.c);

上述代码的输出结果如下:

1, 0.0, x
0, 0.5, z

新增数据类型

long long int 类型

C99 新增了 long long int 数据类型。在大部分 PC 机上 long long 类型是 6464 位的,有符号数和无符号数的取值范围分别为 [263, 2631][-2^{63},\ 2^{63} - 1][0, 2641][0,\ 2^{64} - 1]

如果进行实际工程开发的话需要注意,这个取值范围在不同的机器上可能会不一样,可以用 sizeof() 运算符查看其实际位数。

但是这个 long long 类型并没有这么简单,当你想使用 scanf/printf 对 long long 类型的变量进行输入输出时,应当使用什么样的格式符号,是取决于代码运行环境的,可能是 %I64d%lld 之一,也可能这两种格式符号都合法。

如果搞不清楚的话可以先试试 %lld ,现在在多数情况下 %lld 都是可用的。

_Bool 类型

C99 新增了 _Bool 数据类型,用于表示布尔值。

现代的编程语言基本上都提供了布尔类型, C 语言也做出了改进。布尔类型的值只能是 0011

可能是为了方便或是与 C++ 语言相统一, C99 还增加了一些宏定义,包含于头文件 <stdbool.h> 中。实际上这个头文件里的内容非常少,排除掉为了和 C++ 语言兼容而写下的条件宏,就只有这么点内容:

#define bool	_Bool
#define true	1
#define false	0
 
#define __bool_true_false_are_defined	1

也就是说,你可以像这样直观地使用布尔类型,用 true/false 代替 0/1 :

#include <stdbool.h>
 
int main(void) {
    bool x = true, y = false;
    return 0;
}

当然,如果不使用这个头文件,你也可以像这样等效地使用布尔类型,只是这不太方便,代码看起来也没有 true/false 那么直观:

int main(void) {
    _Bool x = 0, y = 1;
    return 0;
}

_Complex 和 _Imaginary 类型

C99 新增了 _Complex 和 _Imaginary 数据类型,用于表示复数和虚数,包含于头文件 <complex.h> 中。

关于 C99 复数的内容我不打算展开介绍,毕竟这对初学者来说不是必要的知识,有兴趣的读者可自行查阅相关资料。

新增保留字

C99 新增了 55 种保留字:

  • _Bool , _Complex , _Imaginary :上一节已经介绍过了。

  • inline :用于定义内联函数。 C 语言在 C99 首次引入内联函数,相比于 C++ 语言, C 语言中的内联函数有诸多复杂的规则,初学者无需深入了解。

  • restrict :用于修饰指针,初学者无需深入了解。


关于 main 函数的返回值

有一些关于 main 函数的谣言广为流传,因此我特别用一节来解释这个问题。

一些说法认为,在 C89 中允许 main 函数的返回值为 void 类型,而从 C99 开始要求 main 函数的返回值必须为 int 类型:

#include <stdio.h>
 
void main() {
    printf("hello, world");
}

事实上,虽然有些编译器允许这种形式,但是还没有任何标准考虑接受它。 C++ 之父 Bjarne Stroustrup 在他的主页上的 FAQ 中明确地表示 void main() 的定义从来就不存在于 C 或 C++ 中。我专门查阅了 C89 和 C99 标准,也没有见到任何允许 void main() 的说明。所以,编译器不必接受这种形式,并且很多编译器也不允许这么写。

事实上,当我在gcc编译器中使用编译参数 -std=c89 -pedantic-errors 时,上述代码确实不能通过编译,这也证明了 C89 也并不支持 void main() 的写法:

|3| error: return type of 'main' is not 'int' [-Wmain]

因此,为了保证你的程序不会在严格的编译环境下发生错误,请你务必将 main 函数的返回值声明为 int 类型:

#include <stdio.h>
 
int main(void) {
    printf("hello, world");
    return 0;
}

其中 return 0 有时可以被省略,在省略的情况下,绝大部分编译器会自动将 00 设为 main 函数的返回值。但我个人建议不要省略。


参考资料

Wikipedia的 C 语言条目,其中描述了 C 语言标准的变迁概要。

C (programming language)