Öröklés

Czirkos Zoltán · 2019.02.27.

Az öröklés és a vele kapcsolatos tudnivalók a C++-ban.

Ez az írás jelentős átfedéseket tartalmaz a jegyzet 7. fejezetével. Ha úgy érzed, maradtak olyan pontok, amiket nem sikerült tökéletesen megértened, érdemes lehet ennek ellenére elolvasnod.

1. Téglalapok és körök: alakzatok

„Csináljunk egy osztályhierarchiát, amelyik téglalapok és körök adatait képes tárolni. Ezekből az alakzatokból egy ábrát készítünk a képernyőn. Elvárásaink, hogy képesek legyenek elmozdulni vízszintes és függőleges komponensével adott vektorral. Képesek legyenek kirajzolni magukat, és megmondani a területüket. A téglalapokat a bal felső és jobb alsó sarkaikkal adjuk meg, a köröket pedig a középpontjukkal és a sugarukkal. Legyen lehetőség arra, hogy ilyen téglalapokat és köröket vegyesen tároljunk egy tömbben.”

A fenti feladatkiírásban szerepel három kulcsszó, amelyek az egyes leírt objektumok közötti kapcsolatot fejezi ki. Ezek a téglalap, a kör és persze az alakzat. Közöttük a hierarchia jelen esetben elég nyilvánvaló; a téglalap is egy alakzat, a kör is egy alakzat. Minden alakzatnak mondhatjuk azt, hogy mozduljon el (9,3) vektorral a képernyőn. Ezzel szemben viszont szélessége csak egy téglalapnak van, a körnek nincs; ahogy sugara is csak a körnek van, a téglalapnak nincs.

Láthatjuk azt, ha külön, függetlenül adjuk meg a téglalap és a kör osztályt, akkor elég sok programrészt meg kell majd ismételnünk; és mivel a téglalap és a kör a C++ számára különböző dolog, külön problémát jelent, hogyan lehetne őket vegyesen tárolni egy tömbben. Kapcsolatba kell hoznunk őket. Az osztályok közötti ilyen jellegű kapcsolatot örökléssel (inheritance) fejezzük ki: minden téglalap egyben alakzat is, és minden kör egyben alakzat is. Az alakzat osztály leszármazottja (subclass, derived class) a kör osztály, és ugyancsak leszármazottja a téglalap osztály. Másképpen: a téglalapnak alaposztálya vagy ősosztálya az alakzat (base class vagy parent class). Első körben annyit nyerünk ezzel, hogy egy adag gépelést megspórolunk; a téglalapokra és körökre nézve közös tulajdonságokat ugyanis már az alakzatokra nézve általánosan megfogalmazhatjuk (code reuse). A nagyobb nyereség a polimorfizmus, vagyis hogy a téglalapok illetve a körök tudnak alakzatként is viselkedni, ha egy olyan műveletet kell végezni, amely értelmes mindkét fajta síkidomra. Ha egy függvény egy alakzatot vár a paraméterként, akkor adhatunk neki egy téglalapot vagy egy kört is, mert a C++ számára is kifejeztük a közöttük lévő kapcsolatot.

Vegyük sorra a tulajdonságaikat:

  • Téglalap: (x1,y1), (x2,y2) sarkok, szín
    • rajzol: 4 db szakasz
    • mozgatás: eltűnés; (x1,y1), (x2,y2) – mindkettőhöz +(xd,yd); megjelenés
    • terület: (x2-x1)*(y2-y1)
  • Kör: (xk,yk), sugár, szín
    • rajzol: kör
    • mozgatás: eltűnés; (xk,yk) középpontokhoz +(xd,yd); megjelenés
    • terület: r2*pi

Ez már kezdi mutatni az öröklődést. Minden, ami téglalap, az alakzat is; van pozíciója és mozgatás függvénye is, ugyanis azokat örökli az ősosztályából, az alakzat osztályból. Írjuk meg, amit eddig tudunk.

class Alakzat {
  protected: // 4
    int x, y;
    int szin;
  public:
    Alakzat(int xp, int yp); // 6
};

