Programare Java - utilizarea exceptiilor


P.S. Acest articol este dedicat programatorilor. Articolul prezinta concluziile la care am ajuns pana acum despre utilizarea exceptiilor. La inceput prezint pe scurt concluziile, apoi urmeaza explicatii mai detaliate, inclusiv pentru incepatori.


Pe scurt : reguli pentru utilizarea exceptiilor

1. "Nu prinde exceptia daca nu stii ce decizie sa iei pe baza ei !". Daca in acel bloc de cod nu ai suficiente informatii sau nu poti efectua operatiile necesare, lasa exceptia sa se propage.

2. "Nu inghiti exceptia !". Exceptia nu trebuie prinsa intr-o bucla vida, si de obicei nu este suficient sa printezi stack-ul. Exceptia este un mesaj care cere o decizie informata. In functie de gravitate, poti face 3 lucruri cu exceptia prinsa: logare + exit, logare + continuare sau "wrap" + re-throw.

3. "Exceptia se scrie in fisierul de loguri in ultimul loc in care este prinsa !". Nu se printeaza exceptia inainte de a fi aruncata mai departe pentru a nu duplica logarea ei.

4. "Nu loga stack-ul sau tipul exceptiei daca exceptia nu cere depanarea codului !". Daca nu poti deschide fisierul de configurare, explici asta utilizatorului specificand calea completa a fisierului, nu ii printezi un stack imens cu care oricum nu poate face nimic.

5. "Fa wrap la exceptii intre layerele aplicatiei !". Pentru a nu creste exponential numarul de exceptii pe care trebuie sa le trateze layerele superioare, transforma o exceptie mai specifica intr-una mai generala. De exemplu o eroare specifica Oracle sau MySql este transformata in "PersistenceException" de catre un layer de abstractizare a accesului catre baza de date.
  Nu uita sa conservi in textul noii exceptii informatiile relevante : contextul logic in care s-a receptionat exceptia (exemplu : "while connecting to server") si cauza exceptiei primite (exemplu : "connection refused"). Pentru erori neasteptate se poate conserva stack-ul initial ca si "cause" al noii exceptii.

6. " Cand tratezi exceptia, evita sa faci operatii care ar putea sa genereze alte exceptii !". Un caz obisnuit este apelul unei "functii membru" a unui obiect de care nu esti sigur ca nu este null. Daca este null, codul de tratare a exceptiei poate genera NullPointerException, ascunzand exceptia initiala.

7. "Cand creezi o exceptie, gandeste-te ce poate face utilizatorul cu ea !". Daca crezi ca utilizatorul metodei trebuie sa prevada o actiune specifica la o anume exceptie, deriveaz-o din Exception ca sa-l obligi sa o trateze. Daca arunci exceptia ca sa anunti ca ai descoperit la executie un bug in functionarea clasei, o poti deriva din RuntimeException, pentru ca cel mai probabil utilizatorul ar da exit() oricum.

8. "Utilizatorul obisnuit al unui program profesionist nu trebuie sa vada o exceptie cu stack decat daca codul are bug-uri". Exceptiile care anunta anumite evenimente prevazute trebuie transformate in mesaj explicativ, eventual internationalizat (in limba utilizatorului). Pentru exceptiile tip "eroare de functionare" se poate afisa totusi textul(cauza) exceptiei, cu restrictia ca nu poate fi internationalizat usor fara match-uri grosolane. Se pot printa anumite exceptii cu stack intr-un fisier special de trace/log care sa poata fi folosit in depanare.

9. Incearca sa conservi in exceptia pe care o re-throw toate informatiile relevante despre contextul aparitiei ei, in textul exceptiei si dupa caz in stack/cause. Textul exceptiei poate fi completat la fiecare layer de re-throw cu informatii despre contextul exceptiei (dar scurt si la obiect). Wrap-ul exceptiei in text, fara stiva completa, este util mai ales la executii remote unde clientul poate sa nu aibe informatiile necesare decodarii stivei.

10. "Nu crea un nou tip de exceptie daca utilizatorul metodei nu poate lua o decizie diferita in functie de acel tip de exceptie". Pentru erori fatale poti face un simplu "throw new RuntimeException("asert failed : ......").


Mesajul exceptiei contine 3 informatii importante :

1. (WHAT) Tipul exceptiei. De exemplu IOException se refere la un eveniment sau eroare de input/output. RuntimeException se refera la o eroare de programare care poate apare in functionarea normala a JVM, precum NullPointerException cand incercam sa folosim o referinta care nu pointeaza spre un obiect. Tipul exceptiei este folosit in a lua decizii diferite in locul in care exceptia este tratata.

