24.1 Öröklés, base subobject és hozzáférés

A fejezet a C++20 nyelvi szabályaira támaszkodik. A konkrét szabványparagrafusok, az ABI-dokumentumok és a compiler-viselkedés ellenőrzése a labor feladata; a megadott forráskeresési kiindulópontok önmagukban nem minősülnek ellenőrzött bizonyítéknak. Ahol a viselkedés implementációfüggő vagy nem specifikált, azt külön jelöljük.

Az öröklés nem elsősorban kódujrafelhasználás, hanem típus-szerződés: a leszármaztatott osztály objektuma a bázisosztály által leírt helyen használhatóvá válik. Ez a helyettesíthetőség (substitutability) adja az értékét, és ez szabja meg a feltételeit is. A következőkben azt nézzük meg, hogyan épül fel egy leszármaztatott objektum a memóriában és az élettartama során, milyen hozzáférési szabályok érvényesek a bázis és a leszármazott között, és mikor helyes egyáltalán öröklést használni.

24.1.1 Az öröklés mint helyettesíthetőségi szerződés

A nyilvános öröklés (public inheritance) azt fejezi ki, hogy a leszármaztatott típus minden objektuma egyben a bázis típus objektuma is: a Derived egy Base. A nyelvi következmény az, hogy egy Derived* vagy Derived& implicit módon Base*-gé, illetve Base&-é alakítható, és a bázisra írt kód a leszármazott objektumot is kezelheti.

Ez azonban csak akkor helyes, ha a leszármazott betartja a bázis szerződését. A bázis osztály a nyilvános műveleteivel nemcsak szignatúrákat, hanem elvárásokat is rögzít: milyen előfeltételekkel hívható egy művelet, milyen posztfeltételt garantál, milyen kivételt enged meg, és mi marad invariáns. A leszármazott nem gyengítheti az előfeltételeket, nem erősítheti a posztfeltételeket, és nem vezethet be olyan megfigyelhető viselkedést, amelyre a bázisra építő kód nem számít. Ez a Liskov-helyettesítési elv gyakorlati tartalma.

Tekintsünk egy tipikus sérülést. A bázis így deklarál:

struct Shape {
    virtual double area() const noexcept = 0;
    virtual ~Shape() = default;
};

A noexcept itt szerződéselem: a bázis azt ígéri, hogy az area() nem dob kivételt. Ha egy leszármazott mégis dob, akkor a bázisra írt kód, amely nem készült kivételkezelésre, std::terminate-hez juthat. A leszármazott tehát nem helyettesíthető minden olyan helyen, ahol a bázist használják. A javítás nem az, hogy a hívóoldalon mindenhol kivételkezelést teszünk, hanem hogy vagy a leszármazott tartja be a szerződést, vagy a bázis szerződését változtatjuk meg tudatosan, minden leszármazottra kiterjedően.

A nyilvános öröklés tehát erős kapcsolat: a bázis nyilvános felülete a leszármazott felülete is lesz. Ezért a bázis nyilvános interfészét úgy kell kialakítani, hogy az valóban minden lehetséges leszármazottra érvényes legyen. Ha csak implementációt szeretnénk újrafelhasználni, és nem állítjuk, hogy a leszármazott „egy” bázis, akkor kompozíció vagy nem nyilvános öröklés a helyes eszköz.

24.1.2 Bázis-alobjektum és leszármaztatott objektum

A leszármaztatott objektum egyetlen objektum, amely tartalmazza a bázis-osztály alobjektumát (base class subobject) és a saját nem statikus adattagjait. A bázis-alobjektumnak saját élettartama van, de ez az élettartam a leszármaztatott objektum élettartamának része: nem önálló objektum, hanem a nagyobb objektum egy darabja. A Base* vagy Base& erre az alobjektumra mutat, nem egy különálló Base példányra.

