OOP prakticky: Dědičnost

…aneb dědo, dědo dědo!
24. 12. 2008

Přestože považuji dědičnost za více méně jasnou záležitost, spousta programátorů má s tímto pojmem problémy a občas se divím, co všechno jsou programátoři schopni podědit :-). Ale zpět k matematice. Nyní jsou na řadě binární operace: sčítání, odečítání, násobení a dělení. Jak budeme reprezentovat tyto operace? Inu, zase jako objekty. Budou to třídy, které budou obsahovat dva sloty: „A“ a „B“. Blbé pojmenování, co? Ono kromě dělení, kde můžeme použít čitatel a jmenovatel, se ty výrazy nijak dobře a jednotně nejmenují, takže zůstaneme u tohohle. Slot „A“ bude reprezentovat první výraz, slot „B“ bude reprezentovat druhý výraz. Easy. Jednoduchá verze součtu a rozdílu by mohla vypadat takto:

   1: class Soucet
   2: {
   3:     public Promenna A;
   4:     public Promenna B;
   5: }
   6:  
   7: class Rozdil
   8: {
   9:     public Promenna A;
  10:     public Promenna B;
  11: }

Bystré (a snad i méně bystré) oko čtenářovo si jistě všimlo, že tyto dvě třídy jsou dosti nesmyslné a… celkově podivné. Jistě vás napadla otázka „proč jsou vlastnosti A a B zrovna typu Proměnná?“ či „jak tam dostanu Konstantu?“ Otázky jsou to správné, protože takhle definované třídy jsou nesmyslné. Ještě bystřejší mysl by se mohla zeptat, jestli není nešikovné, že v každé třídě definujeme tytéž vlastnosti. A v součinu a podílu je jistě budeme definovat taktéž. Hmm, s tím by chtělo něco udělat.

