Figyelem, a senki ne próbálja ki otthon a következőkben látottakat. Az itt bemutatott kódrészleteket mentálisan (nem) felkészült varázslók, sok órán keresztül készítették. Házilag készített programokban kerülendő minden, ami ezen az oldalon található.

Mindenki látott már olyat, esetleg járt már maga is úgy, hogy a program tervezése közben a különféle mérnöktudományok alkalmazói beépítik magukat egy olyan sarokba, ahonnan már nem tudnak épeszű eszközökkel kimászni. Ilyenkor keletkeznek olyan megoldások, amiket a fehér járolapkra nem lépő kisgyerekek produkálnak, olyan helyen, ahol a fekete járólap véget ér: hosszú távon semmi képpen sem működőképes tákolmányok, mint a legközelebbi felnőttel arrébrakatni magukat. De hát ha belegondolunk, a Win32 subsystemtől, X11-en keresztül, <oprendszer> bluetooth implementációjáig, mind a mai napig használt programok, amikre ha újdonságok lennének mindenki azt mondhatná, hogy ezek hosszú távon egyáltalán nem működtethető tákolmányok.

Ez a cikk egy ilyen szituációt ír le.

A probléma

Mielőtt nekilátnánk a különféle trükkök bemutatásához, kerüljön ismertetésre a probléma.

Kontextus

Az egész szituáció a Prog2 tárgy nagy házi feladatának elkészítése közben történt. Az ajánlott feladatok közül (Prog2 HF ötletek) a "Konfiguráció beolvasó" című feladatot választottam, mert relatíve egyszerűnek tűnt, illetve szinte mindig könyvtárakat írok, így ez elég jól kezelhető volt. Emellett a feladat megengedte a szabányos tárolókat és algoritmusokat, amiket nem sok kedvem lett volna újraimplementálni.

Az egy “nehézsége” a feladatnak, hogy adott T típusra a beolvasott és sztringként tárolt adatokat T objektummá kell konvertálni. Bár a feladat specifikálta, hogy elegendő, olyan T típusokra működni, amelyekre értelmezett a operator>>(std::istream&, T&) függvény, de nyilván ettől eltekintünk, mert tudjuk, hogy az iostream interface amúgy is lassú, így csak olyan esetekben használatos, ahol a nagyon könnyű bővíthetőség fontos. Itt például az lett volna, de hát ugye rendesen végiggondolni a követelményeket a gyengéknek való, befutni a sarkoba, hogy utána körbeépítse magát az ember az igazi csanád lépés.

A (feltételezett) implementáció

Majdnem minden, ami a következőkben található, lecserélhető három függvénnyel. Az tárolt objektumot T típusként lekérdező tagfüggvény (config::get_as<T>(std::string_view key)), és ként konverziós függvény, egy, ami működik, egy pedig, ami a jobb hibaüzeneteket szolgálja. Ez utóbbi kettő lecserélhető egy egyszerűbb függvényre, ha úgy gondoljuk, hogy a compiler hibaüzenetei elegendőek a be-krokodil operátort nem támogató fordítási hibák orvoslására.

Szükség van még egy egyszerű segédosztályra, de ez tényleg olyan egyszerű, mint a faék:

template<class>
struct fail {
    constexpr const static auto value = false;
};

Ez az osztály látja el azt a feladatot, hogy a fordító ne legyen túl okos. Mivel a static_assert opererátort fogjuk használni a hibaüzenet generálására, így kell egy olyan osztály, ami indirekciót biztosít a static_assert és a false érték között, hiszen static_assert(false); minden esetben azonnali fordítási hiba.

Így már egyszerűen implementálható a két parseoló függvény, ebből pedig a get_as<T>.

A sikertelen parseolás egyszerűbb:

template<class T>
struct obj_parser {
    constexpr static T parse_obj(std::string_view) noexcept {
        static_assert(fail<T>::value,
                      "Given type T, used in config::get_as<T> cannot be parsed from a string. "
                      "Implement the operator>>(std::istream&, T&) function.");
    }
};

A sikeresnél két megoldás létezik. C++20-as szabvánnyal megkaptuk a concept kulcsszót, ami a template metaprogramozást jelentősen könnyíti, így a következő módon pár sorból implementálható a függvény. A másik implementációt most nem tárgyaljuk, mert nem ez a fő célja ennek a cikknek, csupán összehasonlítás céljából kerül bemutatásra az “elvárt” megoldás. Emellett jelentősen komplikáltabb.