Ebből következik néhány gyakorlati szabály. A Derived* Base*-gé alakítása nyilvános öröklésnél implicit, és a bázis-alobjektum címét adja. A visszafelé irányuló Base* → Derived* átalakítás nem implicit; static_cast-tal csak akkor biztonságos, ha a mutatott objektum valóban Derived, különben a viselkedés nem definiált. A futásidejű ellenőrzést a dynamic_cast adja, amelynek részletei a RTTI-ről szóló részhez tartoznak.

A memóriabeli elrendezésről pontosat kell mondanunk. A szabvány a nem statikus adattagok sorrendjét azonos hozzáférési szekción belül a deklaráció sorrendjéhez köti, de a bázis-alobjektum és a tagok egymáshoz képesti sorrendjét, a kitöltést (padding) és az üres bázis optimalizálását (empty base optimization, EBO) nem rögzíti. Ezért konkrét offseteket csak dokumentált target ABI és fordítási profil mellett szabad állítani. Az offsetof makró csak standard-layout típusra használható; polimorfikus osztályra nem. A sizeof(Derived) legalább sizeof(Base), de a kitöltés és az EBO miatt nem egyszerű összeg.

A bázis-alobjektum léte magyarázza a szeletelést (object slicing) is. Ha egy Derived objektumot érték szerint Base típusba másolunk, akkor csak a bázis-alobjektum másolódik; a leszármaztatott rész levágódik. Az így létrejött Base objektum dinamikus típusa Base, a virtuális hívások a bázis verzióját használják, és a leszármaztatott invariáns elveszik. Érték szerinti átadás bázis típusú paraméternek ezért polimorfikus használatban hibás; referenciát vagy pointert kell használni.

24.1.3 Konstrukció és destrukció sorrendje

A leszármaztatott objektum felépítése szigorú sorrendben történik. A konstruktor mem-initializer listájában a bázis konstruktora fut először; ha nincs megadva, a bázis alapértelmezett konstruktora. A teljes sorrend a következő:

  1. a virtuális bázis-alobjektumok, a mélységi bejárás és a deklarációs sorrend szerint;
  2. a közvetlen, nem virtuális bázis-osztályok, a deklarációs sorrendben;
  3. a nem statikus adattagok, a deklarációs sorrendben;
  4. a konstruktor teste.

A virtuális bázisok részletei a többszörös öröklésről szóló részhez tartoznak; itt azt rögzítsük, hogy a bázis minden esetben a tagok és a konstruktor test előtt épül fel. A destrukció pontosan fordított sorrendben történik: először a leszármaztatott destruktor teste, majd a leszármaztatott tagok, majd a bázis destruktor teste, végül a bázis tagjai.

A sorrend indoklása az invariánsokban rejlik. A leszármaztatott konstruktor testében a bázis-alobjektum és a saját tagok már érvényes állapotban vannak, tehát a leszármaztatott invariáns felállítható. A bázis konstruktorában viszont a leszármaztatott rész még nem épült fel; ezért ott a dinamikus típus a bázis, és egy virtuális függvény hívása a bázis verzióját választja, nem a leszármaztatott felülírást. Ha a bázis konstruktorában tisztán virtuális függvényt hívunk, a viselkedés nem definiált. A bázis konstruktorában a leszármaztatott adattagokra hivatkozni szintén hibás, mert azok még nem jöttek létre.

A destruktoroknál ugyanez fordítva igaz: a leszármaztatott destruktor testében a leszármaztatott tagok még élnek, a bázis destruktorában már nem. Ezért a bázis destruktorának nem szabad olyan virtuális hívást feltételeznie, amely a leszármaztatott részt érinti.

Ha a bázis rendelkezik virtuális destruktorral, akkor a bázis pointeren keresztül történő delete a teljes objektumot, azaz a leszármaztatott destruktort is lefuttatja. Ha nem, a viselkedés nem definiált. Ennek részletes tárgyalása a virtuális destruktorról szóló részhez tartozik; itt a sorrend szempontjából az a lényeg, hogy a bázis destruktora a leszármaztatott rész után fut.

