24.5 Többszörös öröklés és virtuális bázis

A többszörös öröklés akkor válik érdekessé, amikor egy típus egyszerre több, egymástól független szerződést kell hogy teljesítsen. A C++-ban erre nincs külön interface kulcsszó: absztrakt osztályokból, pure virtual függvényekkel építünk interfészeket. Amíg ezek az interfészek függetlenek, a dolog egyszerű. Amint azonban két interfésznek közös őse van, megjelenik a diamond (gyémánt) probléma: a közös ős kétszer kerülne be az objektumba, a nevek pedig kétértelművé válnak. A virtual öröklés ezt egyetlen közös base subobjectre redukálja, de a konstrukció és a pointerkonverzió szabályai ettől lényegesen bonyolultabbak lesznek. Ebben a részben a nyelvi szerződést és annak tipikus ABI-megvalósítását külön kezeljük: az előbbi hordozható, az utóbbi target- és compilerfüggő.

A virtuális dispatch és a virtuális destruktor mechanizmusát Virtual, override, final és dispatch (24.2) és Virtuális destruktor, slicing és ownership (24.3) tárgyalta; a híváskonvenció alapjait Hívási konvenció, stack frame és visszatérés (14.2) rögzítette. Itt azt vizsgáljuk, mi történik, ha egy objektumnak több base subobjectje van, és hogyan lesz ebből egyetlen közös ős.

24.5.1 Több interfész és két base subobject

A base subobject (bázis-alobjektum) a teljes objektum azon része, amely egy base osztály példányát hordozza. Nem-virtualis többszörös öröklésnél minden közvetlen base-hez külön subobject tartozik, saját tárolóval. Tekintsük a következő, még diamond mentes példát:

struct A { int a = 1; };
struct B { int b = 2; };
struct C : A, B { int c = 3; };

A C objektum tartalmaz egy A subobjectet, egy B subobjectet és a c tagot. A C* típusú pointer A*-ká és B*-ká konvertálható, de a két eredmény nem feltétlenül ugyanaz a cím. A helyes konverzió a static_cast, amely elvégzi a szükséges pointer adjustmentet (pointerigazítást). A reinterpret_cast ezt nem teszi meg, ezért base pointerek között nem használható.

A nyelvi garancia az, hogy a static_cast a megfelelő base subobjectre mutat, és hogy a subobjectek élettartama a teljes objektum élettartamához kötődik. Az nem garancia, hogy a B subobject a C elején van, vagy hogy az offset fordítási időben ismert. A konkrét layout target ABI és fordítási profil függvénye.

Interfészeknél a base-ek jellemzően absztraktak és adattag nélküliek, de a subobjectek akkor is léteznek. Az üres base optimalizálás (empty base optimization, EBO) megengedett, de nem kötelező; két azonos típusú közvetlen base pedig nem is szerepelhet, mert az már a deklaráció szintjén kétértelmű lenne.

Konstrukció Base subobjectek Következmény
struct D : A, B két külön subobject static_cast igazít; a címek eltérhetnek
struct D : virtual A egy megosztott A subobject futásidejű vbase-offset szükséges
diamond virtual nélkül a közös ős kétszer kétértelmű név, duplikált állapot
diamond virtual-lal a közös ős egyszer egyértelmű hozzáférés, bonyolultabb konstrukció

24.5.2 A diamond és a virtuális bázis

A diamond akkor jön létre, amikor egy ős két különböző útvonalon érhető el. Vegyük a V közös őszt, valamint az L és R köztes osztályokat, amelyekből a D származik. Virtual inheritance nélkül a D objektumban két V subobject lenne, és a V::f hívása kétértelmű:

// diamond_bad.cpp — szándékosan nem forduló tanítási példa
struct V { void f() {} };
struct L : V {};
struct R : V {};
struct D : L, R {};

int main() {
    D d;
    d.f(); // hiba: a V::f két útvonalon érhető el
}

A javítás a base-lista virtual kulcsszava:

struct L : virtual V {};
struct R : virtual V {};

Ettől a D-ben egyetlen V subobject jön létre, és a d.f() egyértelmű. Fontos, hogy a base-listában szereplő virtual nem azt jelenti, hogy a V függvényei virtuálisak; a két virtual kulcsszó helye és jelentése különbözik. A virtual base (virtuális bázis) a megosztott subobjectre vonatkozik, a virtual function a dinamikus dispatch-re.

A megosztott bázis ára a layoutban jelentkezik: a köztes osztályoknak valahogy el kell érniük a közös V subobjectet, amelynek pozíciója a teljes objektum típusától függ. A tipikus megvalósítás ezért a köztes subobjectekben rejtett offset- vagy pointerinformációt tárol, vagy a vtable-ből olvassa ki a vbase-offsetet. Ez már ABI-szintű részlet, nem nyelvi garancia.

Nézzünk egy fordítható példát, amely a konstrukciós sorrendet és a címeket is szemlélteti:

// diamond_ok.cpp — ISO C++20, x86-64 System V, GCC/Clang illusztráció
#include <cstdio>

struct V {
    explicit V(int x) : tag(x) { std::printf("V(%d)\n", x); }
    virtual ~V() = default;
    virtual void f() { std::printf("V::f tag=%d\n", tag); }
    int tag;
};

struct L : virtual V {
    L() : V(1) { std::printf("L\n"); }
    void f() override { std::printf("L::f\n"); }
    int l = 10;
};

struct R : virtual V {
    R() : V(2) { std::printf("R\n"); }
    int r = 20;
};

struct D : L, R {
    D() : V(3) { std::printf("D\n"); }
    int d = 30;
};

int main() {
    D obj;
    V& v = obj;
    std::printf("sizeof(D)=%zu\n", sizeof(D));
    std::printf("&obj=%p &L=%p &R=%p &V=%p\n",
                static_cast<void*>(&obj),
                static_cast<void*>(static_cast<L*>(&obj)),
                static_cast<void*>(static_cast<R*>(&obj)),
                static_cast<void*>(static_cast<V*>(&obj)));
    v.f();
}

Fordítás és futtatás a laborban:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o diamond_ok diamond_ok.cpp
./diamond_ok

A várt kimenet első sorai — illusztráció, nem mért eredmény — a V(3), L, R, D sorrendet követik, majd a sizeof és a négy cím, végül az L::f. A címek konkrét értéke target- és compilerfüggő; a példa csak azt mutatja, hogy a D eleje, az L subobject, az R subobject és a közös V subobject címe általában nem esik egybe. A sizeof(D) értéke sem hordozható, mert a virtuális bázis és a köztes subobjectek implementációs többletet hordoznak.

24.5.3 Ki építi a virtuális bázist?

A virtuális bázis inicializálására külön szabály vonatkozik. A most-derived object (leginkább származtatott objektum) az, amelynek dinamikus típusa a teljes objektum típusa. A virtuális bázist mindig a most-derived osztály inicializálja; a köztes osztályoknak a virtuális bázisra vonatkozó mem-initializer-ei figyelmen kívül maradnak, amikor az adott osztály nem most-derived.

A fenti példában a D a most-derived, ezért a D() : V(3) initializer fut, az L() : V(1) és R() : V(2) pedig nem. Ha a D konstruktorából törölnénk a V(3) inicializálót, a V default konstruktorára lenne szükség; mivel a V-nek csak explicit V(int) konstruktora van, a program nem fordulna. Ha lenne default konstruktor, az futna, és az L initializerét akkor is figyelmen kívül hagynánk.

Az indoklás — ez nem bizonyítás, hanem a szabály motivációja — a következő. A virtuális bázis egyetlen példányban létezik, és a pozíciója a teljes objektum típusától függ. Egy köztes konstruktor nem tudhatja, hogy ő most-derived-e, és azt sem, hogy a virtuális bázis hol helyezkedik el a végső layoutban. Ezért a felelősséget a leginkább származtatott osztály viseli. A konstrukció sorrendje: először a virtuális bázisok, mélységi, balról jobbra haladó sorrendben, majd a nem-virtualis közvetlen bázisok, ezután a tagok, végül a konstruktortest. A destruktorok fordított sorrendben futnak; a virtuális bázis destruktora a most-derived objektum destruktora után, utolsóként hívódik.

