在C++11中,`i += ++i + 1`是否会出现未定义的行为?

47

当我阅读(答案)那么为什么在C++11中i = ++i + 1是良定义的?时,出现了这个问题。

我了解到微妙的解释是:(1)表达式++i返回一个lvalue,但+需要prvalues作为操作数,因此必须执行从lvalue到prvalue的转换;这涉及获取该lvalue的当前值(而不是比i旧值多一),因此必须在增量的副作用之后进行排序(即更新i)(2)赋值的LHS也是lvalue,因此其值评估不涉及获取i的当前值;虽然这个值计算与RHS的值计算未排序,但这并不会导致问题(3)赋值本身的值计算涉及再次更新i,但在其RHS的值计算之后排序,因此在先前更新i之后没有问题。

好的,那么这里没有未定义行为。现在我的问题是,如果将赋值运算符从=更改为+=(或类似的运算符),会怎样呢?

评估表达式i += ++i + 1会导致未定义行为吗?

据我所见,这个标准似乎在这里自相矛盾。由于 += 的左侧仍然是一个lvalue(其右侧仍然是一个prvalue),因此与上面相同的推理适用于(1)和(2); 在 += 的操作中,没有未定义的行为。至于(3),复合赋值 += 的操作(更精确地说是该操作的副作用;如果需要,其值计算在任何情况下都在其副作用之后被序列化)现在必须同时获取 i 的当前值,并将RHS添加到其中,然后将结果存回 i 。(显然,在其之后进行排序,即使标准未明确说明此类运算符的评估也会始终引发未定义的行为)。如果它们与 ++ 的副作用未排序,则这两个操作都将产生未定义的行为,但正如上述论证一样( ++ 的副作用在 + 的值计算之前被排序,给出了 += 运算符的RHS,该值计算在该复合赋值的操作之前被排序),这不是问题。

另一方面,标准也说E += F等同于E = E + F,除了(左值)E只被计算一次。现在在我们的例子中,变量i的值计算作为左值时不涉及需要按照其他操作顺序进行排序的任何内容,因此计算一次或两次没有区别;我们的表达式应严格等同于E = E + F。但是这里有一个问题:很明显,计算i = i + (++i + 1)将导致未定义的行为!怎么回事?或者这是标准的缺陷吗?

已添加。 我稍微修改了上面的讨论,更好地区分了副作用和值计算,并使用“评估”(与标准一样)来包含两者。我认为我的主要问题不仅在于这个例子中行为是否被定义,而且如何阅读标准以决定这个问题。特别是,对于复合赋值操作,应该将E op = F的等价性视为语义的终极权威(这种情况下,该示例明显具有UB),还是仅仅作为确定要赋值的值所涉及的数学操作的指示(即由op所识别的操作,其中复合赋值运算符的LHS转换为RHS作为左操作数,其RHS作为右操作数)。后者选项使得在这个例子中争论UB变得更加困难,我已经试图解释过。我承认,将等价性强制执行(使得复合赋值变成第二类基元,其含义通过重写以一类原语的方式给出;因此,语言定义会变得简化)是很诱人的,但是有相当强的反对意见:

等价性并不是绝对的,因为存在“E只计算一次”的例外。请注意,这个例外很重要,以避免在计算E时涉及副作用未定义行为的情况下进行任何使用,例如在相当常见的a[i++] += b;用法中。事实上,我认为没有绝对等价的重写来消除复合赋值;使用虚构的|||运算符来指定无序计算,可以尝试将E op= F;(以int操作数为简单起见)定义为等效于{ int& L=E ||| int R=F; L = L + R; },但是这样,该示例就不再具有UB。在任何情况下,标准都没有给我们提供重写配方。
标准不将复合赋值视为二等公民,不需要单独定义语义。例如,在5.17中(我强调的部分):
赋值运算符(=)和复合赋值运算符都从右到左组合。[...] 在所有情况下,赋值在右操作数和左操作数的值计算之后,并在赋值表达式的值计算之前进行排序。关于一个不确定顺序的函数调用,复合赋值的操作是单个评估。
如果意图是让复合赋值成为简单赋值的简写,那么没有理由在此描述中明确包含它们。最后一句话甚至直接与如果等价性被视为权威所应该发生的情况相矛盾。
如果承认复合赋值有自己的语义,那么问题就在于它们的评估涉及(除了数学运算之外)不仅是副作用(赋值)和值评估(在赋值之后顺序进行),还包括一个未命名操作来获取LHS的(先前的)值。这通常会在“lvalue-to-rvalue转换”的标题下处理,但在这里这样做很难证明,因为没有运算符将LHS作为rvalue操作数(尽管在扩展的“等效”形式中有一个)。正是这个未命名操作与++的副作用存在潜在的未排序关系,但是这种未排序关系在标准中没有明确说明,因为未命名操作不存在。使用其存在只是在标准中隐含存在的操作来证明UB是很难证明的。

