Skip to main content

整数

程序员为什么没有办法分清万圣节和圣诞节?

整数类型

毫无疑问,整数是编程中使用最多的数据类型。C 语言提供了丰富的整数类型,如下表所示:

有符号无符号宽度(比特)
signed charunsigned char8
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 charunsigned 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很好理解,就是最长的整型,和指针对应的整型。leastfast是干嘛的?答案是,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;

你可能以为b42,实际上只有34

当然,比这更难差觉的情况,出现在连 0 都没有的时候,参见这里

程序员为什么没有办法分清万圣节和圣诞节?因为 Oct 31 = Dec 25 啊。