20.3 Destruktor és részleges konstrukció

A destruktor az objektum élettartamának végét jelöli, nem a tároló felszabadítását. Ez a különbség a C++ erőforrás-kezelésének egyik sarokköve: a destruktor a logikai élettartam lezárása, a tároló felszabadítása (storage release) külön művelet. A fejezet a C++20 nyelvi magjára támaszkodik, és az Osztály, konstruktor és invariáns (20.2) részben bevezetett konstruktor/invariáns fogalomra, valamint a Leállás, cleanup és nem lokális vezérlés (3.5) rész C cleanup-ágára épít. A cél, hogy a konstrukció és a destrukció sorrendjét, a részleges felépülés eseteit és a tárolókezelés különválasztását biztosan le tudd vezetni.

20.3.1 Az automatikus objektum élettartamának vége és a scope exit

Automatikus tárolási idővel rendelkező objektum esetén a destruktor a hatókör (scope) elhagyásakor fut le. A hatókör kilépése (scope exit) többféle lehet: a blokk vége, return, throw miatti stack unwinding, goto-val történő kiugrás, illetve break/continue a ciklusblokkból. A lényeg, hogy a blokkban deklarált automatikus objektumok destruktorai a deklaráció sorrendjének fordítottjában futnak le. Ha egy blokkban A a; B b; szerepel, akkor a kilépéskor előbb ~B(), majd ~A() fut.

Ez a szabály akkor is érvényes, ha a kilépés nem a blokk utolsó utasítása. Egy return előtt a lokális objektumok destruálódnak, majd a visszatérési érték átadása következik. Egy throw esetén a stack unwinding során a kilépett hatókörök automatikus objektumai destruálódnak, egészen addig, amíg a kivételt el nem kapják. A goto-val történő kiugrás szintén lezárja az elhagyott hatókör automatikus objektumait; ezért a goto használata C++-ban különösen óvatosságot igényel, ha az ugrás objektumokat kerül meg.

Fontos kivétel a std::exit: ez nem hívja meg az automatikus tárolási idővel rendelkező objektumok destruktorát, a statikus tárolási idővel rendelkező objektumokét viszont igen. Ez a Leállás, cleanup és nem lokális vezérlés (3.5) részben tárgyalt nem lokális vezérléshez kapcsolódik: a std::exit, a std::abort és a std::terminate nem tekinthető úgy, mintha a normál scope exit lefutna. A destruktorokra épülő takarítás ezért csak a normál visszatérés, a kivétel és a blokkból való kilépés esetén garantált.

A destruktor hívása megkezdi az objektum élettartamának végét. A tároló ettől még létezhet: az automatikus objektum tárolója a stack frame része, és a frame lebontásával szabadul fel. A dinamikus objektum tárolója csak a delete deallokációs lépésével szabadul fel. A kettő nem azonos.

20.3.2 A részobjektumok destrukciójának fordított sorrendje

Egy osztály destruktora nem csak a saját törzsét futtatja: sorra hívja a nem statikus adattagok és a bázisosztályok destruktorait is. A konstrukció sorrendje és a destrukció sorrendje egymás tükörképe. A konstrukció sorrendje:

  1. a virtuális bázisok,
  2. a közvetlen bázisok a bázislista sorrendjében,
  3. a nem statikus adattagok a deklarációs sorrendben,
  4. végül a konstruktor törzse.

A destrukció sorrendje ennek fordítottja:

  1. a destruktor törzse,
  2. az adattagok fordított deklarációs sorrendben,
  3. a közvetlen bázisok fordított bázislista-sorrendben,
  4. végül a virtuális bázisok.
Sorrend Konstrukció Destrukció
első virtuális bázisok destruktor törzs
második közvetlen bázisok adattagok fordított deklarációs sorrendben
harmadik adattagok deklarációs sorrendben közvetlen bázisok fordított sorrendben
utolsó konstruktor törzs virtuális bázisok

A leggyakoribb hiba, hogy a taginicializáló lista (mem-initializer lista) sorrendjét tekintjük mérvadónak. Nem az: a tényleges inizializálási és destrukciós sorrendet a deklarációs sorrend dönti el. Ha a taginicializáló lista eltér ettől, a fordító figyelmeztethet, de a szemantika a deklarációs sorrend.