24.5.4 Kétértelműség és minősítés

Ha két testvér base ugyanazzal a névvel deklarál egy tagot, a származtatott típuson keresztül történő hozzáférés kétértelmű:

struct A { void f() {} };
struct B { void f() {} };
struct C : A, B {};

int main() {
    C c;
    c.f();      // hiba: kétértelmű
    c.A::f();   // OK: minősített hívás
}

A minősítés (c.A::f()) feloldja a kétértelműséget, de nem szünteti meg a két subobject létét. Alternatíva a using deklaráció a származtatott osztályban:

struct C : A, B { using A::f; };

Virtuális bázisnál a közös ős egyetlen példányban van, ezért a közös ősben deklarált tagok elérése önmagában egyértelmű. Ha viszont a két ág külön-külön deklarál azonos nevű tagot, az továbbra is kétértelmű lehet. A névfeloldás ilyenkor a dominancia szabályát is figyelembe veszi: a közelebbi, származtatottabb deklaráció elrejtheti a távolabbit. Ennek pontos részleteit a konkrét esetre kell ellenőrizni; a biztos megoldás a minősítés vagy a using deklaráció.

24.5.5 Pointer adjustment, thunk és a vtable mint ABI-példa

A nyelvi szerződés két dolgot garantál: a static_cast a megfelelő base subobjectre állítja a pointert, a dynamic_cast pedig a dinamikus típusból kiindulva adja vissza a kért base vagy derived pointert. A szabvány nem ír elő vtable-t, vptr-t, thunköt vagy konkrét offsetet. Ezek az ABI (application binary interface) szintjén jelennek meg, és implementációnként eltérhetnek.

A tipikus, x86-64 System V és Itanium C++ ABI mentén futó megvalósításban a nem-virtualis base-re konvertálás konstans offset hozzáadása; a virtuális bázisra konvertálás viszont futásidejű információt igényel, mert az offset a teljes objektum típusától függ. Sematikusan:

D* -> B*  (nem-virtualis):  cím = d_cím + const_offset(B, D)
D* -> V*  (virtuális):      cím = d_cím + vbase_offset(V, D)   // futásidejű

A vptr egy rejtett pointer a polymorphic subobjectben, amely a vtable-re mutat. A vtable tartalmazhatja a virtuális függvények pointereit, az RTTI-re mutató bejegyzést, valamint a virtuális bázis offsetjét. Ha egy virtuális függvényt egy másodlagos base vtable-jén keresztül hívunk, a bejegyzés egy thunkre mutathat: ez egy kis adapter, amely a this pointert a teljes objektum megfelelő pontjára igazítja, majd a valódi függvényt hívja. A thunk címe nem feltétlenül egyezik a valódi függvény címével.

Fogalom Nyelvi státusz Tipikus ABI-megvalósítás
vptr nincs a szabványban rejtett pointer a polymorphic subobjectben
vtable nincs a szabványban statikus tábla, RTTI-pointerrel
vbase offset nincs a szabványban vtable-ből vagy konstrukciós információból
thunk nincs a szabványban adjustor thunk a másodlagos vtable-ben
RTTI typeid, dynamic_cast nyelvi eszköz type_info objektum, vtable-hivatkozással

A dynamic_cast virtuális bázis esetén is működik, mert az RTTI tartalmazza a szükséges típusinformációt; a cross-cast (az egyik base pointerből a másikba) is erre épül. Ennek nyelvi hátterét RTTI, dynamic_cast és típusazonosság (24.4) tárgyalta. A konkrét Itanium ABI-leírást e jegyzet nem idézi; a könyv forrásjegyzékében megadott dokumentum verziója és a laborprofilban rögzített compiler az irányadó. A fenti mechanizmusok tehát illusztrációk, nem a szabvány által megkövetelt reprezentáció.

