未定义行为、未指定行为和实现定义行为

650

在C和C++中,什么是未定义行为(UB)?又有哪些未指定行为实现定义行为呢?它们之间有何区别?


1
我非常确定我们以前做过这件事,但是我找不到它了。另请参阅:https://dev59.com/HEzSa4cB1Zd3GeqPjhpL - dmckee --- ex-moderator kitten
1
http://theunixshell.blogspot.com/2013/07/what-is-undefined-behavior.html - Vijay
1
这里有一篇有趣的讨论(第"L附录和未定义行为"部分),链接为http://www.drdobbs.com/go-parallel/article/print?articleId=232901670&siteSectionName=。 - Owen
https://en.cppreference.com/w/cpp/language/ub - Jesper Juhl
9个回答

488

未定义行为是C和C++语言的一个方面,对于来自其他语言的程序员来说可能会感到惊讶(其他语言试图更好地隐藏它)。基本上,即使许多C++编译器不会报告程序中的任何错误,但仍有可能编写出不可预测表现的C++程序!

让我们看一个经典的例子:

#include <iostream>

int main()
{
    char* p = "hello!\n";   // yes I know, deprecated conversion
    p[0] = 'y';
    p[5] = 'w';
    std::cout << p;
}

变量p指向字符串字面值"hello!\n",下面的两个赋值尝试修改该字符串字面值。这个程序做什么?根据C++标准的第2.14.5段第11句话,它调用了未定义行为

尝试修改字符串字面值的效果是未定义的。

我听到有人尖叫着说“等等,我可以编译这个没有问题,并得到输出yellow”或者“你说什么未定义,字符串字面值存储在只读内存中,所以第一个赋值尝试会导致核心转储”。这正是未定义行为的问题所在。基本上,一旦您调用未定义行为(甚至鼻子恶魔),标准允许发生任何事情。如果根据您对语言的心理模型存在“正确”的行为,那么该模型就是错误的。 C++标准拥有唯一的投票权。
其他未定义行为的例子包括访问超出其边界的数组,取消引用空指针在其生命周期结束后访问对象或编写据称聪明的表达式,例如i++ + ++i
C++标准的第1.9节还提到了未定义行为的两个不太危险的兄弟,即未指定行为实现定义的行为

本国际标准中的语义描述定义了一个参数化的非确定性抽象机。

本国际标准将抽象机的某些方面和操作描述为实现定义的(例如,sizeof(int))。这些构成了抽象机的参数。每个实现都应包括描述其特征和行为的文档。

抽象机的某些其他方面和操作被描述为未指定的(例如,函数参数求值的顺序)。在可能的情况下,本国际标准定义了一组允许的行为。这些定义了抽象机的非确定性方面。

某些其他操作被描述为未定义的(例如,取消引用空指针的效果)。[ :本国际标准对包含未定义行为的程序的行为没有任何要求。—end note ]

具体而言,第1.3.24节规定:

允许的未定义行为范围从完全忽略具有不可预测结果的情况到以环境特征为特征进行翻译或程序执行的文档方式行为(无论是否发出诊断消息),再到终止翻译或执行(发出诊断消息)。

你如何避免遇到未定义行为?基本上,你需要阅读由懂得自己在说什么的作者所写的好的 C++ 书籍。避免使用互联网教程。避免使用 Bullschildt。


