Erős típusok 2.

Czirkos Zoltán, Dobra Gábor · 2026.10.29.

1. tag struct

Az első erős típusos előadáson láttunk példát arra is, amikor az a cél, hogy bizonyos típusok, bár hasonlóan működnek, ne legyenek kompatibilisek egymással. Ez volt a Quantity hierarchia legfontosabb feladata, hogy ne lehessen (véletlenül) összekeverni különböző mértékegységű mennyiségeket.

Abban a példában a template paraméterek hordozták a mértékegységet mint információt, és annak segítségével tudtuk típusbiztos osztás-szorzás műveleteket implementálni.

Ez a technika azonban működik akkor is, ha a template paramétereknek semmilyen extra információt nem kell hordozniuk, és csak azért vannak, hogy a típusokat különbözőnek tekintse a fordító.

Például ha egy vonatjegy-kiadó rendszert készítünk, a vonatokon sokmindennek van "száma", és azok egymással nem összekeverendő "számok":

  • Ülőhely száma
  • Kocsi száma
  • Járatszám
  • Vágány száma
  • Járművek pályaszáma
  • stb.

Így ha a kódban ilyet látunk leírva, akkor csak reménykedhetünk, hogy a Vonatjegy konstruktora is "Járatszám, kocsi, ülőhely" sorrendben várja a paramétereket.

Vonatjegy v{"Budapest", "Pécs", 802, 21, 86};

Sokkal olvashatóbb kódot kapunk, ha mindegyik fenti számra létezik külön típus. Ennek csak akkor van igazán értelme, ha ezek a típusok nem kompatibilisek. Ha kompatibilisek (vagy esetleg ugyanazok using-gal) lennének, akkor továbbra is lefordulna (hibásan), ha a konstruktor más sorrendben várja ugyanezeket a paramétereket.

Vonatjegy v{"Budapest", "Pécs", JaratSzam{802}, KocsiSzam{21}, UlohelySzam{86}};

Ezt könnyedén meg tudjuk valósítani úgy, hogy létrehozunk egy sablon típust, hogy csak egyszer kelljen implementálni a "Szám" osztályt:

template<typename TAG>
class VasutiSzam {
    int szam_;
public:
    explicit VasutiSzam(int szam) : szam_{szam} {}
    int szam() { return szam_; }
};

A trükk ott van, hogy a template paraméterként megadott típusoknak nem kell semmit tudnia azon kívül, hogy léteznek.

struct UlohelySzamTag {};
using UlohelySzam = VasutiSzam<UlohelySzamTag>;

struct KocsiSzamTag {};
using KocsiSzam = VasutiSzam<KocsiSzamTag>;

struct JaratSzamTag {};
using JaratSzam = VasutiSzam<JaratSzamTag>;

Ezt a technikát hívjuk angolul tag struct-nak. A nevét arról kapta, hogy azokat az üres típusokat csak címkének használjuk, amikkel az igazi típusokat vagy (a későbbi példákban) függvényhívásokat megkülönböztetjük egymástól.

2. tag struct mint overload

Az objektumorientált alapokra az egyik legegyszerűbb példa a Complex osztály. Be lehet mutatni rajta, hogy az osztályoknak van interfésze és belső reprezentációja, és ezek nem feltétlenül egyeznek. Az osztály tárolhat egy komplex számot algebrai (a + b*i) vagy trigonometriai (r * e^(i*phi)) alakban, sőt akár mindkettőben, és lekérdezhető a, b, r és phi is. Az osztály maga eldöntheti, hogy melyik alakkal szeretné a műveleteit elvégezni.

Ez a példa azonban sántítani szokott a konstruktornál:

Complex c1{3.0, 4.0};

A két double-t váró konstruktor óhatatlanul elárulja, melyik reprezentáció az osztály "natív" formátuma, és ezután nem igazán lehet kicserélni, ugyanis akkor minden ilyen (olcsónak szánt) konstruktorhívás költséges művelet lesz hirtelen. Emellett ugyanúgy hibalehetőséget hordoz magában, ahogy a korábbi példák.

Ehelyett arra lenne szükség, hogy az osztálynak lehessen mindkét alakra egyenrangú konstruktora, hogy mindig érdemes legyen azt hívni, amelyik az adott helyen kényelmes. A gond persze annyi, hogy mindkét alak double, double paramétereket várna. Sokkal jobb lenne, ha a kód itt is beszédes lenne, és valahogyan bele lehetne írni a konstruktorhívásba, melyik alakkal hívjuk épp.

A konstruktornak viszont nem adhatunk nevet. Függvénynek adhatunk, de nem érdemes függvényt csinálni belőle: akkor az összes new, std::make_shared<Complex>(), stb. kifejezés elromlik.

