OOP tervezés, RAII

Dobra Gábor, Fintha Dénes · 2019.02.27.

Jegyzet 4. fejezet: OOP tervezés, RAII

A fejezet anyaga kis részben videó formában is elérhető, a videó vége felé.

1. A friend kulcsszó

Az osztályok, objektumok privát tagváltozóit, tagfüggvényeit kívülről nem érhetjük el, így sem globális függvény, sem másik osztály tagfüggvénye nem láthatja. Ritkán, nagyon ritkán (nagyon ritkán!) előfordul, hogy ez alól szeretnénk feloldozást adni egyetlen osztály vagy függvény számára, miközben másoknak továbbra sem engedjük meg a hozzáférést. Ez általában egy code smell, szinte biztosan más a szakszerű megoldás. Ha úgy döntünk, hogy eme figyelmeztetés ellenére mégis szükségünk van rá, a friend kulcsszóval megtehetjük.

Egy függvényt vagy akár egy teljes osztályt az előző fejezetben megismert String barátjaként deklarálhatunk. Ekkor az látja a String privát adattagjait. Ezt a String belsejében kell jeleznünk, a friend kulcsszóval:

class String {
    // ...

    friend std::ostream& operator<<(std::ostream&, const String&);
}

std::ostream& operator<<(std::ostream& os, const String& s) {
    os << s.str;
    return os;
}

Ha az inserter operátornak – amit tagfüggvényként nem lehet megvalósítani – mindenképpen bele kellene látnia a String belsejébe, jobb egy kiíró tagfüggvényt írni, amit az inserter operátor már tud hívni.

class String {
    // ...
  public:
    void kiir(std::ostream& os) {
        // ...
    }
}

std::ostream& operator<<(std::ostream& os, const String& s) {
    s.kiir(os);
    return os;
}

Nézzünk egy példát egy teljes osztály friend-dé tételére.

class RAM {
    friend class CPU;
    // ...
}

class CPU {
    // ...
}

A processzor teljes hozzáférést kapott a rendszermemória tartalmához, ami más osztálynak nem jár: csak a processzor kezelheti a memória teljes tartalmát. Ha a hozzáférést getterekkel, setterekkel valósítottuk volna meg, bárki hozzáférhetne a memóriához, nem csak a processzor. Ebben az esetben indokolt a friend használata.

A friend tulajdonság nem reciprok (tehát ha A-nak barátja B, attól még B-nek nem lesz barátja A), és nem is tranzitív (tehát barát barátja nem lesz automatikusan barát). Ezen felül, a friend osztály leszármazottai nem lesznek friend-ek, ez a tulajdonság nem örökölhető. (Az öröklésről később lesz csak szó.)

2. Osztályszintű adattagok, függvények

C-ben a static kulcsszó két jelentését ismerhettük meg. Egy globális változó vagy függvény módosítójaként csak az adott fordítási egységben tette láthatóvá. Egy függvény statikus lokális változója pedig globális memóriaterületen elhelyezkedő, de lokális láthatóságú változója, amely két hívás között megőrzi az értékét.

Az utóbbi használathoz hasonló jelentéssel bír egy osztály statikus adattagja: az osztályhoz tartozik, nem a példányokhoz. Tekinthetjük úgy is, hogy ez egy olyan változó, amelyik az osztály objektumai számára közös, ezért kifejezőbb az osztályszintű megnevezés. Ez globális memóriaterületen van, külső elérése ugyanúgy lehet public vagy private. Az osztályszintű adattagot egyrészt az osztály belsejében deklarálnunk kell, másrészt azon kívül, egy fordítási egységben definiálni is, itt kaphat kezdeti értéket.

class String {

    // ...

  public:

    static bool debug;

    String() {
        if (debug)
            std::cerr << "String default ctor" << std::endl;
        // ...
    }

    // ...
}

bool String::debug = false; // statikus adattag "példányosítása"

