4.5 — 无符号整数,以及为何要避免使用它们

无符号整数

在上一课(4.4 -- 有符号整数)中,我们学习了有符号整数,它们是一组可以存储正负整数(包括 0)的类型。

C++ 也支持无符号整数。无符号整数是只能存储非负整数的整数。

定义无符号整数

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

unsigned short us;
unsigned int ui;
unsigned long ul;
unsigned long long ull;

无符号整数范围

一个 1 字节无符号整数的范围是 0 到 255。与此相比,一个 1 字节有符号整数的范围是 -128 到 127。两者都可以存储 256 个不同的值,但有符号整数将其范围的一半用于负数,而无符号整数可以存储两倍大的正数。

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

大小/类型范围
8 位无符号0 到 255
16 位无符号0 到 65,535
32 位无符号0 到 4,294,967,295
64 位无符号0 到 18,446,744,073,709,551,615

一个 n 位无符号变量的范围是 0 到 (2n)-1。

当不需要负数时,无符号整数非常适合网络和内存较小的系统,因为无符号整数可以在不占用额外内存的情况下存储更多正数。

记住有符号和无符号的术语

新程序员有时会将有符号和无符号混淆。以下是记住区别的简单方法:为了区分负数和正数,我们使用负号。如果没有提供符号,我们假定数字是正数。因此,带有符号的整数(有符号整数)可以区分正数和负数。没有符号的整数(无符号整数)假定所有值都是正数。

无符号整数溢出

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

作者注

奇怪的是,C++ 标准明确规定“涉及无符号操作数的计算永远不会溢出”。这与普遍的编程共识相反,即整数溢出涵盖有符号和无符号用例(引用)。鉴于大多数程序员会认为这是溢出,我们将它称为溢出,尽管 C++ 标准有相反的说法。

如果无符号值超出范围,它将被该类型最大值加一后除,只保留余数。

数字 280 太大,无法放入我们 0 到 255 的 1 字节范围内。该类型最大值加一为 256。因此,我们将 280 除以 256,得到 1 余 24。存储的是余数 24。

这是思考同一件事的另一种方式。任何大于该类型可表示的最大值的数字都会简单地“回绕”(有时称为“模数回绕”)。255 在 1 字节整数的范围内,所以 255 是可以的。然而,256 超出范围,所以它回绕到值 0257 回绕到值 1280 回绕到值 24

让我们用 2 字节短整数来看看这个

#include <iostream>

int main()
{
    unsigned short x{ 65535 }; // largest 16-bit unsigned value possible
    std::cout << "x was: " << x << '\n';

    x = 65536; // 65536 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    x = 65537; // 65537 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    return 0;
}

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

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

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

也可以向另一个方向回绕。0 在 2 字节无符号整数中是可表示的,所以没问题。-1 不可表示,所以它回绕到范围的顶部,产生值 65535。-2 回绕到 65534。依此类推。

#include <iostream>

int main()
{
    unsigned short x{ 0 }; // smallest 2-byte unsigned value possible
    std::cout << "x was: " << x << '\n';

    x = -1; // -1 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    x = -2; // -2 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    return 0;
}
x was: 0
x is now: 65535
x is now: 65534

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

题外话…

许多著名的电子游戏漏洞都是由于无符号整数的回绕行为而发生的。在街机游戏《大金刚》中,由于溢出漏洞导致用户没有足够的奖励时间来完成关卡,因此无法通过第 22 关。

在 PC 游戏《文明》中,甘地以经常是第一个使用核武器而闻名,这似乎与他预期的被动本性相悖。玩家有一个理论,即甘地的攻击性设置最初设置为 1,但如果他选择民主政府,他会得到一个 -2 的攻击性修正(将其当前的攻击性值降低 2)。这将导致他的攻击性溢出到 255,使他具有最大攻击性!然而,最近 Sid Meier(游戏作者)澄清说事实并非如此。

关于无符号数字的争议

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

这主要是因为两种可能导致问题的行为。

首先,对于有符号值,意外溢出范围的顶部或底部需要一些工作,因为这些值远离 0。对于无符号数,溢出范围底部要容易得多,因为范围底部是 0,这接近我们大多数值的位置。

考虑两个无符号数(例如 2 和 3)的减法

