为什么f(i = -1, i = -1)没有定义?

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

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

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

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

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

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

有人能澄清一下吗?


更新

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


总结

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

为什么?

这里的主要答案来自保罗•德雷伯,而马丁J提供了类似但不广泛的答案。保罗·德雷珀的答案可以归结为

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

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

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

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

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

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

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

为了什么原因/目的

尽管c++标准中存在一些疏忽(可能是粗心大意),但许多遗漏都是合理的,并为特定的目的服务。虽然我知道这样做的目的通常是“让编译器编写者的工作更容易”,或者“更快的代码”,但我主要想知道你是否有充分的理由离开 f(i=-1, i=-1) 乌兰巴托。

harmicsupercat提供了为UB提供原因的主要答案。Harmic指出,优化编译器可能会将表面上的原子赋值操作分解为多个机器指令,并且可能进一步交织这些指令以获得最佳速度。这可能会导致一些非常令人惊讶的结果:i在他的场景中最终为-2 !因此,harmic演示了如果操作未排序,将相同的值多次赋值给变量会产生不良影响。

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指出,如果我们考虑重载赋值操作符(而不仅仅是普通的标量),那么我们也会遇到麻烦。

25660 次浏览

首先,“标量对象”意味着像intfloat或指针这样的类型(参见在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这样的东西”。

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

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

由于操作是无序的,因此执行赋值的指令不能交叉。这样做可能是最佳的,这取决于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

现在i是-2。

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

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

// config.h
#define VALUEA  1


// defaults.h
#define VALUEB  1


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

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

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

 #define VALUEB 2

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

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

编辑

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

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

如果一个变量被多次写入,而没有任何内容来确保写入发生在不同的时间,某些硬件可能允许使用双端口内存对不同的地址同时执行多个“存储”操作。然而,一些双端口存储器明确禁止两个存储器同时到达同一个地址不管写入的值是否匹配的场景。如果这种机器的编译器注意到两次对同一个变量的未排序的写入尝试,它可能会拒绝编译或确保两次写入不能同时调度。但是,如果一次或两次访问都是通过指针或引用,编译器可能并不总是能够判断两次写入是否会触及相同的存储位置。在这种情况下,它可能会同时调度写操作,导致访问尝试时出现硬件陷阱。

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

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的内容传递给“zoo”。如果“zoo”不能修饰“v”, 这两个调用不应该传递不同的值, 但这很容易发生。另一方面,在这种情况下 两个存储将写入相同的值,这种奇怪的情况不会发生 在大多数平台上都没有合理的实现理由 做任何奇怪的事情。不幸的是,一些编译器编写者不需要任何代码 为愚蠢的行为找借口,而不是“因为标准允许”,所以即使如此

令人困惑的是,将一个常量值存储到一个局部变量中并不是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定义了更严格的计算规则。特别地,它对函数参数进行排序(尽管没有指定顺序)。

< p > N5659 §4.6:15 < br > 当一个B之前排序或B一个之前排序时,计算一个B是不确定排序的, 但没有说明是哪一种。[请注意:不确定排序的计算不能重叠,但也可以重叠 先执行。-最后请注意] < / p > < p > N5659 § 8.2.2:5 < br > 的 参数的初始化,包括每个相关的值计算和副作用,都是不确定的

.

.

.

它允许一些情况,之前是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