Tekintsük a következő példát. A D bázisa B, adattagjai A a; C c;. A konstrukció: B(), A(), C(), majd a D törzse. A destrukció: a D törzse, majd ~C(), ~A(), végül ~B().

#include <iostream>

struct A { A() { std::cout << "A()\n"; } ~A() { std::cout << "~A()\n"; } };
struct B { B() { std::cout << "B()\n"; } ~B() { std::cout << "~B()\n"; } };
struct C { C() { std::cout << "C()\n"; } ~C() { std::cout << "~C()\n"; } };

struct D : B {
    A a;
    C c;
    D() { std::cout << "D()\n"; }
    ~D() { std::cout << "~D()\n"; }
};

int main() {
    D d;
}

A fordítás a tanuló laborjában például ezzel a paranccsal történhet:

g++ -std=c++20 -Wall -Wextra -pedantic -o order order.cpp

A program várható kimenete (a tényleges futtatás a labor feladata): B(), A(), C(), D(), ~D(), ~C(), ~A(), ~B(). A példa nem állítja, hogy lefutott; a kimenet a fenti sorrendi szabályokból vezethető le. Ellenőrzési feltétel: a ~C() megelőzi a ~A()-t, és a ~B() az utolsó.

20.3.3 Kivétel a konstrukció alatt: részleges felépülés

Ha egy konstruktor a törzsében vagy egy adattag/bázis konstruktorában kivételt dob, az objektum élettartama nem kezdődött el. A szabvány nyelvén ez nem „részben felépült objektum”, hanem egy olyan helyzet, amikor a teljes objektum nem jött létre; a már teljesen felépült részobjektumok (subobjectek) élettartama viszont véget ér, és a destruktoruk lefut. A teljes objektum destruktora nem fut le. A „részleges konstrukció” (partial construction) itt pedagógiai név erre a helyzetre.

A szabály lényege: a konstruktor törzsének megkezdése előtt a bázisok és az adattagok már felépültek; ha a törzs dob, ezek destruálódnak. Ha egy adattag konstruktora dob, az utána következő adattagok és a törzs már nem épülnek fel; az előbb felépült adattagok és bázisok destruálódnak. A dobó adattag destruktora nem fut, mert annak a konstrukciója nem fejeződött be. A teljes objektum destruktora sem fut.

#include <iostream>
#include <stdexcept>

struct Base {
    Base() { std::cout << "Base()\n"; }
    ~Base() { std::cout << "~Base()\n"; }
};
struct Member {
    Member() { std::cout << "Member()\n"; }
    ~Member() { std::cout << "~Member()\n"; }
};
struct Thrower {
    Thrower() { std::cout << "Thrower()\n"; throw std::runtime_error("boom"); }
    ~Thrower() { std::cout << "~Thrower()\n"; }
};

struct Derived : Base {
    Member m;
    Thrower t;
    Derived() { std::cout << "Derived()\n"; }
    ~Derived() { std::cout << "~Derived()\n"; }
};

int main() {
    try {
        Derived d;
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << "\n";
    }
}
g++ -std=c++20 -Wall -Wextra -pedantic -o partial partial.cpp

A várható kimenet: Base(), Member(), Thrower(), majd ~Member(), ~Base(), végül caught: boom. A ~Thrower() és a ~Derived() nem fut le. A Derived törzse sem fut le. A Member és a Base destruktora azért fut, mert ők teljesen felépültek. A destrukció sorrendje itt is fordított: a Member a Base előtt destruálódik.

Ebből következik a gyakorlati szabály: a konstruktor törzsében szerzett, nem adattag erőforrásokért a konstruktornak kell felelnie. Ha a törzs dob, az adattagok destruktora lefut, de a törzsben nyitott file descriptor, lefoglalt memória vagy zárolt mutex nem lesz automatikusan felszabadítva. Ezért érdemes minden erőforrást adattaggá tenni, amelynek a destruktora takarít. A részleges felépülés így válik kezelhetővé.

20.3.4 A destruktor default és noexcept jellege