24.5.6 Alternatíva: kompozíció és type erasure

A diamond és a virtuális bázis gyakran elkerülhető kompozícióval. A két viselkedést külön tagként tartjuk, és delegálunk:

#include <cstdio>

struct Input {
    void write(const char* s) { std::printf("in: %s\n", s); }
    int in = 1;
};
struct Output { int out = 2; };

struct Duplex {
    Input input;
    Output output;
    void write(const char* s) { input.write(s); }
    int own = 3;
};

int main() {
    Duplex d;
    d.write("hello");
}

Itt nincs pointer adjustment, nincs virtuális bázis, és a delegálás explicit. Cserébe több egyszerű továbbító kód keletkezik, és a Duplex nem helyettesíthető be egy Stream& paraméterbe. Ha mégis egységes dinamikus interfész kell, a type erasure (típuseltüntetés) lehet a következő lépés: egyetlen absztrakt Stream interfész és std::unique_ptr<Stream> birtoklás, vagy std::function/std::any használata. Ezeket unique_ptr és egyedi tulajdonlás (21.3) és invoke, function wrapper és type erasure (30.2) részletezi majd.

A döntés szempontjai röviden: ha a közös viselkedés egyetlen tengelyen marad, a kompozíció egyszerűbb; ha a típusnak több, egymástól független szerződést kell teljesítenie, a többszörös interfész-öröklés indokolt lehet; ha a közös ős mégis megjelenik, a virtual öröklés vagy a kompozíció közül kell választani. A mély, sokágú hierarchia általános ajánlásként nem tárgya ennek a résznek.

24.5.7 Tipikus hibák és javításuk

A leggyakoribb hiba a virtual kulcsszó elhagyása a köztes base-en. Ettől a közös ős kétszer kerül az objektumba, a nevek kétértelművé válnak, és a közös állapot duplikálódik. A javítás a base-lista virtual jelölése, vagy a hierarchia átalakítása kompozícióvá.

A második gyakori hiba, hogy a köztes osztályban inicializáljuk a virtuális bázist, és azt hisszük, hogy az futni fog. Ha az adott osztály nem most-derived, az initializer figyelmen kívül marad; ezt a viselkedést a fenti V(1)/V(3) példa mutatta.

A harmadik hiba a reinterpret_cast használata base pointerek között. Ez nem végzi el a pointer adjustmentet, ezért rossz címre mutathat. A helyes eszköz a static_cast, illetve a dinamikus esetben a dynamic_cast.

További hibák: pointeraritmetika virtuális bázisra mutató pointerrel (a virtuális bázis nem teljes objektum, ezért az aritmetika nem definiált); delete virtuális bázis pointerén keresztül virtuális destruktor nélkül (ez általános szabály, lásd Virtuális destruktor, slicing és ownership (24.3)); slicing, amikor a származtatott objektumot base értékkel másoljuk; valamint a base subobjectek címének void*-ként való összehasonlítása objektumazonosság helyett.

24.5.8 Feladatok

  1. Szerkezeti magyarázat. A diamond_ok.cpp kimenetében miért V(3) az első sor, és miért nem V(1)? Mi történik, ha a D konstruktorából töröljük a V(3) inicializálót?

  2. Átalakítás kompozícióvá. Alakítsd át a diamond_ok.cpp hierarchiáját kompozícióvá, őrizd meg a write/f viselkedést, és hasonlítsd össze a sizeof értékeket. Indokold, miért nem hordozható a különbség.

  3. Diagnózis és javítás. A diamond_bad.cpp nem fordul. Adj három javítási stratégiát, és mindegyiknél mondd meg a szemantikai következményt: mit nyerünk, és mit veszítünk?

  4. ABI-megfigyelés. Írasd ki a static_cast<L*>(&obj), static_cast<R*>(&obj) és static_cast<V*>(&obj) címeket GCC-vel és Clang-gel, lehetőleg azonos optimalizálási szinten. Mit bizonyít ez a mérés, és mit nem?

24.5.9 Megoldások

24.5.9.1 Szerkezeti magyarázat

