24.4 RTTI, dynamic_cast és típusazonosság

Az előző részekben a dinamikus polimorfizmus arról szólt, hogy a hívás a dinamikus típus szerint oldódik fel: a hívónak nem kellett ismernie a konkrét osztályt. Most az ellenkező irányt vizsgáljuk: hogyan tud a program futásidőben rákérdezni a dinamikus típusra, és mikor szabad ezt egyáltalán megtenni. A futásidejű típusinformáció (run-time type information, RTTI) a C++ azon képessége, hogy bizonyos objektumokról futásidőben is megállapítható a legjobban leszármazott típus, illetve hogy egy bázisra mutató hivatkozás ellenőrzötten átalakítható legyen egy leszármazott vagy testvér típusra.

A rész vezérmondata: az RTTI nem tervezési alap, hanem célzott eszköz. Akkor indokolt, amikor a típuskészlet nyílt (plugin, eseménytovábbítás, szerializálás, diagnosztikai eszköztár), és a hívási helyen nincs jobb, típusbiztos mechanizmus. Ahol a típuskészlet zárt és fordítási időben ismert, ott a virtuális függvény, az overload vagy a std::variant + std::visit olcsóbb és kifejezőbb. Ahol viszont az RTTI-t használjuk, ott a sikertelenségi szerződést pointer és referencia esetén egyaránt pontosan kell kezelni — ez a rész fő gyakorlati mondanivalója.

A szabályok forrása a forrásjegyzék [C++20] tétele: [expr.typeid], [expr.dynamic.cast] és [type.info]. A fejezet két teljes programját (typeid_eval.cpp, rtti_demo.cpp) a lab-gcc profilban lefordítottuk és lefuttattuk; a kimeneteket a buildjegyzőkönyvek rögzítik.

24.4.1 Statikus típus, dinamikus típus és a polimorf osztály feltétele

A statikus típus (static type) a kifejezés deklarált típusa; fordítási időben ismert, és a fordító minden típusellenőrzést ezen végez. A dinamikus típus (dynamic type) annak az objektumnak a legjobban leszármazott típusa, amelyre egy glvalue (pointer vagy referencia által megnevezett objektum) ténylegesen hivatkozik. A kettő csak indirekción keresztül válik szét: Derived d; esetén nincs kérdés, Base* p = &d; esetén viszont p statikus típusa Base, dinamikus típusa Derived.

Dinamikus típusról csak polimorf osztály esetén beszélhetünk, azaz ha az osztálynak van legalább egy virtuális függvénye (saját vagy örökölt). Ez az RTTI első és leggyakrabban elrontott előfeltétele: nem polimorf bázison a typeid csendben a statikus típust adja vissza, a dynamic_cast pedig nem fordul le. A dinamikus típus fogalma alatt azt is érteni kell, hogy konstruktor és destruktor futása közben a dinamikus típus az éppen épülő vagy bontott osztály — ilyenkor egy leszármazottra irányuló dynamic_cast jogosan hiúsul meg.

24.4.2 typeid: a dinamikus típus lekérdezése

A typeid operátor a <typeinfo> fejlécben deklarált std::type_info osztály egy const lvalue-ját adja vissza, amely statikus tárolási idővel él. Két alapvető eset van:

// typeid_eval.cpp — C++20: mikor értékelődik ki a typeid operandusa?
#include <cstdio>
#include <typeinfo>

struct B { virtual ~B() = default; };   // polimorf
struct D : B {};

int f_calls = 0, g_calls = 0, h_calls = 0;

int f() { ++f_calls; return 1; }        // prvalue, nem polimorf
B& g() { ++g_calls; static D d; return d; }   // polimorf glvalue
D h() { ++h_calls; return D{}; }        // polimorf típusú prvalue

