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'; }
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).