为什么忽略算术溢出?


76

曾经尝试用您喜欢的编程语言对1到2,000,000之间的所有数字求和吗?结果很容易手动计算:2,000,001,000,000,比无符号32位整数的最大值大900倍。

C#打印出来-1453759936-负值!而且我猜Java也是如此。

这意味着有一些通用的编程语言默认情况下会忽略算术溢出(在C#中,存在用于更改该值的隐藏选项)。在我看来,这是一种非常冒险的行为,难道不是由这样的溢出导致Ariane 5崩溃吗?

那么:如此危险的行为背后的设计决策是什么?

编辑:

这个问题的第一个答案表示检查的过度成本。让我们执行一个简短的C#程序来测试此假设:

Stopwatch watch = Stopwatch.StartNew();
checked
{
    for (int i = 0; i < 200000; i++)
    {
        int sum = 0;
        for (int j = 1; j < 50000; j++)
        {
            sum += j;
        }
    }
}
watch.Stop();
Console.WriteLine(watch.Elapsed.TotalMilliseconds);

在我的计算机上,选中的版本需要11015毫秒,而未选中的版本需要4125毫秒。即检查步骤几乎花了两倍的时间(总共是原始时间的三倍)。但是,有了10,000,000,000次重复,检查所花费的时间仍不到1纳秒。在某些情况下,这很重要,但是对于大多数应用程序而言,这并不重要。

编辑2:

我使用/p:CheckForOverflowUnderflow="false"参数(通常,我打开了溢出检查功能)重新编译了服务器应用程序(Windows服务,该服务分析从多个传感器接收的数据,涉及很多数字运算),并将其部署在设备上。Nagios监控显示,平均CPU负载保持在17%。

这意味着在上面的虚构示例中发现的性能下降与我们的应用完全无关。


19
只是作为注释,对于C#,您可以使用checked { }section标记应执行算术溢出检查的代码部分。这是由于性能
帕维尔Lukasik的

14
“是否曾经尝试过用您喜欢的编程语言对从1到2,000,000的所有数字求和?” –是:(1..2_000_000).sum #=> 2000001000000。我最喜欢的另一种语言:sum [1 .. 2000000] --=> 2000001000000。不是我的最爱:Array.from({length: 2000001}, (v, k) => k).reduce((acc, el) => acc + el) //=> 2000001000000。(公平地说,最后一个是作弊。)
JörgW Mittag

27
IntegerHaskell中的@BernhardHiller 是任意精度的,只要您没有用完可分配的RAM,它将保存任何数字。
Polygnome'5

50
阿丽亚娜5号坠机事故是由检查无关紧要的溢流引起的-火箭在飞行的一部分中,甚至不再需要计算结果。而是检测到溢出,这导致航班中止。
西蒙B

9
But with the 10,000,000,000 repetitions, the time taken by a check is still less than 1 nanosecond.这表明循环已被优化。同样,这句话与以前的数字相矛盾,后者对我来说非常有效。
usr

Answers:


86

这有3个原因:

  1. 在运行时检查溢出(针对每个算术运算)的成本过高。

  2. 证明可以在编译时忽略溢出检查的复杂性过高。

  3. 在某些情况下(例如CRC计算,大数目库等),“溢出时换行”对于程序员来说更方便。


10
@DmitryGrigoryev unsigned int不应该引起我的注意,因为默认情况下,具有溢出检查功能的语言应检查所有整数类型。你应该写wrapping unsigned int
immibis

32
我不赞成成本论点。CPU会检查每个单整数计算的溢出,并在ALU中设置进位标志。缺少编程语言支持。一个简单的didOverflow()内联函数,甚至是一个__carry允许访问进位标志的全局变量,如果不使用它将花费零的CPU时间。
slebetman'5

37
@slebetman:那是x86。ARM没有。例如ADD,没有设置进位(您需要ADDS)。安腾甚至没有进位标记。即使在x86上,AVX也没有进位标志。
MSalters

30
@slebetman它设置进位标记,是的(请注意,在x86上)。但是随后您必须读取进位标志并确定结果-这是昂贵的部分。由于算术运算通常用在循环中(以及紧要的循环中),因此即使您只需要一条额外的指令(并且您需要的更多内容),也很容易阻止许多安全的编译器优化,这些优化可能会对性能产生很大的影响。 )。这是否意味着它应该是默认值?也许,尤其是在像C#这样的语言中,说起来unchecked很容易;但是您可能高估了溢出问题的发生频率。
Lu安