Adhatunk viszont a konstruktornak extra paramétereket:

Complex c1{Complex::arithmetic, 3.0, 4.0};
Complex c2{Complex::trigonometric, 5.0, M_PI / 4};

A régi megoldás erre az volt, hogy csináltak enum-ot, aminek az értékei arithmetic és trigonometric neveket kaptak, mi azonban nem futási, hanem fordítási időben szeretnénk ezeket elválasztani, konstruktor overloadokat szeretnénk látni.

class Complex {
    // ...
public:
    struct arithmetic_t {};
    static constexpr arithmetic_t arithmetic{};

    struct trigonometric_t {};
    static constexpr trigonometric_t trigonometric{};

    Complex(arithmetic_t, double re, double im);
    Complex(trigonometric_t, double r, double phi);
};

A konstruktor definíciójánál a két tag paraméternek csak a típusát írtuk ki, a nevét nem, az értéket úgysincs mire használni.

3. Optional

Néha szükségünk van egy olyan objektumra, amelyik egy jól meghatározott típusú értéket tárol - vagy éppenséggel üres. Például egy függvénynél, amelyik visszaad egy értéket, vagy nem ad vissza semmit. Ha sikerül beolvasnia egy számot, akkor visszaadja azt, ha nem, akkor pedig csak egy üres objektumot.

Optional<std::string> get_string_from_stdin();

Vagy például ha hálózatról akarunk olvasni adatot, akkor megjelenhet paraméterként is a timeout. Fontos, hogy a "nincs timeout" nem ugyanaz, mint a "timeout == 0": az előbbi általában azt jelenti, hogy a végtelenségig várunk (vagyis addig, amíg el nem vesztjük a kapcsolatot), az utóbbi pedig hogy a már beérkezett adatot adjuk vissza, és ha még nem érkezett be, akkor nem várunk semennyit.

class NetworkConnection
    // ...
public:

    Optional<DataFrame> next_frame(Optional<std::chrono::milliseconds> timeout);
};

Vegyük észre, hogy ez az osztály nem ugyanazt a szerepet tölti be, mint az std::unique_ptr: nem feladata a dinamikus memóriakezelés, sőt a cél az, hogy konkrétan olyan típusú objektumot tartalmazzon az Optional, mint amit template paraméterében megadtunk, és nem valamelyik leszármazottját. A használatát így képzelhetjük el:

Optional<std::string> get_string_from_stdin() {
    Optional<std::string> result;

    std::string in;
    /* ... */
    result = in;

    return result;
}

int main() {
    Optional<std::string> opt = get_string_from_stdin();
    if (opt) {
        std::cout << "Ezt a sztringet kaptam: " << *opt << std::endl;
    } else {
        std::cout << "Nem kaptam sztringet" << std::endl;
    }
}

A pointer szinaxis sajnos itt is az egyetlen kényelmes megoldás a tartalmazott objektum elérésére - de ettől még ez egy érték szemantikájú objektum.

A dinamikus memóriakezelés itt nemcsak kényelmetlen, hanem lassú is lenne: egyetlen egy int-et kellene dinamikusan foglalni, indirekten visszaadni stb. Ehelyett inkább egy olyan objektumra lenne szükségünk, amelyik ezt érték szerint tartalmazza, és tudja azt is, hogy üres-e vagy nem.

Ezekre a műveletekre lesz szükségünk:

  • létrehozhatjuk üresen,
  • létrehozhatjuk tárolt objektummal, mondjuk std::string-gel,
  • értékül adhatunk neki std::string-et,
  • másolható, mozgatható,
  • ellenőrizhetjük, hogy van-e benne tárolt elem,
  • elkérhetjük a tárolt elemet,
  • kiüríthetjük.

Arra figyelnünk kell, hogy a tárolt típusnak nem biztos, hogy van default konstruktora, tehát amikor üres az Optional, akkor nem tárolhatunk egyáltalán semmit. Akkor kell konstruálnunk a tárolt objektumot, amikor beteszi a hívó. Ezt a placement new szintaxissal tudjuk megoldani. A placement new-t hívhatjuk nem-dinamikus memóriaterületre is: tegyünk az Optional-be egy karaktertömböt megfelelő mérettel!

template<typename T>
class Optional {
    bool has_value_;
    alignas(T) char data_[sizeof(T)];
public:
    Optional() : has_value_{false} {}

    Optional(T const& obj) : has_value_{true} {
        new (data_) T{obj};                 // placement new
    }

    Optional(T&& obj) : has_value_{true} {
        new (data_) T{std::move(obj)};      // placement new
    }

    ~Optional() {
        if (has_value_)
            reinterpret_cast<T*>(data_)->~T();   // destruktor
    }

