为什么f(i = -1,i = -1)未定义的行为?

我正在阅读评估违规的顺序 ,他们给我一个困惑的例子。

1)如果标量对象的副作用相对于同一个标量对象上的另一个副作用未被sorting,则行为是不确定的。

// snip f(i = -1, i = -1); // undefined behavior 

在这种情况下, i是一个标量对象 ,这显然意味着

算术types(3.9.1),枚举types,指针types,成员types指针(3.9.2),std :: nullptr_t和这些types的cv限定版本(3.9.3)统称为标量types。

在这种情况下,我不明白这个陈述是如何含糊不清的。 在我看来,无论是第一个还是第二个参数先评估, i最终为-1 ,两个参数也是-1

有人可以澄清?


UPDATE

我非常感谢所有的讨论。 到目前为止,我很喜欢@ harmic的回答,因为它暴露了定义这个陈述的陷阱和错综复杂的问题,尽pipe它乍看之下多么直截了当。 @ acheong87指出了使用引用时出现的一些问题,但是我认为这与这个问题的不确定副作用方面是正交的。


概要

由于这个问题引起了很大的关注,我将总结一下主要的观点和答案。 首先,请允许我进行一个小小的分析,指出“为什么”可以有密切相关但意义不同的含义,即“为什么”,“ 为什么 ”,“为了什么目的 ”。 我将把他们所说的“为什么”的那些含义的答案分组。

为什么导致

这里的主要答案来自保罗·德雷珀 ( Paul Draper) , 马丁·J ( Martin J)提供了一个类似但不是广泛的答案。 Paul Draper的答案归结为:

这是未定义的行为,因为它没有定义什么行为。

就解释C ++标准所说的问题而言,答案总的来说非常好。 它也解决了一些相关的UB案例,如f(++i, ++i);f(i=1, i=-1); 。 在第一个相关案例中,不清楚第一个参数是i+1还是第二个i+2 ,反之亦然。 在第二个函数调用之后, i不清楚i应该是1还是-1。 这两种情况都属于UB,因为它们属于以下规则:

如果标量对象上的副作用相对于同一标量对象上的另一个副作用是不确定的,则行为是不确定的。

因此,尽pipe程序员的意图是(恕我直言)明显而明确, f(i=-1, i=-1)也是UB,因为它属于同一规则。

Paul Draper在他的结论中也明确表示

难道它已经被定义的行为? 是。 有没有定义? 没有。

这使我们想到“ f(i=-1, i=-1)是什么原因/目的留下来作为未定义的行为?

为了什么原因/目的

尽pipe在C ++标准中存在一些疏漏(可能是粗心),但许多遗漏是合理的,并有特定的目的。 虽然我知道目的往往是“让编译器或作者的工作更容易”,或者“更快的代码”,但我主要想知道是否有一个好的理由leave f(i=-1, i=-1) 作为UB。

harmic和supercat提供了为UB提供理由的主要答案。 Harmic指出,优化编译器可能会将表面上primefaces分配操作分解为多个机器指令,并且可能会进一步交叉这些指令以获得最佳速度。 这可能会导致一些非常令人惊讶的结果:在他的情况下, i最终为-2! 因此,如果操作是不确定的,那么Harmic就certificate了如何将多个相同的值分配给一个variables可能会产生不良影响。

supercat提供了一个试图让f(i=-1, i=-1)做它看起来应该做的事情的陷阱的相关论述。 他指出,在某些体系结构中,存在对多个同时写入同一内​​存地址的硬限制。 如果我们处理的是比f(i=-1, i=-1)更less的东西,编译器可能很难捕捉到这个问题。

davidf也提供了一个非常类似于harmic的交错指令的例子。

虽然每个harmic,supercat和davidf的例子都是有些人为的,但是它们仍然可以为f(i=-1, i=-1)应该是不确定的行为提供一个有形的理由。

我接受了哈利米的答案,因为尽pipe保罗·德雷珀的回答更好地解决了“为什么”这个部分,但是它为解决这个问题的所有含义做了最好的工作。

其他的答案

JohnB指出,如果我们考虑重载赋值操作符(而不是简单的标量),那么我们也可能遇到麻烦。

由于操作是无序的,因此没有任何事情可以说执行分配的指令不能交错。 这可能是最佳的,这取决于CPU架构。 引用的页面陈述如下:

如果A在B之前未被测序,B在A之前未被测序,则存在两种可能性:

  • A和B的评估是不确定的:它们可以以任何顺序执行并且可以重叠(在单个执行线程内,编译器可以交织包括A和B的CPU指令)

  • 对A和B的评估是不确定的:它们可以以任意顺序执行,但是可能不重叠:A在B之前完成,或者B在A之前完成。顺序可能与下一次相反被评估。