int main() {

    String s1; // nincs kimenet
    String::debug = true;
    String s2; // van kimenet

    return 0;
}

Ezzel az összes String-et tettük varázsütésre bőbeszédűvé, megkönnyítve a hibakeresést.

Az osztályszintű függvény ehhez hasonlóan viselkedik és hívható. Fontos, hogy nem tartozik hozzá objektum, nincs benne this pointer – úgymond önálló, globális függvény. Viszont a statikus adattagokat, és az osztály példányainak adattagjait is látja (ha kap paraméterként, hoz létre, stb.)

class Komplex {
  private:
    double re, im;

  public:

    Komplex() :
        re(0),
        im(0) {
    }

    static Komplex create_from_re_im(double re = 0, double im = 0) {
        Komplex k;
        k.re = re;
        k.im = im;
        return k;
    }

    static Komplex create_from_r_phi(double r = 0, double phi = 0) {
        Komplex k;
        k.re = r * cos(phi);
        k.im = r * sin(phi);
        return k;
    }
};

int main() {
    Komplex k1 = Komplex::create_from_re_im(1, 0);        // 1 + 0i
    Komplex k2 = Komplex::create_from_r_phi(1, M_PI / 4); // 0 + 1i
    return 0;
}

3. A hármas szabály alkalmazása

Az előző fejezetben megtanultuk, hogy az erőforrásokat érdemes egy objektumra rábízni, ami az élettartama végén fel tudja őket szabadítani. Ez azzal járt, hogy ha a destruktorra szükség van, akkor másoló konstruktorra és értékadó operátorra is.

Akinek a kezében kalapács van, mindent szögnek néz.

A C++ tanulmányok ezen szakaszában gyakran előfordul, hogy egy C kódot, vagy a C-s gondolkodást rosszul C++-osítjuk. Vegyük példának ezt a C-s struktúrát:

struct Vonatjegy {
    char * indulo_allomas;
    char * vegallomas;
};

Jó lenne, ha ezeket az állomásokat konstruktorban tudnánk inicializálni, ráadásul dinamikus memóriába kerülnének a sztringek, sőt a destruktor fel is szabadítaná a dinamikusan tárolt neveket. Na de ha destruktor kell, akkor copy ctor és op= is! Szép menet lesz ezeket mind megírni, de megéri. Vagy mégsem? Annyira rossz lenne az a megoldás, hogy inkább le se írjuk, nehogy véletlenül bárkinek az ragadjon meg a fejében.

Itt szövegek (mondhatni sztringek) dinamikus tárolását szeretnénk megvalósítani. Na de azt a feladatot nemrég oldottuk meg! Pontosan erre írtunk egy osztályt (String), használjuk hát fel! Illetve ne is ezt, hanem a nála sokkal jobb std::string-et.

struct Vonatjegy {
    std::string indulo_allomas;
    std::string vegallomas;
};

Így a fordító által generált "szentháromság" pontosan azt fogja csinálni, ami nekünk kell. Mennyit nyertünk ezzel a megoldással? Alsó hangon harminc sort, hatvan hibalehetőséget, esetleg két álmatlan éjszakát, visszadobott nagyházit, és szerencsére a laborvezető sem fojtotta meg az elkövetőt.

4. Egyértelmű felelősség

Ezt a code smell-t kicsit formalizálva: a hibás megoldás OOP egyik alapelvével ütközne, történetesen a Single Responsibility Principle-lel. Abban az esetben ugyanis a Vonatjegy egyszerre lenne felelős a vonatjegyért mint az utasnak adott információdarabkáért, és a szövegek dinamikus memóriakezeléséért.

A jó megoldás szerint egy osztálynak pontosan egyetlen felelőssége van, és a két felelősségi kört szétválasztja, az std::string osztály csak dinamikusan foglalt szöveget kezel, a Vonatjegy pedig csak az utasnak átadott információért felel.