    explicit operator bool() const {
        return has_value_;
    }

    bool has_value() const {
        return has_value_;
    }

    T& value() {
        if (!has_value_)
            throw std::runtime_error{"Optional üres"};
        return *reinterpret_cast<T*>(data_); // tudjuk, hogy helyes
    }

    T const& value() const {
        if (!has_value_)
            throw std::runtime_error{"Optional üres"};
        return *reinterpret_cast<T*>(data_);
    }

    T& operator*() { return value(); }
    T const& operator*() const { return value(); }

    T* operator->() { return &value(); }
    T const* operator->() const { return &value(); }
};

A másoló és mozgató konstruktor, illetve a két értékadó operátor trükkösebb: mozgatásnál el kell vennünk a tartalmazott objektumot. Az Optional-t úgy találtuk ki, hogy két állapota legyen: üres, vagy van benne igazi objektum. Ezért célszerű (bár nem kötelező) mozgatás után üres Optional-t hagyni a kimozgatott objektumban.

template<typename T>
class Optional {
    /* ... */
public:
    /* ... */

    Optional(Optional const& other) : has_value_{other.has_value_} {
        if (other.has_value_)
            new (data_) T{ *other };

    Optional(Optional&& other) : has_value_{other.has_value_} {
        if (other.has_value_) {
            new (data_) T{ std::move(*other) };
            reinterpret_cast<T*>(other.data_)->~T();  // other-ben lévő objektum destruálása
            other.has_value_ = false;
        }
    }

    Optional& operator=(Optional const& other) {
        if (this != &other) {
            if (has_value_)
                value().~T();

            has_value_ = other.has_value_;
            if (other.has_value_)
                new (data_) T{ *other };
        }
        return *this;
    }

    Optional& operator=(Optional&& other) {
        if (this != &other) {
            if (has_value_)
                value().~T();

            has_value_ = other.has_value_;
            if (other.has_value_) {
                new (data_) T{ std::move(*other) };
                reinterpret_cast<T*>(other.data_)->~T(); // other-t kiürítjük
                other.has_value_ = false;
            }
        }
        return *this;
    }
};

Még hiányzik az, hogy lehessen T-t értékül adni az Optional-nek, akár másolva, akár mozgatva. Itt arra kell figyelnünk, hogy lehet trükkös önértékadás is, pl. x = x.value() formában.

template<typename T>
class Optional {
    /* ... */
public:
    /* ... */

    Optional& operator=(T const& obj) {
        if (data_ != &obj) {
            if (has_value_)
                value().~T();
            new (data_) T{ obj };
        }
        return *this;
    }

    Optional& operator=(T&& obj) {
        if (data_ != &obj) {
            if (has_value_)
                value().~T();
            new (data_) T{ std::move(obj) };
        }
        return *this;
    }
};

Egyvalamit nem tud még az osztályunk: ha már létezik, nem tudjuk közvetlenül kiüríteni. Ezt úgy tudjuk legegyszerűbben megoldani, hogy értékül adunk neki egy "semmit jelképező" objektumot. Ennek a neve általában nullopt, azaz null-optional. A tag struct itt is jó megoldás:

template<typename T>
class Optional {
    /* ... */
public:
    struct nullopt_t {};
    static constexpr nullopt_t nullopt{};

    Optional(nullopt_t) : has_value_{false} {}