这本身似乎并不会造成问题 – 假设正在执行的操作将值-1存储到内存位置。 但是也没有什么可说的,编译器不能将其优化成一组单独的具有相同效果的指令,但是如果该操作与另一个操作在相同的存储器位置上交错,那么该指令可能会失败。

例如,想象一下,将内存归零,然后将其递减,与将值加载为1相比,效率更高。然后,

 f(i=-1, i=-1) 

可能会变成:

 clear i clear i decr i decr i 

现在我是-2。

这可能是一个虚假的例子,但它是可能的。

首先,“标量对象”意味着像intfloat或指针这样的types(请参阅什么是C ++中的标量对象? )。


其次,这似乎更明显

 f(++i, ++i); 

会有未定义的行为。 但

 f(i = -1, i = -1); 

不太明显。

一个稍微不同的例子:

 int i; f(i = 1, i = -1); std::cout << i << "\n"; 

什么任务发生“最后”, i = 1i = -1 ? 这个标准没有定义。 真的,那意味着i可能是5 (见harmic的答案,对于这种情况如何,这是一个完全可信的解释)。 或者你的程序可以segfault。 或者重新格式化你的硬盘。

但是现在你问:“我的例子呢?我对这两个任务使用了相同的值( -1 ),可能有什么不清楚的地方?

你是正确的,除了C ++标准委员会描述这个的方式。

如果标量对象上的副作用相对于同一标量对象上的另一个副作用是不确定的,则行为是不确定的。

他们本可以为你的特例做一个特例,但是他们没有。 (他们为什么要这样做呢?会有什么用?)所以, i仍然可以5 。 或者你的硬盘可能是空的。 因此,你的问题的答案是:

这是未定义的行为,因为它没有定义什么行为。

(这是值得强调的,因为很多程序员认为“未定义”意味着“随机”或“不可预测”,不是意味着标准没有定义,行为可能是100%一致的,仍然是未定义的。

难道它已经被定义的行为? 是。 有没有定义? 不,因此,它是“未定义的”。

这就是说,“未定义”并不意味着编译器会格式化您的硬盘驱动器……这意味着它可以 ,它仍然是一个符合标准的编译器。 实际上,我相信g ++,Clang和MSVC都会做你所期望的。 他们只是不会“必须”。


一个不同的问题可能是为什么C ++标准委员会select使这种副作用不确定? 。 答案将涉及委员会的历史和意见。 或者在C ++中使用这种副作用是不是很好? ,无论这是否是标准委员会的实际推理,都可以提供任何理由。 你可以在这里问这些问题,或在程序员.stackexchange.com。

一个切实可行的理由,不要因为这两个价值观的原因而违反规则:

 // config.h #define VALUEA 1 // defaults.h #define VALUEB 1 // prog.cpp f(i = VALUEA, i = VALUEB); 

考虑这是允许的情况。

现在,几个月后,需要改变

  #define VALUEB 2 

看似无害,不是吗? 但突然prog.cpp不会编译了。 但是,我们认为汇编不应该依赖于文字的价值。

底线:规则没有例外,因为它会使编译成功取决于常量的值(而不是types)。

编辑

@HeartWare指出 ,在B为0的情况下,某些语言不允许expression式A DIV B常量expression式,并且导致编译失败。 因此,改变常数可能会导致其他地方的编译错误。 恕我直言,这是不幸的。 但把这种事情限制在不可避免的地步当然是好事。

行为通常被指定为未定义的,如果有一些可以想象的原因,为什么一个试图成为“有用”的编译器可能会做一些会导致完全意想不到的行为。

在variables被多次写入而没有任何东西确保写入在不同时间发生的情况下,某些types的硬件可能允许使用双端口存储器同时对不同的地址执行多个“存储”操作。 但是,有些双端口存储器明确禁止两个存储器同时命中相同地址的情况, 而不pipe写入的值是否匹配 。 如果这样一台机器的编译器注意到两个无序的尝试写入相同的variables,它可能会拒绝编译或确保两个写入不能同时进行调度。 但是,如果一个或两个访问是通过指针或引用,编译器可能并不总是能够告诉两个写入是否可能命中相同的存储位置。 在这种情况下,它可能会同时安排写入操作,从而在访问尝试中造成硬件陷阱。

当然,有人可能在这样的平台上实现一个C编译器的事实并不意味着当使用足够小的types的存储以便primefaces处理时,不应该在硬件平台上定义这种行为。 如果编译器没有意识到这一点,试图以不确定的方式存储两个不同的值可能会导致怪异; 例如,给出:

 uint8_t v; // Global void hey(uint8_t *p) { moo(v=5, (*p)=6); zoo(v); zoo(v); } 

如果编译器将调用内联到“moo”,并且可以告诉它不修改“v”,它可以存储一个5到v,然后存储一个6到* p,然后传递5到“zoo”,然后把v的内容传给“动物园”。 如果“动物园”没有修改“v”,两个调用应该没有办法通过不同的值,但这很容易发生。 另一方面,如果两个商店都写相同的价值,那么这种古怪就不会发生,而且在大多数平台上没有任何明智的理由来执行任何奇怪的事情。 不幸的是,一些编译器编写者不需要任何理由超越“因为标准允许”,所以即使这些情况也不安全。

这种情况下,在大多数实现中结果是相同的事实是偶然的; 评估的顺序尚未定义。 考虑f(i = -1, i = -2) :这里,顺序很重要。 在你的例子中,唯一的原因是两个值都是-1

假定expression式被指定为具有未定义行为的expression式,则当您评估f(i = -1, i = -1)并中止执行时,恶意兼容的编译器可能会显示不适当的图像,并且仍被视为完全正确。 幸运的是,没有编译器,我知道这样做。

令人困惑的是,将一个常量值存储到局部variables中并不是每个架构上的一个primefaces指令,C被devise为运行在其上。 在这种情况下,代码运行的处理器比编译器更重要。 例如,在ARM上,每条指令都不能携带一个完整的32位常量,在一个variables中存储一个int就需要一条指令。 这个伪代码的例子,你一次只能存储8位,并且必须在32位寄存器中工作,我是一个int32:

 reg = 0xFF; // first instruction reg |= 0xFF00; // second reg |= 0xFF0000; // third reg |= 0xFF000000; // fourth i = reg; // last 

你可以想象,如果编译器想要优化它,可能会交织两次相同的序列,而你不知道写入i的值是什么。 假设他不是很聪明:

 reg = 0xFF; reg |= 0xFF00; reg |= 0xFF0000; reg = 0xFF; reg |= 0xFF000000; i = reg; // writes 0xFF0000FF == -16776961 reg |= 0xFF00; reg |= 0xFF0000; reg |= 0xFF000000; i = reg; // writes 0xFFFFFFFF == -1 

然而在我的testing中,gcc非常友好地认识到,相同的值被使用了两次,并生成了一次,没有什么奇怪的。 我得到-1,-1但是我的例子仍然是有效的,因为重要的是要考虑即使是一个常数也不像看起来那么明显。

在我看来,关于函数参数expression式的sorting的唯一规则就是在这里:

3)调用一个函数时(函数是否内联,是否使用显式函数调用语法),与任何参数expression式相关联的每个值计算和副作用,或者与指定被调用函数的后缀expression式在执行被调用函数体内的每个expression式或语句之前进行sorting。