12
ARM的adds价格与ARM的价格相同add(只是一条指令1位标志,用于选择是否更新进位标志)。MIPS的add指令会在溢出时捕获陷阱-您必须要求不要使用溢出来捕获陷阱addu
immibis

65

谁说这是一个不好的权衡?

我在启用溢出检查的情况下运行所有​​生产应用程序。这是一个C#编译器选项。我实际上对此进行了基准测试,但无法确定差异。访问数据库以生成(非玩具)HTML的成本超过了溢出检查的成本。

知道我的生产中没有任何操作溢出的事实,对此我表示赞赏。在存在溢出的情况下,几乎所有代码都会表现异常。这些错误不会是良性的。可能会发生数据损坏,可能会出现安全问题。

万一我需要性能(有时是这种情况),我会禁用unchecked {}基于粒度的溢出检查。当我想指出我依赖于不溢出的操作时,可能会多余地添加checked {}到代码中以记录这一事实。我很注意溢出,但不必一定要感谢检查。

我相信C#团队在默认情况下选择检查溢出时会做出错误的选择,但是由于强烈的兼容性问题,现在密封了该选择。请注意,这种选择是在2000年左右做出的。硬件功能不强,.NET尚未吸引很多人。也许.NET希望以此方式吸引Java和C / C ++程序员。.NET还意味着能够与金属保持紧密联系。这就是为什么它具有Java所没有的不安全代码,结构和出色的本机调用功能。

默认情况下,硬件获取速度越快,编译器越聪明,其溢出检查就越具有吸引力。

我还认为,溢出检查通常比无穷大的数字更好。无限大小的数字具有更高的性能成本,更难优化(我相信),并且它们打开了无限资源消耗的可能性。

JavaScript处理溢出的方式更糟。JavaScript数字是浮点双精度。“溢出”表明自己离开了完全精确的整数集。会出现稍微错误的结果(例如,被一个结果取为1-这会将有限循环变成无限循环)。

对于某些语言(例如C / C ++),默认情况下,默认情况下进行溢出检查显然是不合适的,因为用这些语言编写的各种应用程序都需要裸机性能。尽管如此,仍在努力通过允许选择加入更安全的模式来使C / C ++成为更安全的语言。这值得称赞,因为90-99%的代码趋向于冷。一个示例是fwrapv强制2补码换行的编译器选项。这是编译器(而不是语言)的“实现质量”功能。

Haskell没有逻辑调用堆栈,也没有指定的评估顺序。这使得异常发生在不可预测的点。在a + b它未被指定是否a还是b第一次评估,这些表达式是否在全部或不会终止。因此,对于Haskell大部分时间使用无界整数是有意义的。这种选择适合于纯函数式语言,因为在大多数Haskell代码中异常实际上是不合适的。在Haskells语言设计中,零除确实是一个问题点。他们也可以使用固定宽度的包装整数来代替无界整数,但是这与该语言所具有的“关注正确性”主题不符。

溢出异常的替代方法是由未定义的操作创建并通过操作传播的有毒值(例如float NaN值)。这似乎比溢出检查要昂贵得多,它会使所有操作变慢,而不仅仅是会失败的操作(除非硬件加速(通常是float且int通常没有)-尽管Itanium的NaT表示“不是”)。我也不太清楚使程序与不良数据一起继续瘫痪的意义。就像ON ERROR RESUME NEXT。它隐藏了错误,但无助于获得正确的结果。supercat指出,有时这样做是对性能的优化。


2
极好的答案。那么,关于他们为什么决定这样做的理论是什么?只是复制其他复制C并最终汇编和二进制的人吗?
jpmc26

19
当您有99%的用户群希望有这种行为时,您倾向于将其提供给他们。至于“复制C”,它实际上不是C的副本,而是它的扩展。C保证unsigned仅针对整数的无异常行为。在C和C ++中,有符号整数溢出的行为实际上是未定义的行为。是的,未定义的行为。碰巧的是,几乎每个人都将其实现为2的补码溢出。C#实际上使它正式化,而不是像C / C ++那样使它成为UB
Cort Ammon

10
@CortAmmon:Dennis Ritchie设计的语言已定义了有符号整数的环绕行为,但实际上并不适合在非二进制补码平台上使用。尽管允许与精确的二进制补码回绕有一定的偏差可以极大地帮助某些优化(例如,允许编译器用x替换x * y / y可以节省乘法和除法),但是编译器作者将“未定义行为”解释为没有机会对于给定的目标平台和应用程序领域而言,什么才是有意义的,而这却是将其抛诸脑后的机会。
超级猫