A destruktor kivételspecifikációja C++11 óta implicit: ha nincs kiírva, a destruktor pontosan akkor noexcept(true), ha minden bázis és adattag destruktora az; ha valamelyik noexcept(false), akkor a destruktor is noexcept(false). Ez a szabály a saját, felhasználó által írt destruktorra is érvényes, ha nem ad meg noexcept specifikációt; a törzsében álló throw nem befolyásolja az implicit specifikációt, az csak a részobjektumoktól függ. Explicit noexcept(true) vagy noexcept(false) mindkét irányban felülírja. A = default destruktor nem azt jelenti, hogy nem történik semmi: a tagok és bázisok destruktorát hívja, csak a definíciót a fordító állítja elő.

Eset A destruktor noexcept specifikációja Következmény
nincs felhasználói destruktor; minden adattag és bázis destruktora noexcept(true) noexcept(true) a destruktor nem dob
valamely adattag vagy bázis destruktora noexcept(false) noexcept(false) a destruktor dobhat, a hívónak kezelnie kell
felhasználói destruktor noexcept megadása nélkül mint az implicitnél: noexcept(true), ha minden bázis és tag destruktora az, különben noexcept(false) noexcept(true) esetén a törzsből kilépő kivétel std::terminate-hez vezet
felhasználói destruktor explicit noexcept(true), noexcept(false) tag mellett noexcept(true) a tag destruktorából kilépő kivétel is std::terminate
felhasználói destruktor noexcept(false) noexcept(false) tudatos kivétel; ritka és veszélyes
// destructor_exception_spec.cpp — C++20
#include <type_traits>

struct Risky  { ~Risky() noexcept(false) {} };

struct Owner  { Risky r; ~Owner() {} };                  // saját dtor, nincs specifikáció
struct Forced { Risky r; ~Forced() noexcept(true) {} };  // explicit noexcept(true)
struct Plain  { int v; ~Plain() { /* throw itt is csak terminate-et adna */ } };

static_assert(!std::is_nothrow_destructible_v<Owner>);   // a tagtól örökli: noexcept(false)
static_assert( std::is_nothrow_destructible_v<Forced>);  // explicit felülírás
static_assert( std::is_nothrow_destructible_v<Plain>);   // minden részobjektum noexcept

int main() {}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic destructor_exception_spec.cpp figyelmeztetés nélkül fordul, tehát mindhárom static_assert teljesül: az Owner saját destruktora a Risky tagtól noexcept(false)-t örököl, a Forced explicit noexcept(true)-ja ezt felülírja.

A destruktornak nem szabad kivételt dobnia. Ha egy noexcept(true) destruktor mégis dob, a program std::terminate-mel leáll. Ha noexcept(false) destruktort használunk, és a destruktor egy másik kivétel miatti unwinding közben dob, az is std::terminate-hez vezet. A takarítási hibát ezért naplózni kell, vagy egy külön, explicit close() metódusra kell bízni, amelynek a hibáját a hívó kezelheti. A destruktor feladata a források felszabadítása, nem a hibák propagálása.

20.3.5 delete, destruktor és a tároló felszabadítása

A delete kifejezés két lépést végez: először meghívja a dinamikus objektum destruktorát, majd felszabadítja a tárolót az operator delete segítségével. A delete[] ugyanezt teszi a tömb minden elemére, majd az operator delete[]-et hívja. A destruktor önmagában nem szabadítja fel a tárolót; a tároló felszabadítása külön művelet.

Ez akkor válik igazán láthatóvá, ha a konstrukció és a tárolókezelés szétválik. A placement new csak a konstrukciót végzi el egy már meglévő tárolóban; a destruktort explicit kell meghívni, a tárolóról pedig külön kell gondoskodni.

#include <iostream>
#include <new>

struct Tracer {
    Tracer() { std::cout << "Tracer()\n"; }
    ~Tracer() { std::cout << "~Tracer()\n"; }
};

int main() {
    alignas(Tracer) unsigned char buf[sizeof(Tracer)];
    Tracer* p = new (buf) Tracer;
    p->~Tracer();
}
g++ -std=c++20 -Wall -Wextra -pedantic -o storage storage.cpp

