Warning
This page is located in archive. Go to the latest version of this course pages. Go the latest version of this page.

Cvičení 8: Dědičnost

Průvodce studiem Cílem dnešního cvičení je naučit se rozpoznat různá nebezpečí, která se skrývají v dědění a objektově orientovaném polymorfismu v C++. Začněme následujícím kódem:

#include <iostream>
 
class Writable {
public:
};
 
class Foo : public Writable {
public:
};
 
class Bar : public Writable {
public:
};
 
int main() {
    Writable* writ = new Writable;
    Writable* foo = new Foo;
    Writable* bar = new Bar;
    delete writ;
    delete foo;
    delete bar;
}

Třída Writable má být společným předkem všech tříd, které lze vypsat na standardní výstup metodou write. Teď bude naším cílem metodu write naimplementovat. Všimněte si, že můžeme na potomky třídy Writable odkazovat ukazatelem typu Writable*. Stejně tak můžeme použít chytré ukazatele, ale zatím nebudeme unique_ptr používat, abychom mohli lépe uvažovat o tzv. virtuálním destruktoru (viz. dále).

První pokus o implementaci metody write by mohl vypadat takto:

class Writable {
public:
    void write(std::ostream& out) const { out << "Writable\n"; }
};
 
class Foo : public Writable {
public:
    void write(std::ostream& out) const { out << "Foo\n"; }
};
 
class Bar : public Writable {
public:
    void write(std::ostream& out) const { out << "Bar\n"; }
};

Naším záměrem je změnit funkcionalitu metody write podle toho, jaký je přesný typ objektu. Přidejme volání metody write pro každý z ukazatelů writ, foo a bar:

    writ->write(std::cout); // Writable
    foo->write(std::cout);  // Foo
    bar->write(std::cout);  // Bar

Zde nárážíme na první nepříjemné překvapení, protože dostaneme následující výstup:

Writable
Writable
Writable

Dynamická vazba

Kde se stala chyba? Problém je v tom, že jsme neoznačili metodu write jako virtuální. Pouze volání virtuálních metod způsobí vyhledávání správné implementace pro danou metodu. Takové volání nazýváme dynamické.

Podmínky pro dynamické volání metody fun třídy T jsou:

  • fun je virtuální metoda ve třídě T a
  • volání je ve tvaru obj->fun(…), kde obj je ukazatel na T, nebo
  • volání je ve tvaru obj.fun(…), kde obj je reference na T.

Všimněte si, že dynamického volání nelze dosáhnout bez ukazatele nebo reference. Když máme přímo hodnotu typu T, nemůže se jednat o potomka, který dědí z T; musí to být právě T. To se projevuje neintuitivním chováním při kopírování z předka do potomka (tzv. slicing problem).

Virtuální metodu zavedeme klíčovým slovem virtual:

class Writable {
public:
    virtual void write(std::ostream& out) const { out << "Writable\n"; }
};
 
class Foo : public Writable {
public:
    virtual void write(std::ostream& out) const { out << "Foo\n"; }
};
 
class Bar : public Writable {
public:
    virtual void write(std::ostream& out) const { out << "Bar\n"; }
};

Přísně vzato bychom nemuseli psát virtual u dědiců Foo a Bar, ale je to více než slušné. Informujeme tím čtenáře kódu, že dochází k dynamickému volání.

Je důležité, aby název a parametry dané virtuální metody byly zcela shodné ve všech dědících metodách. Pokud bychom například pojmenovali omylem jednu z metod wirte místo write, nedošlo by k dynamickému volání. Stejně tak přidání druhého (ač nepovinného) parametru by zabránilo tomu, aby se zavolala námi zamýšlená definice metody. Těmto druhům chyb zabrání klíčové slovo override, které oznamuje náš záměr nahradit virtuální metodu předka:

class Writable {
public:
    virtual void write(std::ostream& out) const { out << "Writable\n"; }
};
 
class Foo : public Writable {
public:
    virtual void write(std::ostream& out) const override { out << "Foo\n"; }
};
 
class Bar : public Writable {
public:
    virtual void write(std::ostream& out) const override { out << "Bar\n"; }
};

Nyní pokud nebude v předkovi odpovídající deklarace, program se nezkompiluje. V tuto chvíli je rozumné v dědicích Foo a Bar vypustit klíčové slovo virtual, protože override samo o sobě vyžaduje, aby metoda byla virtuální.

Zrádný destruktor

Pojďme zkoumat životní cyklus objektů uvnitř našich tříd. Nápomocná nám bude třída, která oznamuje svůj vznik a zánik výpisem na standardní výstup.

struct LisNaJesterkyAHady {
    LisNaJesterkyAHady() { std::cout << "Lis vytvoren!\n"; }
    ~LisNaJesterkyAHady() { std::cout << "Lis znicen!\n"; }
};

Umístěte objekt typu LisNaJesterkyAHady na různá místa v programu a pozorujte výpisy na standardní výstup:

  • Vytvořte objekt jako lokální proměnnou ve funkci main.
  • Umístěte objekt do třídy Writable.
  • Umístěte objekt do tříd Foo a Bar.

