9. hét: Kivételek

Czirkos Zoltán · 2022.06.21.

1. Kivételbiztos kódok

Noexcept move constructor

Mondtuk, hogy jó lenne, ha minden move konstruktor noexcept minősítést kaphatna, mert akkor tudják a többi függvények, hogy egy (ismeretlen) objektum erőforrása mozgatható. Meg azt is látjuk, hogy legtöbbször a move konstruktor szinte triviális, egy-két pointerművelet vagy ilyesmi. De akkor hogy is van ez? Meg lehet minden move konstruktort noexceptesre csinálni, és csak szintaktikai elvárás, hogy kiírjuk a noexceptet, vagy nem lehet mindet, és okkal kell figyelni ennek kiírására? Miért?

(Ha nem lehet, mutass ellenpéldát. Ha lehet, akkor magyarázd meg, miért. Rosszul vagy hiányosan implementált osztály nem lehet pro, sem kontra érv.)

Megoldás

Több példa is van erre:

  • Konstans adattag vagy referencia adattag. (Az utóbbi olyasmi, mint egy konstans pointer.) Ezek nem módosíthatóak, ezért nem is mozgatható el a tartalmuk.
  • Ha más, egyéb okból nem mozgatható részei vannak az objektumnak. Az std::list pl. strázsás láncolt listát épít, mégpedig azért, hogy a végéről (std::list::end), vagyis az utolsó utáni elemtől vissza lehessen jönni -- operátorral az utolsóra. Egyben azt is ígéri, hogy bármilyen műveletet végzünk a listával, a nem módosított elemek iterátora érvényes marad. Ha van egy end iterátorunk egy listára, és annak a listának a tartalmát elmozgatjuk, az end iterátor érvényes kell maradjon; ami azt jelenti, hogy a strázsát nem mozgathatjuk, az meg kell maradjon az eredeti listánál. Emiatt az új lista, amibe áthelyezzük az objektumokat, a strázsát nem veheti el, hanem saját strázsákat kell létrehoznia.
  • Operációs rendszer vagy hardver limitációk, pl. egy std::mutex.
  • Olyan esetek, amikor harmadik szereplővel végzett művelet is bekerül a képbe. Ilyen a SuperNoisy osztály is; ebben a közös tárolóba regisztrált objektumok miatt a mozgató konstruktornak többlet feladata van, amelyik nem csak a két objektumot érinti (amiből és amibe mozgatni kell az adatot), hanem a tárolót is, amelyhez hozzá kell adni az új objektumot.

Noexcept függvények

A főprogramban két kódrészlet van. Külön kell őket vizsgálnod. Melyiknél mi történik? Miért?

#include <iostream>

struct X {
    X() = default;
    X(X const &) { throw 0; }
};

void f_val(X param) noexcept {
}

void f_ref(X const & param) noexcept {
    X copy(param);
}

int main() {
    X x1;

    try {
        std::cout << "f_val hivasa" << std::endl;
        f_val(x1);
    } catch (...) {}
    
    try {
        std::cout << "f_ref hivasa" << std::endl;
        f_ref(x1);
    } catch (...) {}
}
Megoldás

Az f_val() függvényben tényleg nem keletkezhet kivétel. Ugyan a hívásához létrejönne az x1 objektum másolata az X osztály másoló konstruktorához, amely kivételt dob, de ez a kivétel nem a függvényben keletkezik, hanem még azon kívül.

Az f_ref() esetében más a helyzet. Ennek hívása kedvéért csak egy referencia jön létre, a függvénytörzsbe belépés előtt kivétel még nem keletkezik. A törzsben viszont igen, a másolat létrehozásakor. Mivel a függvény noexcept minősítővel rendelkezik, ez a kivétel nem jut ki a függvényből, hanem megszakad a program futása.

Noexcept mozgató konstruktor

Mi a hiba az alábbi programrészben? Kettőt is tudsz mutatni!

class String {
    /* ... */
};

String::String(String && moved) noexcept {
    data_ = moved.data_;
    len_ = moved.len_;
    moved.data_ = new char[1];
    moved.data_[0] = '\0';
    moved.len_ = 0;
}
Megoldás

