24.3 Virtuális destruktor, slicing és ownership

Egy heterogén objektumgyűjtemény, amelyet egyetlen interfészen keresztül kezelünk, a modern C++ egyik leggyakoribb tervezési alakzata: naplózó sinkek, protokoll-adapterek, parancs-objectek. A gyűjtemény tulajdonosa tipikusan std::unique_ptr<Base>, a bázis pedig absztrakt interfész. Az ilyen kód helyessége két, egymástól független szerződésen múlik: a destruktornak a dinamikus típus szerint kell lefutnia, és a származtatott objektumnak nem szabad értékként bázis típusba másolódnia. A fejezet ezt a két szerződést tárgyalja, majd összeveti a polimorfikus másolást (clone) a move-only tulajdonosi interfésszel. Az előfeltétel a virtuális dispatch és a unique_ptr egyedi tulajdonlás ismerete; az ott rögzített fogalmakat itt nem ismétlem meg.

Definíció (polimorfikus törlés). Polimorfikus törlésnek nevezzük azt a delete kifejezést, amelynek statikus típusa bázis pointer, dinamikus típusa pedig származtatott osztály. A művelet akkor definiált, ha a statikus típus destruktora virtuális, vagy ha a statikus és a dinamikus típus megegyezik. Ha a statikus típus destruktora nem virtuális, és a dinamikus típus eltér, a viselkedés nem definiált (undefined behavior, UB).

Definíció (object slicing). Szeletelésről akkor beszélünk, ha egy származtatott objektumot érték szerint másolunk vagy érték szerinti paraméterként adunk át bázis típuson. Ilyenkor a bázis másoló konstruktora fut, csak a bázis részobjektum másolódik, és az eredmény dinamikus típusa a bázis lesz. Ez nem UB, hanem logikai hiba: a származtatott tagok és a származtatott viselkedés elvesznek.

24.3.1 A destruktorhívás szerződése és a delete bázis pointeren

A virtual ~Base() = default; deklaráció azt jelenti, hogy a bázis destruktora virtuális, és a származtatott destruktorok automatikusan felülírják, akkor is, ha a származtatott osztályban nem írjuk ki a virtual kulcsszót. A destruktor neve a származtatottban ~Derived, de a virtuális dispatch a destruktorra ugyanúgy működik, mint bármely más virtuális függvényre. Ezért a bázisban elég egyszer kimondani a virtualitást; a hierarchia minden tagja örökli.

A delete p kifejezés végrehajtása két lépésből áll: először a destruktor fut le, majd a tároló felszabadítása történik. Ha a statikus típus destruktora virtuális, a destruktorhívás a dinamikus típushoz megy, és a teljes objektum destruálódik. Ha nem virtuális, a statikus típus destruktora fut le, a származtatott tagok destruktorai elmaradnak, és a deallokáció a statikus típus méretével történik. A konkrét következmény implementációfüggő — heap-korrupció, memóriaszivárgás, összeomlás —, de maga a viselkedés hiánya szabványos: UB.

Fontos, hogy a std::unique_ptr<Base> nem véd meg ettől. A std::make_unique<Derived>() által visszaadott unique_ptr<Derived> implicit módon konvertálódik unique_ptr<Base>-vé, ha a Derived* konvertálható Base*-ra. A kód lefordul, és a unique_ptr destruktora a default_delete<Base>-en keresztül delete-et hív a statikus Base* típuson. Ha a Base destruktora nem virtuális, a törlés futásidőben UB. A fordítási siker tehát nem bizonyíték a helyességre; a virtuális destruktor a szerződés része, nem a fordító által kikényszerített feltétel.

A következő tanítási példa szándékosan hibás, és UB-t mutat be. UB esetén nincs garantált kimenet, ezért a példa csak a hiba okát illusztrálja.

// s119_ub_delete.cpp — tanítási példa: UB, nincs garantált output
#include <string>

struct Base {
    ~Base() = default;   // NEM virtuális
};

struct Derived : Base {
    std::string payload;
};

int main() {
    Base* p = new Derived;
    delete p;   // UB: a Derived::~Derived() nem fut le
}

A helyes változatban a bázis destruktora virtual ~Base() = default;. Ekkor a delete p a Derived::~Derived()-et hívja, a payload felszabadul, és a deallokáció a teljes objektum méretével történik.

24.3.2 Két biztonságos destruktor-minta

A polimorfikus tulajdonláshoz két bevett minta létezik. A választás nem esztétikai kérdés, hanem a tulajdonlási szerződés része.

