具有未定义行为但从未实际执行的表达式是否会使程序出错

Does an expression with undefined behaviour that is never actually executed make a program erroneous?

本文关键字:表达式 执行 是否 程序出错 未定义      更新时间:2023-10-16

在许多关于未定义行为(UB)的讨论中,有人提出了这样的观点,即在任何构造的程序中,只要存在,程序中有UB,就要求一致的实现只做任何事情(包括什么都不做)。我的问题是,即使在UB与代码的执行相关的情况下,是否也应该从这个意义上考虑这一点,而标准中规定的行为(否则)规定不应该执行有问题的代码(这可能是针对程序的特定输入;在编译时可能无法决定)。

更非正式地说,UB的气味是否要求一个一致的实施来决定整个程序的臭味,并拒绝正确执行程序的哪怕是行为定义得很好的部分。就是一个例子

#include <iostream>
int main()
{
int n = 0;
if (false)
n=n++;   // Undefined behaviour if it gets executed, which it doesn't
std::cout << "Hi there.n";
}

为了清楚起见,我假设程序是格式良好的(所以特别是UB与预处理无关)。事实上,我愿意限制UB与";评价";,它们显然不是编译时实体。我认为,与给出的例子相关的定义是(重点是我的):

Sequenced before是由单个线程执行的求值之间的不对称、可传递、成对关系(1.10),这在这些求值之间引发了偏序

的操作数的值计算在运算符的结果的值计算之前对运算符进行排序。如果标量对象上的副作用相对于。。。或者使用相同标量对象的值进行值计算,则行为未定义。

很明显,最后一句中的主语;副作用";以及";"值计算";,是";"评估";,因为这就是关系";在";为定义。

我假设,在上述程序中,标准规定,不发生满足最后一句中条件的评估(相对于彼此和所描述的类型不排序),因此程序不具有UB;它没有错。

换言之,我确信我的头衔问题的答案是否定的。然而,我很感激其他人对此事的(积极的)意见。

对于那些主张肯定答案的人来说,也许还有一个额外的问题,那就是当编译错误的程序时,可能会发生众所周知的硬盘格式化吗?

这个网站上的一些相关指针:

  • 可观察的行为和未定义的行为——如果我不这样做会发生什么;不要调用析构函数
  • 对此答案的评论https://stackoverflow.com/a/24143792/1436796(我不再完全支持我的答案本身)
  • C++最早的未定义行为是什么
  • 未定义行为和格式错误之间的区别,不需要诊断信息及其两个答案,这两个答案代表了相反的观点

如果标量对象上的副作用相对于etc未排序

副作用是执行环境状态的变化(1.9/12)。变化是一种变化,而不是一个表达式,如果进行评估,可能会产生变化。如果没有变化,就没有副作用。如果没有副作用,那么相对于其他任何副作用,都不会产生副作用。

这并不意味着任何从未执行过的代码都是UB免费的(尽管我很确定大部分都是)。标准中UB的每次出现都需要单独检查(删除的文本可能过于谨慎;见下文)。

标准还说

一个执行良好程序的一致性实现应产生相同的可观察行为作为具有相同程序的抽象机的相应实例的可能执行之一以及相同的输入。但是,如果任何此类执行包含未定义的操作,则此International标准对使用该输入执行程序的实现没有任何要求(甚至没有关于在第一未定义操作之前的操作)。

(强调矿)

据我所知,这是唯一一个说明短语"未定义行为"含义的规范性引用:程序执行中的未定义操作。没有执行,没有UB。

否。示例:

struct T {
void f() { }
};
int main() {
T *t = nullptr;
if (t) {
t->f(); // UB if t == nullptr but since the code tested against that
}
}

决定程序是否执行整数除以0(即UB)通常相当于停止问题。一般来说,编译器无法确定这一点。因此,仅仅存在可能的UB并不能从逻辑上影响程序的其余部分:标准中的一项要求是,每个编译器供应商都需要在编译器中提供一个停止的问题解决程序

更简单的是,以下程序只有在用户输入0:时才具有UB
#include <iostream>
using namespace std;
auto main() -> int
{
int x;
if( cin >> x ) cout << 100/x << endl;
}

如果坚持认为这个程序本身具有UB,那将是荒谬的。

然而,一旦发生未定义的行为,那么任何事情都可能发生:程序中代码的进一步执行就会受到影响(例如,堆栈可能已经被破坏)。

在一般情况下,我们在这里可以说的最好的情况是它取决于。

在处理不确定值时,会出现一种答案为"否"的情况。最新的草案明确规定了在评估过程中产生不确定值的未定义行为,但有一些例外,但代码示例清楚地表明了这可能是多么微妙:

示例:

int f(bool b) {
unsigned char c;
unsigned char d = c; // OK, d has an indeterminate value
int e = d;           // undefined behavior
return b ? d : 0;    // undefined behavior if b is true
}