/* a Téglalap öröklődik az Alakzatból: */
class Teglalap: public Alakzat { // 2
    int sz, m;
  public:
    Teglalap(int x1, int y1, int x2, int y2);
    int terulet() const {
        return sz * m;
    }
};

/* a Kör öröklődik az Alakzatból: */
class Kor: public Alakzat { // 3
    int r;
  public:
    Kor(int xk, int yk, int sug);
    double terulet() const {
        return r * r * 3.14;
    }
};

Alakzat::Alakzat(int xp, int yp) { // 1
    x = xp;
    y = yp;
    szin = 1;
}

Teglalap::Teglalap(int x1, int y1, int x2, int y2)
    : Alakzat(x1, y1) { // 5
    sz = x2 - x1;
    m = y2 - y1;
}

Kor::Kor(int xk, int yk, int sug)
    : Alakzat(xk, yk) {
    r = sug;
}

Az alakzat osztályhoz lett egy egyszerű konstruktor, amelyik az alakzat pozícióját állítja be a képernyőn (1). A téglalap osztályt örökléssel deklaráltam, vagyis kifejeztem a kódban, hogy a téglalap az egyfajta alakzat (2), ugyanez igaz a körre is (3). Publikus öröklést használok, ami azt jelenti, hogy a téglalap nyilvánosan vállalja, hogy alakzat; vagyis például egy alakzatot váró függvénynek át lehet majd adni. A protected kulcsszó (4) azt jelenti, hogy a tagváltozók kívülről nem elérhetőek, de a leszármazott osztályok tagfüggvényei láthatják. Privát esetén egy téglalap tagfüggvény nem látná például az x változót.

Létezik privát öröklés is, de arra nagyjából semmi nem érvényes ebből az írásból, és teljesen mást jelent. Ott nem feltétlenül teljesül a minden-leszármazott-ős-is (minden bogár rovar) kijelentés sem. Általában ha öröklésről beszélnek, publikus öröklést értenek alatta.

Külön magyarázatot érdemel az (5) jelű inicializáló lista. Minden, ami téglalap, az egyben alakzat is. Ha létrehozunk egy téglalapot (az adott sor a téglalap konstruktor része), akkor létrejön egy alakzat is. Az alakzat pedig hogy jön létre? A konstruktorával. Hogy a fordító tudja, hogy az alakzat konstruktornak (amelyből egyetlen egy van, a két int paraméterű, lásd (6)) milyen paramétereket adjon, az inicializáló listán feltüntetjük, hogy hogyan kell létrejönnie a téglalap objektum „alakzat darabjának”. Ha nem adunk meg ilyet, akkor az alakzatot a default, paraméter nélküli konstruktorával próbálná létrehozni, olyan pedig nincs, vagyis jelen esetben kötelező használni ezt. Ami egyébként józan ésszel is látható, mert különben honnan tudná a gép, hogy a négy paraméter közül melyik lesz az alakzat pozíciója? Ha az alakzatnak többféle konstruktora van, akkor pedig így választhatjuk ki, hogy melyikkel jöjjön létre. Azt mondjuk, hogy a leszármazott osztály (téglalap) meghívja az alaposztályának (alakzat) a konstruktorát.

A feladat szerint minden alakzat ki kell tudja számolni a területét; meg is írtuk a körre és a téglalapra ezt, de az alakzatoknál erre nézve semmilyen említést nem tettünk. Ez bajos, mert egy alakzatokat tároló tömbnek nem tudjuk majd minden elemére azt mondani, hogy a területét kérjük; ehhez a fordító számára már az alaposztályban említést kellett volna tennünk a terület függvényről. A terület kiszámítása jelen formában a téglalapoknál és a köröknél mintha két teljesen különálló, egymástól független függvény lenne, a gép számára úgy tűnik, mintha csak véletlenül neveztük volna el őket ugyanúgy. Az alakzatnál viszont semmit nem tudunk írni a terület kiszámításáról, mert az az egyes alakzat fajták esetén más lesz. A két kulcs gondolat a „fajták esetén más” és a „semmit nem tudunk írni”. A következő dolgot írjuk ezért a kódban:

class Alakzat {
    virtual double terulet() const = 0; // 1
};