Az első hiba a kód jelenlegi formájában a noexcept kulcsszó. A függvény törzsében lévő kód igenis dobhat kivételt; ha egy noexcept függvényben ilyen történik, a program futása megszakad. Ennél jobb ötlet lenne a noexcept kulcsszót törölni; ha kivétel dobódik, legalább kaphassa el azt a hívó.

A másik hiba az, hogy az új dinamikus tömb valószínűleg felesleges. A moved objektum tartalmát ez a konstruktor elmozgatja; az objektumnak érvényes állapotban kell maradnia, de lehet üres is (destructable / assignable state). Az üres objektum tartalmát senkinek nem szabad felhasználnia, senki nem építhet arra a kódban, hogy mi tárolódik benne. Jobb, hatékonyabb lenne, ha az a tömb nem jönne létre, hanem a moved.data_ értékét nullptr-re állítanánk.

Ha így teszünk, akkor egyébként a függvény visszakaphatja a noexcept minősítőjét:

String::String(String && moved) noexcept {
    data_ = moved.data_;
    len_ = moved.len_;
    moved.data_ = nullptr;
    moved.len_ = 0;
}

std::stack top és pop

Az std::stack osztálynak két függvénye van, amelyik a stack tetején lévő elemmel dolgozik. A top() visszaadja ezt az elemet (T& értékű függvény), de nem veszi ki a stackből; a pop() kitörli az elemet, de nem adja vissza (void értékű függvény). Miért van ez így? Miért nincs olyan, amelyik kitörli és visszaadja a visszatérési értékében? Miért nincs olyan, amely egy referencia által adott helyre másolja? Miért nincs olyan, hogy... [egyéb verzió]? Mutasd be kódrészleten a másféle megoldások problémáit!

A kivételkezelés tesztelése

A kivételek általában kivételes helyzetekben keletkeznek – ahogy a nevük is mutatja. A legtöbb kivétel olyan körülmény miatt jön létre, aminek nem kellene bekövetkeznie; pl. név nélküli fájlba próbálunk elmenteni valamilyen tartalmat, vagy elfogy a memória.

A kivételeket kezelő kódrészleteket ezért sokszor nehéz tesztelni, mivel elő kell állítani a kivételes helyzetet. A labor verem osztályával könnyebb a helyzet; mivel a legtöbb helyen a sablon példányosító osztály, T által generált kivételeket próbálja a verem helyesen kezelni. Nincs más dolgunk, mint létrehozni egy olyan osztályt (egy mock-ot), amelynek adott művelete adott pillanatban kivételt dob, és ellenőrizni, hogy a verem objektum megoldotta-e a helyzetet.

Írj ilyen osztályt, és tesztelj vele pár helyzetet!

std::shared_ptr

Tudjuk, hogy egy dinamikusan foglalt objektumot („kezelt objektum”) egy std::shared_ptr okos pointernek adva az okos pointer felelni fog annak felszabadításáért. Sőt több std::shared_ptr közösen is mutathat egyetlen kezelt objektumra. Sőt vannak std::weak_ptr-ek is, amelyek azt is tudják, hogy a kezelt objektum már delete-elve lett-e. Mindezt úgy oldják meg, hogy közösen használnak egy menedzser objektumot, amely tartalmazza a kezelt objektum pointerét, továbbá a referenciaszámlálókat.

A menedzser objektumot a kellő számú indirekció és az élettartamának beállítása miatt szintén dinamikusan kell foglalni. Ebből az következik, hogy az std::shared_ptr konstruktora dinamikus memóriafoglalást kell tartalmazzon, ami std::bad_alloc típusú kivételhez vezethet. Ha a konstruktora kivételt dob, akkor az okos pointer nem jött létre, tehát a destruktora sem fog lefutni, amelyik felszabadította volna a kezelt objektumot.

A kérdés a következő. Helyesen működik-e a következő függvény akkor, ha a kezelt T objektumot már sikerült létrehozni, de a std::shared_ptr konstruktorában kivétel keletkezik? Lesz-e itt memóriaszivárgás? Miért? Rövid, vázlatos kódrészleteket írj a magyarázat mellé!