A várható kimenet: Tracer(), ~Tracer(). A buf tárolója tovább él; a delete p itt nem definiált viselkedés lenne, mert a p nem new-val jött létre. A destruktor csak az objektum élettartamát zárta le, a tárolót nem szabadította fel. Ugyanez a kettéválasztás figyelhető meg a std::vector esetében is: a clear() destruálja az elemeket, de a kapacitás megmarad; a shrink_to_fit() a tárolót is felszabadíthatja. Az allocator modellben a destroy és a deallocate külön művelet; erről az Allocator model és allocator_traits (37.1) rész szól.

A delete és a destruktor összekeverése tipikus hiba. A delete a destruktor és a deallokáció együttese; a destruktor nem „delete-el”. A placement new-val létrehozott objektumra a delete használata UB. A delete[] elhagyása tömb esetén szintén UB. A polimorf bázispointeren történő delete csak virtuális destruktorral biztonságos; erről a Virtuális destruktor, slicing és ownership (24.3) rész szól.

20.3.6 Részleges inicializálás C-ben és C++-ban

C-ben nincs destruktor, ezért a részleges inicializálás takarítását kézzel kell megoldani. A tipikus minta a goto cleanup: minden erőforrás megszerzése után a hibás ág a közös cleanup címkére ugrik, amely a már megszerzett erőforrásokat felszabadítja. A cleanup ágnak minden lehetséges hibapontot le kell fednie, és a sorrendet kézzel kell helyesen tartani.

int open_pair(const char *path, char **out_buf, size_t n) {
    int fd = open(path, O_RDONLY);
    if (fd < 0) return -1;
    char *buf = malloc(n);
    if (buf == NULL) {
        close(fd);
        return -1;
    }
    *out_buf = buf;
    return fd;
}

Ez részlet; a hívó felelős a close(fd) és a free(buf) hívásáért. A C++ megfelelője az, hogy a már felépült adattagok destruktora automatikusan takarít, de csak akkor, ha minden erőforrás adattag. A következő részlet ezt szemlélteti:

#include <fcntl.h>
#include <unistd.h>
#include <cstddef>
#include <memory>
#include <stdexcept>

class Resource {
    int fd_ = -1;
    std::unique_ptr<char[]> buf_;
public:
    Resource(const char* path, std::size_t n)
        : fd_(open(path, O_RDONLY)), buf_(new char[n]) {
        if (fd_ < 0) throw std::runtime_error("open");
    }
    ~Resource() { if (fd_ >= 0) close(fd_); }
    Resource(const Resource&) = delete;
    Resource& operator=(const Resource&) = delete;
};

Ez részlet; a teljes RAII-kezelést a RAII wrapper file descriptorhoz és lockhoz (21.2) rész tárgyalja. A példa lényege: a fd_ nyers int, a buf_ viszont std::unique_ptr. Ha a buf_ allokációja std::bad_alloc-ot dob, a fd_ már nyitva van, de nincs olyan destruktora, amely bezárná; a Resource destruktora nem fut le, mert a konstrukció nem fejeződött be. A fd_ nyitva marad. A megoldás az, hogy a file descriptor is RAII-adattaggá válik (például egy unique_fd wrapperrel), így a részleges felépülés alatt is lezáródik. A C cleanup-ág és a C++ RAII ugyanazt a célt szolgálja: a már megszerzett erőforrások felszabadítását a hibaágakon is. A C++ eszköze az adattagok destruktora; a konstruktor törzsében szerzett nem adattag erőforrásokért továbbra is a konstruktornak kell felelnie.

20.3.7 Gyakori hibák és javításuk

A leggyakoribb hibák: kivétel dobása a destruktorban (a noexcept(true) miatt std::terminate); a delete és a destruktor összekeverése; a taginicializáló lista sorrendjének összetévesztése a deklarációs sorrenddel; nyers erőforrás a konstruktor törzsében, amely nem adattag, ezért részleges felépülés esetén nem szabadul fel; delete használata placement new-val létrehozott objektumra; a delete[] elhagyása tömb esetén; virtuális destruktor hiánya polimorf bázisnál. A javítás rendre: a destruktor ne dobjon; a delete a destruktor és a deallokáció együttese; a tagok sorrendjét a deklaráció dönti el; minden erőforrást adattaggá kell tenni; placement new esetén explicit destruktorhívás és külön tárolókezelés; tömbnél delete[]; polimorf bázisnál virtuális destruktor.

