HOWTO · C

C 语言中无符号整数和有符号整数的区别

下面的文章将探讨 C 编程语言中有符号和无符号 int 之间的区别。

本页内容

signed int 可以表示负数和正数,unsigned int 则表示非负数。在 C 中,int 默认是有符号类型;需要有符号整数时写 int,需要无符号整数时写 unsigned int。应根据程序需要处理的数值和运算方式选择类型。这一区别看似很小 — 一种类型允许负号,另一种不允许 — 但它同时改变了取值范围、到达范围边界时会发生什么,以及两种类型在同一个表达式中混合时的行为。

C 语言中 signed 与 unsigned int 的区别

这两种类型都是整数类型,但取值范围不同。C 标准保证 int 至少可以表示 -32767 到 32767,unsigned int 至少可以表示 0 到 65535。这些是最低保证,并非所有系统上的确切范围。包含 limits.h,并查看 INT_MIN、INT_MAX 和 UINT_MAX,即可确定当前编译实现所支持的范围。

类型 可表示的值 可移植的最低范围
int 负数和非负数 -32767 到 32767
unsigned int 非负数 0 到 65535

两种类型通常占用相同的存储大小,因此在两者之间选择并不会获得更多的总空间;它决定的是这些空间用在哪里。有符号类型把它的值分配在负数和正数之间,无符号类型则把全部范围用于非负数,因此在同一实现上,它的最大值大约是有符号类型的两倍。在本文示例所用的 64 位 Linux 系统上,两种类型都占四个字节,打印出这些限值,得到的是熟悉的 32 位范围:

#include <limits.h>
#include <stdio.h>

int main(void) {
    printf("sizeof(int) = %zu\n", sizeof(int));
    printf("sizeof(unsigned int) = %zu\n", sizeof(unsigned int));
    printf("int range: %d to %d\n", INT_MIN, INT_MAX);
    printf("unsigned int range: 0 to %u\n", UINT_MAX);
    return 0;
}
sizeof(int) = 4
sizeof(unsigned int) = 4
int range: -2147483648 to 2147483647
unsigned int range: 0 to 4294967295

应把这些确切数字看作该实现的性质,而不是语言的性质。必须在任何平台上运行的代码,应当与 INT_MIN、INT_MAX 和 UINT_MAX 比较,而不是把 32 位的限值写死在代码中。

取值范围与转换

实际取值范围和存储大小取决于具体实现。sizeof(int) 和 sizeof(unsigned int) 不一定是四字节;C 也不保证 int 有 32 位。不能笼统地说有符号整数会把最左侧的位留作符号位;具体表示方式取决于实现。

负数转换为无符号类型时,结果与该数对 UINT_MAX + 1 取模后的值同余。因此,无符号对象中保存的是非负值;它不会保留负数供之后读取时还原。无符号算术也会按 UINT_MAX + 1 取模回绕。下面的示例以三种方式应用这条规则 — 转换 -1、减到零以下、加过最大值:

#include <limits.h>
#include <stdio.h>

int main(void) {
    unsigned int converted = -1;
    unsigned int wrapped_down = 0u - 1u;
    unsigned int wrapped_up = UINT_MAX + 1u;

    printf("converted -1: %u\n", converted);
    printf("0u - 1u: %u\n", wrapped_down);
    printf("UINT_MAX + 1u: %u\n", wrapped_up);
    return 0;
}
converted -1: 4294967295
0u - 1u: 4294967295
UINT_MAX + 1u: 0

在这个实现上,-1 变成了 4294967295,恰好就是 UINT_MAX;在任何实现上,结果都是 UINT_MAX 本身,因为在模运算中,-1 永远位于零的下方一步。零减一会回绕到同一个最大值,而不是产生 -1;最大值加一则回绕回零。这些运算没有一个是错误:回绕正是无符号算术的定义行为。

下面的完整示例对 int 使用 %d,对 unsigned int 使用 %u。示例已成功运行;下方输出来自该次运行。

#include <stdio.h>

int main(void) {
    int temperature_change = -7;
    unsigned int item_count = 42;

    printf("Temperature change: %d\n", temperature_change);
    printf("Item count: %u\n", item_count);
    return 0;
}
Temperature change: -7
Item count: 42

可以用上面的转换规则推断负数赋给无符号类型后的结果;确切数值取决于 UINT_MAX。将 unsigned int 传给 printf 时,如果使用 %d 而不是 %u,就会造成格式不匹配并引发未定义行为,因此不能依赖其打印结果。

溢出:对无符号有定义,对有符号未定义

上面的回绕在有符号类型中没有对应行为。有符号整数溢出 — 计算出高于 INT_MAX 或低于 INT_MIN 的结果 — 在 C 中是未定义行为,而不是有保证的回绕。编译器在假定它永不发生的前提下进行优化,因此像检查 x + 1 是否小于 x 这样的防护,无法在事后可靠地检测有符号溢出。如果某个计算可能接近限值,应在计算之前检查,例如在把 y 加到 x 之前测试 x > INT_MAX - y,或者使用更宽的类型。这种不对称是普通算术优先使用有符号类型的一个实际理由:无符号值上的错误会悄悄回绕成一个错误但看似有效的数字,而有符号代码则在真正越过限值之前保持你预期的值。

