Debugowanie - rzemiosło i sztuka

| Technika

Możliwe jest tylko stwierdzenie obecności błędów w programie, natomiast nie da się stwierdzić ich braku. Stąd zadaniem programisty jest przewidzenie jak największej liczby zdarzeń, które mogą spowodować błędne wykonanie kodu, a następnie usunięcie defektów. Od tego, czy debugowanie zostanie przeprowadzone z należytą dokładnością, zależy zadowolenie późniejszego użytkownika, co przekłada się w dużej mierze na zyski. Sam proces debugowania jest pracochłonny i wiąże się z kosztami, ale lepiej ponieść je w fazie tworzenia programu niż później.

Debugowanie - rzemiosło i sztuka

W zależności od branży skutki wypuszczenia na rynek programu mogą być różne. W przypadku programu dla komputerów osobistych trzeba liczyć się z telefonami i e-mailami od niezadowolonych klientów i koniecznością wydawania łatek. Program z dużą liczbą defektów w mikrokontrolerze sterującym urządzeniem spowoduje dużą liczbę roszczeń gwarancyjnych, a załadowanie źle napisanego programu do sterownika PLC będzie wiązać się z kosztownymi wyjazdami programisty na obiekt i zagrożeniem uszkodzenia sprzętu obsługiwanego przez sterownik.

Ważne jest znalezienie rozsądnego kompromisu pomiędzy zapewnieniem jakości oprogramowania a opłacalnością procesu debugowania, ponieważ wyeliminowanie wszystkich defektów z kodu może okazać się zbyt drogie lub nawet niemożliwe.

Nieprawidłowo prowadzony proces debugowania może prowadzić do sytuacji, w której w celu usunięcia defektów kod programu jest modyfikowany, co powoduje powstawanie kolejnych defektów i w najgorszym przypadku otrzymanie kodu działającego jeszcze gorzej niż pierwotny, dlatego sprawne i skuteczne debugowanie kodu jest istotnym elementem w tworzeniu oprogramowania, niezależnie od języka i zastosowania.

Skąd biorą się błędy?

Rys. 1. Schemat powstawania błędu

Błędy występujące w programach to przypadłość prześladująca programistów i użytkowników, która wydaje się nieunikniona wszędzie tam, gdzie człowiek tworzy kod następnie wykonywany przez komputer. W zależności od punktu widzenia źródłem wszelkich nieprawidłowości w działaniu programów są nieudolni programiści dla zamawiającego napisanie programu, mający niesprecyzowane wymagania i presja czasu dla programistów lub wydawcy oprogramowania dla użytkowników końcowych. Można przedstawić wiele takich zależności i każda z nich będzie w pewnym stopniu uzasadniona, a najprostszym sposobem na pozbycie się odpowiedzialności za błędy w oprogramowaniu jest zrzucenie jej na kogoś innego.

U źródła błędów występujących w programach zawsze znajduje się człowiek tworzący kod, co jednak nie oznacza, że cała odpowiedzialność leży po stronie programisty. Jeśli zatrudnia się niedoświadczoną osobę w celu cięcia kosztów i nie weryfi- kuje efektów jej pracy przed oddaniem programu użytkownikom, należy liczyć się ze znacznymi lukami w oprogramowaniu. Jeśli zleci się pracę doświadczonemu koderowi, ale nie da się mu wystarczająco dużo czasu na doszlifowanie kodu i przetestowanie go, efekt będzie podobny, chociaż zapewne pojawiać się będą błędy innego rodzaju.

Pierwszą przyczyną błędów w programach, która daje o sobie znać, zanim powstanie pierwsza linijka kodu, jest komunikacja między ludźmi związanymi z projektem. Jednoznaczne i konkretne ustalenie założeń funkcjonowania programu, dobór odpowiedniego zespołu programistycznego, zapewnienie wystarczającej ilości czasu na napisanie i przetestowanie oprogramowania to czynniki, które mogą wydawać się bez związku z faktycznym kodem, ale gdy zostaną wzięte pod uwagę przed rozpoczęciem programowania, ograniczają zbiór błędów tylko do rzeczywistych usterek w kodzie programu.

