R05-03.DOC

(277 KB) Pobierz
Szablon dla tlumaczy

1

 

Rozdział 5.         Uruchamianie, śledzenie przebiegu i testowanie aplikacji

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Po skompletowaniu kodu źródłowego projektu, jego skompilowaniu, skonsolidowaniu i uruchomieniu przychodzi zazwyczaj czas pierwszych rozczarowań – stworzona z takim trudem aplikacja, o ile w ogóle nie odmawia współpracy z użytkownikiem, zamiast oczekiwanych danych produkuje przeważnie komunikaty o błędach. Scenariusz taki wpisany został już na dobre w realia życia zawodowego programistów projektantów i pozostanie aktualny tak długo, dopóki w drastyczny sposób nie zmieni się technologia tworzenia oprogramowania dla komputerów.

Etymologia angielskiej nazwy debugging, określającej całokształt postępowania zmierzającego do zlokalizowania i poprawienia błędnych elementów aplikacji, nie doczekała się jednolitego wyjaśnienia. Najczęściej powtarzająca się wersja, zgodnie z którą zabłąkana pomiędzy kable komputera pluskwa (ang. bug) spowodowała jego błędne działanie, kwestionowana bywa przez autorytety powołujące się na dokumentację ikonograficzną; wiele materiałów źródłowych przypisuje autorstwo samego terminu bug pani admirał Grace Hopper[1], która 9 września 1945 r. odnalazła martwą ćmę (mola?) między stykami jednego z tysięcy przekaźników ówczesnego komputera Mark II. Prawdopodobnie jednak dla Czytelników książek takich jak niniejsza techniczne szczegóły „odpluskwiania” aplikacji są znacznie bardziej interesujące od samej nazwy, swoją drogą dość trafnie odzwierciedlającej charakter zmagania się programistów z oporną rzeczywistością.

Równie niejednoznaczną kwestią jest sama definicja „błędu” aplikacji – okazuje się, iż zaklasyfikowanie takich czy innych aspektów funkcjonowania aplikacji jako „błędy” zależne jest od punktu widzenia projektanta, użytkownika itp.[2] Abstrahując w tym miejscu od ścisłej, teoretycznej definicji „błędu” ograniczymy się do stwierdzenia, iż najpowszechniej popełniane przez programistów błędy, nie pozostawiające wątpliwości co do swojego charakteru, zaliczyć można do jednej z poniższych kategorii:

 

·         syntaktyczne – polegają na wprowadzeniu do kodu programu konstrukcji niezgodnych ze składnią języka; są automatycznie wykrywane przez kompilator i dla programistów wystarczająco znających tę składnię nie stanowią żadnego problemu;

·         konstrukcyjne – są wynikiem nieprawidłowej procedury przekształcania kodu źródłowego w postać binarną, na przykład wykonania kompilacji w trybie Make w sytuacji, gdy konieczny jest tryb Build, bądź nieprawidłowego określenia ścieżek dostępu do bibliotek i plików dołączanych. Do tej kategorii zalicza się także nieuwzględnienie przez kompilator zmian wprowadzonych do kodu podczas kompilacji „w tle”;

·         semantyczne – sprowadzają się do użycia poprawnych składniowo elementów języka o znaczeniu innym niż zamierzone (na przykład operatora „=” zamiast „==”) oraz opuszczenia pewnych niezbędnych konstrukcji (na przykład inicjalizacji zmiennych). Większość z nich nie jest wykrywana w sposób automatyczny;

·         algorytmiczne – polegają najczęściej na właściwej realizacji niewłaściwej koncepcji, czego przykładem może być posortowanie elementów listy w kolejności rosnącej długości, podczas gdy wymagana jest kolejność malejąca. Błędy tej kategorii są najtrudniejsze do wykrycia;

·         kontekstowe – powodują produkowanie błędnych wyników przez poprawne procedury, na skutek niewłaściwych danych lub przekazania tych danych w niewłaściwy sposób;

·         bilansowania – związane są z wykorzystaniem zasobów przez aplikację, a ściślej brakiem komplementarności działań tej ostatniej, czego przykładem może być niezwolnienie przydzielonej pamięci, niezamknięcie otwartego pliku czy też brak dekrementacji licznika odwołań współdzielonego obiektu po jego wykorzystaniu;