20.3.8 Összefoglaló

A destruktor az objektum élettartamának végét jelöli; a tároló felszabadítása ettől független művelet. Automatikus objektumnál a scope exit hívja a destruktort, a blokkban deklarált objektumok fordított sorrendben destruálódnak. Az osztály destruktora a törzs után az adattagokat fordított deklarációs sorrendben, majd a bázisokat fordított sorrendben destruálja. Ha a konstrukció kivétellel megszakad, a már felépült részobjektumok destruálódnak, a teljes objektum destruktora nem. A destruktor alapértelmezés szerint noexcept(true), ezért nem szabad dobnia. A delete a destruktort és a deallokációt együtt végzi; a placement new és az explicit destruktorhívás esetén a kettő szétválik.

20.3.9 Feladatok

  1. Adott a vázlat: struct Base { ~Base(); }; struct M { ~M(); }; struct D : Base { M m1; M m2; ~D(); }; Írd fel a konstrukció és a destrukció sorrendjét, és indokold, miért nem a taginicializáló lista sorrendje dönt.

  2. Írj programot, amelyben a Derived bázisa Base, adattagjai Member m; Thrower t;, és a Thrower konstruktora std::runtime_error-t dob. Naplózd a konstruktorokat és destruktorokat. Fordítsd g++ -std=c++20 -Wall -Wextra -pedantic parancsal. Add meg a várható kimenetet és az indoklást.

  3. Az alábbi részletekben találj hibát, és javítsd: (a) struct Bad { ~Bad() { throw std::runtime_error("x"); } }; (b) Tracer* p = new (buf) Tracer; delete p;.

20.3.10 Megoldások

20.3.10.1 Az első feladat megoldása

A konstrukció sorrendje: Base, m1, m2, majd a D törzse. A destrukció sorrendje: a D törzse, ~m2, ~m1, végül ~Base. Az adattagok a deklarációs sorrendben inicializálódnak, nem a taginicializáló lista sorrendjében; a destruktorok ennek fordítottjában futnak. Ellenőrzési feltétel: a felsorolt sorrend minden lépése szerepel, és a m2 a m1 előtt destruálódik.

20.3.10.2 A második feladat megoldása

A fejezet Derived/Thrower példája pontosan ezt mutatja. A várható kimenet: Base(), Member(), Thrower(), majd a Thrower konstruktorában a dobás után ~Member(), ~Base(), végül caught: boom. A ~Thrower() és a ~Derived() nem fut, mert a Thrower nem épült fel, a Derived élettartama pedig el sem kezdődött. Ellenőrzési feltétel: a kimenetben a ~Thrower() és a ~Derived() nem szerepel, a ~Member() a ~Base() előtt van, és a caught a végén.

20.3.10.3 A harmadik feladat megoldása

  1. A destruktor alapértelmezés szerint noexcept(true), ezért a benne dobott kivétel std::terminate-hez vezet. A javítás: a destruktor ne dobjon; a hibát naplózni kell, vagy a takarítást egy explicit close() metódusra kell bízni. Ha mégis noexcept(false)-t használunk, a hívónak kell kezelnie, és ügyelni kell arra, hogy egy másik kivétel miatti unwinding közben a dobás ismét std::terminate-hez vezet. (b) A delete p nem definiált viselkedés, mert a p placement new-val jött létre, nem new-val; a helyes hívás p->~Tracer();. A buf tárolója tovább él; a delete a tárolót is felszabadítaná, ami itt hibás. Ellenőrzési feltétel: a javított kódban a destruktor nem dob, és a placement new-os objektumra nincs delete.

20.3.11 Verzió- és forráskorlátok

A fejezet a C++20 nyelvi magra támaszkodik. A C++23 és a konkrét fordítók eltéréseit a laborban, a választott fordító dokumentációjával kell ellenőrizni. A megadott kiinduló URL-ek nem kerültek feldolgozásra, ezért a fejezet nem tartalmaz ellenőrzött külső forráshivatkozást; a szabványparagrafusok és a mérési eredmények ellenőrzése a labor része.