Ha ezt a gondolatmenetet tovább visszük, lesznek tisztán erőforráskezelő osztályaink, aminek azon kívül szinte semmi dolga, és egyik más osztályunknak sem kell utána erőforrást kezelni.

Egy olyan objektum, aminél a hármas szabály életbe lép, arra való, hogy az erőforráskezelést elrejtsük a felsőbb szintű kód elől. Az őket használóknak már nem kell ezzel szerencsétlenkedni, például String-et tartalmazó osztálynak emiatt nem kell a másoló konstruktort, destruktort, értékadó operátort megírni, hiszen a fordító által generáltak is jók. Érték szerint tudjuk a String-et kezelni, akár csak egy int-et, függetlenül attól, hogy a belsejében éppen dinamikus memória van.

Míg a többi osztálynak nem kell – sőt tilos – erőforrást kezelnie, ott koncentrálhatunk arra, ami a valódi felelősség.

5. Hibakezelés

Emlékezzünk vissza, C-ben mit csinálunk, ha a hibát valamilyen módon jelezni akarjuk a hívónak! Erre gyakorlatilag egyetlen értelmes megoldás van: visszatérési értékben jelezni a hibát. Nézzünk erre egy példát! Olyan függvényt kell írnunk, ami vezetéknév és keresztnév összefűzésével előállítja a teljes nevet.

char * teljes_nev(char const * vezeteknev, char const * keresztnev) {
    int h1 = strlen(vezeteknev);
    int h2 = strlen(keresztnev);

    char * result = (char*)malloc((h1 + h2 + 1 + 1) * sizeof(char)); // szóköz + lezáró nulla

    strcpy(result, vezeteknev);
    strcat(result, " ");
    strcat(result, keresztnev);

    return result;
}

Ezzel a megoldással van egy komoly baj: nem foglalkozik a hibakezeléssel. Akár a paraméter sztringek helyett is kaphatunk NULL-okat, és a malloc is térhet vissza NULL-lal. Ezért ha ilyen hiba történik, mi is térjünk vissza NULL-lal!

char * teljes_nev(char const * vezeteknev, char const * keresztnev) {
    if(vezeteknev == NULL || keresztnev == NULL)
        return NULL;

    int h1 = strlen(vezeteknev);
    int h2 = strlen(keresztnev);

    char * result = (char*)malloc((h1 + 1 + h2 + 1) * sizeof(char)); // szóköz + lezáró nulla

    if(result == NULL)
        return NULL;

    strcpy(result, vezeteknev);
    strcat(result, " ");
    strcat(result, keresztnev);

    return result;
}

Nézzük meg ugyanezt C++-ban, std::string használatával!

std::string teljes_nev(std::string const& vezeteknev, std::string const& keresztnev) {
    return vezeteknev + " " + keresztnev;
}

Mitől lett ennyivel rövidebb, ennyivel egyszerűbb?

Egyrészt az erőforrás-kezelést teljes egészében rábíztuk az std::string-re. Bár az agyunk mélyén tudjuk, hogy belül valamiféle memóriafoglalás van, igazából ez semmit nem zavar. Másrészt az std:string, mint osztály, a maga legjellemzőbb műveleteit (itt: összefűzés) "konyhakészen" adja a használója számára, nem nekünk kell kitalálni az algoritmust, hogyan is kell kezelni. Az operator overloading csak egy plusz nyalánkság a történetben, a szemnek kedves, a lényegen nem változtat. Ezeket a tanulságokat az előző fejezetekben mind részletesen tárgyaltuk, de még mindig van látnivaló ebben a néhány sorban.

Hova tűnt a hibakezelés? Nem butább ez a kód, mint az előző?