Czy to rzeczywiście bug?

Użytkownik może obserwować tylko skutki nieprawidłowego działania programu, które manifestują się na przykład uzyskaniem nieprawidłowego wyniku, utratą danych lub niespodziewanym zakończeniem działania. Część z tych symptomów w oczywisty sposób świadczy o niedociągnięciach w kodzie, jednak zdarza się, że wizja prawidłowego działania programu odbiorcy jest różna od zamierzeń programisty.

Przyczyn takiego stanu należy upatrywać w niedokładnie sprecyzowanych założeniach projektu. Z drugiej strony tłumaczenie klientowi, że wskazywane przez niego błędy to prawidłowe działanie programu, jest łatwym narzędziem do pozbycia się odpowiedzialności przez programistę. Winą za nieprawidłowe działanie można też obarczać sprzęt lub inne oprogramowanie, z którym dany program musi współpracować. Na liście wymówek programistów można znaleźć również stwierdzenie, że błąd jest zbyt trudno powtórzyć, aby ktokolwiek go zauważył, więc nie ma sensu go naprawiać.

Droga od źródła do błędu

Programiści muszą radzić sobie z różnego rodzaju błędami programów niezależnie od tego, kto ponosi winę za ich powstanie. Błędy powodowane są przez defekty, czyli fragmenty kodu, które powodują infekcje prowadzące do błędnego działania programu. Defekty są częścią kodu, a każda linijka kodu jest napisana przez programistę. Wynika z tego, że są one efektem pracy kodera. Czy to oznacza, że program jest źle napisany? Niekoniecznie. Możliwe, że założenia początkowe nie przewidywały późniejszych modyfikacji lub, w programie o budowie modułowej, dochodzi do konfliktów między modułami.

Pojawienie się błędu przebiega według pewnego schematu, przedstawionego na rysunku 1. Program zawierający defekt zostaje wykonany. Ukryty defekt może spowodować infekcję, co oznacza, że od pewnego momentu stan programu różni się od zamierzonego przez programistę. Występowanie defektu w programie nie jest jednoznaczne z wystąpieniem infekcji, ponieważ aby do niej doszło, wadliwy fragment kodu musi zostać wykonany pod pewnymi warunkami.

Większość błędów w funkcjach jest spowodowana podaniem nieprawidłowych danych na wejściu. Raz zapoczątkowana infekcja może powodować podawanie błędnych danych do kolejnych funkcji, które dają błędne rezultaty, wpływając na następne - infekcja rozprzestrzenia się. Widoczne dla obserwatora oznaki błędnego działania programu są rezultatem infekcji narastającej pomiędzy kolejnymi stanami programu.

Nie każdy defekt jest źródłem infekcji i nie każda infekcja jest przyczyną błędu. Oznacza to, że brak błędów wcale nie oznacza braku defektów. To kluczowa sprawa podczas testowania oprogramowania. Jest bardzo prawdopodobne, że błąd programu ujawni się tylko przy koincydencji kilku rzadko występujących razem warunków. Zadaniem osoby testującej oprogramowanie jest przewidzieć tego typu sytuacje.

Droga do powstania błędu przypomina łańcuch, w którym następują kolejne infekcje. Program jest wykonywany, przechodząc przez kolejne stany, a po napotkaniu defektu infekcja jest pogłębiana aż do powstania błędu. Aby usunąć błąd, należy prześledzić tę drogę, znaleźć źródło i usunąć defekt. Brzmi banalnie, ale w dużym programie mającym tysiące zmiennych prześledzenie stanów, w których infekcja przenosi się pomiędzy funkcjami, może być bardzo pracochłonne.

Jak zwalczać błędy?

Rys. 2. Propagacja infekcji przez kolejne zmienne

Debugowanie programu można podzielić na następujące kroki:

  • identyfikacja problemu,
  • powtórzenie błędu,
  • znalezienie prawdopodobnych źródeł infekcji,
  • wybór najbardziej prawdopodobnych przyczyn,
  • wyizolowanie łańcucha infekcji,
  • korekcja defektu,
  • weryfikacja działania programu.

