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.
delete bázis pointerenA 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.
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
}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.
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_sinkDiagnosztikai 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_asanA 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.
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.
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.
delete Base*. A kód lefordul, de futásidőben UB.
Javítás: virtual ~Base() = default;.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.void f(Base b) szeletel. Javítás:
void f(const Base& b), vagy a bázis absztrakttá
tétele.std::vector<Base> heterogén
gyűjteményre. A push_back(derived) szeletel.
Javítás:
std::vector<std::unique_ptr<Base>>.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.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.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; }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.
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?
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?
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ő.
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.