Ha megnézzük a két C-s változat közti különbségeket, két helyen kellett hibát kezelni. Egyrészt a bemenetként kapott sztringek lehettek NULL-ok, másrészt a malloc is NULL-lal térhet vissza. A C++-os változatban egy std::string nem lehet érvénytelen, biztosan valid a vezetéknév és a keresztnév. A new pedig kivételt dob NULL helyett. A hibajelzésre nem NULL pointert használunk, hanem kivételeket. Ez a mi függvényünkre is igaz: hiba esetén az std::string kivételt fog dobni, nem kell a visszatérési értéket vizsgálni, (sem memóriaterületeket felszabadítani,) így a használata is egyszerűbb lett:

char * nev = teljes_nev("Neumann", "János");
if(nev == NULL) {
    fprintf(stderr, "Elfogyott a memória. Mégis, ilyenkor mit lehet csinálni?");
    exit(1); // ?
}
printf("%s\n", nev);
free(nev);
std::cout << teljes_nev("Neumann", "János");

Az első esetben muszáj a visszatérési értéket megtartani, mert meg kell vizsgálni, hogy valid-e, és fel kell szabadítani. A C++ kódban a felszabadítás nem a hívó dolga, és a kivétel kezelését is ráhagyhatjuk valami felsőbb szintű kódra.

6. Hibakezelés és erőforráskezelés

Visszatérési értékkel

Az előző C-s példakódban gyakorlatilag egy kritikus művelet volt, a memóriafoglalás. Nézzük meg, mi történik, ha hibakezelés és erőforráskezelés is bőven akad.

A feladat egy mátrix tartalmának beolvasása fájlból, a kód egyelőre félkész. A mátrix egy double** típusú kétdimenziós tömbbe kerül majd, ez lesz a visszatérési érték. Azt még biztosan javítani kell, hogy a hívó megkapja a méretet, de egyelőre ezzel akadnak nagyobb bajok is.

double ** read_matrix_0(char const * filename) {
    FILE * fp = fopen(filename, "rt");

    int w, h;
    fscanf(fp, "%d %d", &w, &h); // méret beolvasása

    double ** ret = (double **) malloc(sizeof(double *) * h);
    for(int y = 0; y < h; ++y)
        ret[y] = (double *) malloc(sizeof(double) * w);

    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            fscanf(fp, "%lf", &ret[y][x]); // számok beolvasása

    fclose(fp);
    return ret;
}

Kifelejtettük a hibakezelést! Az fopen adhat vissza NULL-t, így ha a fájl nem létezik – ami nem olyan ritka, mint a memóriahiány! –, a negyedik sorban egy segmentation fault jöhet szembe. Ugyanígy a malloc és az fscanf is hibába futhat bele, amit szintén ellenőrizni kell. Próbáljuk meg naiv módon kezelni a hibát, adjunk vissza NULL-t, ha bármi hiba történt!

double ** read_matrix_1(char const * filename) {
    FILE * fp = fopen(filename, "rt");
    if(fp == NULL)
        return NULL;

    int w, h;
    if(fscanf(fp, "%d %d", &w, &h) != 2)
        return NULL;

    double ** ret = (double **) malloc(sizeof(double *) * h);
    if(ret == NULL)
        return NULL;
    for(int y = 0; y < h; ++y) {
        ret[y] = (double *) malloc(sizeof(double) * w);
        if(ret[y] == NULL)
            return NULL;
    }

    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            if(fscanf(fp, "%lf", &ret[y][x]) != 1)
                return NULL;

    fclose(fp);
    return ret;
}

Nos, ez nem sokkal jobb. Ha pl. hibás a fájl formátuma, gátlástalanul kilépünk a függvényből, ezzel elszivárogtatunk némi memóriát, és nyitva felejtünk egy fájlt! Így talán még az előző változatnál is rosszabb.

Akkor hát mi a teendő? Hiba esetén mindent, amit addig csináltunk, vissza kell vonni, legtöbbször pont fordított sorrendben. Felszabadítani a kétdimenziós tömböt, és a fájlt becsukni, csak ezután jöhet a return NULL.

