Захоплена змінна в циклі в C #


216

Я зустрів цікаве питання про 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, щоб він працював так, як ви очікуєте в рамках передбачення. (порушує зміни)
Ніл Тібревала


3
@Neal: хоча цей приклад досі не працює належним чином на C # 5, оскільки він все ще видає п’ять 10 с
Ian Oakes

6
Він перевірив, що він сьогодні випускає п'ять 10 на C # 6.0 (VS 2015). Я сумніваюся, що така поведінка змінних закриття є кандидатом на зміни. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

Відповіді:


196

Так - візьміть копію змінної всередині циклу:

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, тоді ви для циклів виконуєте дії, які всі працюють на одній і тій же 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-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.