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.