·         interfejsu – stanowią pogwałcenie zasad wynikających z założonych protokołów współpracy pomiędzy aplikacjami, bądź częściami danej aplikacji;

·         efekty uboczne – powodują niepożądane efekty w otoczeniu aplikacji, na przykład niszczenie zawartości obszarów pamięci przynależnych do systemu albo innych aplikacji lub dostęp do współużytkowanych plików bez przestrzegania założonych procedur synchronizacyjnych.

 

W uzupełnieniu wymienić można wiele innych, rzadziej spotykanych okoliczności, jak na przykład nie nadający się do współpracy interfejs użytkownika (o czym pisaliśmy w rozdziale 3.) czy też niemożliwe do zaakceptowania ślamazarne tempo pracy aplikacji.

Jak wynika z powyższego zestawienia, natura większości błędów wyklucza możliwość efektywnego wykrywania ich w sposób automatyczny, należy więc walkę z błędami uczynić integralną częścią działań projektowych, bez złudnych nadziei, iż wyręczy nas w tym kompilator czy konsolidator.

Szanse programistów na tworzenie bezbłędnego kodu zwiększają się dzięki oferowanym przez C++Buildera narzędziom wspomagającym proces kodowania, opatrzonych wspólną nazwą Code Insight. Ułatwiają one tworzenie bezbłędnych konstrukcji dzięki m.in. funkcjom automatycznego uzupełniania kodu, podpowiedziom dotyczącym deklaracji używanej właśnie funkcji, wzorcom kodu itp.

Projektowe uwarunkowania śledzenia aplikacji

Jak już wspomnieliśmy, wobec nieuchronności popełniania błędów programistycznych walkę z tymi błędami należy uwzględnić już w początkowych stadiach tworzenia aplikacji. To enigmatyczne stwierdzenie przekłada się w praktyce na działania dwojakiego rodzaju. Po pierwsze, uciekając się do działań obarczonych znikomym ryzykiem błędu, zwiększamy szansę na bezbłędność produktu końcowego – i tak na przykład wykorzystanie gotowych, gruntownie przetestowanych bibliotek, udostępniających środki dla realizacji określonego aspektu funkcjonalnego aplikacji, jest posunięciem zdecydowanie pewniejszym niż samodzielne tworzenie takowych bibliotek ab ovo. Bowiem jakby na przekór znanemu przysłowiu, iż „toczący się kamień nie obrasta mchem”, intensywne prace projektowe – niby przysłowiowe toczenie kamienia – niosą ze sobą groźbę równie intensywnego obrastania „mchem” popełnianych błędów.

Po drugie, popełniane przez programistów błędy stają się mniej groźne, jeżeli manifestują się w aplikacji w sposób wyraźny – na przykład w postaci komunikatu, iż wartość, stanowiąca (zgodnie z algorytmem obliczeń) kwadrat liczby rzeczywistej jest ujemna, a wskaźnik do struktury zawierającej dane wejściowe jest wskaźnikiem pustym. Należy również dążyć do takiego stylu kodowania, by jak najwięcej pomyłek popełnianych przez programistów przekładało się na błędy syntaktyczne, jak wiadomo wykrywalne w stu procentach. W charakterze przykładu rozpatrzmy częsty błąd, polegający na gubieniu jednego znaku „=” w operatorze porównania. Konstrukcja:

 

if (x == 5)

...

 

zamienia się wówczas w instrukcję przypisania:

 

if (x=5)

...

zmieniającą niezauważalnie wartość zmiennej x, co może być naprawdę trudne do wykrycia, lecz już równoważna konstrukcja:

 

if (5 == x)

 

w przypadku opuszczenia jednego ze znaków „=” zamienia się w konstrukcję błędną syntaktycznie. Wybierając drugi ze wskazanych sposobów porównania zmiennej x z wartością 5, zwiększamy w znacznym stopniu szansę na wykrycie ewentualnej pomyłki – nosi to znamiona swoistej działalności „obronnej” przed skutkami ludzkiej niedoskonałości i z tego względu zyskało sobie powszechnie miano programowania defensywnego.

 

