在循环中的什么时候整数溢出变成了未定义的行为?
At what point in the loop does integer overflow become undefined behavior?
这是一个示例来说明我的问题,其中涉及一些更复杂的代码,我无法在此处post。
#include <stdio.h>
int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
printf("Hello\n");
a = a + 1000000000;
}
}
此程序在我的平台上包含未定义的行为,因为 a
将在第 3 次循环时溢出。
这会使 整个程序 具有未定义的行为,还是仅在 溢出实际发生后 ?编译器是否可能会计算出 a
将 溢出,因此它可以声明整个循环未定义,而不必理会 运行 printfs,即使它们都发生在溢出?
(标记为 C 和 C++ 尽管它们不同,因为如果它们不同,我会对两种语言的答案感兴趣。)
针对 16 位 int
的积极优化 C 或 C++ 编译器将 知道 将 1000000000
添加到 int
的行为类型是 undefined.
任何一个标准都允许做任何它想做的事,可能包括删除整个程序,留下int main(){}
。
但是更大的 int
呢?我还不知道有哪个编译器可以做到这一点(而且我无论如何都不是 C 和 C++ 编译器设计方面的专家),但我想 有时 一个针对 a 的编译器32 位 int
或更高版本会发现循环是无限的(i
不会改变) 和 所以 a
最终会溢出。所以再一次,它可以将输出优化为int main(){}
。我在这里想表达的观点是,随着编译器优化变得越来越积极,越来越多的未定义行为构造以意想不到的方式表现出来。
你的循环是无限的这一事实本身并不是未定义的,因为你正在循环体中写入标准输出。
如果您对纯理论答案感兴趣,C++ 标准允许未定义的行为 "time travel":
[intro.execution]/5:
A conforming implementation executing a well-formed program shall produce the same observable behavior
as one of the possible executions of the corresponding instance of the abstract machine with the same program
and the same input. However, if any such execution contains an undefined operation, this International
Standard places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation)
因此,如果您的程序包含未定义的行为,那么您的整个程序的行为是未定义的。
首先纠正一下这个问题的标题:
未定义行为不属于(特别是)执行领域。
未定义的行为影响所有步骤:编译、链接、加载和执行。
一些例子可以巩固这一点,请记住,没有一个部分是详尽无遗的:
- 编译器可以假设包含未定义行为的代码部分永远不会执行,因此假设导致它们的执行路径是死代码。参见 Chris Lattner 以外 none 的 What every C programmer should know about undefined behavior。
- 链接器可以假定存在多个弱符号定义(通过名称识别),所有定义都是相同的,这要归功于 One Definition Rule
- 加载程序(如果您使用动态库)可以假设相同,从而选择它找到的第一个符号;这通常(ab)用于在 Unix
上使用 LD_PRELOAD
技巧拦截调用
- 如果您使用悬挂指针,执行可能会失败 (SIGSEV)
这就是未定义行为如此可怕的原因:几乎不可能提前预测确切的行为会发生什么,必须在工具链的每次更新,底层 OS, ...
我推荐观看 Michael Spencer(LLVM 开发人员)的视频:CppCon 2016: My Little Optimizer: Undefined Behavior is Magic。
假设 int
是 32 位,未定义的行为发生在第三次迭代。因此,例如,如果循环只能有条件地可达,或者可以在第三次迭代之前有条件地终止,那么除非实际达到第三次迭代,否则不会有未定义的行为。但是,在未定义行为的情况下,程序的所有输出 都是未定义的,包括 "in the past" 相对于未定义行为调用的输出。例如,在您的情况下,这意味着无法保证在输出中看到 3 "Hello" 条消息。
您的示例没有考虑的一件事是优化。 a
在循环中设置但从未使用过,优化器可以解决这个问题。因此,优化器完全丢弃 a
是合法的,在这种情况下,所有未定义的行为都会像 boojum 的受害者一样消失。
当然这本身是未定义的,因为优化是未定义的。 :)
要理解为什么未定义的行为可以,让我们看一下'as-if'规则:
1.9 Program execution
1 The semantic descriptions in this International Standard define a
parameterized nondeterministic abstract machine. This International
Standard places no requirement on the structure of conforming
implementations. In particular, they need not copy or emulate the
structure of the abstract machine. Rather, conforming implementations
are required to emulate (only) the observable behavior of the abstract
machine as explained below.
这样,我们就可以将程序视为具有输入和输出的 'black box'。输入可以是用户输入、文件和许多其他东西。输出就是标准中提到的'observable behavior'.
标准只定义了输入和输出之间的映射,没有别的。它通过描述 'example black box' 来做到这一点,但明确表示具有相同映射的任何其他黑盒同样有效。这意味着黑框的内容是无关紧要的。
考虑到这一点,说未定义的行为在特定时刻发生是没有意义的。在黑盒的 sample 实现中,我们可以说它发生的时间和地点,但是 actual 黑盒可能是完全不同的东西,所以我们不能再说它发生的地点和时间了。理论上,编译器可以决定枚举所有可能的输入,并预先计算结果输出。然后在编译期间会发生未定义的行为。
未定义的行为是输入和输出之间不存在映射。程序可以对某些输入具有未定义的行为,但对其他输入具有定义的行为。那么输入输出之间的映射根本就是不完整的;存在不存在到输出的映射的输入。
问题中的程序对任何输入都有未定义的行为,因此映射为空。
TartanLlama 的回答是正确的。未定义的行为可能随时发生,甚至在编译期间。这可能看起来很荒谬,但它是允许编译器做他们需要做的事情的关键特征。成为编译器并不总是那么容易。你必须每次都完全按照规范所说的去做。然而,有时要证明特定行为正在发生可能非常困难。如果您还记得停机问题,那么开发您无法证明在提供特定输入时它是否完成或进入无限循环的软件是相当微不足道的。
我们可以让编译器变得悲观,并且不断地编译,因为担心下一条指令可能是这些停机问题之一,但这是不合理的。相反,我们给编译器一个通行证:在这些 "undefined behavior" 主题上,他们不承担任何责任。未定义的行为包括所有非常邪恶的行为,我们很难将它们与真正令人讨厌的邪恶的停止问题和诸如此类的东西区分开来。
有一个我喜欢 post 的例子,虽然我承认我失去了来源,所以我不得不解释一下。它来自 MySQL 的特定版本。在 MySQL 中,他们有一个循环缓冲区,其中填充了用户提供的数据。当然,他们想确保数据不会溢出缓冲区,所以他们进行了检查:
if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }
看起来很正常。但是,如果 numberOfNewChars 真的很大并且溢出了怎么办?然后它环绕并成为一个小于 endOfBufferPtr
的指针,因此永远不会调用溢出逻辑。所以他们在那个之前添加了第二个支票:
if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }
看起来你解决了缓冲区溢出错误,对吧?但是,提交了一个错误,指出此缓冲区在特定版本的 Debian 上溢出!仔细调查表明,这个版本的 Debian 是第一个使用特别前沿的 gcc 版本的。在这个版本的 gcc 上,编译器识别出 currentPtr + numberOfNewChars 可以 never 是一个比 currentPtr 更小的指针,因为指针溢出是未定义的行为!这足以让 gcc 优化整个检查,并且突然间您无法防止缓冲区溢出 ,即使您编写了代码来检查它!
这是规范行为。一切都是合法的(尽管据我所知,gcc 在下一版本中回滚了此更改)。这不是我认为的直觉行为,但如果您稍微发挥一下想象力,就会很容易看出这种情况的轻微变体如何成为编译器的暂停问题。正因为如此,规范编写者制定了它 "Undefined Behavior" 并声明编译器绝对可以做任何它喜欢的事情。
根据定义,未定义的行为是一个灰色区域。您根本无法预测它会做什么或不会做什么——这就是 "undefined behavior" 的意思。
自远古以来,程序员总是试图从未定义的情况中挽救已定义的残余。他们有一些他们真正想使用的代码,但结果是未定义的,所以他们试图争论: "I know it's undefined, but surely it will, at worst, do this or this; it will never do that." 有时这些争论或多或少是正确的——但通常,它们是错误的。随着编译器变得越来越聪明(或者,有些人可能会说,越来越狡猾),问题的界限也在不断变化。
所以说真的,如果您想编写保证可以工作并且可以长期工作的代码,只有一个选择:不惜一切代价避免未定义的行为。真的,如果你涉足它,它会回来困扰你。
从技术上讲,根据 C++ 标准,如果程序包含未定义的行为,则整个程序的行为 even at compile time(甚至在程序执行之前)都是未定义的。
在实践中,因为编译器可能假设(作为优化的一部分)不会发生溢出,至少程序在循环的第三次迭代(假设是 32 位机器)上的行为会未定义,尽管您很可能会在第三次迭代之前得到正确的结果。然而,由于整个程序的行为在技术上是未定义的,因此没有什么可以阻止程序生成完全错误的输出(包括没有输出)、在执行期间的任何时候在运行时崩溃,甚至无法完全编译(因为未定义的行为扩展到编译时间)。
未定义的行为为编译器提供了更多的优化空间,因为它们消除了关于代码必须做什么的某些假设。这样做时,依赖于涉及未定义行为的假设的程序不能保证按预期工作。因此,您不应依赖任何根据 C++ 标准被视为未定义的特定行为。
最佳答案是错误的(但很常见)误解:
未定义的行为是 运行-时间 属性*。它 不能 "time-travel"!
某些操作(根据标准)被定义为具有副作用并且无法被优化掉。执行 I/O 或访问 volatile
变量的操作属于此类。
但是,有一个警告:UB 可以是任何行为,包括撤消 之前的操作。在某些情况下,这可能会产生与优化早期代码类似的后果。
其实这和置顶回答(重点是我的)中的引述是一致的:
A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input.
However, if any such execution contains an undefined operation, this International Standard places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).
是的,这句话 说 "not even with regard to operations preceding the first undefined operation",但请注意,这是专门针对 执行,不仅仅是编译。
毕竟,未实际到达的未定义行为不会做任何事情,并且要实际到达包含 UB 的行,它之前的代码必须先执行!
所以是的,一旦 UB 被执行,之前操作的任何影响都变得不确定。但在此之前,程序的执行是明确定义的。
但是请注意,所有导致这种情况发生的程序执行都可以优化为等效程序,包括任何执行先前操作但随后取消其效果的程序.因此,前面的代码可能会被优化掉只要这样做等同于取消它们的效果;否则,它不能。请参阅下面的示例。
*注:这不与UB occurring at compile time不一致。如果编译器确实可以证明 UB 代码 will 总是对所有输入执行,那么 UB 可以扩展到编译时间。但是,这需要知道所有之前的代码最终returns,这是一个很强的要求。同样,请参阅下面的 example/explanation.
为了使其具体化,请注意以下代码 必须 打印 foo
并等待您的输入,无论其后是否有任何未定义的行为:
printf("foo");
getchar();
*(char*)1 = 1;
但是,还要注意,不能保证 foo
会在 UB 发生后保留在屏幕上,或者您键入的字符将不再在输入缓冲区中;这两个操作都可以是"undone",和UB"time-travel"有类似的效果。
如果 getchar()
行不存在,将优化掉这些行是合法的 当且仅当 与输出 foo
然后 "un-doing" 将 无法区分 。
两者是否无法区分将完全取决于实现(即取决于您的编译器和标准库)。例如,您的 printf
是否可以在等待另一个程序读取输出时阻塞 您的线程?还是马上return?
如果它能在这里阻塞,那么另一个程序就可以拒绝读取它的全部输出,它可能永远不会return,因此UB可能永远不会真正发生。
如果它可以立即return,那么我们知道它必须return,因此优化它与执行它然后取消它的效果完全没有区别.
当然,由于编译器知道其特定版本的 printf
允许的行为是什么,它可以相应地进行优化,因此 printf
在某些情况下可能会被优化,而在其他情况下则不会。但是,再一次,理由是这与 UB 未执行之前的操作无法区分,不是因为 UB.[=21= 之前的代码是 "poisoned" ]
除了理论答案之外,一个实际的观察结果是,长期以来,编译器对循环应用了各种转换,以减少循环中完成的工作量。例如,给定:
for (int i=0; i<n; i++)
foo[i] = i*scale;
编译器可能会将其转换为:
int temp = 0;
for (int i=0; i<n; i++)
{
foo[i] = temp;
temp+=scale;
}
这样就节省了每次循环迭代的乘法运算。一种额外的优化形式,编译器以不同程度的积极性进行调整,
会把它变成:
if (n > 0)
{
int temp1 = n*scale;
int *temp2 = foo;
do
{
temp1 -= scale;
*temp2++ = temp1;
} while(temp1);
}
即使在溢出时无声环绕的机器上,如果
有一些小于 n 的数字,当乘以 scale 时,会产生
0.如果从内存中读取比例尺,它也可能变成无限循环
不止一次并且某些东西意外地改变了它的价值(在任何情况下
"scale" 可以在不调用 UB 的情况下更改循环中,编译器不会
允许执行优化)。
虽然大多数此类优化在两个
短无符号类型相乘产生一个介于 INT_MAX+1 之间的值
和 UINT_MAX,gcc 在某些情况下会在循环中执行此类乘法
可能导致循环提前退出。我没有注意到这种行为源于
来自生成代码中的比较指令,但在某些情况下可以观察到
编译器使用溢出来推断循环最多可以执行的地方
4次或更少;在某些情况下,它默认不会生成警告
输入会导致 UB 而其他人不会,即使它的推论会导致
要忽略的循环上限。
由于这个问题是双重标记的 C 和 C++,我将尝试解决这两个问题。 C 和 C++ 在这里采用不同的方法。
在 C 中,实现必须能够证明将调用未定义的行为,以便将整个程序视为具有未定义的行为。在 OP 示例中,编译器证明这一点似乎微不足道,因此就好像整个程序是未定义的。
我们可以从 Defect Report 109 中看出这一点,它的关键问题是:
If however the C Standard recognizes the separate existence of "undefined values" (whose mere creation does not involve wholly "undefined behavior") then a person doing compiler testing could write a test case such as the following, and he/she could also expect (or possibly demand) that a conforming implementation should, at the very least, compile this code (and possibly also allow it to execute) without "failure."
int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];
int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}
So the bottom line question is this: Must the above code be "successfully translated" (whatever that means)? (See the footnote attached to subclause 5.1.1.3.)
响应是:
The C Standard uses the term "indeterminately valued" not "undefined value." Use of an indeterminate valued object results in undefined behavior.
The footnote to subclause 5.1.1.3 points out that an implementation is free to produce any number of diagnostics as long as a valid program is still correctly translated.
If an expression whose evaulation would result in undefined behavior appears in a context where a constant expression is required, the containing program is not strictly conforming. Furthermore, if every possible execution of a given program would result in undefined behavior, the given program is not strictly conforming.
A conforming implementation must not fail to translate a strictly conforming program simply because some possible execution of that program would result in undefined behavior. Because foo might never be called, the example given must be successfully translated by a conforming implementation.
在 C++ 中,该方法似乎更宽松,并且会建议程序具有未定义的行为,无论实现是否可以静态证明它。
我们有 [intro.abstrac]p5 表示:
A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input.
However, if any such execution contains an undefined operation, this document places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).
这是一个示例来说明我的问题,其中涉及一些更复杂的代码,我无法在此处post。
#include <stdio.h>
int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
printf("Hello\n");
a = a + 1000000000;
}
}
此程序在我的平台上包含未定义的行为,因为 a
将在第 3 次循环时溢出。
这会使 整个程序 具有未定义的行为,还是仅在 溢出实际发生后 ?编译器是否可能会计算出 a
将 溢出,因此它可以声明整个循环未定义,而不必理会 运行 printfs,即使它们都发生在溢出?
(标记为 C 和 C++ 尽管它们不同,因为如果它们不同,我会对两种语言的答案感兴趣。)
针对 16 位 int
的积极优化 C 或 C++ 编译器将 知道 将 1000000000
添加到 int
的行为类型是 undefined.
任何一个标准都允许做任何它想做的事,可能包括删除整个程序,留下int main(){}
。
但是更大的 int
呢?我还不知道有哪个编译器可以做到这一点(而且我无论如何都不是 C 和 C++ 编译器设计方面的专家),但我想 有时 一个针对 a 的编译器32 位 int
或更高版本会发现循环是无限的(i
不会改变) 和 所以 a
最终会溢出。所以再一次,它可以将输出优化为int main(){}
。我在这里想表达的观点是,随着编译器优化变得越来越积极,越来越多的未定义行为构造以意想不到的方式表现出来。
你的循环是无限的这一事实本身并不是未定义的,因为你正在循环体中写入标准输出。
如果您对纯理论答案感兴趣,C++ 标准允许未定义的行为 "time travel":
[intro.execution]/5:
A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input. However, if any such execution contains an undefined operation, this International Standard places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation)
因此,如果您的程序包含未定义的行为,那么您的整个程序的行为是未定义的。
首先纠正一下这个问题的标题:
未定义行为不属于(特别是)执行领域。
未定义的行为影响所有步骤:编译、链接、加载和执行。
一些例子可以巩固这一点,请记住,没有一个部分是详尽无遗的:
- 编译器可以假设包含未定义行为的代码部分永远不会执行,因此假设导致它们的执行路径是死代码。参见 Chris Lattner 以外 none 的 What every C programmer should know about undefined behavior。
- 链接器可以假定存在多个弱符号定义(通过名称识别),所有定义都是相同的,这要归功于 One Definition Rule
- 加载程序(如果您使用动态库)可以假设相同,从而选择它找到的第一个符号;这通常(ab)用于在 Unix 上使用
- 如果您使用悬挂指针,执行可能会失败 (SIGSEV)
LD_PRELOAD
技巧拦截调用
这就是未定义行为如此可怕的原因:几乎不可能提前预测确切的行为会发生什么,必须在工具链的每次更新,底层 OS, ...
我推荐观看 Michael Spencer(LLVM 开发人员)的视频:CppCon 2016: My Little Optimizer: Undefined Behavior is Magic。
假设 int
是 32 位,未定义的行为发生在第三次迭代。因此,例如,如果循环只能有条件地可达,或者可以在第三次迭代之前有条件地终止,那么除非实际达到第三次迭代,否则不会有未定义的行为。但是,在未定义行为的情况下,程序的所有输出 都是未定义的,包括 "in the past" 相对于未定义行为调用的输出。例如,在您的情况下,这意味着无法保证在输出中看到 3 "Hello" 条消息。
您的示例没有考虑的一件事是优化。 a
在循环中设置但从未使用过,优化器可以解决这个问题。因此,优化器完全丢弃 a
是合法的,在这种情况下,所有未定义的行为都会像 boojum 的受害者一样消失。
当然这本身是未定义的,因为优化是未定义的。 :)
要理解为什么未定义的行为可以
1.9 Program execution
1 The semantic descriptions in this International Standard define a parameterized nondeterministic abstract machine. This International Standard places no requirement on the structure of conforming implementations. In particular, they need not copy or emulate the structure of the abstract machine. Rather, conforming implementations are required to emulate (only) the observable behavior of the abstract machine as explained below.
这样,我们就可以将程序视为具有输入和输出的 'black box'。输入可以是用户输入、文件和许多其他东西。输出就是标准中提到的'observable behavior'.
标准只定义了输入和输出之间的映射,没有别的。它通过描述 'example black box' 来做到这一点,但明确表示具有相同映射的任何其他黑盒同样有效。这意味着黑框的内容是无关紧要的。
考虑到这一点,说未定义的行为在特定时刻发生是没有意义的。在黑盒的 sample 实现中,我们可以说它发生的时间和地点,但是 actual 黑盒可能是完全不同的东西,所以我们不能再说它发生的地点和时间了。理论上,编译器可以决定枚举所有可能的输入,并预先计算结果输出。然后在编译期间会发生未定义的行为。
未定义的行为是输入和输出之间不存在映射。程序可以对某些输入具有未定义的行为,但对其他输入具有定义的行为。那么输入输出之间的映射根本就是不完整的;存在不存在到输出的映射的输入。
问题中的程序对任何输入都有未定义的行为,因此映射为空。
TartanLlama 的回答是正确的。未定义的行为可能随时发生,甚至在编译期间。这可能看起来很荒谬,但它是允许编译器做他们需要做的事情的关键特征。成为编译器并不总是那么容易。你必须每次都完全按照规范所说的去做。然而,有时要证明特定行为正在发生可能非常困难。如果您还记得停机问题,那么开发您无法证明在提供特定输入时它是否完成或进入无限循环的软件是相当微不足道的。
我们可以让编译器变得悲观,并且不断地编译,因为担心下一条指令可能是这些停机问题之一,但这是不合理的。相反,我们给编译器一个通行证:在这些 "undefined behavior" 主题上,他们不承担任何责任。未定义的行为包括所有非常邪恶的行为,我们很难将它们与真正令人讨厌的邪恶的停止问题和诸如此类的东西区分开来。
有一个我喜欢 post 的例子,虽然我承认我失去了来源,所以我不得不解释一下。它来自 MySQL 的特定版本。在 MySQL 中,他们有一个循环缓冲区,其中填充了用户提供的数据。当然,他们想确保数据不会溢出缓冲区,所以他们进行了检查:
if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }
看起来很正常。但是,如果 numberOfNewChars 真的很大并且溢出了怎么办?然后它环绕并成为一个小于 endOfBufferPtr
的指针,因此永远不会调用溢出逻辑。所以他们在那个之前添加了第二个支票:
if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }
看起来你解决了缓冲区溢出错误,对吧?但是,提交了一个错误,指出此缓冲区在特定版本的 Debian 上溢出!仔细调查表明,这个版本的 Debian 是第一个使用特别前沿的 gcc 版本的。在这个版本的 gcc 上,编译器识别出 currentPtr + numberOfNewChars 可以 never 是一个比 currentPtr 更小的指针,因为指针溢出是未定义的行为!这足以让 gcc 优化整个检查,并且突然间您无法防止缓冲区溢出 ,即使您编写了代码来检查它!
这是规范行为。一切都是合法的(尽管据我所知,gcc 在下一版本中回滚了此更改)。这不是我认为的直觉行为,但如果您稍微发挥一下想象力,就会很容易看出这种情况的轻微变体如何成为编译器的暂停问题。正因为如此,规范编写者制定了它 "Undefined Behavior" 并声明编译器绝对可以做任何它喜欢的事情。
根据定义,未定义的行为是一个灰色区域。您根本无法预测它会做什么或不会做什么——这就是 "undefined behavior" 的意思。
自远古以来,程序员总是试图从未定义的情况中挽救已定义的残余。他们有一些他们真正想使用的代码,但结果是未定义的,所以他们试图争论: "I know it's undefined, but surely it will, at worst, do this or this; it will never do that." 有时这些争论或多或少是正确的——但通常,它们是错误的。随着编译器变得越来越聪明(或者,有些人可能会说,越来越狡猾),问题的界限也在不断变化。
所以说真的,如果您想编写保证可以工作并且可以长期工作的代码,只有一个选择:不惜一切代价避免未定义的行为。真的,如果你涉足它,它会回来困扰你。
从技术上讲,根据 C++ 标准,如果程序包含未定义的行为,则整个程序的行为 even at compile time(甚至在程序执行之前)都是未定义的。
在实践中,因为编译器可能假设(作为优化的一部分)不会发生溢出,至少程序在循环的第三次迭代(假设是 32 位机器)上的行为会未定义,尽管您很可能会在第三次迭代之前得到正确的结果。然而,由于整个程序的行为在技术上是未定义的,因此没有什么可以阻止程序生成完全错误的输出(包括没有输出)、在执行期间的任何时候在运行时崩溃,甚至无法完全编译(因为未定义的行为扩展到编译时间)。
未定义的行为为编译器提供了更多的优化空间,因为它们消除了关于代码必须做什么的某些假设。这样做时,依赖于涉及未定义行为的假设的程序不能保证按预期工作。因此,您不应依赖任何根据 C++ 标准被视为未定义的特定行为。
最佳答案是错误的(但很常见)误解:
未定义的行为是 运行-时间 属性*。它 不能 "time-travel"!
某些操作(根据标准)被定义为具有副作用并且无法被优化掉。执行 I/O 或访问 volatile
变量的操作属于此类。
但是,有一个警告:UB 可以是任何行为,包括撤消 之前的操作。在某些情况下,这可能会产生与优化早期代码类似的后果。
其实这和置顶回答(重点是我的)中的引述是一致的:
A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input.
However, if any such execution contains an undefined operation, this International Standard places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).
是的,这句话 说 "not even with regard to operations preceding the first undefined operation",但请注意,这是专门针对 执行,不仅仅是编译。
毕竟,未实际到达的未定义行为不会做任何事情,并且要实际到达包含 UB 的行,它之前的代码必须先执行!
所以是的,一旦 UB 被执行,之前操作的任何影响都变得不确定。但在此之前,程序的执行是明确定义的。
但是请注意,所有导致这种情况发生的程序执行都可以优化为等效程序,包括任何执行先前操作但随后取消其效果的程序.因此,前面的代码可能会被优化掉只要这样做等同于取消它们的效果;否则,它不能。请参阅下面的示例。
*注:这不与UB occurring at compile time不一致。如果编译器确实可以证明 UB 代码 will 总是对所有输入执行,那么 UB 可以扩展到编译时间。但是,这需要知道所有之前的代码最终returns,这是一个很强的要求。同样,请参阅下面的 example/explanation.
为了使其具体化,请注意以下代码 必须 打印 foo
并等待您的输入,无论其后是否有任何未定义的行为:
printf("foo");
getchar();
*(char*)1 = 1;
但是,还要注意,不能保证 foo
会在 UB 发生后保留在屏幕上,或者您键入的字符将不再在输入缓冲区中;这两个操作都可以是"undone",和UB"time-travel"有类似的效果。
如果 getchar()
行不存在,将优化掉这些行是合法的 当且仅当 与输出 foo
然后 "un-doing" 将 无法区分 。
两者是否无法区分将完全取决于实现(即取决于您的编译器和标准库)。例如,您的 printf
是否可以在等待另一个程序读取输出时阻塞 您的线程?还是马上return?
如果它能在这里阻塞,那么另一个程序就可以拒绝读取它的全部输出,它可能永远不会return,因此UB可能永远不会真正发生。
如果它可以立即return,那么我们知道它必须return,因此优化它与执行它然后取消它的效果完全没有区别.
当然,由于编译器知道其特定版本的 printf
允许的行为是什么,它可以相应地进行优化,因此 printf
在某些情况下可能会被优化,而在其他情况下则不会。但是,再一次,理由是这与 UB 未执行之前的操作无法区分,不是因为 UB.[=21= 之前的代码是 "poisoned" ]
除了理论答案之外,一个实际的观察结果是,长期以来,编译器对循环应用了各种转换,以减少循环中完成的工作量。例如,给定:
for (int i=0; i<n; i++)
foo[i] = i*scale;
编译器可能会将其转换为:
int temp = 0;
for (int i=0; i<n; i++)
{
foo[i] = temp;
temp+=scale;
}
这样就节省了每次循环迭代的乘法运算。一种额外的优化形式,编译器以不同程度的积极性进行调整, 会把它变成:
if (n > 0)
{
int temp1 = n*scale;
int *temp2 = foo;
do
{
temp1 -= scale;
*temp2++ = temp1;
} while(temp1);
}
即使在溢出时无声环绕的机器上,如果 有一些小于 n 的数字,当乘以 scale 时,会产生 0.如果从内存中读取比例尺,它也可能变成无限循环 不止一次并且某些东西意外地改变了它的价值(在任何情况下 "scale" 可以在不调用 UB 的情况下更改循环中,编译器不会 允许执行优化)。
虽然大多数此类优化在两个 短无符号类型相乘产生一个介于 INT_MAX+1 之间的值 和 UINT_MAX,gcc 在某些情况下会在循环中执行此类乘法 可能导致循环提前退出。我没有注意到这种行为源于 来自生成代码中的比较指令,但在某些情况下可以观察到 编译器使用溢出来推断循环最多可以执行的地方 4次或更少;在某些情况下,它默认不会生成警告 输入会导致 UB 而其他人不会,即使它的推论会导致 要忽略的循环上限。
由于这个问题是双重标记的 C 和 C++,我将尝试解决这两个问题。 C 和 C++ 在这里采用不同的方法。
在 C 中,实现必须能够证明将调用未定义的行为,以便将整个程序视为具有未定义的行为。在 OP 示例中,编译器证明这一点似乎微不足道,因此就好像整个程序是未定义的。
我们可以从 Defect Report 109 中看出这一点,它的关键问题是:
If however the C Standard recognizes the separate existence of "undefined values" (whose mere creation does not involve wholly "undefined behavior") then a person doing compiler testing could write a test case such as the following, and he/she could also expect (or possibly demand) that a conforming implementation should, at the very least, compile this code (and possibly also allow it to execute) without "failure."
int array1[5]; int array2[5]; int *p1 = &array1[0]; int *p2 = &array2[0]; int foo() { int i; i = (p1 > p2); /* Must this be "successfully translated"? */ 1/0; /* Must this be "successfully translated"? */ return 0; }
So the bottom line question is this: Must the above code be "successfully translated" (whatever that means)? (See the footnote attached to subclause 5.1.1.3.)
响应是:
The C Standard uses the term "indeterminately valued" not "undefined value." Use of an indeterminate valued object results in undefined behavior. The footnote to subclause 5.1.1.3 points out that an implementation is free to produce any number of diagnostics as long as a valid program is still correctly translated. If an expression whose evaulation would result in undefined behavior appears in a context where a constant expression is required, the containing program is not strictly conforming. Furthermore, if every possible execution of a given program would result in undefined behavior, the given program is not strictly conforming. A conforming implementation must not fail to translate a strictly conforming program simply because some possible execution of that program would result in undefined behavior. Because foo might never be called, the example given must be successfully translated by a conforming implementation.
在 C++ 中,该方法似乎更宽松,并且会建议程序具有未定义的行为,无论实现是否可以静态证明它。
我们有 [intro.abstrac]p5 表示:
A conforming implementation executing a well-formed program shall produce the same observable behavior as one of the possible executions of the corresponding instance of the abstract machine with the same program and the same input. However, if any such execution contains an undefined operation, this document places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).