int main() {
    const std::type_info& t1 = typeid(f());   // statikus típus, nem kiértékelt
    const std::type_info& t2 = typeid(g());   // dinamikus típus, kiértékelt
    const std::type_info& t3 = typeid(h());   // prvalue: statikus típus, nem kiértékelt
    std::printf("f_calls=%d g_calls=%d h_calls=%d\n", f_calls, g_calls, h_calls);
    std::printf("t1==int:%d t2==D:%d t3==D:%d\n",
                t1 == typeid(int), t2 == typeid(D), t3 == typeid(D));

    B* p = nullptr;
    try {
        (void)typeid(*p);                     // polimorf glvalue null pointerből
    } catch (const std::bad_typeid&) {
        std::puts("bad_typeid");
    }
}

Buildjegyzőkönyv (lab-gcc profil, g++ -std=c++20 -Wall -Wextra -Wpedantic -O2). A kimenet: f_calls=0 g_calls=1 h_calls=0, majd t1==int:1 t2==D:1 t3==D:1, végül bad_typeid. A számlálók igazolják: csak a polimorf glvalue-t adó g() hívódott meg.

Ez a különbség a leggyakoribb félreértés forrása. Ráadásul pointer és pointerált objektum között is éles a határ: typeid(p) a Base* pointer típust adja, typeid(*p) a pointerált objektum dinamikus típusát — de csak akkor, ha Base polimorf, és p nem null. Ha p null, a typeid(*p) nem null-t ad, hanem std::bad_typeid kivételt dob, mert a * operátor eredménye egy null pointerre alkalmazott glvalue.

A std::type_info hasznos felülete: name() (implementációfüggő, jellemzően torzított/mangled név), operator==, before() és hash_code(), három különböző feladattal. Az == típusazonosságot dönt el. A before() egy implementáció által meghatározott teljes rendezés a típusokon (pontosan az egyik igaz: a.before(b) vagy b.before(a), ha a típusok különböznek); a sorrend nem kapcsolódik az öröklési viszonyhoz, és programfuttatások között sem garantáltan stabil. A hash_code() hash: azonos típusra ugyanazt adja egy futáson belül, különböző típusokra ütközhet, és nem rendezésre való. A name() sztring tartalma nem szerződés: két fordítási egység vagy két fordító eltérő szöveget adhat ugyanarra a típusra, ezért std::strcmp(typeid(a).name(), ...) helyett mindig a type_info egyenlőségét használjuk. C++20-ban csak a külön operator!= került ki a tagok közül (a != az ==-ből átírt jelöltként adódik); a before() megmaradt. Tárolókulcshoz a <typeindex> fejléc std::type_index burkolója való: a < operátora a before()-re, a std::hash specializációja a hash_code()-ra épül.

// rtti_order.cpp — C++20: before(), ==, hash_code() és type_index
#include <cstdio>
#include <typeindex>
#include <typeinfo>

