Przechwycona zmienna w pętli w C #

216

Spotkałem interesujący problem dotyczący C #. Mam kod jak poniżej.

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

Oczekuję, że wyniesie 0, 2, 4, 6, 8. Jednak faktycznie wyprowadza pięć 10.

Wydaje się, że jest to spowodowane wszystkimi działaniami odnoszącymi się do jednej uchwyconej zmiennej. W rezultacie, gdy zostaną wywołane, wszystkie mają taką samą wydajność.

Czy istnieje sposób obejścia tego limitu, aby każda instancja akcji miała własną przechwyconą zmienną?

Morgan Cheng
źródło
15
Zobacz także blog Erica Lipperta na ten temat: Zamykanie pętli Zmienna uważana za szkodliwą
Brian
10
Ponadto zmieniają C # 5, aby działały zgodnie z oczekiwaniami w obrębie foreach. (przełomowa zmiana)
Neal Tibrewala,
3
@Neal: chociaż ten przykład nadal nie działa poprawnie w C # 5, ponieważ nadal generuje pięć 10-tych
Ian Oakes
6
Sprawdził, że generuje pięć 10 do dziś na C # 6.0 (VS 2015). Wątpię, aby takie zachowanie zmiennych zamknięcia było kandydatem do zmiany. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

Odpowiedzi:

196

Tak - weź kopię zmiennej wewnątrz pętli:

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

Możesz myśleć o tym tak, jakby kompilator C # tworzy „nową” zmienną lokalną za każdym razem, gdy trafi ona do deklaracji zmiennej. W rzeczywistości utworzy odpowiednie nowe obiekty zamknięcia i komplikuje się (pod względem implementacji), jeśli odwołujesz się do zmiennych w wielu zakresach, ale działa :)

Zauważ, że częstszym występowaniem tego problemu jest użycie forlub foreach:

for (int i=0; i < 10; i++) // Just one variable
foreach (string x in foo) // And again, despite how it reads out loud

Więcej informacji na ten temat znajduje się w sekcji 7.14.4.2 specyfikacji C # 3.0, a mój artykuł na temat zamknięć zawiera również więcej przykładów.

Zauważ, że od kompilatora C # 5 i późniejszych (nawet przy określaniu wcześniejszej wersji C #) zachowanie foreachzmieniło się, więc nie musisz już tworzyć kopii lokalnej. Zobacz tę odpowiedź, aby uzyskać więcej informacji.

Jon Skeet
źródło
32
Książka Jona ma również bardzo dobry rozdział na ten temat (przestań być pokorny, Jon!)
Marc Gravell
35
Wygląda to lepiej, jeśli pozwolę innym ludziom to podłączyć;) (Przyznaję, że mam tendencję do głosowania nad odpowiedziami zalecającymi to.)
Jon Skeet,
2
Jak zawsze, mile widziane będą opinie na adres [email protected] :)
Jon Skeet
7
Dla zachowania w C # 5.0 jest inne (bardziej rozsądne) patrz nowsza odpowiedź Jona Skeeta - stackoverflow.com/questions/16264289/...
Alexei Levenkov
1
@Florimond: To nie tak działają zamknięcia w C #. Przechwytują zmienne , a nie wartości . (Jest to prawdą niezależnie od pętli i można to łatwo wykazać za pomocą lambda, która przechwytuje zmienną i drukuje tylko bieżącą wartość za każdym razem, gdy jest wykonywana.)
Jon Skeet
23

Uważam, że to, czego doświadczasz, to coś, co nazywa się Closure http://en.wikipedia.org/wiki/Closure_(computer_science) . Twoja Lamba ma odniesienie do zmiennej, której zasięg wykracza poza samą funkcję. Twoja Lamba nie jest interpretowana, dopóki jej nie wywołasz, a gdy już ją uzyska, uzyska wartość, którą ma zmienna w czasie wykonywania.

TheCodeJunkie
źródło
11

Za kulisami kompilator generuje klasę reprezentującą zamknięcie twojego wywołania metody. Używa tego pojedynczego wystąpienia klasy zamknięcia dla każdej iteracji pętli. Kod wygląda mniej więcej tak, co ułatwia zrozumienie przyczyny błędu:

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

To nie jest właściwie skompilowany kod z twojej próbki, ale sprawdziłem własny kod i wygląda to bardzo podobnie do tego, co faktycznie wygenerowałby kompilator.

gerrard00
źródło
8

Rozwiązaniem tego problemu jest przechowywanie potrzebnej wartości w zmiennej proxy i przechwytywanie tej zmiennej.

TO ZNACZY

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}
tjlevine
źródło
Zobacz wyjaśnienie w mojej zredagowanej odpowiedzi. Znajduję teraz odpowiedni fragment specyfikacji.
Jon Skeet,
Haha jon, właśnie przeczytałem twój artykuł: csharpindepth.com/Articles/Chapter5/Closures.aspx Dobra robota, przyjacielu.
tjlevine,
@tjlevine: Dziękuję bardzo. Dodam odniesienie do tego w mojej odpowiedzi. Zapomniałem o tym!
Jon Skeet,
Ponadto, Jon, chciałbym przeczytać o twoich przemyśleniach na temat różnych propozycji zamknięcia Java 7. Widziałem, jak wspomniałeś, że chciałeś napisać jeden, ale go nie widziałem.
tjlevine,
1
@tjlevine: Dobra, obiecuję spróbować napisać to do końca roku :)
Jon Skeet
6

To nie ma nic wspólnego z pętlami.

To zachowanie jest wyzwalane, ponieważ używasz wyrażenia lambda () => variable * 2w zakresie zewnętrznymvariable nie jest zdefiniowany w wewnętrznym zakresie lambda.