Az első a public virtual destruktor. Ez akkor helyes, ha a bázis pointeren keresztül történő törlés megengedett, és a bázis valóban polimorfikus tulajdonosi interfész. A std::unique_ptr<Base> és a factory-minta ezt igényli. A destruktor legyen public, hogy a delete és a default_delete hozzáférjen; legyen virtual, hogy a dinamikus típus destruálódjon.

A második a protected nonvirtual destruktor. Itt a destruktor nem virtuális, de protected, ezért delete Base* nem fordul le, mert a destruktor nem elérhető. A származtatott osztály destruktora viszont eléri a bázis destruktorát, így a Derived példány törölhető. A minta célja annak kikényszerítése, hogy a bázist kizárólag származtatott típuson keresztül töröljék, például mert a bázisnak nincs virtuális destruktora, vagy mert a törlést a származtatott osztály végzi. A C++ Core Guidelines ajánlásai között is szerepel ez a minta; a konkrét ajánlás szövegének ellenőrzése a labor feladata.

Minta Deklaráció delete Base* unique_ptr<Base> Mikor jó
public virtual public: virtual ~Base() = default; legális, dinamikus destruktor legális polimorfikus tulajdonlás
protected nonvirtual protected: ~Base() = default; fordítási hiba fordítási hiba csak származtatottként törölhető

A táblázat utolsó oszlopa a lényeg: a protected nonvirtual destruktor nem használható std::unique_ptr<Base>-bel, mert a default_delete<Base> nem fér hozzá a destruktorhoz, ezért a unique_ptr<Base> destruktora nem fordul le. Ha mégis ilyen tulajdonosi típus kell, egyedi deleter vagy barát osztály szükséges; ez azonban már nem a legegyszerűbb interfész.

// s119_protected_dtor.cpp
class Base {
public:
    virtual void run() = 0;
protected:
    ~Base() = default;   // nem virtuális, de protected
};

class Derived final : public Base {
public:
    void run() override {}
    ~Derived() = default;
};

int main() {
    Derived d;          // OK
    // Base* p = new Derived;
    // delete p;        // fordítási hiba: ~Base() protected
}

24.3.3 Object slicing és pass-by-value base

A szeletelés leggyakoribb forrása az érték szerinti paraméter. A void f(Base b) szignatúra a hívás pillanatában a Base másoló konstruktorát hívja, még akkor is, ha a hívó Derived objektumot ad át. A void f(const Base& b) ezzel szemben referenciát köt, nem másol, ezért megőrzi a dinamikus típust. Ugyanez a különbség a Base* és a Base& között: a pointer és a referencia nem szeletel, az érték igen.

A szeletelés nem UB, ezért a sanitizerek és a fordító figyelmeztetései gyakran nem jelzik. A tünet az, hogy a virtuális hívás a bázis verziójára megy, a származtatott tagok elvesznek, és a sizeof a bázis méretét mutatja. A typeid-vel való vizsgálat nem megbízható detektor, mert a szeletelt objektum dinamikus típusa valóban a bázis; a hiba már a másolás pillanatában megtörtént. A legjobb védelem a szerződés szintjén van: az interfész bázist érdemes absztraktként tartani, mert absztrakt bázisból nem lehet példányt létrehozni, így a Base b = d; már nem fordul le. Kiegészítő védelem a bázis másoló konstruktorának törlése (Base(const Base&) = delete;), de ez a bázist másolhatatlanná teszi, ami nem minden esetben kívánatos.

// s119_slicing.cpp — tanítási példa: nem UB, de logikai hiba
#include <iostream>
#include <string_view>

class Shape {
public:
    virtual ~Shape() = default;
    virtual std::string_view name() const { return "Shape"; }
    virtual double area() const { return 0.0; }
};

class Circle final : public Shape {
public:
    explicit Circle(double r) : r_(r) {}
    std::string_view name() const override { return "Circle"; }
    double area() const override { return 3.141592653589793 * r_ * r_; }
private:
    double r_;
};

void print_by_value(Shape s) { std::cout << s.name() << '\n'; }
void print_by_ref(const Shape& s) { std::cout << s.name() << '\n'; }

int main() {
    Circle c{2.0};
    print_by_value(c);  // Shape
    print_by_ref(c);    // Circle
    Shape s = c;        // slicing
    std::cout << s.name() << '\n'; // Shape
}

A példában a Shape nem absztrakt, ezért a Shape s = c; lefordul. Ha a name és az area pure virtual (= 0) lenne, a Shape absztrakt lenne, és a szeletelés már fordításkor elbukna. Ez a legolcsóbb, legkorábbi védelem.

24.3.4 Polymorphic clone és move-only interface owner

