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


267

我正在阅读有关评估违规的顺序,它们举了一个令人困惑的例子。

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


摘要

既然这个问题引起了很多关注,我将总结要点/答案。首先,请允许我指出一下“为什么”可能具有紧密相关但又微妙的不同含义,即“出于何种原因 ”,“出于何种原因 ”和“出于何种目的 ”。我将按照回答“为什么”的含义中的哪一个分组答案。

是什么原因

这里的主要答案来自Paul DraperMartin J做出了类似但不太广泛的答案。保罗·德雷珀(Paul Draper)的答案归结为

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

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

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

因此,f(i=-1, i=-1)UB也是如此,因为它属于同一规则,尽管程序员(IMHO)的意图是明显且明确的。

保罗·德雷珀(Paul Draper)在其结论中也明确指出:

可以定义行为吗?是。定义好了吗?没有。

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

由于什么原因/目的

尽管C ++标准中存在一些疏漏(可能是粗心大意),但是许多遗漏是有道理的,并且可以满足特定的目的。尽管我知道目的通常是“使编译器-编写器的工作更轻松”或“更快的代码”,但我主要还是想知道是否有充分理由离开 f(i=-1, i=-1) UB。

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

超级猫提供了尝试f(i=-1, i=-1)去做它应该做的陷阱的相关陷阱。他指出,在某些体系结构上,对多次同时写入同一内​​存地址有严格的限制。如果我们处理的琐碎事情比编译器小,编译器可能会很难抓住它f(i=-1, i=-1)

davidf还提供了一个与谐波指令非常相似的交织指令示例。

尽管每个谐波,超级猫和davidf的示例在某种程度上都是人为设计的,但将它们合在一起,仍然可以提供一个明确的理由,说明为什么f(i=-1, i=-1)应采用不确定的行为。

我接受harmic的答案是因为,即使Paul Draper的答案更好地解决了“原因”部分,它也尽了最大努力解决了所有原因。

其他答案

JohnB指出,如果我们考虑重载的赋值运算符(而不只是普通的标量),那么我们也会遇到麻烦。


1
标量对象是标量类型的对象。请参阅3.9 / 9:“算术类型(3.9.1),枚举类型,指针类型,成员类型的指针(3.9.2)std::nullptr_t和这些类型的cv限定版本(3.9.3)统称为标量类型。 ”
罗伯·肯尼迪

1
也许页面上有错误,实际上是他们的意思f(i-1, i = -1)或类似的意思。
李斯特先生,2014年


@RobKennedy谢谢。“算术类型”是否包含布尔值?
Nicu Stiurca 2014年

1
您的更新应该在答案部分。
Grijesh Chauhan 2014年

Answers:


343

由于操作是无序列的,因此没有什么可以说执行分配的指令不能交错。这样做可能是最佳选择,具体取决于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。

这可能是一个伪造的例子,但是有可能。


59
一个很好的例子,说明了如何在遵守排序规则的同时实际执行意外的操作。是的,有些人为的设计了,但是我首先问的是代码删除。:)
Nicu Stiurca 2014年

10
而且,即使将分配作为原子操作完成,也可以设想超标量体系结构,其中两个分配同时进行,从而导致导致失败的内存访问冲突。设计该语言是为了使编译器编写者在使用目标计算机的优势时拥有尽可能多的自由。
2014年

11
我真的很喜欢您的示例,因为即使两个赋值都没有顺序,所以即使在两个参数中将相同的值赋给同一变量也可能导致意外的结果
Martin J.

1
+ 1e + 6(好的,+ 1),这样编译的代码并不总是您所期望的。当您不遵循规则时,优化程序确实擅长向您抛出此类曲线:P
Corey 2014年

3
在Arm处理器上,负载32位最多可以执行4条指令:它load 8bit immediate and shift最多可以执行4次。通常,编译器会执行间接寻址以从表中获取数字来避免这种情况。(-1可以在一条指令中完成,但可以选择另一个示例)。
ctrl-alt-delor 2014年

208

首先,“标量对象”是指一种类型的像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上问这些问题。


9
@hvd,是的,实际上我知道,如果您启用-Wsequence-point了g ++,它将警告您。
Paul Draper

47
“我相信g ++,Clang和MSVC都能满足您的期望。”我不相信现代的编译器。他们是邪恶的。例如,他们可能认识到这是未定义的行为,并假定此代码不可访问。如果他们今天不这样做,他们明天可能会这样做。任何UB都是滴答作响的定时炸弹。
CodesInChaos

8
@BlacklightShining“您的答案不好,因为它不好,”反馈不是很有用,是吗?
Vincent van der Weele 2014年

13
@BobJarvis面对不确定的行为,编译器绝对没有义务生成甚至是远程正确的代码。它甚至可以假设从未调用过此代码,因此用nop替换了整个代码(请注意,编译器实际上是在UB面前做出这样的假设)。因此,我想说的是,对这种错误报告的正确反应只能是“关闭,按预期工作”
Grizzly 2014年

