1. 整型
我们知道,在 C 语言中char型占一个字节的存储空间,一个字节通常是 8 个 bit。如果这 8 个 bit 按无符号整数来解释,取值范围是 0~255,如果按有符号整数来解释,采用 2's Complement 表示法,取值范围是 -128~127。C 语言规定了signed和unsigned两个关键字,unsigned char型表示无符号数,signed char型表示有符号数。
那么以前我们常用的不带signed或unsigned关键字的char型是无符号数还是有符号数呢?C 标准规定这是 Implementation Defined,编译器可以定义char型是无符号的,也可以定义char型是有符号的,在该编译器所对应的体系结构上哪种实现效率高就可以采用哪种实现,x86 平台的gcc定义char型是有符号的。这也是 C 标准的 Rationale 之一:优先考虑效率,而可移植性尚在其次。这就要求程序员非常清楚这些规则,如果你要写可移植的代码,就必须清楚哪些写法是不可移植的,应该避免使用。另一方面,写不可移植的代码有时候也是必要的,比如 Linux 内核代码使用了很多只有gcc支持的语法特性以得到最佳的执行效率,在写这些代码的时候就没打算用别的编译器编译,也就没考虑可移植性的问题。如果要写不可移植的代码,你也必须清楚代码中的哪些部分是不可移植的,以及为什么要这样写,如果不是为了效率,一般来说就没有理由故意写不可移植的代码。从现在开始,我们会接触到很多 Implementation Defined 的特性,C 语言与平台和编译器是密不可分的,离开了具体的平台和编译器讨论 C 语言,就只能讨论到本书第一部分的程度了。注意,ASCII 码的取值范围是 0~127,所以不管char型是有符号的还是无符号的,存一个 ASCII 码都没有问题,一般来说,如果用char型存 ASCII 码字符,就不必明确写是signed还是unsigned,如果用char型表示 8 位的整数,为了可移植性就必须写明是signed还是unsigned。
Implementation-defined、Unspecified 和 Undefined
在 C 标准中没有做明确规定的地方会用 Implementation-defined、Unspecified 或 Undefined 来表述,在本书中有时把这三种情况统称为“未明确定义”的。这三种情况到底有什么不同呢?
我们刚才看到一种 Implementation-defined 的情况,C 标准没有明确规定char是有符号的还是无符号的,但是要求编译器必须对此做出明确规定,并写在编译器的文档中。
而对于 Unspecified 的情况,往往有几种可选的处理方式,C 标准没有明确规定按哪种方式处理,编译器可以自己决定,并且也不必写在编译器的文档中,这样即便用同一个编译器的不同版本来编译也可能得到不同的结果,因为编译器没有在文档中明确写它会怎么处理,那么不同版本的编译器就可以选择不同的处理方式,比如下一章我们会讲到一个函数调用的各个实参表达式按什么顺序求值是 Unspecified 的。
Undefined 的情况则是完全不确定的,C 标准没规定怎么处理,编译器很可能也没规定,甚至也没做出错处理,有很多 Undefined 的情况编译器是检查不出来的,最终会导致运行时错误,比如数组访问越界就是 Undefined 的。
初学者看到这些规则通常会很不舒服,觉得这不是在学编程而是在啃法律条文,结果越学越泄气。是的,C 语言并不像一个数学定理那么完美,现实世界里的东西总是不够完美的。但还好啦,C 程序员已经很幸福了,只要严格遵照 C 标准来写代码,不要去触碰那些阴暗角落,写出来的代码就有很好的可移植性。想想那些可怜的 JavaScript 程序员吧,他们甚至连一个可以遵照的标准都没有,一个浏览器一个样,甚至同一个浏览器的不同版本也差别很大,程序员不得不为每一种浏览器的每一个版本分别写不同的代码。
除了char型之外,整型还包括short int(或者简写为short)、int、long int(或者简写为long)、long long int(或者简写为long long)等几种 [1],这些类型都可以加上signed或unsigned关键字表示有符号或无符号数。其实,对于有符号数在计算机中的表示是 Sign and Magnitude、1's Complement 还是 2's Complement,C 标准也没有明确规定,也是 Implementation Defined。大多数体系结构都采用 2's Complement 表示法,x86 平台也是如此,从现在开始我们只讨论 2's Complement 表示法的情况。还有一点要注意,除了char型以外的这些类型如果不明确写signed或unsigned关键字都表示signed,这一点是 C 标准明确规定的,不是 Implementation Defined。
除了char型在 C 标准中明确规定占一个字节之外,其它整型占几个字节都是 Implementation Defined。通常的编译器实现遵守 ILP32 或 LP64 规范,如下表所示。
表 15.1. ILP32 和 LP64
| 类型 | ILP32(位数) | LP64(位数) |
|---|---|---|
| char | 8 | 8 |
| short | 16 | 16 |
| int | 32 | 32 |
| long | 32 | 64 |
| long long | 64 | 64 |
| 指针 | 32 | 64 |
ILP32 这个缩写的意思是int(I)、long(L)和指针(P)类型都占 32 位,通常 32 位计算机的 C 编译器采用这种规范,x86 平台的gcc也是如此。LP64 是指long(L)和指针占 64 位,通常 64 位计算机的 C 编译器采用这种规范。指针类型的长度总是和计算机的位数一致,至于什么是计算机的位数,指针又是一种什么样的类型,我们到第 17 章 计算机体系结构基础和第 23 章 指针再分别详细解释。从现在开始本书做以下约定:在以后的陈述中,缺省平台是 x86/Linux/gcc,遵循 ILP32,并且char是有符号的,我不会每次都加以说明,但说到其它平台时我会明确指出是什么平台。
在第 2 节“常量”讲过 C 语言的常量有整数常量、字符常量、枚举常量和浮点数常量四种,其实字符常量和枚举常量的类型都是int型,因此前三种常量的类型都属于整型。整数常量有很多种,不全是int型的,下面我们详细讨论整数常量。
以前我们只用到十进制的整数常量,其实在 C 语言中也可以用八进制和十六进制的整数常量 [2]。八进制整数常量以 0 开头,后面的数字只能是 0~7,例如 022,因此十进制的整数常量就不能以 0 开头了,否则无法和八进制区分。十六进制整数常量以 0x 或 0X 开头,后面的数字可以是 0~9、a~f 和 A~F。在第 6 节“字符类型与字符编码”讲过一种转义序列,以\或\x 加八进制或十六进制数字表示,这种表示方式相当于把八进制和十六进制整数常量开头的 0 替换成\了。
整数常量还可以在末尾加 u 或 U 表示“unsigned”,加 l 或 L 表示“long”,加 ll 或 LL 表示“long long”,例如 0x1234U,98765ULL 等。但事实上 u、l、ll 这几种后缀和上面讲的unsigned、long、long long关键字并不是一一对应的。这个对应关系比较复杂,准确的描述如下表所示(出自 C99 条款 6.4.4.1)。
表 15.2. 整数常量的类型
| 后缀 | 十进制常量 | 八进制或十六进制常量 |
|---|---|---|
| 无 | int long int long long int | int unsigned int long int unsigned long int long long int unsigned long long int |
| u 或 U | unsigned int unsigned long int unsigned long long int | unsigned int unsigned long int unsigned long long int |
| l 或 L | long int long long int | long int unsigned long int long long int unsigned long long int |
| 既有 u 或 U,又有 l 或 L | unsigned long int unsigned long long int | unsigned long int unsigned long long int |
| ll 或 LL | long long int | long long int unsigned long long int |
| 既有 u 或 U,又有 ll 或 LL | unsigned long long int | unsigned long long int |
给定一个整数常量,比如 1234U,那么它应该属于“u 或 U”这一行的“十进制常量”这一列,这个表格单元中列了三种类型unsigned int、unsigned long int、unsigned long long int,从上到下找出第一个足够长的类型可以表示 1234 这个数,那么它就是这个整数常量的类型,如果int是 32 位的那么unsigned int就可以表示。
再比如 0xffff0000,应该属于第一行“无”的第二列“八进制或十六进制常量”,这一列有六种类型int、unsigned int、long int、unsigned long int、long long int、unsigned long long int,第一个类型int表示不了 0xffff0000 这么大的数,我们写这个十六进制常量是要表示一个正数,而它的 MSB(第 31 位)是 1,如果按有符号int类型来解释就成了负数了,第二个类型unsigned int可以表示这个数,所以这个十六进制常量的类型应该算unsigned int。所以请注意,0x7fffffff 和 0xffff0000 这两个常量虽然看起来差不多,但前者是int型,而后者是unsigned int型。
讲一个有意思的问题。我们知道 x86 平台上int的取值范围是 -2147483648~2147483647,那么用printf("%d\n", -2147483648);打印int类型的下界有没有问题呢?如果用gcc main.c -std=c99编译会有警告信息:warning: format ‘%d’ expects type ‘int’, but argument 2 has type ‘long long int’。这是因为,虽然 -2147483648 这个数值能够用int型表示,但在 C 语言中却没法写出对应这个数值的int型常量,C 编译器会把它当成一个整数常量 2147483648 和一个负号运算符组成的表达式,而整数常量 2147483648 已经超过了int型的取值范围,在 x86 平台上int和long的取值范围相同,所以这个常量也超过了long型的取值范围,根据上表第一行“无”的第一列十进制常量,这个整数常量应该算long long型的,前面再加个负号组成的表达式仍然是long long型,而printf的%d转换说明要求后面的参数是int型,所以编译器报警告。之所以编译命令要加-std=c99选项是因为 C99 以前对于整数常量的类型规定和上表有一些出入,即使不加这个选项也会报警告,但警告信息不准确,读者可以试试。如果改成printf("%d\n", -2147483647-1);编译器就不会报警告了,-号运算符的两个操作数 -2147483647 和 1 都是int型,计算结果也应该是int型,并且它的值也没有超出int型的取值范围;或者改成printf("%lld\n", -2147483648);也可以,转换说明%lld告诉printf后面的参数是long long型,有些转换说明格式目前还没讲到,详见第 2.9 节“格式化 I/O 函数”。
怎么样,整数常量没有你原来想的那么简单吧。再看一个不简单的问题。long long i = 1234567890 * 1234567890;编译时会有警告信息:warning: integer overflow in expression。1234567890 是int型,两个int型相乘的表达式仍然是int型,而乘积已经超过int型的取值范围了,因此提示计算结果溢出。如果改成long long i = 1234567890LL * 1234567890;,其中一个常量是long long型,另一个常量也会先转换成long long型再做乘法运算,两数相乘的表达式也是long long型,编译器就不会报警告了。有关类型转换的规则将在第 3 节“类型转换”详细介绍。
我们在 第 4 节“结构体和联合体” 还要介绍一种特殊的整型--Bit-field。 ↩︎
有些编译器(比如 gcc)也支持二进制的整数常量,以 0b 或 0B 开头,比如 0b0001111,但二进制的整数常量从未进入 C 标准,只是某些编译器的扩展,所以不建议使用,由于二进制和八进制、十六进制的对应关系非常明显,用八进制或十六进制常量完全可以代替使用二进制常量。 ↩︎