24.1.4 Hozzáférési szabályok és az öröklés módja

Az öröklés módja (public, protected, private) és a bázis tagjainak hozzáférési szintje együtt határozza meg, hogy a leszármazottból és a külvilágból mi érhető el. A szabály táblázatban:

A bázis tagja public öröklés protected öröklés private öröklés
public public protected private
protected protected protected private
private nem érhető el nem érhető el nem érhető el

A „nem érhető el” azt jelenti, hogy a leszármazott tagfüggvényei sem érik el közvetlenül a bázis privát tagjait; kivételt csak a bázis által megadott friend jelent. A hozzáférés fordítási idejű szabály, nem futásidejű: nem attól függ, hogy a pointer dinamikusan milyen típusra mutat.

A nyilvános öröklés a „is-a” kapcsolatot fejezi ki, és kifelé is láthatóvá teszi a bázis nyilvános felületét. A privát öröklés a „implementációt újrahasznosítok” kapcsolat, de kifelé nem teszi láthatóvá a bázis felületét; a Derived* → Base* konverzió a leszármazott tagjai számára elérhető, kívülről nem. A védett öröklés ritka: a bázis felülete a további leszármazottak felé válik elérhetővé, a külvilág felé nem.

A gyakorlatban a privát öröklés helyett többnyire kompozíciót használunk, mert az kifejezőbb és kevésbé kötődik a bázis reprezentációjához. A védett öröklés pedig akkor merül fel, ha egy köztes absztrakciót akarunk a hierarchián belül tartani; ilyenkor is érdemes megvizsgálni, hogy a kompozíció nem elegendő-e.

24.1.5 Tagelrejtés, using és névfeloldás

A leszármaztatott osztályban deklarált név elrejti a bázisban található összes azonos nevű tagot, nem csak az azonos szignatúrájút. Ha a bázisnak van describe() és describe(int) tagfüggvénye, és a leszármazottban deklarálunk egy describe(int)-et, akkor a leszármazottban a describe név feloldása megáll a leszármazott hatókörében, és a bázis describe() nem lesz látható. Ez gyakran meglepetést okoz.

A using Base::describe; deklaráció behozza a bázis tagját a leszármazott hatókörébe, így az overload-halmaz egyesül. A using azonban nem tudja megkerülni a bázis hozzáférési szabályait: ha a bázis tagja privát, a using nem teszi elérhetővé. A using által bevezetett név hozzáférési szintjét a using helye is befolyásolja; egy nyilvános szekcióban elhelyezett using a bevezetett nevet nyilvánossá teheti, de a bázis tagjának eredeti hozzáférése továbbra is számít.

A tagelrejtés nem azonos a virtuális felülírással. A virtuális függvény felülírása a dinamikus dispatch révén a leszármazott verziót hívja a bázis pointeren keresztül is; a nem virtuális tag elrejtése csak statikus névfeloldást érint. A két mechanizmus együtt is előfordulhat, ezért érdemes a bázis felületét úgy tervezni, hogy a nevek ne ütközzenek véletlenül.

24.1.6 Kompozíció és „is-a” szemantika

A kompozíció (has-a) és az öröklés (is-a) közötti választás nem esztétikai kérdés. Ha a leszármazott valóban minden helyen helyettesítheti a bázist, és a bázis szerződése minden leszármazottra érvényes, akkor a nyilvános öröklés indokolt. Ha csak egy részimplementációt szeretnénk átvenni, vagy a bázis belső reprezentációjára támaszkodunk, akkor a kompozíció a biztonságosabb.