3
@CortAmmon-检查gcc -O2for 生成的代码x + 1 > x(处xint)。另请参阅gcc.gnu.org/onlinedocs/gcc-6.3.0/gcc/…。即使在实际的编译器中,C中带符号溢出的2s补码行为也是可选的,并且gcc默认情况下在常规优化级别中忽略它。
乔纳森·

2
@supercat是的,大多数C编译器作者对确保某些不切实际的基准测试比尝试向程序员提供合理的语义要快0.5%感兴趣(是的,我理解为什么这不是一个容易解决的问题,并且有些合理的优化可能导致yada,yada合并时产生了意想不到的结果,但仍然没有重点,如果您按照对话进行操作,就会注意到它)。幸运的是,有些人试图做得更好
Voo

30

因为要自动捕获罕见的确实发生溢出的情况,使所有计算的成本高得多,这是一个不好的权衡。与让所有程序员为他们不使用的功能付出代价相比,负担更多的负担让程序员意识到这是一个罕见的问题,并增加特殊的预防措施要好得多。


28
这有点像说应该省略对缓冲区溢出的检查,因为它们几乎不会发生...
Bernhard Hiller

73
@BernhardHiller:这正是C和C ++的功能。
Michael Borgwardt

12
@DavidBrown:算术溢出也是如此。前者不会损害VM。
重复数据删除器

35
@Deduplicator提出了一个很好的观点。CLR的设计经过精心设计,因此即使发生不良情况,可验证的程序也不会违反运行的不变性。当不良事件发生时,安全程序当然可以违反其自身的不变式。
埃里克·利珀特

7
@svick算术运算可能比数组索引运算更常见。而且大多数整数大小都足够大,以至于很少执行溢出算法。因此,成本收益率差异很大。
巴马尔(Barmar)'17

20

这种危险行为背后的设计决策是什么?

“不要强迫用户为他们可能不需要的功能付出性能损失。”

它是C和C ++设计中最基本的原则之一,源于您不得不经历荒谬的扭曲而几乎无法为当今被认为微不足道的任务获得足够性能的时候。

较新的语言以这种态度打破了许多其他功能,例如数组边界检查。我不确定他们为什么不这样做以进行溢出检查;这可能只是一个疏忽。


18
绝对不是C#设计中的疏忽。C#的设计人员有意创建了两种模式:checkedunchecked,添加了在本地和命令行之间切换(以及VS中的项目设置)以在全局之间进行更改的语法。您可能不同意使用unchecked默认值(我愿意),但是所有这些显然都是非常刻意的。
斯威克(Svick)'17年

8
@slebetman-仅作记录:这里的成本不是检查溢出的成本(这是微不足道的),而是取决于是否发生溢出而运行不同代码的成本(这是非常昂贵的)。CPU不喜欢条件分支语句。
乔纳森·

5
@jcast在现代处理器上进行分支预测是否可以消除条件分支语句的代价?毕竟正常情况下应该没有溢出,所以这是非常可预测的分支行为。
CodeMonkey

4
同意@CodeMonkey。发生溢出时,编译器将有条件地跳转到通常不加载/未加载的页面。缺省的预测是“不采用”,它可能不会改变。总开销是管道中的一条指令。但这是每条算术指令一个指令开销。
MSalters

2
@MSalters是的,有额外的指令开销。如果您完全有CPU约束问题,那么影响可能很大。在大多数使用IO和CPU繁重代码混合的应用程序中,我认为影响是最小的。我喜欢Rust的方式,即仅在Debug版本中添加开销,而在Release版本中删除开销。
CodeMonkey '17年

20

遗产

我要说的是,这个问题很可能源于传统。在C中:

  • 有符号的溢出是未定义的行为(编译器支持对其进行包装的标志),
  • 无符号溢出是定义的行为(它包装)。

遵循程序员知道自己在做什么的原则,这样做是为了获得最佳性能。

导致Statu-Quo

C(以及扩展名为C ++)不需要轮流检测溢出的事实意味着溢出检查很慢。

