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
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
obj->fun(…), kde obj je ukazatel na T, nebo
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í.
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:
main.
Writable.
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.
std::unique_ptr<Writable>.
String, která dědí Writable a v metodě write vypíše řetězec, který získá konstruktorem.
Number, která umí vypsat číslo typu double, které získá konstruktorem.
Foo a Bar.
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
Vytvořme novou třídu dědící z Writable, která sama vlastní nějaký počet
vypisovatelných objektů.
Group dědící z Writable.
Group přidejte jako data objekt typu std::vector<std::unique_ptr<Writable».
Group metodu push_back, která má parametr typu Writable*. Voláním metody Group převezme vlastnictví odkazovaného objektu.
Group tři řetězce m_prefix, m_separator, m_suffix.
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.
Group konstruktor, který jako parametry dostává tři řetězce.
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.
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';
copy, clone, nebo něco podobného).