分支语句中含有未定义行为的代码可以被认为是不可达的并进行优化成死代码吗?

92

考虑以下陈述:

*((char*)NULL) = 0; //undefined behavior

这明显涉及未定义的行为。在给定程序中存在这样的语句是否意味着整个程序是未定义的,还是只有控制流程到达此语句时行为变得未定义?

如果用户从未输入数字3,那么下面的程序是否定义良好?

while (true) {
 int num = ReadNumberFromConsole();
 if (num == 3)
  *((char*)NULL) = 0; //undefined behavior
}

无论用户输入什么,这是否完全是未定义的行为?

另外,编译器能否假设未定义的行为在运行时永远不会被执行?这将允许向后推理:

int num = ReadNumberFromConsole();

if (num == 3) {
 PrintToConsole(num);
 *((char*)NULL) = 0; //undefined behavior
}

在这里,编译器可以推断出如果num == 3,我们将总是调用未定义的行为。因此,这种情况必须是不可能的,数字不需要被打印。整个if语句可以被优化掉。这种反向推理是否符合标准?


20
有时我会想,高声望用户是否因为“哦,他们的声望很高,这一定是一个好问题”,而在问题上获得更多的赞同?但在这种情况下,我在看到提问者之前就已经读完这个问题并认为“哇,这太好了”。 - turbulencetoo
5
我认为未定义行为出现的时间是未定义的。 - eerorika
6
C++标准明确规定,任何具有未定义行为的执行路径都是完全未定义的。我甚至会理解为,任何一条具有未定义行为的执行路径的程序都是完全未定义的(这包括其他部分的合理结果,但不能保证)。编译器可以自由地使用未定义的行为来修改您的程序。http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html中有一些很好的例子。 - Jens
4
@Jens:这实际上只是指执行路径。否则,你会在 const int i = 0; if (i) 5/i; 这里遇到问题。 - MSalters
1
Raymond Chen曾经说过:“未定义的行为可能会导致时间旅行(除其他事情之外,但时间旅行是最奇特的)”。 - GSerg
显示剩余6条评论
8个回答

69
这个程序中是否存在这样一条语句意味着整个程序是未定义的,还是只有当控制流到达此语句时行为才变得未定义? 既不是前者太强也不是后者太弱。标准描述了程序在时间之外的行为,对象访问有时是有序的。如果程序的执行导致未定义的行为,则整个程序具有未定义的行为。因此,具有 UB 的不可到达的语句不会给程序带来 UB。由于编译器无法通常确定什么是 UB,因此需要允许 UB “回溯时间”,并在先前的序列点(或 C++11 术语中被排序在 UB 之前的事物)之前出错,以便允许优化器重新排列具有潜在 UB 的语句。因此,第二个条件太弱。你可以将这视为“UB 有一个时间机器”。具体而言,在第一个例子中,只有读取 3 时行为才是未定义的;在第二个例子中,编译器可以消除死代码,但该示例不是候选项,除非 PrintToConsole(3)已知肯定会返回,因为它可能会引发异常等。
void foo(int *p) {
    if (p) *p = 3;
    std::cout << *p << '\n';
}

并将其更改为:

*p = 3;
std::cout << "3\n";

为什么?因为如果 p 为空,则代码已经存在未定义行为,所以编译器可以假设它不为空并相应地进行优化。Linux 内核就因此而出现问题 (https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897) ,本质上是因为它在一种模式下操作,其中取消引用空指针不应该是未定义行为,而应该是期望产生一个已定义的硬件异常,内核可以处理该异常。启用优化时,gcc 要求使用 -fno-delete-null-pointer-checks 以提供超出标准的保证。

P.S. 对于问题“何时会发生未定义行为”的实际答案是“你计划离开工作前十分钟”。


