我在读关于违反评估顺序的书,他们举了一个让我困惑的例子。

1)如果一个标量对象上的副作用相对于同一标量对象上的另一个副作用未排序,则该行为是未定义的。 / /剪断 F (i = -1, i = -1);//未定义的行为

在这个上下文中,i是一个标量对象,这显然意味着

算术类型(3.9.1)、枚举类型、指针类型、指向成员类型的指针(3.9.2)、std::nullptr_t以及这些类型的cv限定版本(3.9.3)统称为标量类型。

在这种情况下,我看不出这个说法有什么不明确的地方。在我看来,无论第一个参数还是第二个参数先求值,i最终都是-1,而且两个参数也是-1。

有人能澄清一下吗?


更新

非常感谢大家的讨论。到目前为止,我非常喜欢@harmic的回答,因为它暴露了定义这个语句的陷阱和复杂性,尽管乍一看它是多么简单。@acheong87指出了使用引用时出现的一些问题,但我认为这与这个问题的未排序副作用方面是正交的。


总结

由于这个问题得到了大量的关注,我将总结要点/答案。首先,请允许我稍微离题一点,指出“为什么”可以有密切相关但微妙不同的含义,即“为了什么原因”,“为了什么原因”和“为了什么目的”。我将根据他们所说的“为什么”的意思来对答案进行分组。

为什么?

这里的主要答案来自Paul Draper, Martin J也给出了类似但不那么广泛的答案。保罗·德雷珀的答案可以归结为

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

就解释c++标准的含义而言,这个答案总体上是非常好的。同时还讨论了一些UB的相关情况,如f(++i, ++i);f(i=1, i=-1);在第一个相关的情况下,不清楚第一个参数是否应该是i+1,第二个参数应该是i+2,反之亦然;在第二种情况下,不清楚函数调用后我应该是1还是-1。这两种情况都是UB,因为它们符合以下规则:

如果一个标量对象上的副作用相对于同一标量对象上的另一个副作用没有排序,则该行为是未定义的。

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

Paul Draper在他的结论中也明确指出

它可以被定义为行为吗?是的。它被定义了吗?不。

这给我们带来了一个问题:“f(i=-1, i=-1)出于什么原因/目的被保留为未定义的行为?”

为了什么原因/目的

尽管c++标准中存在一些疏忽(可能是粗心大意),但许多遗漏都是合理的,并为特定的目的服务。虽然我知道这样做的目的通常是“让编译器-作者的工作更容易”,或者“更快的代码”,但我主要感兴趣的是,是否有一个很好的理由把f(I =-1, I =-1)作为UB。

harmic and supercat provide the main answers that provide a reason for the UB. Harmic points out that an optimizing compiler that might break up the ostensibly atomic assignment operations into multiple machine instructions, and that it might further interleave those instructions for optimal speed. This could lead to some very surprising results: i ends up as -2 in his scenario! Thus, harmic demonstrates how assigning the same value to a variable more than once can have ill effects if the operations are unsequenced.

Supercat提供了一个相关的陷阱,试图让f(i=-1, i=-1)做它应该做的事情。他指出,在某些架构上,对同一内存地址的多个同时写入有硬性限制。如果我们处理的是比f(i=-1, i=-1)更简单的东西,编译器可能很难捕捉到这个。

Davidf还提供了一个与harmic非常相似的交叉指令示例。

虽然harmic、supercat和davidf的例子都有些做作,但综合起来,它们仍然为f(i=-1, i=-1)应该是未定义行为提供了一个切实的理由。

我接受了harmic的回答,因为它在解释为什么的所有含义方面做得最好,尽管Paul Draper的回答更好地回答了“原因是什么”这部分。

其他答案

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


首先,“标量对象”意味着像int、float或指针这样的类型(参见c++中的标量对象是什么?)


其次,似乎更明显的是

f(++i, ++i);

会有未定义的行为。但

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

不太明显。

一个稍微不同的例子:

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

最后发生了什么赋值,i = 1,还是i = -1?标准中没有定义。实际上,这意味着i可能是5(请参阅harmic的答案,以获得完全合理的解释)。或者您的程序可以段错误。或者重新格式化你的硬盘。

但现在你会问:“那我的例子呢?两次赋值都使用相同的值(-1)。还有什么不清楚的呢?”

你说得对……除了c++标准委员会描述的方式。

如果一个标量对象上的副作用相对于同一标量对象上的另一个副作用没有排序,则该行为是未定义的。

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

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

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

它可以被定义为行为吗?是的。它被定义了吗?不。因此,它是“未定义的”。

也就是说,“未定义”并不意味着编译器会格式化你的硬盘…这意味着它可以并且仍然是一个标准兼容的编译器。实际上,我确信g++、Clang和MSVC都能达到您的预期。他们只是“不必”。


另一个不同的问题可能是,为什么c++标准委员会选择不对这种副作用进行排序?这个答案将涉及历史和委员会的意见。或者在c++中没有对副作用进行排序有什么好处?,这就允许任何理由,不管它是否是标准委员会的实际推理。你可以在这里或programmers.stackexchange.com上问这些问题。


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

假定表达式被指定为具有未定义行为的表达式,恶意兼容的编译器可能在计算f(i = -1, i = -1)并中止执行时显示不适当的映像,但仍然被认为是完全正确的。幸运的是,据我所知,没有编译器这样做。


在我看来,关于函数参数表达式排序的唯一规则是:

3)当调用一个函数时(无论该函数是否内联,以及是否使用显式函数调用语法),与任何参数表达式或指定被调用函数的后缀表达式相关的每一个值计算和副作用,都在被调用函数体中的每一个表达式或语句执行之前进行排序。

这并没有定义参数表达式之间的排序,所以我们最终会得到这样的结果:

1)如果一个标量对象上的副作用相对于同一标量对象上的另一个副作用是未排序的,则该行为是未定义的。

实际上,在大多数编译器上,您引用的示例都可以正常运行(而不是“擦除您的硬盘”和其他理论上未定义的行为结果)。 然而,这是一种负担,因为它取决于特定的编译器行为,即使分配的两个值是相同的。此外,很明显,如果你试图分配不同的值,结果将是“真正的”未定义:

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

这只是回答了“我不确定“标量对象”可能意味着什么,除了像int或float这样的东西”。

我会将“标量对象”解释为“标量类型对象”的缩写,或者只是“标量类型变量”。然后,指针,enum(常量)是标量类型。

这是一篇关于标量类型的MSDN文章。


由于操作是无序的,因此执行赋值的指令不能交叉。这样做可能是最佳的,这取决于CPU架构。参考页面说明如下:

If A is not sequenced before B and B is not sequenced before A, then two possibilities exist: evaluations of A and B are unsequenced: they may be performed in any order and may overlap (within a single thread of execution, the compiler may interleave the CPU instructions that comprise A and B) evaluations of A and B are indeterminately-sequenced: they may be performed in any order but may not overlap: either A will be complete before B, or B will be complete before A. The order may be the opposite the next time the same expression is evaluated.

这本身似乎不会造成问题——假设正在执行的操作是将值-1存储到内存位置。但是,编译器不能将其优化为具有相同效果的单独指令集,但如果该操作与同一内存位置上的另一个操作交织在一起,则可能会失败。

例如,想象一下,与将值-1载入内存相比,先将内存归零,再将其递减更有效。那么这个:

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

可能成为:

clear i
clear i
decr i
decr i

现在i是-2。

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


一个不因为两个值相同而从规则中例外的实际原因:

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);

考虑一下这种情况是允许的。

现在,几个月后,改变的需求出现了

 #define VALUEB 2

看起来是无害的,不是吗?然而突然prog.cpp就不能再编译了。 但是,我们认为编译不应该依赖于字面量的值。

底线:该规则没有例外,因为它将使成功的编译依赖于常量的值(而不是类型)。

EDIT

@ hearttware指出,在某些语言中,当B为0时,形式为A DIV B的常量表达式是不允许的,这会导致编译失败。因此,改变一个常量可能会在其他地方导致编译错误。恕我直言,这很不幸。但是,将这些事情限制在不可避免的范围内当然是好的。


赋值操作符可以重载,在这种情况下,顺序可能很重要:

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

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

In the case where a variable is written multiple times with nothing to ensure that the writes happen at distinct times, some kinds of hardware might allow multiple "store" operations to be performed simultaneously to different addresses using a dual-port memory. However, some dual-port memories expressly forbid the scenario where two stores hit the same address simultaneously, regardless of whether or not the values written match. If a compiler for such a machine notices two unsequenced attempts to write the same variable, it might either refuse to compile or ensure that the two writes cannot get scheduled simultaneously. But if one or both of the accesses is via a pointer or reference, the compiler might not always be able to tell whether both writes might hit the same storage location. In that case, it might schedule the writes simultaneously, causing a hardware trap on the access attempt.

当然,有人可能会在这样的平台上实现C编译器,但这并不意味着在硬件平台上使用小到可以被原子处理的类型存储时不应该定义这种行为。试图以无序的方式存储两个不同的值可能会导致奇怪,如果编译器没有意识到它;例如,给定:

uint8_t v;  // Global

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

if the compiler in-lines the call to "moo" and can tell it doesn't modify "v", it might store a 5 to v, then store a 6 to *p, then pass 5 to "zoo", and then pass the contents of v to "zoo". If "zoo" doesn't modify "v", there should be no way the two calls should be passed different values, but that could easily happen anyway. On the other hand, in cases where both stores would write the same value, such weirdness could not occur and there would on most platforms be no sensible reason for an implementation to do anything weird. Unfortunately, some compiler writers don't need any excuse for silly behaviors beyond "because the Standard allows it", so even those cases aren't safe.


令人困惑的是,将一个常量值存储到一个局部变量中并不是C设计用于运行的每个架构上的一个原子指令。在这种情况下,运行代码的处理器比编译器更重要。例如,在ARM上,每条指令都不能携带一个完整的32位常量,在一个变量中存储一个int就需要更多的指令。这个伪代码的例子,你一次只能存储8位,并且必须在32位寄存器中工作,i是一个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

然而,在我的测试中,gcc足够友好,可以识别相同的值被使用了两次,并生成它一次,没有任何奇怪的事情。得到-1和-1 但我的例子仍然是有效的,因为重要的是要考虑到即使是常数也可能不像它看起来那么明显。


实际上,不依赖于编译器会检查i是否被赋值两次的事实是有原因的,这样就可以用一次赋值替换它。如果我们有一些表达呢?

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)));
}

c++ 17定义了更严格的求值规则。特别地,它对函数参数进行排序(尽管没有指定顺序)。

N5659§4.6:15 当A在B之前排序或B在A之前排序时,计算A和B是不确定排序的, 但没有说明是哪一种。[注意:不确定排序的计算不能重叠,但也可以重叠 先执行。-结束注释] 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