--结束示例]

所以这行代码:

return b ? d : 0;

仅当CCD_ 1为CCD_。如果我们读到《是时候认真对待利用未定义行为了》,这似乎是一种直观的方法,约翰·雷格尔也认为这是一种方法。

在这种情况下,答案是肯定的,即使我们没有调用调用未定义行为的代码,代码也是错误的:

constexpr const char *str = "Hello World" ;      
constexpr char access()
{
return str[100] ;
}
int main()
{
}

clang选择使access出错,即使它从未被调用(查看它的实时)。

固有的未定义行为(如n=n++)和根据运行时的程序状态可以具有已定义或未定义行为的代码(如int的x/y)之间有明显的区别。在后一种情况下,除非y为0,否则程序必须工作,但在第一种情况中,编译器被要求生成完全非法的代码——它有权拒绝编译,它可能只是没有针对此类代码进行"防弹",因此其优化器状态(寄存器分配、自读取以来其值可能已被修改的记录等)被损坏,从而导致该和周围源代码的虚假机器代码。早期的分析可能识别出了"a=b++"的情况,并为前面的if生成了代码,以跳过一条两字节的指令,但当遇到n=n++时,没有输出任何指令,因此if语句会跳到下面的操作码中。不管怎样,游戏就这么结束了。将"if"放在前面,甚至将其包装在不同的函数中,都不会被记录为"包含"未定义的行为。。。代码片段没有被未定义的行为所污染——标准一直说"程序有未定义的性能"。

如果不是,则应为">shall"。

行为,根据ISO C的定义(在ISO C++中没有找到相应的定义,但它应该仍然适用),是:

3.4

1行为

外观或动作

和UB:

WG21/N4527

1.3.25[defnsundefined]

未定义的行为

本国际标准未要求的行为[注:当本国际标准省略了任何明确的行为定义,或者程序使用了错误的构造或错误的数据时,可能会出现未定义的行为。允许的未定义行为包括完全忽略情况并产生不可预测的结果,以及在翻译或程序执行过程中以环境特征的记录方式表现(有或没有发布诊断消息),到终止翻译或执行(有诊断消息的发布)。许多错误的程序构造不会产生未定义的行为;需要对其进行诊断。--尾注]

尽管上面提到了"翻译过程中的行为",ISO C++使用的"行为"一词主要是关于程序的执行

WG21/N4527

1.9程序执行[简介执行]

1本国际标准中的语义描述定义了一个参数化的不确定性抽象机。本国际标准对一致性实施的结构没有提出任何要求。特别地,它们不需要复制或模拟抽象机器的结构。相反,一致性实现需要模拟(仅)抽象机器的可观察行为,如下所述。5

2抽象机的某些方面和操作在本国际标准中被描述为实现定义(例如,sizeof(int))。这些构成了抽象机器的参数。每个实现应包括描述其在这些方面的特征和行为的文件。6此类文件应定义与该实现相对应的抽象机器的实例(以下称为"相应实例")。

3抽象机的某些其他方面和操作在本国际标准中被描述为未指定(例如,如果分配函数未能分配内存,则对新初始化器中的表达式进行评估(5.3.4))。在可能的情况下,本国际标准定义了一组允许的行为。这些定义了抽象机器的不确定性方面。因此,抽象机器的实例对于给定的程序和给定的输入可以具有不止一个可能的执行。

4某些其他操作在本国际标准中被描述为未定义(例如,试图修改const对象的效果)。【注:本国际标准对包含未定义行为的程序的行为没有任何要求。——尾注】

5执行格式良好程序的一致性实现应产生与使用相同程序和相同输入的抽象机的相应实例的可能执行之一相同的可观察行为。然而,如果任何此类执行包含未定义的操作,则本国际标准不对使用该输入执行该程序的实现提出要求(甚至不涉及第一个未定义操作之前的操作)。

5) 该规定有时被称为"好像"规则,因为只要结果,就可以自由地忽略本国际标准的任何要求,就好像已经遵守了要求一样,只要可以从程序的可观察行为中确定。例如,如果一个实际实现可以推断出它的值没有被使用,并且没有产生影响程序可观察行为的副作用,那么它就不需要评估表达式的一部分。

6) 此文档还包括有条件支持的构造和特定于区域设置的行为。参见1.4。

很明显,未定义的行为将由错误使用或以不可移植的方式(不符合标准)使用的特定语言结构引起。然而,该标准没有提及程序中哪一特定部分的代码会导致它。换句话说,"具有未定义的行为"是整个执行程序的属性(关于一致性),而不是它的任何较小部分

一旦某些特定代码没有被执行,该标准本可以提供更有力的保证,使行为定义明确,只有当存在将C++代码精确映射到相应行为的方法时。如果没有关于执行的详细语义模型,这很难(如果不是不可能的话)。简而言之,上述抽象机器模型给出的操作语义不足以实现更强的保证。但无论如何,ISO C++永远不会是JVMS或ECMA-335。我不认为会有一套完整的形式语义来描述这种语言。