Problematyce programowania defensywnego i ogólnie całokształtowi działań programistycznych, przyczyniających się do tworzenia bezbłędnych aplikacji, poświęcona jest w całości książka Steve’a Maguire’a Writing solid code, której polskie wydanie (pod tytułem Niezawodne programowanie) przygotowywane jest właśnie w wydawnictwie Helion – przyp. tłum.

Możliwym źródłem błędu są również luki organizacyjne przy zespołowym tworzeniu projektu. Programista, przystępując do wprowadzania zmian w istniejącej koncepcji, powinien poinformować o tym swoich kolegów, którzy tym samym uniknąć mogą błędów, polegających na wykorzystywaniu nowych konstrukcji na starą modłę. Może się wówczas także okazać, iż jakiś nowy mechanizm, implementowany właśnie przez jednego z programistów, został już wcześniej zrealizowany przez kogoś innego; utrzymywanie dwóch jego różnych implementacji nie sprzyja bynajmniej redukcji popełnianych błędów (nie wspominając już o zwykłej stracie czasu programisty).

Naturalnym sojusznikiem programistów w walce z błędami jest przejrzysty i czytelny styl kodowania, któremu poświęciliśmy rozdział 2. książki. Tworzony kod powinien być w miarę możności kodem samodokumentującym, wszelkie „nieoczywiste” konstrukcje powinny być więc w sposób zrozumiały skomentowane. Należy przy tym tak dobierać sposób kodowania i treść komentarzy, by były one zrozumiałe przez innych programistów, a nie tylko przez programistę kodującego (który, w przeciwnym razie, czytając swój własny produkt po upływie np. pół roku, mógłby sam mieć poważne kłopoty z jego zrozumieniem).

Szczególnie istotnymi dla poprawności tworzonego kodu są te elementy, których w nim po prostu nie ma – mowa tu o przyjętych milcząco specyficznych założeniach, przy których działać ma aplikacja, a także o sytuacjach, które zdaniem programistów nie mają prawa wystąpić. Tak się jednak składa, iż uwarunkowania zewnętrzne szybko się zmieniają (przekonali się o tym niedawno programiści Turbo Pascala, gdy procesor Pentium II okazał się zbyt szybki dla funkcji skalującej czasomierz programowy, powodując w efekcie błąd wykonania 200 – przyp. tłum.), zaś sytuacje uznane dotąd za niemożliwe stają się całkiem realne. Programista utrzymujący aplikację powinien więc być świadom w każdym czasie wspomnianych założeń, które tym samym powinny być wyraźnie udokumentowane w formie komentarzy, a najlepiej dodatkowo weryfikowane za pomocą stosownych testów. Należy również liczyć się z tym, iż domyślne ustawienia opcji, związanych z różnymi aspektami projektu (kompilacją, konsolidacją itp.), mogą ulec zmianie w przyszłych wersjach wykorzystywanego języka programowania.

Poprawiając dostrzeżone błędy, należy jednocześnie uważać, by przy tej okazji nie wprowadzić nowych – wieszając kolejną bombkę na choince, należy uważać, by przy okazji nie stłuc pięciu już wiszących; aktualna staje się w tym momencie zasada, zgodnie z którą „jeżeli coś działa, to nie należy tego poprawiać”.

Programistyczne uwarunkowania śledzenia aplikacji

Zgodnie z tym, co przed chwilą powiedzieliśmy, walka z błędami, które pojawią się w tworzonym projekcie, powinna być organicznie wpisana w sam proces projektowania aplikacji; błędy programistyczne są bowiem zjawiskiem na tyle powszechnym, iż niedopuszczalne jest traktowanie ich jako jedynie swego rodzaju „wypadków przy pracy”, których skutki zniwelować można za pomocą li tylko debuggera. Najdalej nawet posunięta staranność i „defensywność” przy tworzeniu projektów nie jest jednak w stanie całkowicie zapobiec wszystkim błędom (ani nawet ich większości) – wtedy właśnie debuggery stają się najważniejszymi narzędziami, ułatwiającymi zlokalizowanie błędu i jego usunięcie.

