Każda pętla foreach i każde zapytanie LINQ działa przez jeden mały interfejs: IEnumerable<T>. Zrozumienie go wyjaśnia, dlaczego LINQ jest leniwy, dlaczego niektóre sekwencje można odczytać tylko raz i jak yield return pozwala pisać własne sekwencje zwykłą pętlą.
Czym jest IEnumerable
IEnumerable<T> ma jedną metodę, GetEnumerator(). Zwracany przez nią enumerator ma MoveNext() (przejdź dalej i powiedz, czy jest element) oraz Current (bieżący element). foreach to skrót do sterowania tym enumeratorem:
Wynik:
red green blue
red green blue
Enumerator zaczyna przed pierwszym elementem, więc pierwsze MoveNext() przechodzi na niego. Gdy MoveNext() zwraca false, sekwencja się skończyła. Blok using zwalnia enumerator, co ma znaczenie dla iteratorów trzymających zasoby, takie jak otwarte pliki.
IEnumerable a ICollection a List
Interfejsy kolekcji tworzą drabinę. Każdy szczebel obiecuje więcej:
| Typ | Dodaje | Typowe użycie |
|---|---|---|
IEnumerable<T> | tylko foreach | Parametry, po których tylko iterujesz; wyniki LINQ; leniwe sekwencje |
IReadOnlyCollection<T> | Count | Zwracane wartości, których kod wywołujący nie powinien zmieniać |
ICollection<T> | Count, Add, Remove, Contains | Kod dodający do kolekcji dowolnego rodzaju |
IReadOnlyList<T> | Dostęp przez indeks [i] | Wyniki tylko do odczytu z pozycjami |
IList<T> | Dostęp przez indeks, Insert, RemoveAt | Kod edytujący według pozycji |
List<T> | Konkretna klasa implementująca to wszystko | Przechowywanie |
Metoda, która tylko iteruje po danych wejściowych, powinna przyjmować IEnumerable<T>, żeby kod wywołujący mógł przekazać tablicę, listę, zbiór albo zapytanie LINQ bez konwersji. Metoda zwracająca dane powinna zwykle zwracać coś, co mówi "to już jest w pamięci", na przykład List<T> lub IReadOnlyList<T>, bo zwrócony IEnumerable<T> może być zapytaniem, które wykonuje pracę przy każdym odczycie.
Wynik:
30
15
4.0
30
Pisanie iteratora przez yield return
Aby wyprodukować własną sekwencję, napisz metodę zwracającą IEnumerable<T> i użyj yield return dla każdego elementu. Kompilator zamienia metodę w klasę, która implementuje za ciebie enumerator.
Wynik:
0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri
W EvenNumbers nie ma nigdzie listy. Każde yield return przekazuje jedną wartość do kodu wywołującego i wstrzymuje metodę, zachowując jej zmienne lokalne (tu i), dopóki kod wywołujący nie poprosi o następną wartość.
Leniwość: obserwuj kolejność wykonywania
Ponieważ metoda się wstrzymuje, jej ciało i pętla w kodzie wywołującym wykonują się na zmianę. Wypisywanie z obu stron pokazuje ten przeplot:
Wynik:
Called Numbers(), nothing printed yet
start
yielding 1
Loop got 1
resumed after 1
yielding 2
Loop got 2
resumed after 2
yielding 3
Loop got 3
resumed after 3
end
Wywołanie Numbers() nie wypisało "start": tylko utworzyło obiekt enumerable. Ciało zaczęło się przy pierwszym MoveNext() w foreach, działało do pierwszego yield return i się zatrzymało. Każdy kolejny krok wznawiał działanie od linii po yield. Where i Select z LINQ zachowują się tak samo (środowisko uruchomieniowe implementuje je dla szybkości ręcznie napisanymi klasami enumeratorów, ale efekt jest identyczny), dlatego zapytania LINQ są odroczone.
yield break
yield break wcześniej kończy sekwencję; to wersja return dla iteratorów:
Wynik:
Subject: Invoice
From: shop@example.com
Nie można mieszać return value; i yield return w jednej metodzie. Gdy metoda zawiera yield, jest iteratorem, a yield break to jedyny sposób, żeby wcześniej z niej wyjść.
Sekwencje nieskończone
Ponieważ obliczane są tylko żądane elementy, iterator może opisywać sekwencję bez końca. O tym, ile wziąć, decyduje konsument:
Wynik:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon
while (true) jest tu bezpieczne, bo Take i First przestają pytać, gdy mają to, czego potrzebują. Natomiast ToList() albo Count() na nieskończonej sekwencji nigdy nie zwraca wyniku, podobnie jak wyliczanie OrderBy na niej, bo sortowanie potrzebuje najpierw wszystkich elementów.
Pułapki leniwych sekwencji
Sprawdzenia argumentów wykonują się późno. Całe ciało iteratora jest odroczone, łącznie z walidacją na początku. Rozwiązaniem jest podział metody: zwykła metoda sprawdza argumenty i zwraca wynik prywatnego iteratora.
Wynik:
LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call
Każde wyliczenie uruchamia metodę od nowa. Iterator niczego nie zapamiętuje. Dwukrotne przejście pętlą po Numbers() dwa razy wypisze "start", dwa razy przeczyta plik albo dwa razy wywoła usługę sieciową. IDE oznaczają to jako "possible multiple enumeration of IEnumerable". Wywołaj ToList() raz, gdy potrzebujesz elementów więcej niż raz.
finally wykonuje się, gdy konsument przestaje czytać. Jeśli kod wywołujący wcześniej wyjdzie z foreach, enumerator zostaje zwolniony i wykonuje się każdy blok finally w iteratorze; tak iterator czytający plik go zamyka:
Wynik:
open sensor
10
20
30
close sensor
done
Kilka zasad, które kompilator egzekwuje dla iteratorów: yield return nie może wystąpić w bloku try, który ma catch (try/finally jest w porządku), w lambdzie ani metodzie anonimowej, ani w metodzie z parametrami ref lub out.
Strumienie asynchroniczne
Dla sekwencji, których elementy przychodzą asynchronicznie (strony z API, wiersze z bazy danych), C# 8 i nowsze (z .NET Core 3.0 lub nowszym) łączą iteratory z await przez IAsyncEnumerable<T>:
static async IAsyncEnumerable<string> FetchPages()
{
for (int page = 1; page <= 3; page++)
{
await Task.Delay(100); // simulate a network call
yield return $"page {page}";
}
}
await foreach (string p in FetchPages())
{
Console.WriteLine(p);
}
Konsument używa await foreach, a na każdy element czeka w chwili, gdy zostaje wyprodukowany.
Typowe błędy
- Zwracanie
IEnumerable<T>z zapytania i wielokrotne odczytywanie go. Każdy odczyt uruchamia je ponownie; zwracaj zamiast tegoList<T>. - Walidacja argumentów w iteratorze. Sprawdzenie jest odroczone; podziel metodę na opakowanie i iterator.
- Wywołanie
Count()lubToList()na nieskończonym iteratorze. To się nigdy nie kończy; najpierw użyjTake. - Oczekiwanie, że parametr
IEnumerable<T>maCountalbo[i]. UżyjCount()z LINQ (które może wyliczać sekwencję) albo przyjmujIReadOnlyList<T>.
Najczęściej zadawane pytania
Czym jest IEnumerable w C#?
IEnumerable<T> to interfejs dla wszystkiego, po czym można przejść pętlą foreach. Ma jedną metodę, GetEnumerator(), która zwraca IEnumerator<T> z MoveNext() i Current. Implementują go tablice, listy, słowniki, zbiory, zapytania LINQ i metody iteratorów.
Czym różni się IEnumerable od List w C#?
IEnumerable<T> obiecuje tylko, że można iterować; nie ma właściwości Count, indeksera ani Add i może być obliczany leniwie przy każdym przejściu pętli. List<T> to konkretna kolekcja przechowywana w pamięci, która ma to wszystko. W parametrach przyjmuj IEnumerable<T>, gdy tylko iterujesz, a zwracaj List<T> lub IReadOnlyList<T>, gdy kod wywołujący potrzebuje indeksu albo liczby elementów.
Co robi yield return w C#?
yield return value; w metodzie zwracającej IEnumerable<T> przekazuje jeden element do kodu wywołującego i wstrzymuje metodę w tym miejscu. Gdy kod wywołujący poprosi o kolejny element, metoda wznawia działanie od następnej linii. Kompilator przepisuje metodę na maszynę stanów, więc piszesz pętlę, a dostajesz leniwą sekwencję.
Czym jest yield break w C#?
yield break; kończy sekwencję: foreach w kodzie wywołującym normalnie się kończy po elementach już wyprodukowanych. To odpowiednik return dla iteratorów i służy do wcześniejszego zatrzymania, na przykład po osiągnięciu limitu albo przy pustych danych wejściowych.
Dlaczego moja metoda iteratora nie rzuca wyjątku przy wywołaniu?
Ciało metody iteratora nie wykonuje się, dopóki sekwencja nie jest wyliczana, więc sprawdzenia argumentów na jej początku wykonują się przy pierwszym MoveNext, a nie przy wywołaniu. Aby walidować od razu, umieść sprawdzenia w zwykłej metodzie publicznej, która potem wywołuje prywatną metodę iteratora z instrukcjami yield.