class Teglalap: public Alakzat {
    double terulet() const {
        return sz * m;
    }
};

class Kor: public Alakzat {
    double terulet() const {
        return r * r * 3.14;
    }
};

Hogy a közös nevezőt megtaláljuk, a téglalap terület függvénye is double lett. Fontosabb viszont az alakzat osztályban a függvény virtuális (1) megadása. A virtuális fejezi azt ki, hogy az egyes síkidomok, téglalap és kör, tudni fogják magukról, nekik hogyan kell számolniuk a területüket. Ha egy függvénynek átadunk egy alakzatot (pontosabban egy alakzat referenciát, mert nem akarjuk lemásolni egy téglalapnak csak az alakzatokra általában érvényes adatait, hanem nekünk az egész téglalapra szükségünk van), akkor a függvény abból csak azt látja, hogy egy alakzattal van dolga, nem tudja, hogy kör vagy téglalap. Az alakzatnak önmagáról kell tudnia, hogy egy téglalap vagy egy kör, és a területe hogyan számolódik.

Általában ha deklarálunk egy függvényt, akkor meg is kell írni azt, az alakzat területéről viszont semmit nem tudunk mondani. Ezért a sor végére a C-sen szűkszavú „=0”-t biggyesztjük, jelezve ezzel a fordítónak, hogy ezt a függvényt nem fogjuk megvalósítani, ne is keresse. Nem is tudjuk, mert nincsen értelme. Az ilyen függvény neve tisztán virtuális függvény (pure virtual function). Ettől az alakzat osztály egy absztrakt alaposztállyá vált (abstract base class), ami a hétköznapi logika szempontjából is stimmel. Az alakzat egy elvont fogalom; nem kérdezhetem meg, mennyi egy alakzat területe, ha nem mondom meg, hogy milyen alakzatról van szó. Minden alakzatra lesz területképlet, ezért már az alaposztályban említést teszünk róla. Nincs olyan területképlet, amely az összes alakzatra működne, megírni itt még nem lehet. Csak később, a származtatott osztályoknak lesz ilyenjük. A téglalap alakú alakzatokra már van képlet, és a kör alakúakra is van. Mivel elvont osztály lett belőle, a gép innentől kezdve nem is enged majd minket példányt létrehozni belőle (pl. Alakzat a;), mivel nem tudna terület függvényt kapcsolni hozzá, mi viszont azt ígértük, hogy minden alakzatnak van terület függvénye.

Az alakzat mozgatása a terület számításával szemben az összes alakzatnál ugyanúgy működik. Letörlöm a képernyőről az alakzatot, átállítom a koordinátáit, és újra megjelenítem:

void Alakzat::mozgat(int xd, int yd) {
    rajzol(0);     /* kirajzolom feketével, vagyis háttérszínnel */
    x += xd;
    y += yd;
    rajzol(szin);  /* kirajzolom a rajz színével. */
}

Ha meg tudnánk mondani, hogyan kell letörölni a képernyőről egy alakzatot, és hogyan kell újra kirajzolni azt, akkor mozgatni is tudnánk. Az alakzat osztályban viszont nem mondhatjuk azt, hogy rajzoljuk ki feketével, meg rajzoljuk ki a saját színével… Mert azt sem tudjuk, hogy néz ki. Ez az előző problémától alig különbözik, a megoldása ugyanaz; egy tisztán virtuális függvény. Az alakzat osztályban deklaráljuk, hogy lesz egy ilyen, a leszármazott osztályok pedig majd meg is valósítják azt. Úgy mozgatjuk az alakzatot (és az összes alakzatot úgy mozgatjuk!), hogy kirajzoljuk háttérszínnel, utána megváltoztatjuk a pozícióját, aztán kirajzoljuk a szokásos színnel.

class Alakzat {
    void mozgat(int dx, int dy);
    virtual void rajzol(int szin) = 0;
};

class Teglalap: public Alakzat {
    virtual void rajzol(int szin); // 1
};

class Kor: public Alakzat {
    virtual void rajzol(int szin); // 2
};