硬件主要满足C / C ++的要求(严重的是,x86有一条strcmp指令(从SSE 4.2起又名PCMPISTRI!)!),由于C不在乎,因此普通的CPU无法提供检测溢出的有效方法。在x86中,您必须在每次可能的溢出操作之后检查每个内核标志;当您真正想要的是结果上的“污染”标志时(就像NaN传播一样)。向量运算可能会更成问题。一些新的参与者可能会通过有效的溢出处理出现在市场上。但目前x86和ARM不在乎。

编译器优化器不擅长优化溢出检查,甚至无法优化存在溢出的情况。诸如John Regher之类的一些学者抱怨这种状况,但事实是,当使溢出“失败”这一简单事实妨碍了优化时,甚至在装配命中CPU之前就可能会瘫痪。尤其是当它阻止自动矢量化时...

具有级联效果

因此,在缺乏有效的优化策略和有效的CPU支持的情况下,溢出检查的成本很高。比包装要贵得多。

加上一些令人讨厌的行为,例如x + y - 1可能会在x - 1 + y不这样做时溢出,这可能会合法地惹恼用户,并且通常放弃溢出检查,而希望使用包装(这很好地处理了此示例和许多其他示例)。

不过,并非所有希望都消失了

在clang和gcc编译器中,人们一直在努力实现“消毒器”:检测二进制文件以检测未定义行为的方法。使用时-fsanitize=undefined,将检测到签名溢出并中止程序;在测试过程中非常有用。

编程语言启用溢出检查默认情况下,在调试模式(它使用包裹在Release模式运算性能方面的原因)。

因此,人们越来越关注溢出检查以及无法检测到伪造结果的危险,并希望这反过来会引起研究界,编译器界和硬件界的兴趣。


6
@DmitryGrigoryev与检查溢出的有效方法相反,例如,在Haswell上,它将吞吐量从每个周期的4个正常加法减少到仅1个已检查的加法,并且这是在考虑分支错误预测对的影响jo以及它们会在分支预测变量状态和增加的代码大小中带来更多的污染全局影响。如果那个标记是粘性的,它将提供一些真正的潜力。.然后您仍然不能在矢量化代码中正确地实现它。

3
由于您链接到John Regehr撰写的博客文章,因此我认为也应该链接到他的另一篇文章,该文章是在您所链接的文章的几个月之前撰写的。这些文章讨论了不同的哲学:在前面的文章中,整数是固定大小的;整数是固定大小的。检查整数算术(即代码无法继续执行);有一个例外或一个陷阱。较新的文章讨论了完全放弃固定大小的整数,这消除了溢出。
rwong

2
@rwong无限大小的整数也有其问题。如果您的溢出是错误(通常是错误)的结果,则可能会将快速崩溃变成长时间的痛苦,这会消耗所有服务器资源,直到一切都可怕地失败为止。我主要是“早期失效”方法的拥护者-降低中毒整个环境的机会。我宁愿使用Pascal-ish 1..100类型-明确显示预期范围,而不是“强制”进入2 ^ 31等。当然,某些语言提供了此功能,并且它们默认会进行溢出检查(有时在编译时)。
a安

1
@Luaan:有趣的是,中间计算通常可能会暂时溢出,但结果不会。例如,在1..100范围内,即使结果合适,x * 2 - 2x51 时可能会溢出,从而迫使您重新安排计算(有时以不自然的方式)。根据我的经验,我发现我通常更喜欢以更大的类型运行计算,然后检查结果是否合适。
Matthieu M.

1
@MatthieuM。是的,那就是您进入“足够智能的编译器”领域的地方。理想情况下,值103应该对1..100类型有效,只要在期望x = x * 2 - 2为1..100的上下文中从不使用它(例如,应在所有x分配结果为有效1.的情况下使用)。 .100号)。也就是说,只要分配合适,对数字类型的操作可能会比类型本身具有更高的精度。在诸如(a + b) / 2忽略(无符号)溢出可能是正确选项的情况下,这将非常有用。
Lu安

10

尝试检测溢出的语言在历史上已经以严格限制本来有用的优化的方式定义了关联的语义。除其他事项外,虽然按照与代码中指定的顺序不同的顺序执行计算通常会很有用,但大多数陷阱溢出的语言都可以保证给定的代码如下:

for (int i=0; i<100; i++)
{
  Operation1();
  x+=i;
  Operation2();
}

如果x的起始值将导致在循环的第47次遍历中发生溢出,则Operation1将执行47次,而Operation2将执行46。将在Operation1或Operation2引发异常后使用x的值,代码可以替换为:

x+=4950;
for (int i=0; i<100; i++)
{
  Operation1();
  Operation2();
}

不幸的是,在循环中可能发生溢出的情况下,在保证正确语义的同时执行此类优化非常困难-本质上要求:

if (x < INT_MAX-4950)
{
  x+=4950;
  for (int i=0; i<100; i++)
  {
    Operation1();
    Operation2();
  }
}
else
{
  for (int i=0; i<100; i++)
  {
    Operation1();
    x+=i;
    Operation2();
  }
}

如果人们认为许多现实世界的代码都使用了更多涉及到的循环,那么很明显,在保留溢出语义的同时优化代码是很困难的。此外,由于缓存问题,即使通常执行的路径上的操作较少,代码大小的增加也很有可能会使整个程序运行得更慢。

使溢出检测便宜的所需的将是一组定义的宽松的溢出检测语义,这将使代码易于报告是否已执行计算而没有任何可能影响结果的溢出(*),但不会造成负担编译器具有更多细节。如果语言规范专注于将溢出检测的成本降低到实现上述目标所必需的最低限度,那么它的成本可能会比现有语言便宜得多。但是,我不知道为促进有效的溢出检测所做的任何努力。

(*)如果一种语言承诺将报告所有溢出,则除非可以保证不会溢出x*y/yx否则x*y无法将like这样的表达式简化为。同样,即使计算结果将被忽略,承诺报告所有溢出的语言也将需要执行,以便它可以执行溢出检查。由于在这种情况下溢出不会导致算术错误,因此程序无需执行此类检查即可保证没有溢出会导致潜在的不准确结果。

附带地,C中的溢出特别糟糕。尽管几乎所有支持C99的硬件平台都使用二进制补码的无声环绕语义,但是对于现代编译器而言,生成代码可能会在溢出时引起任意副作用是很时髦的。例如,给出如下所示:

#include <stdint.h>
uint32_t test(uint16_t x, uint16_t y) { return x*y & 65535u; }
uint32_t test2(uint16_t q, int *p)
{
  uint32_t total=0;
  q|=32768;
  for (int i = 32768; i<=q; i++)
  {
    total+=test(i,65535);
    *p+=1;
  }
  return total;
}

GCC将为test2生成代码,该代码无条件地递增一次(* p),无论传递给q的值如何,均返回32768。按照其推理,(32769 * 65535)和65535u的计算将导致溢出,因此编译器无需考虑(q | 32768)会产生大于32768的值的任何情况。由于(32769 * 65535)和65535u的计算应关注结果的高位,因此gcc将使用带符号的溢出作为忽略循环的理由。


2
“对于现代编译器而言,这很流行……”-同样,某些知名内核的开发人员选择不阅读有关其使用的优化标志的文档,然后在整个互联网上表现出愤怒,这简直是时髦的。因为他们被迫添加更多的编译器标志来获得他们想要的行为;-)。在这种情况下,-fwrapv即使不是发问者想要的行为,也会导致定义的行为。诚然,gcc优化确实可以将任何类型的C开发变成对标准和编译器行为的全面检查。
史蒂夫·杰索普

1
@SteveJessop:如果编译器作者认识到一种低级方言,其中“未定义的行为”意味着“在底层平台上执行任何有意义的工作”,那么C将会是一种更加健康的语言,然后为程序员增加了放弃由此隐含的不必要保证的方式,而不是假设标准中的“不可携带或错误”一词仅表示“错误”。在许多情况下,可以用行为担保较弱的语言获得的最佳代码要比用强担保或无担保的语言获得的更好。例如...
超级猫

1
...如果程序员需要以x+y > z一种不会产生yield 0或yield 1的方式进行评估的方式,但是在发生溢出的情况下,任何一个结果都是可以接受的,那么提供保证的编译器通常可以为代码生成更好的代码。该表达式x+y > z比任何编译器都能够生成防御性版本的表达式。实际上,保证除除法/余数以外的整数计算将不会产生副作用的保证将排除有用的与溢出相关的优化的哪一部分?
超级猫

我承认我并没有完全了解细节,但是您的抱怨通常是针对“编译器作家”,而不是具体针对“不接受我的-fwhatever-makes-sense补丁的gcc上的某个人”,这一事实强烈地暗示我还有更多而不是他们的异想天开。我听到的通常的论点是,代码内联(甚至宏扩展)得益于尽可能多地推断代码构造的具体用途,因为这两种情况通常都会导致插入的代码处理不需要的情况到,周围的代码“证明”是不可能的。
史蒂夫·杰索普