这里的一个关键问题是"执行"的含义。有些人认为"执行程序"意味着让程序运行。这不完全正确。请注意,没有指定在抽象机器中执行的程序的表示形式。(另请注意,"本国际标准对一致性实现的结构没有要求"。)此处执行的代码可以是C++代码(不一定是机器代码或标准中根本没有规定的其他形式的中间代码)。这有效地允许将核心语言实现为解释器、在线部分评估器或其他动态翻译C++代码的怪物。因此,在不了解具体实现的情况下,实际上没有办法在执行过程之前完全分割翻译阶段(由ISO C++[lex.phases]定义)。因此,当很难指定可移植的定义良好的行为时,有必要允许UB在翻译过程中发生。

除了上述问题之外,也许对于大多数普通用户来说,一个(非技术性的)原因就足够了:根本没有必要提供更强的保证,允许坏代码,并击败UB本身的一个(可能是最重要的)有用性方面:鼓励快速丢弃一些(不必要的)不可移植的臭代码,而不努力"修复"它们,这最终将是徒劳的。

附加说明:

一些词语是从我对这一评论的一个回复中复制和重构的。

一旦程序进入一个没有定义事件序列的状态,C编译器就可以做任何它喜欢的事情,这将使程序避免在未来的某个时刻调用未定义的行为(请注意,任何没有任何副作用,也没有编译器需要识别的退出条件的循环,都会调用自身的Undefined Behavior)编译器在这种情况下的行为既不受时间定律的约束,也不受因果关系定律的约束。在未定义行为出现在结果从未使用的表达式中的情况下,某些编译器不会为该表达式生成任何代码(因此它永远不会"执行"),但这不会阻止编译器使用未定义行为对程序行为进行其他推断。

例如:

void maybe_launch_missiles(void)
{      
if (should_launch_missiles())
{
arm_missiles();
if (should_launch_missiles())
launch_missiles();
}
disarm_missiles();
}
int foo(int x)
{
maybe_launch_missiles();
return x<<1;
}

在C当前的C标准下,如果编译器可以确定disarm_missiles()将始终返回而不终止,但上面调用的其他三个外部函数可能终止,则语句foo(-1);(忽略返回值)最有效的符合标准的替换将是should_launch_missiles(); arm_missiles(); should_launch_missiles(); launch_missiles();

只有当对b0的调用终止而不返回,如果第一个调用返回非零且arm_missiles()终止而不返,或者如果两个调用都返回非零并且launch_missiles()终止而不回时,才会定义程序行为。在这些情况下正确工作的程序将遵守标准,无论它在任何其他情况下做什么。若从maybe_launch_missiles()返回会导致Undefined Behavior,那个么编译器就不需要识别对should_launch_missiles()的任何一个调用都可能返回零的可能性。

因此,在一些现代编译器中,在分离代码和数据空间以及陷阱堆栈溢出的平台上,典型C99编译器上的任何类型的未定义行为都可能导致负数左移的效果比任何都要差。即使代码参与了可能导致随机控制传输的未定义行为,也不可能导致arm_missiles()launch_missiles()被连续调用而不具有对disarm_missiles()的干预调用,除非对should_launch_missiles()的至少一个调用返回了非零值。然而,超现代编译器可能会否定这种保护。

在启用了完全优化的gcc处理的方言中,如果一个程序包含两个结构,在定义了这两个结构的情况下,它们的行为是相同的,那么可靠的程序操作要求任何在它们之间切换的代码都只能在定义了两者的情况下执行。例如,当启用优化时,ARM gcc 9.2.1和x86-64 gcc 10.1都将处理以下源:

#include <limits.h>
#if LONG_MAX == 0x7FFFFFFF
typedef int longish;
#else
typedef long long longish;
#endif
long test(long *x, long *y)
{
if (*x)
{
if (x==y)
*y = 1;
else
*(longish*)y = 1;
}
return *x;
}

将测试xtrue0是否相等的机器代码中,如果相等,则将*x设置为1,如果不相等,则设置*y为1,但在任何一种情况下都返回*x的前一个值。为了确定是否有任何事情可能影响*x,gcc决定if的两个分支是等价的,因此只计算"false"分支。由于这不会影响*x,因此得出结论,if作为一个整体也不会。通过观察到在真分支上,对*y的写入可以用对*x的写入来代替,该确定不受影响。

在安全关键嵌入式系统的情况下,发布的代码将被视为有缺陷:

  1. 代码不应通过代码审查和/或标准合规性(MISRA等)
  2. 静态分析(lint、cppcheck等)应将其标记为缺陷
  3. 一些编译器可以将其标记为警告(也意味着存在缺陷)