章节目录

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

本节阅读量:

无符号整数

在上一课中,我们讨论了有符号整数,这是一组可以保存正整数和负整数(包括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位)无符号整数中,会发生什么情况?答案是溢出。

如果无符号值超出范围,则将其除以大于类型最大数的一,并仅保留余数。

数字280太大,无法容纳0到255的1字节范围。大于1的最大类型数为256。因此,我们将280除以256,得到1的余数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

下一节