template<class T>
concept in_readable = requires (T obj) {
    { std::istringstream{} >> obj } -> std::common_reference_with<std::istream&>;
};

template<in_readable T>
struct obj_parser<T> {
    static T parse_obj(std::string_view data) { (1)
        std::istringstream ss(std::string(data.data(), data.size()));
        T obj;
        ss >> obj;
        return obj;
    }
};
1 A noexcept kifejezés jelentősen elkomplikálná a példát így lekerült a példáról, főleg, hogy a szabvány szerint az std::basic_istringstream<…​> konstruktora nem noexcept, így ennek megfelelő implementációk esetén, nem lehetne a függvény ténylegesen noexcept soha.

Ezzel már tényleg triviális a get_as<T> függvény implementálása.

template<class T>
T get_as(std::string_view key) {
    const auto str_val = get(key); (1)
    return impl::obj_parser<T>::parse_obj(str_val);
}
1 get(std::string_view) itt a sztring értékként visszatérő konfigurációt kereső tagfüggvény.

A tényleges probléma

Az előzőekben bemutatottak szerint, a feladat nem lett volna egetrengetően nehéz. Mégis létezik ez a cikk, tehát valami történt, különben szóra se lenne érdemes az egész helyzet.

Mint mindenki, aki csinált Progn (\$n in NN\$) nagy názi feladatot tudja, hogy, amikor a specifikációt írja az ember, sikerül szinte biztosan valami olyat beleírni, amivel utána szív az implementáláskor. Ez ebben az esetben a cachelés. Gondolta bodand, végtelen IQ-jával, hogy, mi lenne akkor, ha minden T-re lekérdezéskor a konstruált T objektumot elmentené a könyvtár, így nem lenne szükség újrakonstruálásukra. Utólag, igazán jó ötlet, meg kell mondjam…​

Ebből következően el kellett mentenem ismeretlen T típusú objektumokat, úgy, hogy egy tárhelyen (egy config objektum cache mezőjében) az érték változhat, mivel a cache mindig az utolsó lekérdezett típusú objektumot tárolja.

És itt kezdődi a szaga a sebességért.

A klasszikus visitor pattern

Az alap felállás, hogy létezik egy cache ősosztály, ami minden megfelelő típusnak tárolja az adatait, a számára megfelelő helyen, a gyerekosztályon belül. Például, az alábbi részlet az int értékeket tárolja.

struct int_cache : visitable_cache<int_cache> {
    int_cache(int&& data) noexcept : _data(std::move(data)) { }

    const int*
    get_value_ptr() const { return &_data; }

private:
    int _data;
};

A visitable_cache segédosztály, ami a későbbi látogatók fogadását implementálja. Teljes mértékben egyenértékű, mintha manuálisan került volna a látogató fogadó kód implementálásra, csak megcsinálja automatikusan.

Ez így nagyon szép, és nagyon jó, viszont így két problémával állunk szemben. Nem tudjuk, hogy az adott típus, amit a cache tárol megfelelő-e, a most lekérdezett T típusnak, illetve ha tudjuk is, C++-ben nem létezik minden típus által osztott közös fő-ős (mint, pl. Java Object), így nincs megfelelő interfaceünk a különböző cache osztályok implementálására.

A második problémára létezik egy már régóta ismert megoldás: amikor különböző osztályokkal kell dolgozni, amiket tudni kell feldolgozni egy helyen, de annyira különböznek, hogy nem érdemes külön közös ősbe felemelni a tagfüggvényeket, akkor jönnek be a látogatók (Visitor). Ezek még a GoF könyvben definiált visitor-pattern-nek az implementációja, ami egy közös ős létrehozását igénybe veszi, viszont nem kell ennek mást csinálnia, csak egy látogató fogadó függvényt biztosítania.

Lássunk egy gyors példát.

struct my_type; (1)
struct your_type; (1)

struct visitor_base {
    void visit(my_type& my) { /*...*/; } (2)
    void visit(your_type& your) { /*...*/; } (2)
};

struct base {
    virtual void accept(visitor_base&) const = 0;
};