在同一个表达式中混合有符号与无符号

最令人惊讶的区别出现在两种类型在同一个表达式中相遇时。按照通常算术转换 (usual arithmetic conversions),当同一等级的 int 与 unsigned int 组合时,int 操作数会在运算之前转换为 unsigned int。因此,即使一侧最初是负数,比较或运算也会按无符号进行:

#include <stdio.h>

int main(void) {
    printf("-1 < 1: %d\n", -1 < 1);
    printf("-1 < 1u: %d\n", -1 < 1u);
    printf("-1 == 4294967295u: %d\n", -1 == 4294967295u);
    return 0;
}
-1 < 1: 1
-1 < 1u: 0
-1 == 4294967295u: 1

按数学来读,-1 < 1 为真,打印出 1。加上后缀 u 后,答案变成 0:-1 先被转换为 4294967295,而这个值并不小于 1。出于同一原因,-1 与最大的无符号值相等。GCC 恰好会用 -Wsign-compare 对这种模式发出警告,这个警告值得长期开启。算术遵循同一规则,因此一个负的调整量可能变成一个巨大的正数结果,而不是一个小的负数结果:

#include <stdio.h>

int main(void) {
    unsigned int count = 10u;
    int delta = -20;
    int signed_total = 10 + delta;

    printf("unsigned total: %u\n", count + delta);
    printf("signed total: %d\n", signed_total);
    return 0;
}
unsigned total: 4294967286
signed total: -10

这里无符号类型并没有发生溢出;转换本身就产生了 4294967286。只要一个值可能变为负数 — 两个计数之差、一个余额、像 -1 这样的错误哨兵值 — 就应把它保持在有符号类型中,直到能够保证它非负的那一点为止。

sizeof 陷阱与无符号循环计数器

混合也发生在看不到任何强制转换或后缀的地方。sizeof 运算符产生 size_t,一个无符号类型,因此把有符号变量与 sizeof 的结果比较会触发相同的转换:

#include <stdio.h>

int main(void) {
    int index = -1;

    printf("sizeof(int): %zu\n", sizeof(int));
    printf("index < (int)sizeof(int): %d\n", index < (int)sizeof(int));
    printf("index < sizeof(int): %d\n", index < sizeof(int));
    return 0;
}
sizeof(int): 4
index < (int)sizeof(int): 1
index < sizeof(int): 0

最后一个比较本应为真 — -1 确实小于 4 — 但无符号比较给出了相反的结论。当确定 sizeof 的结果值能装得下时,把它转换为 int,就能恢复预期的答案。一个相关的陷阱是写成 for (unsigned int i = count - 1; i >= 0; --i) 的递减循环:无符号值永远大于或等于零,所以条件永不为假。当计数器到达零并递减时,它会回绕到 UINT_MAX — 与转换示例为 0u - 1u 打印出的回绕相同 — 循环便带着一个巨大的索引继续运行。用有符号类型递减计数,或者使用惯用法 for (unsigned int i = count; i-- > 0; ),它在递减之前先测试旧值。

如何选择 signed 与 unsigned int

如果负数有意义,例如某个变化量可能高于或低于零,就选择 int。如果取值范围仅包含非负数,而且适合使用模运算,例如计数值的运算应在类型最大值处回绕,就选择 unsigned int。unsigned 并不是安全存储更大正数的通用办法:它的范围取决于具体实现,而有符号与无符号值之间的转换或混合运算也可能改变比较和运算结果。应选择取值范围和运算行为都符合程序实际需求的类型。

在实践中,这会得出几个可靠的默认选择。对一般算术、差值和循环计数器使用有符号类型,即使是那些以非负数开始的量也一样,因为中间结果常常需要降到零以下。把无符号类型留给位模式和掩码、回绕是有意为之的值(如哈希值和硬件寄存器),以及与已经使用无符号类型或 size_t 的接口相匹配的场合。不要仅仅为了表明一个值永远不该为负就选择无符号类型:这并不能阻止该值由减法产生,只是把它改名为一个非常大的正数。最后,如果真正的问题是范围,应转向 long long 这样更宽的类型,而不是依赖无符号类型看似提供的额外空间。

围绕这些类型的大多数困惑来自三个误解。第一个误解是,unsigned int 不过是正数范围更大的 int;事实上,这两种类型的区别不仅在范围,还在溢出行为、转换和比较上。第二个误解是,把负数存入无符号变量,会以某种方式记住这个负数;如上面的示例所示,转换会立即把它替换掉。第三个误解是,无符号算术因为不可能变成负数而更安全;它会悄悄地用大的正数结果替换负数结果,这往往比它本想防止的问题更难调试。