W tym rozdziale zamierzamy przyjrzeć się pewnym narzędziom i technikom, które można wykorzystać do przygotowania solidnych aplikacji. Rozważymy niektóre błędy, wkradające się niepostrzeżenie do programów oraz metody zapobiegania im, szybkiego ich odnajdywania i usuwania.
Omówimy polecenia wydruku diagnostycznego (ang. debug print statements), weryfikatory warunku (ang. assertions), funkcje śledzące (ang. tracing functions) oraz wykorzystanie programu uruchomieniowego (ang. debugger).
Zanim przejdziemy do narzędzi, takich jak program uruchomieniowy i biblioteki specjalne, rozważmy powody, dla których napisane programy nie zawsze działają zgodnie z zamierzeniami ich autorów. Niektóre z rzeczy tu omówionych mogą wydawać się zupełnie oczywiste, ale to co dla jednych jest chlebem powszednim, dla innych może być olśniewającym objawieniem. Mamy nadzieję, że wśród omówionych wskazówek i technik, każdy wyłowi jakąś użyteczną myśl na wagę złota.
Zanim rozpoczniemy zasadniczą dyskusję, warto rozważyć klasy błędów. Upraszczając zagadnienie, można wspomnieć jedynie o dwóch spośród najbardziej pospolitych klas błędów, a mianowicie błędnych danych wejściowych (ang. faulty input) i błędnym programowaniu (ang. faulty programming).
Pierwsza klasa błędów pojawia się, gdy coś niezwykłego przydarzy się programowi. Być może użytkownik aplikacji do przeglądania obrazków zechce obejrzeć plik bazy danych. Wystąpi wtedy błąd, ponieważ format tego pliku nie jest taki, jak tego oczekiwano. W takiej sytuacji aplikacja powinna łagodnie zareagować i skłonić użytkownika do ponownego wyboru pliku. Ta klasa błędów nie tylko może być, ale i powinna być zawsze obsługiwana przez oprogramowanie.
Druga klasa błędów pojawia się wtedy, gdy zawini oprogramowanie. W sytuacji wspomnianej powyżej, może to być braku testu poprawności formatu pliku. Następuje wtedy krach przeglądarki do obrazków w wyniku przeczytania bezużytecznych danych. Innym przykładem może być program, działający jako kalkulator finansowy, który nagminnie wylicza niewłaściwą kwotę spłaty pożyczki.
W dalszym ciągu tego rozdziału zostanie omówionych kilka technik, które mogą być użyte zarówno do zminimalizowana efektów pierwszej klasy błędów (błąd użytkownika lub środowiska), jak też do zredukowania czasu potrzebnego na wytropienie przyczyn drugiej klasy błędów (błąd programowy).
Chcąc zaoszczędzić czas i wysiłek należy przyjąć przy tropieniu błędów w programach poniższą zasadę jako złotą regułę.
Zawsze, ale to zawsze sprawdzaj wyniki zwracane.
Programiści mogą całkiem łatwo popaść w złe nawyki, które wynikają z lenistwa lub przyjęcia wątpliwych założeń. Sprawiedliwość jednak nakazuje zaznaczyć, że wiele książek i kursów pomija obsługę błędów w przykładowych programach, aby ich nie obciążać dodatkowymi szczegółami. Jednak po zakończeniu fazy nauki, zawodowy programista będzie chciał dalej doskonalić swój warsztat. Ilekroć używa on funkcji napisanej przez kogoś innego, powinien we własnym zakresie upewnić się, że rozumie działanie tej funkcji, założenia jakie są przyjęte wobec jej argumentów lub środowiska, a także warunki w których ta funkcja może zawieść. Jeśli poprawność wykonania funkcji nie zostanie sprawdzona, to przysporzy to jedynie dodatkowych kłopotów. Program musi być zdolny do radzenia sobie z błędami, które pojawiają się w wywoływanych funkcjach.
Prostego przykładu dostarcza standardowa biblioteka wejścia-wyjścia.
Funkcja fopen otwiera plik do odczytu, zapisu lub obu tych czynności. Funkcja ta zwraca wskaźnik strumienia (ang. stream pointer), który może być użyty w kolejnych operacjach odczytu lub zapisu. Jeśli plik nie może zostać otwarty, to wartość NULL jest zwrócona. Prawie każdy, kto używa fopen dba o to, aby sprawdzać wynik zwracany (ang. return result). W przeciwnym razie ponosi ryzyko, że program zakończy się krachem przy próbie odczytu z nieistniejącego pliku.
Wielu programistów, używających funkcji fwrite, zdaje sobie sprawę z tego, że ta funkcja może nie zapisać w całości danych, które zostaną jej przekazane. Takie zdarzenie może, ale nie musi być wynikiem błędu. Jeśli dysk twardy jest zapełniony, to pewnie jest to błąd. Jeśli zapis odbywa się poprzez sieć lub do sterownika urządzenia, to możliwe, że zapisano z powodzeniem część danych (być może jedynie pojedynczy pakiet). Jest całkiem możliwe, że więcej danych może być chętnie przyjętych do zapisu trochę później, albo nawet natychmiast, jeśli jest to zwyczajny zapis pakiet po pakiecie – trzeba tylko wywołać ponownie fwrite wraz z pozostałymi danymi do zapisu.
Program, który uwzględnia wszystkie trzy możliwe rezultaty wywołania funkcji fwrite, daje wszelką szansę na wykrycie rzeczywistego błędu tuż po jego wystąpieniu. Te trzy możliwości to: sukces w zapisie wszystkich danych, częściowy sukces w zapisie pewnych danych lub porażka. Taki program również jest w stanie obsłużyć wynikłe błędy w sposób jak najbardziej odpowiedni.
Prawie żaden program nie sprawdza wyniku zwracanego przez wywołanie fclose.
Dlaczego tak jest?
No cóż, niektórzy uważają, że w praktyce fclose nie zwraca nigdy żadnego błędu. Jeszcze inni twierdzą, że nic nie można poradzić w sytuacji, gdy wystąpi błąd przy zamykaniu pliku. Jeśli działanie aplikacji zależy od tego czy dane zostały bezpiecznie zapisane, to wtedy lepiej jednak sprawdzić czy operacja zamknięcia pliku zakończyła się powodzeniem. Można też poszukać jakiegoś innego sposobu sprawdzenia czy dane mają się dobrze (funkcje fstat i fflush mogą okazać się pomocne w tym przypadku).
W sieciowym systemie plików (ang. networked file system) lub wszędzie tam, gdzie zapis jest asynchroniczny, buforowanie danych w systemie może nadać pozory poprawności końcowego zapisu. W takim przypadku faktyczne niepowodzenie tej operacji można tylko zauważyć, kiedy bufory są opróżniane przy zamykaniu pliku. Przekroczenie limitu udziału na dysku (ang. disk quota) może być tego przykładem. Aplikacja wrażliwa na ten limit może zatrzymać swoje dane w pamięci, a w razie niepowodzenia próbować zapisać dane w inne miejsce — daje to przynajmniej szansę użytkownikowi na wyjaśnienie sytuacji. To z pewnością lepsze niż ciche niepowodzenie, pozostawiające zniekształconą informację, która prowadzi później do dalszych niepowodzeń i w konsekwencji długiego oraz żmudnego procesu śledzenia wstecz w poszukiwaniu pierwotnego winowajcy.
Wiele funkcji, zwłaszcza w standardowej bibliotece języka C, wykorzystuje zmienną błędu (ang. error variable), errno. Ta zmienna całkowita ma ustawioną wartość, która określa jeden z wielu kodów błędu w razie niepowodzenia w wykonaniu funkcji. Dokumentacja systemowa (ang. manual page) jest dobrym miejscem, od którego można zacząć poszukiwania informacji o wynikach zwracanych i możliwych warunkach wystąpienia błędów dla używanej funkcji.
Na przykład, dokumentacja systemowa przypomina, że fclose może zakończyć się niepowodzeniem i ustawić wartość zmiennej errno dla wskazania przyczyny, dla której close zakończyło się fiaskiem. Z kolei, dokumentacja systemowa dla close, faktycznie zawiera ostrzeżenie przed poważnym, aczkolwiek częstym błędem jakim jest zignorowanie wyniku operacji zamknięcia, zwłaszcza podczas używania sieciowego systemu plików (NFS, Network File System). Ostrzeżenie przed tymi błędami zawdzięczamy jedynie analizie wyników zwracanych.
Należy przy tym pamiętać, że program, który jest obecnie używany jedynie do zapisu na dysk twardy może zostać w przyszłości wykorzystany przez jakiegoś użytkownika do wykonywania operacji za pośrednictwem sieci.
Kontynuując naszą analizę wnioskujemy, że fclose może zakończyć się niepowodzeniem i nadać zmiennej errno jedną z poniższych wartości:
EBADF
wskaźnik strumienia lub bazowy deskryptor pliku jest nieprawidłowy
ENOSPC
brak miejsca na urządzeniu
EIO
operacja wejścia-wyjścia niskiego poziomu zakończona niepowodzeniem
EPIPE
strumień jest przyłączony do zamkniętego potoku lub gniazda
Nie tylko można, ale i należy sprawdzać czy errno przyjmuje którąś z wartości zdefiniowanych w errno.h. Aby poinformować użytkownika o zaistniałych trudnościach w programie można wykorzystać biblioteczną funkcję strerror do uzyskania łańcucha opisującego błąd lub funkcję perror do wydrukowania komunikatu. Funkcja perror zapisuje na strumień standardowego błędu (ang. standard error stream), stderr tekst, opisujący ostatnio napotkany błąd — ten, który spowodował ustawienie errno.
Drobiazgowe sprawdzanie wyników zwracanych pomaga powstrzymywać rozprzestrzenianie się błędów — to część programowania defensywnego. Jeśli jakaś wywołana funkcja zwraca nieoczekiwaną wartość, to inna funkcja może ją wykorzystać i wygenerować dodatkowe błędy. To czasem określane jest jako propagacja błędu lub efekt domina (ang. knock-on errors). Jeśli zadba się o sprawdzenie wyników zwracanych, to można być pewnym, że wszelkie błędy zostaną spostrzeżone, jak tylko się pojawią.
W programowanych funkcjach dobrze jest ustalić od samego początku sposób postępowania z błędami i ich propagacją od samego początku. Warto zdefiniować zbiór wartości błędów, który programowane funkcje mogą używać wspólnie. Trzeba zadbać, aby każda z programowanych funkcji używała konsekwentnie wartości błędów, którym przyporządkowano znaczące nazwy. Oto przykład wzięty z implementacji referencyjnej (ang. reference implementation) interfejsu programowego aplikacji (API, Application Programming Interface) do obsługi aplikacji „Filmoteka DVD” (DVD Store).
/* Definicje bledow */
#define DVD_SUCCESS 0
#define DVD_ERR_NO_FILE -1
#define DVD_ERR_BAD_TABLE -2
#define DVD_ERR_NO_MEMBER_TABLE -3
#define DVD_ERR_BAD_MEMBER_TABLE -4
#define DVD_ERR_BAD_TITLE_TABLE -5
#define DVD_ERR_BAD_DISK_TABLE -6
#define DVD_ERR_BAD_SEEK -7
#define DVD_ERR_NULL_POINTER -8
#define DVD_ERR_BAD_WRITE -9
#define DVD_ERR_BAD_READ -10
#define DVD_ERR_NOT_FOUND -11
#define DVD_ERR_NO_MEMORY -12
#define DVD_ERR_BAD_RENTAL_TABLE -13
#define DVD_ERR_BAD_RESERVE_TABLE –14
static int file_set(FILE *file, long file_position, int size, void *data)
{
if(fseek(file, file_position, SEEK_SET) != 0)
return DVD_ERR_BAD_SEEK;
if(fwrite(data, size, 1, file) != 1)
return DVD_ERR_BAD_WRITE;
return DVD_SUCCESS;
}
...
FILE *member_file;
int dvd_member_set(dvd_store_member *member_record_to_update)
if(member_record_to_update == NULL)
return DVD_ERR_NULL_POINTER;
return
file_set(member_file,
sizeof(dvd_store_member) * (member_record_to_update -> member_id),
sizeof(dvd_store_member),
(void *) member_record_to_update);
Wszystkie funkcje w tej implementacji referencyjnej zwracają wartość stanu. Dane są przekazywane lub zwracane za pośrednictwem argumentów i wskaźników, o ile zajdzie taka potrzeba. To jest bardzo ważna sprawa. Rezerwując wyniki zwracane dla potrzeb wskazania sukcesu lub porażki, oddzielamy przepływy danych i sterowania. Ogólnie rzecz biorąc, to dobra praktyka, jako że pozwala unikać nadawania „wartości specjalnych” zwracanym wynikom, wykorzystywanym do celów specjalnych. Niektóre systemy UNIX-a są kiepsko zaprojektowane pod tym względem. Weźmy funkcję getchar jako przykład. Zwraca kolejny znak, jaki może być przeczytany ze standardowego wejścia. Ale wynik zwracany nie jest typu char, ale typu int. Jest tak, ponieważ funkcja ta wymaga specjalnej wartości, EOF, dla wskazania faktu osiągnięcia końca pliku. Wartość EOF jest zdefiniowana jako –1 tak, aby była poza zakresem poprawnych wartości znakowych. Zatem okazuje się, że ta funkcja miesza w swoim typie zwracanym dane, które są faktycznie zwracane oraz kod stanu, który byłby użyty do sterowania programem poprzez test na wystąpienie EOF.
W naszym przykładzie z referencyjnej implementacji API, funkcja dvd_member_set
W oryginale błędnie: member_set.
zapisuje strukturę jednorodnego pliku bazy danych (ang. flat file database). Tak jak inne funkcje w tej implementacji, zwróci ona albo DVD_SUCCESS, gdy wszystko jest w porządku lub wskazanie błędu — w tym przypadku jeden z DVD_ERR_NULL_POINTER, DVD_ERR_BAD_SEEK lub DVD_ERR_BAD_WRITE. Funkcja użyła struktury danych, przekazanej do niej jako argument. Zauważmy, że funkcja jest zapisana defensywnie. Sprawdza po pierwsze, czy podany wskaźnik to nie NULL, chociaż w gruncie rzeczy to nie jest wystarczająco skuteczny test, aby wykazać, że ten wskaźnik jest poprawny — to już jednak inna historia. Jest to przykład wstępnego testu na warunek konieczny (ang. precondition test). Wkrótce to zagadnienie zostanie omówione szczegółowo. W przypadku, gdy wskaźnikiem jest NULL, to zwrócony jest wynik DVD_ERR_NULL_POINTER. W przeciwnym razie sterowanie jest przekazane do pomocniczej funkcji file_set.
Umożliwianie w programie detekcji i zgłaszania błędów w funkcji, pomoże możliwie szybko określić błędy. Uodpornienie programu na błędy, podjęcie odpowiednich działań w razie, gdy nie wszystko przebiega zgodnie z planem — jak podczas zamykania otwartych plików, kiedy odczyt się nie powiedzie — to zabiegi, które ulepszą aplikację. Solidne aplikacje wykazują tendencję do dłuższego żywota w środowisku innych programów, a także bywają wykorzystywane szerzej niż to zaplanowano.
Nawet jeśli aplikacja została starannie zbudowana oraz wszelkie wprowadzane zmiany były drobiazgowo zapisywane w miarę postępów, to problemy nadal się pojawiają. Jest smutną prawdą, że aplikacje programistów zawierają błędy (ang. bugs). Można znaleźć te błędy, pomyłki i usterki czytając jedynie pobieżnie kod źródłowy lub też wykonując bardziej formalną inspekcję kodu. Bardzo często są one odnalezione dopiero na etapie użytkowania programu, bądź w fazie testów przed opublikowaniem programu, bądź też wtedy, gdy użytkownicy znajdą nowe, wcześniej nie rozważane zastosowania aplikacji.
Niekiedy błędy te mogą być trudne do wykrycia lub też ich wytropienie może być czasochłonne. Niemniej jednak, zanim przystąpi się do etapu testowania, warto zadbać o kilka rzeczy, które ułatwiają szybkie usuwanie błędów.
Przede wszystkim, można przyjąć taki styl pisania programów, który sprzyja usuwaniu błędów. W tym rozdziale zobaczymy, dlaczego powinno się dodać obsługę usuwania błędów (ang. debug support) w programach i jak zabrać się do tego. Dodanie komunikatów diagnostycznych (ang. debug messages) oraz innych funkcji, które wspomagają usuwanie błędów, jest znacznie łatwiejsze w czasie pisania programu, niż później, kiedy próby usunięcia nieprzyjemnego błędu są naprawdę czasochłonne.
Jeśli już powstanie aplikacja o przejrzystej strukturze, zaprogramowana z myślą o usuwaniu błędów, to jej testowanie i korygowanie będzie znacznie prostsze. Testowanie omówimy szczegółowo w jednym z kolejnych rozdziałów.
Podczas etapu testowania w procesie przygotowania aplikacji odkrywa się błędy — pomyłki popełnione gdzieś w programowaniu. Takie błędy mogły się zakraść na każdym etapie opracowywania aplikacji.
Najprostszym błędem jest zwyczajny i prosty błąd programowania (ang. coding error). Może to być zmiana lub przestawienie liter, błędnie użyta zmienna, przekazanie niewłaściwego parametru lub przestawienie porządku instrukcji w jakiejś funkcji. Jeśli już odnajdzie się miejsca występowania błędów programowania, to są one zwykle łatwe do poprawienia. Nie powinno być z tym trudności, jeśli aplikacja była przygotowana zgodnie z wyżej wspomnianymi wytycznymi. Niektóre języki wyższego poziomu (ang. higher-level languages), zwłaszcza te z bardziej rozbudowanymi typami danych niż język C, potrafią dostrzec niektóre z tych problemów w czasie kompilacji.
Niekiedy zdarzają się poważniejsze błędy. Może się okazać, że źle zrozumiano działanie jakiś programów bibliotecznych, lub niewłaściwie postąpiono w przypadku innych systemów, z którymi trzeba było się porozumieć. Te błędy projektowe (ang. design errors) mogą okazać się trudne do poprawy. Może okazać się konieczne ponownie przemyślenie wykonania określonego zadania oraz ponowne napisanie jakiegoś fragmentu aplikacji.
Można przeciwdziałać błędom projektowym poprzez dogłębne przemyślenie zamierzonego działania aplikacji. Z pewnością warto poświęcić trochę czasu na określenie struktury modułowej tworzonego programu, określenie tego, co każdy moduł będzie robił i jak będzie się porozumiewał z innymi modułami. Warto narysować kilka schematów, które zilustrują związki pomiędzy modułami oraz danymi przez nie używanymi.
Najgorszy typ błędu to błąd specyfikacji (ang. specification error). Może się on pojawić, jeśli cel aplikacji jest nie w pełni jasny lub niedokładnie przekazany. Niekiedy przyszły użytkownik nie precyzuje jasno swoich oczekiwań, lub też zmienia zdanie w trakcie realizacji projektu. Programista, którego to spotka może w rezultacie pozostać z ukończoną aplikacją, która poprawnie działa, została przetestowana, usunięto z niej błędy, ale nie spełnia oczekiwań. W najgorszym razie może się okazać, że trzeba ją porzucić i zacząć wszystko od nowa. Wszystkie te wysiłki mogłyby być zaoszczędzone, gdyby więcej czasu poświęcić na dyskusję z docelowym odbiorcą (ang. end user) na temat tego jak powinna działać ta aplikacja.
Można zauważyć, że wysiłek niezbędny do poprawienia każdego spośród tych typów błędów gwałtownie rośnie w miarę jak postępuje opracowywanie aplikacji. Ocenia się, że im później nastąpi odkrycie błędu, to z każdym kolejnym etapem opracowywania aplikacji jego naprawa będzie wymagać dziesięciokrotnie więcej wysiłku.
Kluczem do pomyślnego opracowania kodu jest wytrwałość w dążeniu do dobrego zrozumienia wymagań stawianych przed aplikacją, staranne jej zaprojektowanie oraz zaprogramowanie tak, aby ułatwić testowanie i usuwanie błędów. Badania pokazują, że błędy zawsze będą się wyłaniać, może nawet w liczbie 5 na każde 100 wierszy kodu. Dzięki dołożonym staraniom można mieć pewność, że są to głównie błędy łatwe do usunięcia.
Zatem, rozważmy jak wyśledzić błędy w programie oraz jak ich unikać.
Aby móc wyśledzić miejsca w programie, w których coś przebiega niewłaściwie, trzeba być zdolnym do stwierdzenia tego co on robi w danej chwili. Niekiedy sprawa może być prosta. Na przykład, jeśli błąd tkwi na ekranie interfejsu jakiegoś użytkownika, to zapewne można zauważyć niepoprawne wyświetlanie i natychmiast wydedukować przyczyny usterki. Bardzo często jednak nie jest to tak oczywiste. Jeśli arkusz kalkulacyjny wylicza niepoprawną wartość w prawym dolnym rogu skomplikowanego arkusza roboczego, to pewnie trzeba będzie zapoznać się szczegółowo ze ścieżką, po której program podążał poprzez kod i jak dokładnie do tego doszło, że otrzymano zły wynik.
Najprostszym sposobem odkrycia, które fragmenty kodu były wykonywane, jest wprowadzenie do kodu instrukcji diagnostycznych (ang. debug statements). Można to zrobić na wiele sposobów, ale wybór najlepszej metody będzie zależał od rozmiaru i złożoności programu. Dla małych programów proste podejście może okazać się całkiem skuteczne. Większe projekty mogą wymagać wyrafinowanych strategii dla uzyskania informacji diagnostycznej (ang. debugging information). Inne aplikacje mogą wymagać jakiś rozwiązań pośrednich. Poniżej przyjrzymy się kilku możliwościom, oceniając wady i zalety każdej z nich.
Zapewne najprościej wprowadzić wydruk informacji diagnostycznej przy użyciu fprintf w kluczowych miejscach kodu. Na przykład:
fprintf(stderr, "wywolanie funkcji zasadnicza z %d\n", arg);
wynik = zasadnicza(arg);
fprintf(stderr, "funkcja zasadnicza zwrocila %d\n", wynik);
To podejście jest oczywiście łatwe do zrealizowania i może być trafnym wyborem dla małych programów — ma ono jednak pewne wady. Na początek trzeba zauważyć, że nie ma sposobu sterowania tym wydrukiem — otrzymuje się go przez cały czas.
W czasie wykonywania się programu dostajemy komunikaty diagnostyczne (ang. debug messages) pomieszane z normalnym wydrukiem wyjściowym z programu. Można jednak skierować komunikaty diagnostyczne niezależnie od normalnych danych wyjściowych, korzystając z potoku powłoki (ang. shell redirection):
$ ./prog 2>stderr.log # skieruj diagnostyke do pliku stderr.log
To polecenie powłoki uruchamia program, prog, zmieniając kierunek na plik stderr.log dla wszystkich danych wyjściowych posyłanych na stderr. Nie jest to zbyt wygodne, jeśli chcemy przeglądać dane wyjściowe w czasie wykonywania programu, ale w tym celu możemy użyć polecenia
$ tail –f stderr.log
w innej sesji terminalowej, jeśli sobie tego życzymy.
W razie potrzeby można pominąć wydruk diagnostyczny (ang. debug output) poprzez skierowanie go do „śmietnika bitów”, /dev/null, dzięki któremu po prostu go wyrzucimy.
$ ./prog 2>/dev/null
Do sterowania samym wydrukiem diagnostycznym można użyć instrukcji warunkowych (ang. conditionals). Z powodów, które wkrótce staną się oczywiste przy omawianiu weryfikatorów warunku (ang. assertions), sens ma produkowanie informacji diagnostycznej tylko, jeśli makrodefinicja (ang. macro) NDEBU...
b.senni