Tratarea Exceptiilor






Modalitati clasice de tratare a erorilor

Vom prezenta notiunile legate de tratarea erorilor cu ajutorul unui exemplu, si anume, vom considera o clasa Vector pentru reprezentarea vectorilor de elemente intregi:

class Vector {
   int *p;
   int size;
   static int fan_err;
   static int eroare;
public:
Vector(int s); //aloca un tablou de s elemente
~Vector(){if(p) delete p;}
int& operator[](int i);  //returneaza o referinta la elementul cu
                         //indice i, daca 0<=i<size
int find(int v); //returneaza indicele elementului cu valoarea
                 //v, daca el exista
int min(int i); //returneaza elem. cu val. min din subtabloul
                //cuprins intre indicii 0 si i
};
int Vector::fan_err=0;
int Vector::eroare=0;

Considerand ca se aplica o tratare clasica a erorilor, functiile clasei Vector ar putea fi definite ca mai jos:
 

Vector::Vector(int s){
    if((size=s)==0) p=NULL;
    else p=new int[s];
}

int& Vector::operator[](int i){
    if(i<0 || i>=size){ 
          fan_err=1;
          return eroare;
    }
    fan_err=0;
    return p[i];
}

int Vector::find(int v){
    for(int i=0;i<size;i++) if(p[i]==v) return i;
    return -1;
}

int Vector::min(int i){
    if(i<0 || i>=size){
         fan_err=1;
         return eroare;
    }
    fan_err=0;
    int m=p[0];
    for(int i=1;i<s;i++) if(p[i]<m)m=p[i];
    return m;
}

O functie confruntata cu o situatie de eroare, in cazul tratarii clasice, poate adopta una din urmatoarele optiuni:

In exemplul cu clasa Vector se poate spune ca metoda Vector::find aplica alternativa [2], in timp ce celelalte metode aplica alternativa[3].

Cu privire la alternativa [2] trebuie spus ca ea nu este intotdeauna usor de aplicat. Daca pentru functia Vector::find, de exemplu, valoarea -1 ca indicator de esec este satisfacatoare (este clar ca un indice de tablou nu poate fi negativ in mod normal), in schimb pentru Vector::operator[] si Vector::min ce valoare a tipului int ar putea fi considerata ca "iesita din comun"? Pentru asemenea situatii o solutie ar putea fi aceea ca, in loc de int, tipul returnat sa fie o structura compusa din rezultatul propriu-zis si un fanion de eroare.

Pe de alta parte, chiar si acolo unde este aplicabila, optiunea [2] implica necesitatea testarii de fiecare data a rezultatului executiei functiilor respective. Acest lucru poate duce usor la dublarea dimensiunii codului.

Alternativa [3] prezinta dezavantajul ca apelantul functiei in care a aparut eroarea poate sa nu "observe" ca s-a intamplat ceva. Astfel, multe functii din biblioteca standard C indica aparitia unei erori prin setarea variabilei globale errno. De cele mai multe ori, insa, in programe nu se testeaza valoarea lui errno suficient de sistematic incat sa se obtina o tratare consistenta a erorilor.

Tratarea erorilor cu ajutorul exceptiilor

Vom relua exemplul cu clasa Vector si vom rescrie functia Vector::operator[] astfel incat sa lucreze cu exceptii:

class Vector {
    //...
public:
    class ErrIndex{};
    //...
};

int& Vector::operator[](int i){
    if(i<0 || i>=size) throw ErrIndex();
    return p[i];
}

//exemplu de utilizare

void o_functie(){
    Vector v(10);
    //...
    try{
       int i=diverse_valori;
       fa_ceva_cu(v[i]);
    }
    catch(Vector::ErrIndex){
       trateaza_eroarea();
    }

//... (*)

}


 
 

Mecanismul exceptiilor aduce ca noutate separarea explicita a secventelor de cod "normale" fata de cele de tratare a erorilor. In felul acesta programele devin mult mai clare, simplificandu-se cooperarea intre fragmente de program scrise separat.

Ideea fundamentala care sta la baza mecanismului exceptiilor este aceea ca daca o functie oarecare F1 se confrunta cu o situatie pe care nu o poate rezolva, ea va anunta acest lucru apelantului ei, prin emiterea unei asa-numite exceptii, "sperand" ca apelantul va putea (direct sau indirect) sa rezolve problema. O functie F2 care doreste sa rezolve o situatie de exceptie trebuie sa-si manifeste intentia de a capta exceptia emisa in situatia respectiva. Secventa de program care realizeaza captarea unei exceptii se numeste handler de exceptii.

C++ pune la dispozitie 3 constructii de baza pentru lucrul cu exceptii:

Inainte de a trece la prezentarea acestor trei elemente este necesar sa precizam ce inseamna, in C++, o exceptie: o exceptie este un obiect. Teoretic, acest obiect poate apartine oricarui tip recunoscut de limbaj. Practic, tipul unei exceptii se alege in functie de modul in care ea va fi tratata, respectiv de cantitatea de informatie suplimentara pe care trebuie sa o poarte. Trebuie spus ca tipul unei exceptii reprezinta mijlocul de identificare a imprejurarii in care exceptia respectiva a fost generata.

In exemplul propus s-a definit clasa Vector::ErrIndex pentru a reprezenta exceptiile emise la utilizarea unui indice eronat in contextul obiectelor de tip Vector. Se observa ca aceasta clasa nu contine nici un membru si, ca urmare, obiectele ei nu transmit nici o alta informatie decat simplul fapt ca, la o referire de forma v[i], unde v este de tip Vector, s-a utilizat o valoare eronata pentru i.

Instructiunea throw

Ca sintaxa, aceasta intructiune este asemanatoare cu return:
 
 




throw expresie_exceptie;


 














unde expresie_exceptie are ca rezultat un obiect apartinand unui tip ales de proiectant pentru reprezentarea exceptiilor.

Obs: exista si varianta simpla, fara argument a instructiunii throw. Rolul ei este de a retransmite o exceptie si va fi discutat in paragraful referitor la blocurile catch.

Presupunem pentru inceput ca instructiunea throw nu apare in interiorul unui bloc try. In acest caz putem spune ca throw si return sunt asemanatoare si d.p.d.v. al comportamentului, in sensul ca executia lui throw inseamna, printre altele:

Se poate observa ca instructiunea throw reprezinta modalitatea de a rezolva "dilema" legata de alternativa [2] de tratare clasica a erorilor. Prin throw, practic, putem impune ca o functie sa returneze rezultate de alt tip decat cel specificat in prototipul functiei. Mai mult, un constructor, care este considerat ca o functie ce nu returneaza nimic, poate sa se termine printr-un throw, deci in conditii speciale poate returna totusi "ceva" (v. constructori/distructori).

Intre instructiunile throw si return exista si deosebiri:

Clauza throw

Sintaxa limbajului C++ permite specificarea in prototipul unei functii tipurile de exceptii pe care aceasta le poate emite, prin adaugarea unei clauze throw:
 
 




tip_rez func(lista_par_form) throw(tip_exp1,. . .,tip_expn);


 














Clauza throw poate lipsi sau poate avea lista de tipuri de exceptii vida. Vom avea deci pentru specificarea unei functii urmatoarele posibilitati:

In acest caz functia poate emite orice tip de exceptie. In acest caz functia nu poate emite nici un fel de exceptie. In acest caz functia poate emite doar exceptii apartinand tipurilor enumerate in clauza throw sau tipurilor derivate din acestea.

Clauza throw face parte din interfata (prototipul) unei functii. Prezenta ei aduce un plus de claritate pentru utilizatorii functiei respective, mai ales atunci cand ei nu au acces la definitia functiei.

Blocurile try-catch

Din punct de vedere sintactic un bloc try-catch are forma:

try {
   //secventa obisnuita de operatii, care poate fi
   //intrerupta de aparitia unei exceptii 
}
catch(tip_ex1 e){
   //secventa tratare exceptie de tip tip_ex1
}
catch(tip_ex2 e){
  //secventa tratare exceptie de tip tip_ex2
}

//. . .

catch(tip_exn e){
   //secventa tratare exceptie de tip tip_exn
}

//. . . (*)

Un bloc try poate fi urmat de unul sau mai multe blocuri catch. Modul de functionare al unei asemenea constructii este urmatorul: daca in decursul desfasurarii secventei de operatii din blocul try este emisa o exceptie, fie direct (adica prin executia unui throw explicit), fie indirect (adica prin apelul unei functii care emite exceptia), secventa se intrerupe si se baleiaza lista de blocuri catch asociate pentru a-l detecta pe cel corespunzator tipului de exceptie emisa. Daca un astfel de bloc exista, se va lansa in executie secventa de tratare a exceptiei respective. Daca aceasta secventa nu trece printr-un return sau throw, la terminarea ei executia programului continua cu linia care urmeaza dupa ultimul bloc catch atasat blocului try curent (cea notata cu (*) in figura de mai sus).

Trebuie precizat ca un bloc catch poate avea si forma:

catch(. . .){
   //secventa tratare exceptie de orice tip
}

Un astfel de bloc capteaza exceptii de orice tip: este handler universal.

Daca nu exista nici un bloc catch corespunzator exceptiei emise, aceasta va fi propagata spre eventuale blocuri try-catch exterioare.

Este posibil ca o secventa catch sa execute la randul ei o comanda throw. Efectul in acest caz este intreruperea functiei curente prin emisia unei exceptii care va fi preluata de unul din apelantii functiei. Exceptia emisa dintr-un bloc catch poate fi aceeasi cu cea primita spre rezolvare (spunem ca are loc restransmisia exceptiei), sau o alta noua. Retransmisia unei exceptii se face cu ajutorul unei instructiuni throw fara argument.

Practic, un bloc catch poate fi asimilat cu o functie care are un singur parametru, de tipul celui specificat dupa cuvantul catch. Apelul unei asemenea functii se face automat, la aparitia unei exceptii de tip corespunzator in blocul try la care este atasat blocul catch respectiv.

Referitor la "drumul" parcurs de o exceptie din momentul emiterii sale si pana ajunge sa fie tratata, lucrurile stau astfel:

Obs: procesul de baleiere a stivei de apeluri implica practic parasirea tuturor functiilor "intalnite" in cale pana la gasirea blocului try inconjurator. Consecinta este ca se vor elimina din stiva toate variabilele locale ale acelor functii, deci se vor executa distructori acolo unde este cazul.

Cu ajutorul unor mici exemple vom ilustra mecanismul de functionare a blocurilor try-catch si a instructiunii throw.

class O_exceptie {/*...*/};
class Alta_exceptie {/*...*/};

void o_functie(){
   //. . .
   if(eroare) throw O_exceptie();
   //. . .
}

void alta_functie(){
   //. . .
   o_functie(); //nu suntem intr-o secventa try
   //. . .
}

void si_inca_una(){
   //. . .
   try{//1
      //. . .
      alta_functie();
      //. . .
   }
   catch(O_exceptie e){
      if(poti_sa_rezolvi) fa_o();
      else throw; //retransmisia exceptiei e
   }
   catch(Alta_exceptie e){
     if(alta_eroare) throw "exceptie de tip char*";
     else tratare_normala(); 
   }
   try{//2

       //aici nu se apeleaza o_functie()
       //si nici alta_functie()
   }
   catch(...){
      //capteaza orice exceptie din try 2
   }

   //. . . 

}//end si_inca_una()

Presupunem ca la un moment dat lantul de apeluri este main -> si_inca_una -> alta_functie -> o_functie si ca o_functie executa instructiunea throw. Se creaza o exceptie de tip O_exceptie, se iese din o_functie, revenindu-se in alta_functie. Aici se constata ca nu exista bloc try si ca urmare se iese din alta_functie ca si cum aici ar exista un throw, si se revine in si_inca_una. De data aceasta revenirea are loc in interiorul unui bloc try (cel notat cu 1). Se cauta printre ramurile sale catch si se gaseste cea cu parametru de tip O_exceptie. Daca exceptia se poate rezolva, dupa executia secventei catch programul continua cu blocul try urmator (cel notat cu 2).
Daca se ajunge la retransmisia exceptiei, atunci se iese din si_inca_una si se verifica daca apelul la aceasta functie nu se afla in interiorul unui bloc try din apelant (main, adica). Atentie deci: la executia unui throw din interiorul unui bloc catch nu se rebaleiaza lista curenta de handler-e si nici nu se trece la un bloc try-catch aflat "mai la vale" in aceeasi functie, chiar daca acesta din urma ar putea capta exceptia emisa (cum ar fi cazul blocului try 2 din exemplul de mai sus).
Exista si posibilitatea ca un bloc try-catch sa fie inclus in secventa try a unui alt bloc try-catch, ca in urmatorul exemplu:

class O_exceptie {/*...*/};
class Alta_exceptie {/*...*/};

void o_functie(){
   //. . .
   if(eroare) throw O_exceptie();
   //. . .
}

void alta_functie(){
   //. . .
   try{//1
      //. . .
      try{//2
         o_functie();
      }
      catch (char *m){
          cout<< m << endl; //tratarea exceptiei 
                        //presupune afisarea lui m
      }
      //. . .
  }
  catch(O_exceptie e){
      if(poti_sa_rezolvi) fa_o();
      else throw; //retransmisia exceptiei e
  }
  catch(Alta_exceptie e){
     if(alta_eroare) throw "exceptie de tip char*";
     else tratare_normala(); 
  }

//. . . 

}//end alta_functie()

In exemplul prezentat se observa ca blocul try-catch notat cu 2 nu va capta exceptii de tipul O_exceptie. Blocul try-catch notat cu 1 este considerat ca inconjurator pentru blocul 2 si deci, exceptia emisa de o_functie va ajunge sa fie tratata de handler-ul corespunzator atasat blocului 1.
In concluzie, se considera ca un bloc try este inconjurator pentru un throw daca secventa try

Blocurile try-catch si vizibilitatea simbolurilor

Blocurile try si catch reprezinta si domenii de definitii de nume. Cu alte cuvinte, identificatorii definiti in interiorul acestor blocuri sunt vizibile si traiesc doar acolo. Exemplu:


void o_functie(){
   int i1;
   //. . .
   try{
     int i2;
     //. . .
  }
  catch(O_exceptie e){
      int i3;
      //. . .
  }
  catch(Alta_exceptie e){
     i1=1; //OK
     i2=2; //eroare, i2 nu e vizibil
     i3=3; //eroare, i3 nu e vizibil
     //...
  }

//. . . 

}//end o_functie()

Gruparea exceptiilor

Este posibil ca proiectantul sa-si defineasca o ierarhie de tipuri de exceptie. In acest caz unul si acelasi handler poate capta exceptii apartinand unor tipuri aflate in relatia clasa de baza-clasa derivata:

class MathErr {/*...*/};

class Overflow: public MathErr {/*...*/};
class Underflow: public MathErr {/*...*/};
class DivByZero: public MathErr {/*...*/};
 

void o_functie(){

//. . .
     try{
        //apeluri la functii care pot emite exceptii de tipurile definite
        //mai sus
     }
     catch(Overflow) {/*...*/}
     catch(Underflow) {/*...*/}
     catch(DivByZero) {/*...*/}
     catch(MathErr) {/*...*/}
           //acest handler va capta exceptiile de tip MathErr sau derivat, dar
           //diferit de cele Overflow, Underflow si DivByZero

//. . .

}
 

Atunci cand nu se doreste tratarea diferentiata a 2 tipuri de exceptii care deriva dintr-o baza comuna, se va scrie un handler care va avea ca parametru o exceptie de tipul clasei de baza. In acest context se pot exploata binefacerile mecanismului de functii virtuale, cu conditia ca tipul parametrului handler-ului sa fie referinta sau adresa la clasa de baza respectiva (cum ar fi MathErr& in exemplul de mai sus):

