Erfasste Variable in einer Schleife in C #

216

Ich habe ein interessantes Problem mit C # getroffen. Ich habe Code wie unten.

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

Ich erwarte, dass es 0, 2, 4, 6, 8 ausgibt. Es gibt jedoch tatsächlich fünf 10s aus.

Es scheint, dass dies auf alle Aktionen zurückzuführen ist, die sich auf eine erfasste Variable beziehen. Wenn sie aufgerufen werden, haben sie alle dieselbe Ausgabe.

Gibt es eine Möglichkeit, dieses Limit zu umgehen, damit jede Aktionsinstanz eine eigene erfasste Variable hat?

Morgan Cheng
quelle
15
Siehe auch Eric Lipperts Blog-Reihe zum Thema: Schließen über die Schleife Variable als schädlich angesehen
Brian
10
Außerdem ändern sie C # 5 so, dass es wie erwartet in einem Foreach funktioniert. (Breaking Change)
Neal Tibrewala
3
@Neal: obwohl dieses Beispiel in C # 5 immer noch nicht richtig funktioniert, da es immer noch fünf 10s ausgibt
Ian Oakes
6
Es wurde überprüft, dass es bis heute fünf Zehner auf C # 6.0 (VS 2015) ausgibt. Ich bezweifle, dass dieses Verhalten von Schließungsvariablen ein Kandidat für Veränderungen ist. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

Antworten:

196

Ja - kopieren Sie die Variable in der Schleife:

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

Sie können sich vorstellen, dass der C # -Compiler jedes Mal, wenn er auf die Variablendeklaration trifft, eine "neue" lokale Variable erstellt. Tatsächlich werden geeignete neue Abschlussobjekte erstellt, und es wird (in Bezug auf die Implementierung) kompliziert, wenn Sie auf Variablen in mehreren Bereichen verweisen, aber es funktioniert :)

Beachten Sie, dass ein häufiges Auftreten dieses Problems wird mit foroder foreach:

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

Weitere Informationen hierzu finden Sie in Abschnitt 7.14.4.2 der C # 3.0-Spezifikation. In meinem Artikel zu Verschlüssen finden Sie auch weitere Beispiele.

Beachten Sie, dass sich ab dem C # 5-Compiler und darüber hinaus (auch wenn Sie eine frühere Version von C # angeben) das Verhalten von foreachgeändert hat, sodass Sie keine lokale Kopie mehr erstellen müssen. Siehe diese Antwort für weitere Details.

Jon Skeet
quelle
32
Jons Buch hat auch ein sehr gutes Kapitel dazu (hör auf demütig zu sein, Jon!)
Marc Gravell
35
Es sieht besser aus, wenn ich es von anderen Leuten anschließen lasse;) (Ich gebe zu, dass ich dazu neige, Antworten zu empfehlen, die es empfehlen.)
Jon Skeet
2
Wie immer wäre ein Feedback an [email protected] willkommen :)
Jon Skeet
7
Für C # 5.0 Verhalten ist anders (vernünftiger) siehe neuere Antwort von Jon Skeet - stackoverflow.com/questions/16264289/…
Alexei Levenkov
1
@Florimond: So funktionieren Verschlüsse in C # einfach nicht. Sie erfassen Variablen , keine Werte . (Dies gilt unabhängig von Schleifen und lässt sich leicht mit einem Lambda demonstrieren, das eine Variable erfasst und
Jon Skeet
23

Ich glaube, was Sie erleben, ist etwas, das als Schließung bekannt ist ( http://en.wikipedia.org/wiki/Closure_(computer_science) . Ihre Lamba hat einen Verweis auf eine Variable, die außerhalb der Funktion selbst liegt. Ihre Lamba wird erst interpretiert, wenn Sie sie aufrufen. Sobald sie verfügbar ist, erhält sie den Wert, den die Variable zur Ausführungszeit hat.

TheCodeJunkie
quelle
11

Hinter den Kulissen generiert der Compiler eine Klasse, die den Abschluss Ihres Methodenaufrufs darstellt. Es verwendet diese einzelne Instanz der Abschlussklasse für jede Iteration der Schleife. Der Code sieht ungefähr so ​​aus, wodurch leichter zu erkennen ist, warum der Fehler auftritt:

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

Dies ist eigentlich nicht der kompilierte Code aus Ihrem Beispiel, aber ich habe meinen eigenen Code untersucht und dies sieht sehr nach dem aus, was der Compiler tatsächlich generieren würde.

gerrard00
quelle
8

Um dies zu umgehen, müssen Sie den benötigten Wert in einer Proxy-Variablen speichern und diese Variable erfassen lassen.

IE

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}
tjlevine
quelle
Siehe die Erklärung in meiner bearbeiteten Antwort. Ich finde jetzt das relevante Stück der Spezifikation.
Jon Skeet
Haha jon, ich habe gerade Ihren Artikel gelesen: csharpindepth.com/Articles/Chapter5/Closures.aspx Sie leisten gute Arbeit, mein Freund.
Tjlevine
@tjlevine: Vielen Dank. Ich werde in meiner Antwort einen Verweis darauf hinzufügen. Ich hatte es vergessen!
Jon Skeet
Außerdem, Jon, würde ich gerne über Ihre Gedanken zu den verschiedenen Java 7-Schließungsvorschlägen lesen. Ich habe gesehen, wie du erwähnt hast, dass du einen schreiben wolltest, aber ich habe ihn nicht gesehen.
Tjlevine
1
@tjlevine: Okay, ich verspreche, zu versuchen, es bis Ende des Jahres zu schreiben :)
Jon Skeet
6