7
@SchighSchagh有时人们需要对术语重新措辞(仅表面上看来是重言式的回答)。大多数对技术规范不熟悉的人在大多数情况下都认为“ undefined behavior手段” something random will happen
Izkata 2014年

27

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

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

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

考虑允许的情况。

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

 #define VALUEB 2

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

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

编辑

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


可以,但是该示例确实使用整数文字。您f(i = VALUEA, i = VALUEB);绝对有可能发生不确定的行为。我希望您不是真正针对标识符后面的值进行编码。

3
@Wold但是编译器看不到预处理器宏。即使不是这样,也很难在任何一种编程语言中找到一个示例,在该示例中,源代码会一直编译直到一个int常数从1更改为2。这是无法接受和无法解释的,尽管您在此处看到了很好的解释为什么即使使用相同的值也会破坏该代码。
Ingo 2014年

是的,编译器看不到宏。但是,是一个问题吗?

1
您的答案遗漏了重点,请阅读harmic的答案以及OP对此的评论。
狼2014年

1
它可以做到SomeProcedure(A, B, B DIV (2-A))。无论如何,如果该语言指出必须在编译时对CONST进行全面评估,那么,当然,我的主张对这种情况无效。由于它在某种程度上模糊了编译时和运行时的区别。如果我们写的话,还会注意CONST C = X(2-A); FUNCTION X:INTEGER(CONST Y:INTEGER) = B/Y; 吗?还是不允许使用功能?
Ingo 2014年

12

混乱之处在于,将常数存储到局部变量中并不是在C设计要在其上运行的每种体系结构上的一条原子指令。在这种情况下,代码运行的处理器比编译器处理更多的事情。例如,在ARM上,每条指令不能携带完整的32位常量,因此将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

但是,在我的测试中,gcc足以识别相同的值被使用两次并生成一次,并且没有任何奇怪的变化。我得到-1,-1但我的示例仍然有效,因为重要的是要考虑甚至一个常数也可能不像看起来那样明显。


我想在ARM上,编译器将只从表中加载常量。您所描述的似乎更像是MIPS。
2014年

1
@AndreyChernyakhovskiy是的,但是在不是简单的情况下-1(编译器已存储在某个地方)3^81 mod 2^32而是一个常量,而是不变的,那么编译器可能会完全按照此处的操作进行操作,并且在某种程度的优化下,我会交错调用序列避免等待。
2014年

@tohecz,是的,我已经检查过了。确实,编译器太聪明了,无法从表中加载每个常量。无论如何,它将永远不会使用相同的寄存器来计算两个常量。这肯定会“未定义”定义的行为。
2014年

@AndreyChernyakhovskiy但是您可能不是“世界上每个C ++编译器程序员”。请记住,有些机器具有3个短寄存器,仅可用于计算。
2014年

@tohecz,考虑f(i = A, j = B)其中ij是两个独立对象的示例。这个例子没有UB。具有3个短寄存器的机器是没有任何借口的编译器的两个值混合A,并B在同一个寄存器(在@ davidf的答案如图所示),因为它会破坏程序的语义。
2014年

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

8

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

N5659 §4.6:15
当在B之前先对A进行排序,或者BA之前对B进行 评估时,评估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

2
感谢您为c ++ 17添加此更新,因此我不必这样做。;)
Yakk-Adam Nevraumont

太好了,非常感谢您的回答。轻微的跟进:如果f的签名为f(int a, int b),C ++ 17是否可以保证a == -1b == -2如在第二种情况下那样被调用?
Nicu Stiurca

是。如果我们有参数ab,则i-then- a初始化为-1,然后i-then- b初始化为-2或其他方式。在两种情况下,我们都以a == -1和结束b == -2。至少我是这样阅读的:“ 参数的初始化,相对于任何其他参数的初始化,不确定地排序,包括每个关联的值计算和副作用。”
AlexD

我认为从C语言开始就一直如此。
fuz

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 family,float family和指针。
Nicu Stiurca 2014年

在这种情况下,真正的问题是赋值运算符是有状态的,因此即使对变量进行常规操作也容易出现此类问题。
AJMansfield

2

这只是在回答“我不知道除了整数或浮点数之外,“标量对象”还意味着什么”。

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

这是标量类型的MSDN文章。


读起来有点像“仅链接答案”。您可以将相关链接中的相关位复制到此答案中吗(以​​块引用)?
科尔·约翰逊

1
@ColeJohnson这不是仅链接的答案。该链接仅用于进一步说明。我的答案是“指针”,“枚举”。
张鹏

我没有说您的答案仅链接的答案。我说它“读起来​​像[一个]”。我建议您阅读为什么我们不希望仅在帮助部分中链接答案。原因是,如果Microsoft更新其站点中的URL,则该链接将中断。
科尔·约翰逊

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

1
无需证明费马定理:只需将赋给1即可i。这两个参数都分配1并执行“正确”的事情,或者参数分配不同的值,这是未定义的行为,因此我们仍然可以选择。
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.