A polimorfikus tulajdonlásnak két alapalakja van. A move-only interface owner esetében a tulajdonos std::unique_ptr<Base>, amelyet a factory ad vissza, és a hívó std::move-val ad tovább. Ez a forma akkor elég, ha a tulajdonlás átadása a cél; a másolás nem szükséges, és a unique_ptr mozgatása olcsó. A polymorphic clone akkor kell, ha a tulajdonosról másolatot kell készíteni, például prototípus, konfiguráció vagy párhuzamos feldolgozás miatt. A clone a dinamikus típust őrzi meg, ellentétben a bázis érték szerinti másolásával.

A clone visszatérési típusa std::unique_ptr<Base> legyen, ne nyers Base*. A nyers pointer visszaadása a hívóra hárítja a delete-et, és a unique_ptr-be csomagolás könnyen elmarad, ami szivárgáshoz vezet. A unique_ptr<Base> visszatérési típus a tulajdonlást a típusba kódolja. A clone visszatérési típusa nem lehet kovariáns, mert a unique_ptr nem pointer típus; a származtatott clone visszatérési típusa pontosan std::unique_ptr<Base> kell legyen, és a make_unique<Derived>(*this) implicit konverziója végzi a típuskonverziót.

A következő példa önállóan fordítható C++20-ban; a laborprofilban rögzített GCC vagy Clang verzióval a megadott buildparanccsal építhető. A példa nem állítja, hogy futásidőben tesztelték; a viselkedés a forrásból következik.

// s119_sink.cpp — C++20, önállóan fordítható
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

class Sink {
public:
    virtual ~Sink() = default;
    virtual void write(std::string_view s) = 0;
    virtual std::unique_ptr<Sink> clone() const = 0;
};

class StdoutSink final : public Sink {
public:
    void write(std::string_view s) override { std::cout << s; }
    std::unique_ptr<Sink> clone() const override {
        return std::make_unique<StdoutSink>(*this);
    }
};

class PrefixSink final : public Sink {
public:
    explicit PrefixSink(std::string prefix) : prefix_(std::move(prefix)) {}
    void write(std::string_view s) override { std::cout << prefix_ << s; }
    std::unique_ptr<Sink> clone() const override {
        return std::make_unique<PrefixSink>(*this);
    }
private:
    std::string prefix_;
};

std::unique_ptr<Sink> make_sink(std::string_view kind) {
    if (kind == "stdout") return std::make_unique<StdoutSink>();
    if (kind == "prefix") return std::make_unique<PrefixSink>("[log] ");
    return nullptr;
}

void emit(const Sink& sink, std::string_view msg) { sink.write(msg); } // borrowed

int main() {
    auto sink = make_sink("prefix");
    emit(*sink, "hello\n");

    auto copy = sink->clone();
    emit(*copy, "world\n");

    std::vector<std::unique_ptr<Sink>> sinks;
    sinks.push_back(std::move(sink));
    sinks.push_back(std::move(copy));

    for (const auto& s : sinks) emit(*s, "!\n");
}

A javasolt buildparancs a laborban:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g s119_sink.cpp -o s119_sink

Diagnosztikai változat cím- és UBSan-ellenőrzéssel:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g \
    -fsanitize=address,undefined s119_sink.cpp -o s119_sink_asan

A példában a Sink destruktora virtuális, ezért a unique_ptr<Sink> törlése a dinamikus típus destruktorát hívja. A clone unique_ptr<Sink>-et ad vissza, így a hívó nem felejtheti el a törlést. Az emit const Sink& paramétere kölcsönzött (borrowed) hozzáférés: nem tulajdonol, nem töröl, és nem él túl a hívó unique_ptr élettartamán.

24.3.5 Destructor dispatch és az erőforrások sorrendje

A teljes objektum destruálása a legutóbb létrehozott részobjektumtól halad a legkorábbi felé. Egy Derived példány esetén a sorrend: a ~Derived() teste, majd a Derived nem statikus tagjai fordított deklarációs sorrendben, majd a ~Base() teste, majd a Base tagjai, végül a tároló felszabadítása. A származtatott tagok tehát előbb szabadulnak fel, mint a bázis tagjai. Ezért a bázis destruktorában nem szabad származtatott tagokra hivatkozni: azok már nem léteznek.

A ~Base() teste alatt a dinamikus típus már Base, ezért a benne hívott virtuális függvények a Base verziójára mennek, még akkor is, ha a teljes objektum eredetileg Derived volt. Ugyanez igaz a konstruktorokra: a Base konstruktorában a dinamikus típus még Base. Ezért a bázis konstruktorában és destruktorában ne hívjunk virtuális függvényt, és ne bízzunk abban, hogy a származtatott felülírás futni fog. Ez a szabály nem a virtuális dispatch hibája, hanem a részobjektum-élettartam következménye.

