Lekce 7 - Dědičnost a polymorfismus

C# .NET Objektově orientované programování Dědičnost a polymorfismus American English version English version

ONEbit hosting Unicorn College Tento obsah je dostupný zdarma v rámci projektu IT lidem. Vydávání, hosting a aktualizace umožňují jeho sponzoři.

V minulé lekci, C# - Aréna s bojovníky, jsme dokončili naši arénu, simulující zápas dvou bojovníků. Dnes si opět rozšíříme znalosti o objektově orientovaném programování. V úvodní lekci do OOP jsme si říkali, že OOP stojí na třech základních pilířích: zapouzdření, dědičnosti a polymorfismu. Zapouzdření a používání modifikátoru private nám je již dobře známé. Dnes se podíváme na zbylé dva pilíře.

Dědičnost

Dědičnost je jedna ze základních vlastností OOP a slouží k tvoření nových datových struktur na základě starých. Vysvětleme si to na jednoduchém příkladu:

Budeme programovat informační systém. To je docela reálný příklad, abychom si však učení zpříjemnili, bude to informační systém pro správu zvířat v ZOO :) Náš systém budou používat dva typy uživatelů: uživatel a administrátor. Uživatel je běžný ošetřovatel zvířat, který bude moci upravovat informace o zvířatech, např. jejich váhu nebo rozpětí křídel. Administrátor bude moci také upravovat údaje o zvířatech a navíc zvířata přidávat a mazat z databáze. Z atributů bude mít navíc telefonní číslo, aby ho bylo možné kontaktovat v případě výpadku systému. Bylo by jistě zbytečné a nepřehledné, kdybychom si museli definovat obě třídy úplně celé, protože mnoho vlastností těchto 2 objektů je společných. Uživatel i administrátor budou mít jistě jméno, věk a budou se moci přihlásit a odhlásit. Nadefinujeme si tedy pouze třídu Uzivatel (nepůjde o funkční ukázku, dnes to bude jen teorie, programovat budeme příště):

class Uzivatel
{
        private string jmeno;
        private string heslo;
        private int vek;

        public bool Prihlasit(string heslo)
        {
                // ...
        }

        public bool Odhlasit()
        {
                // ...
        }

        public void NastavVahu(Zvire zvire)
        {
                // ...
        }

        // ...
}

Třídu jsem jen naznačil, ale jistě si ji dokážeme dobře představit. Bez znalosti dědičnosti bychom třídu Administrator definovali asi takto:

class Administrator
{
        private string jmeno;
        private string heslo;
        private int vek;
        private string telefonniCislo;

        public bool Prihlasit(string heslo)
        {
                // ...
        }

        public bool Odhlasit()
        {
                // ...
        }

        public void NastavVahu(Zvire zvire)
        {
                // ...
        }

        public void PridejZvire(Zvire zvire)
        {

        }

        public void VymazZvire(Zvire zvire)
        {

        }

        // ...
}

Vidíme, že máme ve třídě spoustu redundantního (duplikovaného) kódu. Jakékoli změny musíme nyní provádět v obou třídách, kód se nám velmi komplikuje. Nyní použijeme dědičnost, definujeme tedy třídu Administrator tak, aby z třídy Uzivatel dědila. Atributy a metody uživatele tedy již nemusíme znovu definovat, C# nám je do třídy sám dodá:

class Administrator: Uzivatel
{
        private string telefonniCislo;

        public void PridejZvire(Zvire zvire)
        {

        }

        public void VymazZvire(Zvire zvire)
        {

        }

        // ...
}

Vidíme, že ke zdědění jsme použili operátor :. V anglické literatuře najdete dědičnost pod slovem inheritance.