38
我很惊讶人们为什么会在意这些毫无意义的代码格式,永远不要写这种东西。 - Lightness Races in Orbit
10
我认为这是人们试图理解正在发生的事情,用复杂的例子来测试他们的理解能力。 - dyp
12
@LightnessRacesinOrbit:这个问题并没有在我的编程实践中出现。这只是一种知识性的锻炼,旨在理解标准为有意义的程序设定(或未设定)的限制,以及超出此限制后如何理解标准。但是这样的练习并不是无用的;它们对于实现者来说非常重要,而实现者所能做的最终对程序员具有重要意义。 - Marc van Leeuwen
3
@BЈовић:您链接的引用与上面的代码片段无关(顺便说一句,我没有写这个代码,更没有调试它的意图),但它确实表明您忽略了我的问题的目的。虽然您完全可以认为这超出了您的智力水平(或者只是完全不感兴趣),但断言这对于“任何人”也同样正确有点傲慢。至于复杂性,我见过比这个例子更糟糕的情况。 - Marc van Leeuwen
3
为什么你需要深入理解毫无意义且总是错误的代码?你不会从中获得任何有价值的知识。出于某种原因,人们对通过探索未定义行为的结果来学习低级别知识有模糊的想法。他们没有意识到,没有什么东西阻止他们在任何时候探索这些内容,而不必担心奇怪的未定义行为情景。 - Lundin
显示剩余14条评论
5个回答

16

关于i = ++i + 1的说明

我了解到微妙的解释是:

(1) 表达式 ++i 返回一个左值,但 + 运算符需要 PRValues 作为操作数,因此必须执行从左值到 PRValue 的转换;

可能可以看一下 CWG active issue 1642