Potřebovali bychom nějaký mechanismus, kterým bychom kompilátoru řekli, že do těch slotů A a B můžeme uložit libovolný matematický výraz. Ať už ty, co jsme vytvořili doposud, nebo ty, které vytvoříme v budoucnu. Chtělo by to nějaký prostředek, kterým označíme všechny naše třídy za matematické výrazy a pak jako typ proměnné A a B uvedeme právě tento matematický výraz. A přesně k tomuto slouží dědičnost. Vytvoříme si novou třídu, kterou nazveme „Vyraz“ (či „MatematickyVyraz“). Tato třída bude reprezentovat předka všech dalších tříd, které vytvoříme. A všechny třídy, které jsme vytvořili, budou z této abstraktní třídy dědit. Tím všechny naše třídy označíme za Matematické výrazy a tento typ pak můžeme normálně používat jako vestavěné typy (Integer, String). Nejprve vytvoříme novou třídu MatematickyVyraz a pak upravíme všechny stávající třídy tak, aby z této třídy dědily (v C# se to dělá pomocí dvojtečky):

   1: abstract class MatematickyVyraz
   2: {
   3:  
   4: }
   5:  
   6: class Konstanta : MatematickyVyraz
   7: {
   8:     ...
   9: }
  10:  
  11: class Promenna : MatematickyVyraz
  12: {
  13:     ...
  14: }

Klíčové slovo abstract znamená, že z třídy nepůjde vytvořit instance. Je to vcelku rozumné omezení, asi není moc pravděpodobné, že bychom někdy potřebovali instanci obecného Matematického Výrazu. Nyní můžeme předchozí třídy součtu a rozdílu upravit takto:

   1: class Soucet : MatematickyVyraz
   2: {
   3:     public MatematickyVyraz A;
   4:     public MatematickyVyraz B;
   5: }
   6:  
   7: class Rozdil : MatematickyVyraz
   8: {
   9:     public MatematickyVyraz A;
  10:     public MatematickyVyraz B;
  11: }

Teď už jsme docílili toho, aby ve slotech A a B mohl být libovolný Matematický Výraz. Vzhledem k tomu, že samotné třídy součet a Rozdíl dědí z Matematického Výrazu, tak jeden objekt Součet v sobě může obsahovat další objekt Součet, tudíž již můžeme vytvořit nekonečnou posloupnost součtů a rozdílů. Jeden z konečných případů by mohl vypadat takto: (1 + (2 + (3 – 4))). Ještě jsme ale nevyřešili duplikování stejného kódu. Vidíme, že ve třídách se opakují vždy dva stejné řádky. To by chtělo nějak změnit.

Rodičovské třídy mohou klidně obsahovat i nějaké vlastnosti či metody, nemusí to být pouze prázdná třída. Tyto metody a vlastnosti poté zdědí i ostatní třídy. Pokud tedy definujeme v třídě Matematický Výraz sloty A a B, nemusíme je již definovat v Součtu a Rozdílu, a přesto je tam uvidíme a můžeme s nimi pracovat. Můžete si to představit tak, jako že se do třídy Součtu „nakopírují“ všechny metody a vlastnosti z rodičovské třídy Matematický výraz. To by nám trochu ulehčilo práci, kdybychom nemuseli pořád vypisovat tytéž vlastnosti, ne?

Stop! Kdo si myslí, že dané řešení je špatné, má bod. Bylo by sice hezké, kdybychom si ušetřili trochu práce, ale za jakou cenu? Z třídy Matematický Výraz dědí třeba i Konstanta. Má mít Konstanta dva sloty A a B? K čemu by to bylo dobré? Má tedy libovolný Matematický Výraz mít dva sloty? Určitě ne. Ve třídě Matematický Výraz by se měly vyskytovat vlastnosti či metody, které má mít opravdu každý Matematický Výraz. To dva sloty A a B určitě nejsou. Takže jak z toho ven?

Dědičnost samozřejmě nemusí být pouze jedna, třída A může dědit z třídy B a ta může dědit z třídy C a ta může blabla… Třídy Součet a rozdíl jsou zcela zřejmě binární operace, tak co takhle vytvořit další abstraktní třídu, kterou nazveme Binární Výraz, která bude dědit z Matematický Výraz a která bude obsahovat právě ty dva sloty, o které nám jde? Součet a Součin již pak bude pouze dědit z této třídy. Binární výrazy budou mít své dva sloty a Konstanta nebude mít dva zbytečné sloty navíc. Koza se nažrala a vlk zůstal celý. Třídy upravíme takto:

   1: abstract class BinarniVyraz : MatematickyVyraz
   2: {
   3:     public MatematickyVyraz A;
   4:     public MatematickyVyraz B;
   5: }
   6:  
   7: class Soucet : BinarniVyraz
   8: {
   9:  
  10: }
  11:  
  12: class Rozdil : BinarniVyraz
  13: {
  14:  
  15: }

Teď přijde na řadu metoda ToString(). Zde opět využijeme dědění. Samozřejmě bychom mohli nadefinovat v každé třídě metodu ToString() zvlášť a celou, ale jednodušší bude, když se podíváme, jak chceme, aby ty binární výrazy vypadaly. Vždy to bude něco v takovémto tvaru: (a + 2), (4 – 8), (fronta * helma). Na prvním místě reprezentace A, pak mezera, pak specifický operátor, pak mezera, pak reprezentace B. To by šlo zjednodušit tak, že si napíšeme metodu, která bude brát jeden argument – ten bude představovat právě onen prostřední operátor. Metoda ToString() pak bude tuto metodu volat, v každé třídě pak pouze předá jiný argument, jednou plus, jinde minus. Tato kouzelná metoda bude poté umístěna právě ve třídě Binární Výraz, protože tato metoda se nebude měnit a budou ji potřebovat všechny Binární Výrazy. Implementace:

   1: abstract class BinarniVyraz : MatematickyVyraz
   2: {
   3:     public MatematickyVyraz A;
   4:     public MatematickyVyraz B;
   5:  
   6:     protected string ToBinaryString(string Znamenko)
   7:     {
   8:         return String.Format("({0} {1} {2})", A.ToString(), Znamenko, B.ToString());
   9:     }
  10: }
  11:  
  12: class Soucet : BinarniVyraz
  13: {
  14:     public override string  ToString()
  15:     {
  16:           return ToBinaryString("+");
  17:     }
  18: }
  19:  
  20: class Rozdil : BinarniVyraz
  21: {
  22:     public override string  ToString()
  23:     {
  24:           return ToBinaryString("-");
  25:     }
  26: }

Klíčové slovo protected je takový kompromis mezi private a public. Public metody jsou vidět odkudkoliv a private zase odnikud, ani ze zděděné třídy. Protected zajistí, že metoda nebude vidět zvenčí jako public, ale bude vidět ve zděděných třídách, na rozdíl od private metod.

A teď pár obecných chytrých rad k dědičnosti: nevyužívejte dědičnost při každé příležitosti, kdy mají dva objekty něco společného. Využívejte dědičnost jen v případě, kdy je to logické. Pokud máme třídy Dítě a Rodič (Dítě dědí z Rodiče, zápis v C#: Dítě : Rodič), pak Dítě musí být vždy speciální případ Rodiče. Dítě musí být podmnožinou Rodiče. Například Obdélník je specifický případ Geometrického útvaru. Stejně tak Bod se specifický případ Geometrického objektu. A podmnožinou Geometrického útvaru je jak Obdélník, tak Bod. Pokud programujete nějakou hru, můžete mít abstraktní třídu Hráč a další dvě třídy, které dědí z Hráče: Lidský Hráč a Počítačový Hráč. Vždy musí platit, že pokud v některé funkci vyhovuje Rodič, musí funkci stačit, i když dostane Dítě. Pokud v naší kalkulačce něco předpokládá na vstupu Binární Výraz, musí se spokojit s jakýmkoliv Binárním Výrazem, tj. jak se Součtem tak i s Rozdílem či Podílem. Pokud metoda očekává na vstupu Hráče, tak funkce musí dávat smysl, ať už předáme Lidského Hráče nebo Počítačového.

Co nemá smysl: ve třídě Obdélník definujeme metodu obsah. Nyní definujeme třídu Kružnice. Uvědomíme si, že Kružnice má také obsah, tak ji jako předka dáme Obdélník, tedy Kružnice : Obdélník. Zřejmě je to ale nesmysl, Kružnice není specifický případ Obdélníku. A později se nám to vymstí. V Obdélníku můžeme definovat metodu na výpočet délky úhlopříčky, kterou Kružnice samozřejmě zdědí. Jak implementujeme výpočet úhlopříčky v Kružnici? No nijak, je to evidentní nesmysl, protože už to dědění bylo chujové. Možným řešením je vytvořit nějakou jinou třídu, která bude reprezentovat Geometrické útvary, které mohou mít obsah, tj. zabírají nějakou plochu. Chujové by taky bylo, kdybyste zdědili z Hráče třeba Zákazníka, protože Hráč i Zákazník mají své jméno nebo podobnou blbost. Že mají dva objekty některé společné vlastnosti ještě neznamená, že mají být v nějakém dědičném vztahu. Radiátor i člověk mohou mít nějakou teplotu – mají snad být v dědičném vztahu…?

Často se dává příklad s autem a volantem. Má být auto rodič a volant potomek? Samozřejmě, že ne. Volant není specifický případ auta. Potomkem auta může být třeba Audi nebo Škoda, ale ne volant ani pneumatiky. Pneumatiky sice s autem souvisí, ale jinak. Auto obsahuje volant, auto volant. To je všechno pravda, ale dědičnost tam nemá co dělat. Auto má obsahovat slot, ve kterém bude uložena instance volantu. Rozhodně nedědit!

To by nám prozatím mohlo u binárních výrazů stačit. Třídy pro Součin a Podíl zde teď uvádět nebudu, protože je to na chlup stejné jako definice Součtu a Rozdílu. Snad jen s tím rozdílem, že při zapouzdřování podílu musíte dát pozor, abyste nedělili nulou (tedy vlastnost B musí být různá od nuly). Vrhněme se na poslední základní pilíř OOP: