Sari la conținutul principal

Tratarea erorilor

Orice program este o masina de stari si orice masina de stari trebuie sa trateze toate cazurile de utilizare iar acestea cuprind atat stari de functionare normala cat si stari de eroare. Intrebarea e, cum putem trata erorile? In multe biblioteci de C eroarile erau semnalate la returnarea functiilor cu coduri de eroare, de obicei negative si erau amestecate cu raspunsuri ale apelurilor functiilor care au ca insemnatate altceva decat un cod de succes, ca de exemplu numarul de bytes transferati pe un canal de comunicatie. Alta abordare in C este setarea variabilei errno care poate ramane cu o valoare anterioara setata si poate duce la erori. Lucrul acesta s-a dovedit a fi nesustenabil pentru programe foarte complexe pentru ca programatorii, chiar si cu documentatie, ar avea probleme in a intelege codul dar s-a pastrat aceasta abordare din ratiuni istorice si pentru ca era mult prea greu sa refaca toate bibliotecile, chiar si cele de system, de la 0 in alt mod.

Exceptii

Solutia este sa avem tipuri de eroare si metode de semnalare a erorilor distincte de fluxul normal al programului. Practic, cand avem o eroare intr-o functie acesta in loc sa returneze un tip cum este int pentru succes si eroare, sa returneze un tip suma (uniune discriminatorie) de succes sau eroare. Exista mai multe abordari aici, dar una care este folosita in foarte multe limbaje este suportul pentru exceptii. O exceptie este un obiect care se arunca (throw) la eroare intr-o functie si se prinde (catch) intr-un stack-frame anterior apelului functiei pentru a fi tratata eroare.

In multe cazuri cand se greseste, de exemplu cand incercam sa accesam un camp dintr-un obiect null, apare o eroare sub forma unei exceptii si programul se inchide. In cazul accesului la date de la o referinta null apare NullReferenceException. Aceasta este o exceptie care este aruncata de catre runtime si mosteneste clasa Exception, altfel putem arunca orice obiect care este sau mosteneste Exception prin cuvantul cheie throw ca mai jos.

throw new Exception("This is an error message!");

Este important de mentionat ca desi are cateva proprieti utile clasa Exception cum ar fi Message unde aveti un mesaj de eroare si StackTrace unde aveti toate stack-frame-urile, adica ce functii s-au apelat pe ce linie in cod pana la prinderea exceptiei, si faptul ca se poate printa acestea, cel mai bine este sa creati propriile clase exceptii cu proprietati pe care i le dati voi pentru identificarea mai buna a erorilor.

Tratarea exceptiilor

Tratarea erorilor se face in felul urmator:

try
{
CallMethodWithPossibleError() // Facem ceva ce arunca exceptie.
}
catch (NullReferenceException ex)
{
// Trateaza eroarea prinsa.
}
catch (Exception ex)
{
// Putem avea o alta tratare pentru o exceptie mai putin specifica.
}
finally
{
// Fa curatare daca este nevoie, acest bloc poate lipsi.
}

Dupa cum se vede, exista trei tipuri de blocuri de cod:

  • try - unde se executa un bloc de cod unde poate sa fie aruncata exceptia.
  • catch - unde se prinde exceptia aruncata si poate sa fie tratata, pot exista mai multe catch-uri cu tipuri de exceptii diferite dar ar trebui sa fie puse in ordinea de la cea mai specifica la cea mai generala daca sunt mostenite de la una la alta pentru ca catch-urile sunt parcurse in ordine pana cand unul se nimereste cu tipul exceptiei aruncate. try si catch sunt mereu folosite impreuna, cel putin un catch trebuie sa existe.
  • finally - este un block optional dar care se executa intotdeauna indiferent daca este aruncata o exceptie sau daca in oricare bloc try sau catch se face return. Acest bloc are ca utilitate inchiderea unor resurse daca este nevoie, cum sunt fisierele deschise, pentru ca acest bloc este mereu apelat. De obicei, aici obiectelor care implementeaza IDispose cum e clasa Stream sau interfata IEnumerator<T> li se apeleaza metoda Dispose() pentru dezalocarea resulselor.

Trebuie mentionat ca in C# fata de Java nu aveti ideea de exceptii checked, adica daca o functie arunca o exceptie checked oriunde se apeleaza aceasta trebuie sa fie inconjurata cu un try-catch sau sa marcheze functia curenta ca arunca si ea acea exceptie checked. Acest lucru a dus la multe probleme pentru ca desi se intentiona ca fiind o practica buna sa-ti tratezi mereu erorile s-a transformat repede intr-o practica rea pentru ca programatorii in general erau sacaiti de acest lucru si inconjurau cu un try-catch functia doar ca sa poata compila fara sa dea eroare ignorand exceptia.

In C# trebuie doar sa fiti atenti daca in documentatia metodelor se arunca exceptii si sa le prindeti acolo unde considerati ca e indicat. Daca tratati erorile ar fi bine sa le logati undeva, cel mai bine cu un logger intr-un fisier sau un serviciu specializat. Nu ignorati erorile pentru ca atunci cand programul poate functiona gresit si pentru ca ignorati eroarea o sa va fie foarte greu sa o depanati.

Utilizarea exceptiilor

O posibila folosire corecta a exceptiilor este urmatoarea:


// Putem crea coduri de eroare pentru diferite situatii ca in acest enum.
public enum ErrorCodeEnum
{
Unknown,
NotFound,
Forbidden,
BadRequest
}

// Ne facem propria exceptie care mosteneste Exception cu codul de eroare.
public class CodedException : Exception
{
public ErrorCodeEnum ErrorCode { get; }

public CodedException(string message, ErrorCodeEnum errorCode = ErrorCodeEnum.Unknown) : base(message)
{
ErrorCode = errorCode;
}
}

