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

Nic*_*rca 266 c++ undefined-behavior language-lawyer

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

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

// snip
f(i = -1, i = -1); // undefined behavior
Run Code Online (Sandbox Code Playgroud)

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

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

在这种情况下,我不明白该陈述是如何含糊不清的.在我看来,无论第一个或第二个参数是否首先被评估,i最终都是-1,并且两个参数也是-1.

有人可以澄清一下吗?


UPDATE

我非常感谢所有的讨论.到目前为止,我非常喜欢@ harmic的答案,因为它暴露了定义这个陈述的陷阱和错综复杂,尽管它看起来有多么简单.@ acheong87指出了使用引用时出现的一些问题,但我认为这与这个问题的未测序副作用方面是正交的.


摘要

由于这个问题得到了很多关注,我将总结一下主要观点/答案.首先,请允许我进行一个小小的题外话,指出"为什么"可以具有密切相关但又略有不同的含义,即"为什么原因 ","为什么原因 "和"为了什么目的 ".我将根据他们所解决的"为什么"的含义分组答案.

为什么原因

这里的主要答案来自Paul Draper,Martin 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++标准中存在一些疏忽(可能是粗心),但许多遗漏都是合理的,并且有特定的用途.虽然我知道目的通常是"让编译器 - 编写者的工作变得更容易"或"更快的代码",但我主要想知道是否有一个很好的理由离开 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)应该是未定义的行为.

我接受了哈米克的答案,因为尽管保罗·德雷珀的回答更好地解决了"因为什么原因"的部分,但它尽力解决了为什么要解决的问题.

其他答案

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

har*_*mic 342

由于操作未被排序,因此无法说出执行分配的指令不能交错.这可能是最佳选择,具体取决于CPU架构.引用的页面说明了这一点:

如果A在B之前没有排序,而B在A之前没有排序,则存在两种可能性:

  • A和B的评估未被排序:它们可以以任何顺序执行并且可以重叠(在单个执行线程内,编译器可以交错组成A和B的CPU指令)

  • A和B的评估是不确定的:它们可以按任何顺序执行但可能不重叠:A将在B之前完成,或者B将在A之前完成.顺序可能与下次相同表达式相反被评估.

这本身似乎不会导致问题 - 假设正在执行的操作是将值-1存储到内存位置.但是也没有什么可说的,编译器不能将其优化为具有相同效果的单独指令集,但如果操作与同一存储器位置上的另一个操作交错,则可能会失败.

例如,假设与加载值-1相比,将内存归零然后递减它更有效.然后这样:

f(i=-1, i=-1)
Run Code Online (Sandbox Code Playgroud)

可能成为:

clear i
clear i
decr i
decr i
Run Code Online (Sandbox Code Playgroud)

现在我是-2.

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

  • 非常好的例子,表达式如何在符合排序规则的情况下实际执行某些意外操作.是的,有点做作,但我首先要问的代码也是如此.:) (57认同)
  • 即使分配是作为原子操作完成的,也可以设想一种超标量体系结构,其中两个分配同时进行,导致内存访问冲突,从而导致失败.该语言的设计使编译器编写者在使用目标机器的优势方面拥有尽可能多的自由度. (10认同)
  • 我真的很喜欢你的例子,即如果在两个参数中为同一个变量分配相同的值,可能会导致意外的结果,因为这两个赋值是未排序的 (10认同)
  • 在Arm处理器上,32位负载最多可以使用4条指令:它可以"立即加载8位,最多可以移位4次".通常,编译器将执行间接寻址以从表中获取数字以避免这种情况.(-1可以在1条指令中完成,但可以选择另一个例子). (3认同)

Pau*_*per 208

首先,"标量对象"表示类似a int,float或指针的类型(请参阅C++中的什么是标量对象?).


其次,这似乎更明显

f(++i, ++i);
Run Code Online (Sandbox Code Playgroud)

会有未定义的行为.但

f(i = -1, i = -1);
Run Code Online (Sandbox Code Playgroud)

不太明显.

一个稍微不同的例子:

int i;
f(i = 1, i = -1);
std::cout << i << "\n";
Run Code Online (Sandbox Code Playgroud)

什么任务发生在"最后" i = 1,或i = -1?它没有在标准中定义.真的,这意味着i可能是5(看到harmic的回答是一个完全合理的解释,说明这种情况如何).或者你的程序可能会出现段错误.或者重新格式化硬盘.

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

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

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

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

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

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

可以定义行为吗?是.它被定义了吗?不,因此,它是"未定义的".

也就是说,"未定义"并不意味着编译器会格式化您的硬盘驱动器......这意味着它可以并且它仍然是符合标准的编译器.实际上,我确信g ++,Clang和MSVC都能达到你的预期.他们只是不"必须".


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

  • "我确信g ++,Clang和MSVC都会按照你的预期行事."我不相信现代编译器.他们是邪恶的.例如,他们可能会认识到这是未定义的行为,并假设此代码无法访问.如果他们今天不这样做,他们明天可能会这样做.任何UB都是定时炸弹. (46认同)
  • @BobJarvis编译完全没有义务在面对未定义的行为时生成甚至远程正确的代码.TI甚至可以假设这个代码永远不会被调用,因此用nop替换整个东西(注意编译器实际上在UB面前做出了这样的假设).因此,我要说对这样的错误报告的正确反应只能"关闭,按预期工作" (13认同)
  • @hvd,是的,实际上我知道如果你为g ++启用`-Wsequence-point`,它会警告你. (9认同)
  • @BlacklightShining"你的答案很糟糕,因为它不好"是不是很有用的反馈,是吗? (8认同)
  • @SchighSchagh有时候对术语的重新描述(表面上看起来似乎只是一个同义反复)是人们需要的.大多数对技术规范不熟悉的人都认为"未定义的行为"意味着"随机发生的事情",这在大多数情况下远非如此. (7认同)
  • @SchighSchagh:"就我个人而言,我倾向于将UB与那些本身没有意义或者定义有问题的事物联系起来" - 这就是这个答案试图纠正的错误.您不应将未定义的行为与定义有问题的事物相关联,您应该将它与实际上未定义的事物相关联.如果你能够展示一个例子,为什么它有利于某些假设的实现(如harmic的答案那样),这很好,但是澄清你的陈述"`i`最终为`-1`"所需要的就是它不保证;-) (6认同)
  • @BЈовић,它不那么明显,因为你在两个操作中都将它改为相同的值,并且两个赋值都不依赖于前一个值,因此未定义的操作顺序似乎与随意读者无关. (4认同)
  • @BobJarvis:因为它是未定义的行为.因此,如果某些编译器决定将i设置为5,或者执行其他任何操作,那么它仍然是兼容的编译器.我不认为他实际上意味着我= = 5可能是一个结果. (4认同)
  • 传统上,人们有时可能会指出编译器可以通过让恶魔飞出你的鼻子来自由地对未定义的结构作出反应.因此,编译器在面对未定义的构造时所做的任何行为都是*鼻恶魔*. (2认同)
  • @BobJarvis看看Harmic的答案并告诉我们为什么这个(或任何其他结果数字)不符合标准. (2认同)
  • @BobJarvis我并不是说这种行为是合理的,但我不认为它是由标准明确定义的 - 正如其他人所提到的,一旦你有UB,任何事情都可以发生,包括鼻腔恶魔和格式化硬盘. (2认同)
  • @BobJarvis甚至可能比你想象的更合理.如果您获得制作UB的奢侈品,您可以使用它进行优化.当您在不关心一个用例如何工作的情况下进行优化时,您可以获得有趣的结果. (2认同)
  • @Grizzly:事实上,我甚至已经看到了一个优化指南*特别建议*编写可能具有未定义行为的代码以允许编译器优化.(示例是使用*signed*循环索引变量,这样编译器就不必担心产生*unsigned*循环索引变量溢出的正确行为) (2认同)
  • "这是未定义的行为,因为没有定义行为是什么."对我来说,这种同义反复的答案并不令人满意.就个人而言,我倾向于将UB与本质上没有意义或者定义有问题的事物联系起来.我很欣赏这里的讨论,但我更喜欢harmic的答案,它更好地揭示了试图定义这个问题的陷阱.(你的回答几乎暗示规范委员会太懒了.) (2认同)
  • @SchighSchagh,"原因"是这样的:在一般情况下,行为是不确定的,因为功能参数评估没有相对"顺序".在您的具体情况下,从来没有任何理由这样做,因此他们不担心发生异常. (2认同)

