Chained compound assignments with C++17 sequencing are still undefined behaviour?

Qui*_*mby 11 c++ sequence-points language-lawyer c++17

Originally, I presented a more complicated example, this one was proposed by @n. 'pronouns' m. in a now-deleted answer. But the question became too long, see edit history if you are interested.

Has the following program well-defined behaviour in C++17?

int main()
{
    int a=5;
    (a += 1) += a;
    return a;
}
Run Code Online (Sandbox Code Playgroud)

I believe this expression is well-defined and evaluated like this:

  1. The right side a is evaluated to 5.
  2. There are no side-effects of the right side.
  3. The left side is evaluated to a reference to a, a += 1 is well-defined for sure.
  4. The left-side side-effect is executed, making a==6.
  5. The assignment is evaluted, adding 5 to the current value of a, making it 11.

The relevant sections of the standard:

[intro.execution]/8:

An expression X is said to be sequenced before an expression Y if every value computation and every side effect associated with the expression X is sequenced before every value computation and every side effect associated with the expression Y.

[expr.ass]/1 (emphasis mine):

The assignment operator (=) and the compound assignment operators all group right-to-left. All require a modifiable lvalue as their left operand; their result is an lvalue referring to the left operand. The result in all cases is a bit-field if the left operand is a bit-field. In all cases, the assignment is sequenced after the value computation of the right and left operands, and before the value computation of the assignment expression. The right operand is sequenced before the left operand. With respect to an indeterminately-sequenced function call, the operation of a compound assignment is a single evaluation.

The wording originally comes from the accepted paper P0145R3.

Now, I feel there is some ambiguity, even contradiction, in this second section.

The right operand is sequenced before the left operand.

Together with the definition of sequenced before strongly implies the ordering of side-effects, yet the previous sentence:

In all cases, the assignment is sequenced after the value computation of the right and left operands, and before the value computation of the assignment expression

only explicitly sequences the assignment after value computation, not their side-effects. Thus allowing this behaviour:

  1. The right side a is evaluated to 5.
  2. The left side is evaluated to a reference of a, a += 1 is well-defined for sure.
  3. The assignment is evaluted, adding 5 to the current value of a, making it 10.
  4. The left-side side-effect is executed, making a==11 or maybe even 6 if the old values was used even for the side-effect.

But this ordering clearly violates the definition of sequenced before since the side-effects of the left operand happened after the value computation of the right operand. Thus left operand was not sequenced after the right operand which violets the above mentioned sentence. No I done goofed. This is allowed behaviour, right? I.e. the assignment can interleave the right-left evaluation. Or it can be done after both full evaluations.

Running the code gcc outputs 12, clang 11. Furthermore, gcc warns about

<source>: In function 'int main()':

<source>:4:8: warning: operation on 'a' may be undefined [-Wsequence-point]
    4 |     (a += 1) += a;
      |     ~~~^~~~~
Run Code Online (Sandbox Code Playgroud)

I am terrible at reading assembly, maybe someone can at least rewrite how gcc got to 12? (a += 1), a+=a works but that seems extra wrong.

Well, thinking more about it, the right side also does evaluate to a reference to a, not just to a value 5. So Gcc could still be right, in that case clang could be wrong.

Ami*_*rsh 1

为了更好地跟踪实际执行的操作,让我们尝试用我们自己的类型来模仿并添加一些打印输出:

class Number {
    int num = 0;
public:
    Number(int n): num(n) {}
    Number operator+=(int i) {
        std::cout << "+=(int) for *this = " << num
                  << " and int = " << i << std::endl;
        num += i;
        return *this;
    }
    Number& operator+=(Number n) {
        std::cout << "+=(Number) for *this = " << num
                  << " and Number = " << n << std::endl;
        num += n.num;
        return *this;
    }
    operator int() const {
        return num;
    }
};
Run Code Online (Sandbox Code Playgroud)

然后当我们运行时:

Number a {5};
(a += 1) += a;
std::cout << "result: " << a << std::endl;
Run Code Online (Sandbox Code Playgroud)

我们使用gccclang得到不同的结果(并且没有任何警告!)。

海湾合作委员会:

+=(int) for *this = 5 and int = 1
+=(Number) for *this = 6 and Number = 6
result: 12
Run Code Online (Sandbox Code Playgroud)

铛:

+=(int) for *this = 5 and int = 1
+=(Number) for *this = 6 and Number = 5
result: 11
Run Code Online (Sandbox Code Playgroud)

这与问题中的整数的结果相同。 尽管这不是完全相同的故事:内置赋值有自己的排序规则,而不是作为函数调用的重载运算符,但相似性仍然很有趣。

看起来,虽然gcc保留右侧作为引用并在调用 += 时将其转换为值,但另一方面clang首先将右侧转换为值。

下一步是向我们的 Number 类添加一个复制构造函数,以便在引用转换为值时准确遵循。这样做会导致clang 和 gcc调用复制构造函数作为第一个操作并且两者的结果相同: 11

看来gcc延迟了对值转换的引用(无论是在内置赋值中还是在没有用户定义的复制构造函数的用户定义类型中)。它与 C++17 定义的排序一致吗?对我来说,这似乎是一个 gcc bug,至少对于问题中的内置赋值而言,因为听起来从引用到值的转换是“值计算”的一部分,应在赋值之前排序


至于原始帖子的先前版本中报告的clang的奇怪行为- 在断言和打印时返回不同的结果:

constexpr int foo() {
    int res = 0;
    (res = 5) |= (res *= 2);
    return res;
}

int main() {
    std::cout << foo() << std::endl; // prints 5
    assert(foo() == 5); // fails in clang 11.0 - constexpr foo() is 10
                        // fixed in clang 11.x - correct value is 5
}
Run Code Online (Sandbox Code Playgroud)

这与clang 中的一个错误有关。断言的失败是错误的,是由于在编译时不断求值期间,clang 中该表达式的求值顺序错误造成的。该值应为 5。此错误在 clang trunk 中修复。