    Optional& operator=(nullopt_t) {
        if (has_value_)
            value().~T();
        has_value_ = false;
        return *this;
    }
};

Lényegében így működik az std::optional osztály, illetve az std::nullopt, ami C++17 óta része a szabványnak.

4. Any – type erasure

A C++-ban többféle generikusság is létezik. A sablonok lehetővé teszik azt, hogy egy osztály vagy függvény forráskódjában egy típust fordítási időben kicseréljünk: így létre tudjuk hozni egész számok, valós számok, valamilyen objektumok listáját. A heterogén kollekciókban valamilyen ősosztálybeli objektumokat (pontosabban azok pointereit) tároljuk, és ott a különböző típusú objektumok futási időben cserélhetőek.

Időnként előfordul az, hogy egy olyan változót szeretnénk létrehozni, amelyik „tényleg generikus”. Mit jelent ez? Egy ilyen változó tetszőleges típusú értéket fölvehet, legyen az akár beépített, akár felhasználó által definiált. Továbbá képes arra, hogy dinamikusan, futási időben típust váltson. Olyasmi kellene legyen ez a változó, mint a C-s void* pointer, ami bármire mutathat. Itt is a programozónak kell majd tudnia, hogy épp milyen típusú értéket tárol a változó, de szerencsére ez a C-s verziónál okosabban megcsinálható, futási idejű ellenőrzésekkel, és érték szerinti másolással.

Gondoljuk meg, mire lenne itt szükségünk!

int main() {
    Any a;

    a.set(5);
    std::cout << a.get<int>();

    a.set(std::string("hello"));
    std::cout << a.get<std::string>();
}

A változó itt egyszer int, máskor std::string típusú értéket tárolt el. A .set() tagfüggvény sablonfüggvény kell legyen, hogy tetszőleges típusú paramétert át tudjon venni. Igazából ugyanúgy sablon, mint a .get(), ahol a programozó adja meg a típust. Csak az előbbinek van paramétere, amiből a fordító a sablonparamétert le tudja vezetni. A .get()-nél erre nincsen lehetőség, mivel annak nincs paramétere.

A .set() függvénynek adott objektumok eltérő méretűek lehetnek, mégis ugyanannak az Any típusú, előre lefoglalt memóriaterülettel rendelkező változónak kell eltárolnia őket. Ez csak úgy oldható meg, ha a tényleges tárolás az Any objektumon kívül történik. Ezért dinamikus memóriakezelést kell használnunk: az így lefoglalt objektumnak az Any csak a memóriacímét fogja tárolni. Ahányféle objektumot kap a .set(), annyiféle objektumot kellhet tárolni; így ha a .set() sablonfüggvény, akkor a dinamikusan létrehozott objektumnak is sablonnak kell lennie.

A new int és a new std::string kifejezésektől kapott int* és std::string* típusú pointerek nem kompatibilisek egymással. Az Any tárolhatna void*-ot, de jobban járunk, ha az eltárolandó objektumot becsomagoljuk egy segédobjektumba, a segédobjetumok számára pedig létrehozunk egy közös ősosztályt. Tulajdonképpen egy egyelemű heterogén „kollekciót” kapunk (ahol a kollekció nem véletlenül van idézőjelben, már csak a tervezési mintára való utalásként szerepel):

class Any {
    class ContainerBase {                    // Üres ősosztály
      public:
        virtual ~ContainerBase() = default;
    };

    template <typename T>
    class Container final : public ContainerBase { // Container<T>::data tárol
      public:
        T data;
        Container(T const &what) : data(what) {}
    };

    ContainerBase* pdata = nullptr;          // Emiatt kell az ősosztály
};

A módszer neve: type erasure. Ebben a közös ősosztály „csak azért van, hogy legyen”, mert kell a közös ős.

A .set() és a .get() függvények ezután már könnyen megvalósíthatóak. A .set() megadott típusú csomagoló objektumot foglal, és beállítja a pointert. Ezen a ponton elveszik a típusinformáció, de azt a .get() újból megkapja a sablonparaméterében, és egy dynamic_cast visszaalakítja a segédobjektum referenciáját a megfelelő típusúra:

template <typename T>
void Any::set(T const& what) {
    ContainerBase* newpdata = new Container<T>(what);
    delete pdata;
    pdata = newpdata;
}

template <typename T>
T& Any::get() {
    return dynamic_cast<Container<T>&>(*pdata).data;
}

A dynamic_cast futási idejű hibaellenőrzését itt ki is lehet használni. Ha helytelen a konverzió, std::bad_cast típusú hibát fog dobni (referenciák esetén így viselkedik). A dynamic_cast-hoz szükség van virtuális függvényre, de ez a feltétel szerencsére automatikusan teljesül az ősosztálybeli virtuális destruktor miatt, amire amúgy is szükségünk lenne.

A mozgatás egyszerűen megoldható, elég a pdata pointert mozgatni:

template <typename T>
Any(Any&& other) {
    pdata = other.pdata;
    other.pdata = nullptr;
}

Már csak egy dolgot kell megoldanunk, az Any objektumok érték szerinti másolását. A ContainerBase* indirekció miatt a fordító által generált másoló konstruktor helytelenül működik, csak a pointert másolja, nem az objektumot. A pointer által mutatott objektum dinamikus típusa azonban ismeretlen, ezért a szokásos trükköt kell alkalmaznunk: a virtuális konstruktort. Ez egy újabb virtuális függvény a segédobjektum számára. Szerencsére a .clone()-t most csak egyszer kell implementálnunk, a sablon osztályban:

class Any {
  private:
    class ContainerBase {
        virtual ContainerBase* clone() const = 0;
    };

    template <typename T>
    class Container final : public ContainerBase {
        ContainerBase* clone() const override { return new Container<T>(*this); }
    };

    ContainerBase* pdata;
};

template <typename T>
Any::Any(Any const& to_copy) {
    pdata = to_copy.pdata->clone();
}

A teljes változat letölthető innen: any.cpp. A C++17 már tartalmaz egy ehhez hasonlóan működő osztályt, std::any néven.