Ing*_*ngo 27

一个实际的原因是不要仅仅因为两个值相同而从规则中做出异常:

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);
Run Code Online (Sandbox Code Playgroud)

考虑一下这是允许的情况.

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

 #define VALUEB 2
Run Code Online (Sandbox Code Playgroud)

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

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

编辑

@HeartWare指出A DIV B在某些语言中不允许表单的常量表达式,当B为0时,会导致编译失败.因此,更改常量可能会导致其他位置的编译错误.哪个是恕我直言,不幸.但将这些事情局限于不可避免的当然是好事.

  • @Wold但编译器没有看到预处理器宏.即使不是这样,也很难在任何编程语言中找到一个例子,其中源代码编译直到将一个int常量从1更改为2.这简直是不可接受且无法解释的,而你在这里看到非常好的解释为什么即使使用相同的值也会破坏代码. (3认同)
  • 你的答案没有抓住要点,请阅读[harmic的答案](http://stackoverflow.com/a/21671069/2932052)以及OP对此的评论。 (2认同)

小智 12

令人困惑的是,将常量值存储到局部变量中并不是C设计为运行的每个体系结构上的一条原子指令.在这种情况下,代码运行的处理器比编译器更重要.例如,在ARM上,每条指令都不能携带完整的32位常量,在变量中存储int需要多一条指令.使用此伪代码的示例,您一次只能存储8位并且必须在32位寄存器中工作,我是int32:

reg = 0xFF; // first instruction
reg |= 0xFF00; // second
reg |= 0xFF0000; // third
reg |= 0xFF000000; // fourth
i = reg; // last
Run Code Online (Sandbox Code Playgroud)

您可以想象如果编译器想要优化它可能会将相同的序列交错两次,并且您不知道将写入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
Run Code Online (Sandbox Code Playgroud)

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


sup*_*cat 11

如果有一些可以想象的原因,为什么尝试"有用"的编译器可能会做一些会导致完全意外行为的事情,那么行为通常被指定为undefined.

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

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

uint8_t v;  // Global

void hey(uint8_t *p)
{
  moo(v=5, (*p)=6);
  zoo(v);
  zoo(v);
}
Run Code Online (Sandbox Code Playgroud)

如果编译器在线调用"moo"并且可以告诉它不修改"v",它可能会存储5到v,然后存储6到*p,然后将5传递给"zoo",然后将v的内容传递给"zoo".如果"zoo"没有修改"v",那么两个调用都不应该传递不同的值,但这很容易发生.另一方面,如果两个商店都会写出相同的价值,那么这种奇怪的情况就不会发生,并且在大多数平台上都没有理由让实施做任何奇怪的事情.不幸的是,一些编译器编写者不需要任何借口来处理"因为标准允许它"之外的愚蠢行为,所以即使这些情况也不安全.


Ama*_*dan 9

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

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


Mar*_* J. 8

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

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

这并没有定义参数表达式之间的顺序,所以我们最终在这种情况下:

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

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

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


Ale*_*exD 8

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

N5659 §4.6:15
评价当任一被不定测序之前测序之前进行测序,但它是未指定的哪个.[ 注意:不确定顺序的评估不能重叠,但可以先执行.- 结束说明 ]

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
Run Code Online (Sandbox Code Playgroud)

  • 感谢您为[tag:C++ 17]添加此更新,所以我没有必要.;) (2认同)

Joh*_*hnB 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
Run Code Online (Sandbox Code Playgroud)