A kompozíció lazább kapcsolatot ad: a tartalmazott objektum cserélhető, tesztelhető, és nem kell a bázis minden nyilvános műveletét örökölni. Az öröklés ezzel szemben a bázis felületének változásaira érzékeny, és a leszármazottak száma növeli a karbantartási terhet. A „prefer composition over inheritance” alapértelmezés, nem tilalom: a polimorfikus helyettesítéshez továbbra is öröklés (vagy azzal egyenértékű típus-erasure) kell.

DevOps szempontból külön figyelmet érdemel, hogy a C++ osztálylayout és a virtuális dispatch implementációfüggő. Shared library ABI-határon ne adjunk át C++ osztályokat extern "C" interfészen keresztül, és ne építsünk a bázis-alobjektum konkrét offsetjére. A stabil C API mögött a C++ hierarchia elrejthető, de a határon a szerződést és a layoutot dokumentálni kell.

24.1.7 Védett interfész és a törékeny bázisosztály

A protected tagok a leszármazottak számára készült belső interfészt alkotják. Ez hasznos lehet, ha a leszármazottnak valóban szüksége van egy műveletre, de a külvilágnak nem. A védett adattagok azonban különösen kockázatosak: a leszármazott közvetlenül függ a bázis reprezentációjától, így a bázis belső változása a leszármazottakat is eltöri.

Ezt nevezzük törékeny bázisosztály-problémának (fragile base class problem). A bázis látszólag ártalmatlan változása – egy új overload, egy tag átnevezése, az invariáns módosítása – a leszármazottak viselkedését is megváltoztathatja. A védekezés: a védett felület minimalizálása, védett adattag helyett védett művelet, a nem virtuális interfész (non-virtual interface, NVI) minta alkalmazása, ahol a nyilvános nem virtuális függvény ellenőrzi az előfeltételeket, és egy védett vagy privát virtuális függvényt hív. A kompozíció szintén csökkenti a függőséget.

A mély öröklési hierarchia önmagában nem cél; a sok szint növeli a törékenységet és a megértési költséget. Ahol a hierarchia csak kódujrafelhasználásra szolgál, ott érdemes kompozícióra vagy sablonokra váltani.

24.1.8 Kidolgozott példa

A következő példa egy bázis- és leszármaztatott osztályt mutat be, amely kiírja a konstrukció és a destrukció sorrendjét, szemlélteti a tagelrejtést, a using deklarációt és a védett tag elérését. A kód önállóan fordítható C++20-ban.

// base_subobject_demo.cpp
#include <iostream>
#include <string>
#include <utility>

class Resource {
public:
    explicit Resource(std::string name) : name_(std::move(name)) {
        std::cout << "  Resource ctor: " << name_ << '\n';
    }
    ~Resource() { std::cout << "  Resource dtor: " << name_ << '\n'; }
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
private:
    std::string name_;
};

class Base {
public:
    explicit Base(std::string tag) : r_("Base/" + tag) {
        std::cout << "  Base ctor body\n";
    }
    virtual ~Base() { std::cout << "  Base dtor body\n"; }

    void describe() const { std::cout << "Base::describe\n"; }
    int value() const { return 1; }

protected:
    void hook() const { std::cout << "Base::hook\n"; }

private:
    Resource r_;
};

class Derived : public Base {
public:
    Derived() : Base("d"), r_("Derived") {
        std::cout << "  Derived ctor body\n";
    }
    ~Derived() override { std::cout << "  Derived dtor body\n"; }

    using Base::describe;              // behozza Base::describe()-t
    void describe(int) const { std::cout << "Derived::describe(int)\n"; }

    int value() const { return 2; }    // elrejti Base::value()-t

    void call_hook() const { hook(); } // protected bázis-tag elérése

private:
    Resource r_;
};

int main() {
    std::cout << "-- construction --\n";
    Derived d;
    std::cout << "-- calls --\n";
    d.describe();          // Base::describe a using miatt
    d.describe(42);        // Derived::describe(int)
    std::cout << "d.value() = " << d.value() << '\n';
    std::cout << "d.Base::value() = " << d.Base::value() << '\n';
    d.call_hook();
    std::cout << "-- destruction --\n";
}

