C/C++ (40) - Dědičnost a virtuální metody

Hlavní novou funkčností OOP je dědičnost. Ukážeme si, o co jde, na co je dědičnost dobrá a jaké problémy přináší. Podíváme se také na rozdíl mezi obyčejnou a virtuální metodou.

8.1.2007 06:00 | Jan Němec | přečteno 67085×

Dědičnost

Jedním z velkých problémů při vývoji software je omezená opakovaná použitelnost existujícího kódu. Jedná se o obecný problém, který je dobře vidět i na svobodném software. Snad každý, kdo někdy hledal program pro řešení nějaké konkrétní úlohy ať už zde na LinuxSoftu nebo na SourceForge zažil určitý pocit frustrace, když zjistil, že úlohu řeší hned několik programů, ale žádný z nich uspokojivě. Často by přitom stačilo málo, trochu dotáhnout vlastnosti jednoho z programů a přidat mu některé vlastnosti, které zase fungují v jiných programech a místo tří nebo čtyř nedochůdčat bychom měli jeden skutečně použitý program. Bohužel programy bývají často napsány tak, že rozšíření funkčnosti existujícího kódu může být náročnější než vývoj úplně nového programu.

Stejný problém existuje i na nižší úrovni. V jediném programu bývá zapotřebí na různým místech vykonat podobnou akci, která se však přeci jen v některých aspektech liší. Programátor by se měl pokud možno vyvarovat duplikace kódu, měl by se tedy snažit implementovat sadu podobných akcí na jediném místě. Jsou-li akce zcela stejné, je to velmi jednoduché, stačí napsat funkci bez parametrů. Pokud se liší jen v detailech, obvykle přidáme funkci parametry. Můžeme si to ukázat na příkladu. Když chceme na více místech programu vypsat jeho verzi do (pokaždé jiného) souboru, napíšeme funkci, které předáme soubor jako parametr

void vypis(FILE *f) {
  fputs("Program verze 5.2\n", f);
}

a jistě není problém vypsat verzi podle potřeby na stdout, stderr nebo do běžného otevřeného souboru.

I tak jednoduchá akce, jako výpis verze do souboru, se může začít komplikovat. Co když je soubor blokovaný jiným procesem? Umím si představit situaci, kdy i pro tak banální operaci, jakou je krátký zápis do souboru, má smysl vytvořit vlastní vlákno. Co když se toto vlákno nepodaří vytvořit? A co když někdy chceme bezprostředně po volání fputs vynutit zápis vyrovnávací paměti na disk a jindy ne? A co když mezi fputs a případným fflush chceme vyvolat nějakou (pokaždé jinou) akci? Předat ji jako parametr na callback funkci?

Obecným řešením ve stylu C je funkce s množstvím parametrů. Ve velké většině volání jsou přitom parametry plněny nějakou výchozí hodnotou a programátora vlastně jen otravují. Dobrým příkladem jsou některé rutiny v C API X nebo i MS Windows. Méně zkušený aplikační programátor tam často tápe i v případě velmi jednoduchých volání funkcí, například u parametrů typu ukazatel neví, zda může předat NULL, pokud se má funkce chovat defaultně atd.

Předávat parametry často nestačí. V řadě případů potřebujeme funkčnost nějaké naprogramované rutiny rozšířit. V případě zápisu do souboru může jít třeba o specifické ošetření případné chyby. Představme si, že naše funkce vypisující verzi programu je součástí nějaké obecně užitečné knihovny. Tuto knihovnu přitom psal programátor bez kontaktu s programátorem aplikace, jejíž kód ji bude volat. Programátor knihovny se snaží maximálně zjednodušit základní volání funkce, ale přitom umožnit veškeré vymoženosti typu zápis ve vláknu nebo specifickou reakci na chybu. Počítá tedy s rozšířeními aplikačního programátora, ale neví, o jaká rozšíření půjde. Při klasickém procedurálním programování ve stylu C se podobný problém řeší obtížně.

Objektově orientované programování zde nabízí vlastní řešení: dědičnost. Případ, kdy třída dědičnost nevyužívá a nemá žádného předka, známe z minulého dílu. Veškerá funkčnost třídy je popsána v její definici a metodách. V opačném případě, kdy má třída předka, říkáme že je potomkem této třídy, můžeme v potomkovi používat metody a proměnné předka.

#include <stdio.h>

// Definice třídy bez předka
class Predek {
  public:
  int promenna;
  void metoda() {
    printf("%i\n", promenna);
  }
};

