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.

  1. Rejtett komplexitás: a megoldásokat kommentben írd a kód mellé, magyarázatokkal.
  2. 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.
  3. Bináris fa: tesztelj is, mint az előzőnél!

1. Rejtett komplexitás

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.

2. Kivételek a verem osztályban

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 a malloc()/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ármelyik T 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 a T 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. A saved 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 a return 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; a pop() eldobja a legfölső elemet, de nem tér vissza vele. Nem véletlen, hogy az std::stack top() és pop() függvénye is ilyen.

  • Az empty() függvényen?
    Megoldás

    Semmit, nincs benne kritikus művelet, csak egy int vizsgálata és egy bool 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.

3. Bináris fa másolása

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ű.

4. További feladatok

5. Irodalom

  1. GotW #20 – Guru of the Week, #20, Code Complexity - Part I.
  2. GotW #66 – Guru of the Week, #66, Constructor Failures
  3. GotW #08 – Guru of the Week, #08, Exception Safety