A D a most-derived objektum, ezért a virtuális V bázist a D konstruktora inicializálja; a D() : V(3) initializer fut, az L() : V(1) és R() : V(2) initializerek pedig figyelmen kívül maradnak. Ha a V(3)-at töröljük, a V default konstruktorára lenne szükség. Mivel a V-nek csak explicit V(int) konstruktora van, a program fordítási hibát ad. Ha lenne default konstruktor, az futna, és az L initializerét akkor is figyelmen kívül hagynánk. Ellenőrzési feltétel: a program -Wall -Wextra -Wpedantic mellett figyelmeztetés nélkül forduljon, és a kimenet első sora V(3) legyen; a törölt változat adjon fordítási hibát.

24.5.9.2 Átalakítás kompozícióvá

A Duplex példa szerinti megoldás: Input input; Output output; tagok, a write pedig delegál az input.write-ra. A V közös ős szerepét itt a Duplex saját write függvénye veszi át. A sizeof értékek összehasonlítása azt mutatja, hogy a kompozíció jellemzően kisebb és kiszámíthatóbb layoutot ad, de ez nem garantált: az EBO, a padding és az ABI mind befolyásolják. A mérés csak az adott compilerre és targetre érvényes; általános érvényű kijelentést nem lehet belőle levonni. Ellenőrzési feltétel: a write kimenete azonos legyen, és a program mindkét változatban forduljon.

24.5.9.3 Diagnózis és javítás

Az első javítás a virtual öröklés a két köztes base-en: struct L : virtual V {}; struct R : virtual V {};. Ettől egyetlen V subobject jön létre, a d.f() egyértelmű lesz, és a közös állapot nem duplikálódik. A második javítás a minősített hívás vagy using deklaráció: d.L::f() vagy struct D : L, R { using L::f; };. Ettől a hívás egyértelmű, de a két V subobject megmarad, tehát a V állapota duplikálódik, és a dynamic_cast/pointerkonverziók továbbra is a két subobject valamelyikére mutatnak. A harmadik javítás a kompozíció: nincs öröklési diamond, nincs pointer adjustment, de a D nem helyettesíthető be V& paraméterbe. Ellenőrzési feltétel: mindhárom változat forduljon; a második esetben ellenőrizzük, hogy a két V subobject címe különbözik.

24.5.9.4 ABI-megfigyelés

A mérés megmutatja, hogy az adott compiler és target hogyan helyezi el a subobjecteket, és milyen offseteket használ. Ez hasznos a laborprofil dokumentálásához, de nem bizonyít nyelvi garanciát: a szabvány nem írja elő sem a vtable-t, sem a konkrét offseteket. A két compiler eltérő eredményt adhat, és ugyanaz a compiler más optimalizálási szinten vagy más targeten is eltérhet. Az eredményt ezért a compilerverzióval, a target ABI-val és a fordítási kapcsolókkal együtt kell rögzíteni. Ellenőrzési feltétel: a program mindkét compilerrel forduljon, és a kapott címek/offsetek a laborjegyzőkönyvben dokumentálva legyenek.

24.5.10 Összefoglaló

A többszörös öröklésnél minden nem-virtualis base-hez külön base subobject tartozik; a static_cast végzi el a szükséges pointer adjustmentet. A diamond akkor keletkezik, amikor egy közös ős két útvonalon érhető el; virtual inheritance nélkül az ős kétszer szerepel, és a nevek kétértelművé válnak. A virtual base-lista egyetlen közös subobjectet hoz létre, amelyet a most-derived osztály inicializál; a köztes osztályok initializerei figyelmen kívül maradnak. A kétértelműséget minősítéssel vagy using deklarációval lehet feloldani. A vptr, vtable, vbase-offset és thunk ABI-szintű fogalmak, amelyeket a szabvány nem követel meg; a konkrét részleteket a laborprofilban kell ellenőrizni. A kompozíció és a type erasure gyakran egyszerűbb alternívát kínál, különösen akkor, ha a közös viselkedés egyetlen tengelyen marad.