Zastanawiałeś się kiedyś, czy zastosować asercję, czy wyjątek, czy może kod błędu? Jeśli tak – ten wpis jest specjalnie dla Ciebie.

Błędy i narzędzia

Przede wszystkim – do czego służą te narzędzia.

Otóż wszystkie trzy narzędzia służą do radzenia sobie z błędami. A błędy mamy różnego rodzaju:

  • błędy logiczne – czyli wynikające ze złego projektu lub złej implementacji. Pomyłka po stronie architekta. Albo programisty. Ogólnie – po naszej stronie
  • błędy czasu wykonania – czyli wynikające ze sposobu użytkowania programu. Tutaj mamy wszystko, co związane ze środowiskiem i użytkownikiem. A więc, np. brak miejsca na dysku, zerwanie połączenia sieciowego, uszkodzone pliki konfiguracyjne, podanie przez użytkownika błędnych/źle sformatowanych danych w formularzu

I tutaj odpowiednio mamy właściwe narzędzia do walki z konkretnymi błędami:

  • do błędów logicznych – mamy asercje, które znakomicie przydają się na etapie debugowania do instrumentacji kodu (więcej o tym za chwilę)
  • do błędów czasu wykonania – mamy kody błędu i wyjątki, które pozwalają wybrać alternatywną ścieżkę wykonania programu w wypadku wystąpienia błędu, i miękko wyjść z błędu (czyli nie zawiesić całej aplikacji/nie wysadzić komputera w powietrze)

Zobaczmy, na co pozwalają nam asercje, kody błędu i wyjątki.

Używaj asercji do debugowania

Debugowanie aplikacji to dosyć złożony temat i istnieją różne sposoby wspomagające “odrobaczanie” programów. Można do nich zaliczyć m. in. analizę formalną (czyli przeglądanie projektu i kodu “na sucho”, bez uruchamiania samej aplikacji), użycie debuggera, czy też zastosowanie instrumentacji kodu.

Czym jest instrumentacja kodu? Gdy chcemy odnaleść błąd w aplikacji, najprostszym sposobem jest wstawianie do kodu instrukcji informujących o tym, jak przebiega wykonanie programu. Instrukcje takie np. wypisują na ekran czy dany warunek został spełniony, jakie są wartości zmiennych, itp. Gdy udaje się nam usunąć błąd – instrukcje te stają się zbędne i usuwamy je z programu.

Przykładowo, bardzo popularną funkcją wykorzystywaną do instrumentacji kodu jest funkcja fprint:

...
if (a > b) {
fprint("warunek a > b został spełniony!");
...
}

Asercja to narzędzie (funkcja bądź makro, zależnie od języka) stworzone właśnie do instrumentacji kodu. Sprawdza ona określony warunek, np. assert(user_id >= 0), i, jeśli nie jest on spełniony, kończy program, wypluwając na standardowe wyjście komunikat informujący, w którym module i w której linijce wystąpił błąd (baaardzo przydatne w trakcie poszukiwania błędu). Tak nawiasem mówiąc – asercja wcale nie musi kończyć działania programu (ale jest tak w znakomitej większości przypadków). Może np. zapisywać komunikat do dziennika i pytać użytkownika, czy chce kontynuować. Wszystko zależy od implementacji!

A co takiego sprawdzają asercje? Asercje sprawdzają tzw. niezmienniki programu. Niezmiennik jest to takie założenie w projekcie, które musi być prawdziwe, by program mógł poprawnie funkcjonować. Czyli np. jeśli mamy funkcję zapisującą dokument do pliku, np.  save(const char* filename), to niezmiennikiem może być założenie, że nazwa pliku nie może być równa NULL:

// przykład asercji w C++
#include <cassert>

void save(const char* filename)
{
	assert(filename != NULL);
	... // dalsza część funkcji
}

Ważna uwaga: asercji nie używamy do sprawdzania poprawności danych wprowadzanych przez użytkownika! Takie dane powinny być sprawdzane najlepiej już na poziomie formularza (jeśli użytkownik wypełnia formularz), a błędy od razu zgłaszane użytkownikowi. Asercja ma sprawdzać, czy aplikacja działa zgodnie z założeniami – jeśli linia obrony, jaką jest walidacja formularza, zawiedzie, to asercja powinna to wyłapać i zgłosić nam jako błąd: “niepoprawny parametr prześlizgnął się przez walidację formularza!”