// Derivam exceptia mai departe si pentru fiecare exceptie adaugam si codul de eroare propriu.
public class NotFoundException : CodedException
{
public NotFoundException(string message) : base(message, ErrorCodeEnum.NotFound)
{
}
}

public class ForbiddenException : CodedException
{
public ForbiddenException(string message) : base(message, ErrorCodeEnum.Forbidden)
{
}
}

public class BadRequestException : CodedException
{
public BadRequestException(string message) : base(message, ErrorCodeEnum.BadRequest)
{
}
}

Cum am definit mai sus exceptiile pe care le avem ne ofera multa flexibilitate. Putem, fie sa prindem fiecare tip de exceptie in cate un catch si sa tratam separat fiecare caz in parte, sau sa tratam doar cazul pentru exceptia noastra de baza identificand si codul de eroare. Nu putem sa folosim mesajul din Exception, acesta este doar un mesaj informativ pentru dezvoltator, codul de eroare este mai util pentru ca pe langa faptul ca stim exact care ar putea sa fie problema putem sa transmitem codul de eroare catre alte sisteme care sa-l inteleaga mai usor decat un mesaj care poate fi schimbat la un moment dat de catre programator.

Alternative

Exceptiile sunt in general utile daca se folosesc atunci cand este indicat pentru simplicatea codului si nu se abuzeaza de ele. Exceptiile trebuie aruncate doar cand este necesar astfel incat codul sa fie tinut cat mai simplu. Problema cu exceptiile este ca desi blocul de try se executa cu aceiasi viteza ca un branch al unui if, catch-ul este mult mai incet pentru ca runtime-ul trebuie sa examineze fiecare stackframe pana unde poate sa fie prinsa exceptia iar acest lucru este costisitor. De asemenea, se poate intampla ca in urma unei exceptii sa se declanseze mult mai multe in lant daca fluxul programului nu a fost pregatit pentru anumite cazuri limita.

In plus, in multe aplicatii se creaza un global exception handler care apeleaza bucla principala a aplicatiei iar atunci cand apare o exceptie trateaza fiecare caz de exceptie la nivel global. Solutia este buna insa doar ca metoda de precautie ca aplicatia sa nu crape. O altenativa la exceptii este sa incapsulati raspunsurile si eroarile in acelasi obiect intr-un mod mutual exclusiv.

// Declaram un mesaj de eroare cu un text si un cod de eroare.
public class ErrorMessage
{
public string Message { get; }
public ErrorCodeEnum ErrorCode { get; }

public ErrorMessage(string message, ErrorCodeEnum errorCode = ErrorCodeEnum.Unknown)
{
Message = message;
ErrorCode = errorCode;
}

public override string ToString() => $"{{ {nameof(Message)} = {Message}, {nameof(ErrorCode)} = {ErrorCode}}}";
}

/* Ca sa inlocuim exceptiile, folosim aceasta clasa sa indicam daca o
* functie returneaza cu succes sau o eroare incapsulata in acest obiect.
*/
public class ServiceResponse
{
public ErrorMessage? Error { get; init; }
// Daca nu avem eroare inseamna ca este succes.
public bool IsOk => Error == null;

/* Avem mai multe metode statice pentru ne azuta sa cream obiectele direct pastrand
* eroare si succesul mutual exclusive.
*/
public static ServiceResponse FromError(ErrorMessage? error) => new() { Error = error };
public static ServiceResponse<T> FromError<T>(ErrorMessage? error) where T : class => new() { Error = error };
public static ServiceResponse ForSuccess() => new();
public static ServiceResponse<T> ForSuccess<T>(T data) where T : class => new() { Result = data };
// Putem face si convertueare la raspunsul generic.
public ServiceResponse ToResponse<T>(T result) where T : class => Error == null ? ForSuccess(result) : FromError(Error);

protected ServiceResponse() { }
}

/* Ca sa facem obiecul de raspuns mai versatil includem si date si il putem face generic.
* Trebuie sa fie un tip refetinta ca sa mearga valoarea null pe Result.
*/
public class ServiceResponse<T> : ServiceResponse where T : class
{
// Incapsulam aici raspunsul cu date in caz de succes.
public T? Result { get; init; }
public ServiceResponse ToResponse() => Error == null ? ForSuccess() : FromError(Error);
/* Putem crea alte metode pentru a ne usura munca daca este nevoie cum ar fi acest map
* ca sa nu tratam cazul de succes sau eroare in afara obiectului, doar lasam o functie care
* sa faca convertirea.
*/
public ServiceResponse<TOut> Map<TOut>(Func<T, TOut> selector) where TOut : class => Result != null ?
ForSuccess(selector(Result)) : FromError<TOut>(Error);

protected internal ServiceResponse() { }
}

Cu exemplul prezentat putem sa incapsulam usor raspunsuri de succes si de eroare si chiar sa facem tramsformari peste acestea foarte simplu fara sa invocam exceptii pasand doar obiecte de raspuns. Pe langa faptul ca nu avem penalitatea pentru catch, putem doar sa mapam raspunsuri de un tip la altul fara sa adaugam complexitate (ciclomatica) tratand cazuri cu if, practic fluxul logic al programului este mult mai curat. Avantajul la aceasta abordare prin incapsulare este ca e flexibila si mai eficienta pe cazurile de eroare. Dezavantajul este ca se mai adauga un invelis peste tipurile de iesire ale functiilor si nu avem informatie despre unde anume in cod a aparut eroare decat daca implementam artificii pentru a identifica stackframe-ul sau locatia unde a aparut eroarea.

Referinte

System.Exception