为什么无符号整数容易出错?

64

我看了这个视频Bjarne Stroustrup无符号整数容易出现错误并导致错误,因此只有在确实需要时才应使用它们。我还在Stack Overflow的一个问题中读到(但我不记得是哪个问题),使用无符号整数可能导致安全漏洞。

他们如何导致安全漏洞?可以通过给出合适的例子来清楚地解释吗?


6
我坚决主张使用无符号类型。如果你的循环条件写错了,那么你就是一个糟糕的开发者。使用无符号整数可以通过简单的数学计算来实现,并且这种量的无符号性对我来说感觉更加自然。 - stefan
29
问题在于,大多数开发者都很糟糕...... - Joe
6
它们确实可以放大一的偏差。考虑VLT曾经授予了一个人$2^{32}-1$美分的故事。当然,对于有符号数也存在类似的问题,最小值只比最大值少一,但由于我们经常在0附近运算,所以使用无符号数时悬崖边缘更近。 - Theodore Norvell
6
有符号整数也容易出错。当我在Java中移位“byte”值时,我花了一个小时来调试一个问题,结果产生了奇怪的结果。这是因为扩展符号位导致的。我宁愿拥有两者并选择适合该任务的正确类型。 - Matti Virkkunen
3
除了有符号和无符号之外,我更希望有具有显式包装语义、显式检查语义、松散的 mod 2ⁿ 语义和溢出等效于 UB 语义的不同类型的整数。将不同类型的整数分开将使编写更具可移植性、更健壮和更可优化的代码成为可能,而这是目前可用类型及其相关规则所不能实现的 [在许多情况下,要求较小的有符号类型表现出清晰的包装语义,但允许在较小的无符号类型上进行运算可能会产生未定义行为]。 - supercat
显示剩余6条评论
8个回答

52

可能的一个方面是,无符号整数在循环中可能会导致一些不太容易发现的问题,因为下溢会导致出现大数字。我甚至用无符号整数都数不清楚有多少次犯了这种错误的变体。

for(size_t i = foo.size(); i >= 0; --i)
    ...

请注意,根据定义,i >= 0 总是为真。 (首先导致这种情况的原因是如果 i 是带符号的,编译器将警告可能会发生 size()size_t 溢出。)

还有其他原因在危险 - 在此使用无符号类型!中提到,其中我认为最强大的是有符号和无符号之间的隐式类型转换。


1
我会接受这个答案,因为它是唯一一个编译器不会警告的答案。 - Andriy Tylychko
10
换一个更好的编译器。http://coliru.stacked-crooked.com/a/c79fc9148dfb5f3f - Baum mit Augen
@AndyT 顺便说一下,我的示例实际上没有像上面那样收到警告。 :) - Baum mit Augen
@BaummitAugen:确实 :) - Andriy Tylychko
7
现在是时候使用 operator-->go down to)了:for (size_t i = sz; i --> 0;) ... 循环从 sz-1 迭代到 0 - jingyu9575
4
这并不证明无符号整数存在问题,而是代码本身存在问题。因为它们可能被误用,所以主张避免适当的工具并不会给任何人带来好处。只需不要误用它们即可。 - fyngyrz

38

一个很大的因素是它使循环逻辑更加困难:想象一下,你想要迭代一个数组中除最后一个元素以外的所有元素(在现实世界中确实会出现这种情况)。于是你编写了你的函数:

void fun (const std::vector<int> &vec) {
    for (std::size_t i = 0; i < vec.size() - 1; ++i)
        do_something(vec[i]);
}

看起来不错,是吗?甚至可以在非常高的警告级别下进行干净的编译!(实时演示) 所以你把这个代码放到你的项目中,所有测试都顺利运行,然后你忘记了它。

现在,稍后,有人向你的函数传递一个空的vector。 如果是带符号的整数,您希望会注意到符号比较编译器警告,引入适当的转换,并首先不发布有缺陷的代码。