struct my_type : base {
    void accept(visitor_base& vtor) { vtor.visit(*this); } (3)
    /*...*/;
};
struct your_type : base {
    void accept(visitor_base& vtor) { vtor.visit(*this); } (3)
    /*...*/;
};
1 A visitor pattern implementálásakor meg kell kötnünk az osztályokat, amikre érvényes a visitor. Ez szükséges, hiszen a visit tagfüggvény overloadjait előre létre kell hozni.
2 Az aktuális implementációt, olyan nyelvekben, mint a C++, szükség van külön a forrás (nem fejléc) fájlban való implementálásra, és a teljes típus #includálására. Ezt nem lehet a fejlécben megtenni, különben körkörös #include láncot kapunk.
3 Az implementálás minden leszármazott osztálynál pont ugyan így néz ki. A fent látott visitable_cache ezt a copy-pastelt kódrészletet szűnteti meg.

Az előbbi a klasszikus implementációja a visitor patternnek. Ez lehetővé teszi számunkra, hogy minden cache osztály egy saját get_*ptr függvényt biztosítson, anélkül, hogy egy közös ősbe kellett volna integrálni az összes get*_ptr függvényt, és minden nem megfelelő osztálból nullptr-t visszaadni a sok fölölsleges függvényből.

Miért nem csak void*?

Mert a void* minden létező típusinformációt eldob, így semmi hasznosat nem lehet már utána megtudni róla. Emiatt fontos, hogy ha szükség is van void* használatára, azt minél jobban leszükített környezetben történyjen. Jelenleg is van szükség rá, mellesleg, viszont mélyen elrejtve a dinamikus visitor implementációjában. Így nincs a felhasználó szemében, aki így nem tud vele rossz dolgokat csinálni; hiszen saját típusra a cache-t neki kell megírnia, így a void* interface a felhasználóra lenne bízva. Ez minden esetben rossz, mert ugye a felhasználó nem tudja, mit csinál. Soha. (Mi sem, de az nem számít.)

Van azonban egy probléma a klasszikus implementációval. Mivel előre kell ismerni az összes olyan osztályt, amit látogatunk, így az osztályok halmaza egy zárt halmazt alkot: nem lehetséges új osztály hozzáadása minden létező visitor módosítása nélkül, hiszen az őslátogatót kell módosítani.

A mi szitációnkban azonban a látogatandó osztályok halmaza nyitott, hiszen a felhasználónak csinálnia kell új osztályokat, ha nem csak a beépített típusokat szeretné elcachelni.

A dinamikus visitor naívan

Itt jön be dinamikus visitor: úgy kell implementálni egy visitor mintázatot, hogy ne kelljen lezárni az osztályok halmazát.

Gondol, gondol, gondol.

A kézenfekvő megoldás, amit elsőre megötlöttem, hogy csak szimplán dynamic_cast-al egyszerűen lehet implementálni egy ilyen problémát, hiszen egy hierarchiában kell átcastolni máshová a dolgokat. Pont, amire dynamic_cast való.

Gyorsan össze is lett állítva az első implementáció.

Az előzőekben látott visitor_base-t ki kellett cserélni egy olyan típusra, ami nem csinál semmit, pusztán egy visitor visitorságát jelzi.

struct visitor_base {
    virtual ~visitor_base() = default;
};

Ebből lehetett továbbörökölni a megfelelő osztályokat látogatni képes visitor_impl osztály sablont. Mivel így minden típusra létezik külön visitor_impl, így nem kell előre ismerni az összes típust, csak akkor, amikor az osztály sablont instanciáljuk. Bár ez nem tűnik sokkal jobb helyzetnek, pedig az. A visitor, ami szeretne egy osztállyal csinálni valamit, annak mindenképpen ismernie kell egy adott osztályt, így nem adunk hozzá plusz ismeretségi feltételt a kódunkba, csak olyanokat használunk, amelyek már léteznek.

Ezzel tehát, egy adott osztály meglátogató visitor implementáló osztály sablon:

template<class T>
struct visitor_impl : virtual visitor_base {
    virtual void do_visit(T&) = 0;

protected:
    ~visitor_impl() override = default;
};

Innen pedig már egy egyszerű lépés egy olyan visitort csinálni, ami adott T…​ típusokat képes meglátogatni.

template<class... Ts>
struct visitor : visitor_impl<Ts>... {
    ~visitor() override = default;
};

Ez eddig szép és jó, de nem oldotta meg teljesen a problémánkat. A visitor objektumok már tudnak örökölni visitor<T, U, V, W> osztályból, így tempalte metaprogrammingal állíthatóak, hogy mely osztályokat tudják meglátogatni.

