Czy istnieje powód ponownego użycia zmiennej przez C # w foreach?

1684

Korzystając z wyrażeń lambda lub anonimowych metod w języku C #, musimy uważać na dostęp do zmodyfikowanej pułapki zamknięcia . Na przykład:

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

Ze względu na zmodyfikowane zamknięcie powyższy kod spowoduje, że wszystkie Whereklauzule w zapytaniu będą oparte na końcowej wartości s.

Jak wyjaśniono tutaj , dzieje się tak, ponieważ szmienna zadeklarowana w foreachpowyższej pętli jest tłumaczona w kompilatorze w następujący sposób:

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

zamiast tego:

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

Jak wskazano tutaj , zadeklarowanie zmiennej poza pętlą nie ma żadnych korzyści w zakresie wydajności, aw normalnych okolicznościach jedynym powodem, dla którego mogę to zrobić, jest zaplanowanie użycia zmiennej poza zakresem pętli:

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

Jednak zmiennych zdefiniowanych w foreachpętli nie można używać poza pętlą:

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

Tak więc kompilator deklaruje zmienną w sposób, który czyni ją wysoce podatną na błąd, który często jest trudny do znalezienia i debugowania, a jednocześnie nie daje widocznych korzyści.

Czy istnieje coś, co można zrobić z foreachpętlami w ten sposób, czego nie można było zrobić, gdyby były one skompilowane ze zmienną o zasięgu wewnętrznym, czy jest to tylko arbitralny wybór, który został dokonany, zanim anonimowe metody i wyrażenia lambda były dostępne lub powszechne, a które nie nie został zmieniony od tego czasu?

StriplingWarrior
źródło
4
Co jest nie tak z String s; foreach (s in strings) { ... }?
Brad Christie,
5
@BradChristie OP tak naprawdę nie mówi o foreachwyrażeniach lamda, które skutkują podobnym kodem, jak pokazano przez OP ...
Yahia
22
@BradChristie: Czy to się kompiluje? ( Błąd: zarówno typ, jak i identyfikator są dla mnie wymagane w oświadczeniu foreach )
Austin Salonen
32
@JobobBotschNielsen: To zamknięty lokalny lambda; dlaczego zakładasz, że w ogóle będzie na stosie? Jego żywotność jest dłuższa niż rama stosu !
Eric Lippert,
3
@EricLippert: Jestem zdezorientowany. Rozumiem, że lambda przechwytuje odniesienie do zmiennej foreach (która jest wewnętrznie zadeklarowana poza pętlą) i dlatego kończysz na porównaniu z jej ostateczną wartością; dostaję. To, czego nie rozumiem, to to, jak zadeklarowanie zmiennej w pętli w ogóle coś zmieni. Z punktu widzenia kompilatora-autora przydzielam tylko jeden odnośnik do ciągu (var 's') na stosie, niezależnie od tego, czy deklaracja znajduje się w pętli, czy poza nią; Na pewno nie chciałbym wrzucać nowego odwołania na stos przy każdej iteracji!
Anthony

Odpowiedzi:

1407

Kompilator deklaruje zmienną w sposób, który czyni ją wysoce podatną na błąd, który często jest trudny do znalezienia i debugowania, a jednocześnie nie daje widocznych korzyści.

Twoja krytyka jest całkowicie uzasadniona.

Szczegółowo omawiam ten problem tutaj:

Zamykanie przez zmienną pętli uważane jest za szkodliwe

Czy jest coś, co można zrobić z pętlami foreach w ten sposób, czego nie można było zrobić, gdyby były one skompilowane ze zmienną o zasięgu wewnętrznym? czy jest to tylko arbitralny wybór dokonany, zanim anonimowe metody i wyrażenia lambda były dostępne lub powszechne, i które od tego czasu nie zostały zmienione?

Ten ostatni. Specyfikacja C # 1.0 w rzeczywistości nie mówiła, czy zmienna pętli znajduje się wewnątrz czy poza ciałem pętli, ponieważ nie robiło to zauważalnej różnicy. Kiedy semantyka zamykania została wprowadzona w C # 2.0, wybrano opcję umieszczenia zmiennej pętli poza pętlą, zgodnie z pętlą „for”.