11
由于合并标签的缘故,有一个奇怪的事实是这个答案只涵盖了C++,但是这个问题的标签中包括了C。C有一个不同的“未定义行为”概念:即使对于某些规则违反(约束违反)也会要求实现给出诊断信息。 - Johannes Schaub - litb
15
“@Benoit这是未定义行为,因为标准规定了它是未定义行为,没有别的。在一些系统上,字符串字面量确实存储在只读文本段中,如果您尝试修改一个字符串字面量,程序将崩溃。在其他系统上,字符串字面量确实会发生变化。标准没有规定必须发生什么。这就是未定义行为的意思。” - fredoverflow
8
为什么好的编译器允许我们编译会产生未定义行为的代码?编译这种代码有什么好处?为什么不是所有好的编译器在尝试编译会产生未定义行为的代码时都会给出一个巨大的红色警告标志? - Pacerier
18
有些东西在编译时无法进行检查。例如,不能保证空指针永远不会被解引用,但这是未定义的。 - Tim Seguine
5
@Celeritas,未定义行为可能是不确定的。例如,无法预先知道未初始化内存的内容将是什么,例如int f(){int a; return a;}a的值可能会在函数调用之间更改。 - Mark
显示剩余15条评论

121

嗯,基本上这只是从C标准中直接复制粘贴过来的:

3.4.1 实现定义行为是指未指定行为,每个实现都会记录选择方式。
EXAMPLE:一个实现定义行为的例子是当带符号整数向右位移时高阶位的传播。
3.4.3 未定义行为是指在使用不可移植或错误的程序结构或错误数据时,国际标准没有对其施加任何要求的行为。
NOTE:可能的未定义行为范围从完全忽略情况并产生不可预测的结果,到在翻译或程序执行期间以环境特征的已记录方式进行操作(可以带或不带诊断消息),或者终止翻译或执行(带有诊断消息)。
EXAMPLE:一个未定义行为的例子是整数溢出的行为。
3.4.4 未指定行为是指使用未指定值或其他行为,国际标准提供两个或多个可能性,并且对于任何情况不再有进一步要求的行为。
EXAMPLE:一个未指定行为的例子是函数参数求值的顺序。

4
"implementation-defined"和"unspecified behavior"有何不同? implementation-defined指的是由编译器或标准规定的行为,但可以在不同的实现中有所不同。而unspecified behavior是指标准没有明确规定其行为,因此由编译器自由决定其行为。 - Zolomon
32
就像原文所说的一样:基本上是相同的事情,只不过在实现定义的情况下,实现必须记录(以保证)会发生什么,而在未指定的情况下,实现不需要记录或保证任何事情。 - AnT stands with Russia
13
超现代编译器可以做得更好。对于给定的代码 int foo(int x) { if (x >= 0) launch_missiles(); return x << 1; },编译器可以确定只要不发射导弹就会引起未定义行为,因此可以将调用 launch_missiles() 的行为变为无条件执行。 - supercat
4
正如引文所述,未指定的行为通常被限制在一组可能的行为中。在某些情况下,您甚至可能得出结论,所有这些可能性在给定的上下文中都是可以接受的,在这种情况下,未指定的行为根本不是问题。未定义的行为是完全不受限制的(例如,“程序可能决定格式化您的硬盘”)。未定义的行为始终是一个问题。 - AnT stands with Russia
1
@GabrielStaples:我认为最新发布的Rationale文档可以在http://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf找到(据我所知,标准的后续版本没有发布Rationale文档)。 - supercat
显示剩余13条评论

72

使用更简单的措辞可能比标准的严格定义更容易理解。

实现定义行为:
语言规定我们有数据类型。编译器厂商指定了它们应该使用的大小,并提供了他们所做的文档。

未定义行为:
您正在执行错误操作。例如,您在int中有一个非常大的值,而它却无法容纳在char中。那么该如何将该值放入char中呢?实际上没有办法!任何事情都可能发生,但最明智的做法是将该int的第一个字节放入char中。这样做将分配第一个字节是错误的,但这就是底层发生的事情。

未指定行为:
这两个函数中哪一个先执行?

void fun(int n, int m);

int fun1() {
    std::cout << "fun1";
    return 1;
}
int fun2() {
    std::cout << "fun2";
    return 2;
}

//...