A probléma még a fogadói oldalon áll fenn. Az accept függvény, ami eddig pusztán *this-t adott a paramétere egy tagfüggvényének nem működhet tovább, hiszen frakturáltuk a visitorok hiearchia fáját. Míg eddig minden visitor_base-en keresztül történt, már nem tudjuk ezt megoldani, hiszen a do_visit függvény már, típushelyesen, a különböző visitor_impl osztályokban található.

Erre megoldás a dynamic_cast. Minden visitort megpróbálunk lecastolni a megfelelő visitor_impl<T> osztállyá. Akármennyire barbár megoldás ez, nem mutatokozott jobb ötlet az egész nyitott osztályhalmazt támogató látogatókra. Úgyhogy oda nem nézve, sarokba szorulva, a következő kód született:

template<class Base, class T>
struct visitable_as : Base {
    using Base::Base;

    void accept(visitor_base& dyn_vtor) override {
        if (auto vtor = dynamic_cast<visitor_impl<T>*>(&dyn_vtor)) {
            auto self = static_cast<T*>(this);
            assert(self);

            vtor->do_visit(self);
        }
    }

    ~visitable_as() override = default;
};

Ez a kód tette lehetővé, hogy egy látogatható osztály hierarchia tagjai “belépjenek” a látogathatóságba. Az öröklési láncba beinjektálva ezt az osztályt, a hierarchia alapjában adott accept virtuális függvényt “el tudtuk lopni” a leszármazott osztálytól, és úgy implementálni, hogy, ha a látogató képes rá, akkor a megfelelő osztályra típushelyesen meghívódjon a do_visit tagfüggvény.

Ez az osztály igazából nem feltétlen szükséges, hiszen teljes mértékben implementálható minden osztályban a logika, viszont így a felhasználó hierarchiának nem felelőssége a tőleg független látogató rendszer kezelése, csak el kell szenvednie a látogatást. Ezen felindulásból a tényleges kódban létezik egy visitable_hierarchy osztály is, amiből az ősosztály örökölhet, így neki sem kell még az accept függvénnyel foglalkoznia. Itt ez helyspórolás célokból kivágásra került.

struct base {
    explicit base(int a)
          : _a(a) { }

    virtual void accept(visitor_base& vis) = 0;
    virtual ~base() = default;

private:
    int _a;
};

struct der1 : visitable_as<base, der1> {
    der1()
          : visitable_as<base, der1>(1) { }
};

struct der2 : visitable_as<base, der2> {
    der2()
          : visitable_as<base, der2>(2) { }
};

struct der3 : visitable_as<base, der3> {
    der3()
          : visitable_as<base, der3>(3) { }
};
A következő implementációk során a hierarchia felé felállított interface nem változik, az előző kód, tehát minden esetben érvényes.

A próba program lefordult. Nagy boldogság következett. Viszont volt egy zavaró dolog a történetben; a kód dynamic_cast lecastoló képességeire hagyatkozott, és bár azon állásponton vagyok, hogy dynamic_cast minden esetben egy design problémát takar, nem volt hirtelen jobb ötlet, kikerülni. Az igazi motivációt dynamic_cast megkerülésére valami más adta: az Intel VTune Profiler.

Sebesség

Fontos tudni, hogy dynamic_cast nem csak lefele castolást tesz lehetőve, hanem gyakorlatilag bárhová az osztályhierarchiában. Ez viszont irgalmatlan lassúvá teszi szegényt. Így, ha “csak” lecastolni kell, jelentős sebességbeli problémákat tud fog okozni. Ezt tudva, csak mértékét nem ismerve ellenőrzés céljából VTune-ra lett bízva, hogy vizsgálja meg a kódot.

A következő egy kép egy tesztprogram hotspotjairól, ami sokszor elvégzett egy 3+1 tagú hierarchiában egy látogatást.

dynamic cast

No lám, no lám. Majdnem az egész runtimeot a dynamic_cast futásideje határozza meg, minden más gyakorlatilag elhanyagolható futásidővel rendelkezik.

Ez elég meggyőző szerintem mindenki számára, hogy ebben a pillanatban minden dynamic_cast-ot használó kódot refaktoráljon, hogy véletlenül se hívódjon meg dynamic_cast.

Dinamikus visitor, dynamic_cast nélkül