double ** read_matrix_2(char const * filename) {
    FILE * fp = fopen(filename, "rt");
    if(fp == NULL)
        return NULL;

    int w, h;
    if(fscanf(fp, "%d %d", &w, &h) != 2) {
        fclose(fp);
        return NULL;
    }
    double ** ret = (double **) malloc(sizeof(double *) * h);
    if(ret == NULL) {
        fclose(fp);
        return NULL;
    }
    for(int y = 0; y < h; ++y) {
        ret[y] = (double *) malloc(sizeof(double) * w);
        if (ret[y] == NULL) {
            for (int yy = 0; yy < y; ++yy)
                free(ret[yy]);
            free(ret);
            fclose(fp);
            return NULL;
        }
    }
    for(int y = 0; y < h; ++y) {
        for(int x = 0; x < w; ++x) {
            if(fscanf(fp, "%lf", &ret[y][x]) != 1) {
                for (int yy = 0; yy < h; ++yy)
                    free(ret[yy]);
                free(ret);
                fclose(fp);
                return NULL;
            }
        }
    }
    fclose(fp);
    return ret;
}

Ez a változat már jól működik. Egy probléma van vele: ránézve elég sok minden látszik rajta, csak az nem, hogy mit csinál. Több a hibakezelés, mint a lényeg. Az eredeti read_matrix_0 függvény 17 sor volt, ez 38 sorra hízott, beolvasás és hibakezelés vegyesen.

Most térjünk vissza arra a problémára, hogyan adjuk vissza a hívónak a méreteket. Nyilvánvalóan egy struktúrába kéne tenni az adattal együtt, valahogy így:

struct Matrix {
    int w, h;
    double ** data;
}

Mi legyen a hibát jelző visszatérési érték? Lehet egyrészt egy olyan Matrix, aminek a data adattagja NULL. Ez a kódot utólag olvasva egyáltalán nem lesz kézenfekvő. Vagy térjünk vissza Matrix *-gal? Ahhoz dinamikusan kell foglalni azt is, plusz hibalehetőség, plusz hibakezelés, plusz indirekció. Már a read_matrix_2 is egy behemót, és a tökéletes működéshez még bonyolítani kell rajta, így a kényelmetlenség is fokozódni fog.

Ugyan igaz, hogy a fájl beolvasását és a memóriafoglalást nem kellett volna egy függvénybe tennünk, de ha a memóriakezelést kiszerveznénk egy külön függvénybe, a hibakezelés akkor is ugyanígy nézne ki. Rengeteg helyen kellene ellenőrizni a visszatérési értékeket, és felszabadítani az erőforrásokat sikeres és sikertelen esetekben is.

Kivételekkel

A legnagyobb kényelmetlenséget az okozza a fenti függvényekben, hogy a lefoglalt erőforrásokat hiba esetén is nekünk kell felszabadítani, fordított sorrendben.

Hiszen erre való a destruktor, használjuk arra, amire kitalálták! Legyen egy Matrix osztályunk, ami elrejti előlünk a memóriakezelést! Az elemek elérését érdemes függvényhívás operátorral megoldani, mert az indexelő operátor nem kaphat két paramétert. Valahogy így:

double& Matrix::operator()(int i, int j) {
    return data[j][i];
}

A FILE* helyett is jobb lenne egy olyan osztályt használni, aminek a destruktora bezárja a fájlt. Ilyenek a szabványos fstream fejlécben definiált, std névtérbeli ifstream és ofstream osztályok. Ezek az std::cin-hez és std::cout-hoz nagyon hasonlóan használhatóak, nem véletlenül.

Matrix read_matrix_3(char const * filename) {
    std::ifstream is(filename);
    if(!is.is_open())
        throw FileError();

    int w, h;
    if(!(is >> w >> h))
        throw FileError();

    Matrix ret(w, h);
    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            if(!(is >> ret(x, y)))
                throw FileError();

    return ret;
}