Identyfikacja problemu może zachodzić bez udziału programisty, w zależności od tego, kto napotka błąd. Dobrze, gdy błąd uda się zidentyfikować, zanim dostrzeże go użytkownik. Powtórzenie błędu powinno być proste w deterministycznych programach, ale w programach niedeterministycznych lub takich, które pracują długo, już niekoniecznie.

Kiedy już wiadomo, z jakim błędem programista ma do czynienia i jak go wywołać, należy maksymalnie zawęzić dane wejściowe powodujące powstanie infekcji. Świadomość istoty błędu i znajomość danych wejściowych, przez które został wywołany, powinny naprowadzić programistę na trop prawdopodobnych źródeł.

W tym miejscu zadanie jest najtrudniejsze. Prawdopodobnych przyczyn infekcji może być wiele, a programista powinien znać je najlepiej, więc musi spojrzeć na swój program krytycznie i oszacować, które z nich mogły spowodować błąd w programie. Następnie należy wybrać najbardziej prawdopodobne i skupić się na ich weryfikacji.

Trzeba wystrzegać się założenia, że kod na pewno jest poprawny, a problem leży na przykład w kodzie kolegi lub jest spowodowany przez kompilator. Eliminacja kolejnych możliwych przyczyn powinna doprowadzić do określenia łańcucha powstawania infekcji i znalezienia źródła, czyli defektu. Jego korekcja to już zwykle prosta sprawa.

Kluczem do odnalezienia defektu jest znalezienie stanu programu, w którym zaczynają się pojawiać nieprawidłowe dane. Poszukiwanie błędu nie ogranicza się więc do poszukiwań w przestrzeni, ale również w czasie, co zobrazowano na rysunku 2. Infekcja zaczyna objawiać się w pewnym momencie, więc stany programu można podzielić na te sprzed, czyli prawidłowe, oraz te zainfekowane. Jeśli wartość pewnej zmiennej zmienia się przy przejściu z prawidłowego stanu do zainfekowanego, istnieje prawdopodobieństwo, że to właśnie ona była przyczyną nieprawidłowości.

W poszukiwaniu bugów warto kierować się dwiema zasadami. Po pierwsze oddzielać poprawne od zainfekowanych - jeśli w danym stanie programu wszystko jest w porządku, to nie występuje infekcja, która mogłaby się rozprzestrzeniać. Po drugie oddzielać zmienne powiązane z infekcją od tych, które nie mają z nią związku - każda wartość zmiennej w programie jest wynikiem jej wartości początkowej i wartości innych zmiennych, związanych z nią przez różne operacje i funkcje. Jeśli programista odnajdzie nieprawidłową wartość zmiennej, to istnieje pewna ograniczona grupa zmiennych, które miały na nią wpływ.

Program może mieć tysiące zmiennych, ale tylko część z nich jest powiązana z występującym błędem. Jeśli program jest napisany przejrzyście, zmienne są zorganizowane wedle spełnianych funkcji i nie są wielokrotnie używane do różnych niezwiązanych ze sobą celów, to odnalezienie zmiennej przyjmującej nieprawidłowe wartości powinno doprowadzić do szybkiego zlokalizowania defektu w kodzie. Oznacza to, że część procesu debugowania odbywa się już w trakcie pisania kodu. Czytelny kod, odpowiednio podzielony wedle funkcjonalności, korzystający z przejrzyście nazwanych i zorganizowanych zmiennych, jest o wiele łatwiej badać w poszukiwaniu defektów.

Techniki

Nieocenioną pomocą zarówno w fazie tworzenia kodu, jak i jego debugowania jest wszelkiego rodzaju dokumentacja, czyli instrukcje obsługi kompilatorów, sterowników, dokumentacje języków programowania, książki i poradniki w Internecie. Jest bardzo prawdopodobne, że programista nie do końca rozumie używane przez siebie funkcje i przez to otrzymywane są nieprawidłowe rezultaty. Analiza dokumentacji lub zapoznanie się z przykładami użycia często pozwala na dostrzeżenie luk w toku rozumowania. Ważne jest, aby korzystać z aktualnej dokumentacji ze względu na możliwe zmiany w funkcjonalności narzędzia używanego do programowania.

Sposób pisania kodu ma duży wpływ na późniejszy proces debugowania go. Dobrym sposobem jest programowanie defensywne, realizowane na przykład przez umieszczanie w kodzie asercji, czyli wyrażeń-pułapek, które w danym miejscu w programie muszą być prawdziwe, inaczej następuje przerwanie jego wykonywania. Można na przykład założyć, że pewna zmienna musi być w danym miejscu dodatnia i umieścić asercję, która to sprawdza. Jeśli zmienna nie jest dodatnia, to wykonywanie programu zostaje zatrzymane, a programista jest informowany o tym, która zmienna jest powodem propagacji infekcji.

Kolejną metodą programowania defensywnego jest stosowanie zasady KISS (Keep It Simple, Stupid). Zasada ta stosowana jest nie tylko w programowaniu, jednak w tym kontekście oznacza, że kod powinien być maksymalnie prosty i zrozumiały, tak aby mógł zostać odczytany nie tylko przez autora, ale również przez innych. Znalezienie defektu w przejrzystym kodzie jest o wiele prostsze.

Pełne zrozumienie własnego kodu w znaczący sposób ułatwia programiście znalezienie miejsca, gdzie popełnił błąd. Jedną z technik, która to ułatwia, jest wydrukowanie kodu, wyjście z biura na przykład do kawiarni i poszukiwanie źródła nieprawidłowości przy kawie, w oderwaniu od środowiska pracy. Można również zatrudnić do pomocy osobę postronną i na głos tłumaczyć jej działanie swojego programu. Wypowiedzenie swoich myśli może naprowadzić na właściwy trop. Zamiast do żywego człowieka można mówić do przedmiotu, na przykład gumowej kaczki - stąd nazwa tej techniki "Rubber duck debugging".

Narzędzia

Pierwszym narzędziem, które pomaga programiście wystrzegać się błędów, jest edytor, w którym pisany jest kod. Dobre edytory (środowiska IDE) są wyposażone w udogodnienia takie jak podpowiadanie nazw zmiennych i funkcji, podświetlanie elementów składni, pilnowanie domykania nawiasów i automatyczne dodawanie wcięć, które czynią kod bardziej przejrzystym. Funkcje tego typu pozwalają na unikanie błędów jeszcze przed skompilowaniem programu.

Wiele informacji na temat nieprawidłowości w kodzie można uzyskać, obserwując komunikaty kompilatora. Sygnalizowane błędy i ostrzeżenia pozwalają na szybkie znalezienie defektu w kodzie, jego rodzaju i miejsca występowania. Wybór kompilatora, który będzie wspomagać programistę w jak największym stopniu, w znaczący sposób zredukuje czas potrzebny na poszukiwanie defektów.

Pomyślna kompilacja programu nie oznacza niestety, że będzie on działał prawidłowo. Kompilator jest w stanie wychwytywać defekty, takie jak nieprawidłowa składnia lub rozbieżności w typach zmiennych, jednak nie zna intencji programisty i nie może ocenić, czy program będzie spełniać postawione mu zadania, dlatego jego pomoc jest przydatna tylko w fazie tworzenia kodu.

Zmiany w kodzie dokonywane podczas rozbudowy programu lub w trakcie procesu debugowania mogą być powodem powstawania nowych defektów. Systemy kontroli wersji to narzędzia służące do monitorowania zmian w kodzie i zarządzania kolejnymi rewizjami. Jeśli działający wcześniej kod przestał działać po wprowadzeniu zmiany, to najprawdopodobniej defekt znajduje się w zmodyfikowanym kodzie i warto wiedzieć, co zostało zmienione.

Bugtrackery z kolei to narzędzia służące do zbierania i zarządzania informacjami o błędach rejestrujące dane takie jak rewizja oprogramowania, w której wystąpił błąd i objawy błędu. Są pomocne również podczas debugowania, zarządzając procesem usuwania błędu, kontrolując podział pracy w zespole programistów i nadzorując stan procesu naprawczego.

Pierwsze narzędzia przychodzące na myśl w kontekście zwalczania błędów to debuggery. Ich zadaniem jest śledzenie wykonywania programu i namierzanie defektów przy użyciu pułapek zakładanych w miejscach, gdzie można spodziewać się defektu. Wykonanie programu jest przerywane po napotkaniu pułapki, co pozwala na podgląd wartości zmiennych. Istnieją również debuggery służące do wykrywania nieprawidłowości związanych z pamięcią, takich jak wycieki pamięci czy przepełnienie bufora. Jedną z ich funkcji jest też namierzanie nieprawidłowych wartości, do których odnoszą się wskaźniki.

Pomocne mogą okazać się również profilery, czyli narzędzia mierzące czas wykonania poszczególnych segmentów programu. Są stosowane głównie do optymalizacji kodu, ale mogą również zostać wykorzystane w trakcie debugowania.

Literatura i Internet

Sprawne debugowanie wymaga od programisty nie tylko znajomości języka programowania, architektury sprzętu, na którym program jest uruchamiany oraz zrozumienia działania samego programu, lecz również specyficznego sposobu myślenia ukierunkowanego na poszukiwanie i usuwanie defektów. Wyrobienie tych cech wymaga wiele pracy i czasu, dlatego warto wspomóc się wiedzą zebraną przez innych.

Istnieje wiele publikacji na temat poszczególnych języków programowania, jednak są również takie, które przekazują uniwersalną wiedzę na temat debugowania. Jedną z nich jest "Debugging: The 9 Indispensable Rules for Finding Even the Most Elusive Soft ware and Hardware Problems". Książka jest zbiorem reguł przydatnych każdemu programiście.

Została napisana w przystępny sposób i zawiera wiele przykładów potwierdzających skuteczność opisywanych metod. Kolejna pozycja to "Why Programs Fail: A Guide to Systematic Debugging", która jest kompleksowym przeglądem wszelkich zagadnień związanych z debugowaniem, zawierającym informacje na temat wszystkich jego etapów. Opisane są w niej różnorodne techniki i narzędzia służące do wyszukiwania i usuwania przyczyn błędów niezale żnie od języka, w którym został napisany kod.

Nieocenionym źródłem informacji jest również Internet, w którym znajduje się niezliczona liczba poradników i forów o debugowaniu programów napisanych w różnych językach. Na stronach takich jak www.stackoverflow.com użytkownicy dokonują wymiany informacji na temat różnych przypadków dotyczących wielu języków programowania.

Jeśli napotka się problem przy programowaniu, to istnieje szansa, że ktoś zmagał się z nim wcześniej i został on rozwiązany przez użytkowników. Jeśli chce się zwrócić do użytkowników po pomoc, tworząc nowy wątek, warto mieć sprecyzowane potrzeby i jasno przedstawić problem. Tematy typu "Program nie działa, pomóżcie. Wklejam kod" są raczej niemile widziane i należy się liczyć z negatywnym odzewem.

Trudna droga do celu

Przewidzenie wszystkich sytuacji, które mogą skutkować wystąpieniem błędu, jest niemożliwe, ich ilość jest nieskończona. Trudno wobec tego sprowadzić debugowanie do zbioru uniwersalnych reguł, które miałyby zastosowanie w każdym przypadku. Wymagane jest korzystanie z dostępnych technik i narzędzi oraz wiedzy swojej i innych. Doświadczenie przychodzi z czasem, jednak łatwo nabyć złych nawyków utrudniających pracę.

Działania mające prowadzić do uwolnienia programu od błędów w skrajnym przypadku, jeśli są prowadzone nieprawidłowo, są w stanie pogorszyć sytuację i przysporzyć programiście dodatkowej pracy. Prawidłowe podejście i wiedza na temat debugowania mogą skrócić czas potrzebny na uzyskanie zamierzonego działania programu, co ma przełożenie na wymierne oszczędności.

Piotr Ziółkowski