因此,对于一个简化的示例,如果我写foo(i + INT_MAX + 1),则编译器- 编写器渴望将优化应用于内联foo()代码,该内联代码依赖于其参数为非负数(也许是恶性的divmod技巧)来确保正确性。在您的其他限制下,他们只能应用优化,其负输入行为对于平台有意义。当然,就我个人而言,我很乐意将其作为-f打开-fwrapv等功能的选项,并且可能必须禁用一些没有标记的优化。但这并不是我可以自己做所有工作。
史蒂夫·杰索普

9

并非所有的编程语言都忽略整数溢出。某些语言通过库为所有数字提供安全的整数运算(大多数Lisp方言,Ruby,Smalltalk等),而其他语言则通过库提供-例如,有各种针对C ++的BigInt类。

语言是否默认使整数免受溢出影响取决于其目的:像C和C ++这样的系统语言需要提供零成本抽象,而“大整数”不是。诸如Ruby之类的生产力语言可以并且确实提供了开箱即用的大整数。介于两者之间的Java和C#之类的语言应恕我直言,不要使用安全整数,否则请不要使用。


请注意,在检测到溢出(然后发出信号,出现恐慌,异常等)和切换为大数之间有区别。前者应该比后者便宜得多。
Matthieu M.

@MatthieuM。绝对-我知道我的回答不清楚。
Nemanja Trifunovic

7

正如您所显示的,如果默认情况下启用了溢出检查,C#的速度将慢3倍(假设您的示例是该语言的典型应用程序)。我同意性能并不总是最重要的功能,但是通常会比较语言/编译器在典型任务中的性能。部分原因是由于语言功能的质量有些主观,而性能测试却是客观的。

如果您要引入一种在大多数方面都与C#类似但慢了3倍的新语言,即使最终最终大多数最终用户从溢出检查中获得的收益要比他们多,要获得市场份额也并非易事。从更高的性能。


10
对于C#来说尤其如此,与Java和C ++相比,C#在早期还没有基于开发人员生产率指标(该指标很难衡量)或基于从不交易中获得现金节省的安全性指标,很难衡量,但以微不足道的性能基准为准。
埃里克·利珀特

1
而且很可能通过一些简单的数字运算来检查CPU的性能。因此,针对溢出检测的优化可能会在这些测试中产生“不良”结果。抓住22。
伯恩哈德·希勒

5

除了可以证明缺乏基于性能的溢出检查的许多答案外,还有两种不同的算法需要考虑:

  1. 索引计算(数组索引和/或指针算术)

  2. 其他算术

如果语言使用的整数大小与指针大小相同,则构造良好的程序将不会在执行索引计算时溢出,因为在索引计算引起溢出之前,它必然会耗尽内存。

因此,在处理涉及分配的数据结构的指针算术和索引表达式时,检查内存分配就足够了。例如,如果您具有32位地址空间,并使用32位整数,并最多分配2GB的堆空间(大约是地址空间的一半),则索引/指针计算(基本上)不会溢出。

此外,您可能会惊讶于涉及数组索引或指针计算的加/减/乘运算多少,从而属于第一类。对象指针,字段访问和数组操作是索引操作,许多程序不比这些更多的算术运算! 本质上,这是程序无需整数溢出检查就可以正常工作的主要原因。

所有非索引和非指针计算都应分类为那些希望/期望溢出的计算(例如,哈希计算)和不需要的(例如您的求和示例)。

在后一种情况下,程序员通常会使用替代数据类型,例如double或some BigInt。许多计算都需要decimal数据类型而不是数据类型double,例如财务计算。如果他们不坚持整数类型,那么他们需要注意检查整数溢出-否则,是的,正如您所指出的,程序可能会遇到未检测到的错误情况。

作为程序员,我们需要对数字数据类型的选择及其溢出的可能性(更不用说精度)敏感。通常(尤其是在使用C系列语言并希望使用快速整数类型的情况下),我们需要敏感并意识到索引计算与其他计算之间的差异。


3

Rust语言通过添加对调试版本的检查并在优化的发行版中将其删除,在检查是否溢出之间提供了一种有趣的折衷方法。这样,您可以在测试过程中发现错误,同时在最终版本中仍能获得完整的性能。