fun(fun1(), fun2()); // which one is executed first?
该编程语言没有指定计算顺序,可能由左到右或由右到左!因此未指定的行为可能会导致未定义的行为,但是您的程序肯定不应该产生未指定的行为。
@eSKay我认为您的问题值得编辑回答以进一步澄清:)
对于fun(fun1(),fun2());不是“实现定义”的行为吗?毕竟,编译器必须选择其中一种路径,对吗?
实现定义和未指定之间的区别在于,在第一种情况下,编译器应该选择一种行为,但在第二种情况下却不必如此。例如,一个实现必须有且只有一个sizeof(int)的定义。因此,它不能说sizeof(int)在程序的某些部分为4,而在其他部分为8。与未指定的行为不同,编译器可以说:“好吧,我要从左到右评估这些参数,并且下一个函数的参数将从右到左评估。”它可以发生在同一个程序中,这就是为什么它被称为未指定。事实上,如果一些未指定的行为被指定,C++可能会更容易。在这里查看Stroustrup博士的回答

3
对于 fun(fun1(), fun2()); 这条语句,它的行为难道不是“实现定义”的吗?毕竟编译器必须选择其中一种执行顺序,对吗? - Lazer
1
@AraK:谢谢你的解释。我现在明白了。顺便说一下,"我将从左到右评估这些参数,并且下一个函数的参数将从右到左进行评估",我理解这是可能发生的。但是在我们现在使用的编译器中真的会这样吗? - Lazer
1
@eSKay 你需要咨询一个和多个编译器打交道的大师 :) 据我所知,VC总是从右到左评估参数。 - Khaled Alshaya
5
@Lazer:这绝对是可能发生的。简单情境:foo(bar, boz()) 和 foo(boz(), bar),其中bar是整数,boz()是返回整数的函数。假设CPU需要在寄存器R0-R1中传递参数,函数结果在R0中返回;函数可能会破坏R1。评估"bar"在"boz()"之前需要在调用boz()之前将bar的副本保存在其他地方,然后加载该已保存的副本。在"boz()"之后评估"bar"将避免内存存储和重新获取,并且是许多编译器的优化,无论它们在参数列表中的顺序如何。 - supercat
6
我不了解C++,但C标准指出将int转换为char可能是实现定义的,也可能是完全定义的(取决于类型的实际值和符号)。请参见C99 §6.3.1.3(在C11中未更改)。 - Nikolai Ruhe
显示剩余6条评论

31

来自官方 C 语言解释文档

术语 未指定行为(unspecified behavior)未定义行为(undefined behavior)实现定义行为(implementation-defined behavior) 被用于对编写程序的结果进行分类,这些程序的属性标准没有或不能完全描述。采用此分类的目的是允许不同实现之间存在一定的差异,从而使得实现质量成为市场上的一个活跃因素,并允许某些流行的扩展,同时不会削弱符合标准的含金量。标准附录 F 列出了这三种行为中属于哪一类。

未指定行为 使实现者在翻译程序时有一定的自由度。但这种自由度并不包括无法翻译程序的情况。

未定义行为 允许实现者不捕获某些难以诊断的程序错误。它还确定了可能符合语言扩展的领域:实现者可以通过提供正式未定义行为的定义来扩展语言。

实现定义行为 给实现者选择适当方法的自由,但要求向用户解释这个选择。通常被指定为实现定义的行为是那些用户可以根据实现定义做出有意义的编码决策的行为。在决定实现定义应该多么广泛时,实现者应该记住这个标准。与未指定行为一样,简单地无法翻译包含实现定义行为的源代码不是一个足够的响应。


4
超现代编译器的编写者也认为“未定义行为”赋予编译器作者许可,假设程序不会收到会导致未定义行为的输入,并且在程序接收此类输入时可以任意更改程序行为的各个方面。 - supercat
2
我刚刚注意到的另一点是:C89没有使用“扩展”这个术语来描述在某些实现中保证但在其他实现中不保证的功能。C89的作者们认识到,当时大多数实现会将有符号算术和无符号算术视为相同,除非结果在某些情况下被使用,即使在有符号溢出的情况下也适用;然而,他们没有将其列为Annex J2中的常见扩展,这表明他们认为这是一种自然状态,而不是一种扩展。 - supercat

