Ref Параметр и назначение в одной строке
Недавно я столкнулся с неприятной ошибкой, и упрощенный код выглядит следующим образом:
int x = 0;
x += Increment(ref x);
...
private int Increment(ref int parameter) {
parameter += 1;
return 1;
}
Значение x после вызова Increment равно 1! Это было легко исправить, как только я узнал, что происходит. Я присвоил возвращаемое значение временной переменной, а затем обновил x. Мне было интересно, что объясняет этот вопрос. Это что-то в спецификации или в каком-то аспекте С#, который я пропускаю.
Ответы
Ответ 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
Но я бы предпочел не полагаться на него: -)