整数
程序员为什么没有办法分清万圣节和圣诞节?
整数类型
毫无疑问,整数是编程中使用最多的数据类型。C 语言提供了丰富的整数类型,如下表所示:
有符号 | 无符号 | 宽度(比特) |
---|---|---|
signed char | unsigned char | 8 |
signed short int signed short short int short | unsigned short int unsigned short | 至少 16 |
signed int signed int | unsigned int unsigned | 至少 16 |
signed long int signed long long int long | unsigned long int unsigned long | 至少 32 |
signed long long int signed long long long long int long long | unsigned long long int unsigned long long | 至少 64 |
同一个单元格中的类型是等价的。任何一个由多个关键字组成的类型,你都可以任意打乱关键字的顺序。
下面围绕着我们用的最多的整型,来看看我们会遇到哪些问题。
可移植性缺陷
整数的宽度
所谓整数的宽度(width)是指整数的位数。
上表中的“至少”二字意味着我们不能保证整数的宽度,进而造成一些意外的溢出。不过 C 语言至少保证1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)
。
C 的宏(C++ 的关键字)wchar_t
,作为平台相关的宽字符,在 Windows 下是两字节,而在 *nix 下是四字节。因此依赖于wchar_t
宽度的也是不可靠的。
整数的符号性
所谓整数的符号性(signedness)是指整数是否可以表示负数。绝大多数整数类型默认都是signed
的。
char
等价于signed char
和unsigned char
中的 任意一个,是由编译器决定的,而不是默认signed
。因此依赖于char
的符号性是不可靠的。但是char
有一样是非常可靠的,那就是它的宽度——sizeof(char) == 1
恒成立!
C99 还引入了_Bool
类型。它只有一个位,表示 0 或 1,即假或真。此外,_Bool
是无符号的。如果只有一位还是有符号的会怎么样?那就只能表示 0 或 -1 咯。(想一想,为什么?)
有符号数的实现
对于现代人来说,使用补码(two's complement)表示有符号数简直是天经地义。然而,在 C23 和 C++20 之前,标准并没有废除对其它表示方法的支持。也就是是说,不排除遇到极为罕见的反码(one's complement,即取相反数时只取反,不加一)乃至是原码(sign-and-magnitude,即相反数即为原数字仅最高位反转后的数字)的可能性。这就意味着,一切涉及到有符号数实现的操作,例如在有符号数整数溢出时的行为,都是实现定义的!
当然这似乎太钻牛角尖了,几乎所有 C 编译器和所有的 C++ 编译器都使用补码表示有符号数,考虑这个问题多少有点杞人忧天。如果你真的遇上了其它的情况,倒也不能不说是一件幸事。
除法与右移
这里提到左移和乘法转换的时候需要注意优先级问题。其实右移 和除法也是,但是还有更多的因素需要考虑。
printf("%d\n", -1 / 2);
printf("%d\n", -1 >> 1);
C99 要求:商向 0 取整。所以第一行输出 0。
C++20 要求:有符号数右移时,最高位补符号位(即,算术右移,造成的结果是向负无穷取整)。所以第二行输出 -1。
也就是说,在被除数是负数的情况下,除法与右移的舍入方向是不同的。
你可能注意到我在要求前面加的标准了,没错,尽管大多数编译器都遵循这两个惯例,但是在对应的标准之前,它是不被保证的。
另外要注意不要与个别语言的例外行为混淆,如 Python 的-1 // 2
结果为-1
(Python 中//
才是整除)。
而右移的这个问题,我们可以进一步讨论一下。对于有符号数而言,我们一般把最高位补 0 的右移成为逻辑右移,最高位补符号位的右移成为算术右移。C 语言和 C++20 之前都没有对此作明确的规定,也就是说任何一种右移都可能,因此最好不要依赖于这一点。推而广之的,我建议在进行位运算的时候,尽可能的使用无符号整数,来确保运算不会因为其他算术运算对符号位的特殊处理而收到干扰。关于这一点,你会在这里中了解更多。
求余还是求模
根据前面除法的定义,我们已经可以知道,%
是求余而不是求模(与之对应的,Python 的%
就是求模)。
注意,C 语言的%
的操作数只能是整数。如果想用浮点数,可以使用<math.h>
提供的fmod
(对,虽然是求余,但是名字是fmod
,而remainder
既不是求余也不是求模)。
于是,我们已经合理定义了下面这些运算:
3 / 2 == 1 && 3 % 2 == 1;
-3 / 2 == -1 && -3 % 2 == -1;
3 / -2 == -1 && 3 % -2 == 1;
-3 / -2 == 1 && -3 % -2 == -1;
它们都符合商乘除数加余数等于被除数(基本的数学要求)和商向零取整(C99 要求)的要求。
你可以思考一下,如果把第二个条件换成别的什么(例如余数大于零),结果及其意义会是什么。
定长整型
C99 引入了<stdint.h>
和<inttypes.h>
,来解决整数不定长的问题。但它们也带来了一些问题。
<stdint.h>
中定义的定长整型基本的格式是:
[u]int[N|_leastN|_fastN|max|ptr]_t
其中 N 替换为 8, 16, 32, 64 中的一个。
u 的前缀表示无符号数。由于是宏定义,所以下面这种写法大概率也是合法的,但不是良好的形式:
unsigned int64_t x; // why not uint64_t?
可能令你有些意外的是,标准不保证实现给出[u]intN_t
的定义。也就是说,从整型的最初的设计,到定长整型的定义,自始至终没有要求编译器真正意义上给出某个定长的整型。这种过分的灵活性是双刃剑。好在大部分的实现还是会给出定长的宏定义。标准同样不保证实现给出[u]intptr_t
的定义。
[u]intmax_t
和[u]intptr_t
很好理解,就是最长的整型,和指针对应的整型。least
和fast
是干嘛的?答案是,least
是我们最熟悉的话术:至少 N 比特的整型(这也解释了为什么它可以保证被提供);而fast
则是略令人费解的:最快的 N 比特的整型。例如一种典型的做法是把int_fast16_t
定义为int
,因为实际上对short
的计算都先提升到了int
,计算完毕之后又转换回了short
(参见这里)。遗憾的是,这样做虽然是有一定道理的,但同样是不能保证的。正因为这里对“最快”的描述非常模糊,我很认同 gcc 对此的理解:Not actually guaranteed to be fastest for all purposes。
假设有这样一段代码:
int a;
long long b;
puts("Please input a 32-bits int and a 64-bits int");
scanf("%d%lld", &a, &b);
printf("%d, %lld", a, b);
你刚刚学习了定长整型,为了增加可移植性,你把代码改成了这样:
int32_t a;
int64_t b;
puts("Please input a 32-bits int and a 64-bits int");
scanf("%d%lld", &a, &b);
printf("%d, %lld", a, b);
一旦用上定长整型,你仿佛就找回一种可移植性上身的感觉,但这恐怕有些得意忘形了。"%d"
对应的是int32_t
吗?很明显应该是int
。"%lld"
同理。你这代码不仅没有实现可移植性目标,反而变得不可移植了!(原来那个代码反而是相对而言可移植性更高的版本)
为了解决这个问题,<inttypes.h>
中定义了一些宏,帮你写出真正可移植的代码:
int32_t a;
int64_t b;
puts("Please input a 32-bits int and a 64-bits int");
scanf("%"SCNd32"%"SCNd64, &a, &b);
printf("%"PRId32", %"PRId64, a, b);
我知道你想说什么,这也太丑了吧。没办法,这就是可移植性的代价。注意这里利用的是字符串字面量在编译时自动连接的特性。
此外,这段代码的可移植性仍然不够高——它不能移植到 C++ 中。字符串末尾的标识符会被 C++ 解读为用户定义字面量,所以最终的可移植版本如下:
int32_t a;
int64_t b;
puts("Please input a 32-bits int and a 64-bits int");
scanf("%" SCNd32"%" SCNd64, &a, &b);
printf("%" PRId32", %" PRId64, a, b);
假如有一天,你想把a
也改成int64_t
。请检查一下,这段代码总共需要修改几处?
(答案:四处,别忘了用来提示输入的字符串!)
位段
位段(bitfield)可以精确地指定一个整型成员在结构体中占用的比特数。
底层类型
位段可以是下面四种类型之一
unsigned
,无符号位段,例如unsigned b:3;
的范围是0..7
signed
,无符号位段,例如signed b:3;
的范围是-4..3
int
,是的,在位段里面,int
也变成跟char
一样,实现定义有无符号,所以请不要在 C 语言中使用int
作为位段的类型。C++ 中规定与signed
一致。_Bool
(C99),无符号位段,只允许_Bool b:1;
,与它的隐式转换遵循布尔转换的机制(参见这里)
其它整型实现也可以选择支持,位段的位数不能超过底层类型的宽度(C++ 中取消了这个限制,但额外的比特仅作填充用,参见后文)
struct S {
unsigned b : 3;
} s = {7};
++s.b; // 无符号溢出,s.b 变为 0
位段的布局
下面假设sizeof(unsigned) == 4
。
一个底层整数类型就是位段的分配单位,相邻的段位很可能会被打包到同一个段位之中。如果段位没有名字的话,相应的比特仍会被分配,但不能被使用,成为填充(padding)比特。而宽度为 0 的段位会打断一个分配单位,直接进入下一个分配单位。
struct S1 {
// 通常占用 4 字节:
// 5 bits: b1
// 11 bits: 未使用
// 6 bits: b2
// 2 bits: b3
// 8 bits: 未使用
unsigned b1 : 5;
unsigned : 11;
unsigned b2 : 6;
unsigned b3 : 2;
};
struct S2 {
// 通常占用 8 字节:
// 5 bits: b1
// 27 bits: 未使用
// 6 bits: b2
// 15 bits: b3
// 11 bits: 未使用
unsigned b1 : 5;
unsigned : 0; // 开一个新的 unsigned
unsigned b2 : 6;
unsigned b3 : 15;
};
此外,你还需要注意,
对于位段,你不可以:
- 取地址
- 取大小
- 取对齐要求
对于位段,下面这些行为是实现定义的:
-
单个位段能否跨越分配单位
-
单个分配单位内的多个位段的顺序
可以发现,位段真正在内存中的布局,是非常依赖于实现的。因此,如果要写可移植性较高的代码,我们应该减少对于位段的内存布局的依赖,而是着眼于利用位段抽象的功能——通过把较少的比特紧凑的安排来减少内存占用——这个功能。
八进制常量
不要为了对齐在整数开头补 0,比如
int a = 134;
int b = 042;
你可能以为b
是42
,实际上只有34
。
当然,比这更难差觉的情况,出现在连 0 都没有的时候,参见这里。
程序员为什么没有办法分清万圣节和圣诞节?因为 Oct 31 = Dec 25 啊。