30

I found something a little strange in C# and Java. Let's look at this C++ code:

#include <iostream>
using namespace std;

class Simple
{
public:
    static int f()
    {
        X = X + 10;
        return 1;
    }

    static int X;
};
int Simple::X = 0;

int main() {
    Simple::X += Simple::f();
    printf("X = %d", Simple::X);
    return 0;
}

In a console you will see X = 11 (Look at the result here - IdeOne C++).

Now let's look at the same code on C#:

class Program
{
    static int x = 0;

    static int f()
    {
        x = x + 10;
        return 1;
    }

    public static void Main()
    {
        x += f();
        System.Console.WriteLine(x);
    }
}

In a console you will see 1 (not 11!) (look at the result here - IdeOne C# I know what you thinking now - "How that is possible?", but let's go to the following code.

Java code:

import java.util.*;
import java.lang.*;
import java.io.*;

/* Name of the class has to be "Main" only if the class is public. */
class Ideone
{
    static int X = 0;
    static int f()
    {
        X = X + 10;
        return 1;
    }
    public static void main (String[] args) throws java.lang.Exception
    {
        Formatter f = new Formatter();
        f.format("X = %d", X += f());
        System.out.println(f.toString());
    }
}

Result the same as in C# (X = 1, look at the result here).

And for the last time let's look at the PHP code:

<?php
class Simple
{
    public static $X = 0;

    public static function f()
    {
        self::$X = self::$X + 10;
        return 1;
    }
}

$simple = new Simple();
echo "X = " . $simple::$X += $simple::f();
?>

Result is 11 (look at the result here).

I have a little theory - these languages (C# and Java) are making a local copy of static variable X on the stack (are they ignoring the static keyword?). And that is reason why result in those languages is 1.

Is somebody here, who have other versions?

4

4 回答 4

48

The C++ standard states:

With respect to an indeterminately-sequenced function call, the operation of a compound assignment is a single evaluation. [ Note: Therefore, a function call shall not intervene between the lvalue-to-rvalue conversion and the side effect associated with any single compound assignment operator. —end note ]

§5.17 [expr.ass]

Hence, as in the same evaluation you use X and a function with a side effect on X, the result is undefined, because:

If a side effect on a scalar object is unsequenced relative to either another side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined.

§1.9 [intro.execution]

It happens to be 11 on many compilers, but there is no guarantee that a C++ compiler won't give you 1 as for the other languages.

If you're still skeptical, another analysis of the standard leads to the same conclusion: THe standard also says in the same section as above:

The behavior of an expression of the form E1 op = E2 is equivalent to E1 = E1 op E2 except that E1 is evaluated only once.

In you case X = X + f() except that X is evaluated only once.
As there is no guarantee on the order of evaluation, in X + f(), you cannot take for granted that first f is evaluated and then X.

Addendum

I'm not a Java expert, but the Java rules clearly specify the order of evaluation in an expression, which is guaranteed to be from left to right in section 15.7 of Java Language Specifications. In section 15.26.2. Compound Assignment Operators the Java specs also say that E1 op= E2 is equivalent to E1 = (T) ((E1) op (E2)).

In your Java program this means again that your expression is equivalent to X = X + f() and first X is evaluated, then f(). So the side effect of f() is not taken into account in the result.

So your Java compiler doesn't have a bug. It just complies with the specifications.

于 2014-08-15T08:29:53.793 回答
21

感谢 Deduplicator 和 user694733 的评论,这是我原始答案的修改版本。


C++ 版本有不明确的未指定的行为。

“未定义”和“未指定”之间存在细微差别,前者允许程序执行任何操作(包括崩溃),而后者允许程序从一组特定允许的行为中进行选择,而无需指定哪个选择是正确的。

除了极少数情况外,您总是希望避免这两种情况。


理解整个问题的一个很好的起点是 C++ FAQs为什么有些人认为 x = ++y + y++ 不好?, i++ + i++ 的值是多少?“序列点”是怎么回事?

在前一个和下一个序列点之间,一个标量对象的存储值最多只能通过表达式的评估修改一次。

(...)

基本上,在 C 和 C++ 中,如果您在同时写入变量的表达式中读取变量两次,则结果为undefined

(...)

在被称为序列点的执行序列中的某些指定点处,先前评估的所有副作用都应是完整的,并且后续评估的副作用不应发生。(...) 被称为序列点的“某些指定点”是(...) 在对函数的所有参数求值之后但在函数内的第一个表达式执行之前。

简而言之,在两个连续序列点之间修改变量两次会产生未定义的行为,但是函数调用会引入一个中间序列点(实际上是两个中间序列点,因为 return 语句会创建另一个)。

这意味着您的表达式中有一个函数调用“保存”您的Simple::X += Simple::f();行未定义并将其变成“仅”未指定的事实。

1 和 11 都是可能且正确的结果,而打印 123、崩溃或向老板发送侮辱性电子邮件是不允许的行为;您将永远无法保证打印 1 还是 11。


以下示例略有不同。这似乎是对原始代码的简化,但实际上突出了未定义和未指定行为之间的区别:

#include <iostream>

int main() {
    int x = 0;
    x += (x += 10, 1);
    std::cout << x << "\n";
}

这里的行为确实是未定义的,因为函数调用已经消失,所以这两个修改都x发生在两个连续的序列点之间。C++ 语言规范允许编译器创建一个打印 123、崩溃或向您的老板发送侮辱性电子邮件的程序。

(当然,电子邮件只是一种非常常见的幽默尝试,用于解释未定义的真正含义崩溃通常是未定义行为的更现实的结果。)

实际上,, 1(就像原始代码中的 return 语句一样)是一条红鲱鱼。以下也会产生未定义的行为:

#include <iostream>

int main() {
    int x = 0;
    x += (x += 10);
    std::cout << x << "\n";
}

可能会打印 20(它在我的机器上使用 VC++ 2013 打印)但行为仍未定义。

(注意:这适用于内置运算符。运算符重载将行为改回指定的,因为重载运算符复制内置运算符的语法但具有函数的语义,这意味着+=自定义类型的重载运算符出现在表达式中实际上是一个函数调用。因此,不仅引入了序列点,而且整个歧义消失了,表达式变得等价于x.operator+=(x.operator+=(10));,这保证了参数评估的顺序。这可能与您的问题无关,但应该提及反正。)

相比之下,Java 版本

import java.io.*;

class Ideone
{
    public static void main(String[] args)
    {
        int x = 0;
        x += (x += 10);
        System.out.println(x);
    }
}

必须打印 10。这是因为 Java 在评估顺序方面既没有未定义的行为,也没有未指定的行为。没有需要关注的序列点。请参阅Java 语言规范 15.7。评估顺序

Java 编程语言保证运算符的操作数看起来是以特定的评估顺序进行评估的,即从左到右。

因此,在 Java 案例中,x += (x += 10)从左到右解释,意味着首先将某物添加到0中,然后某物是0 + 10。因此0 + (0 + 10) = 10

另请参见 Java 规范中的示例 15.7.1-2。

回到您的原始示例,这也意味着具有静态变量的更复杂示例已在 Java 中定义和指定行为。


老实说,我不了解 C# 和 PHP,但我猜它们都有一些保证的评估顺序。与大多数其他编程语言(但像 C)不同,C++ 倾向于允许比其他语言更多的未定义和未指定行为。这不好也不坏。这是健壮性和效率之间的权衡。为特定任务或项目选择正确的编程语言始终是权衡分析的问题。

无论如何,具有这种副作用的表达式在所有四种语言中都是不好的编程风格

最后一句话:

我在 C# 和 Java 中发现了一个小错误。

如果您没有多年的软件工程师专业经验,则不应假设在语言规范编译器中发现错误。

于 2014-08-15T09:23:44.347 回答
7

正如 Christophe 已经写过的,这基本上是一个未定义的操作。

那么为什么 C++ 和 PHP 是一种方式,而 C# 和 Java 是另一种方式呢?

在这种情况下(对于不同的编译器和平台可能有所不同),与 C# 相比,C++ 中参数的求值顺序是倒置的——C# 按书写顺序求值,而 C++ 示例则相反。这归结为两者都使用的默认调用约定,但同样 - 对于 C++,这是一个未定义的操作,因此它可能会根据其他条件而有所不同。

为了说明,这个 C# 代码:

class Program
{
    static int x = 0;

    static int f()
    {
        x = x + 10;
        return 1;
    }

    public static void Main()
    {
        x = f() + x;
        System.Console.WriteLine(x);
    }
}

将产生11输出,而不是1.

这仅仅是因为 C# 评估“按顺序”,所以在您的示例中,它首先读取x然后调用f(),而在我的示例中,它首先调用f()然后读取x.

现在,这仍然可能是不现实的。IL(.NET 的字节码)+几乎有任何其他方法,但 JIT 编译器的优化可能会导致不同的评估顺序。另一方面,由于 C#(和 .NET)确实定义了评估/执行的顺序,所以我猜一个兼容的编译器应该总是产生这个结果。

无论如何,这是您发现的一个可爱的意外结果,并且是一个警示故事 - 即使在命令式语言中,方法中的副作用也可能是一个问题 :)

哦,当然 -static在 C# 与 C++ 中意味着不同的东西。我以前看到过 C++ers 使用 C# 时犯的错误。

编辑

让我稍微扩展一下“不同语言”的问题。您已经自动假设 C++ 的结果是正确的,因为当您手动进行计算时,您正在按特定顺序进行评估 - 并且您已确定此顺序符合 C++ 的结果。但是,C++ 和 C# 都没有对表达式进行分析——它只是对某些值的一堆操作。

C++确实存储x在寄存器中,就像 C# 一样。只是 C#在评估方法调用之前存储它,而 C++. 如果您更改 C++ 代码来x = f() + x代替,就像我在 C# 中所做的那样,我希望您会得到1on 输出。

最重要的部分是 C++(和 C)根本没有指定明确的操作顺序,可能是因为它想利用执行这些顺序之一的架构和平台。由于 C# 和 Java 是在这不再重要的时代开发的,并且由于它们可以从 C/C++ 的所有这些失败中吸取教训,因此它们指定了明确的评估顺序。

于 2014-08-15T08:43:21.243 回答
4

根据 Java 语言规范:

JLS 15.26.2,复合赋值运算符

形式的复合赋值表达式 E1 op= E2 等价于 E1 = (T) ((E1) op (E2)) ,其中 T 是 的类型 E1 ,除了 E1 只计算一次。

这个小程序演示了差异,并展示了基于此标准的预期行为。

public class Start
{
    int X = 0;
    int f()
    {
        X = X + 10;
        return 1;
    }
    public static void main (String[] args) throws java.lang.Exception
    {
        Start actualStart = new Start();
        Start expectedStart = new Start();
        int actual = actualStart.X += actualStart.f();
        int expected = (int)(expectedStart.X + expectedStart.f());
        int diff = (int)(expectedStart.f() + expectedStart.X);
        System.out.println(actual == expected);
        System.out.println(actual == diff);
    }
}

为了,

  1. actual分配给 的值actualStart.X += actualStart.f()
  2. expected被赋值给
  3. 检索的结果actualStart.X,即0
  4. 将加法运算符应用于actualStart.Xwith
  5. invoking 的返回值actualStart.f(),即1
  6. 并将结果分配0 + 1expected

我还宣布diff展示更改调用顺序如何改变结果。

  1. diff被分配到的值
  2. 调用的返回值diffStart.f(), 是1, 和
  3. 将加法运算符应用于该值
  4. 的值diffStart.X(为 10,副作用为diffStart.f()
  5. 并将结果分配1 + 10diff

在 Java 中,这不是未定义的行为。

编辑:

解决您关于变量本地副本的观点。这是正确的,但它与static. Java 保存评估每一侧的结果(左侧优先),然后评估对保存的值执行运算符的结果。

于 2014-08-15T08:51:43.587 回答