Kivételek
Czirkos Zoltán · 2025.09.02.
Kivételkezelés
A laborokhoz
A laborok mellé minden héten lesz kiírva egy beadandó az admin portálon. Ide óra végén töltsd fel a forráskódokat (*.cpp, *.h)! A feladatokat ezért külön projektben oldd majd meg, ne írd felül a megoldásokat.
Labor otthoni munkában
A labor teljesítéséhez legalább az első két feladatot meg kell oldani.
- Rejtett komplexitás: a megoldásokat kommentben írd a kód mellé, magyarázatokkal.
- Kivételek a veremben: a kódot ne add be teszteletlenül! Figyelj arra, hogy a sablon kódnak csak a használt része példányosodik; hívd meg a tesztben mindegyik függvényt! Előbb dolgozatsz egész számokkal, de később próbáld ki a vermet úgy is, hogy „igazi” munkát is végez a beszúrás és a törlés; konstruktorral, destruktorral rendelkező osztállyal példányosítsd a vermet! Lehet ez
std::string
, vagy az extrák alatt elérhető SuperNoisy.- Bináris fa: tesztelj is, mint az előzőnél!
Adott az alábbi kódrészlet. Minden további információ nélkül (csak ezt a részét ismerjük a programnak), a kérdés: a végrehajtása hány különböző vezérlési utat eredményezhet?
String EvaluateSalaryAndReturnName(Employee e) {
if (e.Title() == "CEO" || e.Salary() > 100000) {
cout << e.First() << " " << e.Last()
<< " is overpaid" << endl;
}
return e.First() + " " + e.Last();
}
A legszembetűnőbb természetesen az if()
utasítás: vagy végrehajtódik
a kiírás, vagy nem – ez az elágazás eleve két lehetséges végrehajtási utat ad. Hol van a többi
lehetséges út, ha nem keletkezik kivétel, és akkor, ha igen?
Tipp
Az alábbiakra gondolj:
- Vezérlési utasítással megadott utak.
- Rövidzár tulajdonsággal rendelkező operátorok.
- A programozó által definiált operátorok.
- Sikertelen konstruktorhívások (pl. érték szerinti visszatérés).
- Sikertelen I/O műveletek.
Megoldás
Ez a feladat a GotW #20 fordítása.
Ha nem dobódik kivétel:
1. Ha e.Title() == "CEO"
, akkor a kifejezés második fele nem értékelődik ki, azaz e.Salary()
sosem
hívódik meg, a ||
operátor rövidzár-tulajdonsága miatt.
2. Ha e.Title() != "CEO"
, de e.Salary() > 100000
, akkor mindkét részkifejezés kiértékelődik, és a kiírás
végrehajtódik.
3. Ha e.Title() != "CEO"
és e.Salary() <= 100000
, a kiírás nem hajtódik végre.
A többi végrehajtási út a kivételek miatt jöhet létre:
*. Az argumentumot érték szerint kapja a függvény. Ehhez egy másolásra van szükség; a másoló konstruktor kivételt dobhat, bár ez
még a függvényen kívül történik. (Megj.: az eredeti írásban ez hibásan szerepel. Nem a hívott hozza létre ezt a másolatot, hanem a
hívó. Lásd a szabványt: 8.5.1.2(4) When a function is called, each parameter shall be initialized with its corresponding
argument. [...] The initialization and destruction of each parameter occurs within the context of the calling function.)
4. A visszatérési érték létrehozásához egy konstruktorhívásra van szükség. A String
konstruktora kivételt
dobhat, amelyik egy másik úton jelent visszatérést.
5. A Title()
tagfüggvény kivételt dobhat (semmit nem tudunk róla), vagy érték szerint adhat vissza objektumot (pl.
String
), amely létrehozása kivételt eredményezhet.
6. Az operator==
hívásához előfordulhat, hogy a "CEO"
sztring literálist ideiglenes objektummá kell alakítani (pl. operator==(String, String)
esetén operator==(String, String(char const*))
). A temporális objektum létrehozása kivételt eredményezhet.
7. Az operator==
maga is dobhat kivételt, ha nem beépített operátorról, hanem egy függvényről
van szó. (Fura helyzet lenne, de az elvi lehetőség megvan.)
8. Az 5-öshöz hasonlóan, a Salary()
hívás kivételt eredményezhet.
9. A 6-oshoz hasonlóan temporális objektum keletkezhet, hogy megfelelő operator>
-t lehessen találni.
10. Szintén extrém eset, ha az operator>
furcsa működésű, programozó által írt függvény, kivételt
dobhat.
11. A ||
operátor is lehet olyan, amit a programozó írt (nem a beépített), és ez is dobhat kivételt.
12-16. Mind az öt <<
kiírás operátor dobhat kivételt.
17-18. A First()
és Last()
függvények kivételt dobhatnak a kiírás előtt.
19-20. Az 5-öshöz hasonlóan, a return
utasításnál a First()
és Last()
függvények, vagy az azokból való visszatérés kivételt eredményezhet.
21. A sztringek összeadásához a " "
literálist objektummá kellhet alakítani (pl. operator+(String, String)
esetén).
22-23. Az operator+
-ok is dobhatnak kivételt, az utolsó sorban.
Adott az alábbi törzzsel rendelkező verem osztály, amelynek teljes kódját töltsd le a feladat megoldásához: stack.cpp!
template <typename T>
class Stack {
public:
explicit Stack(size_t max_size);
Stack(Stack const &orig);
Stack & operator=(Stack const &orig) = delete;
~Stack();
void push(T const &what);
T pop();
bool empty() const;
private:
size_t size_;
size_t max_size_;
T *pData_;
};
Ez egy régebbi laborfeladat megoldása; egy egyszerű vermet valósít meg, placement new-s memóriakezeléssel, konstruktorban megadott méretű tömbbel.
Figyeld meg kivételkezelés szempontjából a kódot! Haladj sorban a függvényeken, és javítsd ki úgy őket, hogy a hibajelzésre kivételeket használjanak, és kivételek esetén pedig minél erősebb garanciát adjanak a helyes működésre! Vajon mit kell változtatni a...
- Konstruktoron?
Megoldás
A mostani változat nem kezeli a memóriafoglalási hibát (
malloc()
null pointert ad). Ezt kezelni kell a visszaadott érték ellenőrzésével, vagy::operator new(size_t)
használatával. Az utóbbi esetben az összes helyen át kell térni amalloc()
/free()
párosról az::operator new()
/::operator delete()
párosra! - Másoló konstruktoron?
Megoldás
A
malloc()
mint az előző esetben. Ezen felül, bármelyikT
konstruktor kivételt dobhat. A másoló konstruktor megoldható konstruktor delegálással is, a mintamegoldásban egy ilyen verzió is van. - Destruktoron?
Megoldás
Remélhetőleg semmit. A
T
-k destruktora ne dobjon kivételt. - A
push()
függvényen?Megoldás
Először is, ha megtelt a verem, dobjon kivételt, ahelyett, hogy túlírná a foglalt memóriaterületet. Másodszor, a
size_
tagváltozót csak azután növeljük meg, miután már sikerült a kapott elemet bemásolni a verembe! Így automatikusan változatlan marad majd a számláló (és ezzel együtt a verem is), ha aT
másolása nem sikerült. - A
pop()
függvényen? Vigyázat, ez kemény dió.Megoldás
Első körben megcsinálhatjuk ugyanazokat a változtatásokat, mint a
push()
függvényen. Asaved
változóba akár mozgathatjuk is az adatot, mivel a következő sorban úgyis a destruktort hívjuk, egy garantáltan sikeres függvényt.template <typename T> T Stack<T>::pop() { if (size_ == 0) throw std::length_error("stack ures"); T saved(std::move(pData_[size_-1])); pData_[size_-1].~T(); size_--; return saved; }
Ez nem rossz, de ilyen formán ez csak alap garanciát (basic guarantee) tud adni a hívónak. Miután a tömbből már kitörlődött az adat (a
pData_
-n belüli példány destruktora lefutott), kezelt elemről még egy másolatot (vagy mozgatott példányt) kell készíteni. Ez lesz a visszatérési érték. Ha ennek a létrehozása nem sikerül areturn saved
sorban, akkor kivétel fog keletkezni. A verem ilyenkor ugyan konzisztens állapotban van, de a kivett elemet a hívó nem kapja meg.Ezt a problémát sajnos lehetetlen megoldani. Nem lehetséges az, hogy a függvény ki is veszi az elemet a veremből, vissza és adja, és közben még erős garanciát is ad a művelet elvégzésére. Úgy lehetséges csak erős garanciát adni, ha két külön függvényt írunk: a
top()
visszaadja a legfölső elemet (akár referencia szerint), de nem szedi ki a veremből; apop()
eldobja a legfölső elemet, de nem tér vissza vele. Nem véletlen, hogy azstd::stack
top()
éspop()
függvénye is ilyen. - Az
empty()
függvényen?Megoldás
Semmit, nincs benne kritikus művelet, csak egy
int
vizsgálata és egybool
visszatérési érték másolása. - A főprogramon?
Megoldás
Kapjuk el a kivételt.
- Mit kell ahhoz csinálni, hogy legyen másoló értékadó operátor?
Megoldás
Legegyszerűbb copy-and-swap-et használni, az jól kezeli a kivételeket is.
A teljes megoldás
A teljes megoldás: stack_megoldas.cpp.
Adott egy bináris fát megvalósító osztály, az alábbi kódrészlettel:
template <typename T>
class Node {
private:
T data;
Node* left = nullptr;
Node* right = nullptr;
public:
Node(Node const& original) : data(original.data) {
left = new Node(*original.left);
right = new Node(*original.right);
}
};
Mi a probléma ezzel a másoló konstruktorral? Javítsd meg! Írd meg a mozgató konstruktort is, továbbá a kétféle értékadó operátort és a destruktort! Figyelj arra, mikor, milyen kivételek keletkezhetnek!
Megoldás
Először is, nullptr
-t nem szabadna dereferálni, kell báziskritérium a rekurzióhoz.
Az adattag másolása az inicializáló listán atomi művelet. Ha az létrejött, és utána kivétel dobódik, akkor az adattag destruktora meg fog hívódni automatikusan.
Ezért mi csak a dinamikus részért felelünk. Az egyes részfák másolása szintén atomi
művelet; new Node(...)
vagy sikerül, és akkor létrejött a részfa, vagy nem, akkor
pedig a megfelelő destruktorok már hívódtak. Gond csak ott lehet, ha a bal részfa már megvan, és a jobb
oldali másolása sikertelen. Ezért ott el kell kapni a kivételt:
Node::Node(Node const& original) : data(original.data) {
if (original.left)
left = new Node(*original.left);
try {
if (original.right)
right = new Node(*original.right);
} catch (...) {
delete left;
throw;
}
}
Persze az sem lenne baj, ha mindkét new
bekerülne a try
blokkba,
és a catch
-ben pedig két delete
is szerepelne. A pointerek a fenti
osztálydeklarációban külön is inicializálva vannak, nullptr
értékűek.
Nagyban megkönnyítjük a dolgunkat, ha nyers pointerek helyett okos pointereket használunk.
Ilyenkor ha valamelyik make_unique
-on belüli new
kivételt dob,
a unique_ptr
-ek destruktora futni fog, ahogy a data
-é is:
template <typename T>
class Node {
private:
T data;
std::unique_ptr<Node> left, right;
public:
Node(Node const& original) : data(original.data) {
if (original.left)
left = std::make_unique<Node>(*original.left);
if (original.right)
right = std::make_unique<Node>(*original.right);
}
};
A mozgató konstruktor a unique_ptr
-es esetben:
Node::Node(Node && original)
: data(std::move(original.data))
, left(std::move(original.left))
, right(std::move(original.right))
{
}
Tehát igazából csak ennyi:
Node::Node(Node && original) = default;
Nyers pointerek esetén ez sem lenne ilyen egyszerű.