2. (WHERE) Locul in care s-a generat exceptia. Acest loc este definit de stiva ("stack"-ul) de apeluri de la main() pana la locul in care s-a generat exceptia. In Java se poate extrage inclusiv fisierul si linia de cod in care s-a aruncat exceptia. Aceasta ajuta la depanarea codului cu probleme (debugging), dar este irelevant pentru evenimente care au fost prevazute in logica programului (probleme de conexiune).

3. (WHY) Motivul care a generat exceptia. Acesta este un mic text care explica cauza exceptiei. De exemplu un java.net.ConnectException poate fi cauzat de "Connection refused" primit de la server. Motivul este de obicei trimis utilizatorului sau este folosit la depanare.
 Puterea acestui text sta tocmai in faptul ca fiind un simplu String, el poate trimite transparent prin diferite layere informatii specifice logicii unui anumit layer. De exemplu ne putem inchipui ca layerul abstract de persistenta trimite pana la utilizator exceptia: PersistenceException("mysql index file table.MYI corrupted at offset dddd"). Aplicatia nu trebuie sa cunoasca detaliile bazei de date respective, dar utilizatorul poate primi o informatie foarte exacta despre locul problemei.







Exceptiile in detaliu
(inclusiv pentru incepatori)

Cuvant inainte

Am gasit destul de greu informatii despre "best practice" in utilizarea exceptiilor. De aceea m-am gandit sa combin informatiile gasite cu ideile proprii, in acest articol.

 Voi folosi limbajul Java ca exemplu, dar multe idei se pot folosi si in alte limbaje de programare. Java forteaza in multe situatii folosirea (tratarea) exceptiilor. Alte limbaje, precum C++, ofera doar optiunea de a le folosi.

Ce sunt exceptiile ? (pentru incepatori)

  Exceptiile au aparut din nevoia de a descrie mai clar ce trebuie sa faca programul in situatii mai speciale (de unde numele de "exceptie"). De obicei exceptia exprima o abatere de la flow-ul obisnuit al programului.

Sa luam exemplul urmator, imperfect, dar functional si exemplificativ. In programul de mai jos, incerc deschiderea fisierului "cucu.txt" si citirea primei linii din el.

  Operatia ar putea esua din mai multe motive, de exemplu fisierul poate sa nu existe, sau nu am dreptul de citire asupra lui. Pentru a nu verifica la fiecare operatie daca a aparut vreo problema, creez un bloc "try" in care pun operatiile asupra fisierului. Daca va apare o eroare, functia apelata va "arunca"("throw") o exceptie, fortand terminarea blocului "try".

  La sfarsitul blocului "try" exista un bloc "catch" care se executa in cazul aparitiei unei exceptii. In cazul de fata, afisez un mesaj, printez stack-ul unde a aparut exceptia si dau "exit()" pentru ca programul nu are rost sa incerce afisarea liniei care nu a putut fi citita.


import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;


public class Test {

public static void main(String[] args) {

File file;
FileReader fileReader;
BufferedReader fileBufferedReader;
String firstLine="";
file = new File("cucu.txt");

try {

fileReader = new FileReader(file);
fileBufferedReader = new BufferedReader(fileReader);
firstLine = fileBufferedReader.readLine();

}catch(Exception e){ //Catch any exception

System.out.println("Error reading file : " + file.getAbsolutePath());

//This is just to illustrate the exception content
//for this functional exception you should print 

   //an explanatory message, without the stack
e.printStackTrace();

System.exit(-1); //Terminate the program

}

System.out.println("First line is : " + firstLine);
}

}




In cazul in care fisierul nu exista, programul va afisa:

