Kivételkezelés és RAII
Czirkos Zoltán · 2019.02.27.
Mire is jó a kivételkezelés? Miért több, mint egy lehetséges módja a hibajelzésnek?
When I give talks on EH [exception handling], I teach people two things:
- POINTERS ARE YOUR ENEMIES, because they lead to the kinds of problems that unique_ptr is designed to eliminate.
- POINTERS ARE YOUR FRIENDS, because operations on pointers can't throw.
Then I tell them to have a nice day :-)– Scott Meyers
A C++ nyelv támogatja a kivételkezelést. Bármely függvény
számára megengedett az, hogy a szokásos visszatérési értéke
helyett – ha valamilyen hibát szeretne jelezni – egy
kivételobjektummal „térjen vissza”. A kivételobjektumokat a
hiba észlelésének helyén a throw
kulcsszóval kell
eldobni. A dobás hatására a végrehajtás a hívási sorban
(vagyis inkább: veremben) a dobáshoz legközelebbi olyan
catch{}
utasításblokkhoz kerül, amely az adott
típusú kivételt el tudja kapni.
A kivételkezelés által a függvények hívóját kényszeríteni tudjuk arra, hogy az észlelés helyén kezelhetetlennek bizonyuló hibával foglalkozzon. Ez a kényszerítés éppen a vezérlésátadásban jelenik meg. Vegyünk egy egyszerű példát, amelyben egy tömb objektumot indexel a használója:
int & IntTomb::operator[] (size_t idx) {
if (idx >= meret)
throw "hiba: tulindexeles";
return dintomb[idx];
}
int main() {
IntTomb v(... valamilyen ctor parameterek ...);
try {
v[12] = 93;
} catch (char const * hibaszoveg) {
std::cerr << hibaszoveg << std::endl;
}
}
Ha a hívó olyan indexet adna az operátornak, amely hibás, túlmutat a tömb méretén, akkor a függvény nem képes helyes visszatérési értéket adni: muszáj lenne visszatérnie egy egész típusú változóval, de nem létezik az a változó, amely helyes lehetne. Ha átadja a hívónak a tömb egyik elemét, azzal becsapja, mert nem azt az indexű elemet adja neki, amit kért. Ha egy másik változót ad, azzal is, mert a hívó nem fog róla tudni, hogy hibát követett el. Márpedig a hibát a hívó okozza.
Normál visszatérés esetén a visszatérési érték egy
int &
, a tömb egyik eleme. Ilyenkor a v[12]=93
értékadás
is megtörténik. Kivétellel „visszatérés” esetén azonban már
az értékadás sem történik meg: a v[12]=93
kifejezés
végrehajtása megszakad, és helyette a végrehajtás az
std::cerr
-re történő kiírásnál folytatódik, mivel az a
catch{}
blokk képes elkapni a dobott szövegre mutató pointert.
Ez jogos, hiszen az értékadás úgysem produkálhatna helyes eredményt:
láttuk az előbb, hogy ha az indexelő operátor bármivel is visszatért
volna, az mindenképp csakis helytelen lehetett volna.
A kivételkezelés bizonyos szempontból nagyon kényelmes. A bonyolult
műveletsorok kódját, amely sorra kritikus, potenciálisan sikertelen műveleteket
tartalmaz (pl. memóriafoglalás, fájlból olvasás stb.), nem kell
teletűzdelnünk hibakezeléssel, a mindenféle
függvényektől visszakapott hibakódok vizsgálatát tartalmazó if()
-ekkel. Helyette az
egész kritikus műveletsort betesszük egy try{}
blokkba, és biztosak lehetünk abban, hogy ha valamelyik művelet
sikertelen volt, az utána lévők nem lesznek végrehajtva az érvénytelen
adatokon.
Más szempontból azonban elég veszélyesen is hangzik. A programunk végrehajtása olyan útra terelődhet, amelyre nem is számítunk; a függvényeinkből olyan helyeken térhetünk vissza, amelyekre ránézésre nem is gondolnánk. Vegyük példának az alábbi függvényt:
void foo(ValamiTomb & tomb, Valami const & v) {
tomb[12] = v;
std::cout << "Siker.\n";
}
Ezt a pici kódrészletet vizsgálva evidensnek tűnik az, hogy a
szöveg a képernyőn meg fog jelenni. Ez azonban közel sincs így, hiába
nem szerepel a return
kulcsszó sehol! Az alábbiak
bármelyike megtörténhet:
- Ha minden rendben van, természetesen végrehajtódik az értékadás, és a kiírás is.
- Előfordulhat azonban, hogy a tömböt túlindexeljük, és az indexelő operátor kivételt dob. Ebben az esetben már az értékadás előtt visszatérünk a függvényből, mivel a kivételt itt nem kaptuk el. Sem az értékadás, sem a kiírás nem történt meg, el sem kezdtük őket.
- Előfordulhat az is, hogy a
Valami
osztály értékadó operátora dobott egy kivételt. Itt nem nagyon derül ki, aValami
osztály mit csinál, de lehet dinamikus adattagja, amely esetben az értékadása egy összetett, explicit memóriakezelést igénylő művelet, amely nem biztos, hogy sikerrel jár. - Előfordulhat, hogy a kiírás közben történik valamilyen hiba – bár azon segíteni úgysem tudunk.
Ha ezt a kódot az alábbi módon egészítjük ki, kész a baj: ha valamelyik függvényben kivétel dobódik, memóriaszivárgásunk lesz.
void foo(ValamiTomb & tomb, Valami const & v) { // rossz lehet
int * t = new int[tomb.meret()];
tomb[12] = v;
std::cout << "Siker.\n";
delete[] t;
}
Látjuk tehát, hogy a kivételkezelés miatt új szempontból kell vizsgálnunk a kódunkat, ha a helyességéről szeretnénk megbizonyosodni. Bár jó dolog, hogy kivétel keletkezése esetén az utasítások végrehajtása megszakad, és alternatív útra kényszerülünk, ennek váratlan bekövetkezése mégis gondot jelenthet.
A fenti programrészben az indexelés és az értékadás a kritikus művelet (talán még a kiírás is, de azzal most ne foglalkozzunk). Javíthatjuk például így:
void foo(ValamiTomb & tomb, Valami const & v) {
int * t = new int[tomb.meret()];
try {
tomb[12] = v;
} catch (...) {
delete[] t;
throw;
}
std::cout << "Siker.\n";
delete[] t;
}
A kritikus kódrészletet egy try{}
blokkba tettük,
hogy a keletkező kivételt el tudjuk kapni. Erről a kivételről nem sokat
tudunk (bár az indexelő és az értékadó operátorok dokumentációjában elvileg
le van írva, milyen hibák keletkezhetnek), úgyhogy simán csak továbbdobjuk
a hívónak. De mindenképp el kell kapnunk, mivel a dinamikus memóriáért, az
int
tömbért, amelyet létrehoztunk, mi felelünk.
A javítás másik, jobban kezelhető módja a következő:
void foo(ValamiTomb & tomb, Valami const & v) {
std::vector<int> t(tomb.meret()); // RAII
tomb[12] = v;
std::cout << "Siker.\n";
}
Tehát a nyers, általunk kezelt dinamikus memóriaterületet (int
tömböt) lecseréljük egy tömb objektumra. Ez azért lesz így helyes, mert ugyan az el
nem kapott kivétel hatására ebből a függvényből kikerülünk – de még mielőtt
a hívónak automatikusan továbbdobódna a kivétel, ennek a függvénynek
a lokális változói megszűnnek, ami egyben azt is jelenti, hogy
a t
objektum destruktora lefut, amely pedig a
felelősségi körébe tartozó dinamikus tömböt felszabadítja.
Így egyébként még az ezidáig utolsó sorban éktelenkedő delete[]
műveletet
is megúsztuk: a destruktor tartalmazza azt.
A tanulság: ha valamilyen erőforrást foglalunk, egyből adjuk oda azt egy objektumnak, mert akkor az erőforrás felszabadítása automatikus lesz, az objektum fog felelni érte. Még kivételek ide-oda röpködése esetén is! Ennek az elvnek a neve RAII, Resource Acquisition Is Initialization. Bár ez arra utal, hogy az erőforrás foglalását az objektum létrejöttéhez kötjük, a hangsúly igazából a destruktoron és a felszabadításon van.
Az előbb az RAII elv alapján az erőforráskezelés
problémáját az std::vector<>
osztályra toltuk ki.
Nézzük meg egy kicsit a problémakört ennek az osztálynak a szemszögéből:
egy egyszerű dinamikus tömb sablonosztályon keresztül.
template <typename T>
class Vektor {
private:
T *adat;
size_t meret;
public:
explicit Vektor(size_t s = 0) {
meret = s;
adat = new T[s];
}
~Vektor();
Vektor(Vektor const & masolando);
Vektor & operator=(Vektor const & masolando);
};
A konstruktorban már látszik, hogy ez az osztály egy dinamikusan foglalt tömbért felel, emiatt tudjuk, hogy a destruktor így kell kinézzen:
template <typename T>
Vektor<T>::~Vektor() {
delete[] adat;
}
A destruktorról egyből eszünkbe jut az ökölszabály is, miszerint ha egy osztálynak a destruktor, másoló konstruktor, értékadó operátor közül bármelyik kell, akkor szinte biztos, hogy mind a három kell. Gyorsan egy másoló konstruktort:
template <typename T>
Vektor<T>::Vektor(Vektor<T> const & masolando) { // rossz
meret = masolando.meret;
adat = new T[meret];
for (size_t i = 0; i < meret; ++i)
adat[i] = masolando.adat[i];
}
Az előző részben leírtakból okulva itt azonban egyből be is ránthatjuk
a kéziféket. A helyzet ugyanis: ebben a pársoros függvényben egy csomó
helyen keletkezhet kivétel. Némelyek olyanok, amelyekről nem is tudunk
semmit, mivel a T
típus a sablon kódban ismeretlen számunkra!
- A
new T[meret]
kifejezés hatására először lefoglalódikmeret
darabT
-nek elegendő memóriaterület. Ha ez a foglalás nem sikerül, akkor egystd::bad_alloc
kivétel dobódik.- Gond ez nekünk? Igen, mert nem tud létrejönni a vektor. De a kivételt szándékosan nem kapjuk el, mert a konstruktorból csak így tudjuk jelezni a hibát.
- Ezek után lefut
meret
darabT()
alapértelmezett konstruktor. Ezek közül bármelyik dobhat kivételt.- Vajon ez gond? Fogasabb kérdés, hiszen a dinamikus memóriaterület már le van foglalva, sőt az objektumok egy része inicializálva van, a másik részük pedig még memóriaszemét. De ezt a kivételt sem szabad elkapnunk: a fordító biztosítja számunkra, hogy tömb foglalásakor a már inicializált elemek destruktora lefut, és a dinamikus memóriaterület automatikusan felszabadul. (Ez csakis erre az esetre igaz! Máskor a tömb nem szabadul fel magától.)
- Végül jönnek a másolások. A
T
osztályoperator=
értékadó operátora fog lefutnimeret
-szer. Ezek közül bármelyik kivételt dobhat. Mivel ilyenkorra a tömb foglalása megtörtént, ezért ezen a ponton a tömbért már mi felelünk!
Tehát az utóbbi esetet kezelnünk kell. Mivel itt most nem szeretnénk egy
std::vector
-t használni (épp azt hivatott bemutatni az írás, hogy
annak a belsejében mi történik), kénytelenek vagyunk elkapni ezt a kivételt,
és a tömb felszabadítását „kézzel” elvégezni:
template <typename T>
Vektor<T>::Vektor(Vektor<T> const & masolando) {
meret = masolando.meret;
adat = new T[meret];
try {
for (size_t i = 0; i < meret; ++i)
adat[i] = masolando.adat[i];
} catch (...) {
delete[] adat;
throw;
}
}
Így már nem fog memóriaszivárgást okozni az, ha a Vektor objektumot nem lehet létrehozni. A hívó pedig, aki megpróbálta a másoló konstruktorát lefuttatni, kivételt fog kapni.
A new T[meret]
rész nem került bele a try{}
blokkba,
mivel annak memóriaszivárgás-mentességét a fentiek szerint a fordító biztosította.
Nem is lenne szabad beletennünk! Ha az is a try{}
blokkban lenne,
akkor elkapnánk azt a kivételt is, amely a new
-tól származik. Ez
a kivétel azonban az adat
pointer értékadása előtt keletkezik, tehát
a catch{}
blokkba úgy kerülnénk, hogy az adat
pointer
még memóriaszemét. A delete[]
-en pedig ilyenkor elhasalna a program.
Nézzük végül az értékadó operátort. Tudjuk, hogy az összeollózható a destruktorból és a másoló konstruktorból, de nézzük nagyon kritikusan az eredményt:
template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> const & masolando) { // rossz
if (this != &masolando) {
delete[] adat;
meret = masolando.meret;
adat = new T[meret];
try {
for (size_t i = 0; i < meret; ++i)
adat[i] = masolando.adat[i];
} catch (...) {
delete[] adat;
throw;
}
}
return *this;
}
Bár a kivételbiztos másoló konstruktort másoltuk be ide, rá kell
jönnünk, hogy ez így egyáltalán nem lesz jó. Ugyanis bármelyik helyen
dobódik kivétel, akár a new
-nál, akár a
for()
ciklusban, meg vagyunk lőve: a régi tömböt, a
régi T
-ket már felszabadítottuk, az újakat
pedig nem tudjuk létrehozni! Ilyenkor a régi T
-k
felszabadítását már nem tudjuk visszacsinálni. A Vektor használója pedig
hiába teszi az értékadást try-catch
blokkba:
Vektor<std::string> v1 = ....., v2 = .....;
try {
v1 = v2;
} catch (...) {
std::cerr << "Nem sikerült az értékadás.\n";
}
std::cerr << "De most itt mi van az v1-ben?!?!\n";
Addig ugyan rendben, hogy ő értesül a hibáról, és tudja, hogy az
értékadás nem sikerült. De mi lesz ezután a v1
objektumával? Az az objektum ilyenkor érvénytelen, félholt
állapotban van: nincsen dinamikus tömbje. A v1.adat
pointer memóriaszemét. Még ha nem is használja ezt az v1
objektumot semmire a hívó, akkor is előbb-utóbb le kell futnia a
destruktorának, amely pedig azt várja, hogy értelmes adatot lásson
a pointerben.
Ez megengedhetetlen. A legjobb megoldás az lenne, ha el lehetne valahogyan érni azt, hogy az értékadás atomi, bonthatatlan legyen. Azaz vagy történjen meg teljes egészében, hibátlanul, vagy pedig dobódjon kivétel, de akkor az objektum maradjon „sértetlen”, sőt lehetőleg tartsa is meg az eredetileg tárolt adatokat. Ezt úgy tudjuk megoldani, ha a destruktorból és a másoló konstruktorból összeollózott részeket megcseréljük, vagyis előbb végezzük el a kritikus lépéseket, tehát a másolást, és csak utána a felszabadítást. Valahogy így:
template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> const & masolando) { // jó (jó bonyolult)
if (this != &masolando) {
size_t ujmeret = masolando.meret;
T * ujadat = new T[ujmeret];
try {
for (size_t i = 0; i < ujmeret; ++i)
ujadat[i] = masolando.adat[i];
} catch (...) {
delete[] ujadat;
throw;
}
delete[] adat;
adat = ujadat;
meret = ujmeret;
}
return *this;
}
Nézzük meg, ez helyes-e. Ha a new T[]
kifejezés dob,
azt nem kell elkapnunk. A hívó megkapja, és tudja, hogy az értékadás sikertelen.
Se a meret
, se az adat
adattaghoz nem nyúltunk, így
az objektum változatlanul maradt. Ezután jön a T
-k másolása.
Ha valamelyik T::operator=
dob, elkapjuk, felszabadítjuk az
elkezdett másolat tömböt, és továbbdobjuk a kivételt. A hívó értesül, a
vektor továbbra is érvényes és változatlan.
Ha ezek is mind sikerültek, készen áll a másolat. Innentől jön a régi
adatok törlése: a delete[] adat
. Dobhat ez kivételt?
Nem! A szabvány megköveteli, hogy a destruktorok ne dobjanak
kivételt, és ha a T
osztályt szabványkövető módon írták meg,
akkor az sem dob. (Amúgy is, miért dobna, a memória felszabadítása
egyszerű művelet, a foglalás a necces.) Végül két értékadással fejezzük be, amelyek közül az
egyik egy egész számot, a másik pedig egy pointert másol. Ezek közben
sem keletkezhet kivétel, a világ legegyszerűbb műveletei.
Feltűnhet, hogy megint kódrészletet duplikáltunk, amiről pedig tudjuk, hogy nem helyes. Ezt a kódduplikációt itt egy trükkel el tudjuk kerülni – ennek a trükknek a neve a „copy and swap”, vagyis a másolás és csere. A trükk a következő:
template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> const & masolando) { // jó!
if (this != &masolando) {
Vektor<T> masolat(masolando);
std::swap(this->adat, masolat.adat);
std::swap(this->meret, masolat.meret);
}
return *this;
}
Ez a kódrészlet a következőt csinálja:
- Átveszi a
masolando
objektumot, amelyből az értékadást kell végezni, tehát amelyet tulajdonképpen a*this
-be be kell másolni. - Készít erről egy másolatot a másoló konstruktorral. Vegyük észre, hogy ez a másolás az összes kritikus műveletet magában foglalja. Ha nem sikerül, akkor ez a másoló konstruktor hívás kivételt fog dobni, amelyet ez a kódrészlet nem kap el.
- Ezek után pedig megcseréli a másolatot saját magával,
tehát a
masolat
objektumot a*this
-szel. A csere az adattagok cseréjét jelenti, tehát a pointerét és a méretét.
Mi történt itt?! Nagyon egyszerű: az értékadó operátor készített a lemásolandó adatokról egy másolatot. Utána pedig ebből a másolat objektumból ellopta a lemásolt adatokat magának (elvette tőle az adatok pointerét). A régi, felszabadítandó adatokat pedig belecsempészte a másolat objektumba, amely az utasításblokk végén megszűnik, és így a felszabadításra szánt adatok végül tényleg megszűnnek!
Tehát közvetlenül a másolás után három objektum van:
Ezek után jön a csere, amely kicseréli a tömböket.
Mire a másolat objektum törlődik, addigra a csere által hozzá kerültek
a törlendő adatok. Ezeket a masolat
nevű objektum destruktora
fogja végül törölni:
Az értékes adatok (a másolat) pedig megmaradnak a *this
-nél. És ennyi.
Ezt a trükköt még tovább el szokás vinni. Ugyanis ha már úgyis szükségünk van egy másolatra, akkor minek is vesszük át referenciával az objektumot – miért nem vesszük át értékként, hogy már eleve egy komplett másolatot kapjunk? Hiszen a paraméterek nem egyebek, mint kívülről inicializált lokális változók. A függvény tehát átírható így:
template <typename T>
Vektor<T> & Vektor<T>::operator=(Vektor<T> masolat) { // még jobb!
std::swap(this->adat, masolat.adat);
std::swap(this->meret, masolat.meret);
return *this;
}
Mire a függvény törzsébe jut a végrehajtás, addigra az összes kritikus
művelet megtörtént (már a paraméterátadáskor), és nincs más hátra, mint
a csere elvégzése. Az önértékadás ellenőrzését el is hagyhatjuk, hiszen
a másolat objektum úgysem lehet azonos a *this
-szel.
- Az alábbi kódrészlettel találkozunk valahol.
Helyes ez kivételkezelési szempontból? Ha igen, miért? Ha nem,
hogyan lehet javítani?
/* egy deklaráció valahol a kódban */ void fv(X *egyik, X *masik); /* valahol egy függvényben */ fv(new X, new X);
- Mi a helyzet a kivételkezelés szempontjából az alábbi függvénnyel?
Ha problémás lehet, hogyan kellene javítani?
/* dinamikusan foglalt X-szel tér vissza. */ X * create_new_x(); void foo(ValamiTomb & tomb, Valami const & v) { X * ptr = create_new_x(); tomb[12] = v; std::cout << "Siker.\n"; delete ptr; }
- Vajon kell-e másoló konstruktort és értékadó operátort írni az alábbi osztálynak?
Kell-e neki másoló konstruktor és értékadó operátor, ha figyelembe
vesszük a kivételeket?
template <typename T1, typename T2> class Par { T1 egyik; T2 masik; };
- Az alábbi bináris fát megvalósító osztály két pointert tartalmaz, amelyek
dinamikusan foglalt gyerekekre mutatnak. Hogy kell megírni ennek a destruktorát,
másoló konstruktorát és értékadó operátorát?
class BinFa { private: int adat; BinFa *bal, *jobb; public: ... };
- Van-e hátránya a „copy and swap” stílusú értékadó operátornak a hagyományos, destruktor-aztán-másoló-konstruktor-copypaste megoldással szemben? Ha igen, mi az?