但是对于无符号整数的实现,您会发现已经出现了环绕,循环条件变成了i < SIZE_T_MAX。灾难、未定义行为和最有可能的崩溃!

我想知道它们是如何导致安全漏洞的?

这也是一个安全问题,特别是它是缓冲区溢出。一种可能利用它的方法是,如果do_something会执行可以被攻击者观察到的操作。他们可能能够找到输入进入do_something的数据,并且这样从内存泄漏出攻击者不应该访问的数据。这将类似于Heartbleed漏洞的情况。(感谢ratchet freak在评论中指出了这一点。)


25
我一直对这个所谓的反例感到不安。从近视的角度来看,您可能会认为有符号整数在这里更好。然而,这忽略了更大的算法问题:该算法显然想要特殊处理范围的最后一个元素。因此,该算法应该具有某种前提条件或分支,实际上确保范围最后一个元素!有了这样的分支,无符号整数也可以很好地工作。 - Kerrek SB
5
为什么每个人都要在这里使用减法?为什么不用for (std::size_t i = 0; i + 1 < vec.size(); ++i)呢? - Siyuan Ren
5
@SiyuanRen,我使用减法是因为它是错误的。这个问题和答案的整个重点是强调潜在的错误。没有人试图争辩这些错误是不可修复或不可避免的。我只是认为这样的事情可能会发生,而且会很糟糕。所以是的,你可以使用你的代码,然后有正确的代码。重点是一个人可以(相当容易地)出错(就像我在我的答案中故意做的那样)。 - Baum mit Augen
9
再次强调,这是糟糕的代码,与变量类型无关,这并不能证明什么。整数并不容易出错,编程 才容易出错。 - fyngyrz
3
在需要执行模运算的情况下,“unsigned int”是一个完全合适的变量类型,但在表示数量时,它是一个语义上不合适的类型,而不是“不好的”类型。 - supercat
显示剩余13条评论

27

我不会为了回答一个问题而观看视频,但如果混合使用有符号和无符号值,可能会出现令人困惑的转换问题。例如:

#include <iostream>

int main() {
    unsigned n = 42;
    int i = -42;
    if (i < n) {
        std::cout << "All is well\n";
    } else {
        std::cout << "ARITHMETIC IS BROKEN!\n";
    }
}

促销规则意味着将i转换为unsigned进行比较,产生一个大的正数和一个令人惊讶的结果。


9
没有投反对票,但只是猜测:如果你的编译器让你这样做,那么你可能启用的警告标志太少了。 - example
8
你的编译器必须允许你这样做;代码格式正确,意义明确。尽管警告有助于发现逻辑错误,但这不是编译器的主要责任。 - Pete Becker
7
通过对unsigned n = 2; int i = -1, j = 1;进行比较,可以使结果更加有趣。可以发现n < ii < jj < n都是成立的。 - supercat
5
这段文字应该写成“C++已经崩溃了”。@PeteBecker说“它的含义是明确定义的”,从正式上来说,这是正确的,但是这个定义在数学上是荒谬的。如果你要生成一个整数结果,则很难避免将i转换为无符号类型,但是对于比较而言,正确地定义语言是微不足道的。即使COBOL带有“On size error”的功能,但C(++)却给了你足够的绳子来让你自杀!在VMS上,DEC C(我不知道++)会警告有关有符号/无符号比较/赋值的问题,这是完全正确的(考虑到这个破碎的语言)。 - PJTraill
3
你真的认为混合比较的代码“又大又慢”吗?在极少数需要额外的1个(负数跳转)或2个(测试、跳转)指令会有影响的应用程序中,人们可以通过正确输入来进行优化。难道基本的设计原则是“快而不准确”吗? - PJTraill
显示剩余6条评论