由于有时有时需要进行溢出环绕,因此还有一些运算符的版本从不检查溢出。

您可以阅读有关RFC选择的更多理由。此博客文章中还包含许多有趣的信息,包括此功能有助于捕获的错误列表


2
Rust还提供了类似的方法checked_mul,用于检查是否发生了溢出,None如果溢出,则返回Some。这可以用于生产以及调试模式:doc.rust-lang.org/std/primitive.i32.html#examples-15
Akavall,2005年

3

在Swift中,默认情况下会检测到任何整数溢出并立即停止程序。在需要折回行为的情况下,可以使用不同的运算符&+,&-和&*来实现。并且有一些函数可以执行操作并判断是否有溢出。

看着初学者尝试评估Collat​​z序列并使其代码崩溃是很有趣的:-)

现在,Swift的设计师同时也是LLVM和Clang的设计师,因此他们对优化有一点点了解,并且能够避免不必要的溢出检查。启用所有优化后,溢出检查不会增加代码大小和执行时间。而且由于大多数溢出会导致绝对不正确的结果,因此它的代码大小和执行时间都花在了充分的时间上。

PS。在C,C ++中,Objective-C有符号整数算术溢出是未定义的行为。根据定义,这意味着在有符号整数溢出的情况下编译器所做的任何操作都是正确的。处理有符号整数溢出的典型方法是忽略它,不考虑CPU给您的任何结果,在编译器中建立这样的假设:这种溢出将永远不会发生(例如,得出结论n + 1> n始终为true,因为溢出是假设永远不会发生),而很少使用的一种可能性是像Swift一样检查并检查是否发生溢出。


1
有时我想知道在C语言中推动UB驱动的精神错乱的人们是否正在暗中尝试通过某种其他语言破坏它。那是有道理的。
超级猫

