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.
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ó |
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_okA 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.
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.
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ó.
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ó.
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.
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.
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?
Á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.
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?
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?
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.
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.
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.
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.
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.