void Teglalap::rajzol(int szin) {
    kepernyo.vonal(x, y, x + sz, y, szin);
    kepernyo.vonal(x, y, x, y + m, szin);
    …
}

void Kor::rajzol(int szin) {
    kepernyo.kor(x, y, r, szin);
}

Az alakzat osztály mozgat függvénye nem virtuális. Azt nem definiáljuk át a leszármazott osztályokban, az összes alakzatra ugyanúgy működik. Érdemes megfigyelni, hogy a tisztán virtuális függvény segítségével az alakzat előre tudott gondolkodni; hivatkozhatott egy olyan függvényre, amelyet nem ismer, hanem majd csak a leszármazottai valósítják meg. Az egyes leszármazott osztályokban a rajzol() függvény deklarációját újra szerepeltetni kell (1 és 2), ezzel jelezve a fordítónak, hogy az adott osztály megírja (vagy újradefiniálja) a megadott függvényt.

Ha nem virtuális egy felüldefiniált függvény, az olyan, mintha „véletlenül” lenne a leszármazott osztálynak egy ugyanolyan nevű függvénye, de annak olyankor semmi köze az alaposztálybeli függvényhez! A polimorfizmust (hogy alakzatot váró függvénynek téglalapot adhatunk) olyankor nem tudjuk kihasználni. Ezért általában az osztályokban lévő, ugyanolyan szerepű, de eltérő működésű függvények, mint pl. a rajzolás és a terület számítása, virtuálisak. Vannak objektum orientált nyelvek, amelyekben csak virtuális függvények vannak.

2. Az új osztályok használata, polimorfizmus

Kezdjünk valamit ezekkel az alakzatokkal, gyűjtsük őket össze egy tömbben. A tömb (vagy bármilyen más tároló) neve ilyenkor heterogén kollekció, ugyanis eltérő típusú (heterogén) objektumokat gyűjt (kollekció) össze. Itt is kell egy közös nevezőt találnunk, mert egy tömb csak teljesen egyforma típusú dolgokat tárolhat. Az biztos, hogy ez nem a téglalap és nem a kör, csak az alakzat lehet. Az alakzatokat magukat viszont nem tárolhatjuk a tömbben, mert akkor elveszítenénk a körökre és téglalapokra nézve specifikus adatokat. A megoldás az alakzatokra mutató pointer, az minden lehetséges típusra nézve közös.

/* 20 elemű, alakzat pointereket tartalmazó tömb */
Alakzat *rajztabla[20];

rajztabla[0] = new Teglalap(10, 20, 50, 70); // 1
rajztabla[1] = new Kor(15, 30, 17);
rajztabla[2] = …

double osszterulet = 0;
for (int i = 0; i < 20; ++i)
    osszterulet += rajztabla[i]->terulet(); // 4
for (int i = 0; i < 20; ++i)
    rajztabla[i]->mozgat(3, 4); // 3

for (int i = 0; i < 20; ++i)
    delete rajztabla[i]; // 2

Dinamikusan foglaljuk az objektumokat (1), úgyhogy a delete operátorral kell felszabadítani őket (2).

A (4)-as sorban összeadjuk az összes alakzat területét. Itt fontos megfigyelni, hogy a rajztábla típusa alakzatokra mutató pointereket tároló tömb; ennek egy eleme alakzatra mutató pointer. A pointer miatt egyrészt a nyíl operátorral kell hívni a függvényeket, de ami fontosabb, hogy mindenhol látszólag az alakzat osztály terület függvényét hívjuk: Alakzat::terulet(). Ha nem lenne virtuális a terület számítása, akkor a fordító bele akarná drótozni fixen ugyanazt a képletet minden alakzatra a lefordított programba. Ez viszont nem jó, mert minden alakzatnak más a formája; az alakzatnak magának kell tudnia, hogy mi módon számítódik a területe. Fordítás közben nem lehet eldönteni. Arról nem is beszélve, az egyes alakzatok például menüből lehettek kiválasztva a program futása közben, és akkor végképp nem állnak rendelkezésre a típusaik a fordítás alatt.

