Öröklés – hogyan?

Czirkos Zoltán · 2019.02.27.

Hogyan használjuk az öröklést, és hogyan ne...

Az öröklés, altípusok létrehozása az OOP egyik legerősebb eszköze. Segítségével különféle osztályok közötti kompatibilitást tudunk kifejezni; azt, hogy egy bizonyos feladatot, viselkedést többen is megvalósíthatnak.

Meg tudjuk mutatni a fordítónak, hogy a téglalap, a kör és a háromszög is alakzat. Mert mindegyik kirajzolható és mindegyiknek kiszámítható a területe – ha ezt várjuk el az alakzatoktól. Közös interfészt definiálhatunk a programunk bővítményei számára, amelyek mindannyian képesek elemeket beszúrni a programunk menüjébe, de más feladatokat oldanak meg. Kifejezhetjük, hogy az emberi játékos és a gép is egy szereplője a játékunknak, amelyek mindketten a játékállás ismeretében meg tudják mondani, mi a következő lépésük. Az ember úgy, hogy a program megkérdezi a felhasználót, a gép pedig mondjuk úgy, hogy elemzi az állást, előnyöket-hátrányokat vizsgál, és azokhoz pontszámokat rendelve kiválasztja a legjobbnak tűnőt.

Ezt az eszközt elsőre nehéz használni, mert sokféleképp félreérthető. Az öröklést legkönnyebb közismert példákon keresztül megmutatni. Ezek általában matematikai vagy biológiai, rendszertani példák szoktak lenni. De miután ezek segítettek megérteni az alapelveket, el kell dobni őket, mert a hétköznapi tulajdonságok és a programunkban leírt tulajdonságok eltérőek lehetnek, és emiatt a hétköznapi és a programunkbeli modell is eltérő lehet. Ha így van, akkor viszont a hétköznapi példa csak félre fog vezetni. A matematika szerint a négyzet téglalap, az OOP szerint nem. A biológia szerint a strucc madár, az OOP szerint viszont nem.

Az örökléssel az új osztályunkban az ősosztály tulajdonságait, adattagjait és függvényeit is megörököljük, tehát a kódduplikáció elkerülésére is jó lehet. De ez sosem a cél ennél az eszköznél! Sőt ez is félrevezethet, hibás programtervet eredményezhet, ha ezt tekintjük célnak.

Ezekre a tévutakra mindjárt látunk néhány példát.

1. Mit jelent az öröklés? A Liskov-féle elv

Az öröklés tulajdonságait, használhatóságát Barbara Liskov írta le. Az általa definiált elv, amit azóta Liskov Substitution Principle (LSP) néven emlegetünk, egyetlen szabályból áll:

A Liskov-féle elv

X osztálynak akkor lehet leszármazottja Y, ha minden ami igaz volt X-re, az igaz Y-ra is.

Ennyi. Ha ezt lehet teljesíteni, akkor az öröklés használható, ha nem, akkor viszont rossz a modellünk.

Lássuk a szabály alkalmazását az alakzatok és a madarak esetére!

  • Az alakzatok kirajzolhatóak és a területük meghatározható. A téglalap egy alakzat (kirajzolható és területe kiszámítható), a kör is egy alakzat (ugyanígy).
  • A maradak tudnak repülni. A veréb egyfajta madár, az is tud repülni. A strucc nem madár, mert ha az lenne, akkor kellene tudnia repülni. De nem tud, tehát nem mondhatjuk madárnak.

Egy osztály definíciója azt mutatja meg, hogy az osztály objektumai mit tudnak csinálni, mit lehet velük csinálni. Öröklés esetén erre néha másképp kell gondolni: ilyenkor egy ősosztály ígéretét a leszármazottnak kell betartaniuk. Sőt lehet csak ők fogják tudni. Például az alakzatnál a terület kiszámítása tisztán virtuális tagfüggvény: előzetes ígéret a leszármazottak nevében.

class Alakzat {
    /* ... */
    virtual double get_terulet() const = 0;     // majd lesz
};

class Teglalap: public Alakzat {
    /* ... */
    virtual double get_terulet() const {
        return a*b;
    }
};

A madár ősosztályban a repülés a leszármazottakra is kell vonatkozzon. Ha megpróbáljuk ezt valahogy megkerülni, például egy kamu implementációval, azzal a problémát nem oldottuk meg, csak annyit érünk el vele, hogy a tervezési hibánk futási időben derül csak ki:

Ez így hibás
class Madar {
    virtual void repul() { /* ... */ }
};

class Strucc : public Madar {   // hibás
    virtual void repul() {
        throw std::runtime_error("Nem tudok repülni");
    }
};

Fontos itt az, hogy a típusoknak a forráskódunkban látható definíciója az érvényes, nem pedig az, amit máshol tanultunk. Általában pedig ez szokott félrevezetni. Például a madár definíciója nem az, amit biológiából ismerünk. Hanem az, amit a kódunkban a class Madar-hoz írtunk; itt mi mondjuk meg, hogy mit jelent a madár. Ha úgy definiáljuk, hogy tud repülni (a madaraknak van void repul() tagfüggvénye), akkor onnantól kezdve a strucc nem lehet madár, mert az ellentmondás. Vagy a madár, vagy a strucc programbeli definícióját módosítanunk kell.

2. A téglalap nem származhat a négyzetből

Ahogyan a strucc sem madár az OOP-ben, úgy a téglalap sem származhat a négyzetből.

Pedig csábítónak tűnik a dolog, megúszhatnánk a leszármazással egy csomó kódduplikációt: megörököljük a négyzet tulajdonságait, double x, y a pozíciónak és double a adattag az oldalhossznak, ezen kívül pedig hozzáadunk egy double b oldalhosszt is:

Ez is hibás
class Alakzat {
    double x, y;    /* pozíció */
    /* ... */
};

class Negyzet : public Alakzat {
    double a;
};

class Teglalap : public Negyzet {   // hibás
    double b;
};

Mondjuk ezzel az erővel a háromszöget származtathatnánk a téglalapból... Annak van a oldala (örökölve a négyzettől), b oldala (örökölve a téglalaptól), és c oldala (ez az újdonság). Ugye, mennyire vadul hangzik?

Hogy derül ki, hogy hibás a modellünk? A négyzet területe a², a téglalapra ez nem igaz. Ha le lehetne kérdezni a négyzet oldalhosszát a Negyzet::get_a() függvénnyel, akkor ilyet írhatnánk:

Ezért hibás a fenti
double terulet(Negyzet const & n) {
    return pow(n.get_a(), 2);
}

Teglalap t1(20, 30);
std::cout << terulet(t1);   // 400 :(

De nem kell ilyen messzire menni... Bármelyik függvény, amelyik négyzetet kap, a téglalap négyzetből történő hibás származtatása miatt kaphat téglalapot is.

A téglalap
nem négyzet!
void fuggveny(Negyzet const & n) {
    std::cout << "Ez négyzet, minden oldala egyforma.";
}

Teglalap t1(20, 30);
fuggveny(t1);               // "minden oldala egyforma"

Hol a hiba? Ott, hogy hazudtunk a fordítónak és a többi programozónak, amikor a téglalapot a négyzetből származtattuk. Nem igaz, hogy a téglalap négyzet volna. Tudunk olyan állítást mondani, amelyik a négyzetre igaz, de a téglalapra nem.

Ugyanígy hibás például az is, ha a kétdimenziós (síkbeli) vektorból származtatjuk a háromdimenziós (térbeli) vektort:

Újabb hiba
struct Vektor2D {
    double x, y;
};

struct Vektor3D : public Vektor2D {     // hibás
    double z;
};

Miért is? Azért, mert a síkbeli vektorok egy síkban vannak – ezt mondja a nevük is –, a térbeli pontok meg nincsenek egy síkban. A hazugság megbosszulja magát itt is, ugyanis előbb-utóbb belefutunk abba, hogy a Vektor3D objektumainkat át tudja venni paraméterként a Vektor2D-ket váró operator+:

Ezért hibás a fenti
Vektor2D operator+(Vektor2D p1, Vektor2D p2) {
    return Vektor2D(p1.x + p2.x, p1.y + p2.y);
}

Vektor3D a(...), b(...);
std::cout << a+b;           // !

Így a két térbeli vektor összegeként egy síkbeli vektort kapunk, amely az összegnek valójában csak a vetülete. Megpróbálhatunk térbeli vektorokat átvevő operator+-t is írni, de hiába van az is, attól még össze lehet adni majd egy síkbeli+térbeli, vagy egy térbeli+síkbeli vektort, és azokat a műveleteket is a síkbeli vektorok összeadófüggvénye fogja elvégezni... Látszik, hogy csak javítgatjuk, szigszalagozgatjuk körbe az elvi hibás megoldást. A térbeli pont nem származhat a síkbeli pont osztályból, és ezt ilyen egyszerű bizonyítani.