V příkladu výše nebudou v potomkovi přístupné privátní atributy, ale pouze atributy a metody s modifikátorem public. Atributy a metody s modifikátorem private jsou chápány jako speciální logika konkrétní třídy, která je potomkovi utajena, i když ji vlastně používá, nemůže ji měnit. Abychom dosáhli požadovaného výsledku, použijeme nový modifikátor přístupu protected, který funguje stejně, jako private, ale dovoluje tyto atributy dědit. Začátek třídy Uzivatel by tedy vypadal takto:

class Uzivatel
{
        protected string jmeno;
        protected string heslo;
        protected int vek;

        // ...

Když si nyní vytvoříme instance uživatele a administrátora, oba budou mít např. atribut jmeno a metodu Prihlasit(). C# třídu Uzivatel zdědí a doplní nám automaticky všechny její atributy.

Výhody dědění jsou jasné, nemusíme opisovat oběma třídám ty samé atributy, ale stačí dopsat jen to, v čem se liší. Zbytek se podědí. Přínos je obrovský, můžeme rozšiřovat existující komponenty o nové metody a tím je znovu využívat. Nemusíme psát spousty redundantního (duplikovaného) kódu. A hlavně - když změníme jediný atribut v mateřské třídě, automaticky se tato změna všude podědí. Nedojde tedy k tomu, že bychom to museli měnit ručně u 20ti tříd a někde na to zapomněli a způsobili chybu. Jsme lidé a chybovat budeme vždy, musíme tedy používat takové programátorské postupy, abychom měli možností chybovat co nejméně.

O mateřské třídě se někdy hovoří jako o předkovi (zde Uzivatel) a o třídě, která z ní dědí, jako o potomkovi (zde Administrator). Potomek může přidávat nové metody nebo si uzpůsobovat metody z mateřské třídy (viz dále). Můžete se setkat i s pojmy nadtřída a podtřída.

Další možností, jak objektový model navrhnout, by bylo zavést mateřskou třídu Uzivatel, která by sloužila pouze k dědění. Z Uzivatel by potom dědili Osetrovatel a z něj Administrator. To by se však vyplatilo při větším počtu typů uživatelů. V takovém případě hovoříme o hierarchii tříd, budeme se tím zabývat ke konci této sekce. Náš příklad byl jednoduchý a proto nám stačily pouze 2 třídy. Existují tzv. návrhové vzory, které obsahují osvědčená schémata objektových struktur pro známé případy užití. Zájemci je naleznou popsané v sekci Návrhové vzory, je to však již pokročilejší problematika a také velmi zajímavá. V objektovém modelování se dědičnost znázorňuje graficky jako prázdná šipka směřující k předkovi. V našem případě by grafická notace vypadala takto:

Dědičnost objektů – grafická notace

Datový typ při dědičnosti

Obrovskou výhodou dědičnosti je, že když si vytvoříme proměnnou s datovým typem mateřské třídy, můžeme do ni bez problému ukládat i její potomky. Je to dané tím, že potomek obsahuje vše, co obsahuje mateřská třída, splňuje tedy "požadavky" (přesněji obsahuje rozhraní) datového typu. A k tomu má oproti mateřské třídě něco navíc. Můžeme si tedy udělat pole typu Uzivatel a v něm mít jak uživatele, tak administrátory. S proměnnou to tedy funguje takto:

Uzivatel u = new Uzivatel("Jan Novák", 33);
Administrator a = new Administrator("Josef Nový", 25);
// Nyní do uživatele uložíme administrátora:
u = a;
// Vše je v pořádku, protože uživatel je předek
// Zkusíme to opačně a dostaneme chybu:
a = u;

V C# je mnoho konstrukcí, jak operovat s typy instancí při dědičnosti. Podrobně se na ně podíváme během kurzu, nyní si ukažme jen to, jak můžeme ověřit typ instance v proměnné:

Uzivatel u = new Administrator("Josef Nový", 25);
if (u is Administrator)
        Console.WriteLine("Je to administrátor");
else
        Console.WriteLine("Je to uživatel");

Pomocí operátoru is se můžeme zeptat, zda je objekt daného typu. Kód výše otestuje, zda je v proměnné u uživatel nebo jeho potomek administrátor.

Jazyky, které dědičnost podporují, buď umí dědičnost jednoduchou, kde třída dědí jen z jedné třídy, nebo vícenásobnou, kde třída dědí hned z několika tříd najednou. Vícenásobná dědičnost se v praxi příliš neosvědčila, časem si řekneme proč a ukážeme si i jak ji obejít. C# podporuje pouze jednoduchou dědičnost, s vícenásobnou dědičností se můžete setkat např. v C++.

Polymorfismus

Nenechte se vystrašit příšerným názvem této techniky, protože je v jádru velmi jednoduchá. Polymorfismus umožňuje používat jednotné rozhraní pro práci s různými typy objektů. Mějme například mnoho objektů, které reprezentují nějaké geometrické útvary (kruh, čtverec, trojúhelník). Bylo by jistě přínosné a přehledné, kdybychom s nimi mohli komunikovat jednotně, ačkoli se liší. Můžeme zavést třídu GeometrickyUtvar, která by obsahovala atribut barva a metodu Vykresli(). Všechny geometrické tvary by potom dědily z této třídy její interface (rozhraní). Objekty kruh a čtverec se ale jistě vykreslují jinak. Polymorfismus nám umožňuje přepsat si metodu Vykresli() u každé podtřídy tak, aby dělala, co chceme. Rozhraní tak zůstane zachováno a my nebudeme muset přemýšlet, jak se to u onoho objektu volá.

Polymorfismus bývá často vysvětlován na obrázku se zvířaty, která mají všechna v rozhraní metodu Speak(), ale každé si ji vykonává po svém.

Polymorfismus

Podstatou polymorfismu je tedy metoda nebo metody, které mají všichni potomci definované se stejnou hlavičkou, ale jiným tělem. Polymorfismus si spolu s dědičností vyzkoušíme v příští lekci, Aréna s mágem (dědičnost a polymorfismus), na bojovnících v naší aréně. Přidáme mága, který si bude metodu Utoc() vykonávat po svém pomocí many, ale jinak zdědí chování a atributy bojovníka. Zvenčí tedy vůbec nepoznáme, že to není bojovník, protože bude mít stejné rozhraní. Bude to zábava :)


 

 