A mozgat függvényt bele lehet drótozni, az mindig ugyanúgy működik; csak egy Alakzat::mozgat() létezik. Ezért annak nem kellett virtuálisnak lennie, már a fordításkor látszik, hogy melyik függvényről van szó. Alakzat objektumra mutató pointerre hívjuk meg a mozgat függvényt, ami rendben is van, mert nekünk az Alakzat::mozgat() kell. A területnél nem tudtuk, hogy a Teglalap::terulet() vagy a Kor::terulet() fog kelleni; itt tudjuk. A mozgat() ugyan hív virtuális függvényt, de ez már nem a for() ciklus vagy az alakzat pointer dolga.

Összefoglalva, két esetben van szükség virtuális függvényre:

  • Ha egy leszármaztatott osztály függvényét szeretnénk meghívni az alaposztály pointerén vagy referenciáján keresztül. Ilyen a területszámítás a heterogén kollekcióban (4).
  • Ha az ősosztály valamelyik metódusában egy másik, csak később megvalósított függvényről beszél. Ilyen a rajzolás az Alakzat::mozgat() függvényben.

Ez a két dolog persze igazából nem különbözik: az Alakzat::mozgat() függvényben az alaposztály pointerén keresztül érjük el a leszármazott osztály rajzol() függvényét. Csak ott a pointer történetesen a this.

3. Új típus létrehozása: a sokszög

Származtassunk egy új fajta alakzatot, legyen ez a sokszög. Az alakzat osztály minden alakzatnak tárolja a pozícióját a képernyőn; ezért a sokszög osztály belső reprezentációjának válasszuk azt, hogy minden pontját ehhez a ponthoz képest viszonyítunk, vagyis ehhez képest eltolásokat tárolunk. Ez lehetővé teszi azt, hogy a mozgat() függvény megmaradjon, és helyesen működjön a sokszögre is.

class Sokszog: public Alakzat {
    int csucsok;
    int *xe;
    int *ye;
  public:
    Sokszog(… valami paraméterek);
    ~Sokszog();
    double terulet() const {
        return … hadd ne :) … ;
    }
    void rajzol(int szin);
};

Sokszog::Sokszog(… valami paraméterek) {
    …
    xe = new int[csucsok];
    ye = new int[csucsok];
}

Sokszog::~Sokszog() {
    delete[] xe;
    delete[] ye;
}

Hoppá, ennek lett destruktora is! A dinamikus adat miatt muszáj neki. (Meg persze másoló konstruktor és operator= is kellene, hiszen tudjuk, hogy ha a három közül valamelyik kell, akkor általában mindegyik.) És ebből baj is lesz, ha azt írjuk, hogy

rajztabla[4] = new Sokszog(… valami paraméterek);
…
delete rajztabla[4];

mert rajztábla az alakzatra mutató pointereket tartalmaz, vagyis a delete rajztabla[4] az Alakzat::~Alakzat() destruktort hívja. A megoldás: virtuális destruktor. A sokszög tudni fogja magáról, hogy neki fel kell szabadítania a tömböket. A körnek és a téglalapnak nincs semmi teendője. Hogy egy függvény virtuális, azt viszont már az alaposztályban jeleznünk kell, vagyis:

class Alakzat {
    …
    virtual ~Alakzat() {}
}

Az alakzat destruktora üres, mert nincsen dolga. Nem tisztán virtuális függvény, meg van valósítva (üres kapcsos zárójelek), csak épp nem csinál semmit. Ezt az üres destruktort örökli a téglalap és a kör is, a sokszög viszont hozzátesz új funkciókat ehhez (jelen esetben a semmihez), felszabadítja a dinamikus tömbjeit. Fontos, hogy erre már az alakzat megírásakor gondolni kell; általában véve jó ötletnek számít, ha egy alaposztálynak virtuális destruktort csinálunk. Sőt ha egy osztálynak van virtuális függvénye, akkor szinte egészen biztos, hogy a destruktora is virtuális kell legyen.

Az öröklési viszonyokat ábrázoló rajz jelölésben hasonlít az UML szabványhoz. A három részre osztott téglalapok fejléce az osztály nevét mutatja; középső része a tagváltozókat, alsó része pedig a metódusokat. Fontos, hogy az öröklést ábrázoló nyíl az ősosztály felé mutat, nem pedig a leszármazott osztály felé! Az ábrát egyébként az ingyenes Doxygen program csinálta, a fenti forráskód részletekből teljesen automatikusan.

