Używając pętli foreach mogę przez iteracje uzyskać dostęp do każdego pojedynczego elementu w kolekcji. Co się dzieje w środku pętli i jak to jest możliwe. Ta tematyka jest istotna, gdy chcesz stworzyć swoją własną kolekcje i przekonując się ,że mechanizm pętli foreach nie zostanie dodany automatycznie.
W C# istnieje kilka sposobów na zautomatyzowanej tego procesu.
Wyliczanie elementów z kolekcji
Oto prosty przykład użycia pętli foreach.
int[] numbers = { 1, 2, 3, 4, 5 };
foreach (int number in numbers)
{
Console.WriteLine(number);
}
Pętla foreach umożliwia elegancki mechanizm, który bardzo ułatwia kod ,ale działa ona pod pewnymi warunkami. Możesz użyć pętli foreach tylko w kolekcjach enumerable. Czy jest kolekcja wyliczeniowa enumerable? Jest to kolekcja, która implementuje interfejs System.Collection.IEnumerable.
Wszystkie tablice pochodzą od klasy System.Array ,a ta klasa implementuje ten interfejs, więc wyrażenie foreach jest możliwe.
Interfejs IEnumerable posiada jedną metodę GetEnumerator. Spraw jedna nie jest taka prosta.
IEnumerator GetEnumerator();
Metoda GetEnumerator powinna zwrócić iterowany obiekt, który implementuje interfejs System.Collection.IEnumerator.
Dla łatwiejszego zrozumienia jak można przetłumaczyć słowo Enumerator na polski. Na pewno nie jest to “Rachmistrz”. Co oznacz te słowo po angielsku w ogóle.
Czyli Enumerator oznacza proces wyświetlenia elementu po elemencie w kolekcji. Obiekt enumerator jest używany do przejścia pomiędzy elementami kolekcji. Interfejs IEnumerator ma następujące właściwości i metody.
IEnumerator GetEnumerator();
public interface IEnumerator { object Current { get; } bool MoveNext(); void Reset(); }
Pomyśl o enumerator jako o wskaźniku, który wskazuje elementy z listy. Początkowo wskaźnik jest umieszczony przed pierwszym elementem (ma to sens nie każda kolekcja ma chociaż 1 element). Wywoływana potem jest metoda MoveNext(), która przesuwa ten wskaźnik w dół do następnego elementu (pierwszego elementu) w liście Metoda MoveNext() zwróci true jeśli istnieje następny element jeśli tak nie jest zwróci false. Właściwość Current służy do uzyskania dostępu do obecnego elementu, który jest wskazany przez wskaźnik. Metoda Reset() służy do ustawienie wskaźnika ponownie przed pierwszym element.
Tworząc enumerator poprzez użycie metody GetEnumerator w kolekcji i używając metody MoveNext() i otrzymując element za pomocą właściwości Current poprzez użycie enumeratora możesz poruszać się po jednym elemencie w elementach w kolekcji.
Jeśli chcesz stworzyć swoją własną kolekcje wyliczeniową enumerable musi najpierw zaimplementować interfejs IEnumerable w swojej kolekcji ,a potem zaimplementować interfejs IEnumerator tak aby on mógłby być zwracany w metodzie GetEnumerator w klasie kolekcji.
Powyżej dodałem ilustracje by zobaczyć różnicę pomiędzy interfejsami IEnumerable i IEnumerator . Zwłaszcza że oba interfejsy brzmią podobnie.
We wpisie omawiając jak tworzyć klasy generyczne zwróciłem uwagę ,że niektóre interfejsy w metodach operują na typie object. W tym wypadku jest podobnie interfejsy IEnumerator i IEnumerable operują na typie object co tworzy problem z bezpieczeństwem typów. Na szczęście w .NET do dyspozycji są dostępne interfejsy IEnumerator i IEnumerable od T. Czyli przykładowo interfejs IEnumerator zwraca typ T zamiast object. Wersje tych interfejsów od T istnieje od .NET 2.0, więc jeśli nie piszesz aplikacji w .NET 1.0 nie ma powodu by ich nie użyć.
Interfejs IEnumerator od T ma kilka różnic od interfejsu IEnumerator. Nie zawiera on metody Reset i rozszerza on interfejs IDisposable i posiada metodę Dispose().
Implementowanie interfejsu IEnumerator .
Wracając do kolekcji drzewa binarnego która została utworzona w tym wpisie. Dodam do niej implementacje generycznego interfejsów IEnumerator<T>. Sprawa wydaje się prosta w końcu klasa ta ma już metodę “WalkTree” która wyświetla zawartość drzewa teraz wystarczy wykonać to samo dla pętli foreach. Jednak problem jest bardziej złożony, Określając enumerator-a musisz pamiętać gdzie jesteś w tej strukturze tak aby wywołanie metody MoveNext() było możliwe. Rekursywny algorytm, który został użyty wcześniej do chodzenia po drzewie nie pozwala na określenie stanu miejsca pomiędzy wywoływaniami metod w prosty sposób. Dla tego najpierw informacje drzewa binarnego musi być przerobiona na inną strukturę danych jak np. kolejka i po niej wykonać enumeracje . Oczywiście, ponieważ ta struktura będzie prywatna ten mechanizm iterujący będzie nie widoczny dla użytkownika tej klasy generycznej.
Do projektu BinaryTree trzeba dodać klasę służącą za enumerator dla klasy Tree. Nazwałem plik i klasę “TreeEnumerator.cs”.
Projekt drzewa binarnego zaczyna wyglądać obiecująco.
Klasa TreeEnumerator będzie generować enumerator dla obiektu Tree(TItem). Aby mieć pewność ,że klasa jest bezpieczna typowo muszę zaimplementować interfejs IEnumerator(T). Typ parametru musi być zgodny z obiektem Tree(TItem), przez którą ta klasa będzie enumerować, czyli wyliczać. Typ elementu TItem musi też implementować interfejs IComparable(TItem).
Jak wygląda deklaracja klasy TreeEnumerator. Modyfikator dostępu internal określa ,że klasa TreeEnumerator jest dostępna tylko wokół biblioteki.
internal class TreeEnumerator<TItem> :
IEnumerator<TItem> where TItem :IComparable<TItem>
{
}
Do klasy trzeba dodać pewne prywatne zmienne, które będą potrzebne do zmiany działania drzewa binarnego.
internal class TreeEnumerator<TItem> :
IEnumerator<TItem> where TItem :IComparable<TItem>
{
private Tree<TItem> currentData = null;
private TItem currentItem = default(TItem);
private Queue<TItem> enumData = null;
}
Zmienna currentData reprezentuje referencje do drzewa binarnego, które będzie enumerowane. CurrentItem będzie trzymać wartość zwracaną przez właściwość Current. Potem wypełnię kolejkę enumData wartościami otrzymaną z węzłów z drzewa binarnego i metoda MoveNext zwróci każdy element z tej kolejki po w swojej turze.
Słowo kluczowe default w klasach generycznych w dużym skrócie pilnuje aby w miejscu tego pola pojawiła się wartość domyślna typu TItem bez względu na to, czy TItem jest typem referencyjny, czy wartościowy. Omówię te słowo kluczowe później.
Mam już pola ,ale jak one się inicjalizują trzeba stworzyć konstruktor do tej klasy. Do działanie tej klasy będzie potrzebne drzewo binarne więc niech klasa pobierze go w konstruktorze.
public TreeEnumerator(Tree<TItem> data)
{
this.currentData = data;
}
Do klasy potrzebna jest metoda, która by zmieniła drzewo binarne, na której kolejkę użyjemy później.
private void populate(Queue<TItem> enumQueue,Tree<TItem> tree)
{
if (tree.LeftTree != null)
{
populate(enumData, tree.LeftTree);
}
enumQueue.Enqueue(tree.NodeData);
if (tree.RightTree != null)
{
populate(enumQueue, tree.RightTree);
}
}
Metoda ta chodzi po binarnym drzewie i dodaje dane do kolejki w stylu algorytmu, który był użyty już wcześniej.
Czas na implementacje interfejsu. Podobnie jak robią to inne klasy w .NET interfejs IEnumerator trzeba zaimplantować explicity (jawnie).
Używając małej pomocy z Visual Studio 2010 interfejs łatwo zbudować w ten sposób.
Jak widać wygenerowała się też metoda dispose() z interfejsu IDisposable, który jest implementowany w interfejsie IEnumerator.
TItem IEnumerator<TItem>.Current
{
get { throw new NotImplementedException(); }
}
void IDisposable.Dispose()
{
throw new NotImplementedException();
}
object System.Collections.IEnumerator.Current
{
get { throw new NotImplementedException(); }
}
bool System.Collections.IEnumerator.MoveNext()
{
throw new NotImplementedException();
}
void System.Collections.IEnumerator.Reset()
{
throw new NotImplementedException();
}
Małymi kroczkami trzeba zastąpić ciała tych metod użytecznym kodem. Zaczniemy od metody MoveNext().
bool System.Collections.IEnumerator.MoveNext()
{
if (this.enumData == null)
{
this.enumData = new Queue<TItem>();
populate(this.enumData, this.currentData);
}
if (this.enumData.Count > 0)
{
this.currentItem = this.enumData.Dequeue();
return true;
}
return false;
}
Celem metod MoveNext() jest wykonanie enumeracji . Na początku tego procesu jest tworzona nowa kolejka, która uzupełniania wyniku działania metody populate().
Później, jeśli w tej kolejce są jakieś informacje są one z niej wyrzucane do momentu ,aż kolejka stanie pusta. Warto pamiętać ,że ta metoda nie zwraca elementu do tego celu jest właściwość Current. Celem tej metody jest odświeżanie wartości obecnego elementu w wyniku enumeracji.
Jeśli kolejka ma elementy metoda ta zwraca true ,a w przeciwnym wypadku false co jest sygnałem dla innych ,że proces enumeracji się skończył.
Przejdźmy teraz do właściwości Current.
TItem IEnumerator<TItem>.Current
{
get {
if (this.enumData == null)
throw new InvalidOperationException
("Use MoveNext before calling Current");
return this.currentItem;
}
}
Właściwość Current najpierw prześwietla kolejkę enumData, jeśli jest ona pusta znaczy to ,że metoda MoveNext() nie została jak do tej pory wywołana ,a tak nie powinno być. Wyjątek InvalidOperationException w .NET informuje ,że dana operacja nie może być wykona w danym stanie. Jeżeli MoveNext() zostało wywołane przed pobraniem właściwości Current jedynie co trzeba zrobić to zwrócić pole prywatne currentItem które przetrzymuje informacje o obecnym elemencie.
To tyle, jeśli chodzi o ten interfejs i o tą klasę.
Metoda Dispose nie musi być zaimplementowana, ponieważ nie ma tutaj zasobów, które wymagałyby czyszczenia. Jednak musi ona być i ponieważ zostaje ona wywołana pod koniec pętli foreach lepiej za komentować wyjątek.
void IDisposable.Dispose()
{
//throw new NotImplementedException();
}
Faktycznie są jeszcze inne metody, które trzeba zaimplementować ,ale skoncentrujmy się na głównym zadaniu.
Implementowanie interfejsu IEnumerable
Do działania pętli forach w klasie Tree brakuje powiązania pomiędzy klasą Tree ,a TreeEnumerator. Czas zaimplementować w klasie Tree interfejs IEnumerable od T.
Metoda GetEnumerator zwróci obiekt TreeEnumerator.
Do deklaracji klasy tree trzeba dodać implementacje interfejsu IEnumerable<TItem>.
public class Tree<TItem> :
IEnumerable<TItem> where TItem: IComparable<TItem>
Po raz kolejny implementuje interfejs w stylu jawny (explicity).
IEnumerator<TItem> IEnumerable<TItem>.GetEnumerator()
{
throw new NotImplementedException();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
throw new NotImplementedException();
}
Do kodu dodaje się też wersja niegeneryczna metody GetEnumerator . Dzieje się tak, ponieważ generyczny interfejs IEnumerable(TItem) dziedziczy po IEnumerable.
Do metody IEnumerable(TItem) musimy dodać następujący kod.
IEnumerator<TItem> IEnumerable<TItem>.GetEnumerator()
{
return new TreeEnumerator<TItem>(this);
}
Drugą metoda, jeśli nie mylę wymaga napisania inne nie generycznej wersji klasy TreeEnumerator dlatego pominę ten proces.
Testowanie
Wracając do aplikacji konsolowej która sprawdza działanie drzewa binarnego. Wystarczy dodać do metody Main niewielki fragment kodu ,aby się przekonać ,że implementacja tych dwóch interfejsów działa dobrze.
Poniższy kod dodaje liczby double do drzewa ,a później dzięki w pętli foreach zostaje wyświetlony wynik w konsoli.
Tree<double> tree4 = new Tree<double>(double.NaN);
tree4.Insert(3.14);
tree4.Insert(5.555);
tree4.Insert(0.1);
tree4.Insert(-12.12);
tree4.Insert(15.15);
tree4.Insert(0);
tree4.Insert(double.PositiveInfinity);
tree4.Insert(double.NegativeInfinity);
tree4.Insert(double.MaxValue);
tree4.Insert(double.MinValue);
tree4.Insert(-8.16);
foreach (double item in tree4)
{
Console.Write(" {0} ", item);
}
Console.ReadKey();
Wynik działania pętli jest następujący. Przy okazji ten kod uświadomił mi ,że wartość double.Nan symbolizująca wartość liczbową, która nie jest liczbą jest traktowana jako mniejsza wartość niż minus nieskończoność. Jeśli ktoś o to mnie zapyta będę wiedział.
Proces tworzenie enumeracji nie kończy się tutaj. Trzeba jeszcze omówić parę zagadnień w tym słowo kluczowe yield.