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

281
我正在阅读关于求值顺序违规的内容,他们给出了一个让我感到困惑的示例。

1) If a side effect on a scalar object is un-sequenced relative to another side effect on the same scalar object, the behavior is undefined.

// 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指出了在使用引用时出现的一些问题,但我认为这与此问题的无序副作用方面是不相关的。


摘要

由于这个问题引起了很多关注,我将总结主要观点/答案。首先,让我稍微离题一下,指出“为什么”可能有密切相关但略有不同含义,即“因为什么原因”,“为什么目的”。我将根据它们回答的“为什么”的含义分组。

因为什么原因

主要答案来自Paul DraperMartin J提供了类似但不够详细的答案。 Paul Draper的答案可以概括为

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

总体而言,该答案在解释C++标准方面非常好。它还涉及到一些相关的未定义行为,例如f(++i, ++i);f(i=1, i=-1);。在第一个相关情况中,不清楚第一个参数是否应该是i+1,第二个参数是否应该是i+2或者反之;在第二个情况中,在函数调用后i是否应该是1或-1也不清楚。这两种情况都是未定义行为,因为它们属于以下规则:

如果对标量对象的副作用与同一标量对象上的另一个副作用无序,则行为未定义。

因此,f(i=-1, i=-1)也是未定义行为,因为它遵循相同的规则,尽管程序员的意图(在我看来)是明显且明确的。

保罗·德拉珀在他的结论中也明确表示:

它可能是已定义的行为吗?是的。它被定义了吗?没有。

这让我们想到了一个问题:“为什么会将f(i=-1, i=-1)定义为未定义行为?”

为什么会这样

虽然C++标准中有一些疏漏(可能是粗心),但很多省略都有明确的理由和特定目的。虽然我知道这个目的通常是“让编译器编写工作更轻松”或“生成更快的代码”,但我主要想知道为什么要把f(i=-1, i=-1)定义为UB。 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的答案,因为它最好地解决了所有why的含义,尽管Paul Draper的答案更好地解决了“为什么”的部分。
其他答案 JohnB指出,如果考虑到重载赋值运算符(而不仅仅是普通标量),那么我们也可能遇到问题。

1
标量对象是标量类型的对象。参见3.9/9:“算术类型(3.9.1)、枚举类型、指针类型、成员指针类型(3.9.2)、std::nullptr_t以及这些类型的cv限定版本(3.9.3)统称为标量类型。” - Rob Kennedy
1
也许页面上有错误,他们实际上是指 f(i-1, i = -1) 或类似的东西。 - Mr Lister
请看这个问题:http://stackoverflow.com/a/4177063/71074 - Robert S. Barnes
1
你的更新应该在答案部分。 - Grijesh Chauhan
我认为的主要原因是f(i = -1, i = -2)是未定义的,如果两个值相等,没有真正的理由例外。这将使标准和编译器更加复杂,而几乎没有任何好处。 - Phil1970
显示剩余3条评论
11个回答

358
由于操作是未排序的,没有什么可以说明执行赋值的指令不能交错。根据CPU架构的不同,这可能是最佳选择。引用页面陈述如下:
如果A在B之前没有排序,而B在A之前也没有排序,则存在两种可能性:
1. A和B的评估未被排序:它们可以以任何顺序执行,并且可以重叠(在执行的单个线程中,编译器可以交错构成A和B的CPU指令)。 2. A和B的评估具有不确定顺序:它们可以以任何顺序执行,但不能重叠:要么A在B之前完成,要么B在A之前完成。下次评估相同表达式时的顺序可能相反。
这本身似乎不会导致问题-假设正在执行的操作是将值-1存储到内存位置中。但是也没有什么可以说编译器不能将其优化为另一组具有相同效果的指令,但如果该操作与对同一内存位置进行的另一个操作交错,则可能会失败。
例如,想象一下,相对于加载值-1,将内存清零然后递减它更有效率。那么这个例子:
f(i=-1, i=-1)

可能会变成:

clear i
clear i
decr i
decr i

现在 i 的值是 -2。

这可能是一个虚假的例子,但是这种情况是有可能的。


63
非常好的例子,展示了表达式在遵循顺序规则的同时可能会产生意想不到的效果。是的,有点勉强,但我一开始要询问的代码片段也是如此。 :) - Nicu Stiurca
10
即使任务作为原子操作完成,也有可能想象出一种超标量架构,在这种架构中,两个赋值同时进行,导致内存访问冲突,从而导致失败。该语言的设计旨在为编译器编写者提供尽可能多的自由度,以利用目标机器的优势。 - ach
11
我很喜欢你举的例子,即使在两个参数中将同一个变量赋相同的值,也可能导致意外的结果,因为这两个赋值是无序的。 - Martin J.
1
+1e+6(好的,+1)表示编译代码并不总是符合您的预期。当您不遵循规则时,优化器会非常擅长向您投掷这些曲线。 :P - Corey
3
在Arm处理器上,加载32位数据需要最多4个指令:它会重复执行“加载8位立即数并移位”最多4次。通常编译器会使用间接寻址的方式从表中获取数字以避免这种情况发生。(可以选择其他示例,-1可以在一条指令中完成)。 - ctrl-alt-delor
显示剩余3条评论

