章节目录

无符号整数,以及为什么要避免使用它们

本节阅读量:

无符号整数

在上一课中,我们讨论了有符号整数,这是一组可以保存正整数和负整数(包括0)的类型。

C++还支持无符号整数。无符号整数是只能保存非负整数的类型。


定义无符号整数

要定义无符号整数,我们使用unsigned关键字。按照惯例,它放在类型之前:

1
2
3
4
unsigned short us;
unsigned int ui;
unsigned long ul;
unsigned long long ull;

无符号整数范围

1字节无符号整数的范围为0到255。将其与1字节有符号整数的范围对比,两者都可以存储256个不同的值,但有符号整数将范围的一半分配给了负数,因此无符号整数能存储的正数最大值是有符号整数的两倍。

下表显示了无符号整数的取值范围:

类型大小 取值范围
8位 0 to 255
16位 0 to 65,535
32位 0 to 4,294,967,295
64位 0 to 18,446,744,073,709,551,615

n位无符号变量的范围为0到(2^n)- 1。

当不需要表示负数时,无符号整数非常适用于网络传输或内存受限的系统,因为它可以在不占用额外内存的情况下表示更大的正数。


无符号整数溢出

如果我们试图将数字280(需要9位来表示)存储在1字节(8位)的无符号整数中,会发生什么?答案是溢出。

如果无符号值超出范围,则会将其对(该类型最大值加1)取模,只保留余数。

数字280超出了1字节0到255的范围。该类型最大值加1等于256。因此,将280除以256,得到余数24。最终存储的就是24。

换一种方式理解:超过该类型最大值的数字会”从头开始计数”(有时称为”取模环绕”)。255在1字节整数的范围内,所以255没问题。但256超出了范围,因此它环绕回到0。257环绕到1。280环绕到24。

让我们看一个使用2字节无符号整数的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>

int main()
{
    unsigned short x{ 65535 }; // 最大的16位无符号整数
    std::cout << "x was: " << x << '\n';

    x = 65536; // 65536 溢出, 所以重新取值
    std::cout << "x is now: " << x << '\n';

    x = 65537; // 65537 溢出, 所以重新取值
    std::cout << "x is now: " << x << '\n';

    return 0;
}

你认为这个程序的结果会是什么?

(注意:如果尝试编译上述程序,编译器可能会发出关于溢出或截断的警告——你需要禁用”将警告视为错误”选项才能运行该程序)

1
2
3
x was: 65535
x is now: 0
x is now: 1

环绕当然也可以向反方向发生。如果一个2字节无符号整数的值为0,那么-1会环绕到取值范围的最大值,即65535;-2环绕到65534,以此类推。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>

int main()
{
    unsigned short x{ 0 }; // 最小的16位无符号整数
    std::cout << "x was: " << x << '\n';

    x = -1; // -1 溢出, 所以重新取值
    std::cout << "x is now: " << x << '\n';

    x = -2; // -2 溢出, 所以重新取值
    std::cout << "x is now: " << x << '\n';

    return 0;
}

上面的代码可能会在某些编译器中触发警告,因为编译器检测到整数字面量超出了给定类型的范围。如果仍想编译代码,请暂时禁用”将警告视为错误”选项。


关于无符号数的争论

许多开发人员(以及一些大型开发公司,如Google)认为,开发人员通常应该避免使用无符号整数。

这主要是由于两种容易引发问题的行为。

首先,对于有符号值来说,意外溢出的概率较低,因为其最大值和最小值都离0很远。而对于无符号数字,由于范围底部就是0——这恰好是我们大多数值所在的区域——因此向下溢出(低于0)非常容易发生。

考虑两个无符号数相减的情况,例如2和3:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>

// int 是 4 字节
int main()
{
	unsigned int x{ 2 };
	unsigned int y{ 3 };

	std::cout << x - y << '\n'; // 2 - 3 = 4294967295

	return 0;
}

我们都知道2-3等于-1,但-1无法用无符号整数表示,因此发生了溢出,得到如下结果:

1
4294967295

当无符号整数被反复递减1(使用–运算符)并降到0以下时,还会发生另一种常见的意外环绕。在学习循环时,你将看到这样的例子。

第二,更隐蔽的问题是,混合使用有符号和无符号整数时可能会导致意外的行为。在C++中,如果一个数学运算(例如算术或比较)中同时包含有符号整数和无符号整数,有符号整数通常会被隐式转换为无符号整数。因此,运算结果也将是无符号的。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>

// int 是 4 字节
int main()
{
	unsigned int u{ 2 };
	signed int s{ 3 };

	std::cout << u - s << '\n'; // 2 - 3 = 4294967295

	return 0;
}

在这个例子中,如果u是有符号整数,就会产生正确的结果。但由于u是无符号整数,s被转换为无符号类型,而结果(-1)无法用无符号整数表示,因此发生了溢出,得到了错误的答案。

下面是另一个示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>

// int 是 4 字节
int main()
{
    signed int s { -1 };
    unsigned int u { 1 };

    if (s < u) // -1 被转换为 4294967295,  4294967295 < 1 结果 false
        std::cout << "-1 is less than 1\n";
    else
        std::cout << "1 is less than -1\n"; // 这条语句执行

    return 0;
}

该程序格式正确、可以编译,而且从表面上看逻辑似乎也正确。但它却打印了错误的结果。在这种情况下,编译器应该会警告你存在有符号/无符号不匹配的问题,但编译器也会对其他不会出问题的情况生成相同的警告(例如两个数都为正数时),这使得在真正出现问题时很难被发现。

此外,还有其他难以检测的问题场景。考虑以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <iostream>

// int 是 4 字节
void doSomething(unsigned int x)
{
    // 做一些其它操作

    std::cout << "x is " << x << '\n';
}

int main()
{
    doSomething(-1);

    return 0;
}

doSomething() 的作者期望调用者只传入正数。但调用者传入了-1——这显然是一个错误。在这种情况下会发生什么呢?

有符号的参数-1会被隐式转换为无符号类型。由于-1不在无符号数的范围内,它会环绕到4294967295。然后你的程序就会出现异常行为。

更令人担忧的是,这种情况很难防范。除非你已经将编译器配置为积极地生成有符号/无符号转换警告(你确实应该这样做),否则编译器可能根本不会给出任何提示。

以上这些问题在实际开发中经常遇到,它们会产生意外的行为,而且很难排查,即使使用专门检测此类问题的自动化工具也是如此。

鉴于以上原因,我们给出一个可能有些争议的最佳实践建议:除非在特定场景下,否则应避免使用无符号类型。


那么什么时候应该使用无符号数字呢?

在C++中,仍然有一些情况下可以/需要使用无符号数字。

首先,在进行位操作时,无符号数字是首选。当需要利用良好定义的值环绕行为时,它们也很有用(这在加密和随机数生成等算法中很常见)。

其次,在某些场景下使用无符号数仍然不可避免,主要与数组索引有关。我们将在数组和数组索引相关的课程中详细讨论这一点。

另外需要注意,如果你是为嵌入式系统(例如Arduino)或其他处理器/内存受限的环境开发程序,出于性能考虑,使用无符号数字更为常见和可接受(在某些情况下甚至不可避免)。


4.3 有符号整数

上一节

4.5 固定宽度整数和size_t

下一节