Figyeljük meg a változásokat a read_matrix_0 változathoz képest!

  • Ha írunk egy FileError osztályt – ami akár egy üres osztály is lehet –, hiba esetén ilyen típusú kivételt dobhatunk. Dobni viszont csak objektumot lehet, osztályt nem, ezért hívjuk a default konstruktorát a throw utasításnál.
  • Ilyen kivételt akkor dobunk, ha a beolvasás során bármi hiba van a fájllal: nem sikerült megnyitni, vagy rossz a formátuma.
  • A memória foglalása eltűnt, legalábbis nem látjuk. Helyette a Matrix konstruktorát hívjuk, ami létrehozza a mátrixot. Sejtjük, hogy ott belül valami new van, ami hiba esetén kivételt dob.
  • Ezt a kivételt ebben a függvényben szándékosan nem kapjuk el. Ha a mátrix nem tud létrejönni, akkor a read_matrix_3 függvény sem tudja visszaadni azt.
  • Bármiféle hiba esetén nem valami furcsán megjelölt mátrixszal térünk vissza, hanem kivétel dobódik. Ez a hívónak is kényelmesebb: dönthet úgy, hogy nem kapja el, míg egy visszatérési értéket muszáj megvizsgálni.
  • A lényeg – ez a sok egyszerűsítés – a sorok között van, illetve ott sem: nincs odaírva. Nem kellett odaírni!
  • Az ifstream destruktora nem csak visszatéréskor zárja be a fájlt, hanem mindig, akárhogy lépünk ki az őt tartalmazó blokkból, – jelen esetben a függvényből, – kivételdobás esetén is!

Ez a kód közelebbről vegyes-büdös, mert a beolvasás sikerességét még mindig visszatérési értéken keresztül figyeljük. Jó lenne, ha már maga a beolvasás dobna kivételt. Szerencsére erre meg tudjuk kérni az std::ifstream-et.

Matrix read_matrix_4(char const * filename) {
    std::ifstream is;
    is.exceptions(std::ifstream::badbit |
                  std::ifstream::eofbit |
                  std::ifstream::failbit);
    is.open(filename);

    int w, h;
    is >> w >> h;

    Matrix ret(w, h);
    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            is >> ret(x, y);

    return ret;
}

Vessük össze ezt a megoldást a kiindulási, ezer sebből vérző read_matrix_0 függvénnyel! Ahhoz képest csak annyi változott, hogy a foglalás eltűnt, pointerek helyett érték szerint kezelt objektumok vannak. A kód ugyanúgy mentes mindenféle hibakezeléstől, de nem azért, mert kifelejtettük, hanem mert automatikusan történik.

7. A RAII elv

Az erőforrások kezelése során a problémát általában az okozta, hogy kivétel dobása esetén a pointerre rábízott erőforrás felszabadítását nagyon könnyű elfelejteni.

Ezért igazából nem a pointereket kell hibáztatni. A C-ben írt függvénykönyvtárak többsége azt a konvenciót követi, amit a FILE * példáján ismertünk meg. Az fopen veszi fel a konstruktor, az fclose pedig a destruktor szerepét. Az erőforrást itt egy FILE * írja le, ennek a tartalma közvetlenül nekünk érdektelen, csak a függvényeken keresztül tudjuk őket használni. Ezért nem is kell pointernek lennie, ahogy Linux API-nál int, OpenGL-nél GLuint veszi fel ezt a szerepet. Az a lényeg, hogy kicsi és könnyen kezelhető legyen.

Akik a könyvtárakat írják, hogy mernek rábízni egy erőforrást egy primitív típusra, egy pointerre? Miért nem objektumra bízzák rá?