5
实际上,过去由于这个问题存在相当多的安全性问题。特别是,任何事后的溢出检查都有可能因此被优化掉。例如 void can_add(int x) { if (x + 100 < x) complain(); } 可以完全被优化掉,因为如果 x+100 没有溢出,就什么也不会发生,而如果 x+100 溢出了,那么根据标准,这是未定义行为,所以可能什么也不会发生。 - fgp
3
@fgp:没错,这是一个优化方法,但如果程序员被它绊倒了,就会对它抱怨不已,因为它让人觉得编译器有意破坏你的代码来惩罚你。“如果我想让你删除它,我为什么要写那个呢!”;-) 但我认为有时候在处理更大的算术表达式时,假设没有溢出并避免任何昂贵的操作可能对优化器有用,因为这些操作只在这种情况下才需要。 - Steve Jessop
2
如果用户从未输入 3,那么说程序未定义是否正确,但是如果在执行期间输入 3,则整个执行都变得未定义。只有在确保程序将调用未定义行为的情况下(而不早于此),行为才允许成为任何值。这些陈述是否百分之百正确? - usr
3
我相信那是正确的。对于你提供的特定例子(并且假设数据处理是不可避免的),我认为一个实现原理上可以在 STDIN 的缓冲区中向前寻找数字“3”,如果找到了就可以立即结束当天的工作。 - Steve Jessop
3
感谢您的附言,如果可以的话,我会再额外加上一个+1。 - Fred Larson

10

这个标准规定在1.9/4

[注意:此国际标准对包含未定义行为的程序的行为不作任何要求。--注]

有趣的点可能在于"包含"的含义。稍后在1.9/5中它说:

但是,如果任何此类执行包含未定义的操作,则本国际标准不对使用该输入执行该程序的实现(甚至不涉及第一个未定义操作之前的操作)做出任何要求。

这里特别提到了"执行...以该输入"。我解释为,在当前分支未执行的其中一种可能分支中存在未定义行为不会影响当前执行分支。

然而,基于未定义行为的假设在代码生成期间是另一个问题。关于这个问题,请参见Steve Jessop的答案。


2
如果字面上理解,那就是所有真实存在的程序的死刑。 - usr
7
我认为问题并不是代码实际到达之前会不会出现未定义行为,根据我理解,问题是即使代码不会被执行,是否可能出现未定义行为。当然,答案是“不可能”。 - sepp2k
标准在1.9/4中对此并不是很清楚,但1.9/5可能可以被解释为你所说的。 - Danvil
1
注释不是规范性的。1.9/5比1.9/4中的注释更重要。 - MSalters

5
一个很好的例子是:
int foo(int x)
{
    int a;
    if (x)
        return a;
    return 0;
}

当前的GCC和Clang(在x86上)都会进行优化,以达到以下效果:

xorl %eax,%eax
ret

因为在 if (x) 的控制路径中,他们从 UB 推断出 x 总是为零。GCC 甚至不会给出使用未初始化值的警告!(因为应用上述逻辑的处理程序在生成未初始化值警告的处理程序之前运行)


2
有趣的例子。启用优化隐藏了警告,这相当讨厌。这甚至没有记录 - GCC文档只说启用优化会产生更多警告。 - sleske
@sleske 我同意,这很讨厌,但未初始化值警告通常很难“正确处理”——完美地处理它们等同于停机问题,程序员在添加“不必要”的变量初始化以消除错误警告时会变得奇怪而非理性,因此编译器作者陷入了困境。我曾经在 GCC 上进行过修改,我记得每个人都害怕处理未初始化值警告的过程。 - zwol
2
@supercat 2-5%在这些事情中是巨大的。我见过人们为0.1%而流汗。 - zwol
@zwol:有时我会为了节省几个字节而费尽心思,但如果在本来平稳的情况下不得不浪费代码来防止 UB 的话,我会感到相当恼火。我认为,如果大部分分析只在识别需要假设的地方进行,并邀请程序员添加 __ASSUME* 或 __REJECT_ASSUMPTION 指令(后者的语义是“不要再打扰我”),并且如果这些指令用于优化,编译过程可能会更有效率。 - supercat
@zwol:我认为,在这种情况下,基于指令的方法比盲目假设程序从不涉及UB更容易且更有效,特别是在处理UB的自然平台后果比确保其不发生要便宜得多的情况下。此外,C语言的许多规则对于高性能计算来说真的非常糟糕,比如说char*可以别名但其他类型不能,或者有些规则说有符号类型可能会出现UB,但无符号类型不会,除非它们被提升为有符号类型,那么一切都不确定了。 - supercat
显示剩余8条评论