Wyrażenia lambda (w C # 3 +, a także anonimowe metody w C # 2) nadal tworzą rzeczywiste metody. Przekazywanie zmiennych do tych metod wiąże się z pewnymi dylematami (przekazywanie przez wartość? Przekazywanie przez referencję? C # idzie przez referencję - ale to otwiera kolejny problem, w którym referencja może przeżyć rzeczywistą zmienną). Aby rozwiązać wszystkie te dylematy, C # tworzy nową klasę pomocniczą („zamknięcie”) z polami odpowiadającymi zmiennym lokalnym używanym w wyrażeniach lambda i metodami odpowiadającymi rzeczywistym metodom lambda. Wszelkie zmiany variablew kodzie są faktycznie tłumaczone, aby to zmienićClosureClass.variable

Twoja pętla while aktualizuje ClosureClass.variableaż do osiągnięcia 10, a następnie pętle for wykonują akcje, które działają na tym samym ClosureClass.variable.

Aby uzyskać oczekiwany wynik, musisz utworzyć separację między zmienną pętli a zmienną, która jest zamykana. Możesz to zrobić, wprowadzając inną zmienną, tj .:

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

Możesz także przenieść zamknięcie na inną metodę, aby utworzyć ten rozdział:

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

Możesz zaimplementować Mult jako wyrażenie lambda (niejawne zamknięcie)

static Func<int> Mult(int i)
{
    return () => i * 2;
}

lub z rzeczywistą klasą pomocnika:

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

W każdym razie „Zamknięcia” NIE są pojęciem związanym z pętlami , ale raczej z anonimowymi metodami / wyrażeniami lambda stosującymi zmienne o zasięgu lokalnym - chociaż niektóre nieostrożne użycie pętli wykazują pułapki zamknięcia.

David Refaeli
źródło
5

Tak, musisz ustawić zakres variablew pętli i przekazać go w ten sposób lambdzie:

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();
cfeduke
źródło
5

Ta sama sytuacja ma miejsce w przypadku wielowątkowości (C #, .NET 4.0].

Zobacz następujący kod:

Celem jest wydrukowanie 1,2,3,4,5 w kolejności.

for (int counter = 1; counter <= 5; counter++)
{
    new Thread (() => Console.Write (counter)).Start();
}

Wynik jest interesujący! (Może być jak 21334 ...)

Jedynym rozwiązaniem jest użycie zmiennych lokalnych.

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}
Sunil
źródło
To mi chyba nie pomaga. Wciąż niedeterministyczny.
Mladen Mihajlovic
0

Ponieważ nikt tutaj bezpośrednio nie cytował ECMA-334 :

10.4.4.10 Dla wyciągów

Określone sprawdzanie przypisania dla oświadczenia formularza:

for (for-initializer; for-condition; for-iterator) embedded-statement

odbywa się tak, jakby napisano oświadczenie:

{
    for-initializer;
    while (for-condition) {
        embedded-statement;
    LLoop: for-iterator;
    }
}

W dalszej części specyfikacji

12.16.6.3 Tworzenie instancji zmiennych lokalnych

Zmienna lokalna jest uważana za utworzoną, gdy wykonanie wchodzi w zakres zmiennej.

[Przykład: Na przykład, gdy wywoływana jest następująca metoda, zmienna lokalna xjest tworzona i inicjowana trzykrotnie - raz dla każdej iteracji pętli.

static void F() {
  for (int i = 0; i < 3; i++) {
    int x = i * 2 + 1;
    ...
  }
}

Jednak przeniesienie deklaracji xpoza pętlę powoduje pojedyncze utworzenie x:

static void F() {
  int x;
  for (int i = 0; i < 3; i++) {
    x = i * 2 + 1;
    ...
  }
}

przykład końca]

Jeśli nie zostanie przechwycony, nie można dokładnie obserwować, jak często tworzona jest instancja zmiennej lokalnej - ponieważ czasy życia instancji są rozłączne, możliwe jest, aby każda instancja po prostu używała tego samego miejsca przechowywania. Jednak gdy anonimowa funkcja przechwytuje zmienną lokalną, efekty tworzenia instancji stają się widoczne.

[Przykład: przykład

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

daje wynik:

1
3
5

Jednak gdy deklaracja xjest przenoszona poza pętlę:

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

dane wyjściowe to:

5
5
5

Należy pamiętać, że kompilator jest dozwolony (ale nie wymagany) do optymalizacji trzech instancji w pojedynczej instancji delegata (§ 11.7.2).

Jeśli pętla for deklaruje zmienną iteracyjną, sama zmienna jest uważana za zadeklarowaną poza pętlą. [Przykład: Jeśli więc przykład zostanie zmieniony w celu przechwycenia samej zmiennej iteracyjnej:

static D[] F() {
  D[] result = new D[3];
  for (int i = 0; i < 3; i++) {
    result[i] = () => { Console.WriteLine(i); };
  }
  return result;
}

przechwytywana jest tylko jedna instancja zmiennej iteracyjnej, która daje wynik:

3
3
3

przykład końca]

O tak, myślę, że należy wspomnieć, że w C ++ ten problem nie występuje, ponieważ możesz wybrać, czy zmienna jest przechwytywana przez wartość, czy przez referencję (patrz: Przechwytywanie lambda ).

Nathan Chappell
źródło
-1

Nazywa się to problemem zamknięcia, wystarczy użyć zmiennej kopiowania i gotowe.

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());
}
Juned Khan Momin
źródło
4
W jaki sposób Twoja odpowiedź różni się od odpowiedzi udzielonej przez kogoś powyżej?
Thangadurai