int main() {
    const std::type_info& a = typeid(int);
    const std::type_info& b = typeid(double);
    bool ab = a.before(b), ba = b.before(a);     // C++20-ban is tagfüggvény
    std::printf("before: %d %d (pontosan egy igaz)\n", ab, ba);
    std::printf("==: %d, hash egyezik int-int: %d\n",
                a == typeid(int), a.hash_code() == typeid(int).hash_code());
    std::type_index ia{a}, ib{b};                 // < a before()-re épül
    std::printf("type_index <: %d\n", (ia < ib) == ab);
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic rtti_order.cpp figyelmeztetés nélkül fordul; a kimenet before: 0 1 (pontosan egy igaz), ==: 1, hash egyezik int-int: 1, type_index <: 1. Hogy az int vagy a double kerül-e előre, az implementációs részlet; csak az „egyik igaz” tulajdonság szerződés. A name() emberi olvashatóságához a GNU/Itanium környezetben abi::__cxa_demangle használható, de ez nem szabványos, ezért csak diagnosztikai kódban, nem szerződésként jelenhet meg.

Fontos: typeid(a) == typeid(b) típusazonosságot vizsgál, nem objektumazonosságot. Két külön objektum, amelyeknek azonos a dinamikus típusa, itt egyenlő; két bázispointer, amely ugyanarra a teljes objektumra mutat, typeid-dal nem hasonlítható össze értelmesen.

24.4.3 dynamic_cast: a sikertelenségi szerződés

A dynamic_cast<T>(v) előfeltételei: a cél (T) pointer vagy referencia teljes osztálytípusra (vagy void*); a forrásnak polimorfnak kell lennie, ha a konverzió futásidejű ellenőrzést igényel (leszármazottra alakítás, keresztkonverzió, void*). A futásidejű ellenőrzés ([C++20] [expr.dynamic.cast]/9) nem a teljes objektumból, hanem a forrás által mutatott részobjektumból indul, és két sikeres esetet ismer:

  1. Lefelé, a forrás részobjektumától. Ha a v egy C típusú objektum public bázis-részobjektumára mutat, és a legjobban leszármazott objektumon belül pontosan egy C objektum származik éppen ebből a részobjektumból, az eredmény arra a C-re mutat. Az egyértelműséget tehát a forrás részobjektumától felfelé nézzük: ha a teljes objektumban több C is van, de a mutatott részobjektum csak az egyiknek része, a cast sikeres.
  2. Keresztül a teljes objektumon. Ha az első eset nem alkalmazható, de a v a legjobban leszármazott objektum public bázis-részobjektumára mutat, és a legjobban leszármazott típusnak van egyértelmű és public C bázisa, az eredmény arra a C részobjektumra mutat.

Minden más esetben a cast sikertelen.

A két forma hibaszerződése gyökeresen eltér, és ezt nem lehet elégszer hangsúlyozni:

A dynamic_cast három tipikus esete:

// dynamic_cast_repeated_target.cpp — C++20
#include <cassert>
#include <cstdio>

struct X { virtual ~X() = default; };     // polimorf gyökér
struct B : X {};
struct L : B {};
struct R : B {};
struct M { virtual ~M() = default; };      // független polimorf bázis
struct D : L, R, M {};                      // D-ben két B részobjektum van

int main() {
    D d;
    B* bl = static_cast<L*>(&d);            // az L ág B-je
    B* br = static_cast<R*>(&d);            // az R ág B-je
    X* xl = bl;                              // az L ág X részobjektuma

    // (1) Lefelé a forrás részobjektumától: az xl-ből csak EGY B származik
    //     (az L ágé), ezért a cast sikeres, noha D-ben két B van.
    B* r1 = dynamic_cast<B*>(xl);
    assert(r1 == bl && r1 != br);

    // (2) Keresztkonverzió a teljes objektumon át: M-ből B-re a D-ben
    //     két B van, a cél nem egyértelmű, ezért nullptr.
    M* m = &d;
    assert(dynamic_cast<B*>(m) == nullptr);
    // M-ből L-re viszont egyértelmű, public bázis: sikeres.
    assert(dynamic_cast<L*>(m) == static_cast<L*>(&d));
    std::puts("dynamic_cast_repeated_target: OK");
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic dynamic_cast_repeated_target.cpp figyelmeztetés nélkül fordul, a kimenet dynamic_cast_repeated_target: OK: az L ág X részobjektumából indított cast az L ág B-jére mutat (1. eset), az M-ből indított B* cast nullptr (a 2. esetben a B nem egyértelmű), az M-ből L*-ra pedig sikeres.

A dynamic_cast<void*>(p) speciális forma: a legjobban leszármazott objektum címét adja vissza. Ez nem típus-, hanem teljes objektum azonosság vizsgálatára alkalmas: két különböző bázisaltérületre mutató pointer pontosan akkor ad azonos void*-ot, ha ugyanannak a teljes objektumnak az alterületei.

24.4.4 static_cast downcast: előfeltétel és kockázat

A static_cast bázisról leszármazottra fordítógenerált pointer-/referencia-igazítást végez, futásidejű ellenőrzés nélkül. Előfeltétele, hogy az objektum dinamikus típusa valóban a cél típusa vagy abból leszármazó legyen; ha nem az, a viselkedés nem definiált — nem „nullpointer”, nem kivétel, hanem csendben hibás memória-hozzáférés vagy értelmetlen tagelérés. Ezért a static_cast downcast csak akkor indokolt, ha az invariánst a program másutt már garantálja (például egy enum tag vagy egy korábbi ellenőrzött cast alapján), és ezt a garanciát kód-kommentár és teszt is rögzíti. A gyakori minta, hogy a teljesítménykritikus belső útvonalon egyszer dynamic_cast-tal ellenőrzünk, majd a belső ciklusban már static_cast-ot használunk — ez helyes, ha az objektum élettartama alatt a dinamikus típus nem változhat.

24.4.5 RTTI nélküli profil és a type_info a shared library határon

A GCC és a Clang is ismeri a -fno-rtti (MSVC-nél /GR-) kapcsolót, amellyel az RTTI-generálás kikapcsolható. Ez nem szabványos nyelvi mód, hanem implementációs profil: a dokumentáció szerint ilyenkor a polimorf dynamic_cast és a dinamikus typeid nem használható, a kivételkezelés viszont továbbra is működik, mert ahhoz a fordító szükség szerint maga állít elő típusinformációt. A pontos korlátozás és a hibaüzenet compiler-verziófüggő, ezért ezt a laborprofilban fordítási próbával kell rögzíteni; e nélkül a „kikapcsolt RTTI” viselkedéséről csak annyi állítható biztosan, hogy nem hordozható, standard programra nem támaszkodhatunk vele.

A type_info azonossága megosztott könyvtár (DSO) határon implementáció- és ABI-függő kérdés. A típusinformáció a dinamikus típusokat leíró szimbólumokhoz kötődik; ha két DSO ugyanazt az osztályt külön fordítja, a type_info példányok egyesítése a loader és a könyvtár együttműködésén múlik. A libstdc++ egyes konfigurációkban nevek összehasonlításával dönt az egyenlőségről, más esetekben (például rejtett láthatóság, -fvisibility=hidden, eltérő burkolás) ez nem működik, és a dynamic_cast vagy a typeid-egyenlőség a határon meghiúsulhat. Következmény: a typeid-azonosságot soha ne használd protokollként DSO- vagy FFI-határon. Ott a stabil megoldás egy saját, verziózott típusazonosító (sztring, szám vagy enumerátor) az interfészben, amelyet mindkét fél ismer, és amely nem függ a C++ ABI belső szerkezetétől. Kapcsolódó élettartam-csapda: a typeid eredményére vett const std::type_info& addig érvényes, amíg a típust leíró kód be van töltve; plugin kirakodása után a referencia lógó lesz, ezért a típusinformációt nem érdemes a plugin élettartamán túl tárolni.

24.4.6 Kidolgozott példa: eszközregiszter persistálható alterülettel

A példa egy Device hierarchiát és egy Persistable mellékhierarchiát használ; a SmartGauge mindkettőből származik, így a Device*-ból Persistable*-ra alakítás keresztkonverzió.

// rtti_demo.cpp — ISO C++20 példa; a kimenet implementációfüggő részt tartalmaz
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
#include <typeinfo>
#include <utility>
#include <vector>

class Device {
public:
    virtual ~Device() = default;
    virtual std::string_view name() const noexcept = 0;
};

class Persistable {
public:
    virtual ~Persistable() = default;
    virtual std::string serialize() const = 0;
};

class Gauge : public Device {
public:
    explicit Gauge(std::string n) : name_(std::move(n)) {}
    std::string_view name() const noexcept override { return name_; }
private:
    std::string name_;
};

class SmartGauge final : public Gauge, public Persistable {
public:
    using Gauge::Gauge;
    std::string serialize() const override { return "gauge:" + std::string{name()}; }
};

// Nem tulajdonol: a hívó felel a d élettartamáért.
void persist_if_possible(Device* d)
{
    if (d == nullptr) {                       // null és "nem ez a típus" szétválasztása
        std::cout << "null device\n";
        return;
    }
    if (auto* p = dynamic_cast<Persistable*>(d)) {   // cross-cast, pointer forma
        std::cout << "persist: " << p->serialize() << '\n';
    } else {
        std::cout << "skip: " << d->name() << '\n';
    }
}

void persist_or_report(Device& d)             // referencia forma: bad_cast a hiba
{
    try {
        Persistable& p = dynamic_cast<Persistable&>(d);
        std::cout << "persist(ref): " << p.serialize() << '\n';
    } catch (const std::bad_cast& e) {
        std::cout << "bad_cast: " << e.what() << '\n';
    }
}

int main()
{
    std::vector<std::unique_ptr<Device>> devices;
    devices.push_back(std::make_unique<Gauge>("g0"));
    devices.push_back(std::make_unique<SmartGauge>("g1"));

    for (auto& d : devices) {
        persist_if_possible(d.get());
        persist_or_report(*d);
        std::cout << "typeid(*d).name() = " << typeid(*d).name() << '\n';
        std::cout << "pontosan SmartGauge? " << (typeid(*d) == typeid(SmartGauge)) << '\n';
    }
}

Fordítás és futtatás a tanuló laborjában:

g++     -std=c++20 -Wall -Wextra -Wpedantic -O2 rtti_demo.cpp -o rtti_demo && ./rtti_demo
# vagy
clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 rtti_demo.cpp -o rtti_demo && ./rtti_demo

Buildjegyzőkönyv (lab-gcc profil). A GCC-paranccsal a program figyelmeztetés nélkül fordult, és a következő viselkedést adta: g0 esetén a pointer forma skip: g0-t ír, a referencia forma bad_cast-ot kap el, a typeid-egyenlőség hamis; g1 esetén mindkét forma sikeres (persist: gauge:g1, persist(ref): gauge:g1), és a typeid-egyenlőség igaz. A name() szövege a lab-gcc profilban 5Gauge, illetve 10SmartGauge (Itanium ABI szerinti torzított név), de ez nem szerződés: a fordító és a C++ könyvtár dönti el. A Clang-sort nem ellenőriztük.

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

24.4.8 Összefoglaló

A dinamikus típus fogalma csak polimorf osztályokra értelmezhető. A typeid polimorf glvalue-n a dinamikus, minden más esetben a statikus típust adja, eredménye const std::type_info&, amelynek name()-je nem szerződés. A dynamic_cast pointer formája nullptr-rel, referencia formája std::bad_cast-tal jelzi a sikertelenséget; a pointer forma null bemenetre is null-t ad, a referencia forma null pointerre nem definiált. A cross-cast többszörös öröklésnél a dynamic_cast egyedi képessége, a static_cast downcast pedig csak bizonyított invariáns mellett biztonságos. A kikapcsolt RTTI és a type_info DSO-határon átnyúló azonossága nem hordozható, implementációfüggő terület, ezért interfész-szerződés nem épülhet rá.

24.4.9 Feladatok

  1. Magyarázó. Adott: struct Base { void f(); }; struct Derived : Base { void f(); }; Derived d; Base* b = &d; Mi lesz a typeid(*b) == typeid(Derived) értéke, és miért? Mit változtat, ha a Base::f virtuális?

  2. Kódoló. Írd meg az alábbi függvényt, amely a keresztkonverzióval összegyűjti a persistálható alterületeket; az eredmény ne tartalmazzon null elemet:

    std::vector<Persistable*> collect_persistable(
        const std::vector<std::unique_ptr<Device>>& in);

    Készíts referencia-formás változatot is, de csak ott, ahol a kivétel terjedése megengedett.

  3. Diagnosztikai. Az alábbi kód lefordul, de futás közben leáll: void log(Device& d) noexcept { auto& p = dynamic_cast<Persistable&>(d); std::cout << p.serialize(); }. Magyarázd meg a hibát, és javítsd két különböző szerződés mentén (nem dobó és dobó változat).

  4. Nehezebb. Két vektorban Device* és Persistable* pointerek vannak ugyanazokra az objektumokra. Hogyan párosítanád őket teljes objektum szerint, és miben más ez, mint a typeid szerinti csoportosítás?

24.4.10 Megoldások

1. megoldás. Nem polimorf Base esetén a typeid(*b) a statikus típust, azaz Base-t adja, ezért az összehasonlítás hamis — pedig az objektum valóban Derived. Ha a Base-nek van virtuális függvénye, a *b polimorf glvalue, a typeid a dinamikus típust adja, és az összehasonlítás igaz lesz. Ellenőrzési feltétel: a program mindkét változatban forduljon le, és a második esetben a kiírt érték 1 legyen; a diagnosztika indoklása nevezze meg a „polimorf osztály” feltételt.

2. megoldás. A pointer forma természetes, mert a „nincs ilyen alterület” nem hiba:

std::vector<Persistable*> collect_persistable(
    const std::vector<std::unique_ptr<Device>>& in)
{
    std::vector<Persistable*> out;
    out.reserve(in.size());
    for (const auto& d : in) {
        if (d == nullptr) continue;
        if (auto* p = dynamic_cast<Persistable*>(d.get())) out.push_back(p);
    }
    return out;
}

A referencia-formás változat csak ott használható, ahol a bad_cast elkapható:

for (const auto& d : in) {
    if (!d) continue;
    try {
        out.push_back(&dynamic_cast<Persistable&>(*d));
    } catch (const std::bad_cast&) {
        // nincs Persistable alterület: kihagyjuk
    }
}

Mindkét változat nem tulajdonol: a visszaadott pointerek érvényessége a bemeneti vektor és a mögötte lévő objektumok élettartamához kötött, azt a hívó köteles betartani. Ellenőrzési feltétel: a kimenet hossza egyezzen a persistálható objektumok számával, ne tartalmazzon nullptr-t, és a függvény ne módosítsa a bemenetet.

3. megoldás. A log noexcept, a dynamic_cast<Persistable&> viszont std::bad_cast-ot dob, ha az objektumnak nincs Persistable alterülete. A kivétel egy noexcept függvényből kifelé nem terjedhet, ezért a fordító std::terminate-et hív. Nem dobó javítás: pointer forma, null-ellenőrzéssel, és a hiányzó alterület naplózása külön ágban. Dobó javítás: a noexcept eltávolítása, a try/catch (const std::bad_cast&) bevezetése, és a hibapolitika dokumentálása (naplózás vagy továbbdobás). Ellenőrzési feltétel: a nem dobó változat Gauge-ra is és SmartGauge-ra is lefut, nem hív terminate-t, és a nem dobó volta static_assert(noexcept(...))-tal vagy dokumentált indoklással alátámasztható.

4. megoldás. Teljes objektum szerinti párosításhoz dynamic_cast<void*> való: két pointer pontosan akkor mutat ugyanarra a teljes objektumra, ha a castolt void* értékük azonos (ehhez mindkét oldalon polimorf típus kell, és a pointerek nem nullák). Ezt használhatod kulcsként egy std::unordered_map<void*, ...>-ban, de figyelj rá, hogy a kulcs csak az objektum élettartama alatt érvényes: felszabadítás után cím-újrafelhasználás miatt hamis egyezés lehetséges. A typeid szerinti csoportosítás ezzel szemben típust azonosít, nem objektumot: két külön SmartGauge példány egy csoportba kerül, és a pointer-igazítás miatt a Device* és Persistable* értékek összehasonlítása önmagában félrevezető. Ellenőrzési feltétel: a párosítás pontosan a közös objektumokra adjon egyezést, a typeid-alapú csoportosítás pedig adott esetben több objektumot vonjon össze — a két eredmény különbözősége a helyes értelmezés bizonyítéka.