4
当前的C++工作草案在1.9.4中表示:

本国际标准对包含未定义行为的程序的行为不做任何要求。

基于此,我认为在任何执行路径上都包含未定义行为的程序可以在其执行的每个时刻做任何事情。
有两篇关于未定义行为和编译器通常会做什么的好文章:

1
这没有任何意义。函数 int f(int x) { if (x > 0) return 100/x; else return 100; } 肯定不会导致未定义的行为,即使 100/0 当然是未定义的。 - fgp
1
@fgp 尤其是在1.9/5标准中,规定了如果可能会出现未定义行为,那么它何时出现并不重要。例如,printf("Hello, World"); *((char*)NULL) = 0 并不能保证打印任何内容。这有助于优化,因为编译器可以自由地重新排序操作(当然要遵守依赖约束),而无需考虑未定义的行为。 - fgp
1
确切地说,重要的是UB是否实际上可以被触发,而不是它是否在理论上可以被触发。或者你准备辩称int x,y; std::cin >> x >> y; std::cout << (x+y);允许说"1+1=17",仅仅因为有一些输入使得x+y溢出(这是UB,因为int是有符号类型)。 - fgp
@fgp:在什么情况下编译器可以假定printf总是会完成?如果代码执行void foo{int i=printf("hey")/0;},并且标准输出被发送到一个缓冲区距离满的终端,并且该终端被无限期地阻塞,那么除法操作不需要等待成功缓冲输出的文本吗? - supercat
@fgp:如果允许printf在不返回的情况下终止程序执行,我认为如果它这样做了,程序的行为应该是明确定义的。虽然可能有一些平台保证printf会立即返回(在某些嵌入式平台上,我已经定义了标准输出以丢弃数据,以避免延迟时间关键的主线代码),但肯定有许多平台相反。标准在这方面有什么规定吗? - supercat
显示剩余7条评论

3
未定义行为发生在程序即使接下来发生什么事情都会导致未定义行为的情况下。 不过,您提供了以下示例。
int num = ReadNumberFromConsole();

if (num == 3) {
 PrintToConsole(num);
 *((char*)NULL) = 0; //undefined behavior
}

如果编译器不知道PrintToConsole的定义,它就无法删除if (num == 3)条件语句。假设您有一个名为LongAndCamelCaseStdio.h的系统头文件,其中包含以下PrintToConsole声明。

void PrintToConsole(int);

这并没有太多帮助,现在,让我们看看这个函数的实际定义,来判断供应商到底多么邪恶(或者也许不是那么邪恶,未定义行为可能更糟糕)。

int printf(const char *, ...);
void exit(int);

void PrintToConsole(int num) {
    printf("%d\n", num);
    exit(0);
}

编译器实际上必须假设任何它不知道其功能的任意函数可能会退出或抛出异常(在C ++的情况下)。您可以注意到*((char*)NULL) = 0;不会被执行,因为在PrintToConsole调用后,执行不会继续。
PrintToConsole实际返回时,未定义的行为就会发生。编译器期望这种情况不会发生(因为无论如何都会导致程序执行未定义行为),因此任何事情都可能发生。
然而,让我们考虑另一种情况。假设我们正在进行空值检查,并在空值检查后使用变量。
int putchar(int);

const char *warning;

