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

时间:2014-02-10 06:31:33

标签: c++ language-lawyer undefined-behavior

我正在阅读有关order of evaluation violations的内容,他们举了一个令我困惑的例子。

  

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’s answer,因为它暴露了定义这个陈述的陷阱和复杂性,尽管它在第一眼看起来是多么直接。 @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,因为它属于同一规则,尽管程序员的意图是(恕我直言)显而易见且毫不含糊。

Paul Draper在结论中也明确表示

  

是否已经定义了行为?是。它被定义了吗?否。

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

出于什么原因/目的

尽管C ++标准中存在一些疏忽(可能是粗心大意),但许多遗漏都是合理的,并且有特定的用途。虽然我知道目的通常是“让编译器 - 编写者的工作变得更容易”或“更快的代码”,但我主要想知道是否有充分的理由离开 {{1} } 作为UB。

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

supercat提供了一个相关的说明,试图让i做它看起来应该做的事情的陷阱。他指出,在某些体系结构中,对同一内存地址的多个同时写入存在严格限制。如果我们处理的事情比f(i=-1, i=-1)更简单,那么编译器可能很难捕捉到它。

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

尽管harmic,supercat和davidf的每个例子都有些人为,但它们仍然有助于提供f(i=-1, i=-1)应该是未定义行为的实际原因。

我接受了harmic的答案,因为尽管Paul Draper的回答更好地解决了“为什么原因”部分,但它尽力解决了原因的所有含义。

其他答案

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

11 个答案:

答案 0 :(得分:338)

由于操作未被排序,因此无法说出执行分配的指令不能交错。这可能是最佳选择,具体取决于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。

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

答案 1 :(得分:207)

首先,“标量对象”表示类似intfloat或指针的类型(请参阅What is a scalar Object in 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的答案,找出完全合理的解释,说明这种情况如何)。或者您的程序可能会出现段错误。或者重新格式化硬盘。

但是现在你问:“我的例子怎么样?我对两个作业使用了相同的值(-1)。有什么可能不清楚的?”

你是对的...除了C ++标准委员会描述的方式。

  

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

他们可以为您的特殊情况做出特殊例外,但他们没有。 (他们为什么要这样做?可能会有什么用途?)因此,i仍可能是5。或者你的硬盘可能是空的。因此,您的问题的答案是:

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

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

是否已经定义了行为?是。它被定义了吗?不,因此,它是“未定义的”。

那就是说,“undefined”并不意味着编译器会格式化你的硬盘......这意味着可以,它仍然是一个符合标准的编译器。实际上,我确信g ++,Clang和MSVC都能达到你的预期。他们只是不“必须”。


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

答案 2 :(得分:26)

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

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

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

考虑允许这种情况。

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

 #define VALUEB 2

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

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

修改

@HeartWare pointed outA DIV B为0时,某些语言中不允许使用B形式的常量表达式,并导致编译失败。因此,更改常量可能会导致其他位置的编译错误。哪个是恕我直言,不幸。但将这些事情限制在不可避免的范围内肯定是好的。

答案 3 :(得分:11)

如果有一些可以想象的原因,为什么编译器试图成为&#34;有帮助的&#34;行为通常被指定为undefined。可能会做一些会导致意外行为的事情。

如果变量被多次写入以确保写入在不同时间发生,则某些类型的硬件可能允许多个&#34;存储&#34;使用双端口存储器同时对不同地址执行的操作。但是,某些双端口存储器明确禁止两个存储同时命中同一地址的情况,无论写入的值是否匹配。如果这种机器的编译器注意到两次未经测试的尝试写入相同的变量,它可能会拒绝编译或确保无法同时调度这两个写入。但是,如果访问中的一个或两个是通过指针或引用,则编译器可能无法始终判断两个写入是否可能到达同一存储位置。在这种情况下,它可能会同时调度写入,从而导致访问尝试的硬件陷阱。

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

uint8_t v;  // Global

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

如果编译器将调用内联到&#34; moo&#34;并且可以告诉它不会修改 &#34; v&#34;,它可能存储5到v,然后存储6到* p,然后传递5到&#34;动物园&#34;, 然后将v的内容传递给&#34; zoo&#34;。如果&#34;动物园&#34;没有修改&#34; v&#34;, 两个调用应该没有办法传递不同的值, 但无论如何这很容易发生。另一方面,在哪些情况下 两个商店都会写相同的价值,这样的奇怪之处就不会发生了 在大多数平台上,没有合理的理由可以实施 做任何奇怪的事。不幸的是,一些编译器编写者并不需要 愚蠢行为的借口超过&#34;因为标准允许它&#34;,所以甚至 那些情况并不安全。

答案 4 :(得分:10)

令人困惑的是,将常量值存储到局部变量中并不是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 但我的例子仍然有效,因为重要的是要考虑即使是一个常数也可能不像它看起来那么明显。

答案 5 :(得分:9)

案例中的大多数实现中结果相同的事实是偶然的;评估顺序仍未定义。考虑f(i = -1, i = -2):在这里,订单很重要。在您的示例中,它不重要的唯一原因是两个值均为-1的事故。

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

答案 6 :(得分:8)

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

  

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

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

  

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

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

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

答案 7 :(得分:8)

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

  

N5659 §4.6:15
  在 B B 之前对 A 进行排序时, A B 的评估将不确定地排序>在 A 之前排序,   但它没有具体说明。 [注意:不确定顺序的评估不能重叠,但两者都可以   先执行。 - 结束记录]

     

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

答案 8 :(得分: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

答案 9 :(得分:2)

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

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

这是Scalar Types的MSDN文章。

答案 10 :(得分: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)));
}