Podobnie, jak nie należy pozostawiać zagadnienia błędów programistycznych „na uboczu” zasadniczych prac projektowych, podobnie nie należy odkładać „na później” poprawiania błędów już znalezionych – świadomość, iż po ukończeniu tworzenia aplikacji pozostaje jeszcze usunięcie kilku błędów, utrudnia określenie chociażby terminu ukończenia projektu, trudno bowiem a priori określić, ile czasu zajmie walka z błędami.

Należy ponadto dokumentować wszelkie stwierdzone błędy, zarówno w formie odrębnej listy, jak i innych środków bezpośrednio odnoszących się do kodu źródłowego, jak np. znaczników „ToDo”. Lista zauważonych błędów nie zawsze musi być postrzegana jako „lista złych wiadomości”, szczególnie jeżeli względnie szybko przekształca się ona w listę błędów poprawionych i jeżeli (zgodnie z wytycznymi poprzedniego akapitu) nie będzie nigdy listą zbyt długą.

Przed przystąpieniem do modyfikacji kodu należy bezwzględnie sporządzić kopie zapasowe modyfikowanych modułów – w przypadku totalnego zagubienia się w kodowaniu będziemy mieli wówczas przynajmniej możliwość powrotu do stanu wyjściowego. Zadanie to ułatwiają różnorodne systemy kontrolowania wersji.

I jeszcze jedno: człowiek z reguły uczy się na własnych błędach, należy więc starać się unikać powtórnego popełniania tych samych błędów w tworzonych aplikacjach.

W tym miejscu Autor oryginału poleca ulubioną przez siebie książkę Steve’a McConnella Code Complete wydaną przez w 1993 przez Microsoft Press (ISBN 1–55615–484–4), nazywając ją wprost „biblią dla programistów”. Jego zdaniem książka ta porusza wszelkie aspekty konstruowania kodu źródłowego i jest bardzo przyjemna w czytaniu.

Podstawowe techniki usuwania błędów aplikacji

Zajmiemy się teraz najczęściej stosowanymi metodami lokalizacji błędów w kodzie programu. W dalszej części rozdziału zilustrujemy ich zastosowanie na przykładzie zintegrowanego debuggera C++Buildera 5, zaprezentujemy również kilka technik nieco bardziej zaawansowanych.

Przed zagłębieniem się w złożoną i niekiedy czasochłonną misję poszukiwania błędów należy zastanowić się, czy źródłem błędu nie są zastosowane komponenty niezależnych wytwórców (third party), dokładniej – czy inni użytkownicy tych komponentów nie doświadczyli podobnych błędów lub też czy błędy te znane są samym producentom. Jeżeli tak, to najprawdopodobniej dostępna jest nowa wersja rzeczonych komponentów lub też łata (patch) usuwająca błąd; jeżeli nie, to przyczyna błędu leży najprawdopodobniej w samej aplikacji i czas zabrać się do dzieła. Mamy do dyspozycji cztery zasadnicze metody lokalizowania błędu:

·         weryfikacja wyników produkowanych przez aplikację – porównuje się wówczas wyniki produkowane przez aplikację (lub określony jej fragment) ze znanymi (bo obliczonymi wcześniej) wynikami dla danych testowych. Niekiedy aplikacja wykazuje błędne zachowanie tylko dla niektórych kategorii danych, na przykład liczb ujemnych; możemy wówczas podejrzewać, iż błąd znajduje się w tej części kodu, w której przetwarzane są ujemne dane wejściowe;

·         śledzenie przepływu sterowania pomiędzy instrukcjami i analiza wyników pośrednich – jeżeli na przykład aplikacja sygnalizuje błąd dzielenia przez zero, podejmuje się próbę określenia, dlaczego dane użyte jako dzielnik mają wartość zerową;

·         wykrywanie niespełnionych założeń za pomocą asercji lub innych dodatkowych testów – warunki, co do wystąpienia których (w danym miejscu kodu) programista był w stu procentach pewien, mogą faktycznie nie wystąpić, na przykład z powodu zwykłego błędu w rozumowaniu;

·         przechwytywanie tych wyjątków, które w poprawnej aplikacji nie mają prawa wystąpić – wszelkie „dziwne” wyjątki powinny być sygnalizowane przez globalną funkcję obsługi wyjątków nie obsłużonych na niższych poziomach; integralną częścią komunikatu wyświetlanego przez taką funkcję powinno być wskazanie konkretnej instrukcji kodu źródłowego, w czasie wykonywania której wyjątek zaistniał.

Dwie pierwsze z wymienionych metod mogą wymagać zastosowania pracy krokowej, co oczywiście umożliwia zintegrowany debugger C++Buildera. Należy zdawać sobie sprawę z faktu, iż miejsce wystąpienia błędu (w kodzie źródłowym) zazwyczaj różni się od miejsca zawierającego przyczynę błędu – i tak na przykład niespełnienie którejś asercji może być konsekwencją niespełnienia innych zakładanych warunków we wcześniejszej fazie obliczeń. Dotarcie do faktycznej przyczyny błędu może być dokonane z użyciem trzech następujących metod:

·         analiza wsteczna – polega na „cofaniu się” (w wyobraźni lub za pomocą odpowiednio zastawionych punktów przerwań) po ścieżce wykonania, czyli sprawdzaniu wartości i stanu określonych zmiennych i obiektów na coraz to wcześniejszych stadiach wykonania i porównywaniu tych wartości z wartościami oczekiwanymi; miejsce, w którym porównywane wartości przestają być rozbieżne, staje się wówczas przedmiotem dalszych dociekań co do przyczyny tej rozbieżności;

·         wykonanie kontrolowane – począwszy od miejsca, w którym poprawność obliczeń nie budzi wątpliwości, prowadzi się pracę krokową, weryfikując jednocześnie wartości żądanych obiektów identycznie, jak w przypadku analizy wstecznej;

·         zawężanie poszukiwań – na ścieżce wykonania programu wyróżnia się dwa takie miejsca, iż w pierwszym z nich poprawność wykonania nie budzi wątpliwości, w drugim natomiast błędna sytuacja jest poza wszelkimi wątpliwościami. Tak zdefiniowany „odcinek” wykonania zawęża się sukcesywnie w kolejnych krokach. Metoda ta znana jest powszechnie pod nazwą „dziel i rządź”, bywa też określana mianem „stawiania grodzi” (w podobny bowiem sposób lokalizuje się i izoluje miejsce wystąpienia np. przecieku lub pożaru na okręcie).

 

Każdy, kto w swym życiu zetknął się z programowaniem, doskonale wie, iż najtrudniejszymi do zlokalizowania są błędy pojawiające się od czasu do czasu. Minimalizowanie tego zjawiska – jeżeli już trafiają się błędy, powinny one występować w sposób powtarzalny – jest jednym z elementów wspomnianego wcześniej programowania defensywnego.

 

Przyjrzyjmy się teraz zastosowaniu opisanych technik na gruncie C++Buildera.

Wyprowadzanie informacji testowych

 

Najprostszą metodą śledzenia przebiegu programu i zmiany zawartości wybranych zmiennych i obiektów jest wyprowadzanie na zewnątrz czytelnych informacji dotyczących problemu. W przypadku aplikacji konsolowych wyprowadzenie to może być realizowane np. za pomocą funkcji printf() lub instrukcji cout <<, czego przykład przedstawia wydruk 5.1. Zwróć uwagę, iż konieczne jest dołączenie plików nagłówkowych <stdio.h> i <iostream.h>.

Wydruk 5.1. Wyprowadzanie informacji testowej w aplikacji konsolowej

#include <stdio.h>

#include <iostream.h>

 

void MyDebugOutput(AnsiString OutputMessage)

{

    // pierwsza metoda – użycie funkcji printf()

    printf("Debug: %s\n", OutputMessage.c_str());

 

    // druga metoda – użycie instrukcji cout <<.

    cout << "Debug: " << OutputMessage.c_str() << endl;

}

 

void NormalFunc(int MaxLines)