Článek pro vás napsal David Čápka
Avatar
Jak se ti líbí článek?
40 hlasů
Autor pracuje jako softwarový architekt a pedagog na projektu ITnetwork.cz (a jeho zahraničních verzích). Velmi si váží svobody podnikání v naší zemi a věří, že když se člověk neštítí práce, tak dokáže úplně cokoli.
Unicorn College Autor sítě se informační technologie naučil na Unicorn College - prestižní soukromé vysoké škole IT a ekonomie.
Miniatura
Předchozí článek
C# - Aréna s bojovníky
Aktivity (7)

 

 

Komentáře
Zobrazit starší komentáře (14)

Avatar
Martin Tomko
Člen
Avatar
Martin Tomko:6.2.2017 20:10

Ahojte.
Zaujímalo ma, ako sa bude správať program, keď nezmením v triede Uzivatel atribúty z private na protected. Na moje počudovanie to programu vôbec nevadí a vo vytvorenej inštancii Administratora viem s privátnymi atribútmi Uzivatela pracovať. Prikladám časť spomínaného kódu:

class Uzivatel
{
private string meno = "Jozef";
private string priezvisko = "Nový";
private int vek = 25;

public override string ToString()
{
return String.Format("{0} {1} {2}", meno, priezvisko, vek);
}
...
}

class Program
{
static void Main(string[] args)
{
Administrator a = new Administrator();
Console.Write­Line(a);
Console.ReadKey();
}
}

Dokonca aj keď som si vytvoril konštruktory v triede Uzivatel aj Administrator, tak som vedel pri inštancovaní Administratora nastaviť privátne atribúty definované v Uzivatelovi. Prečo je to tak? Bude sa ten rozdiel prejavovať niekde inde?