void lol_null_check(const char *pointer) {
    if (!pointer) {
        warning = "pointer is null";
    }
    putchar(*pointer);
}

在这个例子中,很容易注意到lol_null_check需要非空指针。给非易失性全局warning变量赋值不会导致程序退出或抛出任何异常。pointer也是非易失性的,因此它不能在函数中间自动更改其值(如果它这样做,那么行为未定义)。调用lol_null_check(NULL)将导致未定义的行为,这可能导致变量未被分配(因为此时已知程序执行未定义的行为)。
然而,未定义的行为意味着程序可以做任何事情。因此,没有阻止未定义的行为回溯时间,并在第一行int main()执行之前使您的程序崩溃。这是未定义的行为,它不必有意义。它也可能在键入3之后崩溃,但未定义的行为将返回时间,并在您键入3之前崩溃。而且谁知道,也许未定义的行为会覆盖您的系统RAM,并在您的未定义程序不运行时导致您的系统在2周后崩溃。

1
所有的观点都是正确的。PrintToConsole 是我尝试插入一个程序外部的副作用,即使在崩溃后也是可见的,并且具有强序列性。我想创建这样一种情况,我们可以确定这个语句是否被优化掉了。但你说得对,它可能永远不会返回;你写到全局变量的例子可能会受到与 UB 无关的其他优化的影响。例如,未使用的全局变量可能会被删除。你有没有想过以一种保证返回控制的方式创建外部副作用? - usr
编译器可以假定返回值的代码是否会产生任何外部可观察的副作用吗?据我所知,即使是一个简单地读取volatile变量的方法也可能合法地触发I/O操作,这可能会立即中断当前线程;然后中断处理程序可以在线程执行其他任何操作之前终止该线程。在此之前,我看不到编译器可以推动未定义的行为的任何理由。 - supercat
从C标准的角度来看,让未定义行为导致计算机向某些人发送消息并追踪和销毁程序先前操作的所有证据并不违法。但是,如果某个操作可以终止线程,则在该操作之前发生的所有顺序必须在之后发生的任何未定义行为之前发生。 - supercat

3

“行为”一词表示正在进行某些事情。从未执行的语句不是“行为”。

举个例子:

*ptr = 0;

这是未定义行为吗?假设我们在程序执行期间至少有一次 100% 确定 ptr == nullptr。答案应该是是。

那么这个呢?

 if (ptr) *ptr = 0;

那是未定义的吗?(至少记得ptr == nullptr一次吗?)我希望不是,否则你将无法编写任何有用的程序。

这个答案的制作过程中没有伤害到任何标准语言。


1
许多事物的标准都花费了大量精力来描述实现应该或不应该做的事情,使用类似于IETF RFC 2119中定义的术语(虽然不一定引用该文档中的定义)。在许多情况下,对实现应该做的事情的描述除非它们是无用或不切实际的,比所有符合规范的实现必须符合的要求更重要。
不幸的是,C和C++标准倾向于避免描述那些虽然不是100%所需但仍应该期望高质量实现的事情,这些实现没有记录相反的行为。建议实现应该做某事可能被视为暗示那些不这样做的实现是劣质的,在通常明显哪些行为在给定实现上有用或实用,而哪些是不实用和无用的情况下,标准很少干涉这种判断。
一个聪明的编译器可以符合标准,同时消除任何无效代码,除非代码接收到必然导致未定义行为的输入,但是“聪明”和“愚蠢”并不是反义词。标准作者认为,在某些实现中,特定情况下的有用行为可能是无用和不切实际的,并不意味着这些行为在其他情况下应被视为实用和有用的。如果一种实现可以保持行为保证,而代价仅仅是失去了“死分支”修剪机会,那么用户代码从该保证中获得的几乎所有价值都将超过提供它所需的成本。死分支消除可能在不需要放弃任何东西的情况下很好,但是如果在某种情况下,除了死分支消除之外,用户代码可以处理几乎任何可能的行为,那么用户代码需要花费的任何努力来避免UB都可能超过从DBE中获得的价值。

