Gibt es einen Grund für die Wiederverwendung der Variablen durch C # in einem foreach?

1684

Bei der Verwendung von Lambda-Ausdrücken oder anonymen Methoden in C # müssen wir uns vor dem Zugriff auf modifizierte Abschlussfallen in Acht nehmen. Zum Beispiel:

foreach (var s in strings)
{
   query = query.Where(i => i.Prop == s); // access to modified closure
   ...
}

Aufgrund des geänderten Abschlusses bewirkt der obige Code, dass alle WhereKlauseln in der Abfrage auf dem Endwert von basieren s.

Wie hier erläutert , geschieht dies, weil die sin der foreachobigen Schleife deklarierte Variable im Compiler folgendermaßen übersetzt wird:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}

statt so:

while (enumerator.MoveNext())
{
   string s;
   s = enumerator.Current;
   ...
}

Wie hier ausgeführt , bietet das Deklarieren einer Variablen außerhalb der Schleife keine Leistungsvorteile. Unter normalen Umständen kann ich mir nur vorstellen, dies zu tun, wenn Sie die Variable außerhalb des Bereichs der Schleife verwenden möchten:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}
var finalString = s;

In einer foreachSchleife definierte Variablen können jedoch nicht außerhalb der Schleife verwendet werden:

foreach(string s in strings)
{
}
var finalString = s; // won't work: you're outside the scope.

Daher deklariert der Compiler die Variable so, dass sie sehr anfällig für Fehler ist, die oft schwer zu finden und zu debuggen sind, ohne dass erkennbare Vorteile entstehen.

Gibt es etwas, das Sie mit foreachSchleifen auf diese Weise tun können, das Sie nicht könnten, wenn sie mit einer Variablen mit innerem Gültigkeitsbereich kompiliert würden, oder ist dies nur eine willkürliche Auswahl, die getroffen wurde, bevor anonyme Methoden und Lambda-Ausdrücke verfügbar oder allgemein waren und die nicht vorhanden sind wurde seitdem nicht mehr überarbeitet?

StriplingWarrior
quelle
4
Was ist los mit String s; foreach (s in strings) { ... }?
Brad Christie
5
@BradChristie das OP spricht nicht wirklich über, foreachsondern über Lamda-Ausdrücke, die zu einem ähnlichen Code führen, wie er vom OP gezeigt wird ...
Yahia
22
@BradChristie: Kompiliert das? ( Fehler: Typ und Kennung sind beide in einer foreach-Anweisung für mich erforderlich )
Austin Salonen
32
@JakobBotschNielsen: Es ist ein geschlossener äußerer Ort eines Lambda; Warum nimmst du an, dass es überhaupt auf dem Stapel sein wird? Die Lebensdauer ist länger als der Stapelrahmen !
Eric Lippert
3
@ EricLippert: Ich bin verwirrt. Ich verstehe, dass Lambda einen Verweis auf die foreach-Variable erfasst (die intern außerhalb der Schleife deklariert wird), und dass Sie am Ende mit ihrem Endwert vergleichen. das bekomme ich. Was ich nicht verstehe, ist, wie das Deklarieren der Variablen innerhalb der Schleife überhaupt einen Unterschied macht. Aus Sicht des Compilers und Writers ordne ich dem Stapel nur eine Zeichenfolgenreferenz (var 's') zu, unabhängig davon, ob sich die Deklaration innerhalb oder außerhalb der Schleife befindet. Ich würde sicherlich nicht bei jeder Iteration eine neue Referenz auf den Stapel schieben wollen!
Anthony

Antworten:

1407

Der Compiler deklariert die Variable so, dass sie sehr anfällig für Fehler ist, die oft schwer zu finden und zu debuggen sind, ohne dass erkennbare Vorteile entstehen.

Ihre Kritik ist völlig berechtigt.

Ich diskutiere dieses Problem hier im Detail:

Das Schließen über der Schleifenvariablen wird als schädlich angesehen

Gibt es etwas, das Sie mit foreach-Schleifen auf diese Weise tun können, das Sie nicht könnten, wenn sie mit einer Variablen mit innerem Gültigkeitsbereich kompiliert würden? oder ist dies nur eine willkürliche Wahl, die getroffen wurde, bevor anonyme Methoden und Lambda-Ausdrücke verfügbar oder allgemein waren und die seitdem nicht überarbeitet wurde?

Letzteres. Die C # 1.0-Spezifikation sagte tatsächlich nicht aus, ob sich die Schleifenvariable innerhalb oder außerhalb des Schleifenkörpers befand, da sie keinen beobachtbaren Unterschied machte. Als die Abschlusssemantik in C # 2.0 eingeführt wurde, wurde die Wahl getroffen, die Schleifenvariable außerhalb der Schleife zu platzieren, was mit der "for" -Schleife übereinstimmt.