Dies hat nichts mit Schleifen zu tun.

Dieses Verhalten wird ausgelöst, weil Sie einen Lambda-Ausdruck verwenden, () => variable * 2bei dem der äußere Bereich variablenicht tatsächlich im inneren Bereich des Lambda definiert ist.

Lambda-Ausdrücke (in C # 3 + sowie anonyme Methoden in C # 2) erstellen weiterhin tatsächliche Methoden. Das Übergeben von Variablen an diese Methoden ist mit einigen Dilemmata verbunden (Übergeben von Wert? Übergeben von Referenz? C # geht mit Referenz einher - dies eröffnet jedoch ein weiteres Problem, bei dem die Referenz die tatsächliche Variable überleben kann). Um all diese Dilemmata zu lösen, erstellt C # eine neue Hilfsklasse ("Closure") mit Feldern, die den in den Lambda-Ausdrücken verwendeten lokalen Variablen entsprechen, und Methoden, die den tatsächlichen Lambda-Methoden entsprechen. Alle Änderungen an variableIhrem Code werden tatsächlich übersetzt, um dies zu ändernClosureClass.variable

Ihre while-Schleife wird also so lange aktualisiert, ClosureClass.variablebis sie 10 erreicht. Dann führen Sie for-Schleifen die Aktionen aus, die alle auf derselben Weise ausgeführt werden ClosureClass.variable.

Um das erwartete Ergebnis zu erzielen, müssen Sie eine Trennung zwischen der Schleifenvariablen und der Variablen, die geschlossen wird, erstellen. Sie können dies tun, indem Sie eine andere Variable einführen, dh:

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

Sie können den Verschluss auch auf eine andere Methode verschieben, um diese Trennung zu erstellen:

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

Sie können Mult als Lambda-Ausdruck implementieren (impliziter Abschluss).

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

oder mit einer tatsächlichen Helferklasse:

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

In jedem Fall handelt es sich bei "Closures" NICHT um ein Konzept, das sich auf Schleifen bezieht , sondern auf anonyme Methoden / Lambda-Ausdrücke, bei denen Variablen mit lokalem Gültigkeitsbereich verwendet werden - obwohl einige vorsichtige Verwendung von Schleifen Schließfallen aufzeigen.

David Refaeli
quelle
5

Ja, Sie müssen variableinnerhalb der Schleife einen Bereich erstellen und auf diese Weise an das Lambda übergeben:

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
quelle
5

Die gleiche Situation tritt beim Multithreading auf (C #, .NET 4.0].

Siehe folgenden Code:

Zweck ist es, 1,2,3,4,5 der Reihe nach zu drucken.

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

Die Ausgabe ist interessant! (Es könnte wie 21334 sein ...)

Die einzige Lösung besteht darin, lokale Variablen zu verwenden.

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}
Sunil
quelle
Das scheint mir nicht zu helfen. Immer noch nicht deterministisch.
Mladen Mihajlovic
0

Da hier niemand ECMA-334 direkt zitierte :

10.4.4.10 Für Aussagen

Definitive Zuweisungsprüfung für eine for-Anweisung des Formulars:

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

wird so gemacht, als ob die Aussage geschrieben wäre:

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

Weiter in der Spezifikation,

12.16.6.3 Instanziierung lokaler Variablen

Eine lokale Variable gilt als instanziiert, wenn die Ausführung in den Bereich der Variablen eintritt.

[Beispiel: Wenn beispielsweise die folgende Methode aufgerufen wird, wird die lokale Variable xdreimal instanziiert und initialisiert - einmal für jede Iteration der Schleife.

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

Das Verschieben der Deklaration von xaußerhalb der Schleife führt jedoch zu einer einzelnen Instanziierung von x:

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

Ende Beispiel]

Wenn sie nicht erfasst werden, kann nicht genau beobachtet werden, wie oft eine lokale Variable instanziiert wird. Da die Lebensdauer der Instanziierungen nicht zusammenhängend ist, kann für jede Instanziierung einfach derselbe Speicherort verwendet werden. Wenn jedoch eine anonyme Funktion eine lokale Variable erfasst, werden die Auswirkungen der Instanziierung offensichtlich.

[Beispiel: Das Beispiel

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

erzeugt die Ausgabe:

1
3
5

Wenn die Deklaration von xjedoch außerhalb der Schleife verschoben wird:

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

Die Ausgabe ist:

5
5
5

Beachten Sie, dass der Compiler die drei Instanziierungen in einer einzelnen Delegateninstanz optimieren darf (aber nicht muss) (§11.7.2).

Wenn eine for-Schleife eine Iterationsvariable deklariert, wird diese Variable selbst als außerhalb der Schleife deklariert betrachtet. [Beispiel: Wenn also das Beispiel geändert wird, um die Iterationsvariable selbst zu erfassen:

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

Es wird nur eine Instanz der Iterationsvariablen erfasst, die die Ausgabe erzeugt:

3
3
3

Ende Beispiel]

Oh ja, ich denke, es sollte erwähnt werden, dass dieses Problem in C ++ nicht auftritt, da Sie auswählen können, ob die Variable nach Wert oder Referenz erfasst wird (siehe: Lambda-Erfassung ).

Nathan Chappell
quelle
-1

Es wird als Abschlussproblem bezeichnet. Verwenden Sie einfach eine Kopiervariable, und fertig.

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
quelle
4
Inwiefern unterscheidet sich Ihre Antwort von der Antwort von jemandem oben?
Thangadurai