class MathErr {
    //...
public:
   virtual void print_mes();
   //...
};

class Overflow: public MathErr {/*...*/};
class Underflow: public MathErr {/*...*/};
class DivByZero: public MathErr {/*...*/};
//in clasele derivate din MathErr se redifineste
//functia print_mes()

void o_functie(){

//. . .
     try{
        //apeluri la functii care pot emite exceptii de tipurile definite
        //mai sus
     }
     catch(MathErr& e) {
        e.print_mes();
     }
//. . .

}
 

Un lucru important care trebuie specificat in legatura cu gruparea exceptiilor este acela ca daca intr-un bloc try-catch exista cel putin 2 handler-e pentru care tipurile exceptiilor sunt in relatia supertip-subtip sau exista handler-ul universal, atunci nu este indiferent in ce ordine plasam in program handler-ele respective. Aceasta restrictie deriva din faptul ca, la emiterea unei exceptii, cautarea unui catch corespunzator se face parcurgand lista de blocuri catch in ordinea textuala in care apar ele.
Regula generala este ca handler-ul corespunzator subtipului sa se scrie inaintea celui corespunzator supertipului (asa cum se vede in exemplul de mai sus), iar handler-ul universal, daca exista, sa fie ultimul din lista. Daca nu se respecta aceasta regula, se ajunge in situatia ca anumite handler-e nu vor fi niciodata accesate, ele fiind "mascate" de handler-ele situate inaintea lor si care vor capta toate exceptiile aparute.

Constructorii/distructorii in contextul tratarii exceptiilor
Intr-un paragraf anterior se facea afirmatia ca terminarea prin throw a unei functii este considerata ca "anormala" si ca lucrul acesta este semnificativ din punct de vedere al mecanismului de functionare a constructorilor/distructorilor.
Sa lamurim ce inseamna aceasta: daca la crearea unui obiect constructorul implicat se termina printr-un throw, la disparitia obiectului respectiv NU se va mai executa distructorul, deoarece se considera ca obiectul nu a fost initializat complet.
Pe de alta parte insa (si aici e salvarea), daca intre membrii obiectului se afla alte obiecte si acestea au apucat sa fie initializate corect, pentru ele se vor executa distructorii aferenti.
Consecintele acestui mod de functionare se "simt" atunci cand un constructor apuca sa realizeze alocarea uneia sau a mai multor resurse (de ex. alocari din heap sau deschideri de fisiere), dupa care, dintr-un motiv oarecare, se termina prin throw. Distructorul, care ar trebui in mod normal sa elibereze resursele respective, nu se va executa la disparitia obiectului, deci resursele raman alocate. Exemplu:
 

class o_clasa{
   int *p;
   int size;
public:
    o_clasa(int s){p=new int[size=s]; init(p);}
    void init(int *pp){
        //...
        if(eroare) throw Exceptie();
    }
    ~o_clasa(){delete[] p;}
};

void o_functie(){
   try{
     o_clasa un_ob(10);
       //daca constructorul esueaza, la iesirea din secv. try nu se va executa
       //distructorul pentru un_ob, deci nu se elibereaza memoria indicata de 
       //membrul p
     use(un_ob);
   }
//. . .

}
 

Portita de scapare pentru aceste situatii consta in a realiza alocarea de resurse prin intermediul unor obiecte, asa ca in urmatoarea secventa:
 

class MemPtr{
   int *p;
public:
    MemPtr(int s){p=new int[s];}
    ~MemPtr(){delete[] p;}
    operator int*(){return p;}
         //acest operator asigura utilizarea obiectelor de tip MemPtr ca si
         //cum ar fi de tip int*
};
class o_clasa{
   MemPtr ob_p;
   int size;
public:
    o_clasa(int s):p(s),size(s){init(ob_p);}
    void init(int *pp){
        //...
        if(eroare) throw Exceptie();
    }
    ~o_clasa(){}
};

void o_functie(){
   try{
     o_clasa un_ob(10);
     //daca constructorul o_clasa esueaza, la iesirea din secv. try nu se va executa
     //distructorul pentru un_ob, dar se va executa distructorul pentru membrul
     //ob_p
     use(un_ob);
   }

//. . .

}
 

Politica aplicata prin solutia de mai sus este cunoscuta sub denumirea de "alocarea resurselor prin initializare".
Acest concept poate fi sintetizat astfel: fie o clasa de forma data mai jos:

class o_clasa{
   tip_1 resursa_1;
   tip_2 resursa_2;
   //. . .
   tip_n resursa_n;
public:
    o_clasa(/*parametri*/){
      alocare_resursa_1();
      alocare_resursa_2();
      //. . .
      alocare_resursa_n();
      //. . .
    }
    ~o_clasa(){
      eliberare_resursa_1();
      eliberare_resursa_2();
      //. . .
      eliberare_resursa_n();
      //. . .
    }
   //. . .
};

Presupunem ca la crearea unui obiect al acestei clase constructorul reuseste sa execute corect alocarea a k resurse (1<= k <n), dupa care esueaza la tentativa de alocare a resursei k+1. In acest caz, la disparitia obiectului respectiv nu se va mai executa distructorul, deci cele k resurse raman alocate. Pentru a rezolva problema trebuie ca tipurile tip_i sa fie clase, constituite dupa modelul clasei MemPtr. Atunci, desi distructorul ~o_clasa nu se executa, totusi distructorii obiectelor resursa_i care au fost create corect se vor executa, ceea ce asigura eliberarea resurselor respective. Astfel se explica denumirea de "alocare prin initializare", si anume alocarea unei resurse se realizeaza de fapt prin initializarea unui obiect resursa_i:

class o_clasa{
   clasa_tip_1 resursa_1;
   clasa_tip_2 resursa_2;
   //. . .
   clasa_tip_n resursa_n;
public:
    o_clasa(/*parametri*/):
      resursa_1(/*par1*/),
      resursa_2(/*par2*/),
      //. . .
      resursa_n(/*parn*/){
      //. . .
    }
    ~o_clasa(){
      //. . .
    }
   //. . .
};
class clasa_tip_i{
   tip_i resursa_i;
public:
    clasa_tip_i(/*parametri*/):{
    //. . .
    }
    ~clasa_tip_i(){
      //. . .
    }
    operator tip_i(){
      return resursa_i;
    }
   //. . .
};

 

Tema
1.Se cere sa se definesca o clasa Queue care modeleaza functionarea unei cozi FIFO cu elemente de tip char. Dimensiunea cozii este limitata si se specifica la creare. Operatiile care se pot aplica asupra cozii sunt:

Operatia Put esueaza cand coada este plina, iar operatia Get esueaza daca coada este goala. Constructorul cozii va semnala eroare daca dimesiunea data ca parametru este <= 0.
Tratarea erorilor se va efectua cu ajutorul mecanismelor de emisie-captare exceptii.

2.Se cere sa se defineasca o clasa Files care sa modeleze lucrul cu fisiere de text, folosindu-se tipul clasic FILE*. Constructorul clasei va realiza deschiderea fisierului, iar distructorul va realiza inchiderea. Problema va fi prezentata in 2 variante: cu si fara aplicarea principiului de "alocare prin initializare", urmarindu-se modul de executie a constructorilor si distructorilor. In constructor se va simula o situatie de eroare oarecare dupa ce s-a deschis fisierul.
Ca metode pentru clasa Files, se vor prevedea minimum:

Obs: programele se vor compila cu g++ de pe bigfoot.cs.utt.ro, care utilizeaza compilatorul egcs (vers 1.0.3)!

Bibliografie

[Bol98] W.R. Bolton - "Free Course Pamphlets", http://www.mindspring.com/~wrba/frepam/index.htm (legatura pe 'CPPTHROW')

[Gre99] C. Green - "Cliff's Teaching Info and Technical Resources", http://www.halcyon.com/cliffg/uwteach/index.html (legatura pe 'C++ Programming: Intermediate')

[Str91] B. Stroustrup  - "The C++ programming language",  AT&T, 1991
 
 

Hosted by www.Geocities.ws

1