12
尽管它可能只被视为现有答案的一种变体:参考Scott Meyers在1995年9月的《C++ Report》中提到的"接口中的有符号和无符号类型",特别重要的是要避免在接口中使用无符号类型。
问题在于这样会变得无法检测到客户端可能出现的某些错误(如果他们可能犯错,他们就会犯错)。
那里给出的例子是:
template <class T>
  class Array {
  public:
      Array(unsigned int size);
  ...

以及该类的可能实例化

int f(); // f and g are functions that return
int g(); // ints; what they do is unimportant
Array<double> a(f()-g()); // array size is f()-g()
f()g()返回值之间的差可能是负数,原因很多。 Array类的构造函数将接收此差作为隐式转换为unsigned的值。因此,作为Array类的实现者,无法区分错误传递的值-1和非常大的数组分配。请注意保留HTML标记。

同样的论点是否适用于引用或值?显然,有人可能错误地将nullpointer传递给Array<double>(*ptrToSize) - josefx
1
@josefx:你可以检查一下这个。一个 assert(ptr != nullptr) 可能就足够了。像 assert(size < theSizeThatIsLikelyToBeAllocated) 这样的东西是不起作用的。当然,人们仍然可以使用带符号类型误用API。只是更难,而且最有可能的错误(由于隐式转换等原因引起的错误)可以被覆盖。 - Marco13

5

无符号整数的一个大问题在于,如果你从一个无符号整数0中减去1,结果不是负数,结果也不小于原先这个数字,而结果是最大可能的无符号整数值。

unsigned int x = 0;
unsigned int y = x - 1;

if (y > x) printf ("What a surprise! \n");

这就是unsigned int容易出错的原因。当然,unsigned int按照设计的方式工作得非常准确。如果你知道自己在做什么并且没有犯错误,那么它绝对是安全的。但是大多数人都会犯错。

如果你使用一个好的编译器,打开编译器产生的所有警告,它会告诉你哪些操作是危险的并有可能是错误的。


2
一个更加棘手的问题是,对于给定的 uint32_t x,y,z;,表达式 x-y > z 在 32 位和 64 位系统上的含义将会非常不同。 - supercat
据我所知,在 LP32、LP64 和 LLP64 系统上,它将产生相同的结果。只有在 ILP64 系统上才会有所不同。 - plugwash
1
@plugwash:我应该澄清一下——在int为64位的系统上。我认为标准会受益于定义非提升类型,其行为在接受使用它们的所有编译器上都是一致的。使用wrap32_t的操作应该在可能的情况下产生该类型的结果,或者拒绝编译(例如因为编译器不支持所需的语义,或者因为代码正在尝试将wrap16_twrap32_t相加——这种操作不可能产生同时满足两个约束条件的结果)。 - supercat

3
在C和C++中,数字转换规则非常复杂。使用无符号类型比使用纯有符号类型更容易陷入混乱。
举个例子,比较两个变量——一个有符号,另一个无符号。
1. 如果两个操作数都小于int,则它们都将转换为int,并且比较将得出数字正确的结果。
2. 如果无符号操作数小于有符号操作数,则两者都将转换为有符号操作数的类型,比较将得到数字正确的结果。
3. 如果无符号操作数大小大于等于有符号操作数并且也大于等于int,则两者都将转换为无符号操作数的类型。如果有符号操作数的值小于零,则会导致数字上不正确的结果。
再举个例子,考虑两个相同大小的无符号整数相乘。
1. 如果操作数大小大于或等于int的大小,则乘法将具有定义好的环绕语义。
2. 如果操作数大小小于int但大于或等于int大小的一半,则可能会发生未定义的行为。
3. 如果操作数大小小于int大小的一半,则乘法将产生数字上正确的结果。将此结果赋回原始无符号类型的变量将产生定义好的环绕语义。

3
无符号整数类型的问题在于,根据它们的大小,它们可能代表两种不同的东西:
1. 比int小的无符号类型(例如uint8)在范围0..2ⁿ-1内保存数字,并且只要它们不超出int类型的范围,与它们进行的计算将按照整数算术规则执行。根据现行规定,如果这样的计算超出了int的范围,编译器可以对代码做任何喜欢的事情,甚至可以否认时间和因果律(一些编译器确实会这样做!),即使计算结果将被分配回比int小的无符号类型。
2. 无符号类型unsigned int和更大的类型保持模2ⁿ同余的整数抽象环的成员;这实际上意味着如果计算超出范围0..2ⁿ-1,则系统将加上或减去多少个2ⁿ的倍数来使值回到范围内。
因此,给定uint32_t x=1,y=2;表达式x-y可能有两种含义,具体取决于int是否大于32位。
1. 如果int大于32位,则表达式将从数字1中减去数字2,得到数字-1。请注意,虽然uint32_t类型的变量无论int的大小如何都不能保存值-1,并且存储任何一个-1都会导致这样的变量保存0xFFFFFFFF,但除非将该值强制转换为无符号类型,否则它将像带符号的量-1一样运作。
2. 如果int为32位或更小,则表达式将产生一个uint32_t值,当加上uint32_t值2时,将产生uint32_t值1(即uint32_t值0xFFFFFFFF)。
在我看来,如果C和C ++定义了新的无符号类型[例如unum32_t和uwrap32_t],这个问题可以干净地解决,因此 unum32_t 将始终行为像一个数字,而不管 int 的大小(如果int为32位或更小,则可能需要将减法或一元负操作的右操作提升到下一个较大的有符号类型),而 wrap32_t 将始终作为代数环的成员行为(即使int大于32位,也会阻止升级)。 然而,在没有这样的类型的情况下,编写既可移植又干净的代码通常是不可能的,因为可移植代码通常需要在各个地方进行类型强制转换。

4
一个非常令人困惑的回答。你是说对于无符号整数,其包装和提升规则取决于它们的大小以及“基础” int 的大小吗? - Martin Ba
3
@MartinBa :是的,他就是这么说的。既然你理解了,我想这不会让你感到困惑,但对于一些人来说可能会令人惊讶。比 int 更小的整数类型真是个大麻烦,无符号整数尤其如此。 - Steve Jessop
4
@MartinBa:答案很令人困惑,因为底层规则也是如此。我在前几个要点上添加了一些内容,这样有帮助吗? - supercat
2
@MartinBa:虽然你说的几乎所有实现都提供了“包装有符号整数”的选项,但还是有一些弱点:(1)C程序无法通过标准手段请求这种语义,或者拒绝编译器无法提供它们的编译;(2)要求整数值(无论有符号还是无符号)进行包装会排除许多通常很有用(但有时灾难性)的优化。我真的很希望C能够提供各种不同类型的整数,选择不同的语义以提供许多良好的优化机会... - supercat
2
...但是要尽量避免出现意外情况。编译器在让程序员表达关心的内容方面提供的越多的灵活性,程序员就能够提供更多机会来积极优化那些不会影响正确性的事情。 - supercat
显示剩余5条评论

-3
除了无符号类型的范围/扭曲问题之外,使用混合无符号和有符号整数类型会对处理器产生显着的性能问题。虽然比浮点数转换少,但相当多以至于不能忽略。此外,编译器可能会为该值放置范围检查并更改进一步检查的行为。

3
请问您能详细说明哪些性能问题很重要,同时给出示例代码吗? - user694733
1
如果你将unsigned转换为int或者反过来,二进制表示法是完全相同的。因此,在将一个类型转换为另一个类型时,CPU不会有任何额外的开销。 - example
(假设C++实现使用二进制补码表示负整数) - Ruslan
示例:二进制布局不同。无符号值占据所有位空间(8、16、32、64),但有符号值具有最高有效位用于表示符号,从而减少了1位的值空间。在SIMD指令的情况下,没有一种指令可以同时对两种类型进行计算。饱和转换会发生,这会导致性能下降。 - PRu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接