问题 ref参数和赋值在同一行


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

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

...

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

增量调用后的x值为1!一旦我发现发生了什么,这是一个简单的解决方案。我将返回值分配给临时变量,然后更新x。我想知道是什么解释了这个问题。是我在俯视的规范或C#的某些方面。


3182
2018-04-08 14:25


起源

你究竟想做什么?你的意思是“这段代码的输出是1”? - Laurent S.
伙计们,忘了目标。他已经处理过了。这里有意义的目标是他试图理解为什么输出就是这样。 - Anthony Pegram
非常好的问题!要添加到您的观察中,请替换 x += Increment(ref x); 与...一致 x = Increment(ref x) + x;  产生  x == 2!这可能是个错误吗?当我写作 x = x + Increment(ref x); 我还是得到了 x == 1。 - dasblinkenlight
好吧,我不认为它是一个错误,可能是一个功能,但对我来说太低了: - /假设我认为如果自己这样做可能有问题,因为我们在同一个语句中访问变量两次,我会问自己系统在做什么以及以什么顺序...... - Laurent S.
在单个语句中两次修改相同的变量是一个非常糟糕的主意。在C ++中,这将是未定义的。 - CodesInChaos


答案:


+ =读取左参数然后读取右参数,因此它读取变量,执行递增的方法,对结果求和,并分配给变量。在这种情况下,它读取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的值。


7
2018-04-08 14:36



你能告诉IL代码吗? - MarcinJuraszek
这就说得通了。基本上它正在扩展到 x = x + Increment(x) 并且ref部分完全被忽略了。 - Michael


这个:

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 从打电话到 Increment() 正在使用。

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

形式x op = y的操作通过应用二元运算符重载决策(第7.3.4节)来处理,就好像操作是在x op y中编写的一样。然后,

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

意思就是:

x += Increment(ref x);

将被重写为:

x = x + Increment(ref x);

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


6
2018-04-08 14:55





关于复合算子的C#规范说:(7.17.2)

该操作被评估为 x = x op y,除了x只评估一次。

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


1
2018-04-08 15:23





这是其他答案所暗示的,我赞同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

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

请注意,“重构”这个:

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

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


1
2017-09-01 04:47