11

我最近遇到了一个讨厌的错误,简化的代码如下所示:

int x = 0;
x += Increment(ref x);

...

private int Increment(ref int parameter) {
    parameter += 1;
    return 1;
}

Increment 调用后 x 的值为 1!一旦我发现发生了什么,这很容易解决。我将返回值分配给一个临时变量,然后更新 x. 我想知道是什么解释了这个问题。它是规范中的内容还是我忽略的 C# 的某些方面。

4

4 回答 4

7

+= 读取左边的参数,然后读取右边的参数,所以它读取变量,执行递增的方法,对结果求和,然后分配给变量。在这种情况下,它读取 0,计算 1 并带有将变量更改为 1 的副作用,求和为 1,并为变量分配 1。IL 确认了这一点,因为它按顺序显示了加载、调用、添加和存储。

将返回值更改为 2 以查看结果是 2 确认该方法的返回值是“粘住”的部分。

既然有人问,这是通过 LINQPad 的完整 IL 及其注释:

IL_0000:  ldc.i4.0
IL_0001:  stloc.0     // x
IL_0002:  ldloc.0     // x
IL_0003:  ldloca.s    00 // x
IL_0005:  call        UserQuery.Increment
IL_000A:  add
IL_000B:  stloc.0     // x
IL_000C:  ldloc.0     // x
IL_000D:  call        LINQPad.Extensions.Dump

Increment:
IL_0000:  ldarg.0
IL_0001:  dup
IL_0002:  ldind.i4
IL_0003:  ldc.i4.1
IL_0004:  add
IL_0005:  stind.i4
IL_0006:  ldc.i4.2
IL_0007:  ret

请注意,在 IL_000A 行,堆栈包含 x 的加载(加载时为 0)和 Increment 的返回值(为 2)。然后它运行add并且stloc.0不进一步检查 x 的值。

于 2013-04-08T14:36:49.263 回答
6

这:

static void Main()
{
    int x = 0;
    x += Increment(ref x);
    Console.WriteLine(x);
}

编译为此:

.method private hidebysig static void Main() cil managed
{
    .entrypoint
    .maxstack 2
    .locals init (
        [0] int32 x)
    L_0000: nop 
    L_0001: ldc.i4.0 
    L_0002: stloc.0 
    L_0003: ldloc.0 
    L_0004: ldloca.s x
    L_0006: call int32 Demo.Program::Increment(int32&)
    L_000b: add 
    L_000c: stloc.0 
    L_000d: ldloc.0 
    L_000e: call void [mscorlib]System.Console::WriteLine(int32)
    L_0013: nop 
    L_0014: ret 
}

编译器ldloca.s x用于将当前值x放入本地寄存器,然后调用Increment()并使用add将返回值添加到寄存器中。这导致使用x调用之前的 from值Increment()

实际 C# 语言规范的相关部分是这样的:

通过应用二元运算符重载决议(第 7.3.4 节)来处理 x op= y 形式的操作,就好像该操作写成 x op y。然后,

如果所选运算符的返回类型可隐式转换为 x 的类型,则该操作将计算为 x = x op y,但 x 仅计算一次。

意思就是:

x += Increment(ref x);

将被改写为:

x = x + Increment(ref x);

由于这将从左到右进行评估,因此将捕获并使用 的值,x而不是调用更改的值Increment()

于 2013-04-08T14:55:52.343 回答
1

C# 规范说明了复合运算符:(7.17.2)

运算被评估为x = x op y,除了 x 只被评估一次。

因此 x 被评估(为 0),然后由方法的结果递增。

于 2013-04-08T15:23:30.317 回答
1

其他答案暗示了这一点,我赞同 C++ 的建议,将其视为“一件坏事”,但“简单”的解决方法是:

int x = 0;
x = Increment(ref x) + x;

因为 C# 确保从左到右评估表达式*,所以这符合您的预期。

*引用 C# 规范的“7.3 运算符”部分:

表达式中的操作数从左到右计算。例如,在 中F(i) + G(i++) * H(i)F使用 的旧值调用i方法,然后使用 的旧值G调用方法i,最后H使用 的新值调用方法i。这与运算符优先级分开且无关。

请注意,最后一句话的意思是:

int i=0, j=0;
Console.WriteLine(++j * (++j + ++j) != (++i + ++i) * ++i);
i = 0; j = 0;
Console.WriteLine($"{++j * (++j + ++j)} != {(++i + ++i) * ++i}");
i = 0; j = 0;
Console.WriteLine($"{++j} * ({++j} + {++j}) != ({++i} + {++i}) * {++i}");

输出这个:


5 != 9
1 * (2 + 3) != (1 + 2) * 3

最后一行可以“信任”为与前两个表达式中使用的值相同的值。IE 即使在乘法之前执行加法,由于括号,操作数已经被计算。

请注意,将其“重构”为:

i = 0; j = 0;
Console.WriteLine(++j * TwoIncSum(ref j) !=  TwoIncSum(ref i) * ++i);
i = 0; j = 0;
Console.WriteLine($"{++j * TwoIncSum(ref j)} != { TwoIncSum(ref i) * ++i}");
i = 0; j = 0;
Console.WriteLine($"{++j} * {TwoIncSum(ref j)} != {TwoIncSum(ref i)} * {++i}");

private int TwoIncSum(ref int parameter)
{
    return ++parameter + ++parameter;
}

仍然完全一样:


5 != 9
1 * 5 != 3 * 3

但我仍然宁愿不依赖它:-)

于 2016-09-01T04:47:09.610 回答