Vidíme, že ve třetím případě dojde k úniku paměti – ani jeden lis nebyl smazán. Nachytali jsme se totiž na další neintuitivní chování dědičnosti v C++: stejně jako ostatní metody, i destruktor musí být označen jako virtuální, aby se zavolal dynamicky. Bohužel, automaticky generovaný destruktor není virtuální, pokud není virtuální destruktor předka. Je trestuhodné, že řada rozšířených kompilátorů nás nevaruje, i když máme ve třídě jiné virtuální metody.

Chybu opravíme takto:

class Writable {
public:
    virtual ~Writable() = default;
    ...
};
 
class Foo : public Writable {
public:
    virtual ~Foo() override = default;
    ...
};
 
class Bar : public Writable {
public:
    virtual ~Bar() override = default;
    ...
};

Jak bylo zmíněno výše, stačilo by deklarovat destruktor Writable jako virtuální, a vše by již fungovalo. Klíčové slovo override u destruktorů ve Foo a Bar má ale výhodu, že virtualní destruktor ve Writable vynutí; kdyby tam chyběl, program se nezkompiluje. Protože nepotřebujeme v žádném z těchto destruktorů provést nic speciálního, použijeme kompilátorem generované tělo pomocí = default.

  • Vyměňte všechny ukazatele v kódu za chytré ukazatele std::unique_ptr<Writable>.
  • Vytvořte třídu String, která dědí Writable a v metodě write vypíše řetězec, který získá konstruktorem.
  • Podobně vytvořte třídu Number, která umí vypsat číslo typu double, které získá konstruktorem.
  • Odstraňte třídy Foo a Bar.

Řešení

Hierarchie pomocí dědičnosti

Teď, když máme virtuální destruktory, nic nám nebrání vlastnit objekty pomocí ukazatele na předka. Pokud bychom chtěli uložit různé potomky třídy Writable do kolekce, poslouží nám vektor ukazatelů, volitelně chytrých:

std::vector<Writable*> writables;
std::vector<std::unique_ptr<Writable>> writables;

Co naopak nebude fungovat je uložit si přímo hodnoty daného typu, protože stále platí, že hodnota typu Writable musí být právě Writable, nikoli některý z jeho potomků.

std::vector<Writable> writables; // takto ne

Úkoly k procvičení Vytvořme novou třídu dědící z Writable, která sama vlastní nějaký počet vypisovatelných objektů.

  • Vytvořte novou třídu Group dědící z Writable.
  • Do Group přidejte jako data objekt typu std::vector<std::unique_ptr<Writable».
  • Přidejte do Group metodu push_back, která má parametr typu Writable*. Voláním metody Group převezme vlastnictví odkazovaného objektu.
  • Přidejte do třídy Group tři řetězce m_prefix, m_separator, m_suffix.
  • Nahraďte ve třídě Group poděděnou metodu write. V ní vypište všechny vlastněné objekty oddělené řetězcem m_separator, navíc celý výpis uvěďte řetězcem m_prefix a zakončete řetězcem m_suffix.
  • Vytvořte ve třídě Group konstruktor, který jako parametry dostává tři řetězce.
  • Ve třídách Number a String nevypisujte konce řádků za číslem/řetězcem.

Vyzkoušejte kód na následující funkci main:

int main() {
    Group g("(", ", ", ")");
    g.push_back(new Number(123));
    g.push_back(new Number(456));
    g.push_back(new String("abc"));
    g.push_back(new String("def"));
 
    std::unique_ptr<Group> gp(new Group("[", " | ", "]"));
    gp->push_back(new Number(789));
    gp->push_back(new String("ghi"));
    g.push_back(gp.release());
 
    g.write(std::cout); // (123, 456, abc, def, [789 | ghi])
    std::cout << '\n';
}

Metoda .release() uvolňuje vlastnictví spravovaného objektu. Na rozdíl od metody .get(), která sice vrátí ukazatel na objekt spravovaný std::unique_ptr, ale nevolní jeho vlastnictví (tj. ukazatel zůstává stále zodpovědný za správu dat, za jejich odstranění).

Metoda .reset() zničí objekt aktuálně spravovaný std::unique_ptr.

Dynamické volání v nezávislé funkci

Bylo by rozumné, aby nám fungoval operátor << pro výstup do proudu pro všechny dědice třídy Writable. To uděláme snadno tak, že vezmeme referenci na Writable:

std::ostream& operator<<(std::ostream& out, const Writable& writable) {
    writable.write(out);
    return out;
}

Teď můžeme psát:

std::cout << g << '\n';

Řešení

Další nebezpečenství dědění

  • Kopírování nefunguje. Nemůžeme udělat něco jako virtuální kopírující konstruktor a spol. Pokud chceme zkopírovat objekt neznámého typu, musíme vytvořit vlastní metodu, která je virtuální (copy, clone, nebo něco podobného).
  • Dynamické volání nefunguje v konstruktoru. V konstruktoru nelze zavolat variantu metody z dědící třídy, protože předek existuje dříve, než potomek.
courses/b6b36pcc/cviceni/cviceni-08.txt · Last modified: 2022/11/09 17:06 by nagyoing