这涉及获取该左值的当前值(而不是比 i 的旧值多1),因此必须在增量副作用之后进行排序(即更新 i

这里的排序是针对增量(间接地通过 += 实现)定义的: ++ 的副作用(修改 i)在整个表达式 ++i 的值计算之前被排序。后者是指计算 ++i 的结果,而不是加载 i 的值

(2) 赋值语句的左手边也是一个左值,因此其值评估不涉及获取 i 的当前值; 虽然这个值计算与 RHS 的值计算不排序,但这并不构成问题

我认为这在标准中没有正确定义,但我同意这种解释。

(3) 赋值语句本身的值计算涉及再次更新 i

i = expr 的值计算仅在使用其结果时才需要,例如 int x = (i = expr);(i = expr) = 42;。值计算本身不修改 i

i = expr 中由于 = 导致的 i 的修改称为 副作用。该副作用在 i = expr 的值计算之前被排序——或者更确切地说,在赋值语句的值计算之后排序

通常情况下,表达式的操作数的值计算在表达式的副作用之前进行排序。

但是,在其 RHS 的值计算之后排序,因此在先前更新 i 之后; 没有问题。

赋值i = expr副作用 (side effect)在操作数i(A)和赋值的expr的值计算之后发生。

在这种情况下,expr是一个+表达式:expr1 + 1。该表达式的值计算在其操作数expr11的值计算之后发生。

这里的expr1++i++i的值计算在++i的副作用(即修改i)之后发生。(B)

这就是为什么i = ++i + 1是安全的原因:从(A)中的值计算到(B)中对同一变量的副作用之间存在一条顺序化前链。


(a) 标准将++expr定义为expr += 1,其中expr只被评估一次。

因此,对于expr = expr + 1,我们只有一次expr的值计算。 =的副作用在整个expr = expr + 1的值计算之前顺序化,它在操作数expr(LHS)和expr + 1(RHS) 的值计算之后顺序化。

这与我的说法相一致,即对于++expr,副作用在++expr的值计算之前顺序化。


关于i += ++i + 1

i += ++i + 1 的值计算是否涉及未定义行为?

由于+=的LHS仍然是一个左值(其RHS仍然是prvalue),因此与上述情况相同,对于(1)和(2)的推理仍然适用; 至于(3),+=运算符的值计算现在必须同时获取i的当前值,然后(明显地,在执行此类运算符时,即使标准没有明确说明,否则执行将始终调用未定义的行为)执行RHS的加法并将结果存储回i

我认为问题出在这里:在 i += 的 LHS 中添加 i++i + 1 的结果需要知道 i 的值——这是一个值计算(可能意味着加载 i 的值)。这个值计算与 ++i 执行的修改不按顺序执行。这基本上就是你在备选描述中所说的,遵循标准所强制规定的重写 i += expri = i + expr。这里,在 i + expr 中的 i 的值计算与 expr 的值计算不按顺序执行。这就是产生未定义行为的地方。 请注意,值计算可以有两个结果:对象的 "地址" 或对象的值。在表达式 i = 42 中,lhs 的值计算 "生成对象的地址",即编译器需要确定 rhs 存储在哪里(根据抽象机器的可观察行为规则)。在表达式 i + 42 中,i 的值计算产生值。在上面的段落中,我指的是第二种情况,因此[intro.execution]p15适用:
如果对标量对象的副作用与同一标量对象的另一个副作用或使用同一标量对象的值计算不按顺序执行,则行为未定义。
另一种方法解决 i += ++i + 1
现在 += 操作符的值计算必须同时获取 i 的当前值,然后执行 RHS 的添加
RHS 是 ++i + 1。计算此表达式的结果(值计算)与 LHS 中的 i 的值计算不按顺序执行。因此,这个句子中的单词 "then" 是误导性的:当然,它首先加载 i,然后将 RHS 的结果加到其中。但是,在 RHS 的副作用和获取 LHS 值之前没有顺序。例如,对于 LHS,您可以得到修改过的 i 的旧值或新值。 通常情况下,存储和 "并发" 加载是数据竞争,导致未定义行为。 解决附录问题

使用虚构的|||操作符来指定未排序的评估,可以尝试将E op= F;(使用int操作数以简化)定义为等效于{ int& L=E ||| int R=F; L = L + R; },但是该示例不再具有未定义行为。

int* lhs_address;
int lhs_value;
int* rhs_address;
int rhs_value;

    (         lhs_address = &i)
||| (i = i+1, rhs_address = &i, rhs_value = *rhs_address);

*lhs_address = rhs_value;

另一方面,对于 i += ++i 这个表达式

    (         lhs_address = &i, lhs_value = *lhs_address)
||| (i = i+1, rhs_address = &i, rhs_value = *rhs_address);

int total_value = lhs_value + rhs_value;
*lhs_address = total_value;

这旨在表达我对顺序保证的理解。请注意,逗号运算符会将 LHS 的所有值计算和副作用在 RHS 之前进行。括号不影响排序。在第二种情况下, i += ++i,我们有一个修改 ii 的左值到右值转换无序 => UB。

标准并没有将复合赋值视为次等基元,不需要单独定义其语义。

我认为这是多余的。从 E1 op = E2E1 = E1 op E2 的重写还包括必需的表达式类型和值类别(5.17/1 中关于 lhs 有说明),指针类型会发生什么变化,所需的转换等等。不幸的是,5.17/1 中关于“针对..”的句子没有在 5.17/7 中作为该等价性的异常出现。

无论如何,我认为我们应该比较复合赋值和简单赋值加运算符的保证和要求,看看是否存在任何矛盾。

一旦我们将“针对..”也放入 5.17/7 的异常列表中,我认为就不会有矛盾了。

正如您可以在 Marc van Leeuwen 的答案讨论中看到的那样,这个句子导致了以下有趣的观察结果:

int i; // global
int& f() { return ++i; }
int main() {
    i  = i + f(); // (A)
    i +=     f(); // (B)
}

(A) 看起来有两种可能的结果,因为 f 的主体评估与 i + f() 中的 i 的值计算在不确定的顺序中进行。

另一方面,在 (B) 中,f() 的主体评估在 i 的值计算之前进行排序,因为 += 必须被视为单个操作,并且在分配 += 之前必须对 f() 进行评估。


1
("concurrently" == unsequenced) 不是的,unsequenced 意味着无法建立部分排序关系。它可能是并发的,但不一定是。 - P.P
1
你的回答太长了,而且你并没有真正回答这个问题:这是未定义行为吗?你没有在任何地方作出明确的陈述。 - BЈовић
@dyp 然而,不同的解释最终会导致对于这个问题的不同答案:它是否是未定义行为?你不能凭空证明,因此你的答案是有问题的。 - FrankHB
@FrankHB 我同意 lhs_value = *lhs_address 在标准中没有明确说明,但它被暗示为使用对象的值进行的值计算(i += ++i 的 LHS 有一个值计算,并且使用了 LHS 的值)。实际上不需要进行加载,因为我理解这个使用值的值计算在 intro.races p2 中从语义上讲是“从内存位置读取”。 - dyp
@dyp [intro.races]/2 明确关注 _2 表达式求值_,这在此处不适用于单个求值。 - FrankHB
显示剩余17条评论

5

这个表达式:

i += ++i + 1

调用未定义行为。语言专家的方法要求我们返回导致以下缺陷报告的内容:

i = ++i + 1 ;

在C++11中,缺陷报告637.顺序规则和示例不一致中变得更加明确,它开头说:

In 1.9 [intro.execution] paragraph 16, the following expression is still listed as an example of undefined behavior:

i = ++i + 1;

However, it appears that the new sequencing rules make this expression well-defined

报告中使用的逻辑如下:
  1. 赋值语句的副作用需要在其LHS和RHS的值计算后进行排序(5.17 [expr.ass]第1段)。

  2. LHS(i)是一个左值,因此它的值计算涉及计算i的地址。

  3. 为了对RHS(++i + 1)进行值计算,必须先对lvalue表达式++i进行值计算,然后对结果进行lvalue-to-rvalue转换。这保证了增量副作用在加法运算的计算之前排序,进而在赋值副作用之前进行排序。换句话说,它为此表达式产生了明确定义的顺序和最终值。

因此,在这个问题中,我们的问题改变了RHS,它变成了:

++i + 1

到:

i + ++i + 1

由于C++11标准草案的第5.17赋值和复合赋值运算符规定:

形如E1 op = E2的表达式的行为相当于E1 = E1 op E2,除了E1只被计算一次。[...]

因此,现在我们面临这样一种情况:在RHS中计算i的顺序与++i不确定,因此我们遇到了未定义的行为。这是根据第1.9节第15段的规定得出的:

除非另有说明,否则单个运算符的操作数和单个表达式的子表达式的评估是无序的。[注意:在程序执行期间多次计算的表达式中,其子表达式的无序和不确定顺序的评估不需要在不同的评估中一致执行。——注] 运算符的操作数的值计算在运算符的结果的值计算之前排序。如果对标量对象的副作用与同一标量对象上的另一个副作用或使用同一标量对象的值计算相关联的值计算不按顺序进行,则行为未定义。 实际展示这一点的方法是使用clang测试代码,它会生成以下警告(参见see it live):
warning: unsequenced modification and access to 'i' [-Wunsequenced]
i += ++i + 1 ;
  ~~ ^

对于这段代码:

int main()
{
    int i = 0 ;

    i += ++i + 1 ;
}

这在clang的-Wunsequenced测试套件中有一个明确的测试示例来进一步支持这一点:

 a += ++a; 

1
很高兴你在这里 - 对我来说,左值到右值的转换仍然相当模糊。你可能想在最后一节中添加 i + ++i 是未定义行为,因为在 1.9/15 中通常操作数的求值顺序是未排序的 - dyp
2
@dyp 我在睡觉后考虑添加那个部分。多次阅读缺陷报告 637 真正帮助我理解“左值到右值转换”,尽管如果标准最终澄清这一点会更好。 - Shafik Yaghmour
@ShafikYaghmour:你所说的是一个令人信服的论点,即不要编写任何调用未定义行为或可能被编译器错误解释为此类行为的代码;虽然知道编译器有错可能会带来一些快感,但你最终还是会面临问题。但这并没有回答问题。 - Marc van Leeuwen
2
回顾过去,我确信你是对的。感谢@MarcvanLeeuwen为我解惑。 “仅评估一次”的唯一合理解释确实需要将其解读为“作为lvalue仅评估一次”,这确实留下了足够的未指定内容,以声称完整表达式的行为是未定义的。 - user743382
我对这个答案并不满意。将左值转换为右值,即获取某个表达式的右值,与复合赋值中的隐式操作数(右值)无关。后者由左操作数的存储值确定,而左操作数的身份由赋值表达式的左操作数的评估决定,而且永远不需要进行左值到右值的转换。将存储值和隐式操作数值关联甚至不是任何表达式的“计算”超出了识别范围,因此不受“未排序”的影响。 - FrankHB
显示剩余6条评论

1

是的,它是未定义行为!

你的表达式的评估

i += ++i + 1

以下是步骤:

C++11的5.17p1规定(强调我的):

赋值运算符(=)和复合赋值运算符都是从右到左分组。所有运算符都需要一个可修改的左值作为它们的左操作数,并返回指向左操作数的左值。如果左操作数是位域,则在所有情况下结果都是位域。在所有情况下,赋值都在右侧和左侧操作数的值计算之后进行,并在赋值表达式的值计算之前进行。

"值计算"是什么意思?

1.9p12给出了答案:

访问由易失性glvalue(3.10)指定的对象,修改对象,调用库I/O函数或调用执行上下文状态发生更改的函数都是副作用,即执行环境状态的更改。一般来说,评估表达式(或子表达式)包括值计算(包括确定glvalue评估的对象的标识和获取先前分配给prvalue评估的对象的值)和启动副作用。

由于您的代码使用了复合赋值运算符,5.17p7告诉我们,这个运算符的行为如下:

形式为 E1 op= E2 的表达式的行为等价于 E1 = E1 op E2 除了 E1 只被评估一次。

因此,表达式 E1 ( == i) 的评估涉及到确定由 i 指定的对象的标识以及从该对象获取存储在其中的值的 lvalue-to-rvalue 转换。但是两个操作数的评估 E1E2 不按顺序进行。因此,我们得到了 未定义的行为,因为评估 E2 ( == ++i + 1) 初始化了一个副作用(更新 i)。

1.9p15:

... 如果标量对象上的副作用在相对于与同一标量对象上的另一个副作用或使用同一标量对象的值计算之间是未排序的,则行为未定义。


以下陈述是您问题/评论中误解的根源:
(2) 赋值语句的左值也是一个左值,因此其值评估不涉及获取i的当前值
获取值可以作为prvalue评估的一部分。但在E += F中,唯一的prvalue是F,因此获取E的值不是(lvalue)子表达式E的评估的一部分。
一个表达式是lvalue还是rvalue并不能说明如何评估这个表达式。一些运算符要求它们的操作数是lvalue,其他一些则要求rvalue。
第5条8款:
每当glvalue表达式出现为期望该操作数的prvalue的运算符的操作数时,将应用lvalue-to-rvalue(4.1),array-to-pointer(4.2)或function-to-pointer(4.3)标准转换以将表达式转换为prvalue。
在简单赋值中,仅需要确定对象的身份即可评估LHS。但是,在诸如+=的复合赋值中,LHS必须是可修改的lvalue,但在这种情况下,评估LHS包括确定对象的身份和lvalue-to-rvalue转换。这个转换的结果(它是一个prvalue)被添加到RHS的评估结果(也是一个prvalue)中。
“但在E += F中,唯一的prvalue是F,因此获取E的值不是(lvalue)子表达式E的评估的一部分。”

正如我在上面解释的那样,这是不正确的。在您的示例中,F 是一个 prvalue 表达式,但 F 也可能是一个 lvalue 表达式。在这种情况下,对 F 也应用了 lvalue-to-rvalue 转换。正如上面引用的 5.17p7 所述,复合赋值运算符的语义是什么。标准规定 E += F行为E = E + F 相同,但只评估一次 E。这里,对 E 的评估包括 lvalue-to-rvalue 转换,因为二元运算符 + 要求其操作数为 rvalues。


实际上,我发现您提供的参数加强了“非UB”观点。当然,如果您将“E1 = E1 op E2”条款作为语义的最终仲裁者,那么您会得到UB;我的问题说明了这一点,也解释了为什么这种极端观点存在问题。但是,如果您认为“表达式”必须是程序中实际的表达式,而不是由等价规则创建的副本,则您引用的1.9p12说法表示(子)表达式的评估包括值计算和副作用;获取值可以是prvalue评估的一部分。但在E += F中,唯一的prvalue是F... - Marc van Leeuwen
...因此,获取E的值不是对(lvalue)子表达式E进行评估的一部分。它只能成为整个+=公式的评估的一部分(因为虚构的E + F不是程序的实际(子)表达式),并且+=评估的两个部分(副作用和值评估)在+=的两个操作数(周围唯一真正的操作数;再次强调,在E + F中的rvalue左操作数不是程序的实际表达式)的评估之后被排序。 - Marc van Leeuwen
5.17p7 告诉我们,两个操作数都会被评估,从而产生两个 prvalue,然后再将它们相加,再次产生一个 prvalue,该 prvalue 存储在 += 左手边的 lvalue 表达式所指定的对象中。这就是 += 的(操作)语义。 - MWid
5.17p7并没有告诉你,复合赋值的隐含操作数只是左操作数整个求值结果的替换。需要对左操作数进行求值,但仅用于识别lvalue本身。获取与lvalue对应的标量对象中存储的值不属于语言定义的任何形式的(表达式)求值。相关的操作语义规则在标准中根本没有被提出。 - FrankHB

0
从编译器编写者的角度来看,他们不关心"i += ++i + 1",因为无论编译器做什么,程序员可能得不到正确的结果,但他们肯定会得到他们应得的结果。而且没有人会编写那样的代码。编译器编写者关心的是:
*p += ++(*q) + 1;

代码必须读取*p*q,将*q增加1,并将*p增加一些计算出的量。在这里,编译器编写者关心读取和写入操作的顺序限制。显然,如果p和q指向不同的对象,则顺序无关紧要,但如果p == q,则会有所区别。同样,除非编写代码的程序员疯了,否则p将与q不同。

通过使代码未定义,语言允许编译器生成最快的可能代码,而不必担心疯狂的程序员。通过使代码定义,语言强制编译器即使在疯狂的情况下也要生成符合标准的代码,这可能会使其运行速度变慢。编译器编写者和理智的程序员都不喜欢这种情况。

因此,即使在C++11中行为被定义,使用它仍然非常危险,因为(a)编译器可能不会从C++03行为更改,(b)由于上述原因,在C++14中可能是未定义的行为。


0

这里没有明确的未定义行为情况

当然,可以给出导致 UB 的参数,正如我在问题中所指出的,并且迄今为止给出的答案中已经重复了。但是,这涉及到对 5.17:7 的严格阅读,这既是自相矛盾的,也与 5.17:1 中关于复合赋值的明确说明相矛盾。如果对 5.17:7 进行较弱的阅读,则矛盾消失,UB 的论点也消失。因此,我的结论既不是这里存在 UB,也不是有明确定义的行为,而是标准文本不一致,应进行修改以明确哪种阅读方式占优势(我想这意味着应编写缺陷报告)。当然,在标准中可以引用回退条款(1.3.24 中的注释),即标准未能明确定义行为[明确和自洽]的评估是未定义行为,但这将使任何使用复合赋值(包括前缀增量/减量运算符)的操作都成为 UB,这可能会吸引某些实现者,但肯定不会吸引程序员。

不要为了给定的问题争论,让我提出一个稍微修改过的例子,更清楚地展现出不一致性。假设有人定义了

int& f (int& a) { return a; }

一个什么都不做并返回其(左值)参数的函数。现在修改示例以

n += f(++n) + 1;

请注意,虽然标准中给出了一些关于函数调用顺序的额外条件,但乍一看似乎不会影响示例,因为从函数调用中没有任何副作用(甚至在函数内部也没有),因为递增发生在 f 的参数表达式中,其评估不受这些额外条件的限制。实际上,让我们应用未定义行为的关键论据(CAUB),即5.17:7,它说这种复合赋值的行为等同于(在这种情况下)。
n = n + f(++n) + 1;

除了 n 只被计算一次(这里并不重要的一个例外)。我刚刚写的语句的计算显然有未定义行为(右手边第一个 prvalue 的 n 的值计算在与涉及相同标量对象(1.9:15)的 ++ 操作的副作用之间是无序的,你会出事的)。

所以 n += f(++n) + 1 的计算有未定义行为,对吗?不对!在 5.17:1 中已经说明:

对于一个未确定顺序的函数调用,在复合赋值操作中的运算是一个单一的计算。[注意: 因此,函数调用不得介入从 lvalue 到 rvalue 转换和与任何单个复合赋值运算符相关联的副作用之间。—注释结束]

这种语言远不如我所希望的那样精确,但我认为“indeterminately-sequenced”应该意味着“关于复合赋值的操作”。(非规范性说明,我知道)注释清楚地表明,lvalue-to-rvalue转换是复合赋值的操作之一。现在问题来了,f的调用是否与+=的复合赋值操作相关联?我不确定,因为“sequenced”关系仅适用于单个值计算和副作用,而不适用于运算符的完整评估,后者可能涉及两者都包含的内容。实际上,复合赋值运算符的评估涉及三个项目:其左操作数的lvalue-to-rvalue转换、副作用(赋值本身)以及复合赋值的值计算(在副作用之后排序,并将原始左操作数作为lvalue返回)。请注意,标准中从未明确提到过lvalue-to-rvalue转换的存在,除了上面引用的注释之外;特别是,标准根本没有就其相对于其他评估的排序做出任何(其他)声明。很明显,在这个例子中,f的调用在+=的右操作数的值计算中发生,因此在副作用和值计算之前被排序,但它可能与lvalue-to-rvalue转换部分的排序是不确定的。我记得从我的问题中得出结论,由于+=的左操作数是一个lvalue(必须如此),因此不能将lvalue-to-rvalue转换解释为左操作数的值计算的一部分。

然而,根据排除中间原则,对于f的调用必须要么在与+=的复合赋值操作的操作上不确定地排序,要么在其上不确定地排序;在后一种情况下,它必须在其之前排序,因为它不可能在其之后排序(f的调用在+=的副作用之前排序,关系是反对称的)。因此,首先假设它在操作上是不确定排序的。然后引用的条款表示,关于f的调用,+=的评估是单个操作,并且注释解释说这意味着调用不应该介入左值到右值转换和与+=相关的副作用之间;它应该在两者之前或之后排序。但是,在副作用之后排序是不可能的,因此它应该在两者之前。这使得++的副作用在左值到右值转换之前排序,退出UB。接下来假设f的调用在+=的操作之前排序。那么它特别在左值到右值转换之前排序,再次通过传递性,++的副作用也是如此;在这个分支中也没有UB。
结论:如果将后者(CAUB)作为1.9:15未排序评估所导致的UB问题的规范,5.17:1与5.17:7相矛盾。正如我所说,CAUB也是自相矛盾的(通过问题中指出的论点),但这个答案已经太长了,所以我现在就到这里吧。
两个解决方案和三个问题
试图理解标准对这些问题的描述,我区分了三个方面,这些方面都是文本难以解释的性质,因为文本没有清楚地说明其陈述所涉及的模型是什么。 (我在编号项目的末尾引用了文本,因为我不知道如何在引用后恢复编号项目的标记)。
  1. 5.17:7的文本表面上看起来很简单,虽然意图容易理解,但在应对困难情况时却给我们很少的把握。它提出了一个 sweeping claim(等价行为,显然在所有方面),但其应用被例外条款所阻挠。如果E1=E1opE2的行为未定义怎么办?那么E1op=E2的行为也应该是未定义的。但是,如果UB是由于在E1=E1opE2中对E1进行两次评估造成的呢?那么评估E1op=E2应该不是UB,但如果是这样,那么它被定义为什么呢?这就像说“第二个孪生儿童的青春期与第一个完全相同,只是他没有在出生时死亡。”老实说,我认为这段文字,自从C版本“形式为E1 op = E2的复合赋值与简单赋值表达式E1 = E1 op E2之间的唯一区别在于lvalueE1只被评估一次。”以来,它几乎没有发展,可能需要适应标准的变化。

    (5.17) 7形式为E1op=E2的表达式的行为等价于E1=E1opE2,只是E1只被评估一次。[...]

  2. 不太清楚“序列化”关系定义的确切操作(评估)之间是什么。它说(1.9:12)一个表达式的评估包括值计算和副作用的初始化。虽然这似乎表明一个评估可能有多个(原子)组件,但序列化关系实际上主要是针对单个组件定义的(例如,在1.9:14,15中),因此最好将其解读为“评估”的概念包括值计算和(初始化)副作用。然而,在某些情况下,“序列化”关系被定义为表达式或语句的(整个)执行(1.9:15)或函数调用(5.17:1),尽管1.9:15中的一段话通过直接引用所调用函数体中的执行来避免后者。

    (1.9) 12一般情况下,表达式(或子表达式)的评估包括值计算(...)和副作用的初始化。[...]13“序列化之前”是单个线程执行的评估之间的一种非对称、传递、成对关系[...]14与完整表达式相关联的每个值计算和副作用都在与要评估的下一个完整表达式相关联的每个值计算和副作用之前进行序列化。[...]15调用函数时(无论函数是否内联),与任何参数表达式相关联的每个值计算和副作用,或指定所调用函数的后缀表达式,都在调用函数体中的每个表达式或语句执行之前进行序列化。[...]调

    第二个观点与我们具体问题的关系最小,我认为只需要选择一个明确的观点并重新表述那些似乎表达了不同观点的片段即可解决。考虑到旧序列点现在的“顺序”关系的主要目的之一是明确后缀递增运算符的副作用相对于该运算符的值计算后排序的行为是不确定的(因此会导致 i = i++ 不确定),因此观点必须是每个单独的值计算和(初始化的)单独的副作用都是“评估”,可以定义“排序之前”。出于实际原因,我还会包括两种(琐碎的)“评估”:函数输入(因此可以简化 1.9:15 的语言为:“调用函数时……与其任何参数表达式相关的每个值计算和副作用,或指定被调用函数的后缀表达式的值计算和副作用,在进入该函数之前都进行排序”),以及函数退出(这样,函数体中的任何操作都将通过传递排序在任何需要函数值的内容之前;这曾经是由序列点保证的,但 C++11 标准似乎已经失去了这样的保证;这可能会使以return i++; 结尾的函数调用在不想要的情况下变成 UB,而以前是安全的)。然后,我们还可以明确函数调用的“不确定排序”关系:对于每个函数调用和不是(直接或间接)作为评估该调用的一部分的评估,该评估都将排序(在进入或退出该函数之前),并且它在两种情况下都具有相同的关系(因此,在单个线程内,特别是这样的外部操作不能在函数进入之后但在函数退出之前进行排序是非常理想的)。

    现在为了解决第1点和第3点,我可以看到两条路径(每个都影响这两个点),对于我们示例的定义或未定义行为具有不同的后果:

    带有两个操作数和三个评估的复合赋值

    复合操作具有它们通常的两个操作数,即左值左操作数和prvalue右操作数。为了解决第3点的不明确性,它包含在1.9:12中,即在复合赋值中也可能发生获取先前分配给对象的值(而不仅仅是prvalue评估)。通过将5.17:7更改为定义复合赋值的语义:

    在复合赋值op=中,获取之前分配给左操作数所引用的对象的值,将运算符op应用于此值作为左操作数和op=的右操作数,并将生成的值替换左操作数所引用的对象的值。

    (这给出了两个评估,获取和副作用;第三个评估是复合运算符的平凡值计算,在两个其他评估之后排序。)

    为了清晰起见,在1.9:15中明确说明操作数中的值计算在与运算符相关的所有值计算之前进行(而不仅仅是针对运算符结果的值计算),这确保了在获取其值之前对lvalue左操作数进行排序(否则几乎无法想象),并在获取其值之前对操作数的值计算进行排序,从而排除了我们示例中的UB。顺便说一下,我认为没有理由不在任何与运算符相关的副作用之前对操作数中的值计算进行排序(因为它们显然必须这样做);这将使得在5.17:1中明确提到(复合)赋值的这一点变得多余。另一方面,在那里提到复合赋值中的值获取在其副作用之前进行排序。

    具有三个操作数和两个评估的复合赋值

    为了使复合赋值中的获取操作与右操作数的值计算不按顺序进行,从而使我们的示例UB,最清晰的方法似乎是给复合运算符一个隐式的第三个(中间的)操作数,一个prvalue,不由单独的表达式表示,而是通过从左操作数进行lvalue-to-rvalue转换获得(这种三元性质对应于扩展形式的复合赋值,但是通过从左操作数获得中间操作数,确保从将要存储结果的同一对象中获取值,这是一个至关重要的保证,当前的公式仅通过“除了E1只评估一次”条款模糊地隐含给出)。与先前解决方案的区别在于,现在获取是真正的lvalue-to-rvalue转换(因为中间操作数是prvalue),并且作为复合赋值的操作数的值计算的一部分执行,从而自然地使其与右操作数的值计算不按顺序进行。必须在某个地方(在描述此隐式操作数的新条款中)说明左操作数的值计算在进行此lvalue-to-rvalue转换之前被排序(显然必须是这样)。现在可以将1.9:12保留为原样,而我提议使用5.17:7的替代方案。
    在一个复合赋值表达式中,左操作数a(一个lvalue)与中间和右操作数b和c(都是prvalues)一起使用运算符op=。运算符op将b作为左操作数,将c作为右操作数,并且结果值替换了由a引用的对象的值。
    (这给出了一个评估,即副作用,以及一个简单的值计算复合运算符的第二个评估,在其后进行排序。)

    对于1.9:15和5.17:1的仍然适用的更改建议仍然适用,但不会给我们原始示例定义的行为。然而,在本答案顶部修改后的示例仍将具有定义的行为,除非删除或修改5.17:1中的“复合赋值是单个操作”(在后缀增量/减量的5.2.6中有类似的段落)。这些段落的存在表明,将单个复合赋值或后缀增量/减量中的获取和存储操作分离并不是编写当前标准的人的意图(从而使我们的示例UB),但这当然只是猜测。


我认为你引用的5.17/1是指1.9/15,“在调用函数中(包括其他函数调用)的每个求值,如果没有明确安排在被调用函数的主体执行之前或之后,则相对于被调用函数的执行而言是不确定顺序的。” 因此,n的lvalue-to-rvalue转换相对于f的主体具有不确定的顺序性。那么它是否也与参数++n的求值顺序相关?标准规定 ++n 的求值在f的主体执行之前。 - dyp
1
@MarcvanLeeuwen 你说的“+= 的评估”是什么意思?任何运算符 @ 的操作数的评估在该运算符的副作用之前进行,然后是包含/由该运算符 L@R 形成的表达式的值计算。 - dyp
@dyp:我指的是与+=相关的复合赋值操作,不包括其参数的计算(值计算和副作用)。这显然是引用中使用的含义,“复合赋值的操作是单个评估”。 - Marc van Leeuwen
@MarcvanLeeuwen 不行。左边表达式必须是可修改的lvalue,才能应用lvalue-to-rvalue转换。因此,评估LHS的结果是prvalue。 - MWid
@MarcvanLeeuwen 正如我之前所说,返回的lvalue是评估整个复合赋值表达式的结果。 “在所有情况下,赋值都在右操作数和左操作数的值计算之后进行顺序排列,并在赋值表达式的值计算之前进行。” 它是赋值表达式的值计算的结果,这与左操作数的值计算不同。 - MWid
显示剩余9条评论

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