治疗x+1>x为无条件真不要求编译器做任何“假设”对x如果编译器允许评估使用任意类型的更大的方便整数表达式(或者表现为尽管它这样做)。一个基于溢出的“假设”的更糟糕的例子是,确定uint32_t mul(uint16_t x, uint16_t y) { return x*y & 65535u; }一个编译器可以sum += mul(65535, x)用来决定该x值不能大于32768(这种行为可能会震惊编写C89基本原理的人们,这暗示了其中一个决定因素。 ..
超级猫

......在做unsigned short促进signed int的事实是二进制补码无声环绕实现(即大多数的C实现再利用)将处理代码,如以同样的方式是否上方unsigned short晋升intunsigned。该标准并不需要在无声的二进制补码硬件上实现以理智的方式对待上述代码,但是该标准的作者似乎已经希望他们会这样做。
超级猫

2

实际上,造成这种情况的真正原因纯粹是技术上的/历史上的:CPU的大部分忽略符号。通常只有一条指令在寄存器中添加两个整数,CPU一点也不在乎这两个整数是有符号的还是无符号的。减法甚至乘法也是如此。需要知道符号的唯一算术运算是除法。

之所以起作用,是因为几乎所有CPU都使用带符号整数的2的补码表示形式。例如,在4位2的补码中,5和-3的相加看起来像这样:

  0101   (5)
  1101   (-3)
(11010)  (carry)
  ----
  0010   (2)

观察丢掉进位位的环绕行为如何产生正确的带符号结果。同样,CPU通常将减法实现x - yx + ~y + 1

  0101   (5)
  1100   (~3, binary negation!)
(11011)  (carry, we carry in a 1 bit!)
  ----
  0010   (2)

这将减法作为硬件的附加实现,仅以简单的方式微调算术逻辑单元(ALU)的输入。有什么可能更简单?

由于乘法无非是一系列加法运算,所以它的行为类似。使用2的补码表示而忽略算术运算的进行的结果是简化的电路和简化的指令集。

显然,由于C被设计为在金属附近工作,因此它采用了与无符号算术的标准化行为完全相同的行为,仅允许有符号算术产生未定义的行为。然后,这种选择延续到了其他语言,例如Java和C#。


我也来这里给出答案。
李斯特先生,

不幸的是,某些人似乎认为在平台上编写低级C代码的人应该有胆量来期望适用于此目的的C编译器在发生溢出的情况下会表现出约束性的想法,这是完全不合理的。就我个人而言,我认为编译器的行为是合理的,就好像在编译器方便时使用任意扩展的精度来执行计算(因此,在32位系统上,如果x==INT_MAX,则在编译器的位置x+1可能会任意表现为+2147483648或-2147483648方便),但是…
超级猫

有些人似乎认为,如果xand yare 和are uint16_t和32位系统上的代码计算x*y & 65535u何时y65535,则编译器应假定x大于32768 时永远不会到达代码
。– supercat

1

一些答案已经讨论了检查的费用,并且您已经编辑了答案以质疑这是合理的理由。我将尝试解决这些问题。

在C和C ++中(作为示例),语言的设计原则之一就是不提供不需要的功能。通常用“不要为不使用的东西付钱”这句话来概括。如果程序员希望进行溢出检查,则他/她可以要求进行检查(并支付罚款)。这会使该语言使用起来更加危险,但是您选择了使用该语言后就选择使用该语言,因此您会承担风险。如果您不希望出现这种风险,或者如果您在安全性至高无上的性能中编写代码,则可以选择性能/风险权衡不同的更合适的语言。

但是,有了10,000,000,000次重复,检查所花费的时间仍不到1纳秒。

这种推理有一些错误:

  1. 这是特定于环境的。引用这样的具体数字通常没有多大意义,因为代码是针对各种环境编写的,这些环境的性能各不相同。对于一个为嵌入式环境进行编码的人,在(我假设)台式机上的1纳秒似乎惊人地快,而对于为超级计算机集群进行编码的人则似乎令人难以置信地慢。

  2. 对于很少运行的一段代码,1纳秒似乎毫无意义。另一方面,如果代码的主要功能是处于某个计算的内部循环中,那么您可以节省的每一小部分时间都会产生很大的不同。如果您在集群上运行仿真,则内部循环中节省的零点几分之一秒可以直接转化为在硬件和电力上的花费。

  3. 对于某些算法和上下文,10,000,000,000次迭代可能是微不足道的。同样,谈论仅适用于特定上下文的特定方案通常没有任何意义。

在某些情况下,这很重要,但是对于大多数应用程序而言,这并不重要。

也许你是对的。但是,这又是特定语言目标的问题。实际上,许多语言旨在满足“大多数”的需求,或者比其他问题更倾向于安全。其他诸如C和C ++则优先考虑效率。在这种情况下,仅仅因为大多数人都不会被打扰而使每个人都付出性能损失,这与该语言试图达到的目标背道而驰。


-1

有很好的答案,但我认为这里有一个遗漏的地方:整数溢出的影响不一定是一件坏事,事后很难知道是否由于溢出问题而i从存在MAX_INT变为存在MIN_INT或是否有意乘以-1。

例如,如果我想将所有大于0的可表示整数相加,我将使用for(i=0;i>=0;++i){...}加法循环-当它溢出时,它将停止加法运算,这是目标行为(抛出错误将意味着我必须规避任意保护,因为它会干扰标准算法)。限制原始算术是不好的做法,因为:

  • 它们用于所有方面-原始数学的减慢是每个运行程序的减慢
  • 如果程序员需要它们,他们可以随时添加它们
  • 如果您有它们,而程序员不需要它们(但确实需要更快的运行时),则他们将无法轻松删除它们以进行优化
  • 如果有它们并且程序员需要它们存在(如上面的示例中所示),则程序员都在承受运行时命中(可能或可能不相关),并且程序员仍然需要花费时间删除或解决“保护”问题。

3
如果语言不支持,那么程序员实际上不可能添加有效的溢出检查。如果函数计算的值被忽略,则编译器可以优化计算。如果一个函数计算的值是溢出检查,但另有忽略,如果溢出编译器必须执行计算和陷阱,即使溢出,否则不会影响程序的输出,并可以被忽略。
supercat

1
你不能从去INT_MAXINT_MIN被乘以-1。
大卫·康拉德

解决方案显然是为程序员提供一种在给定的代码块或编译单元中关闭检查的方法。
戴维·康拉德

for(i=0;i>=0;++i){...}这是我在团队中不鼓励使用的代码风格:它依赖于特殊效果/副作用,并且没有明确表达其意图。但是,我仍然感谢您的回答,因为它显示了不同的编程范例。
伯恩哈德·希勒

1
@Delioth:如果i是64位类型,即使在具有一致的无声环绕的二进制补码行为的实现上,每秒运行十亿次迭代,也只能保证这种循环在int允许运行时找到最大值。几百年了 在不能保证一致的无声环绕行为的系统上,无论给出多长时间的代码,都无法保证此类行为。
超级猫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.