Myślę, że uczciwie jest powiedzieć, że wszyscy żałują tej decyzji. Jest to jeden z najgorszych „gotchas” w języku C # i zamierzamy wprowadzić przełomową zmianę, aby to naprawić. W C # 5 zmienna foreach loop będzie logicznie wewnątrz korpusu pętli, a zatem zamknięcia będą za każdym razem otrzymywały nową kopię.

forPętla nie zostanie zmieniony, a zmiana nie będzie „z powrotem przeniesiony” do poprzednich wersjach C #. Dlatego powinieneś nadal zachować ostrożność, używając tego idiomu.

Eric Lippert
źródło
177
W rzeczywistości cofnęliśmy tę zmianę w C # 3 i C # 4. Kiedy zaprojektowaliśmy C # 3, zdaliśmy sobie sprawę, że problem (który już istniał w C # 2) będzie się nasilał, ponieważ będzie tak wiele lambdas (i zapytania zrozumienia, które są maskotkami w maskach) w pętlach foreach dzięki LINQ. Żałuję, że czekaliśmy, aż problem stanie się wystarczająco zły, aby uzasadnić naprawienie go tak późno, niż naprawienie go w C # 3.
Eric Lippert
75
A teraz musimy pamiętać, że foreachjest „bezpieczny”, ale fornie jest.
leppie
22
@michielvoo: Zmiana ulega załamaniu w tym sensie, że nie jest kompatybilna wstecz. Nowy kod nie będzie działał poprawnie w przypadku korzystania ze starszego kompilatora.
leppie
41
@Benjol: Nie, dlatego chętnie to przyjmujemy. Jon Skeet zwrócił mi uwagę na ważny scenariusz zepsucia, polegający na tym, że ktoś pisze kod w C # 5, testuje go, a następnie udostępnia go osobom, które nadal używają C # 4, a następnie naiwnie uważają, że jest poprawny. Mamy nadzieję, że liczba osób dotkniętych takim scenariuszem jest niewielka.
Eric Lippert,
29
Nawiasem mówiąc, ReSharper zawsze wychwytuje to i zgłasza to jako „dostęp do zmodyfikowanego zamknięcia”. Następnie, naciskając klawisze Alt + Enter, nawet automatycznie naprawi kod za Ciebie. jetbrains.com/resharper
Mike Chamberlain
191

To, o co pytasz, zostało dokładnie omówione przez Erica Lipperta w jego blogu Zamykanie pętli przez zmienną uważaną za szkodliwą i jej następstwo.

Dla mnie najbardziej przekonującym argumentem jest to, że posiadanie nowej zmiennej w każdej iteracji byłoby niespójne z for(;;)pętlą stylu. Czy spodziewałbyś się nowego int iw każdej iteracji for (int i = 0; i < 10; i++)?

Najczęstszym problemem związanym z tym zachowaniem jest zamknięcie zmiennej iteracyjnej i ma ona łatwe obejście:

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

Mój post na blogu na ten temat: Zamknięcie nad zmienną foreach w języku C # .

Krizz
źródło
18
Ostatecznie to, czego ludzie tak naprawdę chcą, kiedy to piszą, to nie mieć wielu zmiennych, to zamknąć wartość . I ciężko jest wymyślić użyteczną składnię do tego w ogólnym przypadku.
Random832
1
Tak, zamknięcie wartości nie jest możliwe, jednak istnieje bardzo łatwe obejście, które właśnie edytowałem w odpowiedzi.
Krizz,
6
To zbyt złe zamknięcia w C # blisko referencji. Jeśli domyślnie zamknęły wartości, moglibyśmy z łatwością określić zmienne zamykające ref.
Sean U
2
@ Krizz, jest to przypadek, w którym wymuszona spójność jest bardziej szkodliwa niż niespójna. Powinien „po prostu działać”, jak ludzie się spodziewają, i wyraźnie ludzie oczekują czegoś innego podczas korzystania z foreach w przeciwieństwie do pętli for, biorąc pod uwagę liczbę osób, które napotkały problemy, zanim dowiedzieliśmy się o dostępie do zmodyfikowanego problemu zamknięcia (takiego jak ja) .
Andy,
2
@ Random832 nie wie o C #, ale w Common LISP istnieje taka składnia, która pokazuje, że każdy język ze zmiennymi zmiennymi i zamknięciami miałby (nie, musi ) również to mieć. Zamykamy odniesienie do zmieniającego się miejsca lub wartość, jaką ma on w danym momencie (tworzenie zamknięcia). To opisano podobny materiał w Python i schemacie ( cutna literatury / Vars i cutedo utrzymywania ocenione wartości częściowo ocenianych zamknięcia).
Czy Ness
103

Ugryziony przez to mam zwyczaj włączać zmienne zdefiniowane lokalnie w najbardziej wewnętrzny zakres, którego używam do przeniesienia do dowolnego zamknięcia. W twoim przykładzie:

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

Ja robię:

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

Kiedy już będziesz miał ten nawyk, możesz go uniknąć w bardzo rzadkim przypadku, w którym zamierzałeś związać się z zewnętrznymi celami. Szczerze mówiąc, nie sądzę, żebym kiedykolwiek to zrobił.

Godeke
źródło
24
To typowe obejście Dzięki za wkład. Resharper jest wystarczająco inteligentny, aby rozpoznać ten wzór i zwrócić na niego uwagę, co jest miłe. Dawno mnie to nie obchodziło, ale ponieważ jest to, według słów Erica Lipperta, „najczęstszy niepoprawny raport o błędzie, jaki otrzymujemy”, byłem ciekawy, dlaczego bardziej niż jak tego uniknąć .
StriplingWarrior
62

W C # 5.0 problem został rozwiązany i można zamknąć zmienne pętli i uzyskać oczekiwane wyniki.

Specyfikacja języka mówi:

8.8.4 Oświadczenie foreach

(...)

Foreach oświadczenie formularza

foreach (V v in x) embedded-statement

jest następnie rozwijany do:

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

(...)

Umieszczenie vwewnątrz pętli while jest ważne dla tego, jak jest przechwytywana przez dowolną anonimową funkcję występującą w instrukcji osadzonej. Na przykład:

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

Gdyby vzostał zadeklarowany poza pętlą while, byłby współużytkowany przez wszystkie iteracje, a jego wartość po pętli for byłaby wartością końcową 13, czyli to, co fwydrukowałoby wywołanie . Zamiast tego, ponieważ każda iteracja ma swoją zmienną v, ta przechwycona przez fpierwszą iterację będzie nadal przechowywać wartość 7, która zostanie wydrukowana. ( Uwaga: wcześniejsze wersje C # zadeklarowane vpoza pętlą while ).

Paolo Moretti
źródło
1
Dlaczego ta wczesna wersja C # zadeklarowała v wewnątrz pętli while? msdn.microsoft.com/en-GB/library/aa664754.aspx
colinfang
4
@colinfang Koniecznie przeczytaj odpowiedź Erica : Specyfikacja C # 1.0 ( w twoim linku mówimy o VS 2003, tj. C # 1.2 ) faktycznie nie powiedziała, czy zmienna pętli znajduje się wewnątrz czy na zewnątrz korpusu pętli, ponieważ nie ma zauważalnej różnicy . Kiedy semantyka zamykania została wprowadzona w C # 2.0, wybrano opcję umieszczenia zmiennej pętli poza pętlą, zgodnie z pętlą „for”.
Paolo Moretti
1
Mówisz więc, że przykłady w łączu nie były w tamtym czasie ostateczną specyfikacją?
colinfang
4
@colinfang Były to ostateczne specyfikacje. Problem polega na tym, że mówimy o funkcji (tj. Zamknięciu funkcji), która została wprowadzona później (z C # 2.0). Kiedy pojawił się C # 2.0, postanowili umieścić zmienną pętli poza pętlą. A potem zmienili zdanie ponownie z C # 5.0 :)
Paolo Moretti