{

 

    ....

 

 

    MyDebugOutput("Przed pętlą, MaxLines=" + IntToStr(MaxLines));

    for (int i = 0 ; i < MaxLines ; i++)

    {

       MyDebugOutput("In loop, i=" + IntToStr(i));

 

        .......

 

    }

}

W przypadku aplikacji graficznej (GUI) wyprowadzany tekst może być prezentowany w ramach jakiegoś komponentu tekstowego – etykiety, kontrolki edycyjnej, memo itp. – albo wyświetlany w postaci komunikatów produkowanych przez funkcje ShowMessage() lub MessageDlg(). Jeżeli informacja testowa ma być jedynie świadectwem obecności sterowania w określonym miejscu kodu, można nawet zrezygnować z postaci tekstowej i nadać tej informacji formę dźwięku produkowanego przez funkcję MessageBeep(). Wszystkie te metody zilustrowaliśmy na wydruku 5.2.

Wydruk 5.2. Wyprowadzanie informacji testowej w aplikacji GUI

 

void MyDebugOutput(AnsiString OutputMessage)

{

 

    // pierwsza metoda – wyświetlenie tekstu za pośrednictwem etykiety,

    // kontrolki edycyjnej i memo

    MainForm->ErrorLabel->Caption = OutputMessage;

    MainForm->ErrorEdit->Text = OutputMessage;

    MainForm->ErrorMemo->Text = OutputMessage;

 

    // druga metoda – wyświetlenie komunikatu za pomocą ShowMessage()

    ShowMessage(OutputMessage);

 

    // trzecia metoda – wyświetlenie komunikatu za pomocą MessageDlg()

    MessageDlg(OutputMessage, mtInformation, TMsgDlgButtons() << mbOK, 0);

}

 

void MyDebugBeep()

{

    // czwarta metoda – emisja standardowego sygnału dźwiękowego

    MessageBeep(0xFFFFFFFF);

}

W aplikacji wielowątkowej użyteczne jest włączanie do treści komunikatu informacji o identyfikatorze wątku generującego ów komunikat – identyfikator ten otrzymać można za pomocą funkcji GetCurrentThreadId().

Po zakończeniu śledzenia aplikacji komunikaty produkujące informację testową stają się po prostu niepożądane, dlatego jedynym rozsądnym sposobem ich użycia jest uzależnienie ich od jakiegoś symbolu kompilacji warunkowej, zdefiniowanego w trybie testowania aplikacji i nieobecnego w trybie jej normalnego uruchomienia. Symbole kompilacji warunkowej obowiązujące dla całości kodu źródłowego wpisuje się w pole Conditional defines na karcie Directories/Conditionals opcji projektu (należy pamiętać, iż po zmianie listy symboli warunkowych konieczne jest ponowne skompilowanie projektu w trybie Build). W poprzednich wersjach C++Buildera programiści musieli wykonywać tę czynność ręcznie, w wersji 5. została ona nieco zautomatyzowana dzięki przyciskom Full debug i Release na karcie Compiler. Kliknięcie pierwszego z wymienionych przycisków powoduje dodanie do wspomnianej listy symbolu _DEBUG (o ile nie jest on tam już obecny), natomiast przy kliknięciu drugiego przycisku symbol ten jest z listy usuwany (dokładniej: usuwane jest tylko jego ostatnie wystąpienie na tej liście). Jeżeli komuś nie odpowiada nazwa _DEBUG, może ją zmienić stosownie do własnych upodobań, dokonując odpowiednich ustawień w rejestrze systemu – należy mianowicie przypisać żądany symbol do łańcucha o nazwie DebugDefine w kluczu HKEY_CURRENT_USER\Software\Borland\C++ Builder\5.0\Debugging. W tym rozdziale zakładamy, iż w trybie śledzenia zdefiniowany jest symbol _DEBUG.

Zaprezentowane na wydruku 5.2 metody wyświetlania tekstu nie sprawdzają się jednak w przypadku śledzenia metody Paint() któregoś komponentu, bowiem w trakcie obsługi „właściwego” komunikatu WM_PAINT pojawia się kolejny taki komunik...

Zgłoś jeśli naruszono regulamin