Menu

IEnumerable i yield return w C#: leniwe sekwencje

IEnumerable<T> to interfejs stojący za foreach i LINQ, a yield return to najprostszy sposób, żeby go zaimplementować. Zobacz, jak IEnumerable ma się do ICollection i List, jak krok po kroku działa metoda iteratora, yield break, sekwencje nieskończone i pułapki leniwego wartościowania.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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:

TypDodajeTypowe użycie
IEnumerable<T>tylko foreachParametry, po których tylko iterujesz; wyniki LINQ; leniwe sekwencje
IReadOnlyCollection<T>CountZwracane wartości, których kod wywołujący nie powinien zmieniać
ICollection<T>Count, Add, Remove, ContainsKod 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, RemoveAtKod edytujący według pozycji
List<T>Konkretna klasa implementująca to wszystkoPrzechowywanie

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 tego List<T>.
  • Walidacja argumentów w iteratorze. Sprawdzenie jest odroczone; podziel metodę na opakowanie i iterator.
  • Wywołanie Count() lub ToList() na nieskończonym iteratorze. To się nigdy nie kończy; najpierw użyj Take.
  • Oczekiwanie, że parametr IEnumerable<T> ma Count albo [i]. Użyj Count() z LINQ (które może wyliczać sekwencję) albo przyjmuj IReadOnlyList<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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