Głębiej w IEnumerable i iteratory
Wstęp
Dla wielu programistów, interfejs IEnumerable
to po prostu interfejs implementujący jakąś metodę GetEnumerator
. Ale kryje się pod nim sporo więcej. W tym artykule postaram się opisać jak dokładnie działa IEnumerable
, czym jest enumerator i iterator. Kilka rzeczy sprawdziłem też w kodzie IL (intermediate language), żebyś Ty już nie musiał 😀
Po co nam IEnumerable?
Zacznijmy od początku. IEnumerable
jest nam potrzebne, żebyśmy mogli:
- stosować pętlę foreach
- stosować zapytania LINQ
W jaki sposób to działa? IEnumerable
zwraca enumerator. Czyli obiekt, który potrafi przeiterować się po jakiejś kolekcji.
Czym jest enumerator?
Enumerator to, krótko mówiąc, klasa, która posiada dostęp do jakiejś kolekcji danych (np. w postaci tablicy) i potrafi po tych danych przejść od początku do końca. Enumerator musi zatem mieć następujące metody:
MoveNext()
– ustawia się na następnym elemencie w kolekcjiReset()
– resetuje „licznik” – czyli przechodzi na początek kolekcji
a także właściwość Current
, która zwraca aktualnie WSKAZYWANY element. Najprostszy enumerator mógłby wyglądać tak:
class SimpleEnumerator : IEnumerator
{
private readonly int[] _data;
private int _index;
public object Current
{
get
{
if (_index < 0 || _index >= _data.Length)
throw new IndexOutOfRangeException();
return _data[_index];
}
}
public SimpleEnumerator(int[] data)
{
_data = data;
Reset();
}
public bool MoveNext()
{
_index++;
return _index < _data.Length;
}
public void Reset()
{
_index = -1;
}
}
Kod jest na tyle prosty, że nie wymaga za bardzo omówienia. Właściwość Current
, a także metody MoveNext
i Reset
pochodzą z interfejsu IEnumerator
.
To co Cię może zadziwić na pierwszy rzut oka, to to że metoda Reset
ustawia licznik na -1, zamiast na zero. Ale to jest dobra praktyka i tego się powinniśmy trzymać. Dlaczego? Bo z enumeratorem pracuje się tak:
int[] data = new int[] { 1, 2, 3, 4, 5 };
SimpleEnumerator enumerator = new SimpleEnumerator(data);
while(enumerator.MoveNext())
{
Console.WriteLine(enumerator.Current);
}
Naturalnym dla niego jest właśnie iteracja za pomocą pętli while
. Metoda MoveNext
zwraca true
, jeśli jesteśmy ustawieni na faktycznym elemencie lub false
, jeśli jesteśmy już poza elementem (indeks jest poza zakresem). Dzięki temu pętla while
wie, kiedy się zakończyć.
Teraz zwróć uwagę, że gdyby metoda Reset
ustawiała index na 0, wtedy nigdy przy takim kodzie nie otrzymamy pierwszego elementu.
Generyczny Enumerator
Jak już pewnie zauważyłeś, ten podstawowy IEnumerator
operuje na typie object
. Dlatego też powstał enumerator generyczny, który może pracować z każdym typem: IEnumerator<T>
. W związku z tym, że to może być dowolny typ, IEnumerator<T>
, poza interfejsem IEnumerator
, implementuje także interfejs IDisposable
. Więc nasz enumerator teraz mógłby wyglądać tak:
class IntEnumerator : IEnumerator<int>
{
private bool disposedValue;
private readonly int[] _data;
private int _index;
public IntEnumerator(int[] data)
{
_data = data;
Reset();
}
public int Current
{
get
{
if (_index < 0 || _index >= _data.Length)
throw new IndexOutOfRangeException();
return _data[_index];
}
}
object IEnumerator.Current => Current;
public bool MoveNext()
{
_index++;
return _index < _data.Length;
}
public void Reset()
{
_index = -1;
}
//dalej jest standardowa implementacja IDisposable
}
Używamy go dokładnie tak samo jak tego niegenerycznego.
Ok, ja Ci tu gadam o enumeratorach, a jak to się właściwie ma do tego foreach
, o którym pisałem na początku?
Jeśli w powyższym przypadku zamiast tablicy, użyjemy listy (albo innej kolekcji – byle nie tablicy) i zrobimy coś takiego:
static void Main(string[] args)
{
int[] data = new int[] { 1, 2, 3, 4, 5 };
List<int> list = new List<int>(data);
foreach(var i in list)
{
Console.WriteLine(i);
}
}
to kod, jaki zostanie wygenerowany przez kompilator w (IL
– intermediate language) będzie wyglądał po przetłumaczeniu na „ludzki” tak (instrukcje w stylu goto
zamieniłem na pętlę):
IEnumerator<int> enumerator = list.GetEnumerator();
while(enumerator.MoveNext())
{
int value = enumerator.Current;
Console.WriteLine(value);
}
Co z tego wynika? Jeśli posługujemy się pętlą foreach
w naszym programie, to ostatecznie wyjdzie z tego pętla, która posługuje się enumeratorem.
Trochę inaczej to wygląda, gdy zamiast listy damy zwykłą tablicę. Wtedy wygenerowany kod będzie zoptymalizowany w taki sposób, że nie będzie posługiwał się enumeratorem.
To teraz pytanie, co z tym GetEnumerator
?
GetEnumerator
Jak już wiesz, GetEnumerator
pochodzi z interfejsu IEnumerable
. Czyli IEnumerable
jest niejako takim pudełkiem na enumerator. Jednakże IEnumerable
jest tylko dla naszej wygody, ponieważ foreach
możesz zrobić po JAKIEJKOLWIEK klasie, która posiada metodę GetEnumerator
i zwraca w niej IEnumerator
, na przykład – po takiej:
class MyIntData
{
private int[] _data;
public MyIntData(int[] data)
{
_data = data;
}
public IEnumerator<int> GetEnumerator()
{
return new IntEnumerator(_data);
}
}
Zobacz, że klasa MyIntData
nie implementuje interfejsu IEnumerable
, ale posiada metodę GetEnumerator
, w której zwraca enumeratora stworzonego wcześniej w tym artykule:
int[] data = new int[] { 1, 2, 3, 4, 5 };
MyIntData myInts = new MyIntData(data);
foreach(var i in myInts)
{
Console.WriteLine(i);
}
Kod wygenerowany przez kompilator będzie dokładnie taki sam (jeśli chodzi o pętlę foreach), jak w przypadku poprzedniego listingu.
Oczywiście tworzenie klasy w taki sposób jest trochę wbrew intuicji i dobrym praktykom. Taka klasa powinna jednak implementować IEnumerable
lub jego generyczną wersję. Co by nie mówić, wiele klas w .NET i w innych bibliotekach spodziewa się właśnie interfejsu IEnumerable
w swoich metodach.
Zazwyczaj nie powinieneś też musieć tworzyć własnego enumeratora. Jeśli jednak z jakiegoś powodu musisz, jest prostsza metoda…
Czym jest iterator?
Mógłbym powiedzieć, że iterator to w C# cukier składniowy. Spójrz na nieco zmieniony kod naszej klasy:
class MyIntData
{
private int[] _data;
public MyIntData(int[] data)
{
_data = data;
}
public IEnumerator<int> GetEnumerator()
{
for(int i = 0; i < _data.Length; i++)
yield return _data[i];
}
}
Kompilator trafiając na taki kod, stworzy wewnętrzną klasę dla takiego enumeratora. Wewnętrzną w sensie zagnieżdżoną w MyIntData. Wyposaży ją we wszystkie potrzebne rzeczy, jednak metoda Reset()
rzuca wyjątek NotSupportedException
.
Generalnie to będzie nieco inny enumerator niż ten, co stworzyliśmy sami. Działa na zasadzie maszyny stanów. Kod nieco bardziej skomplikowany (od strony IL), ale też bez przesady.
Co to jest to yield?
Zapamiętaj, że w C# samo yield
nie istnieje. Patrząc wysokopoziomowo, mamy yield return
, które zwraca element kolekcji, a potem wraca do tego samego momentu, a także opcjonalne yield break
, żeby zaznaczyć, że nie ma już więcej elementów.
W tym momencie powinieneś już wiedzieć, jaka jest rola enumeratora, iteratora i interfejsu IEnumerable
. Ale są jeszcze dwie rzeczy o IEnumerable
, o których powinieneś wiedzieć…
IEnumerable nie daje wyników od razu
Innymi słowy – jest odroczone (deferred). Niejednemu programiście (a i mi osobiście też) spędziło to sen z powiek. Spójrz na ten kod:
List<int> list = new List<int> { 1, 2, 3};
var lessThan3 = list.Where(x => x < 3);
list.Add(0);
foreach(var x in lessThan3)
{
Console.WriteLine(x);
}
Najpierw tworzymy jakąś listę z cyframi 1, 2 i 3. Następnie pobieramy sobie do jakiejś zmiennej cyfry mniejsze od 3 – czyli 1 i 2. Na koniec dodajemy do głównej listy cyfrę 0.
Jaki będzie wynik?
UWAGA! Analogiczne zadanie pojawia się na rozmowach o pracę 🙂
Konsola wypisze: 1, 2, 0.
Dlaczego to zero, skoro pobraliśmy cyfry mniejsze od 3, gdy nie było zera na liście? Odpowiedź jest prosta – nie pobraliśmy.
IEnumerable
ma tzw. odroczone wykonanie (deferred execution). To oznacza, że enumerator zostanie uruchomiony dopiero wtedy, kiedy potrzebujemy konkretnych danych. W tym kodzie powyżej tych danych potrzebujemy dopiero w pętli foreach. Ale jeśli byśmy zrobili tak:
var lessThan3 = list.Where(x => x < 3).ToList();
czyli na koniec warunku zmieniamy wszystko na listę, to wtedy odczytujemy te dane od razu – potrzebujemy ich w tym momencie. Już w tym momencie enumerator jest uruchamiany.
Dlaczego tak jest? To jest czysta optymalizacja. Weź pod uwagę taki przykładowy kod:
List<int> list = new List<int> { 1, 2, 3};
var data = list.Where(x => x < 3);
data = data.Where(x => x % 2 == 0);
data = data.Select(x => x + 1);
Tutaj iterator nie ruszy do roboty ani razu. Dzięki czemu możemy sobie tworzyć takie różne warunki nie martwiąc się o optymalizację. Każda z tych metod odda po prostu interfejs IEnumerable
.
Enumerator ruszy do pracy dopiero gdy:
- sprawdzimy ilość elementów w kolekcji (rozszerzenie
Count()
) - przekonwertujemy
IEnumerable
na jakąś konkretną kolekcję - wywołamy metodę, która zwraca konkretne dane (np.
Single
,First
,Average
itd – one wymagają iterowania po elementach) - będziemy po tym jawnie iterować
Wbij to sobie do głowy, bo łatwo o tym zapomnieć, a potem na twarzy pojawia się wielkie zdziwienie.
W związku z tym, jest jeszcze jedna ciekawa rzecz dotycząca IEnumerable
, o której się otwarcie nie mówi…
Kiedy IEnumerable Ci zaszkodzi
Tutaj muszę powiedzieć, że zainspirowałem się filmem Nicka Chapsasa.
Załóżmy, że mamy jakąś klasę, która pobiera dane z jakiejś bazy:
class DataReader()
{
public static int[] ReadData()
{
Thread.Sleep(1000); //symulacja długotrwałej operacji
return new int[] { 1, 2, 3, 4, 5 };
}
}
I teraz chcemy te dane wyświetlić:
static void Main(string[] args)
{
foreach(var item in GetData())
{
Console.WriteLine(item);
}
}
static IEnumerable<int> GetData()
{
var data = DataReader.ReadData();
foreach(var d in data)
{
yield return d;
}
}
Spójrz, co się stanie. Pętla foreach
będzie chciała enumeratora. Zostanie on stworzony za pomocą yield return
. Ale przed jego stworzeniem, trzeba będzie pobrać dane. No i super. W efekcie pobierzemy dane, a potem będziemy je oddawać jeden element po drugim. Dane zostaną pobrane tylko raz.
Ale jeśli coś więcej będziesz chciał zrobić z tymi danymi… chociażby zobaczyć, ile ich jest:
var data = GetData();
int count = data.Count();
foreach(var item in data)
{
Console.WriteLine(item);
}
enumerator zostanie wywołany już dwa razy – dwa razy będziemy pobierać dane z bazy.
Pierwsze wywołanie to będzie rozszerzenie Count
– aby policzyć ilość elementów w IEnumerable
, to trzeba przeiterować się po wszystkich.
Drugie wywołanie będzie przez pętlę – pętla będzie chciała po prostu poznać wszystkie elementy.
To znaczy, że możesz w taki sposób zniszczyć performance. W pewnych sytuacjach można to odczuć. Sprawdź takie miejsca w swoich programach.
Różnica między IEnumerable i IQueryable
Czuję, że żeby artykuł był pełny, muszę jeszcze nadmienić o tym IQueryable
.
IQueryable
też ma odłożone wykonanie – podobnie jak IEnumerable
. Różnica polega na tym, że IQueryable
podczas swojego wykonania (czyli faktycznego wywołania enumeratora) wysyła do bazy danych konkretne zapytanie. Ze wszystkimi już filtrami. Wcześniej możesz sobie te filtry budować, np. za pomocą metody Where – dokładnie tak samo jak w IEnumerable
.
Różnica jest po stronie wykonania. IEnumerable
całe filtrowanie robi w pamięci, natomiast IQueryable
wysyła gotowe zapytanie SQL do serwera i to serwer zwraca już przefiltrowane dane.
To tyle. Mam nadzieję, że teraz Twoja wiedza o iteratorach, enumeratorach, a zwłaszcza o IEnumerable
jest pełniejsza 🙂
Jeśli znalazłeś w artykule jakiś błąd albo czegoś nie rozumiesz, koniecznie daj znać w komentarzu.
No i podziel się artykułem z osobami, którym uważasz, że się przyda 🙂