Error reading file : /media/muzica/Java/java_work/FileTreeSearch/cucu.txt
java.io.FileNotFoundException: cucu.txt (No such file or directory)
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.(FileInputStream.java:137)
at java.io.FileReader.(FileReader.java:72)
at Test.main(Test.java:16)


  De fapt, Java chiar imi impune sa tratez exceptiile pe care le-ar putea genera "new FileReader(file)" si "fileBufferedReader.readLine()". Exemplul arata ca aceste cazuri se pot trata separat de firul logic al programului, fara a fi nevoit sa verifici cu un if() rezultatul fiecarei operatii.

  Atunci cand programul intalneste o situatie neobisnuita, poate "arunca" o exceptie ("throw"). Daca programul ajunge in acel punct, el va renunta la continuarea executiei obisnuite, si va muta executia la codul de "tratare" a acelei exceptii.

  Spunem ca exceptia se "propaga" prin blocurile de program apelante pana la primul bloc care "prinde" ("catch") acel tip de exceptie.

  Exceptiile constituie o facilitate puternica a limbajelor de programare, ajutand scrierea mai clara si mai simpla a algoritmilor. Cand nu sunt folosite corespunzator insa, pot sa aibe efectul contrar. O situatie des intalnita este aceea cand exceptia se propaga "neprinsa" pana in functia "main()", generand terminarea programului, precum celebra "Null pointer exception". Astfel de exceptii sunt determinate de erori de programare. Exceptia incerca sa transmita locul (stack-ul) in care s-a petrecut problema pentru a ajuta depanarea codului.

  Un lucru foarte important despre exceptii este ca ele "transporta" un mesaj de la locul in care au fost "aruncate" pana la locul in care au fost "prinse" sau pana la utilizator daca nu sunt prinse.

Exista doua tipuri mari de exceptii.

1. Exceptii generate de erori de programare  (precum "Null pointer exception") sau probleme legate de lipsa resurselor (RAM). In Java aceste erori sunt derivate din RuntimeException si nu forteaza utilizatorul sa trateze exceptia. Se presupune ca de cele mai multe ori decizia optima este propagarea pana la utilizator si terminarea programului, dar se pot crea si mecanisme de restartare a modulului cu probleme (re-creare clasa).

2. Exceptii care exprima "mesaje" despre situatii speciale, dar care nu constituie neaparat erori de programare (de exemplu un server detecteaza ca un client a inchis conexiunea). Aceste mesaje se propaga din locul in care au fost generate pana la apelantul care poate lua o decizie.

 Ce aduc in plus exceptiile fata de un "return code"
  • Pentru functiile care returneaza tipuri de date simple (exemplu int), nici o valoare nu poate fi rezervata pentru a semnala o eroare. Transformarea unui text in numar poate returna orice numar de acel tip. Nu putem rezerva o valoare pentru a semnala un input care nu reprezinta un numar. Aruncand o exceptie se poate comunica acea eroare de procesare. In cazul String se poate folosi intr-adevar valoarea null
  • Exceptia transmite si un text explicativ al erorii aparute, si chiar si stiva de executie pana la locul detectarii erorii. Codul utilizator nu trebuie sa trateze toate cazurile de eroare posibile, dar poate transmite mai departe ce problema a fost identificata, unde si motivul.
  • Exceptia nu se pierde daca nu este tratata (precum codul de return ne-preluat). O functie poate lasa sa se propage toate exceptiile de un anumit tip la un nivel superior unde se poate lua o decizie. Se evita astfel propagarea unor coduri de eroare complicate pana la locul propice tratarii.
  • Exceptiile pot constitui variante mai rapide ca executie pentru maparea mai multor situatii speciale cu blocurile lor de tratare. Daca un cod de eroare ar trebui trecut printr-un "switch", cand se arunca o exceptie se poate calcula exact locul in care ea va fi prinsa, deci controlul programului se poate da direct acolo, fara a mai face selectia in functie de codul de eroare. Aceasta selectie se poate face deja in momentul compilarii.
  •  Exceptia incapsuleaza mai multe informatii (stack, clasa, text) intr-un mod "cunoscut" de toate modulele, in sensul ca este suportat de limbaj. A implementa un sistem separat de comunicare a situatiilor speciale prin coduri de eroare ar necesita ca fiecare layer sa cunosca acel tip de obiect si sa aibe grija sa-l re-transmita pana unde poate fi procesat.

Ce NU sunt exceptiile ?
  •   Exceptiile nu sunt GOTO-uri pe care sa le folosesti pentru a iesi din bucle de procesare, pentru ca au un oarecare overhead care poate afecta performanta (crearea obiectului exceptie). Exceptiile trebuie folosite pentru ceea ce au fost create (cazuri ne-obisnuite), altfel scad si claritatea codului. 
  •  Exceptiile nu sunt un mod de a dialoga cu utilizatorul. Cu exceptia micilor programe de uz personal sau intern companiei, un program trebuie sa traduca exceptiile care fac parte din logica programului in mesaje pe intelesul utilizatorului, deci fara stack.
  •  Exceptiile nu sunt un mod de a returna un String (rezultatul unei procesari). Pentru calea nominala (normala) de executie a programului exista "return".