A teraz fundamentalne pytanie: w czym asercja jest lepsza od, dajmy na to, takiego fprint? Otóż podstawową zaletą asercji jest to, że można je sobie włączać i wyłączać odpowiednią flagą kompilatora (jaką – to już zależy od kompilatora).  Nie wyrzucając przy tym asercji z kodu źródłoweg… Tak! One dalej siedzą sobie w kodzie, a my tylko mówimy kompilatorowi, czy chcemy je mieć w wyjściowym programie, czy nie! Tym sposobem unikamy narzutu związanego z obsługą asercji w wyjściowej aplikacji, zachowując instrumentację kodu na wypadek powrotu do fazy debugowania.

No dobrze. Wiemy już do czego używać asercji. A co jeśli chodzi o wyjątki i kody  błędu…?

Używaj wyjątków i kodów błędu do obsługi błędów

Właśnie tak. Wyjątki i kody  błędu pozwalają zidentyfikować problem (po klasie wyjątku/po numerze błędu) i w gładki sposób wyjść ze stanu błędu, np. zapisując zmiany w edytowanym dokumencie, informując użytkownika o przyczynie błędu, logując problem do dziennika. Przykładowo, jeśli funkcja, która zwraca nazwę miasta o zadanym kodzie pocztowym, nie znajdzie miasta – może:

    • zwrócić kod błędu
#include <map>
using namespace std;

#define CITY_FOUND 0
#define CITY_NOT_FOUND 1

map<char*, char*> cities;

int getCityName(char **city_name, char *postal)
{
	if (cities.find(postal) == cities.end())
		return CITY_NOT_FOUND;

	...
}
    • rzucić wyjątek
#include <map>
#include <exception>
using namespace std;

class NotFoundException : public exception
{ };

map<char*, char*> cities;

char* getCityName(char *postal)
{

	if (cities.find(postal) == cities.end())
		throw NotFoundException();

	...
}

Wykorzystując zwrócony kod błędu/łapiąc wyjątek, funkcja wywołująca getCityNames będzie mogła odpowiednio zareagować w sytuacji nie znalezienia danego miasta. A co takiego może zrobić? Ano chociażby wypisać komunikat “Przykro mi, ale takiego miasta to ja nie znam”

exception VS error code

Pozostaje pytanie: kiedy używać którego mechanizmu?

Wyjątki są nowszym rozwiązaniem i dają większe możliwości. Kody błędu są obsługiwane przez wszystkie nowe i stare języki programowania. Ogólnie tendencja jest taka, że w zastosowaniach wysokopoziomowych królują wyjątki, a w niskopoziomowych (np. systemy wbudowane) – kody błędu.

Na korzyść kodów błędu przemawia:

  • niski narzut. Nie są tworzone żadne obiekty, po prostu przekazywana jest liczba jako rezultat funkcji
  • dostępność w językach, które nie wspierają wyjątków, np. czyste C
  • ciągle jest mnóstwo kodu, który z nich korzysta…

Na korzyść wyjątków przemawia:

  • czytelność kodu. Nie trzeba wplatać sprawdzania kodu błędu po każdym wywołaniu funkcji, wystarczy zdefiniować jedną sekcję try… catch, dzięki czemu kod jest czysty i bardziej zrozumiały
  • dostępność wyniku funkcji. W przeciwieństwie do kodów błędu – wyjątki nie wykorzystują rezultatu funkcji do przekazywania informacji o błędzie. Co więcej, można używać ich tam, gdzie funkcja nie może zwracać żadnego wyniku – czyli w konstruktorach
  • hierarchie wyjątków. Pozwalają budować całą hierarchię wyjątków i obsługiwać dany wyjątek na zasadzie “aha, złapałem wyjątek, który należy do takiej grupy wyjątków, zrobię z nim to samo, co z resztą grupy”
  • większa ilość informacji. Wyjątek może nieść komunikat, kod błędu, stack trace, cokolwiek sobie zażyczysz!
  • rozszerzalność. Ponieważ wyjątek jest obiektem danej klasy, można tę klasę oprogramować np. tak, żeby zgłaszane komunikaty automatycznie zapisywała do dziennika
  • i w końcu – wyjątku nie można przegapić. Kod błędu można zwyczajnie zignorować, można o nim zapomnieć, przegapić go. Wyjątki są bardziej zobowiązujące – jeśli zapomnimy obsłużyć wyjątek, dostaniemy piękny komunikat “Unhandled exception at…”

Podsumowanie

Asercje służą nam TYLKO na etapie debugowania. Jeśli chcesz napisać kawałek kodu do obsługi błędu – użyj wyjątków lub kodów błędu. O tym, czy wykorzystasz wyjątki, czy kody błędu, zdecydujesz na podstawie projektu, nad którym pracujesz – co najlepiej pasuje do danego zastosowania. Ważne jest jednak, by być konsekwentnym, i nie mieszać obu technik bez potrzeby.

Related Posts: