Захваченная переменная в цикле в C #


217

Я встретил интересную проблему о 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 с.

Кажется, что это происходит из-за всех действий, относящихся к одной захваченной переменной. В результате, когда их вызывают, все они имеют одинаковый вывод.

Есть ли способ обойти это ограничение, чтобы каждый экземпляр действия имел свою собственную захваченную переменную?


15
См. Также серию блогов Эрика Липперта на эту тему: Закрытие переменной цикла считается вредным
Брайан,

10
Кроме того, они меняют C # 5, чтобы работать так, как вы ожидаете в foreach. (переломный момент)
Нил Тибревала


3
@Neal: хотя этот пример все еще не работает должным образом в C # 5, поскольку он все еще выдает пять десятых
Иан Оукс

6
Он подтвердил, что до сегодняшнего дня выводит пять десятых на C # 6.0 (VS 2015). Я сомневаюсь, что такое поведение переменных закрытия является кандидатом на изменение. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured,
RBT

Ответы:


198

Да - взять копию переменной внутри цикла:

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 для более подробной информации, и моя статья о замыканиях также имеет больше примеров.

Обратите внимание, что начиная с компилятора C # 5 и выше (даже при указании более ранней версии C #) поведение foreachизменилось, поэтому вам больше не нужно делать локальное копирование. Смотрите этот ответ для более подробной информации.


32
В книге Джона также есть очень хорошая глава об этом (перестань быть смиренным, Джон!)
Марк Гравелл

35
Будет лучше, если я позволю другим людям подключить его;) (Признаюсь, я склонен голосовать за ответы, рекомендующие его.)
Джон Скит

2
Как всегда, обратная связь по адресу skeet@pobox.com приветствуется :)
Джон Скит,

7
Для C # 5.0 поведение другое (более разумное), см. Более новый ответ Джона Скита - stackoverflow.com/questions/16264289/…
Алексей Левенков

1
@Florimond: Это просто не то, как замыкания работают в C #. Они захватывают переменные , а не значения . (Это верно независимо от циклов, и это легко продемонстрировать с помощью лямбды, которая захватывает переменную и просто печатает текущее значение всякий раз, когда она выполняется.)
Джон Скит,

23

Я считаю, что то, что вы испытываете, называется «Закрытие» http://en.wikipedia.org/wiki/Closure_(computer_science) . Ваша лямба имеет ссылку на переменную, которая выходит за пределы самой функции. Ваша лямба не интерпретируется до тех пор, пока вы ее не вызовете, и, как только она появится, она получит значение, которое переменная имеет во время выполнения.


11

За кулисами компилятор генерирует класс, который представляет замыкание для вашего вызова метода. Он использует этот единственный экземпляр класса замыкания для каждой итерации цикла. Код выглядит примерно так, что упрощает понимание причины ошибки:

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

На самом деле это не скомпилированный код из вашего примера, но я изучил свой собственный код, и он очень похож на то, что на самом деле генерирует компилятор.


8

Способ обойти это - сохранить нужное значение в прокси-переменной и получить эту переменную.

IE

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}

Смотрите объяснение в моем отредактированном ответе. Я нахожу соответствующий бит спецификации сейчас.
Джон Скит

Ха-ха, я на самом деле только что прочитал вашу статью: csharpindepth.com/Articles/Chapter5/Closures.aspx Вы хорошо поработали, мой друг.
Tjlevine

@tjlevine: Большое спасибо. Я добавлю ссылку на это в своем ответе. Я забыл об этом!
Джон Скит

Кроме того, Джон, я бы хотел прочитать твои мысли о различных предложениях по закрытию Java 7. Я видел, как вы упомянули, что хотели написать, но я этого не видел.
Tjlevine

1
@tjlevine: Хорошо, я обещаю попытаться написать это к концу года :)
Джон Скит

6

Это не имеет ничего общего с петлями.

Это поведение вызвано тем, что вы используете лямбда-выражение, () => variable * 2где внешняя область variableне определена во внутренней области лямбда-выражения .

Лямбда-выражения (в C # 3 +, а также анонимные методы в C # 2) по-прежнему создают реальные методы. Передача переменных в эти методы сопряжена с некоторыми дилеммами (передача по значению, передача по ссылке, C # идет по ссылке, но это открывает еще одну проблему, когда ссылка может пережить действительную переменную). Что C # делает, чтобы разрешить все эти дилеммы, так это создать новый вспомогательный класс («замыкание») с полями, соответствующими локальным переменным, используемым в лямбда-выражениях, и методами, соответствующими фактическим лямбда-методам. Любые изменения variableв вашем коде фактически переводятся вClosureClass.variable

Таким образом, ваш цикл while продолжает обновлять ClosureClass.variableдо тех пор, пока он не достигнет 10, тогда цикл for выполняет действия, которые все работают одинаково 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;
}

В любом случае, «замыкания» не являются концепцией, связанной с циклами , а скорее с анонимными методами / лямбда-выражениями, использующими локальные переменные в области видимости - хотя некоторые неосторожные использования циклов демонстрируют ловушки замыканий.


5

Да, вам нужно создать область 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();

5

Та же самая ситуация происходит в многопоточности (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();
}

Это, кажется, не помогает мне. Все еще недетерминированный.
Младен Михайлович

0

Поскольку никто здесь прямо не цитирует 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 ++ эта проблема не возникает, потому что вы можете выбрать, будет ли переменная захвачена по значению или по ссылке (см .: лямбда-захват ).


-1

Это называется проблемой закрытия, просто используйте переменную копирования, и все готово.

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

4
Чем ваш ответ отличается от ответа, приведенного выше?
Тангадурай,
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.