A virtuális destruktor jelenléte a legtöbb implementációban egy virtuális táblára mutató pointerrel növeli az objektum méretét, és a törlésnél egy indirekciót ad. A konkrét méret- és költségadat implementációfüggő; mérés nélkül nem állítható konkrét szám. A vtable mint reprezentáció nem ISO-követelmény, ezért itt nem tárgyalom részletesen.

24.3.6 Interface ownership, borrowed reference és factory

A factory függvény a tulajdonost adja vissza: std::unique_ptr<Sink> make_sink(std::string_view kind);. A visszatérési típus a tulajdonlást fejezi ki, a hívó unique_ptr-ben tartja, vagy std::move-val továbbadja. Ha a factory ismeretlen típust kap, adhat nullptr-t; ezt a szerződésben dokumentálni kell, és a hívónak ellenőriznie kell. A kölcsönzött hozzáférés ezzel szemben const Sink& vagy Sink* paraméter, amely nem tulajdonol, és nem töröl. A kölcsönzés élettartama soha nem lehet hosszabb, mint a tulajdonosé; ezt a hívó felelőssége biztosítani.

A clone és a move-only owner együtt is használható: a factory unique_ptr<Sink>-et ad, a clone pedig akkor hívható, ha a tulajdonos másolatára van szükség. A clone költsége dinamikus allokáció és a származtatott objektum másolása; a move-only owner költsége egy pointer mozgatása. A választás a tulajdonlási igényből következik, nem teljesítménybecslésből.

24.3.7 Tipikus hibák és javítások

  1. Nem virtuális bázis destruktor és delete Base*. A kód lefordul, de futásidőben UB. Javítás: virtual ~Base() = default;.
  2. unique_ptr<Base> nem virtuális destruktorú bázisra. A make_unique<Derived>() konverziója lefordul, a törlés UB. Javítás: a bázis virtuális destruktora, vagy a unique_ptr<Derived> megtartása.
  3. Érték szerinti bázis paraméter. void f(Base b) szeletel. Javítás: void f(const Base& b), vagy a bázis absztrakttá tétele.
  4. std::vector<Base> heterogén gyűjteményre. A push_back(derived) szeletel. Javítás: std::vector<std::unique_ptr<Base>>.
  5. clone nyers pointerrel. A hívó elfelejti a delete-et, memóriaszivárgás lesz. Javítás: std::unique_ptr<Base> visszatérési típus.
  6. Protected nonvirtual destruktor és unique_ptr<Base>. A default_delete nem fér hozzá a destruktorhoz, ezért a unique_ptr<Base> destruktora nem fordul le. Ez szándékos védelem; ha mégis kell, egyedi deleter vagy barát osztály szükséges.
  7. Virtuális hívás a bázis destruktorában. A hívás a bázis verziójára megy, nem a származtatottra. Javítás: a virtuális hívást a destruktoron kívülre tenni.

24.3.8 Feladatok

  1. Magyarázó feladat. Adott a következő kód. Mi a hiba, miért UB, és miért nem segít az, ha a Derived destruktorát virtual-ra írjuk?

    struct Base { ~Base() = default; };
    struct Derived : Base { std::string payload; };
    int main() { Base* p = new Derived; delete p; }
  2. Kódolási feladat. Alakítsd át a Sink hierarchiát úgy, hogy a Sink destruktora protected nonvirtual legyen, a StdoutSink és a PrefixSink destruktora pedig public. Mutasd meg, miért nem fordul le ekkor a std::unique_ptr<Sink>, és adj egy működő alternatív tulajdonosi konstrukciót.

  3. Diagnosztikai feladat. Adott a void process(Shape s) és a void process(const Shape& s) túlterhelés, valamint egy Circle : Shape objektum. Melyik hívást választja a fordító, és mit tapasztalsz futásidőben? Hogyan detektálod a szeletelést anélkül, hogy a dinamikus típust typeid-vel vizsgálnád?

  4. Tervezési feladat. Tervezz egy std::unique_ptr<Base>-et visszaadó factory-t, amely clone-t is támogat. Miért kell a clone-nak unique_ptr<Base>-t visszaadnia, és miért nem Base*-t? Milyen esetben elég a move-only owner clone nélkül?

24.3.9 Megoldások

