Nic*_*rca 266 c++ undefined-behavior language-lawyer
我正在阅读有关评估违规的顺序,他们给出了一个令我困惑的例子.
1)如果标量对象的副作用相对于同一标量对象的另一个副作用未按顺序排列,则行为未定义.
Run Code Online (Sandbox Code Playgroud)// 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 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.
harmic和supercat提供了为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.
这可能是一个虚假的例子,但它是可能的.
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上提出这些问题.
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时,会导致编译失败.因此,更改常量可能会导致其他位置的编译错误.哪个是恕我直言,不幸.但将这些事情局限于不可避免的当然是好事.
小智 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",那么两个调用都不应该传递不同的值,但这很容易发生.另一方面,如果两个商店都会写出相同的价值,那么这种奇怪的情况就不会发生,并且在大多数平台上都没有理由让实施做任何奇怪的事情.不幸的是,一些编译器编写者不需要任何借口来处理"因为标准允许它"之外的愚蠢行为,所以即使这些情况也不安全.
在这种情况下,大多数实现中结果相同的事实是偶然的; 评估顺序仍未定义.考虑一下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();
}
Run Code Online (Sandbox Code Playgroud)
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)
赋值运算符可能会重载,在这种情况下,顺序可能很重要:
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)