Подтвердить что ты не робот

Ref Параметр и назначение в одной строке

Недавно я столкнулся с неприятной ошибкой, и упрощенный код выглядит следующим образом:

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

...

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

Значение x после вызова Increment равно 1! Это было легко исправить, как только я узнал, что происходит. Я присвоил возвращаемое значение временной переменной, а затем обновил x. Мне было интересно, что объясняет этот вопрос. Это что-то в спецификации или в каком-то аспекте С#, который я пропускаю.

4b9b3361

Ответ 1

+ = читает левый аргумент, затем правый, поэтому он читает переменную, выполняет метод, который увеличивает, суммирует результаты и присваивает переменной. В этом случае он считывает 0, вычисляет 1 с побочным эффектом изменения переменной до 1, сумм до 1 и присваивает значение 1 для переменной. IL подтверждает это, поскольку он показывает нагрузки, вызов, добавление и хранилище в этом порядке.

Изменив возврат к 2, чтобы увидеть результат, 2 подтверждают, что возвращаемое значение метода является частью, которая "прилипает".

Так как кто-то спросил, вот полный IL через LINQPad с его аннотациями:

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.

Ответ 2

Это:

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().

Соответствующая часть из фактической спецификации языка С# такова:

Операция вида 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().

Ответ 3

Спецификация С# описывает составные операторы: (7.17.2)

операция оценивается как x = x op y, за исключением того, что x оценивается только один раз.

Итак, x оценивается (является 0), а затем увеличивается на результат методом.

Ответ 4

Это подразумевается другими ответами, и я одобряю предложение С++ рассматривать это как "плохое дело", но "простое" исправление:

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

Потому что С# обеспечивает правильную оценку выражений *, это делает то, что вы ожидали.

* Раздел цитат "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}");

выводит это:

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

и что последняя строка может быть "доверена" одинаковыми значениями, используемыми в предыдущих двух выражениях. И.Е. даже если добавление выполняется до умножения, из-за скобок операнды уже были оценены.

Обратите внимание, что "рефакторинг" этого:

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;
}

по-прежнему работает точно так же:

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

Но я бы предпочел не полагаться на него: -)