Захваченная переменная в цикле в C#
Я встретил интересную проблему о C#. У меня есть код, как показано ниже.
List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
actions.Add(() => variable * 2);
++ variable;
}
foreach (var act in actions)
{
Console.WriteLine(act.Invoke());
}
Я ожидаю, что он выведет 0, 2, 4, 6, 8. Тем не менее, он на самом деле выдает пять 10 с.
Кажется, что это происходит из-за всех действий, относящихся к одной захваченной переменной. В результате, когда их вызывают, все они имеют одинаковый вывод.
Есть ли способ обойти это ограничение, чтобы каждый экземпляр действия имел свою собственную захваченную переменную?
11 ответов
Да - взять копию переменной внутри цикла:
while (variable < 5)
{
int copy = variable;
actions.Add(() => copy * 2);
++ variable;
}
Вы можете думать об этом так, как будто компилятор C# создает "новую" локальную переменную каждый раз, когда попадает в объявление переменной. Фактически, он создаст соответствующие новые объекты замыкания, и он станет сложным (с точки зрения реализации), если вы обращаетесь к переменным в нескольких областях, но это работает:)
Обратите внимание, что более распространенным явлением этой проблемы является использование for
или же foreach
:
for (int i=0; i < 10; i++) // Just one variable
foreach (string x in foo) // And again, despite how it reads out loud
См. Раздел 7.14.4.2 спецификации C# 3.0 для более подробной информации, и моя статья о замыканиях также имеет больше примеров.
Я полагаю, что то, что вы испытываете, называется "Закрытие" http://en.wikipedia.org/wiki/Closure_(computer_science). Ваша лямба имеет ссылку на переменную, которая выходит за пределы самой функции. Ваша лямба не интерпретируется до тех пор, пока вы ее не вызовете, и, как только она появится, она получит значение, которое переменная имеет во время выполнения.
За кулисами компилятор генерирует класс, который представляет замыкание для вашего вызова метода. Он использует этот единственный экземпляр класса замыкания для каждой итерации цикла. Код выглядит примерно так, что облегчает понимание причины ошибки:
void Main()
{
List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
var closure = new CompilerGeneratedClosure();
Func<int> anonymousMethodAction = null;
while (closure.variable < 5)
{
if(anonymousMethodAction == null)
anonymousMethodAction = new Func<int>(closure.YourAnonymousMethod);
//we're re-adding the same function
actions.Add(anonymousMethodAction);
++closure.variable;
}
foreach (var act in actions)
{
Console.WriteLine(act.Invoke());
}
}
class CompilerGeneratedClosure
{
public int variable;
public int YourAnonymousMethod()
{
return this.variable * 2;
}
}
На самом деле это не скомпилированный код из вашего примера, но я изучил свой собственный код, и он очень похож на то, что на самом деле генерирует компилятор.
Это не имеет ничего общего с петлями.
Это поведение вызвано тем, что вы используете лямбда-выражение () => variable * 2
где внешняя область variable
на самом деле не определено во внутренней области лямбды.
Лямбда-выражения (в C#3+, а также анонимные методы в C#2) по-прежнему создают реальные методы. Передача переменных в эти методы сопряжена с некоторыми дилеммами (передача по значению, передача по ссылке, C# идет по ссылке, но это открывает еще одну проблему, когда ссылка может пережить действительную переменную). Что C# делает, чтобы разрешить все эти дилеммы, так это создать новый вспомогательный класс ("замыкание") с полями, соответствующими локальным переменным, используемым в лямбда-выражениях, и методами, соответствующими фактическим лямбда-методам. Любые изменения в variable
в вашем коде на самом деле переводится, чтобы изменить в этом ClosureClass.variable
Так что ваш цикл while продолжает обновлять ClosureClass.variable
пока он не достигнет 10, то вы для циклов выполняет действия, которые все работают на том же ClosureClass.variable
,
Чтобы получить ожидаемый результат, вам нужно создать разделение между переменной цикла и закрываемой переменной. Вы можете сделать это, введя другую переменную, а именно:
List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
var t = variable; // now t will be closured (i.e. replaced by a field in the new class)
actions.Add(() => t * 2);
++variable; // changing variable won't affect the closured variable t
}
foreach (var act in actions)
{
Console.WriteLine(act.Invoke());
}
Вы также можете переместить замыкание на другой метод для создания этого разделения:
List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
actions.Add(Mult(variable));
++variable;
}
foreach (var act in actions)
{
Console.WriteLine(act.Invoke());
}
Вы можете реализовать Mult как лямбда-выражение (неявное закрытие)
static Func<int> Mult(int i)
{
return () => i * 2;
}
или с реальным классом помощника:
public class Helper
{
public int _i;
public Helper(int i)
{
_i = i;
}
public int Method()
{
return _i * 2;
}
}
static Func<int> Mult(int i)
{
Helper help = new Helper(i);
return help.Method;
}
В любом случае "замыкания" не являются концепцией, связанной с циклами, а скорее с анонимными методами / лямбда-выражениями, использующими локальные переменные в области видимости - хотя некоторые неосторожные использования циклов демонстрируют ловушки замыканий.
Способ обойти это - сохранить нужное значение в прокси-переменной и получить эту переменную.
IE
while( variable < 5 )
{
int copy = variable;
actions.Add( () => copy * 2 );
++variable;
}
Да, вам нужно сфера variable
внутри цикла и передайте его лямбде следующим образом:
List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
int variable1 = variable;
actions.Add(() => variable1 * 2);
++variable;
}
foreach (var act in actions)
{
Console.WriteLine(act.Invoke());
}
Console.ReadLine();
Та же самая ситуация происходит в многопоточности (C#, .NET 4.0].
Смотрите следующий код:
Цель - распечатать 1,2,3,4,5 по порядку.
for (int counter = 1; counter <= 5; counter++)
{
new Thread (() => Console.Write (counter)).Start();
}
Вывод интересный! (Это может быть как 21334...)
Единственное решение - использовать локальные переменные.
for (int counter = 1; counter <= 5; counter++)
{
int localVar= counter;
new Thread (() => Console.Write (localVar)).Start();
}
Как говорили другие, это не имеет ничего общего с циклами. Это эффект механизма захвата переменных в теле анонимных функций в С#. Когда вы определяете лямбду в качестве примера;
actions.Add(() => variable * 2);
Компилятор генерирует класс-контейнер, например <>c__DisplayClass0_0 , для лямбда-функции () => () => переменная * 2.
Внутри сгенерированного класса (контейнера) он генерирует поле с именем переменная , имеющее захваченную переменную с тем же именем и метод b__0(), содержащий тело лямбды.
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int variable;
internal int <Main>b__0()
{
return variable * 2;
}
}
И чем локальная переменная с именем переменная становится полем класса контейнера (<>c__DisplayClass0_0)
<>c__DisplayClass0_.variable = 0;
while (<>c__DisplayClass0_.variable < 5)
{
list.Add(new Func<int>(<>c__DisplayClass0_.<Main>b__0));
<>c__DisplayClass0_.variable++;
}
Таким образом, увеличение переменной приводит к увеличению поля класса контейнера, и поскольку мы получаем один экземпляр класса контейнера для всех итераций цикла while, мы получаем тот же результат, что и 10.
Вы можете предотвратить это, переназначив захваченную переменную внутри тела цикла новой локальной переменной.
while (variable < 5)
{
var index = variable; // <= this line
actions.Add(() => index * 2);
++ variable;
}
Кстати, такое поведение по-прежнему справедливо для .Net 8 Preview, и я считаю, что оно очень глючное и обманчивое.
for (int n=0; n < 10; n++) //forloop syntax
foreach (string item in foo) foreach syntax
Это называется проблемой закрытия, просто используйте переменную копирования, и все готово.
List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
int i = variable;
actions.Add(() => i * 2);
++ variable;
}
foreach (var act in actions)
{
Console.WriteLine(act.Invoke());
}
Поскольку здесь никто напрямую не цитировал ECMA-334:
10.4.4.10 Для операторов
Проверка определенного присвоения для оператора for в форме:
for (for-initializer; for-condition; for-iterator) embedded-statement
делается так, как если бы было написано заявление:
{
for-initializer;
while (for-condition) {
embedded-statement;
LLoop: for-iterator;
}
}
Далее в спецификации
12.16.6.3 Создание локальных переменных
Локальная переменная считается созданной, когда выполнение входит в область действия переменной.
[Пример: например, когда вызывается следующий метод, локальная переменная
x
создается и инициализируется трижды - один раз для каждой итерации цикла.
static void F() {
for (int i = 0; i < 3; i++) {
int x = i * 2 + 1;
...
}
}
Однако перемещение объявления
x
вне цикла приводит к единственному экземпляруx
:
static void F() {
int x;
for (int i = 0; i < 3; i++) {
x = i * 2 + 1;
...
}
}
конец примера]
Когда они не захвачены, невозможно точно наблюдать, как часто создается экземпляр локальной переменной - поскольку время жизни экземпляров не пересекается, для каждого экземпляра можно просто использовать одно и то же место хранения. Однако, когда анонимная функция захватывает локальную переменную, эффекты создания экземпляра становятся очевидными.
[Пример: пример
using System;
delegate void D();
class Test{
static D[] F() {
D[] result = new D[3];
for (int i = 0; i < 3; i++) {
int x = i * 2 + 1;
result[i] = () => { Console.WriteLine(x); };
}
return result;
}
static void Main() {
foreach (D d in F()) d();
}
}
производит вывод:
1
3
5
Однако когда декларация
x
перемещается за пределы цикла:
static D[] F() {
D[] result = new D[3];
int x;
for (int i = 0; i < 3; i++) {
x = i * 2 + 1;
result[i] = () => { Console.WriteLine(x); };
}
return result;
}
вывод:
5
5
5
Обратите внимание, что компилятору разрешено (но не обязательно) оптимизировать три экземпляра в один экземпляр делегата (§11.7.2).
Если цикл for объявляет переменную итерации, сама эта переменная считается объявленной вне цикла. [Пример: Таким образом, если пример изменен для захвата самой переменной итерации:
static D[] F() {
D[] result = new D[3];
for (int i = 0; i < 3; i++) {
result[i] = () => { Console.WriteLine(i); };
}
return result;
}
фиксируется только один экземпляр переменной итерации, который дает результат:
3
3
3
конец примера]
О да, я думаю, следует упомянуть, что в C++ эта проблема не возникает, потому что вы можете выбрать, будет ли переменная захвачена по значению или по ссылке (см.: Лямбда-захват).