Akkor vissza a tervezőasztalhoz. Ha nem dynamic_cast, akkor mi legyen, hogy a dinamikusságot meg tudjuk tartani. Ideálisan semmilyen *_cast-ra nincs szükség, minden pont úgy típusos, hogy nem kell erőszakosan konvertálgatni innen-oda 's vissza. Viszont ez a probléma már úgy is olyan tákolt szituációt állított elő nekünk, mint a sarokban már egy lábon álló informatikusoknak, hogy a utópikis castolásmentes világképet nyugodtan eldobhatjuk. Marad hát a static_cast, ami, hát, hogy is mondjam, statikus. Egyszerűen csak a fordítási időben játszik szerepet, hogy a fordító boldog legyen olyan dolgokkal, amik amúgy is a futásidőben meg tudnának történni.

Első lépés, hogy valamilyen futásidejű típusinformációra van szükség. Szerencsére (vagy nem, mindenki döntse el magának) a szabvány biztosítja számunkra a <typeinfo> fejlécet. Ezzel használhatóvá válik a typeid operátor, amivel egy adott típust lehetséges (implementation defined formátumú) futásidőbeli információvá változtatni.

Ezzel el tudjuk csúsztatni az implementációt, hogy megcsináljuk azt a részét a dynamic_cast-nak, amire szükségünk van. Ehhez visitor_base visszakapja a visit függvényét, ámbár most már függvény sablon formájában és egyszerűen csak delegálja a munkát egy másik tagfüggvénynek. Utóbbi azonban típusmentes, és virtuális, lehetővée teszi számunkra, hogy normál virtuális függvényként kezeljük és a megfelelő utódban fölülírjuk.

struct visitor_base {
    template<class T>
    void visit(T* visited) {
        visit_imp(static_cast<void*>(visited), typeid(T));
    }

    virtual ~visitor_base() = default;
protected:
    virtual void
    visit_imp(void*, const std::type_info&) = 0;
};

A void* paraméter C++ kódban általában nagyon gyanús, de mindenképpen jobb, mint dynamic_cast. Itt azonban felhasználjuk, hogy az objektumot a futásidőben szétválasszuk: void* a memóriabeli helyéből, és std::type_info a típusinformáicóból. Ebből a két adatból képesek leszünk újrakonstruálni az objektumra mutató pointert, amikor szükség lesz rá.

A másik lépés már csak annyi, hogy a visit_imp függvényt implementáljuk. Ezt a visitor osztály sablonban fogjuk megtenni, békénhagyva a visitor_impl osztály sablont.

Elsőként, definiálunk egy olyan tagfüggvényt, ami megnézi egy darab típusra, hogy azonos-e a jelenleg ellenőrzött T típus a valdi objektum típusával, és ha igen, meglátogatja a megfelelő visitor_impl<T> használatával. A függvény egyszerűen csak összehasonlítja a megadott T típus std::type_info-ját a paraméterként kapott std::type_info-val, ami ha egyezik T-vel azonos típusú objektum található a void* mögött. Ha talált egy megfelelő típust, igazat, különben hamisat ad vissza. Ez a tulajdonsága még hasznos lesz.

template<class... Ts>
template<class T>
bool visitor<Ts...>::visit_one_t(void* typeless_visited, const std::type_info& tid) {
    if (typeid(T) != tid) [[likely]] return false; (1)
    static_cast<visitor_impl<T>*>(this)->do_visit( (2)
        static_cast<T*>(typeless_visited)); (3)
    return true;
}
1 A C++20-ból származó elágazásbecsló attribútum. Nincs különösebb jelentősége, csak jelezzük vele a fordítónak, hogy ez az ága az elágazásnak a valószínűbb kimenetele. Ő pedig majd csinál vele, amit szeretne.
2 Itt már, az előző megoldással szemben felfele castolunk. Ez jeletősen jobb, hiszen felfele mindig biztonságos castolni⁠[1], és fordítási ídőben meg tud történni, hiszen igazából nem történik semmi. És mivel itt már tudjuk, hogy melyik visitor_impl anyaosztállyá kell átváltoznunk, egyszerűen felkonvertáljuk magunkat.
3 Ez a static_cast csak szimplán a void*-ból visszanyeri azt a típusos mutatót, amivel eredetileg is rendelkeztünk.