这没有定义参数expression式之间的顺序,所以我们最终在这种情况下:

1)如果标量对象上的副作用相对于同一个标量对象上的另一个副作用是不确定的,则行为是不确定的。

实际上,在大多数编译器中,你引用的例子将运行良好(与“擦除硬盘”和其他未定义的理论行为结果相反)。
然而,这是一个责任,因为它依赖于特定的编译器行为,即使两个分配的值是相同的。 另外,显然,如果您尝试分配不同的值,结果将是“真正”未定义的:

 void f(int l, int r) { return l < -1; } auto b = f(i = -1, i = -2); if (b) { formatDisk(); } 

赋值运算符可能超载,在这种情况下,顺序可能很重要:

 struct A { bool first; A () : first (false) { } const A & operator = (int i) { first = !first; return * this; } }; void f (A a1, A a2) { // ... } // ... A i; f (i = -1, i = -1); // the argument evaluated first has ax.first == true 

这只是回答“我不知道是什么”的标量对象“可能意味着除了像int或float之外”。

我会将“标量对象”解释为“标量types对象”的缩写,或者只是“标量typesvariables”。 然后, pointerenum (常量)是标量types。

这是标量types的MSDN文章。

C ++ 17定义了更严格的评估规则。 特别是,它对函数参数进行sorting(尽pipe以未指定的顺序)。

N5659 §4.6:15
AB之前被测序,或BA之前被测序时,评估AB被不确定地测序,但是未指定哪个。 [ :不确定的测序评估不能重叠,但可以先执行。 – 结束注意 ]

N5659 § 8.2.2:5
包括每个相关值计算和副作用在内的参数的初始化相对于任何其他参数的初始化是不确定的。

它允许一些以前是UB的情况:

 f(i = -1, i = -1); // value of i is -1 f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one 

实际上,有一个原因不依赖于编译器会检查i被赋值了两次相同的值的事实,所以可以用一个赋值来replace它。 如果我们有一些expression式呢?

 void g(int a, int b, int c, int n) { int i; // hey, compiler has to prove Fermat's theorem now! f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n))); }