Actiuni posibile la tratarea exceptiilor

a) daca exceptia notifica o eroare grava de programare ("assert failed")
   - log exceptie pe nivel FATAL, cu stack
   - utilizatorul poate primi stack-ul exceptiei sau, mai elegant, un mesaj "eroare neasteptata" cu referire la log pentru mai multe informatii
   - programul se termina

b) o preconditie a functionarii programului nu este indeplinita, de exemplu nu se poate deschide fisierul de configuratie la pornirea aplicatiei
   - log pe nivel FATAL cu explicarea preconditiei, de preferinta fara stack
   - utilizatorul primeste un mesaj "friendly" despre ce lipseste, unde, si ce se recomanda pentru remedierea problemei
   - programul se termina

c) daca exceptia notifica un eveniment relativ important dar prevazut de programator (clientul s-a deconectat)
  - log pe nivel corespunzator gravitatii DEBUG, INFO, WARN, ERROR (stack-ul cel mult la ERROR)
  - utilizatorul primeste un mesaj "friendly" despre eveniment, daca este cazul. Din exceptie se va loga cel mult cauza exceptiei - exemplu "Connection refused", nu si stack-ul sau tipul clasei-exceptie. Pe utilizator nu il intereseaza unde a fost detectat evenimentul.
  - programul executa eventul actiunile specifice evenimentului (clean-up, reconectare, etc)


 Cand se face wrap la exceptii

O problema in proiectele mari este gestiunea exceptiilor "checked" care obliga la tratarea lor (in Java). Din acest motiv, multi programatori ajung sa arunce din clasele lor exceptii "unchecked", care nu obliga la tratarea lor. Ca urmare, utilizatorul bibliotecii trebuie sa verifice la fiecare apel ce exceptii posibile ar dori sa trateze.

Eu personal cred ca obligativitatea de a lua o decizie explicita in cazul exceptiilor (tratez sau dau mai departe) este un lucru bun. Pentru a nu deveni insa o corvoada, fiecare layer trebuie sa proceseze un numar redus de exceptii, specifice nivelului sau de procesare. Exceptiile de pe nivelele inferioare vor fi incapsulate (wrap) in exceptii mai generale, specifice acelui layer.

De exemplu o exceptie generata de "disk full" poate deveni o exceptie de StorageException la nivelul de jos al bazei de date, o exceptie de OracleSqlQueryException la nivelul de procesare SQL al bazei de date, o PersistenceException la nivelul abstract de persistenta, o ActorCreationException la nivelul frameworkului de bussiness logic si, in final, o CustomerCreationException la nivelul de implementare a unui CRM.

 Important este ca, pe cat posibil, layerele sa nu fie nevoite sa proceseze exceptii specifice deciziilor de la alt layer.

 Singurul lucru care este recomandat sa fie lasat sa se scurga (vezi "leaky abstraction") este textul exceptiei, care completat la fiecare layer poate crea o imagine de ansamblu a cauzei exceptiei pentru utilizator, singurul care poate intelege transversal sistemul.


 Exceptiile chained


 Atunci cand exceptiile sunt re-thrown, se poate atasa exceptia initiala la noua exceptie ca si "cause". Astfel se creeaza un lant (chain) de exceptii. Aceasta poate ajuta la depanarea unor erori de programare care se propaga prin layere de abstractie.

  Daca situatia poate fi explicata doar cu datele de pe acel layer, iar locul de unde vine este irelavant, atunci este recomandabil sa nu se pastreze stack-ul initial, deci nu este necesar sa se faca "chaining". Informatia se poate condensa eventual in textul exceptiei.
  Daca exceptia se propaga remote (RMI), este foarte recomandabil sa se pastreze doar portiunea de stack care este cunoscuta sigur de apelant, altfel clientul nu va putea prelucra exceptia.

 Stack-ul merita sa fie "chained"  doar in cazul exceptiilor care ies din bussiness-logic, semnaland posibile probleme de programare.


Concluzie

  Exceptiile constituie o facilitate puternica a limbajelor moderne de programare, facilitate care aduce avantaje doar folosita corespunzator. Folosirea productiva a exceptiilor este un subiect destul de extins, si inca suscita multe discutii contradictorii.
  Ceea ce am incercat aici a fost doar o mica ridicare de cortina asupra subiectului, ca o incitare la dialog pe aceste teme. Unele reguli pe care le-am prezentat se pot doveni ne-optime in anumite cazuri, si aici astept feed-back-ul celor interesati in sectiunea de comentarii.