这种有符号整数溢出是未定义的行为,就像在 C/C++ 中一样
What Every C Programmer Should Know About Undefined Behavior
除非您使用 gcc -fwrapv 或等效项进行编译以使有符号整数溢出明确定义为 2 的补码环绕。对于 gcc -fwrapv 或任何其他定义整数溢出 = 环绕的实现,您在实践中碰巧看到的环绕是明确定义的,并且遵循其他 ISO C 规则,用于整数文字类型和评估表达式。
T var = expression 仅将表达式隐式转换为类型T 在 根据标准规则评估表达式。喜欢(T)(expression),不像(int64_t)2147483647 + (int64_t)1。
编译器可能会选择假设永远不会到达此执行路径并发出非法指令或其他东西。在常量表达式的溢出中实现 2 的补码环绕只是一些/大多数编译器做出的选择。
ISO C 标准规定数字文字的类型为 int,除非值太大而无法容纳(可以是 long or long long, or unsigned for hex),或者如果使用了大小覆盖。然后,通常的整数提升规则适用于像 + 和 * 这样的二元运算符,无论它是否是编译时常量表达式的一部分。
这是一个简单且一致的规则,编译器很容易实现,即使在 C 的早期,编译器必须在有限的机器上运行。
因此,在 ISO C/C++ 中,2147483647 + 1 在 32 位 int 的实现中是未定义的行为。 将其视为int(并因此将值包装为有符号负数)自然遵循表达式应具有何种类型的 ISO C 规则,以及非溢出情况的正常评估规则。当前的编译器不会选择以不同的方式定义行为。
ISO C/C++ 确实未定义它,因此实现可以在不违反 C/C++ 标准的情况下从字面上挑选任何东西(包括鼻恶魔)。在实践中,这种行为(换行 + 警告)是不太令人反感的行为之一,并将有符号整数溢出视为换行,这在实际运行时经常发生。
此外,一些编译器可以选择在所有情况下正式定义该行为,而不仅仅是编译时常量表达式。 (gcc -fwrapv)。
编译器会对此发出警告
好的编译器会在编译时出现多种形式的 UB 时发出警告,包括这种形式。 GCC 和 clang 即使没有 -Wall 也会发出警告。来自the Godbolt compiler explorer:
clang
<source>:5:20: warning: overflow in expression; result is -2147483648 with type 'int' [-Winteger-overflow]
a = 2147483647 + 1;
^
gcc
<source>: In function 'void foo()':
<source>:5:20: warning: integer overflow in expression of type 'int' results in '-2147483648' [-Woverflow]
5 | a = 2147483647 + 1;
| ~~~~~~~~~~~^~~
GCC 至少从 2006 年的 GCC4.1(Godbolt 上的最旧版本)开始默认启用此警告,并从 3.3 开始发出叮当声。
MSVC 只警告 with -Wall,这对于 MSVC 来说大部分时间都是不可用的冗长,例如stdio.h 会产生大量警告,例如 'vfwprintf': unreferenced inline function has been removed。 MSVC 对此的警告如下:
MSVC -Wall
<source>(5): warning C4307: '+': signed integral constant overflow
@HumanJHawkins asked为什么会这样设计:
对我来说,这个问题是在问,为什么编译器不使用数学运算结果适合的最小数据类型?使用整数文字,可以在编译时知道发生了溢出错误。但是编译器不会费心去了解和处理它。这是为什么呢?
“懒得处理”有点强;编译器确实会检测到溢出并发出警告。但它们遵循 ISO C 规则,即 int + int 的类型为 int,并且每个数字文字的类型为 int。编译器只是故意选择包装而不是加宽并赋予表达式不同于您期望的类型。 (而不是完全因为 UB 来救助。)
在运行时发生带符号溢出时,包装很常见,尽管在循环中编译器会积极优化 int i / array[i] 到 avoid redoing sign-extension every iteration。
由于与格式字符串的类型不匹配,扩展会带来自己的(较小的)陷阱集,例如 printf("%d %d\n", 2147483647 + 1, 2147483647); 具有未定义的行为(并且在 32 位机器上实际上失败)。如果2147483647 + 1 隐式提升为long long,则需要%lld 格式字符串。 (实际上它会中断,因为 64 位 int 通常在 32 位机器上的两个 arg 传递槽中传递,所以第二个 %d 可能会看到第一个 long long 的第二半。)
公平地说,这对-2147483648 来说已经是个问题了。作为 C/C++ 源代码中的表达式,它的类型为 long 或 long long。它与一元 - 运算符分开解析为 2147483648,并且 2147483648 不适合 32 位有符号 int。因此,它具有可以表示该值的下一个最大类型。
但是,任何受该扩展影响的程序在没有它的情况下都会有 UB(并且可能是包装),并且扩展更有可能使代码发生工作。这里有一个设计理念问题:太多的“碰巧工作”和宽容的行为使得很难理解为什么某件事确实工作,并且很难证实它是否可以移植到其他实现中其他类型的宽度。与 Java 等“安全”语言不同,C 非常不安全,并且在不同的平台上具有不同的实现定义的东西,但许多开发人员只有一种实现可以测试。 (尤其是在互联网和在线持续集成测试之前。)
ISO C 没有定义行为,所以是的,编译器可以将新行为定义为扩展,而不会破坏与任何无 UB 程序的兼容性。但是除非每个编译器都支持它,否则你不能在可移植的 C 程序中使用它。我可以把它想象成至少 gcc/clang/ICC 支持的 GNU 扩展。
此外,这样的选项会与确实定义行为的-fwrapv 发生冲突。总的来说,我认为它不太可能被采用,因为有一种方便的语法来指定文字的类型(0x7fffffffUL + 1 为您提供了一个 unsigned long,它保证对于该值作为 32 位无符号整数来说足够宽。)
但让我们首先将其视为 C 的选择,而不是当前的设计。
一种可能的设计是从其值推断整个整数常量表达式的类型,以任意精度计算。为什么使用任意精度而不是long long 或unsigned long long?如果由于 /、>>、- 或 & 运算符而导致最终值很小,那么对于表达式的中间部分来说,这些可能不够大。
或者更简单的设计,例如 C 预处理器,其中常量整数表达式以某个固定的实现定义的宽度(例如至少 64 位)进行评估。 (然后根据最终值或表达式中最宽的临时值分配类型?)但这对于 16 位机器上的早期 C 有明显的缺点,它使编译时表达式的计算速度比 if 慢编译器可以在内部为int 表达式使用机器的本机整数宽度。
整数常量表达式在 C 中已经有些特殊,在某些情况下需要在编译时进行评估,例如对于static int array[1024 * 1024 * 1024];(其中乘法将在具有 16 位整数的实现上溢出。)
显然我们不能有效地将提升规则扩展到非常量表达式;如果 (a*b)/c 在 32 位机器上可能必须将 a*b 评估为 long long 而不是 int,则除法将需要扩展精度。 (例如,x86 的 64 位 / 32 位 => 32 位除法指令在商溢出时出错,而不是静默截断结果,因此即使将结果分配给 int 也不会让编译器针对一些情况。)
另外,我们是否真的希望a * b 的行为/定义取决于a 和b 是否为static const? 让编译时评估规则与规则匹配因为非常量表达式通常看起来不错,即使它留下了这些讨厌的陷阱。但同样,好的编译器可以在常量表达式中警告这一点。
这个 C 陷阱的其他更常见的情况是使用 1<<40 而不是 1ULL << 40 来定义位标志,或者将 1T 写为 1024*1024*1024*1024。