无符号整数溢出在C和C++标准中都有很好的定义。例如,C99标准(§6.2.5/9
)规定
涉及无符号操作数的计算永远不会溢出,因为无法由生成的无符号整数类型表示的结果是降模-比最大值大一的数字由结果类型表示。
然而,这两个标准都指出,有符号整数溢出是未定义的行为。同样,从C99标准(§3.4.3/1
)
未定义行为的一个例子是整数溢出上的行为
这种差异是历史原因还是(甚至更好!)技术原因?
历史原因是,大多数C实现(编译器)只使用了它使用的整数表示最容易实现的溢出行为。C实现通常使用与CPU使用的表示法相同的表示法,因此溢出行为遵循CPU使用的整数表示法。
在实践中,只有有符号值的表示可能因实现而异:一个补码、两个补码和符号大小。对于无符号类型,标准没有理由允许变化,因为只有一个明显的二进制表示(标准只允许二进制表示)。
相关报价:
C99 6.2.6.1:3:
存储在无符号位字段和无符号字符类型的对象中的值应使用纯二进制表示法表示。
C99 6.2.6.2:2:
如果符号位为1,则应通过以下方式之一修改该值:
--符号位为0的对应值被否定(符号和幅度);
--符号位具有值−(2N)(二的补码);
--符号位具有值−(2N−1)(一个补码)。
如今,所有处理器都使用二的补码表示,但有符号算术溢出仍然未定义,编译器制造商希望它保持未定义,因为他们使用这种不确定性来帮助优化。例如,请参阅Ian Lance Taylor的这篇博客文章或Agner Fog的这篇投诉,以及他的错误报告的答案。
除了Pascal的好答案(我相信这是主要动机)之外,一些处理器也可能在有符号整数溢出时引发异常,如果编译器必须"安排另一种行为",这当然会引起问题(例如,在这种情况下,使用额外的指令来检查潜在的溢出并进行不同的计算)。
同样值得注意的是,"未定义的行为"并不意味着"不起作用"。这意味着在这种情况下,执行可以为所欲为。这包括做"正确的事情"以及"报警"或"撞车"。在可能的情况下,大多数编译器都会选择"做正确的事情",假设这相对容易定义(在这种情况下,确实如此)。然而,如果您在计算中出现溢出,那么了解实际结果很重要,编译器可能会做一些与您预期不同的事情(这可能很大程度上取决于编译器版本、优化设置等)。
首先,请注意,C11 3.4.3与所有示例和脚注一样,不是规范性文本,因此与引用无关!
声明整数和浮点溢出是未定义行为的相关文本如下:
C11 6.5/5
如果在评估表达式(即,如果结果没有数学定义或不在其类型的可表示值的范围内),行为未定义。
关于无符号整数类型的行为的澄清可以在这里找到:
C11 6.2.5/9
有符号整数类型的非负值范围是子范围对应的无符号整数类型,以及每种类型中的相同值是相同的。计算包括无符号操作数永远不会溢出,因为由生成的无符号整数类型表示的值被模化比最大值大一的数字由结果类型表示。
这使得无符号整数类型成为一种特殊情况。
还要注意,如果任何类型被转换为有符号类型,并且旧值无法再表示,则会出现异常。然后,行为仅仅是实现定义的,尽管可以发出信号。
C11 6.3.1.3
6.3.1.3有符号和无符号整数
当带有整数的值如果值可以用新类型表示,它是不变的。
否则,如果新类型是无符号的,则值由重复地加或减一个以上的最大值可以用新类型表示,直到值在新类型。
否则,将对新类型进行签名无法在其中代表;结果是提出实现定义的或实现定义的信号。
除了提到的其他问题外,具有无符号数学包装使无符号整数类型表现为抽象代数组(这意味着,除其他外,对于任何一对值X
和Y
,都将存在一些其他值Z
,使得如果正确地进行强制转换,X+Z
将等于Y
,而如果正确地执行强制转换,则Y-Z
将等于X
)。如果无符号值只是存储位置类型,而不是中间表达式类型(例如,如果没有最大整数类型的无符号等价物,并且对无符号类型的算术运算表现得就像它们首先将它们转换为更大的有符号类型一样,那么就不需要那么多定义的包装行为,但在没有加性逆的类型中很难进行计算
这在环绕行为实际有用的情况下会有所帮助,例如TCP序列号或某些算法,如哈希计算。在需要检测溢出的情况下,它也可能有所帮助,因为执行计算并检查它们是否溢出通常比提前检查它们是否会溢出更容易,尤其是在计算涉及最大可用整数类型的情况下。
定义无符号算术的另一个原因可能是因为无符号数形成的整数模2^n,其中n是无符号数的宽度。无符号数字只是使用二进制数字而不是十进制数字表示的整数。在模数系统中执行标准操作是众所周知的。
OP引用了这一事实,但也强调了一个事实,即在二进制中只有一种明确的逻辑方式来表示无符号整数。相比之下,有符号数字通常使用二的补码表示,但也可以选择标准中描述的其他数字(第6.2.6.2节)
Two的补码表示允许某些运算在二进制格式中更有意义。例如,递增负数与递增正数相同(在溢出条件下除外)。对于有符号数字和无符号数字,机器级别的某些操作可能是相同的。然而,在解释这些操作的结果时,有些情况没有意义——正溢出和负溢出。此外,溢出结果也因底层有符号表示的不同而有所不同。
最技术性的原因很简单,就是试图捕获无符号整数中的溢出需要您(异常处理)和处理器(异常抛出)进行更多的移动。
C和C++不会让你为此付出代价,除非你用有符号整数来要求。这并不是一个硬性的快速规则,正如你在末尾看到的那样,而是它们如何处理无符号整数。在我看来,这使得有符号整数成为奇数,而不是无符号整数,但它们提供了这种基本的区别,这很好,因为程序员仍然可以在溢出的情况下执行定义良好的有符号运算。但要做到这一点,你必须投赞成票。
因为:
- 无符号整数具有定义良好的上溢和下溢
- 已签名的强制转换->unsigned int定义良好,
[uint's name]_MAX - 1
在概念上被添加到负值,以将它们映射到扩展的正数范围 - 从无符号转换->有符号int定义良好,
[uint's name]_MAX - 1
在概念上是从超过有符号类型的最大值的正值中扣除的,以将它们映射到负数)
您总是可以使用定义良好的上溢和下溢行为来执行算术运算,其中有符号整数是您的起点,尽管是以舍入的方式,方法是先强制转换为无符号整数,然后在完成后返回。
int32_t x = 10;
int32_t y = -50;
// writes -60 into z, this is well defined
int32_t z = int32_t(uint32_t(y) - uint32_t(x));
如果CPU使用2的互补(几乎所有类型都使用),则相同宽度的有符号和无符号整数类型之间的强制转换是免费的。如果出于某种原因,您的目标平台没有对有符号整数使用2的Complimit,那么在uint32和int32之间进行转换时,您将支付较小的转换价格。
但使用小于int的位宽时要小心
通常,如果您依赖于无符号溢出,则使用较小的字宽,8位或16位。这些将提升为签名int
(C有绝对疯狂的隐式整数转换规则,这是C最大的隐藏问题之一),请考虑:
unsigned char a = 0;
unsigned char b = 1;
printf("%i", a - b); // outputs -1, not 255 as you'd expect
为了避免这种情况,当您依赖于所需类型的宽度时,您应该始终强制转换为该类型,即使在您认为不必要的操作中间也是如此。这将强制转换temporary并获得有符号性,并截断值,从而获得所需的值。它几乎总是可以自由转换的,事实上,您的编译器可能会感谢您这样做,因为它可以更积极地优化您的意图。
unsigned char a = 0;
unsigned char b = 1;
printf("%i", (unsigned char)(a - b)); // cast turns -1 to 255, outputs 255
C++只是从C.中获得了这种行为
我相信有了C,它的用户和实现者之间已经形成了脱节。C被设计为汇编程序的一种更便携的替代品,最初并没有这样的标准,只是一本描述该语言的书。在早期的C中,低级别平台特定的黑客攻击是常见的,也是公认的做法。许多现实世界中的C程序员仍然这样看待C。
当一个标准被引入时,它的目标主要是使现有实践标准化。有些东西是未定义的,或者实现是定义的。我不相信人们会太关注哪些东西是未定义的,哪些东西是实现定义的。
在C被标准化的时候,二进制补码是最常见的方法,但其他方法也存在,所以C不能完全要求二进制补码。
如果您在https://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf在讨论促销语义的选择时;"保值";语义更安全,但他们做出这一决定的前提是,大多数实现都使用twos补码,并以显而易见的方式安静地处理环绕。
然而,编译器供应商在某个时候开始将签名溢出视为一个优化机会。这已经将签名溢出变成了一个主要的足迹。除非你仔细检查每一个算术运算以确保它不会溢出,否则你最终可能会触发未定义的行为。
一旦未定义的行为被触发;任何事情都可能发生";。在实践中,这意味着变量实际包含的值可能在编译器认为它可以包含的值范围之外。这反过来会使边界检查无效。