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.
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.
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:
A destrukció sorrendje ennek fordítottja:
| 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.cppA 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ó.
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.cppA 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é.
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.
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.cppA 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.
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.
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.
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.
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.
Í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.
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;.
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.
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.
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.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.