template <typename T>
std::shared_ptr<T> my_make_shared() {
    return std::shared_ptr<T>(new T());
}
Megoldás

A shared_ptr konstruktora elkapja a menedzser objektum létrehozásakor keletkező kivételt. Ilyenkor a neki adott T objektumot felszabadítja, és továbbdobja ezt a kivételt.

Más szóval, amint elindul a konstruktor, onnantól kezelt objektumnak számít a T, nem pedig csak onnantól kezdve, amikor lefutott. A konstruktor belseje valami ilyesmi:

template <typename T>
shared_ptr<T>::shared_ptr(T *rawptr) {
    try {
        this->managerobj = new managerobj;
    } catch (...) {     /* ha nem sikerült lefoglalni a menedzsert */
        delete rawptr;  /* akkor felszabadíŧja a kezelt objektumot */
        throw;
    }
    this->managerobj->rawptr = rawptr;
    this->managerobj->refcount = 1;
}

2. „Kivételkezelés” C-ben

goto :)

C-ben nincsenek kivételek. Ellenben sok olyan függvényt írunk, amelyik felépít valamilyen állapotot, erőforrásokat foglal egymás után (nyitott fájl, dinamikus memória stb.), és kivételes körülmény esetén ezeket fel kell szabadítania.

A kritikus függvényeknek két kilépési pontja (return) van: egy olyan, ahol hiba esetén fejeződik be a függvény, és egy olyan, ahol normál esetben. Hiba esetén a lefoglalt erőforrásokat a foglaláshoz képest fordított sorrendben fel kell szabadítani, ahogy a stack unwinding is történne C++-ban. Ezt úgy szokták megoldani, hogy a rendes kilépési pont alá teszik ezeket a programrészeket, címkékkel ellátva. Így a felszabadítást végző programrészbe egy goto utasítással be lehet ugrani, az annak megfelelő helyre, ameddig a foglalásokkal eljutottunk. Így:

ErrorCode func() {
    int* ints = (int*) malloc(sizeof(int) * 100);
    if (ints == NULL)
        goto end_nothing;

    /* ... */
    if (/* ... */)
        goto end_ints;
    /* ... */
    
    free(ints);
    return OK;

  end_ints:
    free(ints);
  end_nothing:
    return Fail;
}
3 3
3.4   9.2   0
1.2   0     4.5
-3    5.6   11

Írd át ilyenre az innen letölthető mátrixos programot: matrix.c! Ennek feladata az, hogy a jobb oldalt látható formátumú fájlból beolvasson egy mátrixot, és visszaadja az arra mutató double** pointert.

Megoldás
double **read_matrix(char const *filename) {
    int w, h;
    FILE *fp;
    double ** ret;

    fp = fopen(filename, "rt");
    if (fp == NULL)
        goto error;
    if (fscanf(fp, "%d %d", &w, &h) != 2)
        goto error_file;
    
    /* mátrix foglalása */
    ret = (double **) malloc(sizeof(double*) * h);
    if (ret == NULL)
        goto error_file;
    /* kitöltjük null pointerrel, hogy tudni lehessen, melyik sor van lefoglalva */
    for (int y = 0; y < h; ++y)
        ret[y] = NULL;
    for (int y = 0; y < h; ++y) {
        ret[y] = (double *) malloc(sizeof(double) * w);
        if (ret[y] == NULL)
            goto error_matrix;
    }
    
    for (int y = 0; y < h; ++y) {
        for (int x = 0; x < w; ++x) {
            if (fscanf(fp, "%lf", &ret[y][x]) != 1)
                goto error_matrix;
        }
    }

  /* rendes visszatérés */
    fclose(fp);
    return ret;

  /* hibák esetén erőforrások felszabadítása */
  error_matrix:
    for (int y = 0; y < h; ++y)
        free(ret[y]);
    free(ret);
  error_file:
    fclose(fp);
  error:
    return NULL;
}