Igazából nem is a pointerre bízzák, hanem ránk. Ehhez kapunk egy névjegykártyát, amit a megfelelő helyen bemutatva elérhetjük a megfelelő hatást. A kártyára nekünk kell vigyázni, ha elveszítjük, nincs többé. C++ objektumot azért nem használnak, mert gondolnak a többi nyelvre is. Egy C-ben írt könyvtárat a legkönnyebb bármilyen más nyelvben felhasználni.

Szóval a mi dolgunk, hogy vigyázzunk az erőforrásokra. Testőrként segít minket egy elv, amit RAII-nak hívnak, és pontosan az olyan helyzetekre találták ki, mint a read_matrix.

A RAII a Resource Acquisition Is Initialization rövidítése. Körülbelül annyit jelent, hogy egy erőforrás lefoglalása egyben egy objektum inicializálása is legyen. Azaz egy objektumra bízzuk az erőforrást, aminek van destruktora, szemben egy "sima" pointerrel. Az objektum élettartamához kötjük hozzá az erőforrást. Amint az objektum megszűnik, automatikusan lefut a destruktor, és felszabadítja a rá bízott erőforrást is.

A RAII-t eddig is alkalmazuk, még ha nem is hívtuk a nevén: a String és a Matrix is így működik. Ezeknél az osztályoknál a foglalás a konstruktor dolga, ezért csak a destruktornak kell tudnia, hogyan kell felszabadítani. Az erőforráskezelés teljes mikéntje a belső reprezentáció "titka" maradhat.

Az ebben a szellemben megírt osztályok erejét az egyszerűség kedvéért egy double tömbön mutatjuk be. Akár lehetne SDL_Surface * vagy GPU-n futó program is. A kezdeti, RAII-t nem alkalmazó kód így néz ki:

void feladat() {
    int n;
    std::cout << "Hany szam lesz?";
    std::cin >> n;

    double * tomb = new double[n];

    // sok-sok művelet

    delete[] tomb;
}​

A read_matrix változatai után itt az olvasóknak kórusban kell felkiáltania: na de mi van, ha a sok-sok művelet bármelyike kivételt dob? RAII nélkül valahogy így lehet javítani.

void feladat() {
    int n;
    std::cout << "Hany szam lesz?";
    std::cin >> n;

    double * tomb = new double[n];

    try {
        // sok-sok művelet
    }
    catch(...) {
        delete[] tomb;
        throw;
    }

    delete[] tomb;
}​

A RAII azt mondja, hogy az erőforrást – ami itt egy double tömb – bízzuk rá egy objektumra, ami a végén felszabadítja. Írjunk tehát egy olyan osztályt, ami egy akármekkora méretű double tömböt kezel.

class Vektor_double {

    // ...

    Vektor_double(int m) {
        this->meret = m;
        this->data = new double[m];
    }

    ~Vektor_double() {
        delete[] tomb;
    }

    // copy ctor, op=
};

Ha a fenti kódban double * helyett ezt a Vektor_double osztályt használjuk, annyival egyszerűsödik a helyzet, hogy nem nekünk kell felszabadítani az erőforrásokat. Igazából nem is a plusz négy sor miatt csináltuk, hanem azért, mert így nem lehet elfelejteni a felszabadítást.

int main() {

    int n;
    std::cout << "Hany szam lesz?";
    std::cin >> n;

    Vektor_double tomb(n);

    /* sok-sok kód */
}

Ez egy elég primitív példa, egyszerűen kezelhető elemekkel (double). Ha a tárolt elemek is bonyolultabbak, mint például egy std::string, az olvasóra bízzuk annak végiggondolását, mennyi macerával jár egy sztringtömb kezelése C-ben. Még akkor is, ha csak annyi a feladat, hogy rendezni kell az elemeket.

Láthatjuk, hogy a kivételek destruktorok híján semmit nem egyszerűsítenek a helyzeten: akkor ugyanúgy el kell kapni minden kivételt, kézzel kipucolni, majd továbbdobni. Az igazi megváltást a kettő együtt hozta.