#include <iostream>

// assume int is 4 bytes
int main()
{
	unsigned int x{ 2 };
	unsigned int y{ 3 };

	std::cout << x - y << '\n'; // prints 4294967295 (incorrect!)

	return 0;
}

你和我知道 2 - 3-1,但 -1 不能表示为无符号整数,所以我们得到溢出和以下结果

4294967295

另一个常见的意外回绕发生在无符号整数重复递减 1,直到它尝试递减到一个负数。当引入循环时,你将看到一个示例。

其次,更阴险的是,当你混合使用有符号和无符号整数时,可能会导致意外行为。在 C++ 中,如果数学操作(例如算术或比较)有一个有符号整数和一个无符号整数,则有符号整数通常会转换为无符号整数。因此,结果将是无符号的。例如

#include <iostream>

// assume int is 4 bytes
int main()
{
	unsigned int u{ 2 };
	signed int s{ 3 };

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

	return 0;
}

这也产生结果

4294967295

在这种情况下,如果 u 是有符号的,则会产生正确的结果。但由于 u 是无符号的(这很容易被忽略),s 被转换为无符号,结果(-1)被视为无符号值。由于 -1 不能存储在无符号值中,所以我们得到溢出和意外的答案。

这是另一个出现问题的例子

#include <iostream>

// assume int is 4 bytes
int main()
{
    signed int s { -1 };
    unsigned int u { 1 };

    if (s < u) // -1 is implicitly converted to 4294967295, and 4294967295 < 1 is false
        std::cout << "-1 is less than 1\n";
    else
        std::cout << "1 is less than -1\n"; // this statement executes

    return 0;
}

这会打印

1 is less than -1

这个程序格式良好,可以编译,并且在逻辑上看起来一致。但是它打印了错误的答案。尽管你的编译器在这种情况下应该警告你符号/无符号不匹配,但你的编译器也会对没有此问题的其他情况(例如,当两个数字都是正数时)生成相同的警告,这使得很难检测何时存在实际问题。

相关内容

我们在课程 10.5 -- 算术转换 中介绍了要求某些二元操作的两个操作数具有相同类型的转换规则。
我们将在即将到来的课程 4.10 -- if 语句介绍 中介绍 if 语句。

此外,还有其他难以检测的问题情况。考虑以下内容

#include <iostream>

// assume int is 4 bytes
void doSomething(unsigned int x)
{
    // Run some code x times

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

int main()
{
    doSomething(-1);

    return 0;
}

doSomething() 的作者期望有人只用正数调用此函数。但调用者传入了 -1 ——显然是一个错误,但无论如何还是发生了。在这种情况下会发生什么?

有符号参数 -1 被隐式转换为无符号参数。-1 不在无符号数的范围内,所以它回绕到 4294967295。然后你的程序就会失控。

更成问题的是,这很难阻止。除非你已将编译器配置为积极生成有符号/无符号转换警告(你应该这样做),否则你的编译器可能甚至不会抱怨。

所有这些问题都是常见的问题,会产生意外行为,而且即使使用旨在检测问题情况的自动化工具也很难找到。

鉴于上述情况,我们将倡导的有些争议的最佳实践是,除了特定情况外,避免使用无符号类型。

最佳实践

对于存储数量(即使是非负数量)和数学运算,优先使用有符号数而不是无符号数。避免混合使用有符号和无符号数。

相关内容

支持上述建议的补充材料(也涵盖对一些常见反驳的驳斥)

  1. 交互式 C++ 面板(参见 9:48-13:08、41:06-45:26 和 1:02:50-1:03:15)
  2. 下标和大小应该是有符号的(来自 C++ 的创建者 Bjarne Stroustrup)
  3. 来自 libtorrent 博客的无符号整数

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

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

首先,在处理位操作时(在 O 章中介绍——这是一个大写字母“o”,不是“0”),首选无符号数。当需要明确定义的环绕行为时,它们也很有用(在某些算法中,如加密和随机数生成)。

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

另请注意,如果你正在为嵌入式系统(例如 Arduino)或其他处理器/内存受限环境开发,出于性能原因,使用无符号数更常见和被接受(在某些情况下,也是不可避免的)。

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
369 评论
最新
最早 最多投票
内联反馈
查看所有评论