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.
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.
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.
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ő:
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.
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.
using és névfeloldásA 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.
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.
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.
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_demoVagy Clangdal:
clang++ -std=c++20 -Wall -Wextra -pedantic -o base_subobject_demo base_subobject_demo.cpp
./base_subobject_demoA 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.
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.
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.
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.
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?
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.
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.
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.
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.