A fordításhoz és futtatáshoz például:

g++ -std=c++20 -Wall -Wextra -pedantic -o base_subobject_demo base_subobject_demo.cpp
./base_subobject_demo

Vagy Clangdal:

clang++ -std=c++20 -Wall -Wextra -pedantic -o base_subobject_demo base_subobject_demo.cpp
./base_subobject_demo

A várt kimenet illusztráció, nem mért eredmény:

-- construction --
  Resource ctor: Base/d
  Base ctor body
  Resource ctor: Derived
  Derived ctor body
-- calls --
Base::describe
Derived::describe(int)
d.value() = 2
d.Base::value() = 1
Base::hook
-- destruction --
  Derived dtor body
  Resource dtor: Derived
  Base dtor body
  Resource dtor: Base/d

A kimenet sorrendje pontosan mutatja a konstrukció és destrukció fordított viszonyát: a Derived teste előtt a bázis és a bázis tagja épül fel, a destrukcióban pedig a Derived teste és tagja után a bázis következik. A d.describe() a using miatt a bázis verzióját hívja, a d.describe(42) a leszármazottét; a d.value() a leszármazott által elrejtett változat, a d.Base::value() pedig minősített hívással a bázisé. A call_hook() a védett bázis-tagot a leszármazott tagfüggvényén keresztül éri el.

24.1.9 Gyakori hibák

Az egyik leggyakoribb hiba, hogy a bázis destruktora nem virtuális, és a bázis pointeren keresztül törlünk. Ez nem definiált viselkedés; a leszármaztatott destruktor nem feltétlenül fut le. A másik a szeletelés: Base b = d; esetén a leszármaztatott rész levágódik, és a polimorfikus viselkedés elveszik. Gyakori a tagelrejtés figyelmen kívül hagyása: a leszármazottban deklarált név elrejti a bázis összes azonos nevű tagját, amit using deklarációval vagy minősített hívással lehet kezelni.

Hibás a privát öröklés mellett kívülről Base*-gé alakítani: a konverzió nem érhető el. Ugyancsak hiba a bázis konstruktorában a leszármaztatott tagokra vagy a leszármaztatott virtuális felülírásra támaszkodni; a dinamikus típus ott még a bázis. Végül a polimorfikus típusra alkalmazott offsetof és a konkrét offsetekre építő szerializálás nem hordozható, mert a layout nem specifikált.

24.1.10 Feladatok

24.1.10.1 1. feladat: helyettesíthetőség

Adott a következő bázis:

struct Shape {
    virtual double area() const noexcept = 0;
    virtual ~Shape() = default;
};

Egy Bad : Shape leszármazott area() implementációja kivételt dob. Magyarázd meg, miért sérti ez a helyettesíthetőségi szerződést, és hogyan javítanád a hibát. Legalább két javítási irányt adj.

24.1.10.2 2. feladat: privát öröklés és hozzáférés

A kidolgozott példában változtasd meg a Derived öröklését private-re. Írd át a main-t úgy, hogy kívülről próbáld meg Base* p = &d;-t használni. Mit tapasztalsz? Úgy javítsd a kódot, hogy a Derived továbbra is elérje a Base::hook()-ot, de kifelé ne legyen Base konverzió. Indokold a választ.

24.1.10.3 3. feladat: diagnosztika

Adott a következő kód:

#include <iostream>

class B {
public:
    B() { std::cout << "B\n"; }
    ~B() { std::cout << "~B\n"; }
    virtual void f() const { std::cout << "B::f\n"; }
};

class D : public B {
public:
    D() { std::cout << "D\n"; }
    ~D() { std::cout << "~D\n"; }
    void f() const override { std::cout << "D::f\n"; }
};

int main() {
    B* p = new D;
    p->f();
    delete p;
}