Ich denke, es ist fair zu sagen, dass alle diese Entscheidung bedauern. Dies ist eine der schlimmsten "Fallstricke" in C #, und wir werden die bahnbrechende Änderung vornehmen , um sie zu beheben. In C # 5 befindet sich die foreach-Schleifenvariable logisch im Hauptteil der Schleife, und daher erhalten Schließungen jedes Mal eine neue Kopie.

Die forSchleife wird nicht geändert, und die Änderung wird nicht auf frühere Versionen von C # "zurückportiert". Sie sollten daher weiterhin vorsichtig sein, wenn Sie diese Redewendung verwenden.

Eric Lippert
quelle
177
Wir haben diese Änderung in C # 3 und C # 4 tatsächlich zurückgedrängt. Als wir C # 3 entwarfen, stellten wir fest, dass sich das Problem (das bereits in C # 2 bestand) verschlimmern würde, weil es so viele Lambdas (und Abfragen) geben würde Verständnis, das verdeckte Lambdas sind) in foreach-Schleifen dank LINQ. Ich bedauere, dass wir darauf gewartet haben, dass das Problem so schlimm wird, dass es so spät behoben werden kann, anstatt es in C # 3 zu beheben.
Eric Lippert
75
Und jetzt müssen wir uns daran erinnern, dass foreaches "sicher" forist , aber nicht.
Leppie
22
@michielvoo: Die Änderung bricht in dem Sinne, dass sie nicht abwärtskompatibel ist. Neuer Code wird bei Verwendung eines älteren Compilers nicht korrekt ausgeführt.
Leppie
41
@ Benjol: Nein, deshalb sind wir bereit, es zu nehmen. Jon Skeet wies mich jedoch auf ein wichtiges bahnbrechendes Änderungsszenario hin, nämlich dass jemand Code in C # 5 schreibt, ihn testet und ihn dann an Personen weitergibt, die noch C # 4 verwenden und dann naiv glauben, dass er korrekt ist. Hoffentlich ist die Anzahl der von einem solchen Szenario betroffenen Personen gering.
Eric Lippert
29
Abgesehen davon hat ReSharper dies immer abgefangen und meldet es als "Zugriff auf geänderte Schließung". Wenn Sie dann Alt + Eingabetaste drücken, wird Ihr Code sogar automatisch für Sie korrigiert. jetbrains.com/resharper
Mike Chamberlain
191

Was Sie fragen, wird von Eric Lippert in seinem Blog-Beitrag Closing over the Loop-Variable, die als schädlich angesehen wird, und deren Fortsetzung ausführlich behandelt .

Für mich ist das überzeugendste Argument, dass eine neue Variable in jeder Iteration nicht mit der for(;;)Stilschleife vereinbar wäre . Würden Sie erwarten, int iin jeder Iteration von eine neue zu haben for (int i = 0; i < 10; i++)?

Das häufigste Problem bei diesem Verhalten ist das Schließen einer Iterationsvariablen, und es gibt eine einfache Problemumgehung:

foreach (var s in strings)
{
    var s_for_closure = s;
    query = query.Where(i => i.Prop == s_for_closure); // access to modified closure

Mein Blogbeitrag zu diesem Problem: Schließen über jede Variable in C # .

Krizz
quelle
18
Letztendlich wollen die Leute beim Schreiben nicht mehrere Variablen haben, sondern den Wert schließen . Und es ist im allgemeinen Fall schwer, sich eine verwendbare Syntax dafür vorzustellen.
Random832
1
Ja, es ist nicht möglich, den Wert zu schließen. Es gibt jedoch eine sehr einfache Problemumgehung, die ich gerade bearbeitet habe, um sie einzuschließen.
Krizz
6
Es ist schade, dass Verschlüsse in C # über Referenzen schließen. Wenn sie standardmäßig Werte schließen, können wir stattdessen einfach das Schließen von Variablen mit angeben ref.
Sean U
2
@Krizz, dies ist ein Fall, in dem die erzwungene Konsistenz schädlicher ist als inkonsistent. Es sollte "einfach funktionieren", wie die Leute es erwarten, und die Leute erwarten eindeutig etwas anderes, wenn sie foreach verwenden, als eine for-Schleife, angesichts der Anzahl der Leute, die Probleme hatten, bevor wir über den Zugang zu modifizierten Schließungsproblemen Bescheid wussten (wie ich selbst). .
Andy
2
@ Random832 weiß nichts über C #, aber in Common LISP gibt es eine Syntax dafür, und es zeigt sich, dass jede Sprache mit veränderlichen Variablen und Abschlüssen diese auch haben würde (nein, muss ). Wir schließen entweder über den Verweis auf den sich ändernden Ort oder über einen Wert, den er zu einem bestimmten Zeitpunkt hat (Schaffung eines Abschlusses). Hier werden ähnliche Dinge in Python und Scheme besprochen ( cutfür refs / vars und cuteum ausgewertete Werte in teilweise ausgewerteten Abschlüssen zu behalten ).
Will Ness
103

Nachdem ich davon gebissen wurde, habe ich die Angewohnheit, lokal definierte Variablen in den innersten Bereich aufzunehmen, den ich verwende, um sie auf einen Abschluss zu übertragen. In Ihrem Beispiel:

foreach (var s in strings)
    query = query.Where(i => i.Prop == s); // access to modified closure

Ich mache:

foreach (var s in strings)
{
    string search = s;
    query = query.Where(i => i.Prop == search); // New definition ensures unique per iteration.
}        

Sobald Sie diese Angewohnheit haben, können Sie sie in dem sehr seltenen Fall vermeiden, dass Sie tatsächlich beabsichtigten, sich an die äußeren Bereiche zu binden. Um ehrlich zu sein, glaube ich nicht, dass ich das jemals getan habe.

Godeke
quelle
24
Das ist die typische Problemumgehung. Danke für den Beitrag. Resharper ist klug genug, um dieses Muster zu erkennen und Sie auch darauf aufmerksam zu machen, was sehr schön ist. Ich habe mich seit einiger Zeit nicht mehr an diesem Muster beteiligt, aber da es nach Eric Lipperts Worten "der häufigste falsche Fehlerbericht ist, den wir bekommen", war ich neugierig zu wissen, warum mehr als wie man es vermeidet .
StriplingWarrior
62

In C # 5.0 ist dieses Problem behoben, und Sie können Schleifenvariablen schließen und die erwarteten Ergebnisse erzielen.

Die Sprachspezifikation sagt:

8.8.4 Die foreach-Anweisung

(...)

Eine foreach-Erklärung des Formulars

foreach (V v in x) embedded-statement

wird dann erweitert auf:

{
  E e = ((C)(x)).GetEnumerator();
  try {
      while (e.MoveNext()) {
          V v = (V)(T)e.Current;
          embedded-statement
      }
  }
  finally {
       // Dispose e
  }
}

(...)

Die Platzierung vinnerhalb der while-Schleife ist wichtig für die Erfassung durch anonyme Funktionen in der eingebetteten Anweisung. Zum Beispiel:

int[] values = { 7, 9, 13 };
Action f = null;
foreach (var value in values)
{
    if (f == null) f = () => Console.WriteLine("First value: " + value);
}
f();

Wenn ves außerhalb der while-Schleife deklariert wird, wird es von allen Iterationen gemeinsam genutzt, und sein Wert nach der for-Schleife ist der Endwert 13, den der Aufruf von fausgibt. Da jede Iteration eine eigene Variable hat v, enthält die fin der ersten Iteration erfasste Variable weiterhin den Wert 7, der gedruckt wird. ( Hinweis: Frühere Versionen von C # wurden vaußerhalb der while-Schleife deklariert . )

Paolo Moretti
quelle
1
Warum hat diese frühe Version von C # v innerhalb der while-Schleife deklariert? msdn.microsoft.com/en-GB/library/aa664754.aspx
colinfang
4
@colinfang Lesen Sie unbedingt Erics Antwort : Die C # 1.0-Spezifikation ( in Ihrem Link handelt es sich um VS 2003, dh C # 1.2 ) hat tatsächlich nicht angegeben, ob sich die Schleifenvariable innerhalb oder außerhalb des Schleifenkörpers befindet, da sie keinen beobachtbaren Unterschied macht . Als die Abschlusssemantik in C # 2.0 eingeführt wurde, wurde die Wahl getroffen, die Schleifenvariable außerhalb der Schleife zu platzieren, was mit der "for" -Schleife übereinstimmt.
Paolo Moretti
1
Sie sagen also, dass die Beispiele im Link zu diesem Zeitpunkt keine endgültige Spezifikation waren?
Colinfang
4
@colinfang Sie waren endgültige Spezifikationen. Das Problem ist, dass es sich um eine Funktion (dh Funktionsabschlüsse) handelt, die später eingeführt wurde (mit C # 2.0). Als C # 2.0 auftauchte, beschlossen sie, die Schleifenvariable außerhalb der Schleife zu platzieren. Und dann haben sie ihre Meinung mit C # 5.0 wieder geändert :)
Paolo Moretti