14

未定义行为 vs. 未指定行为有一个简短的描述。

他们的最终结论:

总之,未指定行为通常是无需担心的,除非您的软件需要可移植性。相反,未定义行为始终是不可取的,不应发生。


1
有两种编译器:一种是默认情况下将大多数标准的未定义行为解释为依赖于底层环境所记录的特征行为,除非明确说明;另一种是默认只使用标准刻画为实现定义的行为。当使用第一种类型的编译器时,可以使用UB高效且安全地完成许多事情。对于第二种类型的编译器,只有在它们提供选项以保证这些情况下的行为时,才适合执行此类任务。 - supercat

12

实现定义(Implementation defined)-

实现者希望能够被很好地记录下来,标准给出了选择但一定要保证能够编译

未指定(Unspecified)-

与实现定义相同,但没有文档记录

未定义(Undefined)-

任何事情都有可能发生,请注意。


3
我认为需要注意的是,“未定义”的实际含义在过去几年中发生了变化。以前,给定 uint32_t s;,当s为33时评估 1u<<s 可能会产生0或2,但不会出现其他奇怪的情况。然而,新编译器评估 1u<<s 可能会导致编译器确定因为s之前必须小于32,所以任何在该表达式之前或之后只有在s大于等于32时才相关的代码可能会被省略。 - supercat

10

从历史上看,实现定义行为(Implementation-Defined Behavior)和未定义行为(Undefined Behavior)都代表标准的作者期望编写高质量实现的人能够根据判断力决定程序所需的行为保证是否有用于既定应用领域在既定目标上运行的程序。高端数值计算代码的需求与低层系统代码的需求完全不同,而无论是UB还是IDB都允许编译器编写人员灵活地满足这些不同的需求。这两类都没有强制要求实现必须以对任何特定目的或甚至对任何目的都有用的方式运行。然而,声称适用于特定目的的高质量实现应该表现出符合这样目的的行为,无论标准是否要求。

实现定义行为和未定义行为之间唯一的区别是前者要求实现定义和记录一致的行为,即使在实现无论如何操作也不会有用的情况下也要如此。它们之间的分界线不在于实现定义行为通常是否有用(编译器编写人员应该在实际可行的情况下定义有用的行为,无论标准是否要求),而是在于是否可能存在定义行为同时具有成本和无用性的实现。这样的实现可能存在的判断并不以任何方式、形式或形态意味着对在其他平台上支持定义行为的有用性的任何判断。

不幸的是,自20世纪90年代中期以来,编译器编写人员开始将行为保证缺失解释为即使在它们是至关重要的应用领域和成本几乎为零的系统上也不值得付出代价的判断。编译器编写人员不再将未定义行为视为行使合理判断的邀请,而是将其视为一种借口来这样做。

例如,给定以下代码:

int scaled_velocity(int v, unsigned char pow)
{
  if (v > 250)
    v = 250;
  if (v < -250)
    v = -250;
  return v << pow;
}

采用二进制补码实现的程序不需要花费任何力气将表达式 v << pow 视为二进制补码移位,而无论 v 是正数还是负数。

然而,一些当今的编译器作者更倾向于这样的哲学:只要程序不涉及未定义行为,v 就只能是负数,因此没有理由让程序截取 v 的负范围。尽管在所有重要的编译器上都支持对负值进行左移操作,并且大量现有代码依赖于该行为,但现代哲学认为标准规定左移负值为未定义行为意味着编译器作者可以自由地忽略它。