避免未定义行为确实会对用户代码产生一定的“代价”,这是一个很好的观点。 - usr
@usr: 这是现代主义者完全忽略的一点。我需要加一个例子吗?例如,如果代码需要在 x*y 不会溢出时评估 x*y < z,并且在溢出的情况下以任意方式产生 0 或 1,但没有副作用,则绝大多数平台在满足第二和第三要求方面不应比满足第一要求更昂贵,但为了保证所有情况下符合标准定义的行为而编写表达式的任何方法都将在某些情况下增加显着的成本。将表达式写为(int64_t)x*y < z 可能会使计算成本增加超过四倍... - supercat
在某些平台上,将其写成 (int)((unsigned)x*y) < z 可以防止编译器使用本来可能有用的代数替换(例如,如果它知道 xz 相等且为正,则可以将原始表达式简化为 y<0,但使用 unsigned 的版本会强制编译器执行乘法)。如果编译器可以保证即使标准没有规定,它也将遵守“产生无副作用的 0 或 1”的要求,用户代码可以为编译器提供无法获得的优化机会。 - supercat
是的,似乎一些较轻形式的未定义行为会在这里很有帮助。程序员可以开启一种模式,在溢出时导致 x*y 发出正常值,而不是任何值。对我来说,在 C/C++ 中可配置的 UB 非常重要。 - usr
如果C89标准的作者在说将短无符号值提升为有符号值是最严重的破坏性变化时是真诚的,并且不是无知的傻瓜,那就意味着他们预期在平台定义了有用的行为保证的情况下,这些平台的实现已经使这些保证可供程序员使用,并且程序员已经利用它们,在这种平台上编译器将继续提供这些行为保证,无论标准是否命令这样做。 - supercat

1
如果程序执行了触发未定义行为的语句,程序输出/行为不受任何要求;无论这些行为是否在触发未定义行为之前或之后发生都没关系。
你对所有三个代码片段的推理是正确的。特别地,编译器可能会像GCC对待__builtin_unreachable()一样处理任何无条件引发未定义行为的语句:将其视为提示该语句是不可到达的优化(因此,所有无条件引导它的代码路径也是不可到达的)。当然,其他类似的优化也是可能的。

2
出于好奇,__builtin_unreachable() 何时开始具有向前和向后的影响?例如 extern volatile uint32_t RESET_TRIGGER; void RESET(void) { RESET_TRIGGER = 0xAA55; __memorybarrier(); __builtin_unreachable(); } 我可以看到 builtin_unreachable() 可以让编译器知道它可以省略 return 指令,但这与说先前的代码可以被省略是相当不同的。 - supercat
@supercat,由于RESET_TRIGGER是易失性的,所以对该位置的写入可能具有任意副作用。 对编译器来说,它就像是一个不透明的方法调用。 因此,无法证明(也不是这种情况)可以到达“__builtin_unreachable”。 此程序已定义。 - usr
如果编译器在处理对公开对象的访问时,不将volatile访问视为对不透明方法调用,则我认为没有特别的理由指望它会对其他目的这样做。标准并不要求实现这样做,因为有些硬件平台上,编译器可能能够知道volatile访问的所有可能影响。然而,适用于嵌入式使用的编译器应该认识到,volatile访问可能会触发在编译器编写时尚未发明出来的硬件。 - supercat
@supercat 我认为你是对的。看起来,volatile操作对“抽象机器没有影响”,因此不能终止程序或引起副作用。 - usr
@usr:标准应该在粗体字的大通告中说明它定义了实现符合要求所必需的内容,并且不会定义所有可能需要使实现适用于任何特定目的的功能和保证。长期以来,它一直被误解得非常可怕,如果人们知道它将如何被解释,就会被彻底拒绝。 - supercat
显示剩余3条评论

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