A visit_imp függvényt pedig ezzel a függvénnyel triviális implementálni. Egyszerűen minden támogatott típusra (Ts…​) meg kell nézni, hogy azonos-e a jelenleg vizitálandó típussal, és ha igen meg kell látogatni. Mivel Ts…​ egy parameter pack, így egy fold-expressionnel (nem szeretném lefordítani) egyszerűen össze lehet egymás után láncolni az összes ellenőrzést, minden fordítási idő generáló sablonrekurzió és egyéb trükkökk nélkül:

template<class... Ts>
void
visitor<Ts...>::visit_imp(void* typeless_visited, const std::type_info& tid) final {
    (visit_one_t<Ts>(typeless_visited, tid) || ...);
}

A rövidzár tulajdonságára hagyatkozva a || operatáronak, ez addig fogja probálgatni a típusokat, amíg talál megfelelőt, vagy el nem fogynak.

Ezzel gyakorlatilag implementáltuk dynamic_cast azon részét, amire szükségünk van, minden más fölösleges dolog nélkül. Ráadásul, mivel van több információnk, mint a teljesen általános dynamic_cast-nak, így nem szükséges még ciklusokat se beépítenünk, amelyekből több is van a dynamic_cast belsejében (leglábbis VTune azt mondja, hogy ott vannak ciklusok).

Emellett, még a visitable_as kódja is módosul, a jó irányba, egyszerűsödik. Mivel eddig a legfelső pointert kaptuk a hierarchiában és a megfelelő típust meg kellett keresni, hogy abba lecastoljunk, mivel az általános típus semmit nem tudott, csak jelezte, hogy a hierarchia egy tagja valami. Most már képes a vizitálás kezdeményezésére, hiszen megkapta a visit tagfüggvénysablont, ami pedig a megfelelő virtális függvényt fogja meghívni. Ez megegyezik az általános—​és helyes—​használatával az osztályhierarchiáknak, így jelentősen jobb, és gyorsabb is a kód.

Mindezzel, az accept kódja:

tempalte<class Base, class T>
void visitable_as<Base, T>::accept(visitor_base& vtor) override {
    auto self = static_cast<T*>(this);
    assert(self);

    vtor.visit(self);
}
Itt van még egy trükk, amit csak parciálisan tudok megmagyarázni és magasabb színtű szabvány varázslók segítségét kell még kérnem, hogy pontosan mi is történik, úgyhogy lehet teljes baromság, ami a következő bekezdésben található.

Mivel a CRTP-nek köszönhetően (a T paraméter a jelenlegi osztályt jelenti, ami örököl az osztály sablonból) a fordító tudja pontosan, hogy a this mutató, hiába visitable_as<…​>* típusú, igazából egy T objektumra mutat, így fordítási időben meg tud történni ez a lecastolás. Emiatt nem szükséges dynamic_cast, hiszen fordítási idejű static_cast is megoldja a problémát. Így, ha úgy nézzük még mindig van a kódban egy lecast, de ez mégsem dinamikusan történik. (Az assert lényegtelen igazából, és csak akkor abortál, ha valami nagyon rossz dolog történik, aminek nem lenne lehetséges megtörténnie.)

Igy már egyszerűen csak a típushelyes self mutató tovább tudjuk adni a visit tagfüggvénynek, ami megcsinál mindent amire szükség van.

Sebesség

A dynamic_cast-ot kioperálva a rendszerből jelentős sebességet nyertünk, hogy már kevesebb fölösleges munkát végeztünk. (Összehasonlító benchmark a cikk végén.)

typeinfo

Ezt megvizsgálva azonban azt lehet észrevenni, hogy a szabványos std::type_info osztály GCC implementációja szöveges összehasonlítást végez a típusazonosság megvalósízására. Látható módon, ez nem optimális, hiszen strcmp dominálja immáron a futásidőnket.

Bár itt már jelentős sebességre tettünk szert, és normális esetben például ez akár egy jó kompromisszum lehetne, hogy ne legyen túl komplikált a kód csak a sebesség miatt; ez egy nagy házi volt. Senki nem fogja a beadás után karbantartani, így teljesen ignorálni lehet a karbantarthatósági szempontot.

Tehát itt az idő egy saját std::type_info-t írni.

Dinamikus látogató, saját típusazonosítókkal

Egyszerű gondolat, hogy kitaláljuk, mit kell tenni jelenleg a sebességért. Mivel az strcmp alapú megoldás lassú, egyszerűen csak egy olyan verziót kell készíteni, ami nem szövegösszehasonlításon alapul. Triviális, nemde?

