Példafeladat
Feladat: modellezzünk alakzatokat! Legyen a modellben téglalap és kör, és ezeket lehessen egy közös tárolóba tenni! Minden alakzatot lehessen kirajzolni, a saját színével! A tároló kirajzolása rajzolja ki az összes alakzatot, a megfelelő sorrendben! Készüljünk fel más típusú alakzatok tárolására is!
Az öröklés témakörét elsősorban ezen a feladaton keresztül fogjuk bemutatni. Ez a reláció a programozásban gyakran előfordul, és rengeteg modellezési példát old meg. Nézzük meg alaposan a feladat kritériumait, amik rávezetnek minket a kapcsolatok elemeire!
- Minden alakzatnak van színe.
- Minden alakzatot ki lehet rajzolni, de a különböző típusú alakzatok kirajzolása mást jelent.
- Az alakzatok pozícióját nem érdemes egységesen tárolni. Körnél a középpont tárolása tűnik a legalkalmasabbnak, téglalapnál a bal felső sarok. Ha esetleg lesz általános sokszög, annál ezek egyike sem. Egyvalami tűnik biztosnak, hogy minden alakzatnak van egy referenciapontja, amihez képest nyilvántarthatjuk a kiterjedését.
- A méretek nyilvánvalóan egyediek a különböző típusú alakzatoknál. Kört a sugár vagy átmérő, téglalapot a két oldalhossz jellemez legjobban.
- Az alakzatok közös kirajzolásánál a sorrend az átfedések miatt fontos. Nem mindegy, hogy előbb a kék kört, és utána a piros téglalapot rajzoljuk ki, vagy fordítva, mert a második takarná az elsőt.
Az első fontos következtetésünk, hogy lesznek olyan tulajdonságok és műveletek, amik minden alakzatra ugyanúgy működnek, és lesznek csak bizonyos típusú alakzatokra jellemzőek is. A közös részeket érdemes az egyes alakzatoktól külön kezelni, hogy elkerüljük a kódduplikációt.
Legyen tehát egy típus a közös részeknek (Alakzat
), amit majd valamilyen módon felhasznál a Kor
és a Teglalap
. A közös részeken kívül a téglalapnak és a körnek semmi köze egymáshoz.
class Alakzat {
uint32_t szin;
public:
// rajzolás?
};
A szín típusának uint32_t
-t választottuk, mert nagyon egyszerűen kezelhető, ugyanakkor a Prog1-ből ismerős SDL rajzolófüggvényei is ezt használják, 0xRRGGBBAA
formátumban.
Micsoda?
Mi köze a Kor
és Teglalap
típusoknak az Alakzat
-hoz?
Minden téglalap egy alakzat, és minden kör egy alakzat. Amit meg lehet csinálni egy alakzattal, azt egy körrel is. Kódban: ahova Alakzat
típus kell, ott meg kéne tudjunk adni egy kört vagy egy téglalapot is. Azt szeretnénk, ha ilyen kódot lehetne írni:
void alakzat_feldolgoz(Alakzat const& a) {
a.rajzol();
std::cout << "az alakzat területe: " << a.terulet();
}
Kor k1(/* ... */);
alakzat_feldolgoz(k1);
Teglalap t1(/* ... */);
alakzat_feldolgoz(t1);
A függvény paraméterként alakzatot vár. Ha a kör az alakzatok egy fajtája, akkor jó kell legyen a kör típusú paraméter is. Ugyanez a helyzet a téglalappal. Általánosságban ez egy „minden micsoda micsoda” jellegű kapcsolat. Angolul úgy mondják, hogy is-a, azaz Teglalap is an Alakzat. A programozásban ezt leszármazásnak vagy öröklésnek nevezzük.
A típusok közötti kapcsolat pontos definícióját pedig a Liskov-féle elv adja meg (LSP, Liskov Substitution Principle), ami a következőt mondja ki: akkor mondhatjuk, hogy B
osztály az A
osztály leszármazottja, ha bárhol használhatunk B
típusú objektumot, ahol használhattunk A
típusú objektumot is.
A fenti példában ez teljesül: a függvény alakzatot vár, ezért kaphat kört is és téglalapot is. Persze igaznak kell lennie, hogy a kör is kirajzolható és a téglalap is, illetve hogy a körnek is kiszámolható a területe és a téglalapnak is. Vegyük észre, hogy ha minden téglalap egy alakzat, akkor a téglalap meg kell örökölje az alakzat attribútumait is. Ha az alakzatoknak van színe, akkor a téglalapoknak is kell legyen. Ezért szokták ezt a kapcsolatot jellemezni az öröklés szóval.
Az osztályok közötti leszármazás jellegű kapcsolatra a C++ jelölés ez:
class Teglalap : public Alakzat {
// ...
};
Mivel a fordítónak ezzel egyértelműen jelezzük, hogy minden téglalap alakzat, ezért megengedi nekünk, hogy átadhassuk a Teglalap
-ot Alakzat
-ként. Ez technikailag úgy jelenik meg, hogy a leszármazott objektum referenciája (és pointere) automatikusan tud konvertálódni ősosztály referenciájává (és pointerévé). Tehát a Teglalap& → Alakzat&
, illetve Teglalap* → Alakzat*
konverziók helyesek, ezért implicit módon is elvégezhetők.
Fontos megjegyezni, hogy ez a konverzió csak akkor működik az elvárt módon, ha pointerek vagy referenciák között történik, érték szerinti átadásnál nem. A fenti alakzat_feldolgoz()
függvény paramétere nem lehet Alakzat
, csak Alakzat&
vagy Alakzat*
, különben a kód nem fog helyesen működni. Ennek okáról lesz szó a fejezet későbbi részében.
Az öröklési viszonyt a következő fogalmakkal is szokták jellemezni:
- A
Teglalap
osztály leszármazik azAlakzat
-ból, tehát azAlakzat
leszármazottja aTeglalap
. - A
Teglalap
ősosztálya azAlakzat
. - Angolul az ősosztály base class, a leszármazott pedig derived class.
- A
Teglalap
egy speciálisAlakzat
, tehát aTeglalap
azAlakzat
specializációja. - A
Teglalap
és aKor
általánosítása azAlakzat
. - Elterjedten használt a típus – altípus terminológia is (type – substype).
- Több osztály ős-leszármazott viszonyát nevezhetjük hierarchiának.
Kompatibilitás
Feladat: írjunk függvényt, ami egy alakzat színét invertálja! Az Alakzat
osztályokhoz ezúttal nem nyúlhatunk.
Jó lenne, ha nem kellene minden alakzattípusra egyesével megírni. Elvégre minden téglalap alakzat, tehát ahogy egy alakzatot lehet invertálni, úgy egy téglalapot is lehet.
void invertal(Alakzat& alakzat) {
uint32_t regiszin = alakzat.get_szin();
uint32_t ujszin = szin_invertal(regiszin); // szin_invertal-t valaki megírta
alakzat.set_szin(ujszin);
}
Teglalap t1(/* ... */);
invertal(t1);
Ez működni fog téglalapokra is, mivel az előbb megmondtuk a fordítónak, hogy a Teglalap
leszármazik az Alakzat
-ból, az LSP értelmében. Ezért ahol a kódban Alakzat
-ot várunk, oda adhatunk paraméternek Teglalap
-ot is, ezt a tulajdonságot nevezzük kompatibilitásnak.
A szín és a referenciapont kezelését meg tudtuk oldani az ősosztályban, mert azt minden alakzatra ugyanúgy kell. A fenti alakzat_feldolgoz
függvény viszont még mindig nem tud működni, az Alakzat
-nak nicsenek rajzol
és terulet
függvényei.
Az invertáló függvénynek tehát át tudunk adni mindenféle alakzatot, de hogyan fog működni az alakzat_feldolgoz
? Valahogy azt is meg kell majd oldani, hogy az alakzatokon a rajzol
és a terulet
is az elvárt módon működjön, tehát ha Kor
objektumot adtunk át neki, akkor kört rajzoljon, ha Teglalap
-ot kapott, akkor téglalapot rajzoljon.
Eddig is tudtuk:
Még mielőtt rátérnénk az új elemekre, nézzük meg, az eddig bemutatott nyelvi elemekkel mit tudunk megoldani, és mit nem!
Az alakzatok pontjait az egyszerűség kedvéért ebben a primitív struktúrában tároljuk:
struct Pont {
int x;
int y;
Pont(int x, int y)
: x(x)
, y(y) {
}
};
A kör pozícióját és méretét egyértelműen a középpont és a sugár / átmérő jellemzi legjobban, mi a sugár mellett döntöttünk. A téglalapnál ez sokkal kevésbé triviális, még akkor is, ha megkötjük, hogy a téglalapok oldalai párhuzamosak a koordináta-tengelyekkel. Legalább három lehetőségünk adódik:
- középpont,
a
ésb
oldal hossza - bal felső sarok,
a
ésb
oldal hossza - bal felső és jobb alsó sarkok
Bármelyik megoldást is választjuk, a téglalap minden adata matematikailag kiszámítható a meglévő adatokból. Így ez a tervezési döntés a lényeg szempontjából irreleváns, ezért mi önkényesen a másodikat választjuk.
class Alakzat {
Pont referenciapont;
uint32_t szin;
public:
Alakzat(Pont referenciapont, uint32_t szin)
: referenciapont(referenciapont)
, szin(szin) {
}
void mozgat(Pont mennyivel) {
referenciapont.x += mennyivel.x;
referenciapont.y += mennyivel.y;
}
uint32_t get_szin() const {
return szin;
}
void set_szin(uint32_t szin) {
this->szin = szin;
}
// rajzolás?
// terület?
};
class Teglalap : public Alakzat {
int szeles, magas;
public:
void rajzol() {
SDL_valami_teglalap(
referenciapont.x, referenciapont.y,
referenciapont.x + szeles, referenciapont.y + magas,
szin);
}
double terulet() {
return szeles * magas;
}
};
class Kor : public Alakzat {
Pont kozeppont;
int sugar;
public:
void rajzol() {
SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
}
double terulet() {
return sugar * sugar * M_PI;
}
};
A protected
kulcsszó
Nézzük meg alaposan a Kor::rajzol
-t!
void Kor::rajzol() {
SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
}
A fordító ebben a függvényben a kezünkre csap. A referenciapont
és szin
adattagokat a Kor
az Alakzat
-tól örökölte, ott viszont private
elérésűnek nyilvánítottuk őket. Privát adattagokat viszont a tartalmazó osztályon kívülről nem érhetjük el, még a leszármazottak sem érhetik el.
Ebben a konkrét esetben a leszármazottak használhatnák a publikus get_szin
függvényt is, viszont sokszor nem tehetjük publikussá azt, amire a leszármazottaknak szüksége van, ezért nézzünk rá más megoldást!
Ha egy adattagot vagy függvényt a leszármazottak számára is elérhetővé szeretnénk tenni, de a nyilvánosság számára nem, használhatjuk a protected
módosítót. Ugyanaz a szintaktikája, mint a public
-nak és a private
-nak.
class Alakzat {
protected:
Pont referenciapont;
uint32_t szin;
public:
// ...
};
Így a leszármazottak már közvetlenül elérik a referenciapont
-ot és a szin
-t. A láthatósági szintek összefoglalva:
public | protected | private | |
---|---|---|---|
Saját tagfüggvények | ✓ | ✓ | ✓ |
friend függvények | ✓ | ✓ | ✓ |
friend osztályok tagfüggvényei | ✓ | ✓ | ✓ |
Leszármazottak tagfüggvényei | ✓ | ✓ | ✕ |
Többi függvény | ✓ | ✕ | ✕ |
Fordító által generált konstruktorok és destruktor
A fordító által generált default konstruktor, mint megtanultuk, az adattagok default konstruktorát hívja az adattagok definiálásának sorrendjében. A leszármazás esetén ez kiegészül az ősosztály konstruktorának meghívásával. Ugyanis az ősosztálynak is vannak adattagjai, azokat is létre kell hozni. Például egy téglalap létrehozása esetén az objektumnak lesz szín adattagja is, annak is kell értéket adni. Ezért ha van ősosztály, akkor még a leszármazott adattagjai konstruktorainak hívása előtt hívja meg az ős konstruktorát. Sorrendben:
- Ős konstruktorának hívása, inicializáló lista alapján
- Adattagok konstruktorainak hívása, sorrendben, inicializáló lista alapján
- Konstruktor törzsében lévő utasítások
A leszármazottak konstruktora miatt a fordító még mindig a kezünkre csap. Az Alakzat
-nak csak olyan konstruktora van, ami egy színt vár paraméternek. azonban mivel mi a Teglalap
konstruktoránál nem adtuk meg, az ős melyik konstruktorát hívja a leszármazott, a fordító alapból a default-ot keresi, olyan pedig nincs. Ha meg akarjuk adni, hogy az ősosztály melyik konstruktora hívódjon, azt az inicializáló lista elején tehetjük meg.
class Teglalap : public Alakzat {
int a;
int b;
public:
Teglalap(Pont balfelso, int szelesseg, int magassag, uint32_t szin)
: Alakzat(balfelso, szin) // <- !
, a(szelesseg)
, b(magassag) {
}
// ...
};
class Kor : public Alakzat {
int sugar;
public:
Kor(Pont kozeppont, int sugar, uint32_t szin)
: Alakzat(kozeppont, szin) // <- !
, sugar(sugar) {
}
// ...
};
A destruktor pontosan ugyanezeket csinálja, csak fordítva:
- Destruktor törzsében lévő utasítások
- Adattagok destruktorainak hívása, deklarációval ellentétes sorrendben
- Ős destruktorának hívása
Ha van virtuális függvény, a destruktor külön figyelmet érdemel, erről is lesz szó nemsokára.
Virtuális tagfüggvények
A hierarchiában hova kerül a rajzol
függvény?
Az első következtetésünk szerint az ősosztály a közös viselkedésért is felelős, nem csak az adattagokért, tehát a rajzol
-nak meg kell jelennie az Alakzat
-ban. Viszont – mivel a különböző típusú alakzatzokat teljesen máshogy kell kirajzolni – ha meghívjuk egy Alakzat
objektumon a rajzol
függvényt, akkor Kor
alakzat esetén a Kor
rajzolásának kellene hívódnia.
Pontosan ezt a viselkedést teszi lehetővé a virtuális tagfüggvény:
class Alakzat {
// ...
public:
virtual void rajzol() {
std::cout << "???" << std::endl;
}
virtual double terulet() {
return 0.0; // <- ???
}
};
class Teglalap : public Alakzat {
// ...
public:
virtual void rajzol() {
SDL_valami_teglalap(
referenciapont.x, referenciapont.y,
referenciapont.x + szeles, referenciapont.y + magas,
szin);
}
virtual double terulet() {
return szeles * magas;
}
};
class Kor : public Alakzat {
// ...
public:
virtual void rajzol() {
SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
}
virtual double terulet() {
return sugar * sugar * M_PI;
}
};
Eszerint az Alakzat
-nak van rajzol
függvénye, amit a leszármazottak megörökölnek, mint minden függvényt, tehát mindenképp lesz rajzol függvényük. Ha egy virtuális függvény ősosztálybeli implementációja a leszármazottban nem az elvárt módon működik, felülírhatjuk a viselkedését. A Teglalap
és a Kor
pontosan ezt teszi. Ha a Teglalap
-ot Alakzat
-ként látjuk, és meghívjuk rajta a rajzol függvényt, akkor is a Teglalap::rajzol
hívódik.
Ha egy virtuális függvényt az osztályon kívül szeretnénk definiálni, a definícióba tilos kiírni a virtual
kulcsszót:
void Kor::rajzol() {
SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
}
Tisztán virtuális függvény, absztrakt osztály
A fenti Alakzat::rajzol
függvény implementációjánál érezzük, hogy valami nincs rendben. Az Alakzat
nem tud semmit a leszármazottairól, de még a saját pozíciójáról sem, azokat is egyedi módon tárolják az alakzatok. Ezért az Alakzat
-ban nem lehet implementálni a rajzol
-t. Viszont mivel minden alakzatot ki lehet rajzolni, ezért a leszármazottban kötelező!
Ezt a kötöttséget – hogy a leszármazottnak kötelező implementálnia a rajzol
-t – igazából a fordítónak kellene ellenőriznie, és arra is lehetőséget kellene adnia, hogy az ősosztályban ne kelljen. Erre való a tisztán virtuális függvény nyelvi elem. C++-ban a függvénydeklaráció végére írt = 0
a jelölése:
class Alakzat {
// ...
public:
virtual void rajzol() = 0;
virtual double terulet() = 0;
};
Ennek a 0
-nak semmi köze a 0
egész számhoz, ez nem NULL
pointer, csak egy jelölés. Később lesz róla szó, mire utal.
Vegyük észre, hogy az az osztály, aminek van tisztán virtuális függvénye, egy különleges gyengeséggel bír: önmagában nem létezhet. Hiszen megígértük, hogy van rajzol
függvénye, akkor annak valahol kell lennie implementációjának, egy leszármazottban. Tehát Alakzat
-ból nem hozhatunk létre példányt, csak Alakzat leszármazottaiból. Az ilyen osztályokat absztrakt osztályoknak nevezzük.
Az eredeti feladat megoldásával szinte készen vagyunk. Egy dolog hiányzik, az 5. részfeladat, amelyik azt mondta, hogy téglalapokat és köröket közös tárolóba kell tudnunk tenni.
A megoldásban a kompatibilitás – mint az öröklés alapvető tulajdonsága – segít. Mivel minden téglalap és kör igazából Alakzat
, ha Alakzat&
-et vagy Alakzat*
-ot látunk, tudjuk, hogy "alatta" akár Kor
, akár Teglalap
lehet, és a rajzol
függvényt meghívva a leszármazott megfelelő rajzol
-ja hívódik.
Ötlet: tegyünk a tárolóba Alakzat*
-okat! Az ősosztályra cast-olással fedjük el a leszármazottak közti különbségeket, és így, közös típusként már az STL tárolók is elbírnak vele, a példában std::vector
:
Teglalap t1(Pont(1, 2), 4, -4, 0xFF0000FF);
Kor k1(Pont(3, 0), 2, 0x00FF00FF);
std::vector<Alakzat*> alakzatok; // <- közös tároló
alakzatok.push_back(&t1); // Teglalap* -> Alakzat*
alakzatok.push_back(&k1); // Kor* -> Alakzat*
std::vector<Alakzat*>::iterator it;
for(it = alakzatok.begin(); it != alakzatok.end(), ++it)
it->rajzol(); // <- kirajzolás a sorrend megtartásával: a kör a téglalap felett van
Miért épp pointer?
Az Alakzat
-okat értelemszerűen érték szerint nem tárolhatjuk, hiszen absztrakt osztály. A referencia sem jó ötlet, azokból nem lehet tömböt építeni. Megállapíthatjuk, hogy a heterogén kollekció mindig pointert tárol. Ez azonban azzal a kényelmetlenséggel fog járni, hogy az alakzatainkat dinamikusan kell foglalni.
Azért hívják heterogén kollekciónak, mert egyetlen tárolóban több különböző típusú valamit – téglalapot, kört – tárol. Legtöbb esetben elmondható, hogy csak azért írunk osztályhierarchiát, mert heterogén kollekcióra van szükség, a feladat többi része megoldható lenne anélkül – például template-tel, duck typing-gal.
Az Alakzat
-okat nem csak azért nem tárolhatjuk érték szerint, mert absztrakt, hanem a C / C++ memóriakezelési sajátosságai miatt sem, mindjárt megmagyarázzuk.
A kompatibilitás kapcsán felmerülhet az a kérdés, hogyan lehet a leszármazott kompatibilis az ősével, hova kerülnek az adattagok a memóriában, és hogyan lehet mindig helyes a leszármazott → ős konverzió.
Ezt úgy oldja meg a fordító, hogy a leszármazott memóriaképének az elejére kerül az ős teljes tartalma, és utána következnek a leszármazott-specifikus adattagok. Így ha van egy pointerünk (vagy referenciánk), ami valami Alakzat
elejére mutat, biztos lehet benne a fordító, hogy a mutatott helyen az első adattag a szin
, a típusa uint32_t
, a következő egy Pont
, amit a kódban referenciapont
-nak hívunk, stb. A leszármazott többi adattagja pedig ezek után következik.
Mi történne, ha egy Alakzat
-ot érték szerint próbálnánk átadni egy függvénynek?
Mivel az érték szerint átadott paraméterek a stackre másolódnak, a fordítónak előre tudnia kell, mennyi hely kell nekik. Egy Alakzat
-nál viszont nem lehet előre tudni, hogy a leszármazottai mennyi helyet foglalnak pluszban, ezért a fordító kénytelen csak egy Alakzat
-nyit hagyni, és a leszármazottak többi adattagjának nem jut hely. Tehát a memóriában leszeletelődik az Alakzat
-ról a leszármazott része, és csak az ősosztály adattagjai maradnak meg.
Ha egy Kor
-t próbálunk Alakzat
-ként érték szerint átadni, a sugar
adattag tehát elveszik. Enélkül viszont a Kor
virtuális függvényei sem működhetnek, hiszen azok próbálnák használni a sugar
-t, ami már nincs ott! Ezért ha egy objektum leszeletelődik, a fordító nem csak az adattagjait, hanem a viselkedését is megváltoztatja: sima Alakzat
-ként viselkedne tovább, virtuális függvényhívásnál az Alakzat
-é hívódna!
Ez ebben a konkrét esetben egyébként nem történhetne meg, a fordító a kezünkre csapna, mert az Alakzat
absztrakt. Mivel nem létezhet belőle önálló példány, nem lehet érték szerint átadni. Persze, hiszen egy önálló Alakzat
-on a rajzol
függvényt sem lehetne meghívni.
Mi történne, ha az Alakzat
nem lenne absztrakt?
void f(Alakzat a);
Kor k1(Pont(3, 0), 2, 0x00FF00FF);
Alakzat a1 = k1; // <- !
f(k1); // <- !
Ez miért fordul le, mi történik itt?
Mindkét esetben az Alakzat
copy ctora hívódik: Alakzat(Alakzat const &a)
. mivel a kompatibilitás miatt a Kor& → Alakzat&
konverzió automatikus, ezért az Alakzat
copy ctora kaphat kört is. És ő alakzatot fog létrehozni, tehát csak a színt és a referenciapontot fogja lemásolni, mivel nem tudja, hogy ténylegesen milyen alakzatról van szó.
Ha egy dinamikusan foglalt objektumot az ősosztály felől törlünk, előfordulhat váratlan memóriaszivárgás. Ősosztály felőli törlés alatt ezt értjük:
Alakzat *a = new Cimke(Pont(0, 10), "Hello world!", 0xFF0000FF);
delete a;
Ezt szerencsére könnyű kikerülni, de előbb nézzük meg, mi történhet ilyenkor, ez miért baj. Vegyünk fel egy új Alakzat
leszármazottat, ami egy sztringet reprezentál (Cimke
, label).
class Cimke : public Alakzat {
std::string felirat;
public:
// ...
};
Ezután vegyünk fel egy heterogén tárolót, amibe tegyünk dinamikusan foglalt alakzatokat, majd dolgunk végeztével töröljük őket!
std::vector<Alakzat*> alakzatok;
alakzatok.push_back(new Teglalap(Pont(1, 2), 3, 4, 0xFF0000FF));
alakzatok.push_back(new Kor(Pont(3, 0), 2, 0x00FF00FF));
alakzatok.push_back(new Cimke(Pont(0, 10), "Hello world!", 0xFF0000FF));
// ...
for(int i = 0; i < alakzatok.size(); ++i)
delete alakzatok[i];
Első ránézésre helyesnek tűnhet a programunk, pedig nem az. Hogyan helyezkednek el ezek az objektumok a memóriában?
Mi történik, amikor egy Alakzat*
-ként ismert Cimke
objektumra hívjuk a delete
-et?
Ilyenkor természetesen az Alakzat
destruktora hívódik, ami meghívja az adattagjai destruktorát. Arról viszont fogalma sincs, milyen adattagok vannak a leszármazottban, pedig akár ott is lehet dinamikusan foglalt terület. Például a Cimke
-nek van egy std::string
adattagja, aminek a belsejében biztosan van valami dinamikusan foglalt adat.
Az Alakzat
destruktora tehát csak a szin
és pont
adattagokról tud, a felirat adattag destruktora ezért nem fog meghívódni! Jó lenne, ha Alakzat törlésénél is a leszármazottak megfelelő destruktora hívódna, ahogy Alakzat kirajzolásánál is kör vagy téglalap rajzolódik ki. Az erre való nyelvi elemet már ismerjük, ez a virtuális függvény. Tehát az ősosztály destruktorát virtuálissá kell tennünk! Ezen felül semmi dolgunk nincs vele, tehát a törzse maradhat üres.
class Alakzat {
// ...
public:
virtual void rajzol() = 0;
virtual double terulet() = 0;
virtual ~Alakzat() {} // <- !
};
Felmerülhet a kérdés, hogy mégis mikor van szükség virtuális destruktorra, a Pont
osztályunknál például feleslegesnek érezhetjük.
Ez a fajta memóriaszivárgás akkor lép fel, ha az ősosztály felől törlünk egy objektumot, tehát heterogén kollekcióban van. Heterogén kollekciónak viszont csak akkor van értelme, ha van virtuális függvény az ősosztályban. Tehát ökölszabályként elmondhatjuk, a destruktort akkor és csak akkor kell virtuálissá tennünk az ősben, ha van legalább egy virtuális függvénye.
A virtuális függvények kapcsán jogosan merülhet fel a kérdés, hogyan derül ki futásidőben, melyik rajzol
-nak kell hívódnia. A tároló bejárásánál csak Alakzat*
-okat látunk, a szin
és pont
adattagokból nem lehet tudni.
Logikusnak tűnik tehát, hogy az Alakzat
-ban kell lennie valami futásidőben rendelkezésre álló információnak, ahonnan a fordító elő tudja varázsolni a megfelelő implementációt, ha szükséges. Első gondolatunk az lehet, hogy függvénypointereket tesz az objektumok belsejébe, ez azonban nem lenne szerencsés:
- Minden
Teglalap
példányban ugyanazokat a függvénypointereket kellene tárolni. - Ahány virtuális függvényünk van, annyi függvénypointer kellene minden példányba – jelen esetben 3 –, annyival nőne az objektumok mérete, pedig az előző pont miatt ez felesleges.
Ezért osztályonként van egy darab "függvénypointer"-tábla (vtable), ami tárolja egy típus összes virtuális függvényének implementációját. Egy az összes Alakzat
-nak, egy a Teglalap
-oknak, egy a Kor
-öknek, stb. Az egyes példányok már erre a vtable-re mutató pointert tartalmaznak (vptr), tehát csak egyetlen pointernyivel nő a méretük. Mindezt természetesen elrejti előlünk a fordító, az ábrán szürkével jelöltük a rejtett részeket.
A fejezet elején szereplő alakzat_feldolgoz
függvény paraméterként kap egy Alakzat const&
-et, és azon meghív két virtuális függvényt. A paraméterként kapott alakzat típusa nem ismert fordítási időben, így a virtuális függvények hívásánál a vtable-höz kell fordulni. A terulet
hívása például az alábbi módon történik:
- Az alakzat első – rejtett – adattagját meg kell keresni. Ez a vptr, ami az objektum pontos típusának vtable-ére mutat.
- A vptr-t dereferálni kell, az általa mutatott területen vannak a virtuális függvények függvénypointerei, mintha egy tömbben lennének.
- A vtable-ből ki kell venni a meghívandó függvény pointerét. Ez a
terulet
függvény esetén mondjuk az 1. indexű függvénypointert jelenti. - Az így kapott függvénypointert meg kell hívni, ahogy C-ben is történt.
Az Alakzat
és a Teglalap
vtable-jében tehát ugyanolyan sorrendben kell következniük a függvénypointereknek. Mi a helyzet akkor a tisztán virtuális függvényekkel? Azoknak nincs implementációja, de akkor minek a címét lehet tenni a vtable-be?
A függvényre mutató pointer – mint minden pointer – ugyanúgy lehet NULL
, mint bármelyik másik pointer. A tisztán virtuális függvényeknél ezért ilyenkor null pointer kerül a vtable-be, erre utal az =0
jelölésük.
Ez a leírás sem pontos technikailag, sőt, nem is lehet az. A C++ szabvány ugyanis azt sem köti meg, hogy a fordítók vtable segítségével implementálják a virtuális függvényhívás kihívásait.
Az öröklés, más néven leszármazás, az OOP egyik legfontosabb eszköze. A két osztály közötti, ilyen jellegű kapcsolat "hétköznapi" szóval jelölése néha félrevezető. Lássunk néhány példát, félreértést ezzel kapcsolatban!
Korlátozó "öröklés"
Ha a :public
helyén :private
vagy :protected
áll, az szemantikailag teljesen mást jelent, semmi köze az OOP értelemben vett örökléshez, csak a C++ jelölésük hasonlít kísértetiesen. Ezt hívják "korlátozó öröklésnek" is, de annyira semmi köze az eddig bemutatott örökléshez, hogy külön fejezetben szántunk neki helyet.
Kör és ellipszis viszonya
Az objektumorientált tervezés egyik sarkalatos kérdése: mi a kapcsolat a kör és az ellipszis között?
Az egyik lehetséges megközelítés programozói szemszögből próbálja megfogni. A körnek van a
átmérője, az ellipszisnek a
nagytengelye és b
kistengelye, így az ellipszis kicsit "több" a körnél, tehát az ellipszis a kör leszármazottja.
Ez a megközelítés teljesen hibás. Eszerint minden ellipszis igazából egy kör, pedig az ellipszisre nem igaz minden, ami a körre, például a get_atmero
függvény nincs értelmezve rajta.
Matematikus fejjel mondhatnánk, hogy minden kör igazából egy ellipszis, tehát az ellipszis leszármazottja a kör. Ezzel a megközelítéssel egészen addig nincs baj, amíg nem akarunk az ellipszisen olyan műveletet végezni, ami körön nem értelmezhető, mondjuk X tengely szerint kétszeresére nyújtani. Ezért általában úgy tekintjük, hogy a Kor
és az Ellipszis
között nincs közvetlen kapcsolat, mindkettő egyszerűen az Alakzat
-ból származik.
Ha kikötnénk, hogy az alakzatjaink tulajdonságai és állapota egyáltalán ne változhasson (azaz legyen immutable), nem lenne probléma a matematikus megközelítéssel. Az immutable objektumok sok más problémát is megoldanak, a koncepció a C++-tól azonban életidegen.
Biológia
Az OOP öröklés fogalma nem azonos a biológia öröklés fogalmával. Ha a gyerek a szülei minden tulajdonságát OOP értelemben véve örökölné, akkor a gyerek képzett óvónő és vasutas lenne. Meg tudná varrni a zoknit, ki tudná cserélni az autó ablaktörlőlapátjait, és mindezt a születése pillanatától fogva azonnal. Ezért ha programozásban öröklésről van szó, tudnunk kell elvonatkoztatni a szó eme jelentéstől, mert teljesen más jellegű a kapcsolat.
A téglalapnak kell legyen területe, mert minden alakzatnak van területe. De a gyereknek nem kell mozdonyvezetőnek lennie (nincs gyerek.mozdonyt_vezet()
), csak azért, mert van apa.mozdonyt_vezet()
.
A kör-ellipszis-probléma egy kézzelfoghatóbb változata a strucc és a madár esete. A strucc madár, vagy nem madár? Attól függ, hogy a strucc tud-e mindent, amit egy madárnak tudnia kell. Ha a modellünkben a madárnak tudnia kell repülni, azaz van Madar::repul
függvény, akkor a strucc nem lehet a madár leszármazottja!
C cast
C-ben a típuskonverzióra egyetlen eszközünk van, a cast. Ezzel tulajdonképpen bármilyen típusról bármilyenre cast-olhatunk, és ez C++-ban is így van. Két nagy veszélye van: egyrészt nem látszik rajta messziről, hogy helyes-e, és mit akarunk vele megoldani, másrészt nem is kereshető. Ez a kettő együtt azt okozza, hogy egy hibás cast-ot nem lehet megtalálni a kódban.
Ezért C++-ban külön operátorok vannak a különböző célú cast-okra, így mindegyiken messziről látszik, miért cast-olunk, és lehet-e belőle baj. A C cast-ok használata pedig erősen ellenjavallott.
C++ függvényszerű cast
Ezt már láttuk a Tort
osztály környékén.
std::cout << Tort(3); // 3/1
Elsődlegesen konverziós operátor vagy konstruktor hívásra használjuk, de bármire használható, amire a C-s cast.
Szintaktikai megkötés, hogy a céltípus neve csak egyszavas lehet plusz névterek, azaz nem lehet benne *
vagy &
.
static_cast
A static_cast
segítségével egymással „többé-kevésbé kompatibilis” típusok között konvertálunk. Ez olyan konverziókra való, amelyekről mi tudjuk, hogy jogosak, de amelyeket a fordító magától nem végezne el. Például:
- Nem karaktert, hanem karakterkódot szeretnénk kiírni (overload kiválasztása):
std::cout << static_cast<unsigned int>('A');
- Egész osztás elkerülése:
int a = 2, b = 3;
std::cout << static_cast<double>(a) / static_cast<double>(b);
- Konverziós operátor vagy konstruktor hívása
std::cout << static_cast<Tort>(3); // 3/1
void*
-ról cast-olásnál
int *p = static_cast<int*>(malloc(sizeof(int)));
- Upcast osztályhierarchiában, ha tudjuk, hogy a cast helyes (bár erre inkább a
dynamic_cast
való):
Alakzat* a = new Teglalap(3, 4);
Teglalap* t = static_cast<Teglalap*>(a);
dynamic_cast
A dynamic_cast
segítségével osztályhierarchiákban ugrálhatunk. Ez a cast, ahogy a neve is mutatja, futási idejű ellenőrzést is végez, és hibajelzést ad, ha a cast helytelen. Ehhez a futási idejű ellenőrzéshez virtuális függvénytáblára van szüksége, tehát ez csak polimorf osztályokon, azaz legalább egy virtuális függvénnyel rendelkező osztályokon működik. (De ha más nem, egy virtuális destruktor úgyis mindig van.) Ha pointert castolunk, helytelen típus esetén NULL
pointert ad:
void valami(Alakzat *a) { // kapunk egy ismeretlen típusú alakzatot
Teglalap* t = dynamic_cast<Teglalap*>(a);
if (t == NULL) {
std::cout << "Nem téglalap";
} else {
std::cout << "Téglalap, " <<
<< t->szelesseg() << "x" << t->magassag();
}
}
Referenciák esetén pedig std::bad_cast
típusú kivételt dob:
void valami(Alakzat &a) {
try {
Teglalap& t = dynamic_cast<Teglalap&>(a);
std::cout << "Téglalap, " <<
<< t.szelesseg() << "x" << t.magassag();
} catch(std::bad_cast) {
std::cout << "Nem téglalap";
}
}
Osztályhierarchiák esetén jobb elkerülni a cast-ok használatát. A túl sok (nullánál több? :D) a dynamic_cast
egy code smell, ami helytelen tervezésre utal; valószínűleg az ősosztályokból hiányoznak virtuális függvények.
Többszörös, virtuális öröklés (lásd lejjebb) esetén mindenképp dynamic_cast
-ot kell használni. Ott ugyanis az objektumrészletek az egész objektumhoz képest nem mindig ugyanazon az offset-en kezdődnek; a pointerek közti eltolást a virtuális táblákból lehet kiolvasni.
const_cast
A const_cast
célja, hogy a const
minősítőt le tudjuk varázsolni egy pointer vagy egy referencia által mutatott objektumról. Tehát ezt pl. T const * → T *
konverzióra használhatjuk, egyéb fajta konverziók fordítási hibához vezetnek.
Ha az eredeti objektum, amit cast-oltunk vele, konstans, akkor futási idejű hibához vezethet (undefined behavior).
Ez az egyetlen egy olyan cast, amelyik a const
-ot le tudja venni.
reinterpret_cast
reinterpret_cast
-ot használunk minden egyéb esetben, ami nem fér a fenti kategóriák valamelyikébe, vagy nem rakható össze azokból; olyan típusok között, amelyeknek semmi közük egymáshoz. Ezek tipikusan a „pointermágiát” használó kódrészletek, pl. tetszőleges típusú pointer unsigned char*
-gá való konvertálása a memória bájtonkénti elérése céljából. Általában elmondható, hogy a reinterpret_cast
nem hordozható kódhoz vezet.
Egy int
bájtjainak kiírása:
int x = 0x11223344;
unsigned char* p = reinterpret_cast<unsigned char*>(&x);
for (size_t i = 0; i != sizeof(x); ++i)
printf("%02x ", p[i]);
Ez nem hordozható kód, a futási eredménye architektúrától függ (int
mérete, ábrázolási módja, endianness stb.).
C cast pontosítva
Ezek ismeretében jobban látszik, hogy miért veszélyes a C cast. Egy C-cast ugyanis ezek bármelyike lehet, konkrétan sorrendben az első, amelyik lefordul:
static_cast
static_cast
ésconst_cast
kombinációjareinterpret_cast
ésconst_cast
kombinációja
Ezek közül csak az első biztonságos, és egy C cast-on messziről nem látszik, hogy tulajdonképpen melyik történik.
A C++ ritka állatfaj az OO-t támogató nyelvek között amiatt, hogy támogatja a többszörös öröklést. A legtöbb nyelvben nem lehet két osztályból leszármazni, mert implementációs nehézségekkel jár; elsősorban a fordító oldaláról, de néha a programozó számára is.
Feladat: nézzük meg a Neptun néhány elképzelt osztályát! Legyen Hallgato
, Oktato
és Demonstrator
!
Alapeset
A probléma lényege, hogy a demonstrátor egyszerre hallgató és oktató. Hallgat órát, és kaphat ösztöndíjat, de tarthat is órát, és kap fizetést (elvileg).
Ez OO modellezési szempontból azt jelenti, hogy a Demonstrator
leszármazottja a Hallgato
-nak és az Oktato
-nak is.
class Hallgato { /* ... */ };
class Oktato { /* ... */ };
class Demonstrator : public Hallgato, public Oktato {
/* ... */
};
Ezzel semmi baj nincsen, minden ugyanúgy működik, ahogy eddig. A Demonstrator
memóriaképe ennek megfelelően két ősosztályt tartalmaz egymás alatt, utána jönnek a saját adattagjai:
A kompatibilitás és a cast-ok ilyenkor ugyanúgy működnek, ahogy eddig, csak a pointerkonverzió során ténylegesen megváltozhat a memóriacím is. Az ábrán az Oktato*
típusú pointer máshova mutat, mint a Hallgato*
vagy a Demonstrator*
, hiszen így kompatibilisek. Ezt a trükköt is elrejti előlünk a fordító.
Gyémánt-öröklés
A problémák akkor kezdődnek, ha rájövünk, hogy a hallgatónak és az oktatónak vannak közös tulajdonságai és viselkedése:
- Mindkettőnek van neve, neptunkódja, számlaszáma.
- Mindkettőnek lehet pénzt utalni, oktatónak a fizetést, hallgatónak pedig az ösztöndíjat.
Ebből az következik, hogy van egy közös ősosztályuk, az Ember
:
class Ember {
protected:
std::string nev;
std::string neptun;
std::string szamlaszam;
public:
void utal(int osszeg);
};
class Hallgato : public Ember { /* ... */ };
class Oktato : public Ember { /* ... */ };
class Demonstrator : public Hallgato, public Oktato {
/* ... */
};
A fenti ábrán bemutatott memóriakép ekkor így módosul:
Azért hívják gyémántnak (diamond), mert az UML osztálydiagramon – ami a jegyzetben csak később fog szerepelni – rombusz alakzatban helyezkednek el.
Ezen már látszik, mi a baj: a demonstrátorban kétszer is megjelenik az Ember
, egyszer a hallgatón, egyszer pedig az oktatón keresztül. Ez baj, hiszen ha a Demonstrator
osztályból el akarjuk érni mondjuk a számlaszámát, akkor nem egyértelmű, melyik számlaszám adattagról van szó. Ilyenkor a Hallgato
ősosztályon hívott tagfüggvények az egyik Ember objektum adattagjait használják, módosítják, az Oktato
ősosztályon hívottak pedig a másikat, így inkonzisztencia is kialakulhat.
Ha ilyen probléma merül fel, az az esetek többségében tervezési hiba, és a probléma mélyebben gyökerezik, ez csak tünet. Ettől most tekintsünk el, és nézzük meg, hogyan lehet megoldani a problémát komolyabb újratervezés nélkül.
Erre nyilvánvalóan az lenne a megoldás, hogy egyetlen közös Ember
objektum legyen egy Demonstrator
objektumban. Ilyenkor viszont a Hallgato
vagy az Oktato
osztálynak is tudnia kell, hogy hozzájuk képest hol van az Ember
objektum, hiszen a számukra eddig megszokott helyen nem lehetnek.
Az erre bevezetett C++ nyelvi elem a virtual
öröklés. Ilyenkor – mint a virtual
szó minden egyes jelentése esetében – pointeren keresztül érünk el valamit. Jelen esetben az ősosztály adattagjait a Hallgato és az Oktato osztályoknak kell egy pointeren keresztül elérniük, hiszen az lehet akárhol.
Ez kódban pedig így jelenik meg:
class Ember {
protected:
std::string nev;
std::string neptun;
std::string szamlaszam;
public:
void utal(int osszeg);
};
class Hallgato : public virtual Ember { /* ... */ };
class Oktato : public virtual Ember { /* ... */ };
class Demonstrator : public Hallgato, public Oktato {
/* ... */
};
Az ilyen öröklésnek az a nehzésége programozói oldalról, hogy a virtuális ősosztály konstruktorát mindig a legalsó leszármazottnak kell meghívnia, itt a Demonstrator
-nak. Logikus, hiszen a Hallgato
és az Oktato
magától azt sem tudja, hozzájuk képest hol lesz az ősosztályuk az objektumon belül, tehát konstruálni sem tudják maguknak.
Ha önálló Hallgato
vagy Oktato
példányokat hozunk létre, ugyanúgy működik minden, mint az alapesetben, csak az ősosztályt továbbra is egy pointeren keresztül érik el.
Az igazi, mélyen gyökerező probléma ebben a példában az, hogy összekevertük a típus és a szerep fogalmakat. Igazából itt egyetlen típusunk van, az Ember, és három szerepünk: Hallgato, Oktato, Demonstrator. Ezt a három szerepet azért nem lehet az emberek típusába foglalni, mert ugyanaz az ember bármelyik szerepet felveheti vagy leadhatja, márpedig egy objektum nem válthat típust.