4. Mi az, ami öröklés, és mi az, ami nem

Öröklést akkor kell használni, amikor egy „minden micsoda micsoda” jellegű relációt szeretnénk kifejezni az objektumok között. Minden bogár rovar; egy rovar paramétert váró függvénynek adhatunk egy bogarat. Ha valaki arra kér, fogjunk egy rovart, akkor foghatunk egy cserebogarat. Fordítva persze nem igaz; egy bogár paramétert váró függvénynek nem fog engedni a fordító átadni egy rovart. Ezen kívül minden téglalap alakzat. Minden nyomtató számítógép tartozék stb.

Van néhány nem triviális eset. Ilyen például az autó és a motorja közötti kapcsolat. Beszélni szoktunk arról, hogy „beindítjuk az autót”, hogy felírjuk „az autó motorszámát”, hogy „hány lóerős az autó”. Kényelmesnek tűnhet ezért az autót a motorból származtatni, mondván, hogy akkor az autó a megfelelő tagváltozókat (motorszám, lóerő) és a megfelelő tagfüggvényeket (beindítás) örökölni fogja. Ez viszont általában így nem helyes. Az autó nem egyfajta motor. (A bicikli sem egyfajta biciklista!) Az autó tartalmaz egy motort. Ha a motorszámról beszélünk, akkor a motorjának a számáról beszélünk. Ha a teljesítményéről, akkor is a motorjáéról. Ha azt mondjuk, hogy beindítjuk az autót, akkor nem csak a motort indítjuk be, hanem egy rakat egyéb dolgot is csinálunk.

class Auto {
    char rendszam[10];
    Motor mot;      /* !!! */
    void indit() {
        elektronika_init();
        lampa_bekapcs();
        mot.indit();
    }
};

Másik nem triviális eset a következő. A kör például nem egyfajta ellipszis, illetve az ellipszis sem egyfajta kör. A kör és az ellipszis között általában nincsen (publikus) öröklési kapcsolat! Azt gondolná az ember, hogy a kör egyfajta ellipszis, azzal a speciális tulajdonsággal, hogy egyforma a két féltengelye. Viszont ha az ellipszis (ősosztály) képes aszimmetrikusan nyúlni: e.nyulik(2.0) hatására az ellipszis kétszer olyan széles lesz, mint magas; a kör meg az ellipszisből öröklődik, akkor örökli a nyúlik függvényt is: k.nyulik(2.0). Erre mi történjen?! Az ellipszis azt ígérte, hogy képes aszimmetrikusan nyúlni; a kör ezt nem tudja betartani. Akkor a kör nem egyfajta ellipszis.

Ez a másik irányba sem működik: kör alaposztály, ellipszis leszármazott. Ha a körnek van egy átmérő() függvénye, amelyik a sugár duplájával tér vissza, az ellipszis ezt örökli. Hiába próbáljuk felüldefiniálni, nem tudunk értelmes működést kitalálni hozzá. A kör azt ígérte, hogy képes megadni az átmérőjét, a leszármazott osztályoknak is kell tudniuk ilyet. Vagyis az ellipszis nem egyfajta kör, legalábbis objektum orientált szempontból nem. (Megj.: a fenti alakzatos példában lehetne egy ellipszis osztály, amelyből egy kör öröklődne. Azért, mert az ellipszis nem csinál semmi olyat, amit egy kör ne tudna. A kör konstruktor egyforma féltengelyeket adna az ellipszisnek. De onnantól kezdve, ha pl. nyújtó metódust írunk neki, már nem öröklődhet.) Ugyanez a helyzet a madarakkal és a struccokkal kapcsolatban is. Ha kijelentjük, hogy a madarak képesek repülni, akkor a strucc nem madár. Vagyis, ha a madár osztálynak van egy repül() függvénye, amelyik nem jelez hibát, nem dob kivételt, akkor a strucc osztály nem származhat a madár osztályból.