Ehhez szükség van egy olyan osztályra, ami lecseréli az std::type_info-t, illetve egy “operátorra”, ami a typeid szerepét veszi át.

Elsődlegesen az osztály implementáláshoz egy nevezett konstruktor patternt fogunk felhasználni. Ez pusztán azért kell, hogy lehessen normálisan átadni egy explicit típusparamétert a konstruktornak.

Emellet kell még egy statikus számláló, ami mindig a következő értéket tárolja, amit egy típus azonosítására használunk. Ehhez a számlálóhoz készítettünk még egy segéd osztálysablont, egy darab statikus tagfüggvénnyel és egy tagváltozóval.

template<class T>
struct my_type {
    static unsigned create_or_get(std::uint_fast32_t& cntr) {
        if (_id == 0) _id = cntr++;
        return _id;
    }

private:
    inline static std::uint_fast32_t _id = 0u;
};

Mivel a sablonparaméter minden különböző értékére saját típust alkot, így a statikus változó minden különböző T típus esetén külön létezik. Így képes eltárolni egy adott típus azonosítóját, ha egyszer már inicializálásra került, és inicializálni is tudja, a kapott paraméter használatával.

Ezen osztály köré már felépíthető a saját my_typeid osztályunk.

struct my_typeid {
    template<class T>
    static my_typeid id_of() {
        return my_typeid(my_type<T>::create_or_get(type_cntr));
    }

    bool operator==(const my_typeid& other) const noexcept {
        return other._value == _value;
    }

    bool operator!=(const my_typeid& other) const noexcept = default;

private:
    explicit my_typeid(std::uint_fast32_t val)
          : _value(val) { }

    std::uint_fast32_t _value;
    inline static std::uint_fast32_t type_cntr = 1;

    template<class T>
    struct my_type {
        static std::uint_fast32_t create_or_get(std::uint_fast32_t& cnt) {
            if (_id == 0) _id = cnt++;
            return _id;
        }

    private:
        inline static std::uint_fast32_t _id = 0;
    };
};

Minden típusra instanciálódik egy my_type<T> típus, így kap egy saját azonosítót, ezzel pedig tudjuk konstruálni a my_typeid osztályt, ami egyszerűen csak ezt a numerikus azonosítót tartalmazza. Így az egynelőség/különbözőség operátorok egyértelműen működnek, és egyszerűen egy numerikus egyenenlőség, amit kell csinálniuk.

Ez így már szép és jó, viszont, még egy QoL változtatás, hogy csináljunk egy külön “operátort”, amivel nem kell mindenhol a my_typeid::id_of<T>()-t kiírni. Ez egyszerűen egy változó sablonnal megoldható:

template<class T>
static auto my_id_of = my_typeid::id_of<T>();

Így kész is vagyunk, már csak le kell cserélni minden typeid(T)-t my_id_of<T>-re, és minden std::type_info-t my_typeid-re. Ez a következő osztályok változtatásával jár:

visitor_base
struct visitor_base {
    template<class T>
    void visit(T* visited) {
        visit_imp(static_cast<void*>(visited), my_id_of<T>);
    }

    virtual ~visitor_base() = default;
protected:
    virtual void
    visit_imp(void*, my_typeid) = 0;
};
visitor
template<class... Ts>
struct visitor : visitor_impl<Ts> ... {
    ~visitor() override = default;
protected:
    template<class T>
    [[gnu::always_inline]]
    bool visit_one_t(void* typeless_visited, my_typeid tid) {
        if (my_id_of<T> != tid) return false;
        static_cast<visitor_impl<T>*>(this)->do_visit(static_cast<T*>(typeless_visited));
        return true;
    }

    void
    visit_imp(void* typeless_visited, my_typeid tid) final {
        (visit_one_t<Ts>(typeless_visited, tid) || ...);
    }
};

Minden más marad pontosan, ugyan az.

Sebesség

Már jelentősen gyorsabban vagyunk, a teszthez használt ciklus végét meg kellett tízszerezni, az eredeti, eddig használt értékeknél VTune nem tudott mit csinálni a kóddal.

speed

Ebből látható, hogy a kódunk már nem csinál semmi fölöslegeset, és pusztán a feladatát látja el, miszerint látogat.

Összehasonlító benchmarkok