Mit mond erre a Liskov-féle elv? „Ami igaz az ősre, az igaz kell legyen a leszármazottra is.” A programjainkban ez gyakran így jelenik meg: „ha egy függvény átvesz egy objektumot paraméterként, akkor át kell tudja venni annak leszármazottját is.” Ez nem teljesül a fenti esetekben (négyzet területe, vektorok összege). De ez nem a függvények hibája, hanem a helytelen származtatásé.

3. A négyzet sem származhat a téglalapból

Matematikából tudjuk, hogy a négyzet egyfajta téglalap, azzal a megkötéssel, hogy az oldalai nem csak páronként, hanem mind egyforma hosszúak. Ez a megkötés, korlátozás viszont olyan dolog, ami ellentmond az OOP elveinek. Az OOP azt mondja, hogy a leszármazott bizonyos dolgokat másképp csinál, nem pedig azt, hogy korlátozásokat vezet be.

Helyes azt mondani, hogy a téglalap alakzat, vagy a kör alakzat, mert az alakzat osztály által tett ígéreteket mind a ketten be tudják tartani. Helytelen viszont azt mondani, hogy a négyzet az téglalap, mert a négyzet nem tudja betartani a téglalap ígéreteit. Például:

class Teglalap : public Alakzat {
    /* ... */
};

void megnyujt(Teglalap & t, double mennyire) {
    std::cout << "a téglalap magassága " << t.get_b();
    t.set_a(t.get_a() * mennyire);
    std::cout << "a magassága még mindig " << t.get_b();
}

Mi történt itt? A téglalap képes megnyúlni úgy széltében, hogy közben a magassága nem változik. A megnyújt() függvény kihasználta ezt a tulajdonságot, megnövelte a téglalap szélességét, és közben kiírta, hogy a magasság nem változott. Ezt megtehette, a téglalapok már csak ilyenek.

Ezek után pedig...

Lesz helyes kód is?
Ez tuti nem az...
class Negyzet : public Teglalap {   // hibás
    /* ... */
};

Negyzet n1(5);
megnyujt(n1, 2);   // a téglalap magassága 5
                   // a magassága még mindig 10

Itt azt hazudtuk, hogy a négyzet egyfajta téglalap. A fordító és a többi programozó ezt elhitte nekünk: ha a négyzet a téglalap egy fajtája, akkor a megnyújt() függvény kaphat paraméterként négyzetet is. Csakhogy a négyzet nem nyújtható kizárólag széltében, az átméretezés hatására a magassága is változik – ezért a függvény hibásan viselkedik.

A helyzet az előzőhöz hasonló. Nem a függvény a hibás, hanem a leszármazás. Akkor lehetne a négyzet téglalap, ha teljesíteni tudna minden elvárást, amit a téglalap teljesíteni tudott.

Ne gondoljuk azt, hogy az ilyen problémákon a virtuális függvények vagy a privát leszármazás segíteni tudnak. Azokkal csak elrejteni tudnánk a hibát, ami előbb-utóbb előbújna máshol. Kár privát leszármazást csinálni, és aztán cast-okkal teleszemetelni a programot: inkább meg kellene javítani azt.

4. Mi köze a struccnak a madarakhoz és a négyzetnek a téglalaphoz?

Hogy oldható meg a struccos probléma? Nem mondható el minden madárról, hogy tud repülni. Vannak repülni tudó és röpképtelen madarak. Ha a madárból származtatunk repülni tudó és röpképtelen madarat, akkor a strucc lehet madár, méghozzá a röpképtelen fajtából. A veréb is, az pedig a repülni tudó fajtából. A helyes modell ez lehet:

Az alakzatok kapcsán pedig, ha nagyon szeretnénk, bevezethetünk a téglalap és négyzet számára egy közös ősosztályt, amely a közös tulajdonságokat, tagfüggvényeket tartalmazza. De a két osztálynak közvetlenül nem lehet köze egymáshoz. Valahogy így:

Általánosságban is elmondható egyébként, hogy egy osztályhierarchiában csak a hierarchia végén álló osztályok lehetnek konkrétak, a közteseknek mindig absztraktnak kell lenniük. Ha nem, az tervezési hibát sejtet – minden olyan programban, ahol ez nem igaz, rá lehet mutatni valamilyen ellentmondásra. Vagy egy potenciális ellentmondásra, amelyik egy paraméterátadással bemutatható.