214

首先,“标量对象”是指像intfloat或指针这样的类型(参见什么是C++中的标量对象?)。


其次,可能更容易理解的是

f(++i, ++i);

否则行为将未定义。但是

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

不太明显。

稍微不同的例子:

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

"last" 发生了什么赋值,i = 1 还是 i = -1?标准中没有定义。这意味着 i 可能是 5 (请参见 harmic 的答案,完全可以解释为何会出现这种情况)。或者你的程序可能会崩溃。或重新格式化硬盘。

但现在你问:“我的例子怎么样?我对两个赋值都使用了相同的值 (-1)。有什么不清楚的地方吗?”

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

如果对标量对象的副作用与另一个对同一标量对象的副作用未排序,则行为未定义。

他们本来可以为您特殊情况做出特殊的例外,但他们没有这样做。(为什么应该呢?那可能有什么用呢?)所以,i 仍然可能是 5。或者您的硬盘可能为空。因此,对于您的问题的答案是:

这是未定义行为,因为未定义行为的行为未被定义。

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

这个行为本可以被定义为有序行为吗?可以。已经被定义了吗?没有。因此,“未定义”。

话虽如此,“未定义”并不意味着编译器会格式化您的硬盘……它可能会,而且仍然符合标准。实际上,我相信 g++、Clang 和 MSVC 都会按照您的期望执行。他们只是“不必须”。


另一个问题可能是“为什么 C++ 标准委员会选择使这个副作用无序?”那个答案涉及历史和委员会的意见。或者“在 C++ 中使这个副作用无序有什么好处?”这允许任何理由,无论它是否是标准委员会的实际原因。你可以在这里或 programmers.stackexchange.com 上提出这些问题。


9
@hvd,是的,实际上我知道如果你在g++中启用了“-Wsequence-point”,它会发出警告。 - Paul Draper
51
“我相信g++、Clang和MSVC都能如你所愿。” 我不会相信现代编译器。它们是邪恶的。例如,它们可能会识别到这是未定义行为并假定该代码是无法访问的。如果它们今天不这样做,它们明天可能会这样做。任何未定义行为都是一颗定时炸弹。 - CodesInChaos
8
“@BlacklightShining你的回答不好,因为它不好。”这样的反馈并不是很有用,对吧? - Vincent van der Weele
14
@BobJarvis 这段代码存在未定义行为,编译器没有生成正确代码的义务。它甚至可以假设这段代码永远不会被调用,因此用 nop 代替整个代码块(请注意,编译器在遇到未定义行为时确实做出这样的假设)。因此,我认为对于这样的 bug 报告,唯一正确的反应是“关闭,按预期工作”。 - Grizzly
7
有时候,重新表述术语(表面上看起来似乎是一种同义反复的答案),可以满足人们的需求。大多数刚接触技术规范的人认为“未定义行为”意味着“会发生一些随机的事情”,但在大多数情况下并非如此。 - Izkata
显示剩余24条评论

27

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

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

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

考虑如果允许这种情况。

现在,几个月后,需要进行更改。

 #define VALUEB 2

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

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

编辑

@HeartWare 指出,在某些语言中,当 B 为 0 时,形式为 A DIV B 的常量表达式是不允许的,并会导致编译失败。 因此,在其他地方更改常量可能会导致编译错误。 这是不幸的事情,但限制这种情况到不可避免的范围内肯定是好的。


当然可以,但是这个例子确实使用了整型字面量。你的 f(i = VALUEA, i = VALUEB); 存在未定义行为的风险。我希望你不是真的针对标识符背后的值编程。 - Wolf
4
但编译器看不到预处理宏。即使不是这样,很难找到一个在任何编程语言中,源代码可以编译通过,直到将一些int常量从1更改为2的例子。这是完全不能接受和无法解释的,而您可以在这里看到非常好的解释,即使使用相同值,该代码也会出现问题。 - Ingo
是的,编译器看不到宏。但是,是问题吗? - Wolf
2
你的回答没有抓住重点,请阅读harmic的回答以及原帖中对此的评论。 - Wolf
1
它可以执行SomeProcedure(A, B, B DIV (2-A))。无论如何,如果语言规定CONST必须在编译时完全计算,那么我的说法当然对这种情况无效。因为它在某种程度上模糊了编译时和运行时的区别。如果我们写CONST C = X(2-A); FUNCTION X:INTEGER(CONST Y:INTEGER) = B/Y;,它也会注意到吗?或者函数不允许? - Ingo
显示剩余12条评论

14

混淆的地方在于,在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。 但是我的例子仍然是有效的,因为重要的是要考虑即使是一个常量也可能并不像它看起来那么明显。