Mi a hiba, miért nem definiált a viselkedés, és hogyan javítod? A javítás után milyen sorrendben várhatók a destruktorok?

24.1.11 Megoldások

24.1.11.1 1. megoldás

A bázis noexcept szerződése azt ígéri, hogy az area() nem dob kivételt. A Bad::area() ezt megszegi, ezért a bázisra írt kód, amely nem számít kivételre, std::terminate-hez juthat. A helyettesíthetőség sérül: a Bad nem használható mindenütt, ahol Shape-et várnak.

Javítási irányok: (a) a Bad::area() ne dobjon, hanem kezelje a hibát a szerződésnek megfelelően; (b) ha a hiba mégis kivétel, akkor a bázis szerződését kell megváltoztatni – eltávolítani a noexcept-ot –, de ez minden leszármazottra és minden hívóra kihat; (c) ha a Bad valójában nem „egy” Shape, hanem csak egy részimplementációt használ, akkor kompozícióra kell váltani. Ellenőrzési feltétel: a Shape-re írt kód forduljon le, és a Bad ne idézzen elő nem várt std::terminate-et.

24.1.11.2 2. megoldás

Privát öröklésnél a Derived továbbra is eléri a Base védett hook()-ját a saját tagfüggvényeiből, de a Derived* → Base* konverzió kívülről nem elérhető. A Base* p = &d; sor ezért nem fordul le. A belső használathoz a Derived tagfüggvényében lehet static_cast<Base*>(this)-t használni, de kívülről nem.

A javított kód lényege:

class Derived : private Base {
public:
    Derived() : Base("d") {}
    void call_hook() const { hook(); } // belül elérhető
};

A main-ben a Base* p = &d; továbbra sem fordul le; ez szándékos. Ha kifelé mégis kell bázis-interfész, akkor vagy nyilvános öröklésre váltunk, vagy egy külön, szűk interfészt adunk ki. Ellenőrzési feltétel: a call_hook() működjön, a külső Base* konverzió pedig fordítási hibát adjon.

24.1.11.3 3. megoldás

A hiba a B nem virtuális destruktora. A delete p; a B* statikus típusán keresztül töröl, és mivel a destruktor nem virtuális, a viselkedés nem definiált; a D destruktora nem feltétlenül fut le. A p->f() ezzel szemben helyesen a D::f-et hívja, mert az f virtuális.

A javítás:

class B {
public:
    B() { std::cout << "B\n"; }
    virtual ~B() { std::cout << "~B\n"; }
    virtual void f() const { std::cout << "B::f\n"; }
};

A javítás után a delete p; a teljes objektumot törli: először a D destruktor teste fut le, majd a B destruktor teste, tehát a várt sorrend ~D, majd ~B. Ellenőrzési feltétel: a program a -Wall -Wextra -pedantic kapcsolókkal figyelmeztetés nélkül forduljon, és a destruktorok sorrendje a fenti legyen.

24.1.12 Összefoglaló

A nyilvános öröklés helyettesíthetőségi szerződés: a leszármazott mindenütt használható a bázis helyett, de csak akkor, ha a bázis szerződését betartja. A leszármaztatott objektum tartalmazza a bázis-alobjektumot; a bázis pointer vagy referencia erre az alobjektumra mutat. A konstrukció sorrendje a bázisok, majd a tagok, végül a konstruktor test; a destrukció pontosan fordított. A bázis konstruktorában a dinamikus típus még a bázis, ezért a leszármaztatott részre támaszkodni hibás. Az öröklés módja és a tagok hozzáférési szintje együtt szabja meg, mi érhető el a leszármazottból és kívülről. A tagelrejtést using deklarációval vagy minősített névvel lehet kezelni. A védett interfész és a törékeny bázisosztály kockázata miatt a védett felületet minimalizálni kell, és ahol csak implementáció-újrafelhasználás a cél, kompozíciót érdemes választani.