1. megoldás. A Base destruktora nem virtuális, a delete p statikus típusa Base*, dinamikus típusa Derived. A delete a Base::~Base()-t hívja, a Derived::~Derived() nem fut le, a payload nem szabadul fel, és a deallokáció a Base méretével történik. Ez UB. A Derived destruktorának virtual-ra írása nem segít, mert a dispatch kiindulópontja a statikus típus destruktora; ha az nem virtuális, a dinamikus dispatch el sem indul. A javítás a bázisban van: virtual ~Base() = default;. Ellenőrzési feltétel: a javított kódban a payload destruktora lefut, és a delete nem okoz UB-t; ezt cím- és UBSan-nal lehet ellenőrizni a laborban.

2. megoldás. A Sink destruktora protected: ~Sink() = default;, a származtatottakban public: ~StdoutSink() = default; és public: ~PrefixSink() = default;. Ekkor a delete Sink* nem fordul le, mert a destruktor nem elérhető. A std::unique_ptr<Sink> sem fordul le, mert a default_delete<Sink> hívja a delete-et, és nem fér hozzá a protected destruktorhoz. Alternatíva: a factory std::unique_ptr<StdoutSink>-et vagy std::unique_ptr<PrefixSink>-et adjon vissza, és a közös kezelést sablon vagy Sink& kölcsönzés végezze; vagy egyedi deletert használjunk, amely barátja a Sink-nek. Ellenőrzési feltétel: a delete Sink* és a unique_ptr<Sink> destruktora fordítási hibát ad, a unique_ptr<StdoutSink> és a Derived d; viszont lefordul.

3. megoldás. A process(c) hívás a Circle objektumra a process(Shape s) túlterhelést választja, mert az érték szerinti paraméter pontos illeszkedést ad; a Shape másoló konstruktora fut, a Circle szeletelődik, és a process belsejében a Shape::area() hívódik. A process(const Shape&) ezzel szemben referenciát köt, nem másol, és a dinamikus típus megmarad. A szeletelés detektálása: a bázis absztrakttá tétele (= 0), a bázis másoló konstruktorának törlése (= delete), vagy a sizeof és a címek helyett a viselkedés tesztelése — például egy olyan származtatott tag, amelynek hiánya a kimenetből látszik. A typeid nem jó detektor, mert a szeletelt objektum dinamikus típusa valóban a bázis. Ellenőrzési feltétel: a process(const Shape&) hívás a származtatott name()-et adja, a process(Shape) a bázisét; absztrakt bázis esetén a érték szerinti változat nem fordul le.

4. megoldás. A factory std::unique_ptr<Base> make(...) szignatúrájú, a clone pedig virtual std::unique_ptr<Base> clone() const = 0;. A clone azért ad unique_ptr<Base>-et, mert így a tulajdonlás a típusba kódolt, a hívó nem felejtheti el a törlést, és a unique_ptr destruktora a virtuális destruktoron keresztül a dinamikus típust törli. A Base* visszatérési típus a hívóra hárítaná a delete-et, és a unique_ptr-be csomagolás elmaradhat, ami szivárgáshoz vezet. A move-only owner clone nélkül akkor elég, ha a tulajdonos soha nem másolódik, csak mozog; például ha a factory által létrehozott objektumot egyetlen fogyasztó kapja meg, és a másolás nem része a szerződésnek. Ellenőrzési feltétel: a clone eredménye unique_ptr<Base>, a dinamikus típus megmarad, és a clone után mindkét objektum függetlenül törölhető.

24.3.10 Összefoglaló

A polimorfikus törlés feltétele, hogy a statikus típus destruktora virtuális legyen; e nélkül a delete Base* UB, és a std::unique_ptr<Base> sem véd meg. A public virtual destruktor a polimorfikus tulajdonlás alapmintája; a protected nonvirtual destruktor a bázis pointeren keresztüli törlést tiltja, és unique_ptr<Base>-bel nem használható. Az object slicing érték szerinti bázis-másolásból ered, nem UB, ezért a védelem a szerződés szintjén kell: absztrakt bázis, referenciás paraméter, vagy törölt másoló konstruktor. A clone a dinamikus típust megőrző másolatot ad unique_ptr<Base>-ként; a move-only interface owner akkor elég, ha a tulajdonlás csak mozog. A destruktorok sorrendje a származtatotttól a bázis felé halad, ezért a bázis destruktorában nem hivatkozhatunk származtatott tagokra.

Forráskorlát: a fejezethez a megadott forrásjegyzet nem tartalmazott konkrét, ellenőrzött szakaszokat vagy citációkat. A fenti szabályok a C++20 nyelvi szerződés általános ismertetései; a konkrét szabvány-paragrafusok, a Core Guidelines ajánlás száma és a compiler-implementáció viselkedése a laborban ellenőrizendő, nem tekinthetők itt bizonyítottnak.