// Definici třídy s předkem
class Potomek : public Predek {
  public:
  void metodaPotomka() {
    puts("Zavoláme předka:");
    // volání metody předka
    metoda();
  }
};

int main(void) {
  Potomek p;

  // přístup k proměnné předka
  p.promenna = 5;
  // volání metody předka
  p.metoda();
  // volání
  p.metodaPotomka();
  return 0;
}

Definovali jsme dvě třídy: Predek a Potomek, Potomek je odvozený od třídy Predek. Má tedy všechny metody a proměnné, které zdědil od Predek a kromě toho ještě jednu vlastní metodu. Při dědění je třeba myslet na přístupová práva. K položkám s oprávněním private mohu přistupovat pouze z metod třídy, kde jsou definovány. K položkám public pak odkudkoli, tedy i z potomka nebo z vnějšku, v našem příkladu z funkce main. C++ zavádí ještě třetí, kompromisní úroveň oprávnění jménem protected. Ta zakazuje pouze přístup z vnějšku. Oprávnění se uvádí i při dědění.

class Potomek : public Predek {
  // ...
};

Pokud dědíme jako public, přístup k položkám předka je omezen jen jejich původním oprávněním. Jazyk C++ nám umožňuje tato oprávnění omezit, při dědění s klíčovým slovem protected se z public položek předka stanou v potomkovi protected a při private dědění budou všechny položky private. Tato omezení se ovšem v praxi používají minimálně, zpravidla se tedy potomci odvozují jako public, tak jako v našem příkladu.

Praktický význam oprávnění metod public a private je zřejmý, položky public tvoří rozhraní. Pokud implementujeme datovou strukturu, AVL strom, zřejmě budou public operace přidání, odebrání prvku atd., zatímco pomocné rutiny (třeba LL rotace AVL stromu) nebo data (ukazatel na kořen stromu) public nebudou. Mají být protected nebo private? Pokud při návrhu předka nemůžeme vyloučit, že k položkám budeme přistupovat i z potomka, měly by mít oprávnění protected. Jedním z dobrých příkladů jsou předefinované metody.

Zatím jsme v potomkovi funkčnost pouze přidávali, pokud předefinujeme metodu, můžeme funkčnost předka i měnit.

class Predek {
  public:
  int promenna;
  void metoda() {
    printf("%i\n", promenna);
  }
};

class Potomek : public Predek {
  public:
  // předefinovaná metoda
  void metoda() {
    printf("promenna = %i\n", promenna);
    // pokud chceme, můžeme zavolat i původní metodu
    Predek::metoda();
  }
};

Přepsání metody předka je velmi účinný nástroj. Dobře navrženou třídu můžeme snadno modifikovat bez toho, abychom zasahovali do jejího zdrojového kódu. Prostě vytvoříme potomka a předefinujeme mu některé metody. Představte si třeba krásný šachový program (editace pozice, ukládání partie, hra po síti, ...) se zabudovaným myslícím algoritmem, který je však velmi slabý. Program chceme vylepšit - volat jiný algoritmus. I při kvalitním klasickém návrhu musíme zasáhnout do kódu programu, přidat jednu funkci a modifikovat kód tak, aby byla volaná. Při správném objektovém návrhu bude stačit vytvořit potomka jedné třídy a předefinovat v něm jednu metodu. Kód tedy pouze přidáváme, ale nemodifikujeme. Je dobré si uvědomit, že například kód knihoven měnit nemůžeme.

Přepisování metod v C++ má však přeci jen jedno úskalí. Představte si následující kód:

#include <stdio.h>

// Hezký šachový program s hloupým myslícím algoritmem
class SachyKnihovni {
  protected:
  // tohle je přesně příklad na oprávnění protected
  // - metoda, kterou může potomek přepsat
  void algoritmus() {
    puts("Hloupý algoritmus");
  }
  public:
  void run() {
    algoritmus();
  }
};

// Odděděný hezký šachový program se změnou algoritmu za chytrý
class SachyNase : public SachyKnihovni {
  protected:
  // náš chytrý algoritmus
  void algoritmus() {
    puts("Chytrý algoritmus");
  }
};

int main(void) {
  SachyNase sachy;

  sachy.run();
  return 0;
}

Třída SachyKnihovni představuje šachový program. Metoda run() by ve skutečnosti implementovala nějaké GUI a funkčnost programu. Občas by přitom volala metodu algoritmus() pro hledání nejlepšího tahu. Tato metoda přitom není napsána nejlépe. V našem zjednodušení to naznačuji výpisem "Hloupý algoritmus". Celý zdrojový kód třídy by mohl být součástí nějaké knihovny. Vlastní program začíná třídou SachyNase, která pouze nahrazuje myslící algoritmus lepší implementací. Ve funkci main se pak zavolá její vstupní metoda run() poděděná od předka. Zkusme si program přeložit a spustit:

[honza@localhost]$ g++ sachy.cpp -o sachy
[honza@localhost]$ ./sachy
Hloupý algoritmus
[honza@localhost]$

Výsledek je pro nás nepříjemné překvapení. Přepsali jsme třídě SachyNase metodu algoritmus, ale v programu se přesto zavolala metoda předka. Jak je to možné? Volání obyčejných metod zpracovává překladač C++ podobně jako v případě C a globálních funkcí. Do metody musí pouze kromě běžných parametrů navíc předat ukazatel na volající objekt, jehož metodu voláme, abychom měli přístup k proměnným objektu, ale jinak se technika volání příliš neliší. Při překladu metody SachyKnihovni::run() se na místě volání algoritmus() přidá odkaz na volanou metodu, tedy SachyKnihovni::algoritmus() a při linkování se odkaz nahradí výslednou adresou metody SachyKnihovni::algoritmus(). V potomkovi jsme sice metodu přepsali na SachyNase::algoritmus() a kdybychom volali algoritmus() z funkce main nebo z vnitřku SachyNase, volala by se opravdu ta správná metoda, ale SachyKnihovni::run() se tato změna nijak nedotkne, zde se bude i nadále volat původní metoda.

Nám by se ovšem líbilo jiné chování. V případě volání metody by měl systém zjistit, o jakou třídu se fyzicky jedná a její metodu zavolat. Takovéto chování není možné zajistit v době překladu. SachyKnihovni a SachyNase mají společnou metodu run(), která volá algoritmus(). Chtěli bychom přitom, aby objektům třídy SachyKnihovni spustila SachyKnihovni::algoritmus() a objektům třídy SachyNase SachyNase::algoritmus(). V C++ toto chování zajistíme klíčovým slovem virtual.

class SachyKnihovni {
  // ...
  virtual void algoritmus() {
    puts("Hloupý algoritmus");
  }
};

class SachyNase : public SachyKnihovni {
  // ...
  virtual void algoritmus() {
    puts("Chytrý algoritmus");
  }
};

Interně obsahuje každý objekt třídy s alespoň jednou virtuální metodou ukazatel do tabulky virtuálních metod své třídy. Pokud přidáme k metodě algoritmus() klíčové slovo virtual, překladač při překladu SachyKnihovni::run() zpozorní. Ví, že odtud má zavolat virtuální metodu algoritmus(), a nemůže tedy vložit jen jednoduché volání funkce. Místo toho přidá na místo volání jednoduchý kód, který se podívá do tabulky virtuálních metod daného objektu a zavolá příslušnou metodu. O tom, která metoda se vlastně zavolá se tedy vlastně rozhoduje až za běhu programu.

Řada jiných jazyků (například Java) má automaticky virtuální všechny běžné metody a není třeba uvádět klíčové slovo virtual. V C++ tomu tak není a hlavním důvodem je zřejmě efektivita. S voláním virtuální metody má runtime přeci jen o něco víc práce, je tedy o něco pomalejší. Nicméně pokud mají metody nějaký rozumný a netriviální obsah a kód není tvořen jen ďábelským převoláváním jednoduchých metod, je rozdíl mezi obyčejnou a virtuální implementací zanedbatelný. V souvislosti s dědičností by měl způsob výběru volané virtuální metody téměř ve 100% případů odpovídat představám programátora, naopak u obyčejných metod dochází k problémům popsaným v našem příkladu. Rozhodujeme-li se, zda vyvíjenou metodu označíme jako virtual, měli bychom myslet hlavně na budoucí použití třídy a návrh jejích případných potomků. Pokud předem víme, že třída žádné potomky mít nebude nebo alespoň žádný z potomků nebude přepisovat naší metodu, můžeme ji definovat jako obyčejnou metodu. V opačném případě obvykle musí být virtual. Při vývoji knihovny bychom si měli uvědomit, že aplikační programátor ji může využívat pro nás naprosto neuvěřitelným způsobem a bude předefinovávat i metody, které považujeme za definitivní. Klíčové slovo virtual by se proto mělo v knihovnách objevovat častěji než v běžném aplikačním kódu.

Pokračování příště

V příštím dílu se podíváme na hierarchii tříd, abstraktní metody a polymorfismus.

Online verze článku: http://www.linuxsoft.cz/article.php?id_article=1355