Ďakujem.

 
Odpovědět 6.2.2017 20:10
Avatar
Michaela Radimská:3. března 13:12

Ahoj, můžeš mi prosím vysvětlit k bodu 1, jestli se to reálně využívá, kdy a proč? Fungování jsem snad celkem pochopila a vyzkoušela.

 
Odpovědět 3. března 13:12
Avatar
Michaela Radimská:3. března 13:17

Ještě doplňuji, že jde o příspěvek z roku 2016, cituji úsek, který mne zajímá:

Cituji:
"Když ale přiřadíš Administrator do Uzivatel můžeš se k němu chovat jako k uživateli s tím, že pro dané metody může například vykonávat něco jiného než Uzivatel jako takový. Tohle chování v důsledku tvoří základ polymorfismu, o kterém je v článku také řeč. Samozřejmě pak platí, že tímto přiřazením se odřízneš od toho, co má Administrator navíc od Uzivatel, ale to v místě, kde se k němu chceš chovat jako k uživateli vlastně vůbec nevadí. :)"

 
Odpovědět 3. března 13:17
Avatar
Jindřich Máca
Tým ITnetwork
Avatar
Odpovídá na Michaela Radimská
Jindřich Máca:3. března 18:49

Zdravím! Polymorfismus, obzvláště ve staticky typovaným jazycích jako je C#, např. ještě v kombinaci s rozhraním nebo abstraktní třídou, je velice mocným nástrojem a má mnoho praktických využití. Takový jednoduchý příklad je již nastíněn i v článku, kde můžeme vzít instance tříd různých zvířat a uložit je všechny do jediného pole typu třídy zvířete, ze které tyto třídy zvířat dědí. Takový hodně zjednodušený kód tohoto by mohl vypadat následovně:

// Třída zvířete.
class Zvire
{
    public virtual string VydejZvuk()
    {
         return "";
    }
}

// Třídy jeho potomků.
class Pes : Zvire
{
    public override string VydejZvuk()
    {
        return "Woof";
    }
}

class Kocka : Zvire
{
    public override string VydejZvuk()
    {
        return "Meow";
    }
}

class Kachna : Zvire
{
    public override string VydejZvuk()
    {
        return "Quack";
    }
}

// Hlavní program ve funkci Main.
Zvire[] zvirata =  {new Pes(),  new Kocka(),  new Kachna()};

foreach (Zvire zvire in zvirata)
{
    Console.WriteLine(zvire.VydejZvuk());
}

Na výstupu podle očekávání vydá popořadě každé zvíře svůj zvuk "Woof", "Meow", "Quack", ale všechny jsou uloženy ve stejném poli pro lepší hromadnou manipulaci.

Doufám, že teď už je to lépe představitelné. Podobné, samozřejmě složitější konstrukce mají v praxi široké uplatnění. ;)

 
Odpovědět 3. března 18:49
Avatar
Odpovídá na Jindřich Máca
Michaela Radimská:3. března 19:28

Děkuji za odpověď, ale asi jsem se špatně zeptala. Šlo mi o případ, který jsi tam vysvětloval:

Uzivatel u = new Administrator("Jo­sef Nový", 25);

s komentářem:
"Samozřejmě pak platí, že tímto přiřazením se odřízneš od toho, co má Administrator navíc od Uzivatel, ale to v místě, kde se k němu chceš chovat jako k uživateli vlastně vůbec nevadí."

Nejsem si jistá, proč ho neimplementovat jako administrátora, tj. kdy se to smysluplně využije (zřejmě chci použít přepsané metody, ale nedovolit mu něco, co smí administrátor a nesmí uživatel?) - nenapadá mne reálná situace, ta mne zajímala. Díky :-)

 
Odpovědět 3. března 19:28
Avatar
Martin Petrovaj:3. března 20:36

Možno by som niečo dodal aj ja:

Scenár 1: Predstavme si, že máme napr. nejakú grafickú, kresliacu aplikáciu, ktorej súčasťou je canvas (kresliaca plocha). Na nej sú rozmiestnené rôzne užívateľom vytvorené objekty - čiary, obdĺžniky, elipsy… ktoré všetky dedia z abstraktnej triedy GeometrickyUtvar, ktorá obsahuje aj vlastnosť Poloha. Užívateľ myšou označí viacero rôznych útvarov a chce ich potiahnutím naraz presunúť niekam vedľa.

foreach (GeometrickyUtvar utvar in oznaceneObjekty)
{
        utvar.Poloha = mys.Poloha + offset;
}

Užívateľ v tomto prípade mohol označiť čokoľvek - elipsu, text, kruh - nás to ale nezaujíma, nepotrebujeme funkcionalitu rozdielnych objektov, chceme nám len zmeniť ich polohu. Stačí nám s nimi teda pracovať ako s inštanciami triedy GeometrickyUtvar. Konieckoncov, snažiť sa predpovedať, aké druhy objektov používateľ označí nedáva veľmi zmysel a nie je to ani potrebné :-)

Scenár 2: Predstavme si jednoduchú akčnú hru, kde máme triedy Nepriatel implementujúcu metódu Zomri a triedu StraslivyNepriatel, ktorá z Nepriatel dedí. Keďže StraslivyNepriatel je ale taký výrazne vzácnejší a náročnejší nepriateľ, budeme mu chcieť pridať nejaké zvláštne efekty pri smrti, aby mal hráč lepší pocit z víťazstva :-) (napr. predsmrtný výkrik, výbuchy v okolí a pod.). V hre máme naskriptovaný event, ktorý počas misie (zrejme pri nejakej zvláštnej udalosti alebo cutscéne) zabije skupinu nepriateľov - aj bežných, aj "strašlivých".

class Nepriatel
{
        //…

        virtual public void Zomri()
        {
                nazive = false;
                PrehrajAnimaciu( this.animaciaSmrti );
                //…
        }
}
class StraslivyNepriatel : Nepriatel
{
        //…

        override public void Zomri()
        {
                nazive = false;
                SpawniVybuchy();
                PrehrajZvuk( this.revPredSmrtou );
                PrehrajAnimaciu( this.animaciaSmrti );
                //…
        }
}
foreach (Nepriatel nepriatel in zasiahnutiNestastnici)
{
        nepriatel.Zomri();      // pouzije spravnu implementaciu ako pre Nepriatel, tak aj pre StraslivyNepriatel
}

Pokiaľ v oddedenej triede preťažíme virtuálnu metódu slovíčkom override, zavolá sa táto preťažená metóda aj v situácii, keď s inštanciou pracujeme ako s jej predkom (napr. ak pracujeme s inštanciou StraslivyNepriatel ako keby to bol Nepriatel).
Ak by sme chceli dosiahnuť opačné chovanie (použiť implementáciu používaného typu bez ohľadu na to, o aký objekt sa v skutočnosti jedná), použili by sme v potomkovi namiesto override slovíčko new.

Pravdaže, reálne by sa obidve tieto situácie mohli a asi aj riešili inak, ale tu sú použité len ako ukážky na vysvetlenie O:-)

Odpovědět 3. března 20:36
if (this.motto == "") { throw new NotImplementedException(); }
Avatar
Odpovídá na Martin Petrovaj
Michaela Radimská:3. března 21:03

A co: Nepritel jmenoNepritele = new StrasnyNepritel();
To by odpovídalo příkladu z článku: Uzivatel u = new Administrator("Jo­sef Nový", 25);
Myslím že chápu, co to dělá, dneska jsem vykoumala i ty override metody, díky za tip na klíčové slovo new. Ale stále nechápu, k čemu by mohlo být dobré uložit potomka do rodiče (doufám že to vysvětluji srozumitelně, s terminologií se zatím úplně nekamarádím)

 
Odpovědět 3. března 21:03
Avatar
Odpovídá na Michaela Radimská
Martin Petrovaj:3. března 21:28