但是以良好的方式处理未定义行为并不是免费的。现代编译器在某些未定义行为的情况下表现出如此奇怪的行为的整个原因是它们不断进行优化,并且为了做到最好,它们必须能够假定未定义行为永远不会发生。 - Tom Swirly
1
但是<<在负数上的UB(未定义行为)事实上是一个麻烦的小陷阱,很高兴被提醒了! - Tom Swirly
1
@TomSwirly:非常不幸,编译器的编写者并不在意提供超出标准规定的宽松行为保证通常可以比要求代码避免一切标准未定义行为带来更大的速度提升。如果程序员不关心 i+j>k 在溢出时产生1或0,只要它没有任何其他副作用,编译器可能能够进行一些巨大的优化,而如果程序员将代码编写为 (int)((unsigned)i+j) > k 就不可能实现这些优化。 - supercat
1
@TomSwirly:对于他们来说,如果编译器X可以采用严格符合规范的程序执行某个任务T,并产生比使用相同程序的编译器Y产生的可执行文件更高效5%的结果,那么这意味着X更好,即使Y可以生成执行相同任务的代码,而给定一个利用Y保证但X不保证的行为的程序,Y可以将其执行效率提高三倍。 - supercat
1
考虑一个简单的场景,假设ijk是编译器在函数调用foo(x, y, x)中展开的函数的参数。在这种情况下,编译器可以将i+j > k替换为x+y > x,然后再将其替换为y > 0,跳过加法运算,消除对x值的任何依赖,并可能允许编译器消除比较和对y确切值的任何依赖,如果它能确定y始终为正数。 - supercat
显示剩余4条评论

6
C++标准 n3337 § 1.3.10 实现定义行为
对于一个正确构建的程序和正确数据,其行为取决于实现,并由每个实现文档记录。
有时C++标准不对某些结构强制施加特定行为,而是指出必须选择特定的、明确定义的行为,并由特定的实现(库的版本)描述。因此,用户仍然可以准确地知道程序的行为,即使标准没有描述。
C++标准n3337 § 1.3.24 未定义行为
当国际标准对某种行为没有任何要求时,这种行为被称为未定义行为。 [注:当国际标准没有明确定义某种行为或程序使用错误的结构或错误的数据时,可能会出现未定义行为。允许的未定义行为范围从完全忽略情况并产生不可预测的结果,到在翻译或程序执行期间以环境特定的已记录方式行为(无论是否发出诊断消息),再到终止翻译或执行(并发出诊断消息)。许多错误的程序结构不会引发未定义行为;它们需要被诊断。— 结束注]
当程序遇到不符合C++标准定义的结构时,它可以随意进行操作(也许给我发送电子邮件,也许给你发送电子邮件,也许完全忽略代码)。
C++标准n3337 § 1.3.25 未指定的行为
对于一个良好构造的程序和正确的数据,其行为取决于实现[注:实现不需要记录发生的行为。可能的行为范围通常由国际标准界定。-结束注释]
C++标准对某些结构没有强制规定特定的行为,而是要求选择一个特定且明确定义的行为(但不一定要描述),由特定的实现(库的版本)来决定。因此,在没有提供描述的情况下,用户可能很难知道程序的行为会是什么样子。

1

未定义行为是“丑陋的”--就像“好的、坏的和丑陋的”一样。

好的:一个编译并以正确的原因工作的程序。

坏的:一个程序存在错误,编译器可以检测到并抱怨的错误类型。

丑陋的:一个程序存在错误,编译器无法检测和警告,这意味着程序编译,有时似乎正常工作,但也会偶尔失败。这就是未定义行为。

一些编程语言和其他形式系统努力限制“未定义性的鸿沟”--也就是说,他们试图安排事情,使得大多数或所有程序都是“好的”或“坏的”,而很少有“丑陋的”。然而,C语言的“未定义性鸿沟”相当宽广,这是其特征之一。


标准所定义的未定义行为是“不可移植或错误的”,但标准并没有试图区分哪些是错误的,哪些是不可移植但在其编写或与其兼容的其他实现中被正确处理的。 - supercat
1
公平地说,通常很可能警告未定义的行为。 - klutt

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