Programování II KI/PGL2
Jiří Fišer Ústí nad Labem 2013
Obor: Klíčová slova:
Informační systémy, Matematická informatika objektově orientované programování, objekt, třída, metoda, kolekce, C#
Anotace:
Tento materiál presentuje základní pojmy objektově orientovaného programování na pozadí programovacího jazyka C#. V souladu s tím jsou zahrnuty i základní konstrukce procedurálního paradigmatu stejně jako elementární datové typy (čísla a řetězce) včteně základních kolekcí (seznamů). Vše tak směřuje k hlavnímu cíli — vytváření vlastních tříd. Složitější partie jsou navíc doplněny praktickými a detailně komentovanými příklady.
Projekt „Univerzitní centrum podpory pro studenty se specifickými vzdělávacími potřebami“ Registrační číslo projektu: CZ.1.07/2.2.00/29.0023 Tento projekt byl podpořen z Evropského sociálního fondu a státního rozpočtu České republiky.
©
UCP UJEP v Ústí nad Labem, 2013
Autor:
Mgr. Jiří Fišer, Ph.D.
Obsah Úvod
6
Rychlý náhled studijní opory
7
Typografické konvence
8
I
Slovník (kolekce)
10
Úvod do problematiky
11
Základní operace . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 Využití slovníku . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 Procházení seznamu . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 Shrnutí
24
Otázky a úkoly
26
II
27
Polymorfismus a sdílená podrozhraní (interface)
Úvod do problematiky
28
Třídní orientace a její omezení ve staticky typovaných jazycích . . . . . . . . . 28 Polymorfismus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 Sdílená rozhraní . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 Základní knihovní rozhraní . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42 Ukázkové programy
56
Peníze . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56 Shrnutí
76
Otázky a úkoly
78
III
79
Dědičnost
Úvod do problematiky
80
Specializace a generalizace . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80 Dědičnost v OOP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84 Abstraktní třídy . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92 Dědičnost v C# . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95 Dědičnost metod a vlastností . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97 Dědičnost a rozhraní . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101 Univerzální bázová třída . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 104 4
Shrnutí
107
Otázky a úkoly
110
IV
Přílohy
111
5
Úvod Opora Programování II organicky navazuje na oporu Programování I. Zatímco první opora byla úvodem do objektového programování, tj. zaměřovala se na základní principy tohoto paradigmatu (např. pojem třídy a objektu), pak její pokračování zavádí komplexnější principy, jako je objektový polymorfismus a dědičnost. I když lze objektový polymorfismus a dědičnost relativně jednoduše definovat, je praktické využití a celkový dopad těchto principů o mnoho složitější. Texty pro začátečníky proto často problematiku zjednodušují tak, že jsou nejen v rozporu s praxí, ale dokonce narušují teoretická východiska obou principů. Ve skutečnosti je problematika polymorfismu a dědičnosti tak mnohovrstvá, že ji plně pochopíte až po několika letech programátorské praxe. Kromě základních principů ji totiž ovlivňují i idiomy programovacího jazyka resp. objektových knihoven, resp. používání tzv. návrhových vzorů. Úvodní text Vám tak může zprostředkovat jen nezbytné teoretické zázemí, pohled na úrovni modelování a několik praktických (i když samozřejmě zjednodušených) příkladů. Mohu alespoň doufat, že vás nezavede na slepou kolej. Situaci bohužel komplikuje jazyk C#. I když je jeho model v oblasti polymorfismu a dědičnosti blízký ostatním OOP jazyků (především Javě), patří bohužel mezi ty složitější. Některé složitější principy a jazykové konstrukce lze naštěstí ignorovat (a to nejen v tomto textu, ale i ve většině projektů), bohužel však nikoliv všechny. Patří mezi ně vztah mezi generikami a rozhraními (některé klíčová rozhraní tak mají dvě podoby) a mechanismus tzv. virtuálních metod. Dalším problémem jsou praktické příklady. I relativně jednoduché praktické příklady související s dědičností a polymorfismem mají stovky řádků, pokud jsou vytvářeny tzv. na zelené louce (angl. from scratch). Použití existujících tříd naráží na jiný problém — komplexnost těchto tříd (např. GUI prvků nebo objektů síťové komunikace). Jen jejich popis by si vyžádal desítky stran. Proto se snažím využívat především třídy kolekcí, z nichž jedna byla zavedena v první opoře (seznam) a druhá je doplněna na začátku těchto skript (slovník). I tak je podíl rozsáhlejších praktických příkladů oproti první opoře menší. Ještě důležitější roli tak hraje cvičebnice mého kolegy Petra Kubery, v níž je obsaženo nejen mnohem více komentovaných příkladů včetně praktických postupů (včetně editace s využitím vestavěné inteligence současných editorů). Na závěr úvodu několik technických informací. Všechny příklady jsou přeložitelné prostřednictvím libovolného překladače jazyka C# ve verzi alespoň 3.0. Zdrojové kódy souvislejších ukázek (označených jménem souboru na prvním řádku vpravo) lze (pokud je již nemáte k dispozici) získat z mých WWW stránek (www.jf.cz/opory).
6
Rychlý náhled studijní opory Tato opora vychází z přednášek kursu Programování II, což je druhá část úvodního kursu programování. Úvodní kurs nepředpokládá žádné předběžné znalosti v oblasti programování resp. v jiném specializovaném informatickém oboru (i když zájem o počítačové technologie je samozřejmě výhodou). Opora pokrývá tyto oblasti • slovník • objektový polymorfismus • sdílená rozhraní • dědičnost Nedílnou součástí opory jsou i komentované praktické příklady.
7
Typografické konvence Některé části textu jsou zvýrazněny nebo jsou dokonce opatřeny ikonkami v levém kraji textu. Je důležité znát význam těchto grafických prvků, a proto si je v krátkosti popíšeme. Tento box s ikonkou specifikuje praktické cíle, které byste měli dosáhnout na konci jednotlivých kapitol. Nestačí však pouze přečtění, často je nutné použít dodatečné zdroje a hlavně zapojit mozek a to nejen při řešení úloh uvedených na konci kapitol. Tyto úlohy jsou dvou druhů. Odpověď na jednoduché úlohy naleznete přímo v textu jednotlivých kapitol (i když někdy musíte trochu déle hledat). Problémové úlohy vyžadují více přemýšlení a hledání informací na Internetu (ve většině případů však postačuje anglická Wikipedie). Problémové úlohy jsou označeny speciální ikonou a vždy obsahují malou nápovědu. Úkol .1: Problémová úloha (úkol) Nápověda: Zde vždy najdete nápovědu
Cíle jsou na začátku kapitol doprovázeny i tzv. klíčovými slovy. Klíčová slova, tj. pojmy, jimž je daná kapitola věnována. Pravidlo: Pravidla stručně shrnují doporučení, jimiž byste se měli při programování řídit. Jedná se skutečně o doporučení, takže nejsou povinná. Navíc jsou určena pro začátečníky. Až se stanete skutečně dobrými programátory, tak zjistíte, že mnohé z nich neplatí zcela obecně. Definice nových pojmů je zvýrazněna modrozelenou ikonkou a vlastní pojem je podtržen červenou čárou. Tyto pojmy jsou využívány v dalším textu a jsou nezbytně nutné i v komunikaci mezi programátory a ostatními informatickými odborníky. U většiny je uveden i anglický překlad podtržený modře – english terms. Ukázkové programy resp. jejich rozsáhlejší fragmenty jsou orámovány dvojitou čárou: Console.WriteLine("Hello␣world"); / / fragment programu
Programy používají zvýraznění syntaxe, tj. například klíčová slova jsou uvedena červeně a poznámky zeleně. Speciální ikony jsou použity u ukázek, které (schválně) obsahují nějakou chybu. Dopravní značka cyklisty s trojúhelníkovými koly (nově vytvořená) je použita u kódů se syntaktickou chybou. Tento kód se Vám nepodaří ani přeložit, tj. příslušný program se Vám ani nepodaří spustit (= stejně jako cyklista se ani nerozjede) string s = 2 * "pes";
8
Reálná dopravní značka Nehoda symbolizuje chyby, které se projeví až za běhu a to předčasným ukončením programu. Program je ukončen tzv. výjimkou a pokud máte štěstí, pak se Vám někde objeví i vysvětlující text (v angličtině). Tyto chyby se identifikují obtížněji než syntaktické, neboť k chybě nemusí dojít při každém spuštění programu. int x = 1 / 0;
Značka Zatáčka vpravo je použita pro označení kódu, který se přeloží a bez problémů proběhne, avšak výsledek Vás nepříjemně překvapí. Buď se nic nezmění, nebo získáte chybný či nedefinovaný výsledek (nedefinovaný = někdy dobrý, ale jindy naopak špatný). Tyto chyby se nejobtížněji hledají a napravují. Proto se jich obávají i zkušení programátoři. int soucet = 5; int pocet = 10; double prumer = soucet / pocet; / / výsledek je 0!
Méně nápadné vyznačování je používáno i v textu. V rámečku jsou malé ukázky kódu v
C# y = x++ . Podobně jsou označeny i základní syntaktické jednotky programu, jako jsou klíčová slova if , přímé hodnoty např. čísla 2 a různé oddělovače např. ; . Speciálně jsou odlišeny i různé identifikátory — jména. Jsou to buď jména produktů (Java), konstrukcí (while) a především různých programových entit, tříd (DateTime), metod (Now) a proměnných (i). Odlišení dané zvýrazněním nese důležitou informaci o kontextu daného textu. Například zápis 2 označuje číslo dva v matematickém smyslu, naopak 2 je jeho zápis v C#, což není totéž. Zápis v C# například nese informaci o třídě nově vytvořeného objektu. Podobně je while označení programové konstrukce (cyklu), while je klíčové slovo (zapsané přímo v programu). Navíc by mohlo existovat i while jako anglické slovo-spojka (v tomto významu však není v textu nikde použito). Ikonka modré knihy se používá pro odkazy na literatury, v níž můžete najít další informace. Následující odkaz vám například může pomoci posoudit, proč se budeme věnovat právě jazyku C#. Číslo v hranatých závorkách identifikuje knihu v seznamu zdrojů za poslední kapitolu. C#: programujeme profesionálně [3], strana 4: Kde všude můžete jazyk C# použít?
9
Část I.
Slovník (kolekce)
Úvod do problematiky • poznejte další užitečnou kolekci • naučte se šetřit paměť prostřednictvím řídkých polí • naučte se počítat výskyty objektů pomocí inteligentních čítačů kolekce, slovník, mapování (zobrazení), klíč, hodnota Slovník (anglicky dictionary) je kolekce representující zobrazení z množiny tzv. klíčů do množiny hodnot (viz obrázek). Hlavní operací slovníku je rychlé vyhledání hodnoty podle klíče. Zobrazení se anglicky označuje slovem map = mapování. Proto se jako alternativní označení slovníku používá i termín map (česky mapování). instance třídy klíčů (TKey)
instance třídy hodnot (TValue) hodnota (obraz)
klíč (vzor)
množina hodnot (Values)
množina klíčů (Keys)
Obrázek 1. Slovník – zobrazení klíčů na hodnoty Existuje několik dalších (vzájemně ekvivalentních) pohledů na slovník: 1. slovník je neuspořádaná kolekce dvojic tvořených klíčem a hodnotou, která preferuje vyhledávání podle klíče (angl. key). Ve slovníků nemohou být uloženy dvojice se shodnými klíči (v opačném případě by to již nebylo jednoznačné zobrazení). 2. slovník je prostředek jak rychle transformovat hodnoty z jedné množiny na množinu druhou (obě množiny nemusí být disjunktní). Transformace je dána explicitní transformační tabulkou. 3. slovník je dvousloupcová tabulka, která asociuje (spojuje) klíče (unikátní) a hodnoty (obdoba jednoduché databázové tabulky). Proto je dalším alternativním označením slovníků termín asociativní tabulka či asociativní pole. 11
Hlavní implementací slovníku ve standardní knihovně .NET je generická třída System.Collections.Generic.Dictionary. Mezi základní vlastnosti slovníku System.Collections.Generic.Dictionary patří: • potenciálně neomezený počet položek (omezením je pouze velikost adresového prostoru a do jisté míry i velikost operační paměti) • klíče musí být homogenní, tj. objekty klíčů by měly patřit do stejné třídy. Tato třída je uváděna při definici seznamu a správná typová správnost je kontrolována již při překladu (při vkládání i hledání). Třída je formálně (v dokumentaci, apod.) označována jako TKey. • hodnoty musí být homogenní, tj. objekty hodnot by měly patřit do stejné třídy. Tato třída je uváděna při definici seznamu a správná typová správnost je kontrolována již při překladu (při vkládání i získávání). Třída je formálně (v dokumentaci, apod.) označována jako TValue. Množina všech instancí třídy klíčů (TKey) tvoří nadmnožinu množiny aktuálních klíčů a množina všech instancí třídy hodnot (TValue) je nadmnožina aktuálních hodnot (viz obrázek výše). Ve speciálních případech může slovník mapovat všechny instance třídy klíčů (např. jsou-li klíči logické hodnoty nebo výčtový typ s několika málo instancemi). Podobně může splývat i množina hodnot s množinou všech instancí třídy hodnot. Tyto vlastnosti slovníku jsou zřejmé a vycházejí již z jeho definice, v níž se uvádějí třídy klíčů a hodnot a naopak se neuvádí (maximální) velikost. Slovník však má ještě jedno klíčové, ale relativně skryté omezení (není bohužel kontrolováno překladačem): Objekty klíčů musí být hodnotové nebo neměnné. V zásadě lze doporučit jen objekty tříd, jež neměnnost zaručují, tj. nemají žádnou metodu měnící adresáta a žádnou vlastnost s mutátorem (setterem). Hodnotové třídy (jako jsou všechny číselné třídy) lze použít vždy (slovník totiž uchovává jejich neměnné kopie). Protože slovník požaduje, aby byly klíče jedinečné, hraje důležitou roli i pojetí relace shodnosti (ekvivalence). Nad množinou klíčů lze totiž definovat velké množství různých relací ekvivalence, z nichž každá může mít své opodstatnění v určitém kontextu. Dokonce i některé vestavěné třídy poskytují hned několik typů ekvivalence. Zatímco v jedné z ekvivalencí mohou být objekty shodné (jako např. řetězce „süß“ a „SÜSS“), v jiné naopak rozdílné. Pro jednoduchost budeme prozatím předpokládat, že ekvivalence je dána operátorem shodnosti (tj. operátorem „==“ a jeho negací „!=“).
Základní operace Definice a naplnění Definice proměnné třídy Dictionary se svou strukturou neliší od definice používané u jiných generických kolekcí: using System.Collections.Generic; Dictionary<string, int> pocetObyvatel;
12
Tato proměnná bude odkazovat na slovník mapující (zobrazující) objekty třídy string (representující např. jméno obce) na objekty třídy int (representující počet obyvatel dané obce). Prozatím však, stejně jako u všech referenčních tříd, žádný nový objekt nevznikl a proměnná obsahuje hodnotu null . Proto je ve většině případů pohodlnější a bezpečnější proměnnou inicializovat v rámci definice (inicializačním výrazem je volání konstruktoru). Dictionary<string, int> pocetObyvatel = new Dictionary<string, int>();
Proměnná nyní odkazuje prázdný slovník (slovník, v němž není uložena žádná dvojice klíč-hodnota). Dalším krokem je proto naplnění (population) nově vzniklé instance slovníku. Základním prostředkem je zde stejně jako u seznamu metoda Add. Ta má na rozdíl od seznamu dva parametry — prvním je objekt klíče (vzor v zobrazení) druhým objekt hodnoty (obraz v zobrazení). V našem případě musí být vzor objektem třídy string, a hodnota instancí třídy int. pocetObyvatel.Add("Decin", 50620); pocetObyvatel.Add("Roudnice␣nad␣Labem", 13114);
Alternativqně lze slovník inicializovat pomocí indexace. Indexem je v tomto případě klíč a indexovaný výraz musí stát na levé straně přiřazení. pocetObyvatel["Litvinov"] = 18960; / / přidá dvojici klíč-hodnota pocetObyvatel["Most"] = 67030;
Oba způsoby přidání se liší v případě, kdy je přidávána hodnota s klíčem, který je již ve slovníku obsažen. V případě použití metody Add je vyvolána výjimka System.ArgumentException (se zprávou „An element with the same key already exists in the dictionary“). V přípqadě přiřazení do indexového výrazu žádná výjimka nevznikne a klíč je svázán s novou hodnotou (původní dvojice klíč-hodnota zanikne). pocetObyvatel.Add("Decin", 50620); / / výjimka (přestože je nová hodnota stejná
naopak následující zápis je OK pocetObyvatel["Decin"] = 51621; / / OK, nový počet obyvatel / / funguje dokonce i
pocetObyvatel["Decin"]++;
O tom jaký způsob vkládání zvolit rozhoduje celková strategie přístupu ke slovníku.V zásadě existují tří základní strategie: 13
1. přípustné je vkládání více hodnot se stejným klíčem. Ve slovníku je zohledněno pouze poslední vložení (aktualizace). V tomto případě je lze přirozeně využít indexaci. 2. přípustné je vkládání více hodnot se stejným klíčem. Ve slovníku je zohledněno pouze první vkládání (inicializace). V tomto případě lze využít oba mechanismy vkládání, ty však musí být spojeny s předběžným testováním výskytu klíče ve slovníku if (!pocetObyvatel.ContainsKey("Decin")) pocetObyvatel.Add("Decin", 50620); / / jednou a dost / / nebo
if (!pocetObyvatel.ContainsKey("Decin")) pocetObyvatel["Decin"] = 50620;
3. je zaručeno, že klíč se bude vkládat jen jednou např. ze zdrojů u nichž je unikátnost jistá. Zde stačí používat vkládací metodu Add. Výjimky vznikne, jen je-li tento předpoklad narušen. Vyhledávání hodnoty podle klíče Nejdůležitější operací nad naplněným slovníkem je vyhledávání položky podle klíče. Standardní rozhraní slovníku nabízí dva mechanismy pro nalezení hodnoty podle klíče, z nichž však je jen jeden použitelný i pro začátečníky — indexace klíčem. Indexace v tomto případě (na levé straně přiřazení) funguje jako getter, vrací hodnotu, na níž se klíč zobrazuje (tj. s jakým klíčem byla do slovníku vložena). Nalezení příslušné hodnoty je velmi rychlé a v zásadě nezávisí na počtu dvojic klíčů a hodnot v seznamu — časová složitost je 𝑂(1)! Důvodem je representace slovníků pomocí tzv. hashovacích tabulek. Co však nastane v případě, že ve slovníku není hodnota s daným klíčem? Je zřejmé, že nemůže být vrácena nějaká speciální hodnota, neboť množina hodnot může (alespoň potenciálně) pokrývat všechny přípustné instance třídy hodnot (včetně hodnoty null u referenčních typů). Indexace bohužel neumožňuje ani zadání ad-hoc implicitní hodnoty. Jediným řešením je vyvolání výjimky (třídy KeyNotFoundException). K vyvolání této výjimky by však v běžném programu nemělo nikdy dojít (výjimka signalizuje chybový stav nikoliv pouhou neexistence klíče). V praxi se využívají dvě základní strategii využití slovníků. První strategie předpokládá, že vyhledávány budou jen a pouze existující klíče. Tuto jistotu lze získat přirozeným omezením množiny klíčů nebo kontrolou rozsahu klíčů před jejich použitím. Pokud budete například zobrazovat GUI tlačítka na příkazy, které vyvolávají, pak je množina klíčů jasně definovaná (= množina všech tlačítek) a nemůže se stát, že bude použit neexistující klíč (= stisknuto neexistující tlačítko). 14
Pokud by přesto tato situace nastala, pak je to příznak chyby v programu (např. opomenutí vložení nebo použití neplatného objektu). Vyvolání výjimky je v tomto případě zcela adekvátní. Podobně lze interpretovat použití neplatného klíče v programu, který mapuje jména povinných HTML atributů na jejich hodnoty. Není-li povinný atribut přítomen ve slovníku, pak je to příznak nepřípustného HTML formátu, který měl být odhalen již ve fázi validace (a nikoliv až při zpracování HTML obsahu). if(imgElement["src"]) { / / element ”img” musí obsahovat atribut ”src” / / zpracování URL obrázku
}
Druhá strategii naopak předpokládá, že i neexistence klíče (resp. hodnoty s daným klíčem) nese nějakou informaci. Pokud například využíváme slovník pro počítání výskytu slov v textu (slovník mapuje slovo-řetězec na počet výskytů, tj. na celé číslo), pak neexistence řetězce signalizuje dosavadní nepřítomnost slova v textu. Je totiž zřejmé, že na začátku zpracování nelze vytvořit slovník všech potenciálních slov (jako klíčů), které jsou všechny mapovány na hodnotu nula (= nula výskytů). Neexistence klíče je v takovém případě zcela běžnou situací. Na začátku zpracování dokonce výrazně převažuje nad situací, kdy je slovo už uloženo ve slovníku (tj. alespoň jednou se již vyskytlo). Vznik výjimky (odvozeno od spojení „výjimečná situace“) je v tomto případě zcela neadekvátní reakce. Výjimku lze sice zachytit a reagovat na ni (vložením nového slova do slovníku), ale je to velmi, velmi pomalé. Proto je nejdříve nutné otestovat existenci klíče pomocí metody ContainsKey a k indexaci pomocí klíče přistupovat až v případě, kdy klíč již existuje (a výjimku tak zcela eliminovat) / / vytvoříme prázdný slovník
Dictionary<string,int> pocetVyskytu = new Dictionary<string,int>(); List<string> text = new List<string>(); / / text rozdělený na slova ... / / načtení textu a rozdělení na slova
... foreach(string slovo in text) { if(pocetVyskytu.ContainsKey(slovo)) { pocetVyskytu[slovo] ++; / / zvýšíme počet výskytů o 1, indexace klíčem je zde bezpečná
} else { pocetVyskytu[slovo] = 0; / / vložíme do slovníku dvojici slovo -> 0 } }
Indexace je v tomto programu použita na dvou místech a ani v jednom případě nezpůsobí výjimku. Při inkrementaci se nejdříve získá původní hodnota indexovaná klíčem 15
(klíč musí existovat, ale to zaručuje podmínka konstrukce if), ta se zvýší o jedničku a výsledek se uloží znovu do slovníku (pod stejným klíčem). Je to možné, neboť pro vložení nové hodnoty je použita indexace a nikoliv metoda Add (ta by skončila výjimkou, neboť klíč už existuje a my chceme pouze aktualizovat namapovanou hodnotu). Druhá indexace klíčem (v sekci else) vkládá novou dvojici klíče a hodnoty do slov
níku. Zde by bylo použití zápisu pocetVyskytu.Add(slovo, 0) možné (a některými programátory dokonce preferované).
Pravidlo: Při vyhledávání klíče ve slovníku pomocí indexace, musí být buď předem zřejmé, že je hodnota s daným klíčem ve slovníku již obsažena, nebo musí být existence klíče testována pomocí metody ContainsKey.
Využití slovníku Slovník je velmi užitečná kolekce, která se uplatní ve všech typech aplikací. Je dokonce tak užitečná, že je často užívána nadbytečně na místo konstrukcí, které jsou mnohem efektivnější. Stává se to především programátorům, kteří jsou zvyklí na programovací jazyky jako je Perl nebo JavaScript, v nichž hrají slovníky klíčovou syntaktickou roli (v C# je slovník jen jednou z mnoha kolekcí). Pravidlo: Nepoužívejte slovník pro mapování identifikátorů na objekty, pokud jsou tyto identifikátory jen pomocné či dočasné (a sémanticky nevýznamné). Zde je lepší využít objekt s pojmenovanými vlstnostmi. My se nyní detailněji podíváme na tři oblasti využití slovníku: • malé aktivní databáze uložené v operační paměti • řídké seznamy (pole) • čítač výskytů Databáze v paměti Slovník umožňuje relativně snadno representovat struktury podobné tabulkám relačních databází. Relační databázové tabulky jsou tvořeny záznamy (řádky) o pevné lineární struktuře. Každý jednotlivý záznam se skládá z několika elementárních údajů (čísel, řetězců, apod.), přičemž alespoň jeden údaj hraje roli primárního klíče — hodnoty podle níž se záznam identifikuje, Primární klíč musí být v rámci tabulky unikátní. Stejný údaj lze v OOP jazyce representovat jako objekt složený z podobjektů elementárních tříd. Tato representace však neumožňuje rychlé vyhledávání v kolekci objektůúdajů. Klíčový údaj je však možno z objektu vyjmout a použít ji jako klíč ve slovníku. Tento klíč pak identifikuje objekt representující celý záznam. V této representaci databázového řádku je atribut sloužící jako klíč uložen dvakrát. Jednou jako součást objektu representujícího řádek, podruhé pak jako klíč slovníku identifikující daný záznam. Je přitom zřejmé, že oba objekty musí být v rámci dané dvojice klíč-hodnota shodné.
16
Tato duplikace se může zdát nadbytečná (stačil by jen objekt ve funkci klíče). Zvyšuje to požadavky na paměť a přináší problémy v případě, že programová chyba vede k nekonzistentnímu stavu, kdy klíč identifikuje záznam s jinou hodnotou klíčové vlastnosti-atributu. Zvýšené paměťové nároky nejsou silným protiargumentem, neboť ve skutečnosti jak klíč tak hodnota běžně odkazují stejný objekt v paměti, v našem případě tedy stejný řetězec (samozřejmě jen u referenčních tříd). Zvýšení je tak pouze v řádu jednotek bytů na záznam. Potenciální problém s nekonzistencí je mnohem závažnější. Na druhou stranu zahrnutí klíče do objektu representujícího celý záznam usnadňuje jeho zpracování. Objekty-záznamy jsou dále zpracovávány (neboť jsou parametry dalších metod) a pro zpracování je ve většině případů nutný celý objekt včetně svého klíče. Například při zobrazení údaje o prodeji auta by měla být zobrazena i státní poznávací značka. Pokud by nebyla obsažena v objektu-záznamu, musela by být předávána jako další parametr či před zpracováním do záznamu přidávána. To by zbytečně komplikovalo program a navíc by to mohlo vést ke stejné nekonzistenci jako v případě duplicitního uložení. Nekonzistence by byla sice omezena jen na zpracovávající metody, ale efekt by byl obdobný. Nejdůležitějším protiargumentem je však velmi malá pravděpodobnost vzniku nekonzistence při dodržování několika elementárních zásad: • objekty representující záznamy by měly být neměnné (po vzniku objektu nelze klíč, jenž je v něm uložen, měnit) • při vkládání je nutno využít klíč získaný přímo z objektu Toto pravidlo si ukažme na fragmentu programu, který využívá slovník při representaci databáze automobilů (klíčem je zse SPZ) class AutoProdej { public string Spz {get; private set;} / / automatická vlastnost public string Typ {get; private set;} public DateTime DatumProdeje {get; private set;} public AutoProdej(string spz, string typ, DateTime datumProdeje) { this.Spz = spz; this.Typ = typ; this.DatumProdeje = datumProdeje; } } ... / / přidání nového záznamu do slovníku
AutoProdej auto = new AutoProdej("5U6␣1705", "Trabant", new DateTime(2012, 11, 20)); prodeje.Add( auto.Spz, auto );
17
/ / klíč == auto.Spz (platí a vždy bude platit) / / nebo
prodeje[ auto.SPZ ] = auto;
Řídké seznamy Seznamy jsou lineárními kolekcemi, u nichž je preferovanou operací rychlá indexace. V C# mají svou přirozenou representaci ve třídě System.Colllections.Generic.List. Obdobnou representaci mají i tzv. pole (pole mají v C# na rozdíl od seznamů pevnou velikost). Položkami seznamů mohou být seznamy, což umožňuje snadno representovat dvojrozměrná (a v zásadě i vícerozměrná data). Seznamy (resp. seznamy seznamů) jsou optimální representací lineárních (resp. dvojrozměrných – maticových dat), avšak neplatí to ve všech případech. Představme si například list tabulkového kalkulátoru. A představte si, že bychom měli podobný tabulkový kalkulátor naprogramovat. Jak budete representovat list (sheet) v paměti? Zvolíte přirozenou representace dvojúrovňovým seznamem? V prvé řadě si musíte uvědomit, že viditelná část listu však tvoří jen malou část virtuálního listu. LibreOffice tabulkový kalkulátor podporuje 210 sloupců a 220 řádků, tj. dokáže adresovat 230 (∼ 109 ) buněk. Pokud by byl list representován dvojúrovňovým pole. pak by zaujímal minimálně 4 GiB paměti (i pouhý odkaz na null zaujímá ve 32-bitovém systému 4 byty paměti). Jinak řečeno, representace listu by se na většině počítačů nevešla do operační paměti a i v případě počítačů s více než 4 GiB by zaujímala její podstatnou část (do zbytku se musí vejít ostatní listy a samozřejmě i další aktuální procesy). Ve skutečnosti je ale valná většina z více než miliardy buněk prázdná. Optimálním řešením je representace jen těch buněk, které nejsou prázdné (= neobsahují žádný text či vzorec). Aby však byla zachována rychlá odezva, musí být k dispozici rychlá indexace (tj. nalezení buňky podle její adresy = řádku a sloupce). Uvažujme proto, zda by vhodným kandidátem pro representaci listů nebyl slovník. Klíčem by byla dvojice dvou celých čísel representrující 2D adresu buňky, hodnotou pak objekt representující buňku (její obsah a formátování). Při zobrazení nebo výpočtech lze snadno a rychle zjistit zda je buňka prázdná či nikoliv (pomocí testování existence klíče-adresy ve slovníku metodou ContainsKey). Podobně je snadné získání informace o neprázdné buňce (pomocí indexace klíčem). Paměťové nároky jsou dány počtem uložených dvojic, jenž odpovídá počtu vyplněných buněk (běžně v řádu maximálně tisíců). Můžeme však dvojici objektů použít jako klíč slovníku? Přímo samozřejmě nikoliv, můžeme je však uzavřít do kompozitního objektu — přepravky. Nejjednodušším řešením je využití kolekce s názvem n-tice (angl. tuple). N-tice je velmi jednoduchá kolekce, umožňující representovat libovolné n-tice objektů 18
(v C# je od verze 4.0 ). Počet objektů (= 𝑛) je pevně dán už při definici (navíc je maximální použitelná velikost n-tice omezena, přičemž omezení závisí na verzi knihovny, bezpečně jsou podporovány alespoň sedmice). Na druhé straně může n-tice obsahovat i hodnoty různých navzájem nesouvisejících tříd. Typ každé položky musí být explicitně určen již při definici. Objekty n-tice jsou vytvářeny konstruktorem, jemuž jsou předány všechny položky (přičemž záleží na pořadí). Po vytvoření objektu n-tice lze získávat jednotlivé uložené položky pomocí vlastností Item1, Item2 až ItemN. Pořadí je přímou součástí identifikátoru, nikoliv běžným indexem (ten by byl v hranatých závorkách). Počet položek je naštěstí velmi malý (v praxi se používají nejčastěji jen dvojice a trojice). Uveďme si malý (prozatím umělý) příklad dvojice (2-tice): Tuple<string, int> tuple = new Tuple<string, int>("Frodo", 42); / / vytvoření
Console.WriteLine(tuple.Item1); / / vypíše Frodo Console.WriteLine(tuple.Item2); / / vypíše 42
Vytvoření je trochu rozvláčné, neboť je nutno dvakrát specifikovat typ každé položky. Naše ukázková dvojice bude složena z řetězce (první položka) a celého čísla (druhá položka). Použití je už snadné. Vlastnost Item1 vrací řetězec, vlastnost Item2 číslo.Třída návratové hodnoty závisí na typu položky specifikované v definici n-tice. Vytvořená n-tice kromě základních metod podporuje i porovnání a obsahuje i podporu použití na místě klíče ve slovnících (např. rozumně předefinovává metodu GetHash). S využitím n-tice je implementace tabulkového listu snadná. Slovník pro ukládání jednotlivých buněk je možno využívat přímo, ale pohodlnější a bezpečnější je využití adaptérů — objektů nově vytvoření třídy, které v sobě ukrývají příslušný slovník, nabízejí však mnohem jednodušší rozhraní. Naši třídu-adaptér nazveme Sheet. class Sheet { private Dictionary
, string> sheet = new Dictionary, string>(); public void SetCell(int row, int column, string context) { Tuple address = new Tuple(row, column); sheet[address] = context; } public string GetCell(int row, int column) { Tuple address = new Tuple(row, column); return sheet.ContainsKey(address) ? sheet[address] : ""; } }
Objekt třídy Sheet obsahuje jen jediný podobjekt (je to tudíž pravý adaptér) — slovník mapující dvojice celých čísel na řetězec representující textový obsah buňky (skutečná 19
implementace by samozřejmě vyžadovala složitější objekt). Rozhraní nabízí jen klíčové metody pro vložení obsahu do buňky a jeho opětné získání. U obou metod je adresa předávána jako dvojice parametrů (zvlášť řádek a zvlášť sloupec). Representace klíčů pomocí n-tic je implementační detail a může být v dalším vývoji změněn. V obou metodách je nejdříve vytvořena adresa (dvojice) z obou předaných parametrů. Pro uložení je využita indexace, neboť použitá adresa nemusí být jedinečná. Při použití stejné adresy se změní příslušná hodnota (= přepíše se obsah buňky). Naopak při čtení je nutno řešit i přístup k nedefinovanému klíči (k prázdné buňce, jichž je valná většina). Proto je nejdříve testována existence klíče metodou ContainsKey. Pokud klíč existuje, pak je vrácena příslušná hodnota (obsah buňky), jinak je vrácen prázdný řetězec. Alternativně lze vrátit i hodnotu null . Vrácení prázdného řetězce je pohodlnější (příjemce nemusí testovat zda byl vrácen skutečný objekt), vynucuje si však model, v němž je prázdná buňka ekvivalentní buňce obsahující prázdný řetězec (což je rozumný předpoklad). Kromě listu tabulkových kalkulátorů existují i další tzv. řídké pole (angl. sparse array). resp. řídké matice, seznamy, atd. Obecně je to jakýkoliv indexovatelná kolekce, která obsahuje jeden a tentýž objekt tolikrát, že to tvoří většinu (tzv. dominantní prvek). Nemusí to být nutně nula nebo null . Důležitý je jen podíl příslušného dominantního objektu. Hranice není ostrá a závisí na typu objektu resp. na interní representace (např. podíl přes 90% je již rozhodně příznakem řídkosti, neboť je nutno explicitně ukládat jen zbývajících méně než 10% položek, při 66% podílu již není situace tak jednoznačná). Čítače výskytů Posledním klasickým využitím slovníku je podpora algoritmů počítajících počet výskytů určité hodnoty v posloupnosti. Algoritmy tohoto druhu se používají především v programech zaměřených na statistické zpracování dat, v menším měřítku se však vyskytují i v jiných typech aplikací, které občas provádějí malá lokální statistická zpracování (kdo se kolikrát přihlásil. kolikrát byl přehrán audio soubor, apod.). Veřejné rozhraní čítače výskytů (angl. counter) musí poskytovat minimálně dvě klíčové metody. První metoda (vkládací) je volána při výskytu příslušné hodnoty (sémantika z pohledu této hodnoty: vyskytla jsem se, započítej mně), druhá (dotazovací) vrací průběžný počet výskytu hodnoty (což jest počet aktivací první metody s parametrem rovným dotazované hodnotě, a to od vzniku čítače do okamžiku volání dotazovací metody). Čítač
lze
také
interpretovat
jako
paměťově
nenáročnou
representaci
tzv.
multimnožiny (angl. multiset). Multimnožina může obsahovat prvek vícekrát (přičemž nelze odlišit jednotlivé výskyty prvku v množině). Vkládací operace je v této representaci přidáním prvku do množiny, operace vyhledávací pak zjištěním počtu výskytu daného prvku v multimnožině. 20
Běžnou množinu lze chápat jako speciální příkladem multimnožiny (každý prvek je v množině právě jednou). Proto lze i množiny representovat slovníkem. Množina je v této implementaci representována klíči, příslušná hodnota (obraz zobrazení) může být sdílena všemi klíči (nemá žádnou informační hodnotu, důležitá je pouze přítomnost klíče). Pro representaci množin však od verze C# 3.0 existuje specializovaná kolekce Set, která využívá hashovací tabulku neukládající hodnotu. To nejen snižuje paměťovou náročnost, ale také usnadňuje použití (množina je například přímo iterovatelná, stejně jako seznam). Implementace čítače výskytů nad slovníkem je jednoduchá a přitom dostatečně efektivní. Jádro implementace bylo uvedeno již v předchozí sekci, ale pro jistotu si ji zopakujme. Vkládací metody (v našem ukázkovém zdrojovém kódu je označena identifikátorem Insert) testuje, zda byl objekt vložen dříve (tj. existuje dvojice s daným klíčem). Pokud existuje, je příslušná hodnota (= počet výskytů) zvýšena o jedničku. Jinak je vytvořena nová dvojice, přičemž hodnota je rovna jedné (= prozatím jeden výskyt). using System.Collections.Generic; / /
counter.cs
class Counter { private Dictionary<string, int> c = new Dictionary<string, int>(); / / podkladový slovník
public void Insert(string s) { if (c.ContainsKey(s)) { c[s]++; } else c[s] = 1; } public int Frequency(string s) { return c.ContainsKey(s) ? c[s] : 0; } }
Vyhledávací metodu (zde označená jako Frequency) lze při použití podmínkového operátoru zapsat na jediném řádku. Je-li hledaný objekt obsažen ve slovníku jako klíč, je vrácena příslušná hodnota (= počet výskytů). V opačném případě je vrácena nula (= žádný výskyt). Všimněte si, že ani v tomto případě nedochází ke vzniku (pomalé a nechtěné) výjimky při přístupu ke slovníku. I když je representace čítače pomocí slovníku elegantní a efektivní, existují i jiné alternativy. Lze-li objekty snadno jednoznačně zobrazit na celá čísla v interval 0 až 𝑛 , pak je efektivnější representací čítače seznam čísel. Seznam o 𝑛 prvcích musí na začátku obsahovat nuly. Při výskytu stačí zvýšit o jedničku položku s daným indexem. Alternativní representace může být výhodnější i v případě, když je po skončení statistického zpracování vyžadován výpis položek s nejvyšší frekvencí výskytů. Slovník neumožňuje přímé vyhledání klíče s nejvyšší četností (resp. dokonce klíče s n-tou 21
nejvyšší četností).
Procházení seznamu Základní slovníkovou operací je vyhledání podle klíče. Relativně často se však setkáte i s dalším požadavkem: procházením všech klíčů resp. dvojic klíč-hodnota ve slovníku. Užitečnost této operace není omezena pouze na fázi ladění, ale hodí se i v produkčním kódu. V případě použití slovníku jako databáze, je vhodná při transformaci databáze do jiné podoby. Například při zobrazení číselníku pomocí vysouvacího seznamu (drop-box) je nutno získat seznam klíčů, při posílání textového seznamu přihlášených uživatelů seznam klíčů (přihlašovacích jmen) resp. i hodnot (veřejných údajů u uživateli). Klíčové je procházení například ve fázi vizualizace výsledků. Celkový pohled na slovník se někdy hodí i u representace řídkých polí (výpis). Zde však pozor, pokud se tato operace provádí často a jejím výsledkem je jiná representace slovníku, např. v podobě výpisu všech dvojic, pak může být něco špatně. Výpis totiž také zaujímá paměťový prostor (i když nemusí být v operační paměti) a tak úspora daná použitím slovníku může být pouze zdánlivá a přímá representace polem nebo seznamem může ušetřit čas a zpřehlednit program. Pro procházení (odborně iterování) slovníku existují v C# dva idiomy. Procházení přes seznam klíčů Tento způsob je jednodušší a ve většině případů dostatečně efektivní. Zvlášť vhodný je samozřejmě v případě, že postačuje skutečně jen procházení klíčů. Navíc jej lze použít téměř ve všech programovacích jazycích s podporou slovníků a to ve velmi podobném tvaru. Při iteraci jsou klíče procházeny v pořadí, které neodpovídá pořadí vkládání a které není ani nijak jinak uspořádané resp. dokonce setříděné. Toto pořadí je sice fixní v rámci daného spuštění aplikace (při opakovaném volání je vždy stejné), může se však lišit mezi jednotlivými spuštění aplikace (i když to není příliš pravděpodobné) a především mezi různými platformami (jak na úrovni OS tak implementace .NET). Proto je lepší chápat jej jako pseudonáhodné resp. dané neveřejnou interní representací slovníku. foreach (string spz in prodeje.Keys) { Console.WriteLine("klic␣{0}␣-␣hodnota␣{1}", spz, prodeje[spz]); }
Časová složitost procházení je O(n) , neboť je procházeno n klíčů, a k nim je v čase 𝑂(1) hledána hodnota. Pokud je však slovník špatně navržen může časová složitost růst až kvadraticky. U větších slovníků (řádově tisícovky a výše položek) je proto vhodnější druhý způsob. Přímé procházení dvojic Při přímém procházení dvojic se na slovník díváme jako na neuspořádaný seznam dvojici klíčů a hodnot a procházíme přímo tyto dvojice. Jazyk C# však nepodporuje 22
přímé vracení a přiřazení dvojic, což vede k trochu složitější a mírně nepřehledné syntaxi. foreach (KeyValuePair<string, AutoProdej> pair in prodeje) { Console.Write("klic␣{0}␣-␣hodnota␣{1},␣", pair.Key, pair.Value); }
Dvojice je representována instancí třídy KeyValuePair (specializovanou přepravkou). Pro získání klíče resp. hodnoty z přepravky je nutno využít vlastnost Key resp. Value. Výhodou je kromě zaručené časové složitosti O(1) i možnost použití složitějších prostředků pro zpracování sekvencí, které jsou jazykem C# podporovány. Slovník se tak například může stát zdrojem dat pro LINQ dotazy.
23
Shrnutí Definice: slovník – kolekce, která umožňuje rychle vyhledávat objekty-hodnoty podle klíčů (= objektů, které hodnoty identifikují) Pomocí slovníků lze representovat vícesloupcové tabulky nebo zobrazení jedné množiny na druhou. definice a inicializace slovníku / / using List.Collections.Generic;
Dictionary slovnik = new Dictionary(); / / prázdný slovník
kde TKey je třída klíčů (téměř libovolná třída, která však musí mít neměnné instance), a TValue je třída hodnot (zcela libovolná třída). Příklad: Dictionary<string, int> mesta = new Dictionary<string, int>();
Slovník mapující řetězce na celá čísla. Může například representovat vyhledávací tabulku, v níž lze podle jména (klíč) vyhledávat počet obyvatel (hodnota). Přidávání a změna prvků mesta.Add("Praha", 1258000);
přidá do seznamu dvojici klíč (jméno), hodnota (počet obyvatel). Klíč již nesmí být obsažen ve slovníku (klíče musí být unikátní). mesta["Praha"] = 1259000;
Změní hodnotu svázanou s klíčem resp. ji nastaví (pokud je již klíč ve slovníku obsažen). Získání (nalezení) hodnoty hodnotu pro daný klíč lze nejsnadněji získat zobecněnou indexací int pocetPotencialnichZakazniku = mesta["Praha"];
Není-li klíč ve slovníku nalezen, pak je vyvolána výjimka. Tato situace by neměla nikdy nastat. Pokud si nejsme jisti, zda je daný klíč ve slovníku, musíme to nejdříve ověřit metodou ContainsKey. if( mesta.ContainsKey(mojeMesto) ) Console.WriteLine( mesta[mojeMesto] );
24
Operace vyhledávání (a testovaní přítomnosti) má časovou složitost 𝑂(1), tj. nezávisí na velikosti slovníku (vyhledávání v obřím gigabytovém slovníku trvá jen pár mikrosekund). Této rychlosti je dosaženo použitím speciální datové struktury – hashovací tabulky. Procházení slovníku Pokud potřebujeme vypsat všechny klíče a jejich hodnoty ze slovníku, lze použít cyklus přes seznam klíčů (pořadí klíčů je náhodné). foreach(string mesto in mesta.Keys) { Console.Write("klic:␣{0},␣hodnota:␣{1}", mesto, mesta[mesto]); }
Druhou možností je cyklus přes dvojice klíč hodnota: foreach (KeyValuePair<string, int> pair in mesta) { Console.Write("klic:␣{0},␣hodnota:␣{1}", pair.Key, pair.Value); }
25
Otázky a úkoly
Úkol I.2: Co je podivného na zápise: Dictionary<string, string> slovnik;
Úkol I.3: Proč není možno používat měnitelné objekty na místě klíčů slovníku? Úkol I.4: Jaký má smysl používání paměťových databází (v podobě slovníků)? Jaké jsou výhody a nevýhody oproti klasickým databázím, jež jsou uloženy na vnějších paměťových zařízeních? Nápověda: klíčová slova: persistence, přístupová doba
Úkol I.5: Občas je potřeba oboustranné mapování, tj. rychlé vyhledávání podle klíče i hodnoty. Proč není ve většině případů vhodné použít metodu ContainsValue? Nápověda: soustřeďte se na datovou representaci slovníku
Úkol I.6: Jak je možno pomocí slovníku representovat obecné relace, u nichž není splněna jedinečnost klíčů (jeden klíč může být v relaci s více hodnotami)? Nápověda: hodnoty nemusí být jen instance jednoduchých tříd
Úkol I.7: Slovník je v C# (a ve většině jazyků) representován pomocí hashovací tabulky. Existuje nějaká alternativní representace? Nápověda: Alternativní representace by měla podporovat rychlé přidání hodnot s rychlým vyhledáváním (s lepší než lineární časovou složitostí). Mohla by přinést i další výhody (např. automatické setřídění klíčů).
26
Část II.
Polymorfismus a sdílená podrozhraní (interface)
Úvod do problematiky • překonejte hranice tříd pomocí polymorfismu • sdílejte části rozhraní mezi objekty různých tříd • poznejte základní knihovní rozhraní a jejich funkci v C# polymorfismus, interface, iterátor
Třídní orientace a její omezení ve staticky typovaných jazycích Jazyk C# a obdobné jazyky jsou běžně označovány jako objektově orientované. Ve skutečnosti by se však měly spíše označovat jako třídně orientované, neboť klíčovou roli v jejich návrhu hrají třídy. Třída jednoznačně určuje datovou strukturu (složení) svých instancí, ale především jejich rozhraní i implementaci všech metod. Pravidlo: Navíc objekt je po svém vytvoření po celou dobu svého života (přímou) instancí jediné třídy s neměnným rozhraním a implementací. V jazyce C# je klíčová role tříd úzce provázána s tzv. statickým typovým systémem (zkráceně statické typování angl. static typing) Při překladu programu musí překladač znát typ každého vytvářeného, zpracovávaného i uvolňovaného objektu, bapř. jeho umístění v paměti, vnitřní struktura a rozhraní. Proto by měla být třída objektu určena již při jeho definici resp. ji překladač odvodí z typu inicializačního výrazu. Navíc musí být uváděny typy parametrů metod, typy návratových hodnot a položek generických kolekcí (ty již překladač C# bohužel nedokáže odvodit). Již v okamžiku překladu tak může překladač provádět tzv. typovou kontrolu a generovat kód pro optimalizované volání metod a přístupových operací. Při typové kontrole se ověřuje, zda příslušný objekt poskytuje požadované metody (s požadovanou signaturou), zda metody vrací objekty požadovaných tříd resp. zda do jsou do kolekce ukládány objekty jen jedné třídy (a tudíž je lze jednotně zpracovávat). Pokud je objeven problém (například volání nedefinované metody) je chybové hlášení zobrazeno již při překladu (v moderních vývojových prostředích často dokonce již při zápisu kódu). Třídní orientace a statický typový systém je pohodlný jak pro kompilátor tak pro pro programátory. Usnadňuje život i tvůrcům inteligentních vývojových prostředí s automatickým doplňováním kódu a inteligentní refaktorizací. Bohužel striktní provázání tříd a statického typového systému (úplná ekvivalence: třída = typ) přináší i zásadní omezení, neboť svět (resp. přesněji) problémovou doménu lze ve většině případů jen obtížně rozdělit na zcela nezávislé třídy objektů. Mnohé objekty totiž sdílejí podobné chování a vlastnosti, ale jejich spojení do jediné třídy není možné, neboť v jiných rysech se naopak výrazně odlišují. 28
Podívejme se například na problémovou doménu rozsáhlejší výrobní firmy. U mnohých objektů této problémové domény lze uvažovat jejich umístění. Umístěny jsou stroje (v halách), výrobky (ve skladech). zaměstnanci (v halách či kancelářích). Tyto objekty lze podle umístění vyhledávat, či přemisťovat. Jen obtížně si však lze představit, že patří do jediné třídy. Ve většině firemních aplikací však mohou být jen stěží zahrnuty do jediné třídy. Zaměstnanci dostávají totiž plat (zatímco stroje a produkty nikoliv), výrobky mají výrobní cenu (lidé samozřejmě nikoliv, cenu strojů lze uvažovat), výrobky mají jiný životní cyklus v rámci než lidé a výrobní stroje, apod. Stejně jako ostatní obecné příklady je existence společné vlastnosti umístění pouze jednou z možných interpretací. Z jiného pohledu (resp. v určitých aplikacích) může být systém interpretován odlišně. Zatímco umístění výrobních strojů může být chápáno jako permanentní a fyzické, je umístění lidí mnohem proměnlivější a mnohdy je nutno odlišovat administrativní a fyzické umístění. V tomto případě by se jednalo o dvě vlastnosti, které by se však nemohly přímo využívat např. pro společné vyhledávání. Jako druhý příklad je možno uvést knihovnu, v níž se kromě knihy uchovávají i CD a jiné audiovizuální materiály (což je zcela běžná situace). Bez ohledu na charakter mají uchovávané věci společné vlastnosti a chování: jsou jednotně identifikovány, platí pro ně stejný či téměř identický výpůjční protokol (liší se např. jen číselným parametrem např. délkou výpůjčky). Rozhodně by měly být společně zpracovávány například při inventurách. Na straně druhé však mají audiovizuální materiály odlišné vlastnosti (např. délku záznamu nebo typ média) a také některé metody se mohou lišit (např. placení poplatků souvisejících s intelektuálním vlastnictvím) a proto by měly být representovány jinou třídou (nebo dokonce více třídami podle jednotlivých médií).
Polymorfismus Aby bylo možno tento handicap alespoň částečně překonat, podporují objektově orientované jazyky tzv. polymorfismus, tj. schopnost vytváření kódu, který dokáže pracovat s objekty několika různých tříd. Polymorfismus (angl. polymorphism)– schopnost kódu pracovat s objekty různých tříd (obecněji mimo OOP kontext s hodnotami různých typů). Označení pochází z řečtiny, český překlad je mnohotvarost (= schopnost pracovat s mnoha různými formamitvary věcí). V staticky typovaných jazycích existuje hned několik mechanismů jak dosáhnout polymorfního chování kódu (včetně i celých metod, a dokonce i kolekcí). Mechanismy se liší rozsahem poskytovaného polymorfismu, možnostmi jeho využívání i omezeními či efektivitou (žádný z mechanismů není dokonalý ani zcela univerzální). Hlavním rozlišovacím znakem jednotlivých typů polymorfismu je okamžik, v němž je identifikován skutečný typ (= třída objektů), což umožňuje provést typovou kontrolu a generování příslušného specializovaného kódu. Pokud je příslušný typ znám již při překladu, jedná se o tzv. statický polymorfismus , který však přímo nesouvisí s 29
objektově orientovaným programováním. Jazyk C# podporuje tři typy statického polymorfismu: generika (= generické typy), implicitní přetypování a přetěžování metod pomocí typů jejich parametrů. Generika se používají především u kolekcí a nabízejí povětšinou velmi široký polymorfismus (často zcela univerzální, tj. kód je použitelný na objekty všech tříd). Zatím jsme ji používali pouze pasivně u kolekcí. Polymorfní kód jsme nevytvářeli, jen jsme využívali specializované verze kodů určeného pro konkrétní typ. Druhým základním druhem polymorfismu je polymorfismus dynamický , v němž je skutečný typ hodnoty (z OOP pohledu třída objektu) znám až za běhu programu. To nabízí mnohem pružnější přístup k datovým typům za cenu oslabené či dokonce chybějící kontroly typů při překladu (chyba se projeví až za běhu, což může být pozdě). Jazyk C# podporuje tzv. objektový dynamický polymorfismus dosažitelný pomocí dvou úzce provázaných mechanismů: (sdílených) rozhraní a dědičnosti. Tento polymorfismus není dokonalý, umožňuje však ve většině případů alespoň částečně využívat statickou kontrolu typů (a je tudíž mnohem bezpečnější). Pro začátečníka je někdy těžké poznat, jaký polymorfismus použít, a i zkušený programátor může být zmaten, neboť jednotlivé typy polymorfismu spolu vzájemně dosti složitě interagují a nemusí být zcela zřejmé, jaké to má důsledky. C# od verze 4.0 podporuje i absolutní dynamický polymorfismus (tzv. dynamické typy) v podobě známé např. z PHP nebo JavaScriptu. V C# je to však jen okrajový jazykový prostředek, který byste si měli osvojit teprve tehdy, až budete znát C# mnohem lépe. C# 2010 [2], strana 587: dynamické typy pro nedočkavé
Sdílená rozhraní Polymorfismus založený na sdíleném (pod)rozhraní (angl. interface) vychází přímo z našeho předchozího pozorování, že existují objekty, které sice sdílejí vlastnosti resp. jsou schopny stejně či podobně reagovat na shodné podněty, avšak nemohou být zahrnuty do stejné třídy. Tato shoda na úrovni obecného objektového modelu se projevuje i na úrovni implementace, a to tím, že několik tříd definuje metody či vlastnosti, které: 1. by měly sdílet signaturu, tj. měly by mít stejné jméno, stejný počet a typ parametrů (u metod a vlastností s definovaným setterem) a vracet objekt stejného typu (pokud nějaký objekt vrací) 2. měly by vykonávat podobnou činnost či splňovat stejné vstupní či výstupní podmínky. Stručněji řečeno: metody či vlastnosti musejí splňovat stejný kontrakt (angl. contract). Kontrakt je hlavním, avšak často přehlíženým aspektem rozhraní. Rozhraní je však skutečným smluvním vztahem mezi jednotlivými třídami a resp. mezi jejich implementátory. Kontrakt (jehož součástí v širším slova smyslu je i signatura) je de facto synonymem rozhraní. 30
C# 2010 [2], strana 159: Rozhraní a smluvní vztahy, vhodně zvolené jméno kapitoly o rozhraní Modelový příklad (umělý a velmi zjednodušený, ale doufám, že ilustrativní) Představme si třídy Letadlo (poznámka: tento termín není přesný, ve skutečnosti je to letoun, tento termín však není všeobecně používán), ModelLetadla, Drak (Draco nobilis) a Balon (dopravní horkovzdušný, oficiální termín je bezmotorový aerostat). Rozborem zjistíme, že v našem programu se hodí, aby objekty těchto tříd sdílely vlastnost určující výšku nad zemí, a podobně reagovaly na požadavek na vzlet a přistání (jinak řečeno, umožňovali polymorfní přístup). Po implementaci tak všechny třídy mají vlastnost Vyska se shodnou signaturou: int Vyska {get;}
a všechny obsahují dvojici (možná zdánlivě) stejných metod: bool Vzletni(int letovaVyska); void Pristan();
To, že se shodují signatury metod a vlastností, však ještě neznamená, že se shoduje i jejich chování či přesněji sémantika. Může se jednat o zcela náhodnou shodu resp. shoda může být pouze povrchní (např. pokud letadlo hlásí výšku ve stopách a drak je metrický). Proto je nutné definovat i kontrakt v podobě podmínek, které musí splňovat všechny implementace bez ohledu na to, v jaké se nacházejí třídě. Pro naše metody (a vlastnosti) by kontrakt mohl mít následující tvar: vlastnost Výška: vrací nezápornou hodnotu výšky měřenou v metrech (od země) metoda Vzlétni: Metodu lze volat jen v případě, že aktuální výška je nula. V opačném případě je vyvolána výjimka třídy ArgumentException. Tuto výjimku vyvolá i pokus o vzlet do cílové výšky 0. Cílová výška určuje požadovanou výšku v metrech, musí být kladná (větší než nula). Pokud se vzlet podaří, musí být skutečná dosažená výška v intervalu (0, požadovaná] a metoda vrací objekt true. Pokud se nepodaří, zůstává výška nulová a metoda vrací false. metoda Přistaň: Metodu lze volat kdykoliv (i když je původní výška nulová). Po provedení je výška rovna nule. Toto chování vychází ze známého tvrzení: že vše, co létá ve vzduchu, musí nakonec přistát či alespoň spadnout na zem. Kontrakt je napsán v přirozeném (zde v českém) jazyce a překladač proto nemůže kontrolovat, zda implementace metod u jednotlivých tříd tento kontrakt plní či nikoliv. Za plnění kontraktu jsou tak odpovědní jen programátoři, či jiní (lidští) členové vývojového týmu. Kontrakt se běžně uvádí v rámci dokumentace příslušných metod. 31
Neověřitelnost kontraktu překladačem má jeden klíčový důsledek (a to ve všech OOP jazycích) — překladač není schopen poznat, jaké třídy sdílejí určité metody či vlastnosti, tj. jaké objekty lze používat společně v rámci jediného kódu bez ohledu na jejich třídy. Proto je nutné explicitně specifikovat, jaké metody jsou sdíleny (tj. implementovány ve více třídách), a jaké třídy se tohoto sdílení účastní (tj. jaké třídy tyto metody ve svém rozhraní podporují). V jazyce C# se definice množiny sdílených metod označuje klíčovým slovem
interface . Interface (česky rozhraní ) definuje signatury metod, které jsou využívá
ny ve společném kontextu (zohledňují nějaký společný aspekt resp. chování několika objektů) a které mohou být implementovány v několika různých třídách. Název rozhraní pro tuto jazykovou konstrukci není zcela optimální, neboť stejný termín se využívá i pro množinu signatur každé třídy (bez ohledu, zda je, či není sdílena) resp. na abstraktnější úrovni pro seznam interaktivních podnětů, na něž dokáží reagovat objekty-instance tříd. Zde se však jedná pouze o tu část rozhraní, která je podporována objekty více tříd (a která povětšinou tvoří jen malou část rozhraní jednotlivých tříd). Autoři jazyka však převzali celý koncept z jazyka Java včetně označení. Některé jiné jazyky používají vhodnější označení protokol. Syntakticky se definice (sdíleného) rozhraní podobá definici třídy, liší se úvodním klíčovým slovem a především omezením na pouhé signatury metod (bez jakéhokoliv kódu a bez specifikace datových členů). Pravidlo: Rozhraní není třídou. Může však hrát roli typu interface ILetaci { int Vyska { get; } bool Vzletni(int cilovaVyska); void Pristan(); }
Všimněte si, že u vlastností je nutné specifikovat, zda jsou pouze pro čtení (mají jen getter), nebo je lze i měnit (mají jak getter tak setter). I metod se také neuvádí ome
zení přístupu (chybí zde klíčové slovo public ). Všechny metody rozhraní jsou totiž automaticky veřejné.
Rozhraní lze na této úrovni abstrakce interpretovat jako specifikace schopností objektů, které budou toto rozhraní implementovat). Proto je nelze ve většině případů popsat termínem v podobě podstatného jména (neboť lidské jazyky preferují pojmenování přesněji vymezených tříd objektů). Proto se jako jména rozhraní používají nejčastěji přídavná jména vyjadřující schopnost provedení nějaké akce (v češtině s příponou -telný, v angličtině -able), resp. aktivní participia (v češtině např. létací, angl. -ing), v češtině i přídavná jména účelová (typu: létací). V jazyce C# (ale nikoliv např. v Javě či některých dalších jazycích) navíc existuje striktní úzus začínat názvy rozhraní prefixem I (jako I-nterface).
32
Kontrakt (který je povinnou částí specifikace rozhraní) je nutno uvést v dokumentaci daného rozhraní. Jazyk C# umožňuje uvádět dokumentaci přímo ve zdrojovém textu pomocí tzv. dokumentačních poznámek. Lze je použít i u tříd a dalších jazykových konstrukcí, v případě rozhraní je to však tato poznámka téměř povinná. using System.Collections.Generic; / /
iletaci.cs
using System; interface ILetaci { / / / <summary> / / / letova vyska nad zemi v metrech / / / / / / / / / nezaporne cislo / / /
int Vyska { get; } / / / <summary> / / / zajisti vzlet objektu, / / / a to pokud mozno do pozadovane cilove vysky. / / / / / / <exception cref=”ArgumentException”> / / / vyjimka je vyvolana pokud je / / / a) pozadovana cilova vyska zaporna ci nulova, / / / b) pocatecni letova vyska nulova / / / / / / <param name=’cilovaVyska’> / / / cilova vyska nad zemi (kladna), / / / v pripade uspechu je konecna letova vyska / / / v intervalu (0, cilovaVyska] / / / / / / / / / true, pokud se vzlet podari jinak false / / /
bool Vzletni(int cilovaVyska); / / / <summary> / / / zajisti pristani ci alespon nerizeny dopad objektu. / / / Vzdy se podari, tj. konecna letova vyska je vzdy nulova. / / /
void Pristan(); }
Dalším krokem využití (sdílených) rozhraní pro dosažení žádaného polymorfismu je implementace tohoto rozhraní ve třídách, které jej hodlají podporovat (a jejíchž instance lze tudíž využívat společně). 33
Třída, která dané rozhraní implementuje, to musí dát explicitně najevo ve své hlavičce. Pouhá shoda signatur metod nestačí (může to být jen pouhopouhá náhoda). Všechny metody a vlastnosti z rozhraní musí být implementovány jako veřejné a jejich signatura musí být zcela identická se signaturou z definice rozhraní (rozdílné mohou být jen identifikátory parametrů, které mají jen informativní roli, i zde se doporučuji důslednou shodu). class Drak : ILetaci { / /
iletaci.cs
public int Vyska { get { return 0;} } public bool Vzletni(int cilovaVyska) { if (cilovaVyska <= 0) throw new ArgumentException("Neplatna␣cilova␣vyska"); return false; } public void Pristan() { } }
Hlavička třídy uvádí za dvojtečkou název implementovaného rozhraní. Tímto zápisem na jedné straně informujeme překladač o tom, že třída dané rozhraní implementuje a nad objekty lze tedy volat příslušné metody splňující kontrakt. Na straně druhé se tím zavazujeme dané metody implementovat, což si překladač ohlídá (bohužel však jen signatury, nikoliv kontrakt). Proto tento zápis vždy čteme: „třída Drakimplementuje rozhraní ILetaci“ (anglicky class X implements interface IY). Při implementaci nezapomeňte specifikovat veřejný přístup k metodě. Žádný jiný není
možný, ale i přesto jazyk C# vyžaduje explicitní uvedení klíčového slova public .
Naše implementace, i když velmi jednoduchá, kontrakt, alespoň formálně splňuje. Drak je sice de iure schopen letu (implementuje rozhraní ILetaci), de facto se však nikdy nevznese (vzlet končí vždy neúspěchem a letová výška je stále nulová). Zkusme proto uvést smysluplnější implementaci (i když stále nepříliš užitečnou). Třída ModelLetadla navíc kromě metod rozhraní ILetaci nabízí i další veřejné metody, které jsou typické jen pro modely letadel (nemají je všechny létací objekty). enum Motor { / /
iletaci.cs
Spalovaci, Elektricky, Gumickovy } class ModelLetadla : ILetaci { private int letovaVyska; public int Vyska {
34
get { return letovaVyska;} private set { letovaVyska = Math.Min(value, Motor == Motor.Gumickovy ? 10 : 50); } } public Motor Motor { get; private set; } / / specificka vlastnost public ModelLetadla (Motor motor) { this.Motor = motor; this.Vyska = 0; } public bool Vzletni(int cilovaVyska) { if (Vyska > 0 || cilovaVyska <= 0) throw new ArgumentException("Neplatna␣cilova␣vyska"); Vyska = cilovaVyska; return true; } public void Pristan() { letovaVyska = 0; } public override string ToString() { / / specifická metoda return string.Format("ModelLetadla:␣Vyska={0}", Vyska); } }
Implementace letové výšky využívá pro ukládání datový člen třídy int, jež je přístupný pouze pomocí vlastnosti výška (přičemž nastavení je omezeno pouze na vlastní metody, zde jen na konstruktor). Maximální letová výška je omezena, navíc toto omezení závisí na pohonu letadélka. Navenek je však však příslušná část rozhraní stejná jako i objektů třídy Drak, včetně splnění stejného kontraktu (i když zde naopak všechny vzlety skončí úspěchem). Kromě toho však instance třídy ModelLetadla nabízejí možnost zjištění pohonu (pomocí vlastnosti) a převod na řetězec. Než přejedeme k využití polymorfismu založeném na rozhraní ILetaci, doplníme alespoň v náznaku implementace dalších tříd podporujících toto rozhraní (protokol). Jsou to třídy Letadlo a Balon. Rozborem jejich chování (z pohledu cílové aplikace), zjistíme, že mají kromě létání ještě jeden společný rys: oba jsou to dopravní prostředky. Mohou tedy potenciálně převážet určitý počet pasažerů. Abychom sjednotily přístup ke všem dopravním prostředkům (kromě letadel a balónů jsou to samozřejmě i auta, vlaky apod.) zavedeme pro společné rozhraní. interface IDopravniProstredek { int MaximalniPocetPasazeru { get; }
35
}
Toto rozhraní obsahuje pouze jedinou vlastnost, jež v našem modelu spojuje všechny dopravní prostředky — maximální počet přepravovaných pasažérů. Rozhraní s jedinou metodou či vlastností jsou v OOP jazycích relativně běžná. Třídy Letadlo a Balon implementují obě rozhraní, jsou tudíž zároveň létacími objekty i dopravními prostředky: class Letadlo : ILetaci, IDopravniProstredek { public int Vyska {get { ... }} public bool Vzletni(int cilovaVyska) {...} public void Pristan() {..} public int MaximalniPocetPasazeru { get { ... } } / / další metody typické pro letadla
} class Balon :
ILetaci, IDopravniProstredek {...}
Celý systém tříd, jejich instancí a sdílených rozhraní lze graficky znázornit například takto:
Obrázek 2. Třídy a rozhraní – množinový pohled Tmavší kolečka označují jednotlivé objekty. Objekty se stejným chováním a tudíž i rozhraním jsou sdruženy do tříd (každý objekt patří právě jedné jedné třídě). Objekty, které sdílejí jen část svého rozhraní, se identicky či podobně se chovají jen v určitém kontextu jsou organizovány do (sdílených) rozhraní (interface, protokol). Na rozdíl od tříd může objekt splňovat libovolný počet rozhraní (např. objekty třídy Letadlo splňují dvě). Je také zřejmé, že pokud jeden objekt třídy podporuje nějaké sdílené rozhraní pak jej podporují všechny istance dané třídy (množinově řečeno: třída je podmnožina rozhraní). Nyní však zodpovíme hlavní otázku. Proč vlastně vytváříme rozhraní a jak je využijeme ve svém kódu? 36
Odpověď na otázku jak vychází z jediného tvrzení: Pravidlo: Rozhraní můžeme použít na místě datového typu a to ve všech kontextech vyjma volání konstruktoru. Podívejme se například na následující rozšiřující metodu: static class LetaciExtensions1 {
//
iletaci.cs
public static bool ZmenStav(this ILetaci letavec) { if (letavec.Vyska == 0) return letavec.Vzletni(100); else { letavec.Pristan(); return true; } } }
Tato metoda je polymorfní, neboť je aplikovatelná na jakýkoliv objekt, který imple
mentuje rozhraní ILetaci (ILetaci je uveden jako typ prvního, tj. this parametru), například na objekty tříd Drak, Letadlo, ModelLetadla i Balon. Polymorfní chování je umožněno uvedením identifikátoru rozhraní jako typu prvního parametru (namísto jména konkrétní třídy). Uvnitř metody je změněn stav létacího objektu z letícího na přízemní a naopak (to se nemusí vždy podařit, je proto vrácen příznak úspěchu). Z tohoto důvodu jsou nad objektem volány metody ze sdíleného rozhraní a to bez ohledu na to, jaká je skutečná třída objektu a jaká verze metody se zavolá (zda se například při vzletu použije triviální metoda draků nebo sofistikovanější metoda modelů letadel, apod.) Nad objektem, jehož typ je určen rozhraním, mohou být volány pouze metody tohoto rozhraní, nikoliv metody konkrétní třídy (resp. jiného prostředí). Nelze tak například zjistit typ motoru (přestože konkrétní objekt, jsa třídy ModelLetadla, může tuto vlastnost podporovat), resp. maximální počet pasažérů (parametr může být instancí třídy Drak a draci pasažéry nepřeváží). Všimněte si, že náš polymorfní kód závisí i na kontraktu metod příslušného rozhraní. Proto je například při změně stavu přistáním vždy vrácena hodnota true (neboť přistání vždy vede ke změně stavu). Rozšiřující metoda, jejíž první (this) parametr je typován rozhraním, může být použita na instanci libovolné třídy, která toto rozhraní implementuje. Všechny tyto zápisy jsou tudíž správné: Drak smak = new Drak(); smak.ZmenStav(); Console.WriteLine(smak.Vyska); / / /vypise 0 (stav se nepodarilo zmenit) ILetaci ufo = new Drak(); / / promenna je typovana rozhranim!
37
ufo.ZmenStav(); Console.WriteLine(ufo.Vyska); / / ufo je opět de iure letaci, de facto neletaci objekt (=drak)
ModelLetadla gumca = new ModelLetadla(Motor.Gumickovy); umca.ZmenStav(); Console.WriteLine(gumca); / / ModelLetadla: Vyska=10
Nyní se podívejme na o něco složitější (a o něco užitečnější) polymorfní metodu nad létacími objekty. static class LetaciExtensions2 { / /
iletaci.cs
public static bool HromadnyVzlet(this List letka, int cilovaVyska) { bool uspech = true; foreach (ILetaci letavec in letka) { uspech = uspech && letavec.Vzletni(cilovaVyska); / / pokusime se o vzlet vsech letadel
} if (!uspech) / / jiz vzletla letadla pristanou foreach (ILetaci letavec in letka) letavec.Pristan(); return uspech; } }
Tato rozšiřující metoda je určena pro seznamy, jejichž položkami jsou objekty implementující rozhraní ILetaci. Tento seznam ukazuje další využití polymorfismu: polymorfní kolekce, — kolekce, jejichž položky jsou různých tříd (i když formálně stejného typu). Metoda zajistí hromadný vzlet letky, v níž mohou být letadla, balóny a modely (a po změně implementace metody Vzletni dokonce i draci!). Navíc zajistí, že buď vzletí všichny objekty z letky anebo žádný z nich. Rozšiřující metoda opět využívá pouze metody z rozhraní ILetaci (samozřejmě se znalostí jejich kontraktu).
. V obou cyklech je řídící proměnná typována Jádrem metody jsou dva cykly foreach rozhraním (ve shodě s typem parametru) a proto mohou procházet polymorfní kolekci a provádět polymorfní volání metody Vzletni (resp. v druhém cyklu Pristan). Použití: / / using System.Linq;
List testovaciLetka = new List(); testovaciLetka.Add(new Letadlo()); / / pridame letadlo testovaciLetka.Add(new ModelLetadla(Motor.Spalovaci)); / / model testovaciLetka.Add(new Drak());
/ / a draka
38
bool uspech = testovaciLetka.HromadnyVzlet(2000); / / a pokusime se o hromadny vylet do 2km
Console.WriteLine(uspech);
/ / ocekavame false, draci maji problemy
/ / a otestujeme, zda jsou vsichni na zemi
Console.WriteLine(testovaciLetka .Select(letavec => letavec.Vyska).Max()); / / vypise maximum z vysek objektu (ocekavame 0)
Pro výpis maxima u výšek objektů je použito tzv. LINQ, a to v tzv. zřetězeném zápise. Na seznam (testovací letka) je zavolána (rozšiřující) metoda Select, jež jako parametr očekává funkci (v názvosloví C# delegáta), která je aplikována na každý prvek seznamu. Výsledná posloupnost prvků (přesněji tzv. iterátor, viz níže) obsahuje položky získané touto aplikací. V našem případě je to posloupnost výšek jednotlivých objek
tů, neboť tzv. lambda funkce letavec => letavec.Vyska zajistí, že na každý prvek je volána vlastnost Vyska (letavec je jméno parametru funkce, jeho typ je odvozen překladačem). Na posloupnost výšek (objektů třídy int) je volána rozšiřující metoda Max, která vrací největší prvek. K tomuto zápisu se ještě za chvíli vrátíme. Lambda funkce (název pochází z jazyka Lisp a odráží ne zcela jednoduchou matematickou teorii) je zápisem funkce (= přibližně statická metoda, resp. matematcká funkce), přijímající parametry a vracející objekt. V zapise není nikde uvedeno jméno (identifikátor) nově vytvářené funkce. Jinak řečeno, je to funkce (metoda) na jedno použití či literál anonymní funkce. V C# je tento zápis podporován od verze 3.0 a je běžně používán pouze pro zcela jednoduché funkce. C# 2010 [2], strana 529: stručný přehled lambda funkcí (bez zbytečné teorie)
Náš zápis letavec => letavec.Vyska můžeme číst jako funkci, která očekává objektparametr (označený v těle funkce identifikátorem letavec ). Tělo funkce (za dvojitou šipkou) určuje, že z tohoto objektu je získána hodnota vlastnosti Vyska, která je vrácena jako výsledek funkce. Lambda funkce neuvádí typ parametru ani typ návratové hodnoty (na rozdíl od metod, kde jsou tyto údaje povinné). Vše totiž může určit z kontextu, v němž je lambda funkce definována, a v němž bude také volána. Funkce totiž nelze znovu použít. I když na jiném místě uvedeme stejný zápis, je to již de facto i de iure jiné funkce! Z kontextu je zřejmé, že parametr je objekt implementující rozhraní ILetaci (neboť je volána na seznam objektů tohoto typu). Výsledný objekt je třídy ILetaci, neboť vlastnost Vyska vrací jen čísla tohoto typu. Vytvoření seznamu létacích objektů se neliší od vytvoření plně homogenních seznamů (tj. s položkami jediné třídy): List letka = new List(); / / polymorfni seznam List dracno = new List(); / / homogenni seznam
39
Všimněte si, že v rámci (obou) konstruktorů seznamu se nevytváří žádný létací objekt, neboť seznamy jsou na začátku prázdné. To je důležité, neboť přímé instance rozhraní vytvářet samozřejmě nelze. Rozhraní není třída a nelze tudíž vytvářet jeho instance. Pravidlo: Nelze vytvářet přímé instance rozhraní (lze však vytvářet instance tříd, jež rozhraní implementují) ILetaci ufo = new ILetaci(); / / jaké třídy by byl nově vytvořený objekt?
Lze však přirozeně vytvářet objekty konkrétních tříd, na něž odkazuje proměnná typovaná odkazem: ILetaci ufo = new Letadlo();
To se hodí v případě, že v dalším kódu chceme využívat jen metody rozhraní, a kód je tak potenciálně polymorfní (v budoucnu můžeme definici přepsat např. na ILetaci ufo = new Balon(); )
ufo.Vzletni(100);
To je samozřejmě OK. Metoda je definována v rozhraní ILetaci (kód bude fungovat i při změně třídy objektu) Console.WriteLine( ufo.MaximalniPocetPasazeru
);
To je naopak syntaktická chyba, neboť rozhraní ILetaci neobsahuje vlastnost MaximalniPocetPasazeru. některé létací objekty ji sice podporují (pokud jsou zároveň dopravními prostředky) jiné však nikoliv (Drak, ModelLetadla). Tím se dostáváme k zajímavému problému. Přestavme si, že chceme vytvořit metodu, která přinutí k přistání všechny létající objekty, jejichž potenciální počet pasažérů je větší nebo roven číslu 𝑛. Tato metoda by se hodila, pokud by řízení letového prostoru chtělo zajistit přistání letadel (včetně balónů, apod.) s větším počtem pasažérů např. při určité hrozbě ve vzdušném prostoru. I když je algoritmus v zásadě triviální, problémy přináší dosažení toho správného polymorfismu. public static void NucenePristani( this List letka, int n) { foreach (ILetaci letavec in letka) if (letavec.MaximalniPocetPasazeru >= n) letavec.Pristan(); }
Tento kód se nám nepodaří ani přeložit, neboť překladač ohlásí, že rozhraní ILetaci neobsahuje metodu MaximalniPocetPasazeru: 40
error CS1061: Type ‘ILetaci’ does not contain a definition for ‘MaximalniPocetPasazeru’ and no extension method ‘MaximalniPocetPasazeru’ of type ‘ILetaci’ could be found (are you missing a using directive or an assembly reference?) A má bohužel pravdu (a řešením není jednoduché dodání direktivy using!). Podobně dopadneme, budeme-li this-parametr typovat rozhraním IDopravniProstredek. public static void NucenePristani( this List letka, int n) { foreach (IDopravniProstredek prostredek in letka) if (prostredek.MaximalniPocetPasazeru >= n) prostredek.Pristan(); }
Nyní nelze pro změnu volat metodu Pristan (autobusy a vlaky nepřistávají, lodě zdánlivě ano, ale sémantika přistání se u nich podstatně liší). Jaké tedy zvolit řešení? Řešením je vytvoření nového rozhraní, které spojí létací objekty a dopravní prostředky: Toto nové rozhraní vznikne formálně rozšířením rozhraní ILetaci a IDopravniProstredek, přičemž nepřidává žádnou novou metodu či vlastnost. V případě rozhraní se tedy dvojtečka čte jako ”rozšiřuje” (rozhraní), nikoliv ”implementuje” (rozhraní). Je navíc zřejmé, že každý létací prostředek (instance třídy implementující rozhraní ILetaciProstredek) je zároveň létací objekt (implementuje rozhraní ILetaci) a také dopravní prostředek (implementuje rozhraní IDopravniProstredek). public static void NucenePristani( this List letka, int n) { foreach (ILetaciProstredek letavec in letka) if (letavec.MaximalniPocetPasazeru >= n) / / je dopr. prostředek letavec.Pristan();
/ / a zároveň létá
}
Při použití však narazíme na problém: List letka = new List(); letka.Add(new Letadlo()); letka.NucenePristani(10);
Při pokusu o přidání letadla do seznamu letka, překladač tvrdí, že letadlo neimplementuje rozhraní ILetaciProstredek. Překladač C# je zde trochu formalistický, neboť vyžaduje aby třída implementovala přímo spojené rozhraní, přestože rozhraní ILetaciProstredek de facto nerozšiřuje sjednocení rozhraní ILetaci a IDopravniProstredek. 41
I zde tedy platí, že rozhraní není pouhý výčet metod a rozhraní, ale formálně definovaná entita s jedinečným identifikátorem. Proto je nutné v hlavičce všech tříd implementujících jak rozhraní ILetaci tak IDopravniProstredek, nahradit tato rozhraní za jejich společné rozšíření — rozhraní ILetaciProstredek. Například u třídy Letadlo bude namísto hlavičky: class Letadlo: ILetajici, IDopravniProstredek
použita hlavička: class Letadlo: ILetaciProstredek
Instance třídy letadla poté implementují rozhraní ILetaci (lze na ně např, použít rozšiřující metodu ZmenStav a mohou hromadně vzlétat pomocí metody HromadnyVzlet), IDopravniProstredek a ILetaciProstredek (mohou nuceně přistávat pomocí rozšiřující metody NucenePristani). Toto spojování rozhraní samozřejmě není povinné, neboť třída může implementovat libovolný počet rozhraní. Je nutné pouze v případě, že potřebujeme metodu, která vyžaduje polymorfismus nad objekty splňujícími vícero rozhraní.
Základní knihovní rozhraní Rozhraní se v jazyce C# používají i v rámci standardní knihovny a některé z těchto rozhraní jsou klíčová pro celý charakter jazyka. Rozhraní IComparable a IComparable Nejjednodušší z těchto rozhraní je System.IComparable. Toto rozhraní implementují všechny třídy, které chtějí definovat jednoznačné uspořádání meezi svými položkami (například číselné množiny, řetězce, časové údaje, atd.). Definice tohoto rozhraní jednoduchá: public interface IComparable { int CompareTo(object other); }
Každá třída implementující toto rozhraní musí implementovat metodu CompareTo. Tato metoda očekává jako svůj jediný parametr objekt libovolné třídy (využívá se zde další typ OOP polymorfismu tzv. dědičnosti, třída object v sobě zahrnuje objekty všech tříd). Návratová hodnota je číslem, které určuje uspořádání mezi adresátem metody (this) a jejím parametrem (other). • je-li this < other, pak je vráceno jakékoliv záporné číslo, • je-li this = other (z hlediska definovaného uspořádání), pak je vrácena nula, • a je-li this > other, je vráceno libovolné kladné číslo
42
Jak si to zapamatovat? Stačí si pamatovat, že u číselných objektů vrací a.CompareTo(b) číslo se stejným znaménkem jako odečtení a-b (např. pro a=b, je
a-b rovna nule, atd.)
Tento typ porovnání se běžně příliš nepoužívá, lze však na něj snadno převést běžné operátory porovnání: běžný tvar
s využitím CompareTo
a
a.CompareTo(b) < 0
a <= b
a.CompareTo(b) <=0
a>b
a.CompareTo(b) > 0
a >= b
a.CompareTo(b) >=0
Některé třídy (čísla, řetězce a plno dalších) přímo poskytují i příslušné operátory. Ty však nejsou častí rozhraní (musejí se definovat zvlášť a nejsou povinné). U polymorfního kódu je proto možné využívat pouze metodu CompareTo, nikoliv relační operátory. Při využívání rozhraní IComparable je vhodné znát i kontrakt metody CompareTo, který není omezen jen na interpretaci návratové hodnoty. Kontrakt vychází z definice relace neostrého uspořádání, jak je definován v algebře. • a.Compare(a) – dvě identické hodnoty jsou si rovny (vychází z vlastnosti [anti]reflexivnost) • a.Compare(b) == -b.Compare(a) (operace uspořádání je slabě antisymetrická) • a.Compare(b) == b.Compare(c), pak a.Compare(b) == a.Compare(c) (tranzitivnost) Nyní však přejdeme k ukázce využití tohoto rozhraní: static class IComparableExtensions { public static bool Between(this IComparable x, IComparable a, IComparable b) { return x.CompareTo(a) >= 0 && x.CompareTo(b) <= 0; } public static IComparable Maximum(this IComparable x, IComparable y) { return x.CompareTo(y) >= 0 ? x : y; } }
První rozšiřující metoda testuje zda adresát leží (ve standardním uspořádání) mezi 43
dvěma dodatečnými parametry metody. Všechny parametry metody musí implementovat rozhraní IComparable (nikoliv však relační operátory). Použití je snadné: 2.Between(1, 4) / / true "kocka".Between("medved", "pes") / / false
V prvém případě se používá přirozené uspořádání čísel (založené na operaci < resp. <=), ve druhé tzv. lexikografické uspořádání (jako v abecedně řazeném slovníku). Ne vše je však zcela v pořádku: Zkusme například vypsat výsledek následujícího volání: "kocka".Between(10, 15)
Toto volání je rozhodně sémanticky nesmyslné, neboť nelze porovnávat prostý řetězec s čísly (nelze tudíž určit ani to, zda řetězec mezi dvěma čísly leží). Navzdory tomu se tento kód bez problémů přeloží, a až teprve za běhu způsobí výjimku (ArgumentException). Důvodem tohoto chování jsou omezené schopnosti specifikace kontraktu v hlavičce metody CompareTo a tím i našich rozšiřujících metod. Hlavička metody CompareTo, totiž popisuje jen to, že objekty implementující IComparable jsou porovnatelné s jakýmkoliv jiným objektem (ten ani nemusí implementovat rozhraní IComparable!). Ve skutečnosti je však uspořádání omezeno jen na instance dané třídy. Tato skutečnost může být v případě rozhraní IComparable zmíněna jen v neformálním kontraktu v rámci dokumentace: The parameter, obj, must be the same type as the class or value type that implements this interface; otherwise, an ArgumentException is thrown. (MSDN: http://msdn. microsoft.com/en-us/library/system.icomparable.compareto.aspx)
Podívejme se jak se toto omezení projevuje v naší metodě Between. Oba dodatečné parametry (a, b) jsou typovány jako objekty implementující rozhraní IComparable. To však nic neříká, zda jsou či nejsou porovnatelné s objektem-this (adresátem rozšiřující metody) či mezi sebou. Ve skutečnosti je dokonce formálně zcela zbytečné, aby byly typovány rozhraním IComparable. Jsou totiž použity jen jako parametr metody CompareTo, která porovnatelnost těchto parametrů nevyžaduje. To je však jen formální hledisko (a vlastnost typového systému). Skutečnost (vyjádřená neformálním kontraktem) je samozřejmě zcela jasná: všechny parametry metody Between (včetně adresáta) musejí být instance stejné třídy, a tento typ musí podporovat rozhraníIComparable. Z tohoto pohledu se podívejme na druhou rozšiřující metodu, která přijímá dva objekty (jeden z nich, je předáván jako adresát) a vrací větší z nich. Je zřejmé, že oba objekty musejí být stejného typu (třídy) stejně jako návratová hodnota. Zde se však (původní) omezení jazyka projeví i při (správném) použití. 44
Předpokládejme následující definici s inicializací: int i = 2.Maximum(5);
Ze sémantického hlediska je vše v pořádku. Maximem je objekt, který byl předán jako první parametr (neboť 5 > 2), — objekt třídy int representující číslo 5. Tento objekt lze přirozeně přiřadit do proměnné int. V staticky typovaném jazyce je však nutno vše nutno zkoumat prizmatem explicitně uvedených typů (ať už tříd nebo rozhraní). Metoda explicitně uvádí, že vrací objekt implementující rozhraní IComparable. To samozřejmě není totéž co objekt třídy int. Vrácený objekt nemusí nabízet rozhraní (použito v širším smyslu) třídy int jako např. aritmetické operátory (např. je-li to řetězec, pak jej nelze násobit či dělit) a proto jej překladač odmítne uložit do proměnné typu int (bez ohledu na skutečný stav) Pravidlo: Překladač určuje typy podle explicitního typování (určení typu) nikoliv podle skutečného stavu. Překladač je v tomto ohledu trochu jako byrokrat, kterého nezajímá skutečný stav, ale jen stav doložitelný dokumenty. V případě překladače však řešení naštěstí existuje – explicitní přetypování. int i = (int) 2.Maximum(5);
S explicitním přetypováním jsme se již setkali, při konverzi jednoduchých např. číselných typů. Zde však přetypování hraje jinou roli. Zde nedochází ke konverzi objektů, tj. ke vzniku ekvivalentního či podobného objektu jiné třídy, ale pouze dodáváme překladači informaci, kterou díky použití polymorfního kódu nemá. V tomto případě si můžeme být jisti, že původní objekt je již třídy int, přetypování vždy skončí úspěchem (a tak je tomu i v mnoha jiných případech). Překladač však pro jistotu vygeneruje ověřovací kód, který typ objektu zkontroluje za běhu. To sice trochu zpomalí přetypování, zabrání to však chybám, pokud by byl kód složitější (nikdo není dokonalý). Pokud k typové chybě dojde je vyvolána výjimka System.InvalidCastException Od verze 2.0 řeší jazyk C# problémy s rozhraním IComparable za pomoci spojení dynamického polymorfismu se statickým. Nová verze rozhraní má následující definici: public interface IComparable { int CompareTo(T other) }
Základním rozšířením oproti původnímu rozhraní je parametr označený identifikátorem T, jenž však není hodnotou, ale typem (dále jej budeme označovat jako typový parametr). Definice tak nezavádí jediné rozhraní, ale celou množinu rozhraní, která se od sebe liší jen příslušným typem. Existuje tak například rozhraní IComparable (kde za T je dosazena třída int) nebo IComparable (kde T = DataTime, celé jméno rozhraní se jmennými prostory je System.IComparable<System.DateTime>), a nespočetné množství dalších. 45
Jaký význam má tento typ T? Podíváme-li se na definici rozhraní (přesněji řečeno na signaturu metody CompareTo), tak snadno zjistíme, že je to typ se kterým se provádí porovnání. Například IComparable je rozhraní, které lze interpretovat jako rozhraní objektů, které jsou porovnatelné nebo lépe vyjádřeno uspořádatelné (právě jen) s instancemi třídy int. Otázkou zůstává, jaké objekty budou toto rozhraní podporovat (tj. budou v našem příkladě uspořádatelné s celými čísly). Odpověď je samozřejmě triviální, jsou to jen a pouze instance třídy int, neboť nemá smysl uspořádávat objekty různých tříd. Obecněji řečeno, pokud chce pro nějaký typ T definovat rozhraní pro uspořádání, pak by to mělo být rozhraní IComparable. Navíc může podporovat i (negenerické) rozhraní IComparable, pro případ že bude použit kontextu, kde se uplatňuje jen dynamický polymorfismus (což je případ i staršího knihovního kódu, jenž byl vytvořen před zavedením generických typů ve verzi 2.0). Že tomu tak skutečně je, lze vidět na definici třídy System.Int32 (zkrácené jméno int, hlavička definice je mírně zjednodušena) public struct Int32 : IComparable, IFormattable, IConvertible, IComparable, IEquatable
Kromě rozhraní popisujících uspořádání (IComparable, resp. negenerické IComparable), je podporováno rozhraní IEquatable, které definují třídy, které podporují testování rovnosti (metoda Equals odpovídající operátoru == ). Toto rozhraní není tak důležité jako IComparable, neboť testování rovnosti podporují úplně všechny objekty prostřednictvím jiného polymorfního mechanismu (dědičnosti), ale hodí se v kontextu generických kolekcí, u metody Contains a příbuzných (určuje zda je hledaný prvek rovný prvku v kolekci). Rozhraní IConvertible implementují všechny základní (elementární) třídy, které chtějí podporovat vzájemnou převoditelnost (čísla, znaky, řetězce, DateTime). Toto rozhraní se nejčastěji používá prostřednictvím metod třídy System.Convert resp. (omezeně) pomocí explicitního přetypování. Toto rozhraní se neimplementuje u uživatelských tříd, neboť je součástí nízkoúrovňové podpory platformy .NET (přesněji CLR). To je štěstí, neboť by vyžadovala implementaci 17 metod. Posledním implementovaným rozhraním je IFormattable, které podporuje formátování objektů v rámci metody String.Format. Jediná metoda tohoto rozhraní se jménem ToString, umožňuje převést objekt na řetězec, přičemž lze pomocí formátovacího řetězce určit formát výstupu. Tím se tato metoda liší od stejnojmenné bezparametrické metody ToString, kterou podporují všechny objekty. Příklad použití: 2.5.ToString()
46
To je běžná metoda ToString (podporovaná všemi objekty), vrací řetězec, jehož formát neovlivníte (zde ”2,5” ). 2.5.ToString("E", null)
To už je volání metody z rozhraní IFormattable, první parametr určuje formát (zde vědecký s exponentem tzv. semilogaritmický), druhý jazykové nastavení (null určuje, že se použije aktuálně nastavený jazyk, v Česku je to nejčastěji čeština). Výsledkem je řetězec ”2.500000E+000” . Tato metoda se používá nejčastěji v rámci formátovací specifikace statické metody String.Format (všimněte si formátovacího znaku ”E” v popisovači). string.Format(”x = {0:E}”, 2.5) Rozhraní IFormattable je sice možno implementovat i u uživatelských tříd, neděje se to však příliš často, neboť povětšinou postačuje fixní převod na řetězec (např. pro účely ladění). My si však (poněkud netradiční) implementaci vyzkoušíme v následující kapitole o dědičnosti. IEnumerable Rozhraní IEnumerable (plně kvalifikované jméno je System.Collection.IEnumerable) je jedno z klíčových rozhraní jazyka C# a celé platformy .NET. Toto rozhraní bylo primárně vytvořeno pro kolekce, které podporují postupné procházení svých prvků, jeden po druhém tzv. enumeraci resp. iteraci (angl. iteration). Termín iterace je mnohem déle zavedený a šířeji používaný (např. na Wikipedii je základní článek o problematice pod heslem iterator) a je využíván ve většině jazyků, který tento koncept podporují (např. C++, Java, Python). Termín enumerace (a tudíž i enumerátor, atd.) se navíc částečně překrývá s termínem enumerated type (česky výčtový typ), což je zcela nesouvisející koncept. Z tohoto důvodu budu raději používat termín iterace a termíny odvozené (iterátor, apod.). Na druhou stranu se termín iterátor často používá v C# pro mechanismus vytváření iterátorů (enumerátorů) pomocí speciálního zápisu využívající klíčové slovo yield . Tento mechanismus se však mnohem častěji označuje jako generátor iterátorů (zkráceně generátor). C# 2010 [2], strana 294: popis iterátorů generátorů (v němž je však slovo iterátor na několika místech použito v našem významu) Objekt je iterovatelný (enumerovatelný) tehdy, když poskytuje tzv. iterátor (enumerátor, angl. iterator resp. enumarator), tj. objekt, který na požádání postupně poskytuje jednotlivé prvky kolekce, či obecněji jakoukoliv posloupnost prvků (dokonce i potenciálně nekonečnou). Rozhraní IEnumerable je proto velmi jednoduché:
47
interface IEnumerable { IEnumerator GetEnumerator(); }
Jediná metoda rozhraní vrací po zavolání nový iterátor, nastavený do počátečního stavu (viz níže). Iterátorem je jakýkoliv objekt implementující rozhraní IEnumerator. Toto rozhraní je jen o málo složitější než IEnumerable (má však o něco složitější kontrakt). interface IEnumerator { Object Current { get; } bool MoveNext(); void Reset(); }
Klíčovou metodou je metoda MoveNext, která buď zajistí posun na další prvek posloupnosti (seznamu, apod.) a vrátí true , nebo vrátí false (žádný další prvek už neexistuje a posun se tudíž nezdařil). Tato metoda musí být jako první volána na nově vzniklý iterátor, neboť se tak zajistí posun na první prvek. Metoda Current vrací aktuální prvek posloupnosti. Může být volána i opakovaně bez mezilehlého posunu (v tomto případě vrací stále tentýž prvek). Protože není známo jakého typu je prvek, je tento typován jako object, což je (jak již víme) třída zahrnující všechny objekty v systému. Pro použití musí tedy být formálně explicitně přetypován! V jednom případě je však chování této vlastnosti nedefinováno, a to tehdy pokud je volána na nově vzniklý iterátor bez předchozího posunu pomocí metody MoveNext. After an enumerator is created or after the Reset method is called, the MoveNext method must be called to advance the enumerator to the first element of the collection before reading the value of the Current property; otherwise, Current is undefined. Co vlastně znamená termín nedefinováno ? (angl. undefined state). V zásadě je to eufemismus namísto tvrzení: může se stát cokoliv a povětšinou to co nejméně očekáváte. A pokud už to čekáváte, tak se to může změnit v další verzi knihovny (a jak je zvykem tak k horšímu) Jinak řečeno může vzniknout výjimka (ale předem nevíte která) resp. může být vrácen haus-objekt (rozšíření konceptu ”hausnumera” na nečíselné třídy), tj. téměř náhodný výsledek, který však může být shodou okolností interpretován jako rozumný či dokonce správný Jaký je důsledek tohoto chování pro programátora? Snažte se za každou cenu vyhnout volání metody v nedefinovaném kontextu. Pokud to totiž omylem učiníte, může se stát, že si nevšimněte podivného chování a chybu dlouho neodhalíte (resp. odhalí ji
48
až zákazník v nejméně vhodném okamžiku). A pokud už metodu s nedefinovaným kontraktem implementujete, zvolte pokud možno co nejrozumnější a nejlépe odladitelné chování (ve většině případů je to vyvolání jakékoliv rozumné výjimky). Je zajímavé, že pokud metodu zavoláte po vyčerpání iterátoru (poté co MoveNext vrátí false ), je chování definované, je vyvolána výjimka (bohužel dokumentace neříká, jaké třídy tato výjimka bude). Zajímavý je i kontrakt metody Reset. Ta by měla podle názvu uvést iterátor do počátečního stavu (před posunem na první položku), ale to není v souladu s globálním kontraktem iterátoru: jednosměrný iterátor bez možnosti výmazu, vkládání či záměny položek. Proto se obecně doporučuje tuto metodu neimplementovat, tj. nic neprovádět a vyvolat výjimku NotSupportedException (i toto resp. právě to splní kontrakt). Pravidlo: Iterátor v .NET je pouze jednoprůchodový a jednosměrný. Potřebujete-li více průchodů musíte vždy vytvořit novou instanci iterátoru! Nyní, když známe metody a vlastnosti rozhraní, můžeme přejít k jeho použití. Rozhraní je vytvořeno tak, že objekty, které jej implementují (např. kolekce) se snadno procházejí pomocí cyklu while . List seznam = new List(); / / ukazkova kolekce / / naplneni kolekce (pro stručnost vynechano)
IEnumerator it = seznam.GetEnumerator(); / / ziskame novy iterator/enumerator
while( it.MoveNext() ) { / / dokud neni konec, posun na dalsi int polozka = (int) it.Current; / / ulozeni polozky Console.WriteLine(polozka); }
První řádek kódu není ještě polymorfní, neboť využívá konkrétní třídu: seznam hodnot třídy int. Pak však následuje získání iterátoru (enumerátoru), což už je aktivní využití polymorfismu. Při volání se totiž neomezujeme na objekty jediné třídy (zde například seznamu), ale stejný kód bude funkční pro libovolný objekt implementující rozhraní IEnumerable (jinou kolekci, generátor hodnot, apod.). Stejně tak nevíme nic o přesné identitě vráceného objektu (jaké je třídy, jakou má representaci), pouze to, že implementuje rozhraní IEnumerator. Přesto nad ním můžeme volat metodu MoveNext a tak se postupně posouvat mezi položkami. Zcela v souladu s kontraktem se nejdříve posuneme, ověříme zda se to povedlo (MoveNext vrátilo true ) a teprve pak prvek získáme. Všimněte si, že to funguje i s prázdným seznamem (už první posun se nepovede a tak není ani jednou provedeno tělo cyklu). Protože iterátor nenese žádnou informaci o typu položky (vlastnost Current vrací položku jako zcela obecný objekt), musí být objekt vrácený explicitně přetypován (je 49
to opět formální přetypování, typ předem známe, neboť List může obsahovat pouze int-ové hodnoty). Toto omezení je v modernějších verzích jazyka C# vyřešeno stejně jako u rozhraní IComparable pomocí generické verze rozhraní v podobě IEnumerable, kde T je opět libovolný typ odpovídající typu položek, které jsou vráceny (plně kvalifikované jméno rozhraní je System.Collections.Generic.IEnumerable) public interface IEnumerable : IEnumerable { IEnumerator GetEnumerator(); }
Je zřejmé, že například seznam List implementuje rozhraní IEnumerable, neboť pouze v tomto případě metoda GetEnumerator vrací iterátor implementující rozhraní IEnumerator (= iterátor vracející položky typu int). Všimněte si také, že rozhraní IEnumerable rozšiřuje rozhraní IEnumerable, tj. objekt implementující rozhraní IEnumerable musí implementovat i rozhraní IEnumerable (a tudíž i dvě téměř identické metody GetEnumerator, viz praktický příklad). Generická verze rozhraní IEnumerator se mírně liší od verze generické (nejen tedy v dodání generického typu položek). public interface IEnumerator : IDisposable, IEnumerator { T Current { get; } }
Rozhraní IEnumerator rozšiřuje rozhraní IEnumerator. Objekt tohoto rozhraní musí implementovat jeho metody (MoveNext, Reset) a jeho vlastnost Current vracející objekt bez určení konkrétního typu (typovaný třídou object). Navíc musí implementovat metodu Dispose z rozhraní IDisposable (neboť i toto rozhraní rozšiřuje, viz dále). Jedinou metodou/vlastností, jež je přidána přímo v rozhraní IEnumerator je vlastnost Current, která vrací přímo typovaný objekt (tj. například u rozhraní IEnumerator vrací objekt typovaný třídou bool). Hodnotu vlastnosti tak lze využívat i bez přetypování a s plnou statickou kontrolou typů (typovou chybu odhalí již překladač). Na počátku této sekce jsem prohlásil, že rozhraní IEnumerable (resp. jeho novější verze IEnumerable) patří k nejdůležitějším rozhraním jazyka C# a celé platformy .NET. Přesto jsme se s ním předtím setkávali je sporadicky (například v signaturách metod kolekcí). Jak je to možné? Důvodem je velmi dobrá integrace rozhraní IEnumerable do jazyka C#, která většinu jeho použití dovedně ukrývá. Nejdůležitější konstrukcí jazyka, která toto rozhraní využívá je cyklus foreach. Zatím jsme tento cyklus popisovali jako cyklus přes všechny prvky kolekce. Ve skutečnosti lze tento cyklus použít nad jakýmkoliv iterovatelným objektem (= objektem implementujícím rozhraní IEnumerable resp. IEnumerable) resp. (méně častý příklad) přímo nad iterátorem (= objekt s rozhraním IEnumerator resp. IEnumerator). 50
Ve skutečnosti je cyklus foreach jen syntaktická zkratka (syntaktické pozlátko) za cyklus while nad příslušným iterátorem, jenž je v případě kolekcí získán voláním metody GetEnumerator z rozhraní IEnumerable. List seznam = new List(); foreach(int prvek in seznam) { ... }
je ekvivalentní zápisu: while(it.MoveNext()) { int element = it.Current; / / není třeba přetypovat ... }
Z tohoto zápisu lze jednoduše odvodit chování cyklu foreach. Možné je pouze postupné procházení prvků, během cyklu nelze měnit hodnotové položky, nelze vkládat odebírat či zaměňovat položky, neboť se prochází pouze iterátor nikoliv seznam. Pokud byste během procházení položky vložili či odebrali přímo přes objekt seznamu, pak se iterátor stává nevalidním a jakýkoliv pokus jej využít (posunout na další položku, apod.) vede k vyvolání výjimky (iterátory kolekcí zůstávají se svými kolekcemi ve spojení i po svém vzniku). Na druhou stranu lze vnořovat dva cykly přes stejnou kolekci do sebe, neboť každá úroveň používá nový (a zcela nezávislý) iterátor (ten je pokaždé získán novým voláním metody GetEnumerator). U některých kolekcí může být iterace o něco složitější. Slovník (Dictionary) je iterovatelný, neboť podporuje rozhraní IEnumerable.KeyCollection (to celé je jméno třídy, třída je definována jako interní uvnitř třídy slovníku). To však není pro většinu uživatelů důležité, neboť jim stačí vědět, že tento objekt implementuje rozhraní IEnumerable (a samozřejmě i jeho negenerickou variantu). Dictionary<string, DateTime> udalosti = new Dictionary<string, DateTime>(); foreach(string oznaceni in udalosti.Keys) {...}
Tento cyklus iteruje přes iterátor typu IEnumerator<string>, který je získán voláním metody GetEnumerator nad objektem třídy Dictionary<string, DateTime>.KeyCollection (tento objekt je získán pomocí vlastnosti Keys slovníku události a podporuje rozhraní IEnumerable<string>). Jak již bylo naznačeno jsou iterovatelné objekty častým parametrem metod kolekcí, především těch které kolekce naplňují. Klasickým příkladem je metoda AddRange generického seznamu List: 51
public void AddRange(IEnumerable collection)
Jak lze vidět parametrem může být libovolný objekt poskytující rozhraní IEnumerable (kde T je typ položek seznamu, do nehož přidáváme). Po získání iterátoru (GetEnumerator) je tento iterátor postupně procházen a jednotlivé získávané hodnoty jsou přidávány do seznamu, jenž je adresátem metody. Jaké jsou hlavní výhody použití rozhraní IEnumerable oproti přímému předání kolekce? Jako zdroj prvků nemusí být použit pouze seznam, ale libovolná kolekce implementující rozhraní IEnumerable. Ve skutečnosti to může být libovolný objekt poskytující příslušné rozhraní, nikoliv jen kolekce (název parametru v dokumentaci je matoucí). / / using System.Linq;
seznam.AddRange( Enumerable.Range(5,8) ) ;
Statická metoda Enumerable.Range vrací objekt, který poskytuje iterátor vracející postupně hodnoty v požadovaném intervalu (včetně) s krokem 1. V našem příkladě jsou do seznamu přidána čísla 5,6,7 a 8. Při vytváření iterátoru není použita žádná dočasná kolekce, ale jednotlivé hodnoty jsou vypočítány až v okamžiku, kdy jsou potřeba. Není tak problém vytvořit iterátor vracející miliardy čísel, jejichž uložení do kolekce by zaujímalo gigabyty. Nejnovější oblastí, v níž se rozhraní IEnumerable nachází uplatnění je tzv. LINQ . LINQ (zavedený ve verzi C# 3.0) je nejčastěji označován jako univerzální dotazovací jazyk ve stylu SQL vestavěný přímo do jazyka C#. Ve skutečnosti je to mnohem obecnější rozšíření jazyka umožnující podobně zpracovávat data z různých zdrojů, přičemž jednotícím prvek je právě rozhraní IEnumerable resp. IEnumerable s jeho sémantikou postupného procházení dat. C# 2010 [2], strana 553: jemný a stručný úvod do LINQ I když LINQ není (navzdory všeobecnému přesvědčení) složitou a pro začátečníky zcela nepochopitelnou technologií, je i úvodní kurs LINQ zcela mimo časové možnosti úvodního kursu programování. Proto se s LINQ prozatím setkáte jen v malých ukázkách v rámci komplexnějších příkladů. S LINQ jsme se dokonce již jednou setkali: testovaciLetka.Select(letavec => letavec.Vyska).Max()
TestovacíLetka byla proměnná odkazující seznam létacích objektů (přesněji objektů implementujících naše rozhraní ILetaci). Tento seznam implementuje (mimo jiné) i rozhraní IEnumerable (specializovaná verze generického rozhraní IEnumerable). Nad tímto rozhraním pracuje rozšířená (a opět generická) metoda Select, která vrací iterovatelný objekt s rozhraním IEnumerable. Funkce, jež je parametrem metody, je volána na každý prvek poskytnutý původním iterátorem a vrací 52
nový iterátor, jenž poskytuje postupně vracené hodnoty (ty jsou typuint, neboť vyjadřují výšku v metrech). Na tento nový iterátor (resp. na iterovatelný objekt, jenž ho poskytuje) je volána metoda Max (plně kvalifikované jméno této rozšiřující metody je System.Linq.Enumerable.Max). Tato metoda postupně prochází iterátor a nalezne jeho největší prvek. Na závěr kapitoly věnované rozhraní IEnumerable se ještě podíváme na některá další rozhraní spojená s kolekcemi. Východiskem nám bude definice třídy List: class List : IList, ICollection, IList, ICollection, IReadOnlyList, IReadOnlyCollection, IEnumerable, IEnumerable { ... }
Jak lze vidět, seznam kromě nám již známých rozhraní IEnumerable (a jeho generické verze IEnumerable) implementuje i rozhraní I List, ICollection, IReadOnlyCollection a IReadOnlyList (některé i v negenerické verzi). Základem je posloupnost postupně rozšiřujících se rozhraní. Nejobecnější rozhraní je IEnumerable, které podporuje jen postupné procházení prvků. Rozhraní ICollection ho rozšiřuje jen o vlastnosti a metody podporované všemi kolekcemi (vlastnosti Count, IsReadOnly; metody Add, Clear, Contains, CopyTo a Remove) bez ohledu na jejich interní strukturu. Dalším rozšířením je rozhraní IList, které podporují jen kolekce s lineárním uspořádáním prvků a tím i možností indexace (např. různé typy seznamů). Zde přibývá tzv. indexer (dvojice metod podobná vlastnosti, která však podporuje předání dodatečného parametru – indexu). Přibývají i další metody využívající resp. vracející index (IndexOf, Insert a RemoveAt). Rozhraní IReadOnlyCollection a IReadOnlyList jsou pozdější přídavkem (od verze C# 5.0). Podobají se rozhraním bez specifikace ReadOnly, avšak poskytují pouze vlastnosti a read-only indexery, neboť ty lze použít i u neměnných (read-only) kolekcí. Celkovou představu o vztahu těchto rozhraní si můžete udělat z následujícího obrázku. <> IEnumerable
<> IEnumerable
<> ICollection
<> ICollection IContainer
<> IReadOnlyCollection
<> IList
<> IList
<> IReadOnlyList
List
53
Obrázek 3. Rozhraní implementovaná generickým seznamem Rozhraní IDisposable Rozhraní IDisposable je další z klíčových rozhraní jazyka C#. Implementují je objekty, které spravují nějaké prostředky, které je nutno po použití uvolnit. S tímto rozhraním je úzce spojena konstrukce using , které se nejčastěji používá se souborovým vstupem a výstupem, Rozhraní IDisposable je extrémně jednoduché: interface IDisposable { void Dispose(); }
Kontrakt také není příliš složitý. Je-li zavolána metod Dispose, pak jsou uvolněny všechny prostředky vlastněné objektem (je např. uzavřen proud, soubor, uvolněna bitmapa z paměti systému apod.). Metodu lze volat i vícenásobně, přičemž opakované volání nijak nezmění stav objektu (uvolněné prostředky, nelze samozřejmě znovu uvolnit). Je nutné zdůraznit, že se uvolňují jen externí prostředky nikoliv vlastní objekt. Ten po volání stále existuje a je uvolněn až sběračem smetí (pokud na něj neodkazuje žádný odkaz). V mezidobí objekt stále existuje, je však téměř nepoužitelný (většina metod vede buď k vyvolání výjimky nebo v horším případě k nedefinovanému chování. Metoda Dispose je nejčastěji volána při opuštění bloku using (to je to nejlepší místo). Může být volána i přímo (to už není tak dobré, neboť volání nemusí být provedeno např. při vzniku výjimky). Poslední možností je automatické volání metody před uvolněním objektu (pozdě, ale přece). Aby vše fungovalo, tak jak by mělo (tj. byl splněn kontrakt) musí být metoda implementována v souladu se speciálním implementačním vzorem (jeho popis najdete v dokumentaci, v sekci ”Implementing a Dispose Method”). / / using System.IO;
using(TextWriter writer = File.CreateText("hello.txt")) { writer.WriteLine("Ahoj␣operacni␣systeme"); }
Metoda Dispose je volána nad objektem odkazovaným z proměnné writer a to hned v okamžiku, kdy řízení opouští blok using (ať už je to normálním dosažením konce bloku nebo vznikem výjimky). Metoda Dispose uzavře soubor a tím se uvolní prostředky operačního systému s ním svázané (v Unixu jsou to záznamy ve dvou tabulkách a kešovaná kopie tzv. i-uzlu). Opuštěním bloku také zaniká lokální proměnná writer a tím zaniká jediný odkaz na objekt zapisovatele. Tím se i samotný objekt stává potenciálně uvolnitelným, avšak ke skutečnému uvolnění dochází až při spuštění sběrače smetí 54
(angl. garbage collector), což může být o mnoho milisekund či dokonce sekund nebo minut později, někdy až při ukončení programu.
55
Ukázkové programy Peníze Funkci rozhraní (vestavěných i uživatelských) si ukažme na praktickém příkladě. Je samozřejmě výrazně zjednodušený, může se však stát základem skutečně užitečné implementace. Návrh Základem aplikace jsou (jak jinak) peníze. Peníze se mohou vyskytovat v různých podobách, my však budeme (pro jednoduchost) uvažovat jen dvě: bezhotovostní peníze na bankovním účtu (zjednodušený, ale se základní podporou kontokorentu) a hotovostní platidla (mince, či peníze). Všechny peněžní objekty mají společnou vlastnost: mají nějakou hodnotu vyjádřitelnou v měnových jednotkách (prozatím budeme podporovat jen jednu měnu – naší korunu českou). Základní operací na nejvyšší úrovni bude převod peněz na účet a to jak z jiného účtu tak v hotovosti (v mincích a bankovkách). Při promýšlení implementace převodu su jistě brzy povšimnete základního rozdílu mezi platidlem a účtem. Platidel může být v zásadě obrovské množství (rozhodně více než účtů), ale všechny mince či bankovky o stejné nominální hodnotě jsou vzájemně zaměnitelné (je jedno jakou máme právě stokorunu, výsledek např. po vložení na účet je stejný). Shoda nikoliv podle objektů (instancí), ale podle obsahu je typická pro objekty s hodnotovou sémantikou (zkráceně hodnoty, angl. value semantics). Kromě čísel jsou to typicky fyzikální jednotky, peněžní částky, ale třeba i cestující (pokud nás nezajímá identita, ale jen jejich základní typ). Vždy však záleží na kontextu resp. problémové doméně. I v případě peněz je někdy výhodné odlišovat jednotlivé instance bankovek např. internetových sledovačích typu ”Where’s George?” (www.wheresgeorge.com). Objekty s hodnotovou sémantikou lze representovat buď pomocí hodnotového typu nebo pomocí referenčního typu s neměnnými objekty. V případě hodnotového typu existuje neomezené množství kopií stejného objektu v paměti počítače, ale všechny se považují za identické. Tato representace je typická například pro čísla. Při representaci referenčním typem musí být objekty neměnné, neboť při změně hodnoty objektu by docházelo k zásadnímu rozporu: na jednu stranu (navenek) se identita objektu nemění (je to stále tentýž objekt na stejném paměťovém místě), na straně druhé se jedná o zcela jiný objekt (liší se totiž hodnotou). Tento rozpor jasně vyvstane, pokud je na objekt odkazováno z více míst (kolekcí, složených objektů). Toto sdílení je nejen možné, ale i vysoce efektivní (je zbytečné udržovat více kopií stejné hodnoty). Ve skutečnosti je nejefektivnější udržovat jen jednu kopii každé instance hodnoty a tu z různých míst odkazovat. V našem případě nám např. stačí jen jedna kopie objektu representující určitý nominál (např. bankovku 100Kč). 56
Vícenásobný výskyt je representován jen mnohočetnými odkazy na tuto hodnotu. Při sdílení hodnot je požadavek na neměnnost zřejmý, neboť jakákoliv změna by se projevila u všech odkazů. Pokud bychom například (omylem) nastavili, že stokoruna má hodnotu 1000 Kč, pak by se tato změna projevila všude, kde je sdílený objekt odkazován (např. ve všech peněženkách). U bankovních účtů je situace zásadně jiná. V praxi totiž může existovat více (vzájemně odlišitelných) účtu se stejnou hodnotou (zůstatkem). Každá instance objektu má svou vlastní identitu a to bez ohledu na obsah. Tento typ objektů má tzv. referenční sémantiku (angl. reference semantics), neboť je lze je jednoduše odlišit i pouhým odkazem. Objekty s referenční sémantikou mohou samozřejmě mít i vnitřní identifikátor (např. u účtů je to jeho číslo), tento identifikátor je však pouze pomocný , neboť i v případě chyby v podobě kolize dvou stejných identifikátorů jsme objekty schopni odlišit už jen tím, že leží na jiném paměťovém místě. Hezkým příkladem objektů s referenční sémantikou jsou (známí) lidé. Jsme schopni je odlišit i v případě, že jsou (téměř) identičtí (např. jednovaječná dvojčata). Stačí, abychom tyto jinak neodlišitelné lidi viděli zároveň, neboť pak pro identifikaci stačí jen ukázat (= odkaz resp. reference). Jména lidí, i když mají také identifikační funkci, hrají jen vedlejší roli. V zásadě je nemusíme znát, a shoda dvou jmen nás nepřekvapí (a ve většině případů ani nedezorientuje). Já jsem si například jist, že jsem nebyl identický se svým tatínkem (vzpomínám). Hodnoty uložené uvnitř objektů s referenční sémantikou se mohou měnit (není to však nutné!). V našem případě je vhodné, aby se měnil zůstatek a to beze změny identity účtu. Potenciálně se dokonce může měnit i číslo účtu, zde je však otázkou zda se identita mění či nikoliv (záleží na zvoleném modelu). V aktuálním modelu tedy předpokládáme dvě třídy s výrazně odlišnou sémantikou. Za prvé jsou to hotovostní platidla (dále pro stručnost jen platidla), representovaná odkazem na sdílené (neměnné) objekty. Každý sdílený objekt representuje vždy jedno platidlo s různou nominální hodnotou a charakterem (v našem modelu jsou to pouze bankovky a mince). Nominální hodnota pro rozlišení nestačí, neboť může existovat mince a bankovka se stejnou nominální hodnotou (v současnosti tento případ nenastává, ale není to tak dlouho, co tato situace existoval u nominální hodnoty 50 Kč). bjekty této třídy nelze vytvářet neřízeně, neboť by měly být dokonale sdíleny (nejvýše jedna kopie každého objektu) a nelze vytvářet libovolné nominální hodnoty (dvaačtyřicetikoruna bohužel neexistuje). Platidla nemají v našem modelu příliš mnoho vlastních metod, měla by však podporovat uspořádání, tj. implementovat rozhraní IComparable. Bankovní účty jsou naopak modifikovatelné a lze je vytvářet neomezeně. Spolu s platidly mají společnou vlastnost – finanční hodnotu, kterou representují (dále jen hodnotu) a musí tudíž implementovat společné rozhraní (s názvem např. IFinančníČástka). interface IFinancniCastka { decimal Hodnota { get; }
57
}
Zajímavostí je pouze použití číselné třídy decimal namísto double. Třída decimal representuje čísla s pohyblivou řádovou čárkou v přímo v desítkové soustavě a s vyšší přesností (28 platných číslic). Na rozdíl od instancí třídy double tak nedochází ke ztrátě přesnosti a zaokrouhlovacím chybám při finančních výpočtech. Nevýhodou je je menší rozsah exponentu (10−28 až 1028 ), což však u finančních hodnot nevadí (maximální nominálem, alespoň formálně bylo 100 miliónů bilpengő, kde bilpengő samo bylo 1 bilion pengő = 1020 základní měnové jednotky). Podstatnější nevýhodou jsou zvýšené paměťové nároky (16 bytů oproti 8 bytům u double) a především řádově pomalejší operace (někdy i 100krát pomalejší, neboť nejsou podporovány speciální výpočetní jednotkou FPU, která se běžně vyskytuje v procesorech). Rozhraní vyžaduje od objektů, které jej implementují pouze akcesor metody (getter). Požadavek mutátoru je nadbytečný, neboť od peněz nelze očekávat, že je lze měnit pouhým přiřazením. Než přistoupíme k implementaci, ještě musíme promyslet operaci pro přesun peněz mezi jednotlivými druhy peněžních inkarnací. Přesun mezi účty je snadný, u jednoho účtu (zdroj) se sníží zůstatek o požadovanou částku, u druhého (cíl) se naopak o stejnou částku sníží. Problémem je však přenos mezi platidlem a účtem (i vice versa, ale to nebudeme prozatím řešit). Platidlo při přesunu sice fyzicky nezaniká, ale v našem modelu by mělo k jeho zdánlivému zániku dojít. Důvod je zřejmý: zákon zachování. Při jakékoliv rozumné representaci by nemělo peněz nekontrolovaně ubývat či přibývat. Pokud Vám někdo vloží na Váš účet hotovost, pak by mu neměly použité bankovky zůstat. Jak však budeme zdánlivý zánik platidla při transferu na účet representovat. Platidlo se totiž při přesunu nemůže nějak zneplatnit (je neměnné) ani nemůže snadno zaniknout. Objekt totiž (alespoň de iure) přestane existovat v okamžiku, kdy na něj neukazuje už žádná reference. Zde však na platidlo může odkazovat velké množství odkazů, a zánik jediné reference existenci objektu neovlivní. Samotný zánik reference je významný pouze v případě, že nastane v nějaké kolekci, či jiném složeném objektu když je např. když platidlo odstraněno z nějakého seznamu. Co z toho vyplývá? Platidla musí být během své soustředěna v nějaké kolekci. To samozřejmě není nikterak překvapivé. To lze odvodit i z pouhého rozboru placení hotovostí. To totiž nespočívá v přesunu jediného platidla z jednoho vlastnictví do druhého, ale v přesunu množiny platidel, z jiné (povětšinou) větší množiny. Tuto množinu lze označíme jménem hotovost. Tento termín není zcela dokonalý, neboť termín hotovost v běžném pojetí má výrazně hodnotovou sémantiku (prolíná se bohužel trochu s termínem obnos). Dvě hotovosti se běžně označují za identické, pokud mají stejnou finanční hodnotu. Na druhou stranu hotovost 496 korun ve Vaší peněžence se v jistém ohledu liší od hotovosti 496 korun, jenž se nachází na jiném pro vás nedosažitelném místě (např. v 58
cizí peněžence). A to je právě ta sémantika, kterou použijeme v našem modelu (lepší termín by mohl být ”aktuálně vlastněná fyzická hotovost”, ale to je dost kostrbaté). Jinak řečeno je to individuální hotovost peněz tvořený neindividuálními mincemi a bankovkami. Tento model se liší od fyzické realizace, v němž jsou mince i bankovky také individuální, ale je blízký mentálnímu modelu většiny lidí (výjimkou jsou lidé, kteří si zapisují či pamatují čísla bankovek a do mincí ryjí své mikroznačky). Hotovost spolu s bankovním účtem sdílejí společné chování, které je zřejmé z toho že oba mohou být zdrojem peněžního převodu (jsou tudíž z tohoto hlediska polymorfní). Z obou lze totiž čerpat peněžní prostředky, čímž se sníží jejich finanční hodnota. Tuto schopnost nesdílejí s platidly. Proto zavedeme nové rozhraní IFinančníZdroj s touto definicí: interface IFinancniZdroj : IFinancniCastka { bool Cerpani(decimal castka); }
Toto rozhraní rozšiřuje rozhraní IFinancniCastka, neboť každý u každého finančního zdroje lze (resp. musí být možné) zjistit jeho aktuální finanční hodnotu (všimněte si slova aktuálně, tímto slovem jsme právě zpřesnili kontrakt vlastnosti Hodnota). Nově přidaná metoda čerpá z příslušného zdroje (u nás obnosu, resp. bankovního účtu příslušnou finanční částku). Při jejím provádění mohou nastat dvě situace (kontrakt!): 1. čerpání se nepovede (hlavním důvodem je nedostatek financí), finanční hodnota objektu se nemění a metoda vrací objekt false 2. čerpání se úspěšně provede. Finanční hodnota se sníží nejméně o požadovanou částku (povoleno je tedy i snížení o více než požadovanou hodnotu) a metoda vrátí true Implementace Nyní již můžeme přistoupit k implementaci. Nejdříve pro úplnost uveďme importované jmenné prostory a zopakujme definice navržených rozhraní: using System;
//
penize.cs
using System.Collections.Generic; using System.Collections; using System.Linq; using System.Diagnostics; interface IFinancniCastka { decimal Hodnota { get; } } interface IFinancniZdroj : IFinancniCastka { bool Cerpani(decimal castka); }
59
Nejjednodušší je implementace třídy bankovních účtů: class BankovniUcet : IFinancniZdroj { / / //
penize.cs
public decimal Zustatek { get; private set; } private decimal kontokorent; public BankovniUcet (decimal kontokorent = 0m, decimal pocatecniZustatek = 0m) { this.kontokorent = kontokorent; this.Zustatek = pocatecniZustatek; } private void Vklad(decimal castka) { Debug.Assert(castka>0, "Neplatny␣vklad␣(zaporny␣ci␣nulovy)"); this.Zustatek += castka; } private bool Vyber(decimal castka) { Debug.Assert(castka>0, "Neplatny␣vyber␣(zaporny␣ci␣nulovy)"); decimal vyber = castka * 1.02m; / / transakcni poplatek if (vyber > this.Hodnota) return false; this.Zustatek -= vyber; return true; } public bool Prevod(IFinancniZdroj zdroj, decimal castka) { Debug.Assert(castka>0, "Neplatny␣prevod␣(nekladny)"); if (!zdroj.Cerpani(castka)) return false; this.Vklad(castka); return true; } / / metoda rozhrani IFinancniCastka
public decimal Hodnota { get { return this.Zustatek + kontokorent;} } / / metod rozhrani IFinancniZdroj
public
bool Cerpani(decimal castka) {
return this.Vyber(castka); } }
Definice začíná automatickou vlastností Zůstatek. Automatická vlastnost vytvoří datový člen pro ukládání zůstatku (hodnotový s třídou decimal) a triviální getter a setter. Getter je veřejný (každý objekt jiné třídy a tím např. i uživatel aplikace může zjistit zůstatek), setter je však privátní, neboť zůstatek lze změnit jen nepřímo vkladem či výběrem. 60
Dále následuje privátní datový člen, v němž je uložena výše kontokorentu, tj. maximální výše přečerpání účtu resp. maximální výše operativní půjčky, která je poskytnuta při poklesu zůstatku pod nulu. Kontokorent je neveřejný a lze ho nastavit jen při vytvoření účtu prostřednictvím konstruktoru (ten je skutečně triviální). Po definici konstruktoru následují definice metod. Začínáme klíčovými metodami, jež implementují vklad a výběr. Ty jsou kupodivu soukromé, a lze je tudíž využívat jen jako pomocné metody v rámci ostatních metod bankovního účtu. Proč jsem zvolil toto poněkud zvláštní řešení? Důvodem je snaha, aby všechny finanční částky byly representovány jako hotovosti nebo byly uloženy na bankovních účtech a jen mezi těmito objekty byly možné finanční transfery. I když prozatím nelze dosáhnout zcela uzavřeného systému, v němž by byla aktuální suma peněz konstantní (v zásadě by měla být nulová), je dobré model navrhovat tak, aby byl tento uzavřený a snadno kontrolovatelný stav dosažitelný. Vložení hodnoty representované pouhým číslem resp. výběr bez přenosu tuto rovnováhu naruší (pokud např. zůstatek vzroste, bez poklesu jinde). Tvrzení Vlastní implementace těchto pomocných je v zásadě triviální. Jedinou zajímavou částí je použití tzv. tvrzení (označovány také jako aserce z anglického assertion). Tyto podmínky testují zda je kód používán v souladu se svým návrhem, např. zda jsou všechny hodnoty v očekávaném rozmezí, resp. zda jsou datové struktury validní či konzistentní. Tato kontrola se provádí povětšinou jen ve fázi implementace a ladění programu či aplikace, nikoliv ve při nasazení programu, tj. zaměřuje se na zachycení programátorských chyb na nejnižší úrovni, nikoliv např. na řešení chybných vstupů. Pro chyby dané neplatnými vstupy či stavem operačního systému je nutno používat jiný mechanismus nejčastěji mechanismus výjimek (včetně ošetření). Na rozdíl mezi ověřováním tvrzení a výjimečným stavem se můžeme podívat i v našem případě. Náš kód používá aserce a tudíž předpokládá, že neplatná hodnota (záporný či nulový výběr či vklad) se může předat jen nepochopením kontraktu ze strany programátora/uživatele naší třídy resp. programovou chybou (např. chybným vzorcem pro výpočet částky). U hodnot zadaných chybně uživatelem či např. jako prostředek útoku na bankovní systém (např. podvržená hodnota u XML požadavku na službu) musí být hodnota zkontrolována již před voláním našich metod. Jinak řečeno, je to pouze poslední linie obrany, která může být po vyladění celé aplikace deaktivována, neboť je už zbytečná. V C# (a obecně .NET) jsou aserce zajištěny statickými/třídními metodami třídy System.Diagnostics.Debug. Metoda Assert, která je používána nejčastěji, má dva parametry. První je podmínka, která testuje, zda je vše v pořádku (měla by vrátit hodnotu true , pokud je předpoklad úspěšného běhu splněn), druhou je text, který se by se měl zobrazit při nesplnění podmínky při ladění. Skutečná funkce metody závisí na tom, zda je vykonána v ladícím (DEBUG) režimu či v režimu finálního vydání (RELEASE). Tento režim je řízen parametrem překlada61
če a v moderních IDE jej lze nastavit jednoduchým přepínačem. V ladícím režimu je podmínka tvrzení vyhodnocena a pokud je nepravdivá (= tvrzení je neplatné), je vyvolána specializovaná výjimka. Ta je zachycena ladícím prostředím, které by mělo programátora viditelně upozornit, že tvrzení nebylo splněno (to jest v programu je závažná chyba). Ve Visual studiu se například zobrazí speciální okno, které vypíše text druhého parametru a umožní program ukončit či pokračovat laděním. V release režimu aserce nic neprovádí (není dokonce ani vyvolána a není vyhodnocena ani aserční podmínka). Při výběru se účet sníží o požadovanou částku zvýšenou o poplatek 2%. To je v souhlasu s kontraktem metody pro čerpání (výběr se při čerpání používá) avšak přináší to dosti velké problémy v návrhu. Hlavní problém lze vyjádřit otázkou: kam mizí peníze z poplatku. Prozatím se ztrácejí bez náhrady, čímž narušujeme zákon o lokálním zachování peněz. V úplné implementaci by měly být přesunuty na jiný účet. I tuto neúplnou implementaci jsem však ponechal, neboť lépe odráží realitu a především ukazuje tvar literálu třídy decimal. Běžné literály reálných čísel (s desetinnou čárkou nebo exponentem) jsou totiž převedeny na instance třídy double nikoliv decimal. Jazyk C# navíc neprovádí implicitní přetypování mezi těmito třídami a tak nelze v jednom výraze mísit čísla obou tříd. Je to ochrana před vnášením nepřesných zaokrouhlených hodnot do výpočtů, který využívá přesnou representaci (= přesnou pro čísla s méně než 28 platnými číslicemi). Následující fragment programu se tak například ani nepřeloží: decimal x = 5; / / přetypování int na decimal je OK Console.WriteLine( 0.1 * x );
Zde je násobena (přesná) hodnota 5 třídy decimal, nepřesnou hodnotou třídy double (která se jen blíží hodnotě 0.1). Výsledek by byl tudíž nepřesný, což by mohlo být pro většinu programátorů překvapující. Proto je nutné využívat specializované literály třídy decimal. Ty mají v zásadě stejný tvar jako běžné literály třídy double jsou však následovány sufixem m (tento sufix je přežitek doby, kdy se odpovídající typ u Microsoftu jmenoval ”money”). Existují i celočíselné literály třídy decimal (samozřejmě opět zcela přesné), ty jsou však využívány méně často, neboť implicitní přetypování ve směru int -> decimal existuje (celé čísla jsou podmnožinou třídy decimal). decimal x = 5m; / / nadbytečné, ale přehledné Console.WriteLine( 0.1m * x ); / / povinné
Třída BankovniUcet přislíbila, že její instance budou podporovat rozhraní IFinancniZdroj (a tím i IFinancniCastka). Proto musí veřejně implementovat vlastnost Hodnota a metodu Cerpani. jejich implementace je jednoduchá. U vlastnosti Hodnota připočítáme k zůstatku i kontokorent (neboť i kontokorentní půjčku lze čerpat), pro čerpání 62
využijeme již hotovou metodu výběr. Metoda pro čerpání je jen veřejnou přístupnou variantou výběru, od něhož se liší jen použitím (může být použita jen při převodu peněz). Metoda pro převod není algoritmicky složitá, její jedinou zajímavostí je první parametr typovaný rozhraním IFinancníZdroj. Metoda je tak polymorfní, neboť podporuje instance více tříd, přesněji všechny objekty implementující rozhraní IFinancniZdroj. Za pozornost stojí i přístup ke zdroji. Čerpání je provedeno bez předběžné kontroly finanční hodnoty zdroje, jde se přímo na věc a úspěch se detekuje až z návratové hodnoty metody. Tento přístup je nejen kratší, ale i bezpečnější. Pokud by totiž program dovoloval souběžné vykonávání většího počtu instrukcí, pak by mezi dotazem a čerpáním mohlo dojít k poklesu zůstatku (například by se mezitím provedl jiný převod z účtu). Druhou třídou naší ukázkové aplikace je třída platidel (mincí a bankovek): class Platidlo : IFinancniCastka, / /
penize.cs
IComparable { public enum TypPlatidla { Mince, Bankovka } public decimal Hodnota { get; private set; } public TypPlatidla Typ { get; private set; } private Platidlo (decimal hodnota, TypPlatidla typ) { Hodnota = hodnota; Typ = typ; } public int CompareTo(Platidlo p) { return this.Hodnota.CompareTo(p.Hodnota); } public override string ToString() { return string.Format("{0}␣Kc({1})", Hodnota, Typ.ToString()[0]); } public static readonly Platidlo koruna = new Platidlo(1, TypPlatidla.Mince); public static readonly Platidlo dvojkoruna = new Platidlo(2, TypPlatidla.Mince); / / další mince
public static readonly Platidlo stokoruna = new Platidlo(100, TypPlatidla.Bankovka); / / další bankovky
}
63
Instance této třídy implementují hned dvě rozhraní: IFinancniCastka a IComparable, lze se tak ptát na to jakou částku representují (implementace předpokládá, že nominální hodnota se rovná hodnotě skutečné, což dnes neplatí jen u pamětních mincí) a jejich hodnoty lze vzájemně porovnávat. Uvnitř třídy platidel je definován výčtový typ (výčtová třída), jejíž instance representují typ platidla. Umístění definice výčtového typu uvnitř třídy žádným způsobem neimplikuje vztah jejich instancí. Položky výčtového typu nejsou automaticky uvnitř objektů své nadřazené třídy resp. netvoří jejich kompenentu. Vztah je čistě jen mezi třídami a ovlivňuje jen viditelnost identifikátorů. Ve skutečnosti je vnější třída jen jakýmsi jmenným prostorem pro vnořený výčtový typ (podobně to platí i pro vnořené definice tříd, apod.) Uvnitř definice třídy lze výčtový typ a jeho položky používat přímo (viz např. zápisy
TypPlatidla nebo TypPlatidla.Bankovka níže v definici třídy), Vně je lze používat
jen je-li definice označena jako veřejná (náš případ) a vždy musí být navíc kvalifiková
na jménem obalující třídy (vně třídy je nutno psát například Platidlo.TypPlatidla
jako jméno výčtového typu resp. Platidlo.TypPlatidla.Mince pro označení výčtové hodnoty).
Další část definice obsahuje dvě automatické vlastnosti. První representuje nominální a tím i finanční hodnotu platidla a její akcesor (getter) implementuje rozhraní IFinancniCastka. Druhý podporuje neveřejné ukládání a veřejné získávání typu platidla (teprve nyní se typ platidla stává součástí objektu). Stručná zápis automatické vlastnosti je bohužel poněkud matoucí, neboť se velmi podobá definici vlastnosti v rozhraní. Význam zápisu se však výrazně liší! V případě rozhraní: interface IFinancniCastka { decimal Hodnota { get; } }
se jedná pouze o definici, tj. zavedení signatury (budoucích) vlastností. Nevytváří se žádný datový člen ani se negeneruje žádný kód. Zápis jen prostě říká: instance tříd implementujících dané rozhraní musí mít vlastnost se jménem Hodnota, s getterem, který vrací objekt třídy decimal. Nezajímá nás, jak jej získá, zda jej ukládá v datovém členu, či jej vypočítá, prostě jen musí vrátit objekt příslušného typu (a splňující daný kontrakt). class Platidlo : IFinancniCastka, IComparable { ... public decimal Hodnota { get; private set; } ... }
64
Tentokrát je však definice vlastnosti pouhou syntaktickou zkratkou za zápis: private decimal _hodnota; public decimal Hodnota { get { return _hodnota;} private set {_hodnota = value;} }
Automatická vlastnost vytváří skrytý privátní člen pro ukládání hodnoty (zde finanční částky) s náhodně zvoleným identifikátorem (identifikátor _hodnota je zde jen pro ukázku). Dále vygeneruje triviální getter (ten vrací hodnoru datového členu) a privátní setter (ukládá hodnotu do datového členu). Dále je zřejmé, že vygenerovaný getter splňuje rozhraní (je veřejný, má správné jméno a vrací správný typ). Konstruktor třídy je triviální, pomocí setterů automatických vlastností ukládá předané podobjekty do datových členů. Má však poněkud překvapivá přístupová práva (je soukromý!). Důvod se dozvíme za chvíli.
Zajímavější je metoda int CompareTo(Platidlo p) , která implementuje rozhraní IComparable (generický parametr určuje s čím je možné platidlo porovnávat, zde jen s ostatními platidly). Protože porovnání platidel spočívá v porovnání jejich nominálních hodnot, je implementace jednořádková. Stačí jen vrátit výsledek metody CompareTo aplikované na objekty získané pomocí vlastnosti Hodnota. Tyto objekty jsou třídy decimal, která implementuje (podobně jako všechny číselné třídy) rozhraní IComparable (ve specializované podobě IComparable<decimal>). Tento obrat tzv. delegování se při objektově orientovaném programování vyskytuje často. Obdobu naleznete i v běžném životě: proč bych to měl dělat sám, když to mohu delegovat na někoho jiného. Následující metoda ToString předefinovává standardní metodu pro převod na řetězcovou representaci (je to opět podpora polymorfismu tzv. dědičnosti). Je zde hlavně z důvodů jednoduššího ladění. Závěr definice třídy vytváří objekty representující jednotlivé mince a bankovky (vždy jeden objekt pro každý druh mince či bankovky). Tyto objekty jsou uloženy do třídních (statických) datových členů (angl. static data field), aby byly tytp objekty přístupné přes všeobecně viditelné a snadno použitelné identifikátory. Třídní datové členy jsou spojeny se třídou nikoliv s objekty-instancemi, tj. existuje vždy jen jeden datový člen na třídu. Jinak řečeno jsou sdíleny všemi instancemi třídy. Pokud jsou veřejné, pak jsou viditelné i mimo třídu musí však být kvalifikovány jménem třídy (třída zde opět tvoří jakýsi jmenný prostor pro tyto identifikátory). Podívejme se na definici jednokorunové mince (definice ostatních platidel jsou obdobné): public static readonly Platidlo koruna = new Platidlo(1, TypPlatidla.Mince);
65
Objekt je jako vždy vytvořen voláním konstruktoru. Ten je sice soukromý, ale my se stále nacházíme v rámci definice třídy, a tak jej smíme použít. Datový člen, do něhož odkaz na objekt uložíme, se jmenuje koruna, je veřejný a třídní (statický), lze k němu
tudíž přistupovat i vně třídy pomocí zápisu Platidlo.koruna (jako by se jednalo o proměnnou patřící třídě). Navíc je tato proměnná read-only, tj. lze jej nastavit jen při inicializaci, poté již slouží pouze ke čtení. Omezení přístupu konstruktoru a read-only charakter vede k tomu, že mimo třídu nelze používat jiné objekty než ty vytvořené samotnou třídou (tj. objekty budou dokonale sdílené) a nelze je ano (omylem) změnit. Nelze tudíž použít následující (pofidérní) zápisy: Platidlo trojkoruna = new Platidlo(3, TypPlatidla.Mince); / / v současnosti neexistující mince 3 Kč
petikoruna = koruna; / / snaha o změnu definice koruny / / projevila by se ve všech následujících použitích
Nyní už nám zbývá poslední třída Hotovost. Ta je díky své složitější datové representaci a nutnosti implementaci rozhraní ICollection nejrozsáhlejší (přestože ještě není zdaleka dokončena). class Hotovost : IFinancniZdroj, / /
penize.cs
ICollection { private List platidla; public Hotovost () { platidla = new List(); } public Hotovost (IEnumerable pocatecniPlatidla) : this() { foreach (Platidlo p in pocatecniPlatidla) this.Add(p); } / / vlastni metody
public bool Platba(decimal pozadovaneCastka) { Debug.Assert(pozadovaneCastka > 0, "Neplatna␣platba"); if (pozadovaneCastka > Hodnota) return false; int stop = 0; foreach (Platidlo p in platidla) { pozadovaneCastka -= p.Hodnota; if (pozadovaneCastka <= 0) { break; } stop++;
66
} int start = 0; foreach (Platidlo p in platidla) { pozadovaneCastka += p.Hodnota; if (pozadovaneCastka > 0) { break; } start++; } platidla = new List( platidla.Take(start).Concat(platidla.Skip(stop + 1))); return true; } public override string ToString() { string vycetka = string.Join(",␣", platidla); return string.Format("Hotovost␣{0}␣[{1}]", Hodnota, vycetka); } / / metody z rozhrani IEnumerable
public IEnumerator GetEnumerator() { return platidla.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { return platidla.GetEnumerator(); } / / metody z rozhrani ICollection
public void Add(Platidlo p) { bool pridano = false; for (int i=0; i < platidla.Count; i++) { if (p.CompareTo(platidla[i]) < 0) { platidla.Insert(i, p); pridano = true; break; } } if (!pridano) platidla.Add(p); } public int Count { get { return platidla.Count;} } public bool IsReadOnly { get { return false;} }
67
public void Clear() { platidla.Clear(); } public bool Contains(Platidlo p) { return platidla.Contains(p); } public void CopyTo(Platidlo[] array, int arrayIndex) { platidla.CopyTo(array, arrayIndex); } public bool Remove(Platidlo p) { return platidla.Remove(p); } / / metoda rozhrani IFinancniCastka
public decimal Hodnota { get { return platidla.Select(p => p.Hodnota).Sum(); } } / / metoda rozhrani IFinancniZdroj
public bool Cerpani(decimal castka) { return this.Platba(castka); } }
Třída Hotovost explicitně implementuje dvě rozhraní: naše rozhraní IFinancniZdroj (a tím i IFinancniCastka) a standardní rozhraní ICollection (a tím i IEnumerable resp. IEnumerable). Než se podíváme na implementaci jednotlivých metod (vlastních i požadovaných implementovanými rozhraními) musíme detailněji popsat interní datovou representaci hotovost. Již z fáze návrhu víme, že hotovost je kolekce platidel. Jaká konkrétní kolekci však nejlépe odpovídá požadavkům? V rámci návrhu je použit několikrát použit termín množina (platidel). Proto nejdříve ověříme využitelnost této kolekce (v .NET je dostupná prostřednictvím generické třídy Set). Množina má z hlediska použití dvě charakteristiky: 1. položky nejsou nijak uspořádány 2. položky musí být jedinečné (= každý objekt se může v množině vyskytovat jen jednou) Klíčové pro rozhodování je především druhá charakteristika množiny. I když se jeví jako triviální, je její interpretaci v praxi mnohdy obtížná. Problém začíná již na národní škole s množinami jablíček a hrušek (obrázek převzat ze serveru jablko.cz). 68
Obrázek 4. Množiny z pohledu základní školy Obsahuje výše zobrazená množina (vlevo) stejný prvek vícekrát či nikoliv? Záleží na tom jak chápeme identitu jablek: pokud ji chápeme referenčně, pak jablka odlišíme například ukázáním, a vše je OK ( množina obsahuje tři jablka). Chápeme-li identitu hodnotově (jablka jsou shodná, pokud je nerozlišíme podle všech či alespoň klíčových vlastností), pak máme problém. Můžeme sice do klíčových vlastností zahrnout 2D souřadnice, ty však nejsou v případě jablek klíčovou vlastnost, navíc nemusí být ani trvalou. Všimněte si, že problém vznikl až zjednodušením reality (v přírodě nejsou žádná dvě jablka nerozlišitelná). V případě programování je situace relativně jednoduchá, pokud ovšem správně chápeme identitu (resp. máme pro objekty definovánu jen jedinou identitu). Platidla mají v našem modelu hodnotovou sémantiku, která se naštěstí díky dokonalému sdílení shoduje se sémantikou referenční. Tato sémantika je rozhodující, neboť je standardní pro referenční typy (a náš typ Platidlo je referenční). To nám však znemožňuje využit množinovou kolekci, neboť hotovost by například nemohla obsahovat dvě mince se stejnou nominální hodnotou (dvě dvoukoruny, apod.), neboť ty jsou representovány jediným (referenčně i hodnotově identickým) objektem. To je samozřejmě pouze vlastnost našeho modelu s neindividuálními platidly. Běžný (méně abstraktní) model reality tyto problémy nemá (při pohledu do peněženky obě dvoukoruny snadno odlišíme, stejně jako jablka na obrázku). Když nemůžeme použít množinu, lze uvažovat o seznamu. Ten může obsahovat stejný objekt vícekrát. Má však jednu vlastnost, která se nám pro naše účely jeví jako nadbytečná a to je uspořádání položek. U szenamů totiž víme, jaká položka je první, druhá atd. U hotovosti tuto informaci znát nepotřebujeme, resp, nemá pro nás žádný hlubší význam. Pokud vám někdo dá 105 Kč v hotovosti, je jedno zda dá nejprve stovku a pak pětikorunu, nebo naopak. To je však jen vnější model, z hlediska vnitřní implementace brzy zjistíme, že uspořádání smysl má, ale jen tehdy, jsou-li platidla setříděná (tj. seřazená od nejmenší k 69
největší), stejně jako je tomu alespoň částečně v peněžence (minimálně je v ní oddělená přihrádka na mince). To co tedy ve skutečnosti potřebujeme je setříděný seznam angl. sorted list. Pokud se podíváme do dokumentace .NET knihovny, může se nám zdát, že tato kolekce je k dispozici, neboť existuje generická třída SortedList. Bohužel, není tomu tak, neboť název zde bohužel klame. SortedList v podání Microsoftu je ve skutečnosti druh setříděného slovníku (přesněji slovníku se setříděnými klíči), jak o tom mimo jiné svědčí generické parametry. Tuto kolekci lze samozřejmě použít i pro representaci seznamu (použijí se jen klíče, hodnoty mohou být nastaveny na null), je to však dost neefektivní a komplikované. My proto zvolíme vlastní řešení. Použijeme běžný seznam a při vkládání se budeme snažit udržet ho v setříděním stavu (výmaz není problém, po vynětí jakéhokoliv prvku zůstane seznam setříděný). Není to sice příliš efektivní (časová složitost vkládání je 𝑂(𝑛), ale pro běžné počty platidel v hotovostech je to akceptovatelné). Seznam List je v instancích třídy odkazován datovým členem platidla (viz začátek definice třídy). Pro vkládání se používá metoda Hotovost.Add, která zajišťuje, že seznam je stále setříděný a to vzestupně (od nejmenšího k největšímu platidlu). Algoritmus je triviální, avšak jak bylo již řečeno, nepříliš efektivní. Nejdříve se nalezne pozice pro nově přidávané platidlo a pak je na tuto pozici vloženo. Platidlo se vždy vloží těsně před první položku, jejíž hodnota je větší nebo rovna (pokud taková položka neexistuje se přidá nakonec). Pro porovnání není použit relační operátor, neboť ten není platidly podporován, ale metoda CompareTo. Ta podporována samozřejmě je, protože objekty platidel implementují rozhraní IComparable. Metoda Add je využita i v přetíženém konstruktoru, který očekává libovolnou iterovatelnou kolekci, tj. objekt implementující rozhraní IEnumerable (může to být pole či seznam platidel, nebo jiná hotovost). Pomocí cyklu foreach je kolekce iterována a jednotlivé položky jsou přidány do nově vytvářené hotovosti a to pomocí metody Add hotovosti nikoliv metody Add podřízeného seznamu. To zajistí, že seznam bude po skončení inicializace setříděn. Zde se však projevuje neefektivita operace přidání (složitost je 𝑂(𝑛2 )). Již pro relativně malé počty platidel se vyplatí běžné přidávání do seznamu (na konec) a následné setřídění vestavěnou seznamovou metodou Sort, neboť ta nabízí časovou složitost řádu 𝑂(𝑛 log(𝑛)). Druhý konstruktor je bezparametrický a tudíž vytváří jen prázdný seznam (representuje prázdnou hotovost). Bezparametrický konstruktor však má i další funkci – je volán z druhého konstruktoru za účelem inicializace datového členu platidla vytvořením prázdného seznamu. Konstruktory se navzájem volají pomocí speciální syntaxe, kdy je mezi hlavičkou a tělem uveden zápis this(seznam-parametrů) uvozený dvojtečkou. Tento zápis zajistí volání jiného konstruktoru těsně před konstruktorem, v němž je tento zápis uveden (lze ho použít jen a pouze v konstruktoru!). Nejzajímavější metodou třídy Hotovost je metoda Platba
70
. Tato metoda vyjímá z hotovosti platidla v požadované částce. Optimální implementace by navíc měla zvolit nejmenší počet odebraných platide,l a pokud je potřeba nějaké mince vrátit, tak vrací minimální částku s minimálním počtem platidel. Při pohledu na praxi (např. při platbě v obchodě) však zjistíme, že ideální řešení nenastává příliš často. Důvodem je relativně obtížný a neefektivní algoritmus, a tak lidé volí nejčastěji jen přibližné řešení používajíce tzv. heuristiky (angl. heuristic). Problém je obzvláště složitý, pokud máme jen omezené množství platidel (na straně plátce i na straně obchodníka) a nelze předpokládat žádné rozumné rozložení nominálů. V tomto případě lze problém řešit hrubou silou: je nutno vyzkoušet všechny možné podmnožiny platidel. Těch je však 2𝑛 , a tak i pro relativně malá 𝑛 je doba provedení neakceptovatelná. Problémy, pro něž existuje jen řešení s exponenciální časovou složitostí, se nazývají NP-úplné (náš problém je speciálním případem problému batohu). Pokud je omezen počet nominálních hodnot a ty mají dobrou strukturu (např. systém 1-2-5 užívaný i u české měny) je možné implementovat rychlý algoritmus, je však nutno vytvářet pomocné datové struktury (kód není příliš stručný a v praxi lidé tento přístup při placení příliš nevyužívají). Pro jednoduchost jsem zvolil heuristiku (přibližný algoritmus), který sice neposkytuje optimální výsledek (může z hotovosti vyjmout i větší částku, než by bylo nutné) je však jednoduchý a rychlý. Není překvapivé, že podobnou heuristiku používám i při placení (samozřejmě ve zjednodušené podobě, v níž si platidla řadím do skupin o podobné nominální hodnotě). Základem je uspořádaný seznam platidel, z něhož se postupně odebírají platidla od nejnižší hodnoty. Toto odebírání ukončím v okamžiku, kdy odebraná suma převýší požadovanou. Tím však ještě nekončím, neboť se ještě pokusím (virtuálně)vrátit zpět nejmenší platidla, která jejichž odebrání už nemusí být nezbytné. Výhodou tohoto algoritmu je skutečnost, že během obou fází algoritmu nemusí být platidla fyzicky odebírána, neboť stačí posouvat jen hraniční indexy (kam až jsem platidla odebral – index označovaný dále jako stop, a kam až jsem platidla zpětně vrátil – index start). Pokud existuje nějaké řešené (je-li požadovaná částka je menší nebo roven hodnotě hotovosti), pak je vždy nalezeno alespoň řešení. Algoritmus také mírně preferuje platbu vyššími nominály (bankovkami) a tím se postupně zvyšuje počet drobnějších mincí (ty přibývají při vracení). Protože však tím roste i jejich hodnota je zde vysoká šance, že nakonec budou použity i při placení vyšších částek (při odebrání se na vyšší nominály nedostane). Aktuální implementace metody neřeší vracení z vnějšího zdroje (např. bankovní pokladny), neboť odebrání vyšší částky formálně nenarušuje kontrakt metody čerpání (navíc se s podobným řešením můžete setkat i v praxi u některých automatů). Přesto doporučuji kód metody rozšířit. Algoritmus pro nalezení optimální vrácené hotovosti je jednoduchý, pokud předpokládáme že máme k dispozici neomezený počet platidel (v opačném případě však samozřejmě ani nemusí existovat řešení). 71
Pro lepší pochopení algoritmu se podívejte na ilustrativní obrázek: 0
1
2
5
2
3
4
10 10 50
požadováno 8 Kč
take skip
start = 2 stop = 2 → take = 2, skip = 3
0
1
2
5
2
3
4
10 10 50
požadováno 14 Kč
take skip
start = 1 stop = 2 → take = 1, skip = 3
Obrázek 5. Algoritmus platby z hotovosti Algoritmus začíná posouváním indexu stop, tak dlouho, dokud suma platidel nevyrovná či nepřevýší požadavek (započítává se i platidlo na daném indexu). V horním příkladu je pro index 1 součet 2+5 < 8, ale pro index 2 je již 2+5+10 > 10). int stop = 0; foreach (Platidlo p in platidla) { pozadovaneCastka -= p.Hodnota; if (pozadovaneCastka <= 0) { break; } stop++; }
Pak opět od nejmenších nominálů postupně vracíme peníze, dokud se aktuální hodnota požadované částky nedostane opět nad nulu (tentokrát posouváme index start). V případě horního příkladu je po první fázi aktuální vrácená částka rovna 8 - (2+5+10) = -9. Proto nejdříve vrátíme 2 Kč (součet je -7), pak 5 Kč (součet je -2). Nakonec vrátíme 10, čímž se dostaneme do kladných hodnot (a index start nabývá hodnot 2, opět zahrneme i platidlo, které změnilo znaménko součtu). Algoritmus si ověřte i u druhého příkladu. Odebrané mince leží mezi indexy start a stop (včetně). Protože odebrání více položek seznamu je relativně složitá operace, tak jsem zvolil alternativní řešení: vytvoření nového seznamu ze zbývajících platidel (jenž následně původní seznam nahradí). platidla = new List( platidla.Take(start).Concat(platidla.Skip(stop + 1)));
Nový seznam je vytvořen konstruktorem, který očekává iterovatelný objekt (objekt implementující rozhraní IEnumerable), neboť využívá jím poskytnutý iterátor. 72
Tento iterátor vznikne zřetězení (sériovým napojením) dvou poditerátorů. Tyto iterátory jsou získány z původního seznamu platidel. První je však omezen na 𝑠𝑡𝑎𝑟𝑡 prvků (metoda Take vrací iterovatelný objekt, jehož iterátor vrací prvních 𝑛 prvků původního iterátoru, kde n je parametr metody). Druhý naopak přeskakuje 𝑠𝑡𝑜𝑝 prvků a vrací jen ty zbývající. První tedy iteruje přes platidla, jež jsou na obrázku obarveny žlutou (světle šedou) barvou (tvoří souvislou oblast v levé části seznamu), druhý pak bílé prvky v pravé části seznamu. V popisu třídy Hotovost, tak již zbývají jen metody nutné pro implementaci rozhraní, která jsme slíbili implementovat. Začněme implementací rozhraní ICollection, které zajistí, že s hotovostí lze pracovat jako s kolekcí platidel. Nejdříve musíme implementovat metodu GetEnumerator z rozhraní IEnumerable. To je v našem případě snadné, neboť ji lze delegovat na seznam platidel. Seznam platidel toto rozhraní také implementuje, a tak stačí, abychom si od tohoto seznamu vyžádali iterátor a ten vrátili. public IEnumerator GetEnumerator() { return platidla.GetEnumerator(); }
Situaci trochu komplikuje nutnost implementace stejnojmenné metody z rozhraní IEnumerable (negenerického), neboť i toto rozhraní je rozšiřováno rozhraním IEnumerable a následně ICollection. Tato metoda má zcela identické jméno a parametry a tak jí nelze uvést jako novou přetíženou verzi. C# tento problém řeší tzv. explicitní implementací rozhraní: IEnumerator IEnumerable.GetEnumerator() { return platidla.GetEnumerator(); }
Jméno metody je v toto případě kvalifikováno jménem rozhraní a není nutno (resp. dokonce možno) uvádět specifikaci public . Kód metody je kupodivu zcela shodný, neboť iterátor vrácený seznamem implementuje jak rozhraní IEnumerator tak IEnumerator (to platí pro všechny objekty implementující rozhraní IEumerable). Protože metodu Add již máme implementovánu (signatura i kontrakt je v souladu s rozhraním ICollection), tak nám zbývá implementovat jen vlastnosti Count, ReadOnly a metody Clear, Contains, CopyTo a Remove. Ty jsou buď zcela triviální (např. metoda ReadOnly vždy vrací false, neboť hotovost je měnitelná) nebo jen deleguje vykonání na interní seznam. public bool Contains(Platidlo p) { return platidla.Contains(p); }
73
Implementace našeho rozhraní IFinancniZdroj vyžaduje implementovat vlastnost Hodnota a metodu Cerpani. public decimal Hodnota { get { return platidla.Select(p => p.Hodnota).Sum(); } }
Pro výpočet hodnoty se použije LINQ metoda Select aplikovaná na (iterovatelný) seznam platidel. Na každé platidlo se aplikuje vlastnost hodnota a tak je výsledkem objekt, který iteruje přes seznam objektů třídy decimal. Na tuto posloupnost se aplikuje metodu Sum, která jednotlivé prvky sečte a vrátí jejich součet (sumu). Metodu lze přirozeně napsat i bez LINQ jen za pomoci cyklu foreach: public decimal Hodnota { / / alternativní implementace get { decimal suma = 0; foreach(Platidlo p in platidla) { suma += p.Hodnota; } return p; } }
Implementace metody pro čerpání je triviální neboť nám stačí zavolat již (alespoň částečně) implementovanou metodu Platba. Obě metody se liší jen jménem, metoda Čerpání je tudíž pouze jakýsi alias pro uživatele rozhraní IFinancniZdroj. V budoucích verzích se však mohou rozejít. Zatímco metoda Platba se může výrazněji změnit i navenek (může například místo příznaku úspěchu vracet strženou hotovost), měla by být signatura metody Cerpani, pokud možno, neměnná (jestliže bychom ovšem nechtěli změnit celé rozhraní, což může být u většího projektu obtížné či dokonce nemožné). public bool Cerpani(decimal castka) { return this.Platba(castka); }
Tím jsme dokončili popis počáteční implementace naší knihovny pro finančníky a jen pro kontrolu uveďme krátký testovací kód. class Program {
//
penize.cs
public static void Main(string[] args) { Hotovost penezenka = new Hotovost(new Platidlo[]{ Platidlo.koruna,
74
Platidlo.stokoruna, Platidlo.dvojkoruna}); penezenka.Add(Platidlo.stokoruna); penezenka.Add(Platidlo.dvojkoruna); Console.WriteLine(penezenka); BankovniUcet ucet = new BankovniUcet(1000); bool uspech = ucet.Prevod(penezenka, 191); Console.WriteLine(uspech); Console.WriteLine(ucet.Hodnota); Console.WriteLine(penezenka); }}
Výstup odpovídá očekávání (což, ale ještě neznamená, že nikde není chyba) Hotovost 205 [1 Kc(M), 2 Kc(M), 2 Kc(M), 100 Kc(B), 100 Kc(B)] True 1191 Hotovost 5 [1 Kc(M), 2 Kc(M), 2 Kc(M)]
75
Shrnutí Definice: polymorfismus – schopnost kódu pracovat s objekty různých tříd speciální případy: • polymorfní metody – parametrem mohou být objekty různých tříd, nebo jsou objekty různých tříd z metod vraceny • polymorfní kolekce – mohou obsahovat objekty různých tříd Jazyk C# (a další moderní OOP jazyky) podporují několik druhů polymorfismu: 1. statický polymorfismus – kód pro konkrétní třídy je generován již při překladu – bezpečnější a efektivnější forma, avšak s menším potenciálem 2. dynamický polymorfismus – skutečná implementace a skutečné třídy objektů jsou známy až za běhu – část kontroly se provádí až za běhu, běh je tudíž pomalejší, ale kontrola je jednodušší a má větší možnosti Definice: sdílená rozhraní (angl. interface) – prostředek pro podporu dynamického polymorfismu. Zajišťuje, že lze společně zpracovávat instance, které sdílí jen část svého rozhraní, tj. stejně se chovají jen v určitém kontextu. Definice rozhraní obsahuje seznam sdílených metod, včetně signatur metod (jméno, parametry), která by měla být doplněna alespoň neformálním kontraktem (co by měly metody dělat, jaká jsou jejich omezení). interface IDopravniProstredek { int Kapacita {get;} / / sdilena read-only vlastnost double Cena(double vzdalenost, int pocetPasazeru); / / musí být kladná
}
Třídy, jejichž objekty chtějí rozhraní implementovat, musejí tuto skutečnost explicitně deklarovat ve své hlavičce (mohou implementovat vícero rozhraní): class Dromedar : IDopravniProstredek, IComparable {...}
A musejí samozřejmě veřejně (se specifikátorem public ) implementovat metody a vlastnosti rozhraní. Každá třída je může implementovat jinak, vždy však musí mít stejnou signaturu a splnit kontrakt. / / ve tride Dromedar
public int Kapacita {get {return 3;}}; public double Cena(double vzdalenost, int pocetPasazeru) { return
vzdalenost * 8.63 * pocetPasazeru;
}
76
Pak lze implementovat polymorfní metodu, která umí pracovat nad polymorfním seznamem (zde např. všech dopravních prostředků nikoliv jen dromedárů). IDopravniProstredek NajdiNejlevnejsi(this List dp, double vzdalenost, int pozadovanaKapacita) {...}
Standardní rozhraní Některá rozhraní jsou tak obecná, že hrají důležitou roli u většího počtu tříd a využívají je i některé konstrukce programovacího jazyka. • IComparable – implementují je objekty, které lze navzájem (v rámci třídy) uspořádat. Jedinou metodou tohoto rozhraní je CompareTo. • IEnumerable – implementují je objekty, které poskytují tzv. iterátor. Iterátor poskytuje posloupnost objektů. Toto rozhraní implementují především kolekce, jejichž iterátory umožňují postupné procházení položek dané kolekce. Iterátory využívá cyklus foreach a nověji především rozšíření jazyka LINQ. • IDisposable – implementují je objekty vlastnící prostředky operačního systému nebo jiné externí zdroje. Rozhraní podporuje uvolňování těchto prostředků či zdrojů v okamžiku, kdy již nejsou potřeba. Objekty s rozhraním IDisposable mohou využívat konstrukci using (nesouvisí s exportováním jmenných prostorů deklarací using ). Rozhraní IComparable a IEnumerable nenesou informaci o třídě porovnávaných objektů resp. procházených položek, což znemožňuje kontrolu typů. Proto existují i jejich tzv. generické obdoby IComparable resp. IEnumerable, kde T je typ porovnávaných resp. procházených položek (stačí jen dosadit a získáme jedno z mnoha konkrétních rozhraní).
77
Otázky a úkoly Úkol II.8: Uveďte nějaké příklady polymorfismu v běžném životě.
Úkol II.9: Jak přečtete zápis interface IA : IB ? A jak class A : IB ? Nápověda: V jazyce C# se používá stejný oddělovač : pro dva různé mechanismy. V příbuzném jazyce Java se však tyto mechanismy odlišují i v zápise.
Úkol II.10:
Jaká je hlavní výhoda generických rozhraní (např. IComparable)
oproti negenerickým (např. IComparable)? Nápověda: statické versus dynamické typování
Úkol II.11: Nadefinujte rozhraní čítače výskytů řetězců, jehož implementace využívající slovník byla uvedena v předcházející části textu. Vytvořte alternativní implementaci tohoto rozhraní. Nápověda: Jednodušší alternativní implementace může být založena například na seznamu, jenž obsahuje přímo jednotlivé výskyty
Úkol II.12: Objekty s hodnotovou sémantikou mohou být representovány i pomocí hodnotových uživatelských typů (tříd). Ty jsou definovány pomocí klíčového slova struct . Jak se projeví jejich použití na representaci platidel? Dojde k úspoře paměti? Urychlení nebo zpomalení programu? Nápověda: Přihlédněte ke skutečnosti, že se může změnit (rozšířit) datová representace platidla. Efektivitu výrazně ovlivňuje i tzv. boxing (zabalení hodnoty do dočasného referenčního objektu).
78
Část III.
Dědičnost
Úvod do problematiky • Vnímáte svět hierarachicky? Pak se Vám bude líbit dědičnost. • poznejte výhody a nevýhody dědečnosti • poznejte syntaktickou podporu dědičnosti v jazyce C# specializace, dědičnost, bázová třída, kořenová třída Dědičnost patří již od počátků k základním pojmům objektově orientovaného programování. Na druhou stranu však patří k pojmům nejproblematičtějším, neboť interpretace tohoto pojmu závisí výrazně na úhlu pohledu, úrovni abstrakce a historickém vývoji. Ve skutečnosti je to spíše soubor koncepcí, které sice spolu úzce souvisejí, nejsou však zcela kompatibilní a zaměnitelné. Pro dovršení chaosu koncepce dědičnosti úzce souvisí s jinými objektově orientovanými koncepcemi jako je polymorfismus, sdílená rozhraní a skládání objektů a to jak na úrovni čistě teoretické tak i praktické,
Specializace a generalizace Koncepce dědičnosti vychází stejně jako celé objektové programování ze světa objektů a jejich tříd. Představme si, že množinu entit v určité problémové doméně například v rámci výrobního podniku. V prvém kroku je možno objekty rozdělit do několika disjunktních tříd na základě jejich chování v rámci podniku. Jsou to například třídy Zaměstnanec, Výrobek apod. Bližším zkoumáním však zjistíme, že třídy nejsou zcela homogenní, neboť obsahují objekty, jejichž chování se při bližším pohledu poněkud liší. Například objekty representující ředitele a konkrétní sekretářku patří určitě do třídy Zaměstnanec, jejich pracovní náplň i pravomoci se však podstatně liší. Podobně je tomu i u výrobků, např. ve strojírenské firmě mohou být vyráběny malé jednoduché součástky i sofistikované stroje. Projevuje se zde tzv. specializace (angl. specialization). Specializované objekty reagují na podněty sofistikovanějším způsobem, resp. mhou reagovat na nové specifické podněty. V rámci určité třídy tak lze někdy vyčlenit podtřídy (angl. subclass) objektů se specializovaným chováním. Toto chování může mít podobu přesněji určeného chování typického pro všechny objekty společné nadtřídy, nebo může být zcela specifické jen pro danou podtřídu. Například všechny výrobky lze expedovat, ale vlastní průběh expedice se liší jednoduchých součástek (ty stačí zabalit a poslat do prodejny) a (složitých) strojů, u nichž lze předpokládat podporu, jež je poskytována kupcům (i potenciálním), tj. expedice je výrazně složitějším procesem. Navíc stroje mohou mít i speciální funkčnost, kterou nelze předpokládat u ostatních výrobků a to ani v abstraktní podobě, například stroj lze spustit (uvést do chodu). 80
Podobně mohou vlastnosti u objektů různých podtříd nabývat jen omezené škály hodnot resp. může jít o vlastnosti zcela nové. Například hodnota vlastnosti plat může být u určité podtřídy zaměstnanců omezena oproti obecnému případu. Případem speciální vlastnosti může být vlastnost ZlatýPadák, která má smysl jen u manažerů. Vytváření podtříd lze přirozeně rekurzivně aplikovat i na podtřídy, lze tak vytvářet podtřídy podtříd, atd. Je tak vytvářen hierarchický strom podtříd, jehož kořenem je základní (nejabstraktnější) třída a třídy se ve směru k listům stávají specializovanějšími. Při vytváření této hierarchie lze přirozeně postupovat i v opačném směru, neboť lze slučovat několik blízkých tříd do jediné obecnější třídy (tj. využívat takzvanou generalizaci , také zobecnění, angl. generalization). Obecnější třída se většinou nazývá nadtřída (angl. superclass). Sloučené třídy vyjadřují obecnější pohled na objekty daných tříd. Aby to bylo možné, musí sjednocené objekty sdílet (na určité úrovni abstrakce) svá chování (= reakce na podněty). Hierarchie podtříd je obdobou hierarchie pojmů, jež je typická i v přirozených jazycích a zvláště je rozvinuta v některých vědeckých názvoslovích (především v biologie). je však nutno si uvědomit, že zde existují i jisté rozdíly: • hierarchie tříd není tak fixní jako hierarchie pojmů, jenž je v mnoha případech zadrátována do našich myslí. Hierarchie tříd musí být naproti tomu vytvářena ad hoc s ohledem na problémovou doménu, požadavky zadavatele resp. aplikační model (včetně návrhových vzorů). Následující příklad ukazuje několik možností zařazení třídy Pes do hierarchie podtříd. Je nutno upozornit, že třídy Pes se v jednotlivých hierarchiích liší, neboť reflektují různé modely (biologický, právní, dopravní). Jejich OOP implementace tak budou mít zcela různá rozhraní i datové členy! Návrhové vzory (angl. design patterns jsou ověřené postupy jak využívat OOP konstrukce (a z nich především polymorfismus a dědičnost) pro řešení běžných požadavků na funkčnost programů. Návrhové vzory se tak podobají např. algoritmům. jsou však na vyšší úrovni abstrakce. V profesionálních programech je většina využití rozhraní a dědičnosti jen aplikací některého z návrhových vzorů (základní myšlenka obou mechanismů však zůstává). Navíc návrhové vzory nejsou všemocné a stále zůstává prostor pro vaši tvůrčí invenci. Návrhové vzory [4], strana : nejlepší česká kniha o návrhových vzorech (i když bohužel pro Javu) C# - návrhové vzory [5], strana : ne tak kvalitní, ale přímo pro C# Pro representaci relace dědičnosti se nejlépe hodí třídní diagramy jazyka UML. Třídy jsou representovány jako obdélníky. Trojúhelníková bílá šipka s plnou spojovací čárou vyjadřuje odvození (na úrovni návrhu specializaci). Šipka vždy míří k nadtřídě). Pro snadnější čitelnost uvádím diagram i v textové podobě, v němž jsou uvedeny hlavičky funkcí a občas i metody (signatury metod jsou těsně za hlavičkou třídy a jsou odsazeny a uvozeny znakem +). 81
UML 2 a unifikovaný proces vývoje aplikací [6], strana : detailní informace o UML Savec
Selma
Pes
Vlk
Primat
Orangutan
Obrázek 6. UML třídní diagram DomaciZvire
Pes
VolneChovatelneZvire
SluzebniPes
Kocka
Obrázek 7. UML třídní diagram PrepravovanyObjekt
Zvire
MaleZvire
Zavazadlo
Pes
Obrázek 8. UML třídní diagram Například ve vlaku nelze pravděpodovně očekávat rozhovor tohoto typu: PRŮVODČÍ: Přeprava dinosaurů je ve vlacích ČD zakázána. CESTUJÍCÍ: Ale to je jen kanárek PRŮVODČÍ: Podle posledních výzkumů patří ptáci do kladu theropodních dinosaurů • Hierarchie tříd zohledňuje především viditelné chování objektů tříd, nikoliv hodnoty atributů (např, fyzikální veličiny) resp. vnitřní strukturu objektů. Tím se podstatně liší od biologie, jejíž klasifikace zohledňují především vnitřní strukturu organismů, resp. jejich vnější vzhled. Následující hierarchie je malým výřezem zoologické klasifikace, kterou lze jen s obtížemi charakterizovat jako hierarchii tříd, neboť jednotlivé třídy klasifikace 82
se ve valné většině problémových domén téměř neliší (příkladem problémových domén je myslivost nebo ekologie). Všimněte si, že šipky vedou od podtříd k obecnější nadtřídě. Savci
Zajicovci
Zajicoviti
Pistuchoviti
Zajic
Obrázek 9. UML třídní diagram I v biologii však existují klasifikace, které mohou být interpretovány jako hierarchie tříd (tj. je myslitelná problémová doména, v níž lze vztah interpretovat jako smysluplnou specializaci) Zivocich
Masozravec Sezer(potrava:Zivocich)
Bylozravec
Predator
Obrázek 10. UML třídní diagram Pro třídy v hierarchii určené specializací/generalizací platí elementární pravidlo, které však má závažné důsledky pro celý model: Množina instancí podtřídy B je vlastní podmnožinou nadtřídy A, tj. ∀𝑜𝑏𝑗𝑒𝑐𝑡 𝑜 ∈ 𝐵 ⇒ 𝑜 ∈ 𝐴. Jinak řečeno, musí platit, že objekt podtřídy je zároveň objektem nadtřídy, což znamená, že musí se chovat jako objekt nadtřídy ve všech kontextech dané problémové domény. Vezměme například model, v němž je základní třídou Zaměstnanec, popisující funkční i mzdové zařazení osoby v rámci jediného podniku. Navrženou podtřídou je AgenturníZaměstnanec. Objekt této třídy je osobou a má v rámci daného podniku svou funkci (= pracovní zařazení). Z hlediska mzdy je však zaměstnancem pracovní agentury a tudíž se v některých kontextech (např. propuštění, stanovení mzdy) se nechová jako běžný zaměstnanec podniku. Třída AgenturníZaměstnanec proto není podtřídou a ve většině případů je vhodnější zvolit jinou hierarchii, např: 83
Osoba
Pracovnik
Zamestnanec
AgenturniZamestnanec
Obrázek 11. UML třídní diagram
Dědičnost v OOP Základním mechanismem modelujícím vztah specializace/generalizace v objektově orientovaných jazycích je dědičnost angl. inheritance (nikoliv však jediným!) Dědičnost je (asymetrická, antireflexivní, tranzitivní) binární relace mezi objektově orientovanými třídami — bázovou třídou (alternativní názvy: nadtřída, třída-předek, angl. base class) a třídou odvozenou (alternativní názvy: podtřída, třída-potomek, angl. derived class). Pro odvozenou třídu a její objekty je překladačem zajištěno, že: • objekt odvozené třídy obsahuje všechny datové členy třídy bázové (tzv. je dědí). Může však přidávat vlastní (specializované) datové členy. Objekt třídy, jež je nepřímo odvozena z několika úrovní bázových tříd si lze představit jako mnohovrstvý, tj. složený z vrstev, jež jsou postupně přidávány jednotlivými odvozenými třídami (efekt sněhové koule). Specializovanější objekty tak mají složitější strukturu než objekty obecnější. Vzájemné skládání objektů při odvozování je typickým rysem OOP dědičnosti (je to tzv. efekt sněhové koule, která nabaluje další a další vrstvy).
Hierarchie tříd
A
Struktura objektu třídy C
A
B
B C
C Obrázek 12. Dědičnost jako skládání • odvozená třída dědí rozhraní třídy bázové, a tak musí obsahovat všechny metody (popřípadě vlastnosti) třídy bázové. Metody jsou v odvozené třídě implementovány buď automaticky (implementace je převzata z bázové třídy), nebo jsou nově implementovány ve třídě odvozené. Nová implementace (tzv. předefinování overriding) může být zcela nezávislá na třídě bázové, musí však splňovat původní
84
kontrakt. Musí mít stejnou signaturu (název, návratový typ, počet a typ parametrů), ale vyžadováno je i splnění všech omezujících podmínek a provedení všech požadovaných vedlejších efektů (to však již není kontrolováno překladačem a zodpovědnost tak leží pouze na programátorovi!). • odvozená třída je podtypem třídy bázové, tudíž objekt odvozené třídy může být bezpečně typován jako objekt třídy bázové (opačná věta naplatí!), Dědičnost tak podporuje jistou formu polymorfismu. Tento polymorfismus je dynamický a v některých aspektech je blízký polymorfismu sdílených pod(rozhraní). Všechny tyto podmínky zajišťují, že objekt odvozené třídy může být ve všech kontextech nahradit objekt třídy bázové, čili je de facto objektem bázové třídy. Rozhodující je zde především druhé pravidlo, které zajišťuje, že rozhraní bázové třídy je podmnožinou rozhraní třídy odvozené. Prvé pravidlo umožňuje snadné převzetí existujících metod odvozenou třídou, neboť převzaté metody mohou stále využívat zděděných datových členů. Třetí pravidlo je nezbytné u staticky typovaných jazyků (= jazyků u nichž jsou datové typy objektů známy již při překladu), neboť umožňuje typovat objekt odvozené třídy typem třídy bázové. Úplnou a dokonalou nahraditelnost objektu bázové třídy objektem třídy odvozené je možno formalizovat tzv. substitučním principem Barbary Liskovové (substitution principle), jenž v (anglickém originále) zní: Let 𝑞(𝑥) be a property provable about objects 𝑥 of type T. Then 𝑞(𝑦) should be true for objects 𝑦 of type S where S is a subtype of T. Prokazatelnou (provable) vlastností může být nejen hodnota navenek viditelných OOP vlastností (property), ale i předběžná nebo následná podmínka u metod (včetně změn v okolním počítačovém prostředí), resp. nepřípustná změna stavového prostoru (např. změna neměnného zděděného neměnného podobjektu). Substituční princip je ověřován ve fázi objektového návrhu a implementace a umožňuje tak mnohem detailnější testování přípustnosti použití dědičnosti než tvrzení o objektech podtříd. Například čtverce jsou přirozenou podtřídou obdélníků, neboť každý čtverec je zároveň i obdélníkem (a existují obdélníky, které nejsou čtverci). Zda však lze pro vyjádření této specializace použít dědičnost je relativně složitou otázkou. V případě, že třída obdélník popisuje modifikovatelné objekty, u nichž lze měnit velikosti, stran nebo pozice popisných bodů (typicky levého horního a pravého dolního), pak je dědičnost nepřípustná, neboť narušuje substituční princip. Změnou těchto vlastností u čtverce lze vytvořit objekt, který by byl sice formálně objektem třídy Čtverec, ale ve skutečnosti by se o čtverec nejednalo! Přesněji řečeno, pro některé vstupní hodnoty, pro něž je operace definována u obdélníků, není definována u čtverců. V případě neměnných objektů resp. objektů modifikovatelných s bezpečnými operacemi (změna měřítka, posunutí, rotace) není substituční princip narušen a dědičnost 85
by bylo lze využít. Ve skutečnosti je však dědičnost i zde nadbytečná, neboť ve většině kontextů počítačové grafiky (= typická problémová doména obdélníků) není nutno rozlišovat mezi obdélníky a čtverci resp. jejich vnější chování se příliš neliší. Pokud je výjimečně potřeba, pak postačuje jednoduchá boolovská vlastnost JsiČtverec. Navíc optimální není ani potenciální representace čtverců, která by musela využívat bodu a dvou rozměrů resp. dvou bodů (i když postačuje jen jeden bod a jeden rozměr), neboť objekt odvozené třídy dědí všechny datové členy nadtřídy (při dědění nelze žádné datové členy vynechat!). Čtverec by však neměl být složitější objekt než obdélník! Mechanismus dědičnosti v sobě spojuje tři dílčí principy objektového programování: 1. polymorfismus Objekty tříd, jež jsou přímo i nepřímo odvozeny z libovolné bázové třídy jsou polymorfní, neboť objekty těchto tříd sdílejí společné rozhraní. Rozsah tohoto jednotného (sdíleného) rozhraní je dán rozhraním společné bázové třídy. Implementace implementace jrdnotlivých metod však může být specifická pro každou odvozenou třídu Na druhou stranu však může však být i zděděna a tudíž jednotná. Tím se (mimo jiné) liší polymorfismus dědičnost od polymorfismu založenem na sdíleních rozhraních (interface). V některých jazycích je objektový dynamický polymorfismus nabízen pouze prostřednictvím dědičnosti (např. v C++). V C# jsou však důležitějším mechanismem podrozhraní (interface). Dědičnost hraje jen pomocnou (i když důležitou) roli. 2. znovupoužití kódu Dědičnost výrazně usnadňuje sdílení kódu mezi blízkými třídami. Sdílené metody stačí uvést pouze jednou v základní třídě, odkud jsou automaticky zděděny do tříd odvozených. Navíc tyto automaticky převzaté metody pracují nad sdílenými a zapouzdřenými daty (tj. je to bezpečné). Tento vlastnost se označuje jako znovupoužití (angl. reusing). Použití stejného kódu na několika různých místech programu bylo možné už v době prvních programovacích jazyků pomocí volání procedur (obdobou v OOP je volání pomocné (privátní) metody z několika metod v rámci jedné třídy). Znovopoužití kódu nabízené dědičností je však na mnohem vyšší úrovni abstrakce, neboť je úzce spojeno se skládáním, zapouzdřením, polymorfismem a samozřejmě i mechanismem specializace. Znovupoužití kódu prostřednictvím dědičnosti je přínosné především tehdy, je-li součástí některých ověřených návrhových vzorů resp. je podporováno ze strany knihovních tříd. V mnoha případech je však výhodnější použít jiný mechanismus. Je tomu tak především v případech u nichž použití dědičnosti narušilo substituční princip, resp. by vedlo ke zbytečně složitému programu. 3. skládání (kompozice) 86
Skládání je nejslabší princip zahrnutý v dědičnosti. Je nezbytný, neboť bez něj byla byla dědičnost implementací metod nemyslitelná, ale rozhodně není vhodným kritériem pro použití mechanismu dědičnosti (tje to jen prostředek nikoliv cíl!). Není proto vhodné odvozovat třídu Auto od třídy Motor, i když auto vznikne složením motoru a dalších komponent. Ve většině kontextů však není auto speciálním případem motoru. Podobně není tlačítko speciálním případem textu (resp. textového popisku). Výhodou skládání založeného na dědičnosti je postupné vytváření stále komplexnějších objektů v malých krocích, jež je prováděno paralelně s vytvářením stále propracovanějšího rozhraní a sofistikovanější implementace. V některých jazycích a hlavně knihovnách je použití rozhraní idiomatické, neboť je nabízeno (a dokumentováno) jako výhradní prostředek pro vytváření určitých druhů uživatelských tříd a mnohdy je automaticky využíváno i různými generátory kódů (tj. vlastně nemáte žádnou jinou možnost jak implementovat požadované objekty). Například pokud potřebujete v GUI knihovně nový vizuální a řídící prvek, pak v mnoha knihovnách musíte odvodit novou třídou (bázovou třídou je nejvhodnější existujicí třída, v nejhorším případě prázdný a na nic nereagující obdelníková oblast). Je však nutno zdůraznit, že idiomy se výrazně liší mezi jazyky a knihovnami. To co je v jedné idiom, nemusí být v jiné zcela košer nebo je to dokonce nemožné. Například v Pythonu se nové kolekce často vytvářejí odvozením ze kolekcí vestavěných. V C# to není tak běžné a hlavně se jako bázové třídy nepoužívají běžně používané kolekce (List, Dictionary). Zatímco v Ruby je odvození od třídy string zcela běžné, je v C# zcela nemožné (třída je označena jako tzv. zapečetěná (angl. sealed). Dědičnost grafických objektů (tvarů) Klasickým příkladem hierarchie odvozených tříd je hierarchie 2D grafických objektů zobrazovaných prostřednictvím bitmapového zobrazovacího zařízení (např. displeje). Navržená hierarchie může mít například tento tvar (pro zjednodušení jsou uvedeny jen příklady tříd). GrafickyObjekt Poloha:Pozice Kresli()
Elipsa
NUhelnik
Kresli()
Kresli()
Smajlik
Obdelnik
Kresli()
Kresli()
87
Obrázek 13. UML třídní diagram Příklady tohoto druhu jsou však ve většině případů spíše protipříklady, neboť častou narušují některé principy dědičnosti, zbytečně zesložiťují program, resp. vedou začátečníky ke tvorbě zcela chybných konstrukcí. Pokud se například podíváme na nejvyšší úroveň hierarchie, pak zjistíme, že Elipsa a N_Úhelník lze interpretovat skutečně interpretovat jako specializace grafického objektu. Otázkou však zůstává jaké metody by měly být v rozhraní grafického objektu. Základní metodou je zde vykreslení objektů na grafické zařízení (metoda Kresli). Při pokusu o její implementaci v bázové třídě zjistíme, že jí nelze implementovat bez znalosti konkrétního tvaru (a konkrétních datových členů). Zásadní chybou by byla implementace tvaru: class GrafickýObjekt { ... public void Kresli() { if(this is Elipsa) .... else if (this is N_Uhelnik) .... } }
Poznámka: Operátor is testuje zda je objekt přímou či nepřímou instancí dané třídy (nepřímou instance = je instancí nějaké odbozené třídy). Lze ji použít i pro otestování zda objekt implementuje nějaké rozhraní. Tato implementace přenáší funkci odvozených tříd do nadtřídy a její nepraktičnost se výrazněji projeví při větším počtu přímých i nepřímých podtříd (může se jednat o desítky podtříd). Pokud však by však bázová třída neobsahovala žádnou metodu (a tudíž ani žádné datové členy), pak by jedinou její funkcí bylo zajištění polymorfismu (přesněji možnosti polymorfně volat metodu Kresli). Tuto funkci by však lépe zajistilo sdílené rozhraní (interface), které by bylo možno pojmenovat jako IKreslitelné. Pokud se však hlouběji zamyslíme nad obecnou representací grafických objektů, tak zjistíme že sdílet lze implementaci některých jednoduchých obecných vlastností jako je např. poloha. Tuto vlastnost lze implementovat např. pomocí triviální vlastnosti, která polohu ukládá do datového členu (lze využít automatických vlastností). Tato implementace však vkládá do každého objektu libovolné odvozené třídy datový člen s pozicí, což může být nadbytečné. K problému se za chvíli ještě vrátíme. Nyní se zaměříme na dědičnost Elipsa ← Smajlík. Použití dědičnosti je zde problematické na první pohled, neboť smajlík není elipsou. Má sice samozřejmě tvar kružnice (a tudíž i elipsy), nemůže však elipsu nahradit. 88
Navíc je smajlík evidentně složeným objektem, neboť jej lze sestavit z několika elementárnějších tvarů (kružnic, půlkružnic). Složených objektů tohoto typu může být neomezený počet (různé figurky, symboly, apod.) a tak je vhodné vytvořit třídu, která by je zastřešovala (označme ji SlozenyGObjekt). Tato třída obsahuje kolekci grafických objektů a požadavek na své vykreslení zajišťuje voláním kreslících metod svých podobjektů (to je společný kód a proto je vhodné použít dědičnost). Složený grafický objekt se podstatně liší od objektů jednoduchých (jako jsou elipsy a n-úhelníky) a nesdílí s nimi mnoho implementačního kódu. Dokonce i vlastnost polohy může být implementována jiným způsobem (například na základě poloh podobjektů). Obecně je proto opravdu vhodnější použít místo všeobjímající bázové třídy GrafickyObjekt rozhraní, jenž zajistí polymorfismus mezi všemi grafickými objekty, nezatíží však jejich implementaci (a navíc je umožní zařadit do jiné hierarchie dědičnosti). Rozhraní můžeme nazvat IKreslitelné. Pro snadnější implementaci jednoduchých objektů vytvoříme bázovou třídu JednoduchyGObjekt, ze které budou odvozeny konkrétní třídy jednoduchých objektů. Tato třída implementuje rozhraní IKreslitelné. U některých vlastností a metod je možno uvést kód společný pro všechny objekty odvozených tříd (např. u polohy nebo u transformace posunutím). Na této úrovni však stále ještě nelze vytvořit metodu pro kreslení (jak vykreslit obecný grafický objekt?). Naštěstí to ještě není nutné, neboť metodu lze deklarovat jako abstraktní (viz dále). Musí ji však implementovat jednotlivé konkrétní podtřídy (čímž skutečně splní kontrakt rozhraní IKreslitelné, k jehož implementaci se zavázali odvozením ze třídy JednoduchyGObjekt) Třída SlozenyGObjekt implementuje stejné rozhraní, přičemž na rozdíl od třídy předešlé lze uvést i kód pro vykreslení: class SlozenyGObjekt : IKreslitelne { private List podobjekty; ... public void Kresli() { foreach(IKreslitelne go in podobjekty) go.Kresli(); } }
Důležitou částí rozhraní třídy SlozenyGObjekt je i metoda PřidejGObjekt, která umožňuje přidávat další podobjekty do složeného tvaru. Tato metoda není nezbytná (podobjekty mohou být předány již v konstruktoru složeného tvaru), ale usnadňuje tvoření objektů složených z mnoha (více než desítek) objektů. Existence této metody však komplikuje odvozování tříd konkrétních složených objektů z této třídy, včetně našeho smajlíka. Tyto třídy totiž tuto metodu nemohou ani převzít, ani ji předefinovat. Pokud by ji totiž převzaly (= zdědily), pak by umožňovala přidávat další podobjekty k již hotovému geometrickému tvaru a změnit ho tak k 89
nepoznání. Zdánlivým řešením by bylo předefinování, které by nic nedělalo resp. by vyvolalo výjimku. To však narušuje kontrakt metody a substituční princip. Pro odvození je tak nutno použit mezistupeň — adaptér, který implementuje rozhraní IKreslitelné pomocí podřízeného složeného grafického objektu. Tento podřízený objekt není navenek viditelný, jsou však na něj delegovány všechny metody implementující rozhraní (název třídy je zkratka za Adaptér pro nemodifikovatelné složené grafické objekty): class AdapterNemodifSGO : IKreslitelne { private SlozenyGObjekt servant; public Poloha Poloha { return servant.Poloha;} public void Kresli() {servant.Kresli();} }
Od tohoto adaptéru již lze odvodit třídu Smajlík. Veškerý kód této třídy může být soustředěn do konstruktoru, neboť není nutno předefinovávat žádnou z klíčových metod (postačuje implementace ze třídy AdapterNemodifSG). I když je v tomto případě dědičnost zdánlivě nejrozumnějším řešením, pochybnosti vyvstanou v okamžiku, kdy si uvědomíme, že potenciální počet složených tvarů je v zásadě neomezený, a že pro každý jednotlivý tvar je nutno vytvářet novou třídu. Navíc se tyto tvary liší jen sestavou podobjektů a jen minimálně svým vnějším chováním. Praktičtější je proto využití jen jediné třídy, která navíc nese identifikaci konkrétního tvaru (např. hodnotu výčtového typu). Pro pohodlné vytváření různých typů tvarů lze vytvořit soubor továrních metod (pro každý tvar jednu) nebo tovární objekt. Poslední neprodiskutovanou třídou je třída Obdélník. Nejjednodušším řešením je representace složeným objektem (resp. jeho nemodifikovatelným adaptérem), který je složen z jediného podobjektu — n-úhelníku. Toto řešení nenarušuje žádný základní princip, je však zbytečně složité a (paměťově) neefektivní. To je zvláště nepříjemné u obdélníků, které jsou základními stavebními kameny velkého počtu složených objektů (lze je dokonce považovat za elementární tvary). Běžně by tak zbytečně docházelo ke vzniku mnohaúrovňových objektů. (viz následující obrázek)
90
AdapterNemodifSGO
SloženýGObjekt
List
Pozice_1
Pozice_2
Pozice_3
Pozice_4
Obrázek 14. Struktura objektu Mnohem praktičtějším řešením je proto přímé odvození třídy obdélníků ze třídy JednoduchyGObjekt. Objekt obdélníka je tak jen jednoúrovňový a pro jeho representaci postačují jen dvě pozice (levý horní a pravý dolní roh). Výsledný model je znázorněn na následujícím UML třídním diagramu. Novinkou je šipka vyjadřující implementaci rozhraní. Ta je podobná šipce odvození, její spojnice je však čárkovaná. Ukazuje od třídy k rozhraní, které je třídou implementováno. Navíc je znázorněna tzv. kompozice (šipka s černým obdelníkem na konci). Ta vyjadřuje vztah mezi objekty-instancemi. Objekt třídy na něž šipka ukazuje obsahuje jako svou komponentu objekt třídy (rozhraní) na druhé straně spojnice. Například SloženýGObject obsahuje objekty implementující rozhraní IKreslitelne. AdapterNemodifSGO Kresli() servant 1
Smajlik
SlozenyGObjekt PridejGObjekt(shape:IKreslitelne) item * «interface»
IKreslitelne position:Position Kresli()
JednoduchyGObjekt position:Position
Elipsa Kresli()
Obdelnik
N_Uhelnik
Kresli()
Kresli()
Obrázek 15. UML třídní diagram
91
Abstraktní třídy Jak již bylo výše zmíněno lze při modelování hierarchických vztahů mezi třídami postupovat nejen ve směru specializace (od nadtřídy k podtřídám) ale i generalizace, tj. definovat nové třídy na základě zobecnění společných rysů. Při postupu tímto směrem lze vytvářet i třídy, které jsou již tak abstraktní, že nemá smysl uvažovat přímé instance těchto tříd, tj. instance, které by zároveň nepatřily do některé ze specializovaných tříd. Jinak řečeno tyto třída pouze zastřešují konkrétní třídy, jimž poskytuje polymorfní chování, zavádí případně i společné datové členy a implementuje některé společné metody (tyto metody nesmí být veskrze předefinovány). Abstraktní třídy (abstract class), jak se běžně označují, lze mají oproti běžným třídám několik typických rysů, které musí být podporovány i na úrovni programovacího jazyka: • nelze vytvářet přímé instance těchto tříd (pokus o vytvoření je syntaktickou chybou, jež je signalizována již při překladu). Abstraktní třída však může mít konstruktor, ten je však využitelný jen v konstruktorech odvozených tříd. • některé metody abstraktních tříd nemusí být na dané úrovni implementovatelné, neboť vyžadují znalost konkrétního objektu (a tudíž i jeho datovou representaci). Na druhou stranu však musí být tyto metody uvedeny, protože tvoří součást rozhraní všech odvozených (konkrétních) tříd a musí být aplikovatelné polymorfně. Většina programovacích jazyků proto umožňuje deklarovat tzv. abstraktní metody. U těchto metod jsou uvedeny jen signatury a implementace je přenechána jednotlivým odvozeným třídám. Vztah mezi abstraktními třídami a metodami je velmi těsný, jelikož důsledně platí následující pravidlo: Pravidlo: Obsahuje-li třída alespoň jednu abstraktní (neimplementovanou) metodu, pak je tato třída abstraktní. Platnost této implikace je zřejmá. U neabstraktní třídy s abstraktní metodou by bylo možno vytvářet přímé instance, u nichž nelze volat některé z jejich metod, neboť abstraktní metoda není definována (= není znám její kód!). Narušení tohoto pravidla je proto u všech OOP jazyků s podporou abstraktních metod a tříd (včetně C#) chápáno jako syntaktická chyba a je signalizována již při překladu. Opačná implikace (je-li třída abstraktní pak musí mít alespoň jednu abstraktní metodu) již není tak jednoznačná. Pokud však má abstraktní třída definovány všechny metody, pak je s velkou pravděpodobností representovatelná svými přímými instancemi (za předpokladu, že metody splňují stejný kontrakt jako u odvozených tříd). Jinak řečeno tato třída může být definována jako konkrétní s možností instanciace jejích objektů. Na druhou stranu, pokud podtřídy pokrývají všechny možné instance bázové třídy, pak přímá instanciace nemusí být v mnoha případech žádoucí (přestože by byla potenciálně možná).
92
Náhled na tuto problematiku se u různých OOP metodik liší a rozdíly jsou i u OOP jazyků: některé jako např. C++ definují abstraktní třídu jako třídu s alespoň jednou abstraktní metodou, jiné (včetně C#) umožňují explicitní označení třídy jako abstraktní (ta tak nemusí mít žádnou abstraktní metodu). Abstraktní třídy nemohou být listy v hierarchickém stromu tříd. Naopak třídy na vrcholu hierarchií dědičnosti resp. ve vyšších úrovních bývají velmi často třídami abstraktními. Obecně však neplatí, že nadtřídou abstraktní třídy musí být opět abstraktní třída. Nadtřída totiž může poskytovat tak obecnou (a limitovanou) funkčnost, že ji lze realizovat i na tak vysoké úrovni abstrakce (otázkou je však, zda má skutečně význam vytvářet přímé instance). V .NETu je tato situace běžná, neboť univerzální třída Objectnení abstraktní třídou. Object o = new Object(); / / syntakticky OK
I když je výše uvedený zápis platný, zůstává otázkou, co vlastně representuje nově vytvořený objekt a jakou má sémantiku (ve skutečnosti může být použit pouze jako anonymní, avšak odlišitelná entita). Z tohoto důvodu doporučuji v nově vytvářených (dílčích) hierarchiích dodržovat zásadu, že nadtřídou abstraktní třídy by měla být opět třída abstraktní. Hlavní rolí abstraktních tříd je zajištění polymorfismu mezi všemi odvozenými třídami. Tato role je velmi blízká roli rozhraní (interface). Navíc stejně jako rozhraní jsou i abstraktní třídy tzv. na jedné straně typem, neboť je lze je využít pro specifikaci typů u proměnných (včetně typů položek kolekcí resp. typů datových členů), na straně druhé však u nich nelze vytvářet přímé instance. Jaký je tedy vztah mezi abstraktními třídami a rozhraními? Z pohledu našeho objektového modelu je základní rozdíl jasný. Abstraktní třídy jsou výsledkem specializace a generalizace, tj. dvě třídy odvozené ze společné abstraktní nadtřídy nesdílí pouze část svého rozhraní, ale jsou to specializované (rozšířené, zpřesněné) varianty původní třídy. Každá třída může implementovat libovolný počet rozhraní, je však součástí pouze jedné hierarchie (tj. mají jen jednu bezprostřední nadtřídu). Abstraktní třídy také mohou na rozdíl od rozhraní nabízet postupné skládání dat tj. mohou obsahovat datové členy, které jsou posléze vestavěny do datové representace tříd odvozených. Mohou samozřejmě také implementovat metody, které jsou využitelné i v odvozených třídách (nabízejí tak znovupoužitelnost kódu) V některých konkrétních případech je však obtížné rozhodnout, zda se jedná o generalizaci či pouhé sdílení rozhraní. V těchto případech je mnohdy rozhodnutí čistě na programátorovi a jeho zkušenostech. Některá základní pravidla je však možno uvést již zde: • V nerozhodných případech je ve většině případů vhodnější preferovat rozhraní, neboť vnášejí méně závislostí mezi třídami. Abstraktní třídy, jež obsahují pouze abstraktní metody by měly být vždy implementovány jako rozhraní. Pokud 93
obsahují neabstraktní metody, pak je mnohdy vhodné přenést implementaci do odvozených tříd (např. pomocí pomocného podobjektu-služebníka) resp. využít rozšiřující metodu nad rozhraním. • Použití rozhraní je nezbytné v případech, kdy návrh vede k tzv. vícenásobné dědičnosti, tj. třída se jeví jako přímá specializace dvou (nesouvisejících) bázových tříd. Ta je v našem objektovém metamodelu (a také v jazyce C#) nepřípustné. {abstract}
{abstract}
Zvire
Zbozi
Vek:int
Cena:decimal
Nakrm()
Prodej()
Pes Nakrm() Prodej()
Obrázek 16. UML třídní diagram Tento model využívá vícenásobné dědičnosti a je důsledkem chybného návrhu. Pokud však již vznikne, pak je jej můžeme opravit tím, že převedeme jednu ze tříd na rozhraní (resp. obě dvě!). Pokud například na rozhraní převedeme třídu Zbozi pak získáme tento model {abstract}
«interface»
Zvire
IZbozi
Vek:int
Cena:decimal
Nakrm()
Prodej()
Pes Vek:int Nakrm() Prodej()
Obrázek 17. UML třídní diagram Otázkou je proč jsme na rozhraní převedli právě třídu Zbozi a nikoliv Zvire. Tuto otázku nelze odpovědět bez znalosti problémové domény. V ní může být hierarchie zvířat primární např. pokud se jedná o ZOO nebo sekundární (např. v případě zverimexu). Neznáme implementaci vlastnosti Vek, její rozsah ani univerzálnost (tj. zda a jak často bude předefinována). Cílem by jako vždy měl být co nejjednodušší a nejpochopitelnější model a přehledný kód. Tento vztah mezi abstraktními třídami a rozhraními je typický pro Javu a .NET. U jazyků s vícenásobnou dědičností oba pojmy téměř splývají (např. v C++ jsou jen abstraktní třídy, mohou však hrát roli rozhraní). Ještě odlišnější je pak po94
hled dynamických jazyků, které buď tento pojem vůbec neznají nebo je nahrazují alternativními koncepcemi.
Dědičnost v C# Model dědičnosti v C# vychází z obecného modelu představeného v předchozí kapitole. Je však poněkud komplikovanější, neboť: 1. integruje přístupová práva (private/public a nové protected) 2. je provázán s mechanismem rozhraní 3. metody podporující předefinování vyžadují explicitní označení tzv.pozdní vazby 4. existuje určitá podpora nezávislého paralelního vývoje bázové a odvozené třídy I když jsou tato rozšíření užitečná (a v případě provázání s rozhraními i nezbytná), je výsledkem systém, který umožňuje vytvářet téměř nepřeberné kombinace různých specifikátorů, z nichž jen některé jsou syntakticky správná a ještě méně je jich skutečně užitečných. Z tohoto důvodu je zde popsán mírně zjednodušený model, který však postačuje pro začátečníky a je dobrým základem i pro profesionály. Datové členy > V případě datových členů je situace relativně jednoduchá, neboť je nelze předefinovat a není zde žádné spojení s mechanismem rozhraní. Zůstává tak jen problematika přístupových práv.
Pokud je datový člen v bázové třídě označen jako soukromý ( private ), pak není dostupný ani z metod definovaných v odvozené třídě. Je však přirozeně součástí objektů odvozené třídy (viz skládání), a zůstává dostupný prostřednictvím zděděných metod.
Z tohoto důvodu byl již ve starších OOP jazycích zaveden specifikátor protected , jenž zavádí tzv. chráněné datové členy. Tyto členy jsou přístupné nejen v rámci své třídy, ale i ze všech tříd odvozených (i nepřímo). Používání chráněných datových členů však do značné míry narušuje zapouzdřenost bázové třídy, neboť umožňuje přístup k datovým členům i z kódu, jenž leží mimo třídu a
nemůže být účinně kontrolován. Každý může ze třídy odvodit podtřídu a v ní libovolně manipulovat s hodnotou datového členu! Proto je obecně vhodnější tento specifikátor u datových členů vůbec nepoužívat a pro manipulaci se zděděnými datovými členy využívat jen zděděných veřejných resp. chráněných metod a vlastností. To může být sice poněkud otravné, ale vyplatí se to. Navíc v C# lze zápisy výrazně zkrátit využitím automatických vlastností. class BaseClass { public int Property {get; protected set;} } class Subclass : BaseClass {
95
... Property = 0;
/ / OK: protected setter
}
Použitím specifikátoru protected u setteru, zabráníme obecné modifikaci vlastnosti, umožníme ji však provést v odvozených třídách (např. v jejich konstruktorech). Navíc lze automatickou vlastnost snadno zaměnit za složitější implementaci (např. s kontrolu validity) na úrovni základní třídy. Třída si tak udržuje svou absolutní zapouzdřenost. Konstruktory Protože úkolem konstruktorů je úplná inicializace (celého!) objektu, nemohou být konstruktory děděny. Namísto toho se využívá mechanismus postupného volání konstruktorů v linii předků. V tomto volání je nejdříve volán konstruktor nejvyšší bázové třídy (v C# tedy vždy konstruktor třídy Object). V rámci tohoto řetězce volání je každý konstruktor primárně odpovědný za inicializaci těch datových členů, které zavádí, může však inicializovat (či lépe modifikovat) i datové členy zděděné. Pravidlo: Konstruktory se nedědí. Namísto toho však konstruktor odvozené třídy deleguje část inicializace na konstruktor třídy bázové U bezparametrických konstruktorů se to vše děje automaticky a nevyžaduje to žádnou podporu ze strany programátora: class A { private int a; public A() {a = 1;} } class B : A { private int b; } class C : B { private int c; public C() {c = 2;} }
Pokud vytvoříme objekt třídy C (voláním new C()), pak je nejdříve formálně vyvolán konstruktor třídy Object, následně třídy A (nastavení datového členu a), poté B (je použit implicitní konstruktor, který nuluje obsah přidaných datových členů) a nakonec cílové třídy C (opět nastavení přidaného datového členu). Výsledný objekt má proto tři celočíselné datové členy s hodnotami a=1, b=0, c=2. V případě použití parametrických konstruktorů je nutno překladači poradit jaký konstruktor bázové třídy má použít pro inicializaci zděděného podobjektu a především
jaké mu předat parametry. K tomu slouží zápis : base(parametry-konstruktoru) , jenž se uvádí bezprostředně za hlavičkou odvozené třídy. 96
class A { private int a1; private int a2; public A(int first, int second) { a1=first; a2=second; } public A() {
/ / musí být expl. definován
a1 = a2 = 0; } } class B1 : A { private
int b1;
public B1(int value) : base(value, value+1) { b1 = 0;} } class B2 : A { protected int b2; public B2(int value) : base() { / / nepovinné b2 = 1; } public B2() {}; / / vše nula }
Dědičnost metod a vlastností Základním principem podpory dědičnosti u metod a vlastností v C# je implicitní ozna
čování metod, které lze potenciálně předefinovat pomocí klíčového slova virtual . Toto klíčové slovo primárně slouží k určení speciálního mechanismu volání metod, ale v zásadě je lze považovat za jednoznačný příznak možnosti předefinování metody v odvozených třídách (označení tak není příliš vhodné, neboť virtuální metody nejsou zdánlivé resp. ani jinak neskutečné apod.) Toto klíčové slovo bylo využíváno již v prvním objektovém jazyce Simula na konci šedesátých let, odkud bylo převzato do jazyka C++ a následně do C#. V ostatních OOP jazycích však využíváno není (např. není v Javě , PHP, atd). V odvozené třídě lze virtuální metodu, buď beze změny převzít (zdědit, v odvozené třídě není metoda vůbec zmíněna) nebo předefinovat. Předefinovaná verze metody musí
být opatřena specifikátorem override (je uvedeno namísto specifikátoru virtual základní implementace v bázové třídě). Pokud je od třídy s předdefinovanou virtuální metodou odvozena další třída, pak může buď zdědit předefinovanou verzi nebo
metodu znovu předefinovat (opět se specifikátorem override ). Speciálním případem virtuální metody je metoda abstraktní, která se může vyskytovat 97
pouze v abstraktní třídě (viz výše). Abstraktní metody jsou označeny specifikátorem abstract implementační blok (signatura je zakončena střední a nesmí obsahovat kem). Klíčové slovo virtual se v tomto případě neuvádí.
V odvozené třídě je možno ponechat abstraktní metodu nedefinovanou (pak, ale musí být jako abstraktní označena i odvozená třída!) nebo ji lze konkrétně implementovat.
I při prvotní definice musí být použito klíčové slovo override , i když je poněkud matoucí, neboť zde de facto nedochází k žádnému předefinování. Od okamžiku prvního definování se abstraktní metoda stává běžnou (virtuální) metodou.
Pokud není metoda označena specifikátorem virtual (resp. abstract ), pak nesmí být v odvozených třídách předefinována. V mnoha případech je neuvedení klíčového
slova virtual nedopatřením, jež se snadno odstraní při ošetření syntaktické chyby vzniklé při pokusu o předefinování. Některé metody však mohou být tak obecné resp. závislé na implementaci bázové třídy, že nelze předpokládat jejich předefinování. Zohlednit je však nutno nejen přímé podtřídy, ale i třídy v dalších úrovních dědičnosti, přičemž některé třídy či úrovně mohou být pouze potenciální. Drobnou ale mnohdy kriticky důležitou výhodou nevirtuálních metod je výrazně rychlejší mechanismus volání těchto metod (řádově dvakrát), který se projevuje především u krátkých a často volaných metod (příkladem mohou být gettery a settery elementárních vlastností). Virtuální a nevirtuální metody (třída Cache) Sémantický i praktický rozdíl si ukažme na příkladu třídy Cache, která realizuje speciální slovník, jenž si pamatuje jen omezený počet klíčů a hodnot. Pokud je keš plná, pak je zapomenuta (nahrazen) dvojice klíč-hodnota, k níž nebylo nejdelší dobu přistupováno. Tuto třídu nelze odvodit přímo ze třídy Dictionary, neboť ta sice není zapečetěna sealed), ale mnohé její klíčové metody nejsou z důvodů efektivity virtuální (nejsou tudíž předefinovatelné). Z tohoto důvodu je nad třídou Dictionary vytvořen jednoduchý adaptér, který navenek nabízí jen zcela klíčové slovníkové metody, z nichž však většina může být předefinována. Teprve z této třídy je odvozena třída Cache resp. mohou být odvozeny třídy další. using System.Collections.Generic; / /
simpledict.cs
using System; class SimpleDict { private Dictionary<string, int> servant = new Dictionary<string, int>(); public virtual int this[string key] { get {return servant[key];} set {servant[key] = value;} }
98
protected int GetValue(string key) { return servant[key]; } protected void SetValue(string key, int value) { servant[key] = value; } public bool ContainsKey(string key) { return servant.ContainsKey(key); } public int Count { / / @count get {return servant.Count;} } public virtual void Remove(string key) { / / @remove servant.Remove(key); } } class Cache : SimpleDict { public int Capacity {get; private set;} private List<string> destroyQueue = new List<string>(); public int DefaultValue {get; private set;} public Cache(int defaultValue, int capacity) { Capacity = capacity; DefaultValue = defaultValue; } public override int this[string key] { get { if(this.ContainsKey(key)) { int v = this.GetValue(key); Revitalize(key); return v; } else { return DefaultValue; } } set { if (value == DefaultValue) throw new ArgumentException("Invalid␣value"); if(this.ContainsKey(key)) { this.SetValue(key, value); Revitalize(key); } else {
99
if(this.Count == Capacity) { string looser = destroyQueue[0]; this.Remove(looser); } this.SetValue(key, value); destroyQueue.Add(key); } } } public override void Remove (string key){ base.Remove (key); destroyQueue.Remove(key); } private void Revitalize(string key) { destroyQueue.Remove(key); destroyQueue.Add(key); } }
Třída SimpleDict je jen jednoduchý adaptér, který veškerou svou službu deleguje na služebníka — běžný slovník. Zajímavější je označení metod pro účely potenciálního odvození. Jako virtuální je označena především klíčová (veřejná) metoda indexeru, který umožňuje přistupovat k položkám slovníku a následně tyto položky měnit. Tato metoda se bude s velkou pravděpodobností předefinovávat v odvozených třídách, neboť přístup k položkám sémanticky odlišuje jednotlivé speciální slovníky (např. zde bude implementováno zapomínání typické pro keše).
Specifikátor virtual je dále uveden u metody pro výmaz prvku SimpleDict.Remove . Zde již není potenciální nutnost předefinování tak zřejmá, a ve skutečnosti jsem specifikátor doplnil až v okamžiku, kdy se ukázala nutnost předefinování metody ve třídě Cache, Vlastnost SimpleDict.Count není označena jako virtuální, neboť se předkládá, že primárním úložištěm odvozených slovníků bude stále podobjekt servant (tj. žádný prvek specializovaného slovníku nebude uložen jinde). Proto i v odvozených třídách postačí jednoduchá implementace bázové třídy. Ze stejného nepodporuje předefinování ani metoda SimpleDict.ContainsKey. Předefinování neumožňují ani pomocné (neveřejné) metody GetValueSetValue, které poskytují přístup k podobjektu slovníku u odvozených tříd (vlastní podobjekt je soukromý a použití indexeru není vždy technicky možné). Důvod je zřejmý, obě metody pracují pouze nad děděným datovým členem a jejich funkce se tak v odvozených třídách nemění. 100
Dědičnost a rozhraní Poslední klíčovou částí syntaxe je vzájemné ovlivňování dědičnosti a implementace rozhraní u metod. Naštěstí je zde možno vycházet z jednoduché premisy (kterou už navíc známe). Mechanismus implementace rozhraní nevyužívá žádné specifikátory u implementovaných metod a vlastností. Pokud do hry nevstupuje dědičnost, tak implementace
metody rozhraní vyžaduje pouze specifikátor public .
Jiná situace nastane pouze v případě, že třída implementující rozhraní se sama stává bázovou třídou, tj. jsou z ní odvozeny další třídy. Tyto třídy automaticky dědí metody implementující rozhraní, a tak (momo jiné) automaticky splňují dané rozhraní (tuto okolnost není nutno u odvozených tříd explicitně uvádět). Pokud jsou metody rozhraní implementovány v bázové třídě bez specifikátoru
virtual virtual
, pak je nelze v odvozených třídách předefinovat. Jestliže mají specifikátor
uveden, pak jsou běžnými virtuálními metodami a lze je v další hierarchii
dědičnosti předefinovat (specifikátor se tak vztahuje k budoucí dědičnosti nikoliv k aktuálně implementovanému rozhraní!). V abstraktní třídě může být u implementované metody uveden i specifikátor . Tím vznikne zajímavá situace, kdy abstraktní třída rozhraní formálně imabstract
plementuje (překladač neohlásí syntaktickou chybu), ale ve skutečnosti neuvádí kód metody. Je však zřejmé, že alespoň listové třídy v hierarchii začínající příslušnou abstraktní třídou musí metodu nakonec implementovat (a reálně tak rozhraní splní) To je však prozatím jen formální stránka věci. Vzájemný vztah mezi rozhraními a dědičností je dosti komplexní a v různých jazycích se používají jiné idiomy. Přesto však existuje jeden jednoduchý a zároveň často používaný přístup (jež jsme již částečně využili v návrhu systému grafických tříd). Hlavní nevýhodou rozhraní je nemožnost definování metod i v případě, že existuje implementace, která vyhovuje většině objektů, které toto rozhraní implementují. Nezbývá než metodu implementovat ve třídách, které dané rozhraní implementují a to i za cenu toho, že se implementace v ničem neliší. Pokud bychom použili dědičnost, pak by se metoda automaticky dědila ze společné nadtřídy. Na druhou stranu však zároveň dané třídy zařazujeme do hierarchie, která je k sobě těsněji váže a navíc brání vytváření jiných (možná klíčovějších) hierarchií. Častečným řešením je společné využití (abstraktní) třídy a rozhraní. Předpokládejme, že potřebujeme u většího počtu tříd formátovaný výstup, a tak se rozhodneme se podporovat rozhraní IFormattable. Ve většině případů nám však stačí jen podpora šířky výstupu, i když některé třídy mohou mít vyšší požadavky. Rozhraní IFormattable definuje jen jedinou metodu: interface IFormattable { string ToString(string format, IFormatProvider formatProvider);
101
}
Pokud bychom měli jen rozhraní, tak by všechny naše třídy museli tuto metodu samostatně implementovat. Protože však očekáváme, že většina implementací bude identická, tak vytvoříme třídu, která tuto základní implementaci poskytne. using System;
//
iformat.cs
using System.Collections.Generic; class BaseFormattable : IFormattable { public string ToString(string format, IFormatProvider formatProvide) { return BaseFormattable.Format(this, format); } public static string NormalizeFormat(string format) { if (format == null || format == "") return "G0"; if (format == "G") return "G0"; return format; } public static string Format(object o, string format) { string nformat = BaseFormattable.NormalizeFormat(format); if (nformat[0] != 'G') throw new FormatException( string.Format("Unsupported␣format␣{0}", format)); int width = int.Parse(nformat.Substring(1)); string srepr = o.ToString(); int padding = Math.Max(0, width - srepr.Length); return (new string(' ', padding)) + srepr; } }
Třída implementuje metodu ToString rozhraní IFormattable prostřednictvím pomocné statické metody Format (proč bylo zvoleno toto řešení bude zřejmé za chvíli). Podporovány jsou jen tzv. všeobecné (general) formáty, označené písmenem ”G” následované šířkou výstupního pole. Tento zápis není zcela v souladu se standardy .NET, neboť zde číslo za formátovacím znakem vyjadřuje nejčastěji počet desetinných míst, ale je to akceptovatelné. Součastí povinného kontraktu metody ToString je však ošetření situace, kdy je formátovací řetezec prázdný nebo null. V tomto případě má být zvolen všeobecný formát s implicitním nastavením (u nás je to nulová šířka výstupního pole). Vlastní formátování je velmi jednoduché. Pomocí standardní metody ToString se získá neformátovaná řetězcová representace objektu this (adresáta metody). Pokud je 102
kratší než požadovaná šířka pole, pak je řetězec doplněn na požadovanou šířkou mezerami zleva. V opačném případě se vrátí beze změny (výstup se nezkracuje na šířku pole). Nyní přejděme k využití. Pokud chce nějaká třída implementovat rozhraní IFormattable má několik možností. Nejejdnodušší je situace tehdy, pokud instancím třídy stačí základní formátování a třída již není zahrnuta v nějaké hierarchii dědičnosti (nemá žádnou nadtřídu). Pak postačuje přímé dědědění ze třídy BaseFormattable. Naše třída tak implementuje požadované rozhraní bez napsání jediného řádku kódu. class Range : BaseFormattable { / /
iformat.cs
public int Min { get; private set; } public int Max { get; private set; } public Range (int min, int max) { Min = min; Max = max; } public override string ToString() { return string.Format("{0}-{1}", Min, Max); } }
Implementaci rozhraní je možno zdědit o odvozením z již odbozené třídy (například z naší třídy Range. Schopnost jednoduchého formátovaní tak mohou téměř zadarmo získat i celé malé hierarchie tříd (stačí když jejich kořenová třída dědí ze třídy BaseFormattable). Horší situace nastane, když třídě stačí jen základní formátování, ale je má již svou nadtřídu a je je zařazena do nějaké hierarchie dědičnosti. V tomto případě nemůže zároveň dědit ze třídy BaseFormattable. Naštěstí to není o mnoho složitejší, neboť vlastní implementaci jsme přesunuli do veřejné statické metody. Proto stačí uvést rozhraní IFormattable v hlavičce nové třídy a jeho metodu ToString implementovat zkopírováním kódu ze třídy BaseFormattable. Je to bezpečné, neboť je to jen jeden řádek, který se nikdy nebude měnit (všechny případné změny a rozšíření se budou týkat statické metody Format). class IntPair : Tuple, IFormattable { / / public IntPair (int x, int y) : base(x, y) { } public string ToString(string format, IFormatProvider formatProvide) { return BaseFormattable.Format(this, format); }
103
iformat.cs
}
Tato třída representující dvojice čísel získává svou základní funkčnost odvozením od knihovní kolekce n-tic, stačí pouze doplnit delegující konstruktor. Tím získáme objekty, které se příliš neliší od objektů třídy Range (rozdíl je jen sémanticky, u rozsahu je první položka interpretována jako minimum, druhá jako maximum). Základní formátování však již nelze zdědit (podporována je jen jedna nadtřída), takže rozhraní musíme implementovat klasicky přímo v této třídě. Proto je uvedeno v hlavičce třídy a příslušná metoda je beze změny zkopírována ze třídy BaseFormattable. To je vše. Poznámka: Toto spojování různých jednoduchých tříd a rozhraní (s příslušnou implementací) je v některých programovacích jazycích zcela běžný návrhový vzor. C# bohužel neposkytuje přímou podporu tomuto přístupu (spojení dědičnosti, rozhraní a volání sdílené implementace z metod rozhraní je trochu neohrabené). Některé jazyky jako např. Ruby bevo Scala však podporují mnohem vhodnější mechanismus tzv. mixinů.
Univerzální bázová třída Každá třída, která explicitně neurčuje svou nadtřídu je v .NET objektovém modelu automaticky potomkem třídy Object (navzdory svému jménu je to skutečně třída, plně kvalifikované jméno je System.Object). Tato třída je tak přímo či nepřímo bázovou třídou všech tříd standardní knihovny i tříd aplikačních. Proto se nejčasteji označuje jako kořenová tříd angl. root class. Odvození všech tříd z jediné společné natřídy (ať už přímo či nepřímo) má dvě funkce. Jednak je tím podporován učitý typ úplného polymorfismu (lze například vytvářet kolekce, jejichž prvem může být libovolný objekt). V současnosti však není tato role příliš významná, neboť jeji funkci mnohem lépe plní další typy polymorfismu (generika a dynamický polymotfismus). Druhá funkce je specifikace společného rozhraní všech objektů v systému spojené s poskytováním základních implementací, s nimiž si může vystačit většina tříd. Zde je role třídy object nezastupitelná. Třída objectje přímým předkem třídy string a prostřednictvím třídy ValueType i bázovou třídou všech čísel a dalších hodnotových typů. Hlavní informace o třídě object a jejích hlavních potomcích shrnuje následující UML diagram (zahrnuty jsou jen některé třídy vestavěné do jazyka, ve skutečnosti má třída object stovky přímých potomků). Graf dědičnosti tak v C# spíše než strom připomíná keř. class Object + string ToString() + bool Equals(Object o) + int GetHashCode() + Type GetType() abstract class ValueType: Object +
bool Equals(Object o)
104
+
int GetHashCode()
class String : Object class Int32 : ValueType class Double : ValueType abstract class Enum : ValueType class Nullable : ValueType abstract class Array : Object Object ToString():string Equals(o:Object):bool GetHashCode():int GetType():Type
{abstract}
ValueType Equals(o:Object):bool GetHashCode():int
Int32
Double
{abstract}
Enum
String
{abstract}
Array
Nullable
Obrázek 18. UML třídní diagram Univerzální bázová třída neobsahuje žádný datový člen, definuje však několik užitečných metod, které mohou (ale nemusí být) být předefinovány v odvozených třídách. metoda
funkce
implementace v Object
ToString
převod objektu na řetězec
řetězec se jménem třídy
Equals
test shody
shoda paměťového umístění
GetHashCode
hashovací hodnota objektu
hodnota odvozená z adresy objektu
GetType
objekt representující třídu
nelze předefinovat
Metodu ToString již známe. Je užitečná především při ladění a testování. I na metodu Equals jsme již narazily (i když nepřímo). Proto vás nepřekvapí, že je předefinována v případě objektů s hodnotovou sémantikou (tj. i u všech hodnotových typů). Je dostupná u všech objektů, z nichž většina poporuje i operátor rovnosti a nerovnosti. Proto se nejčastěji vyskytuje v polymorfním kódu (podobně jako metoda CompareTo z rozhraní IComparable). Pokud už předefinujeme metodu Equals musíme předefinovat i metodu GetHashCode. Ta totiž musí vracet stejný kód, pokud jsou dva objekty označeny jako 105
identické (methoda Equals vrací objekt true ).
106
Shrnutí Hierarchie tříd – vztah specializace/generalizace V rámci některých tříd existují objekty se specializovanou reakcí na podněty, tj. mohou reagovat pro ně specifickým způsobem resp. reagovat na nové jen pro ně typické podněty. Specializované objekty tvoří podtřídu v rámci dané třídy Podobně lze často objekty dvou či více tříd interpretovat, jako kdyby na určité úrovni abstrakce tvořily součást jediné třídy – generalizace. Vztah specializace/generalizace tak vytváří hierarchii mezi třídami. Dědičnost Dědičnost je objektový mechanismus vhodný pro representaci vztahu specializace/generalizace (ne však jediný). Základní rysy dědičnosti: 1. odvození je antisymetrická a tranzitivní relace mezi třídami (nikoliv mezi objekty) 2. podstatou odvození je dědění (automatické a úplné převzetí) datových členů a veřejných metod z jedné třídy (označována jako bázová) do třídy jiné (označována jako odvozená třída) 3. odvozená třída může některé metody předefinovat (nahradit je jinou specializovanější verzí) 4. odvozená třída může přidat vlastní datové členy (representující její většinou komplexnější stavy) a vlastní metody Aby mohla dědičnost modelovat specializaci/generalizaci, musí objekty tříd navíc splňovat tzv. substituční princip – objekt odvozené třídy musí být schopen plně nahradit (substituovat) objekt bázové třídy. Dědičnost nabízí tři prostředky: 1. polymorfismus – kód napsaný pro objekty bázové třídy umí pracovat i s objekty odvozených tříd (tento polymorfismus je blízký polymorfismu sdílených rozhraní, je však omezený hierachickou podstatou dědičnosti) 2. znovupoužití kódu – metody nadtřídy se automaticky převezmou do nadtřídy (a pokud postačují, tak je není potřeba měnit) 3. skládání dat – děděním a přidáváním nových datových členů se vytvářejí stále komplexnější datové objekty postupnou kompozicí Dědičnost v C# Podpora dědičnosti v C# je relativně komplikovaná. Pro jednoduché projekty však vystačíte s její malou podmnožinou. 107
C# 2010 [2], strana 150: detailnější a úplnější popis (i když nikoliv zcela kompletní) class BaseClass { private int x; / / datový člen použitelný jen v metodách bázové třídy
protected int y; / / datový člen použitelný i v odvozených třídách
public BaseClass(...) {x=...; y...;} / / konstruktor inicializuje datové členy třídy / / nedědí se!
public virtual int MethodA() {...} / / metodu lze (potenciálně) předefinovat
public int MethodB() {...} / / metoda bude jen děděna (nesmí být předefinována)
} class DerivedClass : BaseClass { private int z; / / nově přidaný člen
public DerivedClass(...) : base(...) / / volání konstruktoru předka { z = ...;} / / inicializace přidaných dat. členů / / konstruktur deleguje část práce na konstruktor předka
public override int MethodA() {...} / / nová (specializovaná) verze metody
}
Abstraktní třídy a rozhraní Definice: Abstraktní třída je třída, jež nemá žádné vlastní instance (jen zastřešuje nepřímo instance svých odvozených tříd). Je navíc na takové úrovni abstrakce, že mnohé metody není schopna implementovat (tzv. abstraktní metody). Je navrhována primárně kvůli polymorfismu. Na rozdíl od sdílených rozhraní však nabízí znovupoužitelnost (ne všechny metody jsou abstraktní) a může mít i datové členy (ty se stávají základem datové representace objektů odvozených tříd) Dědičnost a sdílená rozhraní se v C# navzájem ovlivňují či dokonce podporují. Jejich vztah vychází ze dvou elementárních pravidel: [sémantické pravidlo] odvozená třída automaticky implementuje rozhraní, která byla implementována v třídě bázové [syntaktické pravidlo] pokud metoda implementující rozhraní obsahuje specifikátory virtual , abstract , pak se týkají potenciálně odvozených tříd nikoliv rozhraní, v případě override pak nadtřídy. 108
Kořenová třída Pokud třída explicitně nedefinuje svou bázovou třídu, pak se její bázovou třídou stává třída object. Tak se tak stává přímo či nepřímo bázovou třídou všech tříd v systému (knihovních uživatelských) a hierarchie dědičnosti v C# je vždy stromem. Poskytuje univerzální polymorfismus a implementuje některé univerzální metody (některé se ani běžně nepředefinovávají)
109
Otázky a úkoly Úkol III.13: Jaké mechanismy objektového programování v sobě dědičnost spojuje? Úkol III.14: Jaké částí třídy se dědí a jaké nikoliv? Úkol III.15: Jaký je rozdíl mezi abstraktní třídou a rozhraním? Úkol III.16: Proč není ve většině případů vhodné modelovat třídu AutomobilŠkoda odvozenou od třídy OsobníAuto? Napadá Vás doména, v níž by to bylo naopak vhodné? Nápověda: Jak s tím souvisí skutečnost, že neexistuje dopravní značka ”škodovkám vjezd zakázán”?
Úkol III.17: Jakou funkci má kořenová třída object? Úkol III.18: Proč nelze modelovat třídu Syn jako podtřídu třídy Otec? A proč to nejde v opačném gardu, tj. třída Otec jako potomek třídy Syn? Nápověda: I když se to může zdát podivné, je těžší odpovědět na druhou otázku. Na vyloučení prvního modelu stačí aplikace jednoduchého principu.
Úkol III.19: Některé základní třídy jako například string jsou tzv. zapečetěné (angl. sealed), a tak z nich nelze odvozovat další třídy? Proč se tvůrci standardní knihovny pro tento krok rozhodli? Jaké existuje náhradní řešení (alespoň pro přidání metody)? Nápověda: klíčová slova: efektivita, pozdní vazba
110
Část IV.
Přílohy
Literatura [1] DRAYTON, Peter, Ben ALBAHARI a Ted NEWARD. C# v kostce: pohotová referenční příručka. 1. vyd. Překlad Karel Voráček. Praha: Grada, 2003, xxi, 764 s. ISBN 80-247-0443-9. [2] NASH, Trey. C# 2010: rychlý průvodce novinkami a nejlepšími postupy. Vyd. 1. Brno: Computer Press, 2010, 624 s. ISBN 978-80-251-3034-6. [3] C#: programujeme profesionálně. 1. vyd. Brno: Computer Press, 2003, xxx, 1130 s. ISBN 80-251-0085-5. [4] PECINOVSKÝ, Rudolf. Návrhové vzory. 1. vyd. Praha: Computer Press, 2007, 528 s. ISBN: 9788025115824 [5] Bishopová, Judith. C# - návrhové vzory. 1. vyd. Praha : Zoner Press, 2010, 328 s. ISBN: 978-80-7413-076-2 [6] Neustadt, Ila a Arlow, Jim. UML 2 a unifikovaný proces vývoje aplikací. 1. vyd. Praha : Computer Press, 2007, 568 s. ISBN: 9788025115039
112