我猜在ARM上编译器只会从表中加载常量。你所描述的更像是MIPS。 - ach
1
@AndreyChernyakhovskiy 是的,但是当它不仅仅是-1(编译器存储在某个地方的值)而是一个常数3^81 mod 2^32时,编译器可能会像这里一样进行优化,并在某些级别的优化中交错调用序列以避免等待。 - yo'
@tohecz,是的,我已经检查过了。确实,编译器太聪明了,可以从表中加载每个常量。无论如何,它永远不会使用相同的寄存器来计算这两个常量。这肯定会“未定义”已定义的行为。 - ach
@AndreyChernyakhovskiy,但你可能不是“世界上每个C++编译器程序员”。请记住,仍然有一些只能用于计算的具有3个短寄存器的机器存在。 - yo'
1
@tohecz,考虑例子f(i = A, j = B),其中ij是两个独立的对象。这个例子没有未定义行为。机器只有3个短寄存器并不是编译器将AB的两个值混合在同一个寄存器中的借口(如@davidf的答案所示),因为这会破坏程序语义。 - ach
这个回答毫无意义。这只是一个关于标准的纯粹问题。任何实际平台是否执行此操作都不相关。即使不存在任何可能失败的平台,行为仍将是未定义的,因为标准明确表示它没有定义它。 - David Schwartz

11

C++17 引入了更为严格的求值规则,特别是对函数参数进行了序列化处理(尽管顺序未指定)。

N5659 §4.6:15
AB 之前序列化或者 BA 之前序列化时,评估 AB 是不确定的,但未指定哪个先执行。[:不确定地序列化的评估不能重叠,但是任何一个可以先执行。—结束注]

N5659 §8.2.2:5
与任何其他参数相比,参数的初始化,包括每个关联值的计算和副作用,在不确定的情况下进行排序。

它允许一些在此之前会出现未定义行为的情况:

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

2
谢谢您为 [tag:C++17] 添加此更新,这样我就不用再做了。 ;) - Yakk - Adam Nevraumont
非常棒,非常感谢您的回答。稍微跟进一下:如果 f 的签名是 f(int a, int b),那么在第二种情况下调用时,C++17 是否保证 a == -1b == -2 - Nicu Stiurca
是的。如果我们有参数ab,那么要么先初始化i-然后-a为-1,然后初始化i-然后-b为-2,或者反过来。在这两种情况下,我们最终得到a == -1b == -2。至少这就是我如何理解“参数的初始化(包括每个相关值计算和副作用)与任何其他参数的初始化的顺序不确定”。 - AlexD
我认为自C语言诞生以来,这一点就一直没有改变。 - fuz
@fuz 可观察行为与定义行为不同。 - Swift - Friday Pie
规则的改变甚至意味着像f(++i, ++i);这样的表达式是被明确定义的,尽管在函数f的参数值中存在两种可能性。 - undefined

11

如果编译器在尝试进行某些操作时可能导致完全意想不到的行为,行为通常被指定为未定义。

如果一个变量多次写入而没有确保这些写入发生在不同的时间,一些硬件可能允许同时使用双端口内存向不同地址执行多个“存储”操作。然而,有些双端口内存明确禁止两个存储同时命中同一个地址的情况,无论写入的值是否匹配。如果这样的机器上的编译器注意到两个未排序的尝试写入同一个变量,它可能会拒绝编译或确保这两个写入不能同时被调度。但如果其中一个或两个访问是通过指针或引用进行的,编译器可能无法始终确定这两个写入是否可能命中相同的存储位置。在这种情况下,它可能会同时安排这些写入,导致访问尝试的硬件陷阱。

当使用足够小以原子方式处理的类型存储时,当然,某人可能在这样的平台上实现 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”,那么两个调用应该没有办法传递不同的值,但仍然可能发生这种情况。另一方面,在两个存储都将写入相同值的情况下,这种奇怪的事情就不会发生,而且在大多数平台上,没有明智的理由让实现做出任何奇怪的行为。不幸的是,一些编译器编写者甚至没有超过“因为标准允许”,所以即使在这些情况下也不安全。

9

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

考虑到表达式被指定为具有未定义行为的表达式,恶意的兼容编译器可能会在你评估f(i = -1, i = -1)时显示不当的图像并终止执行,但仍被认为是完全正确的。幸运的是,我所知道的没有编译器这样做。


8

在我看来,有关函数参数表达式顺序的唯一规则是这个:

3) 在调用函数时(无论函数是否是内联的,无论是否使用显式函数调用语法),与被调用函数指示后缀表达式相关的每个参数表达式的值计算和副作用都先于被调用函数体中的每个表达式或语句执行。

这并没有定义参数表达式之间的顺序,因此我们会遇到这种情况:

1) 如果标量对象上的一个副作用与同一标量对象上的另一个副作用的顺序不确定,则行为是未定义的。

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

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

5
赋值运算符可以被重载,此时顺序可能很重要:
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

1
足够正确,但问题是关于标量类型的,其他人已经指出它基本上意味着int系列、float系列和指针。 - Nicu Stiurca
在这种情况下真正的问题是赋值运算符具有状态,因此即使对变量进行常规操作也容易出现此类问题。 - AJMansfield

3

实际上,有一个原因不依赖于编译器检查 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)));
}

1
不需要证明费马定理:只需将 i 赋值为 1。如果两个参数都赋值为 1,那么这样做就是“正确”的;如果参数分别赋予不同的值,则行为未定义,因此我们的选择仍然被允许。 - user1084944

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