A következőkben az előbb tárgyalt implementációk sebességét vizsgáljuk. Bár három megvalósítást tárgyaltunk, 6 féle került vizsgálatra: minden verzióból kettő, úgy, hogy egyet megpróbáltunk manuálisan elágazásbecsülni.

Az implementációkat következő rövidítésekkel azonosítjuk:

Név Dispatch Típusinformáció Elágazásbecslés

dtb

dynamic_cast

std::type_info

manuálisan branch-predicted

dtc

dynamic_cast

std::type_info

compiler branch-predicted

vtb

virtual dispatch

std::type_info

manuálisan branch-predicted

vtc

virtual dispatch

std::type_info

compiler branch-predicted

vmb

virtual dispatch

my_typeid

manuálisan branch-predicted

vmc

virtual dispatch

my_typeid

compiler branch-predicted

Ismételt látogatás

Elsődlegesen a legegyértelműbb benchmark, hogy egyre többször hajtjuk végre a látogatás műveletét, ugyanazon az osztályhalmazon. Ehhez egy nagyon egyszerű hierarchiát alkottunk:

Diagram

Mind három osztályból konstruáltunk egy darabot, és 100-asával növelve 100 és 1000000 közötti iterációs számmal, sorban meglátogattuk őket. A használt kód már nincs meg sajnos, viszont alapjaiban a következő, csak rendelkezett időmérést szolgáló std::chrono alapú számlálókkal⁠[2]:

int
main() {
    struct nop_visitor final : visitor<der1, der2, der3> {
        void
        do_visit(der1*) final { ++der1_cnt; }

        void
        do_visit(der2*) final { ++der2_cnt; }

        void
        do_visit(der3*) final { ++der3_cnt; }

        std::size_t der1_cnt = 0;
        std::size_t der2_cnt = 0;
        std::size_t der3_cnt = 0;
    } nop;

    base* d1 = new der1;
    base* d2 = new der2;
    base* d3 = new der3;

    for (std::size_t i = 0; i < std::size_t(@ITERATION_COUNT@); ++i) { (1)
        d1->accept(nop);
        d2->accept(nop);
        d3->accept(nop);
    }

    delete d1;
    delete d2;
    delete d3;

    std::cout << "der1: " << nop.der1_cnt << "\n";
    std::cout << "der2: " << nop.der2_cnt << "\n";
    std::cout << "der3: " << nop.der3_cnt << "\n";
}
1 Az @ITERATION_COUNT@ érték behelyettesítésével kaptuk meg a különböző benchmarkokat.

A következő diagramm mutatja a különböző implementációk futásidejét növekvő iterációs szám mellett.

gcc speedup full

dynamic_cast nélkül

Mivel a dynamic_cast-ot használó implemntáció jelentősen lassabb, mint a másik kettő, így kicsit “összenyomja” a grafikont, hogy a többi adat annyira nem jól látszik.

Ez a grafikon ugyan azokból az adatokból készült, mint az előző, csak a első naív implementáció nem került feltüntetésre.

gcc speedup my typeid

Osztályhalmaz számosság növelés

A második benchmark egyszerűen csak annyit csinál, hogy egy tizesével növekvő osztályhalmazból (10 és 1000 között mérve) random konstruál osztályokat, és azokat végiglátogatja. A vízszintes diagramm tehát most a hierarchiában található osztályok száma.

gcc class expansion speedup

További varázslási lehetőség

Bár látható módon, jelentős sebesség növekedést értünk el, még esetlegesen dolgot lehetséges lenne megvizsgálni. Jelenleg a vizitálás gyakorlatilag egy lineáris keresést végez az ismert típusok között. Ugye ez jelentősen gyorsabb még így is, mint a másik megoldások, valami trükkösebb implementáció, ami esetlegesen elég osztály esetén átkapcsol valami bináris keresés szerű algorithmusra, még lehetséges gyorsulást érhet el.

lin search

Ahogy itt is látszik, a 3 osztályos keresésben, ha elég osztály van, akkor lehetséges, hogy egy bináris keresés, és a vele járó elágazás félrebecslésekkel még gyorsabb, mint a naív lináris keresés.


1. Ha esetleg van olyan abszurd szituáció, amikor nem az, kérek mindenkit, hogy juttassa el hozzám. Nagyon szeretem az ilyen random "ackshually…​" dolgokat.
2. Az óra csak a ciklust mérte, a dinamikus memóriafoglalást és felszabadítást például már nem vizsgáltuk.