Ak ťa zaujíma vyslovene len v akej situácii by si napísala niečo v tom štýle ( Rodic p = new Potomok(); ), tak to ti z hlavy len tak nepoviem :-D

Prevody medzi triedami spojenými dedičnosťou sa využívajú veľa, ale väčšinou sa to robí automagicky, keď napr. ukladáš odvodený typ do kolekcie obsahujúcej objekty rodičovskej triedy, posielaš odvodenú triedu funkcii, ktorá ako parameter berie rodiča atď. To je vlastne to, na čo sa ty pýtaš, len už niekde aplikované a použité bez toho, aby si sa o to musela sama nejako extra starať :-)

Práve preto, že to za teba rieši jazyk sa takýto osobitný zápis, ktorý ty zmieňuješ veľmi nevidí. Niekedy, keď napríklad vytiahneš pomocou LINQ nejaké dáta z databázy a chceš ich rýchlo bez nejakej konverzie na list prejsť cyklom a pod., tak môžeš použiť zápis

// vráti nejaký prapodivný objekt, ale nezaujíma nás aký, prevedieme si ho na IEnumerable
IEnumerable<string> mena = from uzivatel in databaza.Users select uzivatel.Name;

foreach (string m in mena)
{
        //…
}

To je v princípe podobné tomu, na čo sa ty pýtaš, len neprevádzame z potomka na rodiča, ale na rozhranie z triedy, ktorá ho implementuje (dokonca nemusíme vôbec riešiť z akej - jedna z výhod prístupu). Pravdaže, alternatívou by bolo napr. použitie slovíčka var, ale pokiaľ by sme mena napr. vracali ako návratovú hodnotu, uvedeniu typu by sme sa asi nevyhli.

Editováno 3. března 21:30
Odpovědět  +1 3. března 21:28
if (this.motto == "") { throw new NotImplementedException(); }
Avatar
Odpovídá na Martin Petrovaj
Michaela Radimská:3. března 21:40

No, přesně to mne zajímalo (kdy se použije Rodic p = new Potomok()). Zaujalo mě to ve škole a tady v tutoriálu, ale už se v tom přestanu šťourat. Zřejmě je to spíš okrajová záležitost, jestli se s tím někdy potkám, pochopím :-D
Díky moc, aspoň jsem se dozvěděla další zajímavé věci :-)

 
Odpovědět  +1 3. března 21:40
Avatar
coells
Redaktor
Avatar
Odpovídá na Michaela Radimská
coells:4. března 0:56

Přimo takovýhle zápis se v C# používá při implementaci explicitního rozhranní.
Metoda hello() nepůjde zavolat přímo a musíš přetypovat.

public interface IA {
    string hello();
}

public class A : IA {
    string IA.hello() {
        return "A";
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        IA a = new A();
        Console.WriteLine(a.hello());
    }
}

V C# také budeš potkávat návrhový vzor Strategy, který používá podobný zápis implicitně.
Jiný důvod tady k takhle explicitnímu zápisu ale obvykle není.

Na širší diskuzi, kdy se to hodí, bychom museli opustit C#, který není tak úplně objektový (část principů je obětována jiné filosofii), a podívat se na jazyky, kde to smysl dává, nebo jazyk úplně opustit a zaměřit se na úlohu rozhraní a polymorfismu z hlediska objektových principů.

 
Odpovědět 4. března 0:56
Děláme co je v našich silách, aby byly zdejší diskuze co nejkvalitnější. Proto do nich také mohou přispívat pouze registrovaní členové. Pro zapojení do diskuze se přihlas. Pokud ještě nemáš účet, zaregistruj se, je to zdarma.

Zobrazeno 10 zpráv z 24. Zobrazit vše