5. És mire jó a privát leszármazás?

A Liskov-féle elv szerinti leszármazást a class X : public Y osztálydefinícióval írjuk le C++-ban. Fölmerül a kérdés, hogy mit jelent az, ha a public helyére mást írunk, például azt, hogy private.

Ilyenkor a leszármazás tényét elrejtjük az osztály használói elől; tehát ők nem tudhatják azt, hogy milyen ősosztálya van a leszármazottunknak, sőt igazából azt sem, hogy egyáltalán van-e ősosztálya. Ilyenkor értelemszerűen a szokásos referencia és pointerkonverziók sem működnek, hiszen akkor mutathatunk rá egy téglalapra egy alakzat pointerrel, ha tudjuk, hogy a téglalap egyfajta alakzat.

A privát leszármazás egészen másra jó, mint a publikus. A publikus „X egyfajta Y” (angolul X is a Y) kapcsolatot fejez ki: a téglalap egyfajta alakzat, az ember egyfajta játékos, a gólya egyfajta röpképes madár. A privát esetben csak az ősosztály kódját hasznosítjuk újra, így az a leszármazás egy „X implementálásához Y-t használjuk” kapcsolatként fogalmazható meg (X is implemented in terms of Y).

Lássunk erre egy példát! Adott egy tömb osztályunk. Ennek az indexelő operátora nem végez semmiféle ellenőrzést, túlindexelés hatására hibás eredményt ad:

template <typename T>
class Array {
    private:
        T* data;
        size_t size;
    public:
        Array(size_t initialsize);

        size_t get_size() const;
        void resize(size_t newsize);

        T& operator[](size_t idx) {
            return data[idx];
        }
        T const & operator[](size_t idx) const {
            return data[idx];
        }
        
        /* ... */
};

Mi a helyzet akkor, ha szükségünk van egy olyan tömbre, amelyik ellenőrzi a túlindexelést? Írhatunk egy teljesen újat, de sejtjük, hogy ez fölösleges, mert az előző osztály fölhasználható lenne. Csak ki kellene egészíteni a túlindexelés ellenőrzésével.

Egyik lehetőségünk a delegálás. A túlindexelés ellenőrző tömb osztály tartalmaz egy sima tömböt, és az összes feladatot delegálja neki:

template <typename T>
class CheckedArray {
    private:
        Array<T> helper;
    public:
        CheckedArray(size_t initialsize) : helper(initialsize) {}

        size_t get_size() const {
            return helper.get_size();
        }
        void resize(size_t newsize) {
            helper.resize(newsize);
        }

        T& operator[](size_t idx) {
            if (idx >= helper.get_size())
                throw std::out_of_range("idx >= size");
            return helper[idx];
        }
        T const & operator[](size_t idx) const {
            if (idx >= helper.get_size())
                throw std::out_of_range("idx >= size");
            return helper[idx];
        }
};

A másik lehetőségünk, hogy az új osztályt a régi tömb osztályból származatjuk, de a leszármazás tényét priváttá tesszük:

template <typename T>
class CheckedArray : private Array<T> {     // !
    public:
        using Array<T>::Array;
        using Array<T>::get_size;
        using Array<T>::resize;

        T& operator[](size_t idx) {
            if (idx >= get_size())
                throw std::out_of_range("idx >= size");
            return Array<T>::operator[](idx);
        }
        
        T const & operator[](size_t idx) const {
            if (idx >= get_size())
                throw std::out_of_range("idx >= size");
            return Array<T>::operator[](idx);
        }
};

Az indexeléskor előbb megvizsgáljuk, helyes-e az index, és ha nem, kivételt dobunk. Utána pedig már továbbítható a kérés az ősosztály indexelő operátorának. A többi függvényt, amit változtatás nélkül megörökölhetünk, láthatóvá tesszük publikusan a using kulcsszó segítségével.

Miért nem publikus ez a leszármazás? Egyszerű: azért, mert akkor bármelyik CheckedArray objektumot paraméterként lehetne adni olyan függvénynek, amelyik Array-t vár. És hopp, a függvényen belül máris az ősosztály indexelő operátora látszana, amelyik a túlindexelés ellenőrzéséről soha nem hallott.