Pokud máme nějaký zdroj sekvenčních dat (např. DataReader s výsledky dotazu do databáze), nejčastěji ho zpracováváme postupným procházením po jednotlivých prvcích pomoci klíčového slova foreach nebo pomoci technologie LINQ. Občas se ale můžou vyskytnout nějaké o něco složitější scénáře, které by typicky vyžadovali přímé použití enumerátoru. Jak jsme si ale ukázali, kód provádějící toto přímé volání členů enumerátoru by nemusel být úplně jednoduchý nebo přehledný. Pro významné zjednodušení většiny z těchto scénářů by se nám proto mohla hodit pomocná třída EnumerableReader, kterou si představíme a ukážeme.
O co tedy jde? Třída EnumerableReader<TSource> je generická třída pracující nad libovolným IEnumerable<T> zdrojem, ke kterému pak zprostředkovává přístup v podobě readeru. Zdrojový kód třídy EnumerableReader je k dispozici zde. Po jeho stažení pak stačí třídu jen přidat do VS projektu.
Pro konstrukci instance této třídy nám bude sloužit extension metoda (*) GetEnumerableReader() dostupná přímo na IEnumerable<T>. Pozor ale, že pouhým vytvořením readeru se daný zdroj již otevírá (tj. volá se na něj první MoveNext()).
Deklarace této extension metody vypadá následovně:
internal static class EnumerableReaderExtensions
{
public static EnumerableReader<TSource> GetEnumerableReader<TSource>(this IEnumerable<TSource> source);
}
A deklarace třídy samotné je pak:
internal class EnumerableReader<TSource> : IDisposable
{
#region action methods
public TSource GetPeekIf(Func<TSource, bool> predicate);
public TResult GetPeekIf<TResult>(Func<TSource, bool> predicate, Func<TSource, TResult> resultSelector);
public TResult GetPeek<TResult>(Func<TSource, TResult> resultSelector);
public TSource ReadIf(Func<TSource, bool> predicate);
public TResult ReadIf<TResult>(Func<TSource, bool> predicate, Func<TSource, TResult> resultSelector);
public IEnumerable<TSource> ReadWhile(Func<TSource, bool> predicate);
public TSource SkipWhile(Func<TSource, bool> predicate);
public TResult SkipWhile<TResult>(Func<TSource, bool> predicate, Func<TSource, TResult> resultSelector);
public void Close();
#endregion
#region property getters/setters
public TSource Peek { get; }
#endregion
}
Třída obsahuje metodu Close() i implementuje interface IDIsposable, celé zpracování je tedy možné provádět v using bloku pro zajištění korektního uzavření “naspodu” používaného zdroje i v případě chyby nebo předčasného ukončení čtení (jinak se zdroj uzavírá automaticky při dosažení konce sekvence).
Základním konceptem třídy je možnost “nahlížení” (bez posunu ve zdrojové sekvenci) na aktuální prvek sekvence pomoci vlastnosti Peek. Pokud se reader nalézá již na konci sekvence, vrací Peek default(TSource). (Z toho sice například u některých datových typů nemusíme konec odlišit od “legální” hodnoty sekvence, ale k tomu slouží další prostředky jako metoda GetPeek(), uvidíme dále).
Třída obsahuje následující velmi užitečné metody:
- GetPeek() – Vrací výsledek aplikování funkce resultSelector na aktuální prvek sekvence nebo
default(TResult) po dosažení konce sekvence. To případně umožňuje odlišit konec sekvence od hodnoty default(TSource) v sekvenci.
- GetPeekIf() – Vrací aktuální prvek sekvence, ale jen pokud vyhovuje podmínce predicate. K dispozici jsou dvě varianty, bez a s funkcí resultSelector. Pokud aktuální prvek podmínce nevyhovuje nebo po dosažení konce sekvence vrací metoda default(TSource) resp. default(TResult).
- ReadIf() – Posune se ve vstupní sekvenci o jeden prvek, pokud se ještě nedosáhlo konce sekvence a pokud aktuální prvek vyhovuje podmínce predicate. K dispozici jsou opět dvě varianty bez a s funkcí resultSelector a metoda vrací hodnotu původního testovaného prvku sekvence (před posunem) v případě posunu ve vstupní sekvenci nebo default(TSource) resp. default(TResult) jinak (tedy návratová hodnota je stejná jako by byla u volání metody GetPeekIf(), které by předcházeno volání ReadIf()).
- ReadWhile() – Metoda vrací vnořený enumerátor pro čtení všech aktuálních prvků sekvence vyhovující podmínce predicate. Pokud aktuální prvek určené podmínce nevyhovuje nebo bylo již dosaženo konce vstupní sekvence, vrací se prázdný enumerátor.
- SkipWhile() – Provede posun ve vstupní sekvenci až na první prvek nevyhovující podmínce predicate nebo na konec vstupní sekvence. K dispozici jsou varianty bez a s funkcí resultSelector. Metoda vrací hodnotu nového aktuálního prvku sekvence nebo default(TSource) resp. default(TResult) při dosažení konce sekvence.
Možná to zatím a na první pohled nevypadá, ale přestože se jedná jen o pár metod, jsou navrženy opravdu chytře a umožňují i poměrně jednoduchým interface pokrýt celou řadu nejrůznějších scénářů. V příkladech použití samozřejmě nebude možné ukázat úplně vše, ale pro nějakou představu to snad stačit bude.
První scénář, na kterém si použití třídy EnumerableReader ukážeme je zatřídění jedné kolekce do kolekce druhé resp. v našem příkladu do výstupní sekvence vznikající až přímo kódem během vlastního zpracování (zde konkrétně for cyklem). Máme seřazenou sekvenci svátků a chceme vytvořit kolekci odpovídající všem dnům v nějakém měsíci (zde konkrétně květen), ale včetně informace, zda je daný den svátek.
Kód příkladu je následující:
var svatky = new[] { new DateTime(2011, 1, 1), new DateTime(2011, 4, 25), new DateTime(2011, 5, 1), new DateTime(2011, 5, 8), new DateTime(2011, 7, 5), new DateTime(2011, 7, 6),
new DateTime(2011, 9, 28), new DateTime(2011, 10, 28), new DateTime(2011, 11, 17), new DateTime(2011, 12, 24), new DateTime(2011, 12, 25), new DateTime(2011, 12, 26) };
DateTime datumOd = new DateTime(2011, 5, 1);
DateTime datumDo = datumOd.AddMonths(1).AddDays(-1);
var dict = new Dictionary<DateTime, bool>();
using (var svatkyReader = svatky.GetEnumerableReader())
{
for (DateTime datum = datumOd; datum <= datumDo; datum = datum.AddDays(1))
{
bool jeSvatek = svatkyReader.SkipWhile(sv => sv < datum, sv => sv == datum);
dict.Add(datum, jeSvatek);
}
}
foreach (DateTime sv in dict)
{
Console.WriteLine("{0:d} {1} svátek", sv.Key, sv.Value ? "je" : "není");
}
A výstup bude:
1.5.2011 je svátek
2.5.2011 není svátek
3.5.2011 není svátek
4.5.2011 není svátek
5.5.2011 není svátek
6.5.2011 není svátek
7.5.2011 není svátek
8.5.2011 je svátek
9.5.2011 není svátek
...
31.5.2011 není svátek
(Pozn.: Použití using bloku není v tomto konkrétním případě nutné, protože obyčejné pole nepotřebuje žádné uvolňování, ale typicky by se mohlo jednat například o objekt DataReader.
V této imperativní implementaci (**) bych zdůraznil a snad je to z ní i vidět, že tvorba výstupní sekvence dict je naprosto nezávislá na tom, jaké prvky obsahuje a kde se aktuálně nacházíme v sekvenci svatky, jedinou podmínkou je jen to, že sekvence svatky je na začátku seřazená. Odpovídající kód používající enumerátor napřímo by takto přehledný a jednoduchý rozhodně nebyl.
Druhý příklad bude také používat vstupní sekvenci svátků. Tentokrát budeme ale vytvářet výstupní pole, ve kterém bude pro každý měsíc vnořená kolekce svátku v daném měsíci. Pokud ale daný měsíc žádný svátek mít nebude, budeme chtít, aby prvek pole kolekci vůbec neobsahovat tj. měl pouze hodnotu null.
Kód druhého příkladu je následující:
var svatky = new[] { new DateTime(2011, 1, 1), new DateTime(2011, 4, 25), new DateTime(2011, 5, 1), new DateTime(2011, 5, 8), new DateTime(2011, 7, 5), new DateTime(2011, 7, 6),
new DateTime(2011, 9, 28), new DateTime(2011, 10, 28), new DateTime(2011, 11, 17), new DateTime(2011, 12, 24), new DateTime(2011, 12, 25), new DateTime(2011, 12, 26) };
var months = new HashSet<DateTime>[12];
using (var svatkyReader = svatky.GetEnumerableReader())
{
for (int i = 0; i < 12; i++)
{
//svatkyReader.SkipWhile(sv => sv.Month < i + 1); //V našem případě není potřeba
if (svatkyReader.GetPeek(sv => sv.Month == i + 1)) //Měsíc obsahuje alespoň jeden svátek
{
months[i] = new HashSet<DateTime>(svatkyReader.ReadWhile(sv => sv.Month == i + 1));
}
}
}
for (int i = 0; i < 12; i++)
{
Console.Write("{0}.", i + 1);
if (months[i] != null)
{
Console.WriteLine();
foreach (DateTime sv in months[i])
{
Console.WriteLine("\t{0:d}", sv);
}
}
else
{
Console.WriteLine("\t(žádné svátky)");
}
}
A opět výstup příkladu:
1.
1.1.2011
2. (žádné svátky)
3. (žádné svátky)
4.
25.4.2011
5.
1.5.2011
8.5.2011
6. (žádné svátky)
7.
5.7.2011
6.7.2011
8. (žádné svátky)
9.
28.9.2011
10.
28.10.2011
11.
17.11.2011
12.
24.12.2011
25.12.2011
26.12.2011
Kód je opět docela jednoduchý a přehledný. Upozornil bych ale na některé záležitosti:
- Funkčnost kódu využívá důležitého předpokladu, že vnitřní sekvence (svátky) neobsahuje data, pro která by neexistovat prvek ze sekvence vnější (měsíc). V opačném případě by se totiž v důsledku nesplnění podmínky sv.Month == i + 1 “párování” dat obou sekvencí zarazilo na prvním takovém prvku, kód by sice doběhl, ale generovaný výstup by byl vadný! Pro opravu by stačilo do kódu dodat ještě volání metody SkipWhile() (viz. “zaremovaný” řádek ve zdrojovém kódu příkladu).
- Naše použití metody GetPeek() s predikátem na místě funkce resultSelector. To je způsob jak můžeme jednoduše testovat zda aktuální prvek sekvence vyhovuje nějaké podmínce a zároveň korektně ošetřit i případ dosažení konce sekvence (kdy je také vráceno false).
- Vnější sekvence je čtena pomoci enumerátoru vráceného metodou ReadWhile(), v našem případě se tak konkrétně děje v konstruktoru kolekce HashSet<DateTime>.
(*) Důvodem je samozřejmě to, aby při konstrukci nebylo nutné uvádět generický argument tj. aby nám fungovala type inference.
(**) U takto jednoduchého příkladu bychom dokázali stejného výsledku dosáhnou i pomoci LINQ klauzule join s využitím sekvence generované pomoci Enumerable.Range(), je však otázkou zda by takové řešení bylo výhodnější.