rozdział 1
Testowanie programów
Testowanie programów
Cel ćwiczenia
Celem ćwiczenia jest zapoznanie się z wybranymi metodami testowania oprogramowania i typowymi środkami pomocnymi w testowaniu oraz wykorzystanie ich w praktyce, w wybranym systemie programowania.
Podstawy teoretyczne
Każdy program powinien być przede wszystkim niezawodny. Pojęcie niezawodności można rozumieć, w pewnym uproszczeniu, jako brak podatności na awarie oprogramowania. Przyjmijmy jako definicję awarii określenie sformułowane przez J. Goodenougha:
Awaria to każdy efekt lub zachowanie, którego wystąpienie jest niedopuszczalne w normalnych warunkach działania, gdzie to, co „niedopuszczalne” i „normalne”, musi być określone dla każdego systemu.
Wówczas przykładami awarii mogą być błędne wyniki lub brak wyników, zmniejszenie efektywności programu (zaskakująco długie przetwarzanie dla pewnych danych) lub niszczenie danych użytkownika. Działania takie mogą być spowodowane błędami w strukturze programu lub niepoprawnym wypełnianiem specyfikacji.
Niezawodność łączy się ściśle z poprawnością programu. Najogólniej można powiedzieć, że program jest poprawny, jeżeli zawsze działa zgodnie z sensownymi oczekiwaniami użytkownika i intencją programisty. Definicja taka jest bardzo ogólna i elastyczna, bo bazuje na pojęciach wysoce subiektywnych. Program prawidłowo zaprojektowany i napisany powinien być zgodny ze swoją specyfikacją. Należy więc upewnić się, czy badany program istotnie tę specyfikację spełnia. Z drugiej strony, czasami sama specyfikacja może nie obejmować pewnych sytuacji lub być niedoskonała albo niespójna w inny sposób, i wówczas w pełni z nią zgodny program także może ulec awarii. Należy więc sprawdzić i specyfikację.
Definicja testowania
Testowanie jest to zbiór czynności wykonywanych z intencją wykrycia w programie jak największej liczby błędów. Jednym z jego głównych składników jest obserwacja zgodności produkowanych przez program wyników z wcześniej przygotowanymi poprawnymi wynikami odniesienia. Proces ten może prowadzić do uznania, że dany program działa poprawnie we wszystkich przypadkach, w których ma działać, lub do wykrycia błędu.
Celem testowania programu jest upewnienie się, że program rozwiązuje to zadanie, do którego został zaprojektowany, i że w każdych warunkach daje poprawne wyniki. Jest to proces różny od procesu uruchamiania programu. Program uruchomiony nie zawiera błędów sygnalizowanych przez translator i jawnie niepoprawnych fragmentów kodu oraz daje jakieś wyniki. Czy wyniki te są poprawne, zgodne z oczekiwaniami ustalonymi w specyfikacji, pozwala ocenić testowanie.
Nie jest możliwe dokładne określenie, jak powinno przebiegać testowanie konkretnego programu. Można jednak sformułować kilka wskazówek dotyczących organizacji procesu testowania, które mogą pomóc w prawidłowym przetestowaniu programu.
Należy dążyć do:
sprowokowania błędu, tzn. stworzenia sytuacji, w której błąd może się ujawnić (odpowiednie środowisko, dobrane dane testowe, przygotowane poprawne wyniki odniesienia);
sporządzenia dokładnego opisu okoliczności, które doprowadziły do błędu, co umożliwi przejście do fazy uruchamiania w celu poszukiwania przyczyny niewłaściwej pracy programu.
Określając granice procesu testowania trzeba wspomnieć o liczbie wykonywanych testów. Chcąc starannie przetestować cały program, należałoby wykonać testowanie gruntowne. Polega ono na wykonaniu programu dla wszystkich możliwych kombinacji danych wejściowych. Jest to prawie zawsze niemożliwe ze względu na niezbędny w tym celu czas i wysiłek, choć byłby to jedyny sposób udowodnienia całkowitej poprawności programu. W praktyce, ze względu na efektywność testowania, należy stosować możliwie małą, lecz dostateczną liczbę zestawów danych testowych, przy czym powinny być one dobierane z odrobiną wyobraźni i podejrzliwości w stosunku do działania programu. Mimo istnienia wielu formalnych metod, w praktyce programista powinien bazować w znacznym stopniu na swoim sprycie i intuicji.
Testując dany program należy przeprowadzić dwa rodzaje testów. Po pierwsze, trzeba przekonać się, czy program realizuje właśnie to zadanie, które zostało sformułowane. Po wtóre, należy uzyskać pewność, że działa poprawnie.
Możliwe są dwa skrajnie odmienne podejścia do testowania programu: względem struktury wewnętrznej (kodu) i względem specyfikacji zewnętrznej.
Można jako podstawę w czasie testowania potraktować kod programu. Wtedy zabezpieczymy się przed błędami wykonania (ang. run-time errors) i wszystkie instrukcje będą się wykonywały poprawnie, jednak nie wykryjemy faktu, że program jest niezgodny ze specyfikacją, tzn. robi nie to, co należało.
Jeśli zaś nie będziemy interesować się kodem, możemy testowaniem objąć jedynie specyfikację. Wówczas mamy sytuację odwrotną - jeżeli umiejętnie dobierzemy dane testowe, możemy wykryć wiele błędów logicznych, jednak nie możemy gwarantować, że program nie zakończy się błędem wykonania. Wynika stąd, że dla właściwego przetestowania programu konieczna jest znajomość i sprawdzenie zarówno specyfikacji, jak i kodu programu.
Testowanie struktury wewnętrznej
Przy testowaniu struktury wewnętrznej pojedynczego modułu (testowaniu względem kodu) konieczna jest znajomość tekstu źródłowego programu. Generalnie przyjmuje się, że warunkami koniecznymi zakończenia testowania względem kodu są:
Doprowadzenie do wykonania każdej instrukcji co najmniej raz, lub - równoważnie - do przejścia każdej z gałęzi przepływu sterowania w module. Chodzi o to, aby każdy warunek (punkt w programie, gdzie sterowanie rozgałęzia się, czyli zaczyna się nowa gałąź) podczas wykonywania testów przyjął wartość zarówno prawda, jak i fałsz. Trudno powiedzieć, ile razy należy powtórzyć wykonanie programu, aby powyższe warunki spełnić - zależy to od zadania, które program wykonuje.
Wykonanie wnętrza każdej pętli minimalną liczbę razy, małą liczbę razy oraz bardzo dużą (lub maksymalną, o ile można taką określić) liczbę razy. Osiąga się to przez dobór odpowiednich danych testowych.
Przygotowując dane testowe dla testowania względem kodu korzystamy z diagramów przepływu sterowania (DPS) i tworzonych na ich podstawie macierzy pokrycia gałęzi. Technika posługiwania się nimi została przedstawiona w Dodatku A. Szerzej o zasadach doboru danych testowych traktuje punkt 1.6.
Testowanie specyfikacji zewnętrznej
W przypadku testowania specyfikacji zewnętrznej program traktujemy jako „czarną skrzynkę”. Znamy jedynie liczbę i rodzaj danych podawanych na wejściu, liczbę i rodzaj danych wyjściowych oraz zdefiniowaną w specyfikacji zależność pomiędzy danymi wejściowymi a wyjściowymi.
W tym przypadku dobór danych testowych, a szczególnie moment, w którym uznamy testowanie za zadowalające i wiarygodne, w jeszcze większym stopniu zależy od doświadczenia i intuicji testującego. Wskazówki na temat doboru danych testowych znajdują się w punkcie 1.6.
W czasie przygotowywania zestawów danych testowych należy brać pod uwagę:
specyfikację zewnętrzną programu,
funkcje realizowane przez program,
strukturę programu,
przepływ sterowania.
Testowanie programu
Testowanie programu trzeba rozpoczynać bardzo wcześnie, właściwie już na etapie projektowania. Należy przede wszystkim sprawdzić, czy poprawnie zaprojektowano rozwiązanie sformułowanego zadania, to znaczy, czy program będzie rzeczywiście rozwiązywał takie zadanie, jakie należy, i czy wzięto pod uwagę wszystkie przypadki bez niejednoznaczności. Można to zrobić jeszcze przed rozpoczęciem kodowania za pomocą „ręcznej symulacji” przygotowanego algorytmu. Pomocny tu jest udział klienta zlecającego wykonanie programu. Nazywamy to testowaniem specyfikacji lub projektu.
Testowanie projektu pozwoli na uniknięcie poważniejszych błędów wynikających z niejasności lub niekonsekwencji w sformułowaniu zadania i specyfikacji zewnętrznej programu.
W czasie pisania programu należy pamiętać, że będzie on testowany. Struktura programu powinna być taka, by jego testowanie (także testowanie poszczególnych fragmentów) było łatwe. Jeśli dla wygody testowania do programu źródłowego wstawia się dodatkowe instrukcje, to należy je odpowiednio skomentować. Wygodnie jest je umieszczać wewnątrz dyrektyw kompilacji warunkowej. Po sprawdzeniu poprawności programu będzie je można bez kłopotu usunąć lub dezaktywować.
Testowanie modułów (procedur, fragmentów programu) przeprowadza się pojedynczo, w środowisku nie korzystającym z innych modułów, także przed powstaniem tych innych modułów. Jeżeli testowany moduł odwołuje się do modułów, które jeszcze nie istnieją, stosuje się symulację tych brakujących części systemu lub programu. Można tu wykorzystać moduły - makiety posiadające jedynie punkt wejścia i punkt powrotu oraz jakąś instrukcję sygnalizującą fakt wywołania modułu. Innym rozwiązaniem jest utworzenie modułu zastępczego realizującego w sposób uproszczony niezbędne do testowania funkcje.
Jeżeli testowaniu podlega oprogramowanie numeryczne, należy pamiętać o sprawdzeniu zachowania się modułu w przypadkach:
błędnych danych wejściowych (dla każdego modułu);
ograniczonego zakresu dokładności obliczeń związanych z nieskończonymi procesami matematycznymi (występuje to w przypadku stosowania algorytmów numerycznych, np. rozwijanie funkcji w szeregi nieskończone);
błędów zaokrągleń maszyny cyfrowej.
Testowanie modułu polega głównie na znalezieniu rozbieżności między logiką i pośrednictwami modułu a jego specyfikacją. Podczas testowania modułów wykorzystuje się często diagramy przepływu sterowania. Szersze informacje na ten temat znajdują się w dodatku A.
Testowanie integracyjne (test scalania) jest weryfikacją pośrednictw między częściami systemu (także programu). Przeprowadza się je na ogół po napisaniu całości lub znacznej części systemu.
Sprawdzenie zgodności zachowania się programu (wszystkich funkcji zewnętrznych) z jego specyfikacją zewnętrzną odbywa się w czasie testowania funkcjonalnego. Wymagana jest do tego dokładna specyfikacja zewnętrzna. Testowanie opiera się na wykonywaniu programu z uwzględnieniem wszystkich warunków wejściowych i opcji oraz na badaniu zachowania się poszczególnych funkcji na granicach dziedzin wejściowych i wynikowych. Sprawdza się także wyniki dla niepoprawnych lub niespodziewanych danych. Do projektowania zestawów danych wykorzystywanych w teście funkcjonalnym stosuje się czasem grafy przyczynowo-skutkowe (por. [1]).
W przypadku sprawdzania poprawności działania systemu konieczne jest jeszcze wykonanie testów systemu, testów akceptacyjnych i instalacyjnych.
Testowanie systemu polega na wykonywaniu programu w środowisku sztucznym (symulowanym) lub rzeczywistym i próbie wykrycia błędów lub niezgodności w stosunku do celów początkowych produktu. Celami początkowymi produktu mogą być takie wytyczne, jak przenośność kodu, użycie określonego typu komunikacji z użytkownikiem (typu Windows, Turbo Vision) itd.
Podczas testowania akceptacyjnego wykonuje się program w środowisku rzeczywistym i kontroluje zgodność jego działania z wymaganiami użytkownika, który tym razem sam wykonuje test na przygotowanych przez siebie danych. Często jednocześnie konfrontuje się dokumentację użytkową z zachowaniem się systemu.
Testowanie instalacyjne polega na wykrywaniu błędów popełnionych podczas instalowania systemu w środowisku, w którym przewidywane jest jego używanie.
Metody testowania
Istnieją dwie przeciwstawne koncepcje dotyczące testowania systematycznego:
testowanie tradycyjne, metodą wstępującą - syntetyczną,
testowanie nowocześniejsze, metodą zstępującą - analityczną.
Możliwe są metody wprost wynikające z jednej i drugiej koncepcji, jak również wiele praktycznie stosowanych metod, które są w mniejszym lub większym stopniu kombinacjami dwu podstawowych, i których wady i zalety są w związku z tym kombinacjami wad i zalet metod podstawowych. W metodach testowania systematycznego przeplatają się elementy scharakteryzowanego w poprzednim punkcie testowania integracyjnego i testowania składowych.
Trudno jest ściśle określić, który ze sposobów testowania programu jest najlepszy. Zależy to od konkretnego projektu, wielkości i złożoności programu bądź systemu. Wyboru musi dokonać testujący, kierując się przy tym znajomością technicznej realizacji projektu oraz zasobami czasu i środków wspomagających testowanie, jakimi dysponuje.
Poniżej opisano w sposób ogólny kilka głównych metod testowania.
Testowanie wstępujące
Metoda opiera się na sukcesywnym pisaniu i testowaniu fragmentów programu w kolejności od dołu do góry. Najpierw pisane i testowane są moduły najniższego poziomu, potem moduły poziomu bezpośrednio wyższego, i tak dalej, aż do głównego modułu programu, na którym cykl prac się kończy. Moduły wyższego rzędu są testowane, gdy sprawdzone są już moduły rzędu niższego.
Wadą takiego podejścia jest możliwość bardzo późnego ujawnienia się poważnych błędów całego projektu i pośrednictw. Skutkiem tego może być konieczność zmiany koncepcji pod sam koniec realizacji projektu. Oprócz tego trzeba także tworzyć środowisko testowe (obwody sterujące i odpowiednie dane) dla każdego sprawdzanego modułu. Obwodem sterującym nazywamy krótki program, który pozwala na przetestowanie modułu przez zadanie określonych danych wejściowych, wywołanie wszystkich procedur modułu i obserwację dostarczonych przez nie wyników. Tworzenie obwodów sterujących i danych testowych powoduje wzrost kosztu projektu, jednak w przypadku elementów najniższego poziomu - procedur - trudno sobie wyobrazić rezygnację z ich przetestowania w krótkim programie przed włączeniem do dużego projektu.
Testowanie zstępujące
Metoda jest ściśle związana z techniką projektowania i kodowania zstępującego. Testowanie zstępujące opiera się na zasadzie sprawdzania i łączenia fragmentów programu w kolejności z góry na dół. Testuje się najpierw program główny, używając zaślepek zamiast dotychczas nie zakodowanych modułów niższego poziomu. Kolejne fazy testowania polegają na wykonywaniu programu przy stopniowym dołączaniu pełnych wersji wywoływanych modułów. Proces jest powtarzany do momentu złożenia i przetestowania wszystkich modułów.
Zaślepka (namiastka) modułu jest zastępczym modułem realizującym funkcje modułu właściwego w sposób uproszczony. Uproszczenie to może być mniejsze lub większe. Najbardziej uproszczony kod zaślepki może się ograniczać tylko do dostarczania pewnych prawidłowych, ustalonych lub łatwo wyznaczalnych wartości, lub wypisywania komunikatu.
Zaletą testowania zstępującego jest brak konieczności tworzenia programów testujących. Nie trzeba także opracowywać nowych danych testowych dla każdego modułu - wystarczy do istniejącego zestawu dodać dane dla kolejnych badanych modułów. Testowanie zstępujące umożliwia także wczesne sprawdzenie kompozycji programu i pośrednictw międzymodułowych. Poza tym, łącząc w sobie elementy testów modułów, testów scalania, a częściowo także testów funkcjonalnych, skraca czas powstania systemu.
Metoda ta jednak ma również wady. Testujący, podając testowe dane wejściowe dla całego systemu, nie ma bezpośredniego wpływu na dane przekazywane sobie przez poszczególne moduły, a w szczególności nie może spowodować wywołania konkretnej procedury z wszystkimi wartościami danych, jakie go interesują. Zamiast obwodów sterujących należy stwarzać zaślepki (rolę globalnego obwodu sterującego pełni szkielet modułu głównego). Ponadto może się także okazać, że napisanie zaplanowanego modułu niższego poziomu nie jest możliwe, a późne stwierdzenie tego faktu prowadzi do poważnych kłopotów.
Zmodyfikowane testowanie zstępujące
Metoda ta łączy w sobie zalety dwu poprzednich. Jest podobna do testowania zstępującego, przy czym wymaga osobnego przetestowania każdego modułu przed jego włączeniem do programu (czyli tak, jak w metodzie wstępującej).
W tym przypadku zaletą jest, nie występująca w zwykłej metodzie testowania zstępującego, możliwość testowania wybranych konstrukcji logicznych wewnątrz modułu. Dotyczy to szczególnie sprawdzania poprawności danych, programowania defensywnego (por. rozdział 2: Uruchamianie) oraz badania konkretnych warunków logicznych występujących wewnątrz kodu. Fragmenty tego typu da się przetestować jedynie wtedy, gdy moduł istniejący o szczebel wyżej przekazuje nieprawidłowe albo specyficzne dane. Po włączeniu badanego modułu do programu taka sytuacja może być trudno osiągalna. Powodem tego jest na ogół wykorzystywanie modułu w ograniczonym kontekście (gdy nie otrzymuje on pełnej dziedziny swoich danych wejściowych) lub też występowanie trudności w określeniu rodzaju danych testowych (jeżeli są one wprowadzane w punkcie znacznie oddalonym od badanego warunku).
Metoda dziedziczy również wady czy też niedogodności swoich poprzedniczek. Wadą zmodyfikowanej metody testowania zstępującego jest konieczność przygotowywania obwodów sterujących oraz namiastek każdego modułu.
Testowanie mieszane
Testowanie mieszane stanowi kompromis między testowaniem wstępującym i zstępującym. Testowanie zstępujące rozpoczynamy od najwyższej warstwy projektu - programu głównego; testowanie zstępujące - od najniższej warstwy - modułów podstawowych, aż do spotkania pośrodku. Linia spotkania powinna zostać wyznaczona przed rozpoczęciem testowania na podstawie znajomości struktury programu. Metodę tę stosuje się głównie do scalania dużych programów (systemy operacyjne i specjalizowane systemy użytkowe). Zaletą tego sposobu testowania jest wczesne scalanie modułów, czyli szybkie uzyskanie szkieletowego, ale już działającego programu. Poza tym zachowane są zalety zarówno testowania zstępującego, jak i testowania wstępującego, przy równoczesnej częściowej neutralizacji ich wad.
Za istotną wadę metody uważa się niemożność pełnego przetestowania części modułów, związaną z testowaniem zstępującym. Co prawda dotyczy to tylko górnych warstw programu, jednak czasem może okazać się kłopotliwe.
Zmodyfikowane testowanie mieszane
Metoda ta jest połączeniem metody wstępującej i zmodyfikowanej metody zstępującej. Przy zmodyfikowanym testowaniu mieszanym niższe poziomy testujemy w sposób ściśle wstępujący, natomiast moduły poziomów wyższych najpierw testujemy osobno, a następnie scalamy stosując testowanie zstępujące. Takie podejście do modułów poziomów wyższych pozwala ustrzec się wad metody testowania mieszanego.
Testowanie big-bang
Metoda big-bang (wielki wybuch) jest najpospolitszą metodą scalania modułów. Pierwszą jej fazą jest testowanie poszczególnych modułów w izolacji od pozostałych. Następnie wszystkie moduły scala się naraz i testuje cały program.
Testowanie big-bang ma stosunkowo wiele wad i niewiele zalet. Konieczne jest napisanie modułów sterujących i namiastek każdego modułu. Scalanie odbywa się w późnym stadium testowania, więc błędy pośrednictw międzymodułowych pozostają długo nie wykryte. Jednoczesne scalenie wszystkich modułów może sprawić dość poważne kłopoty z wykryciem winnego, w przypadku nagłego wystąpienia błędu.
Metoda big-bang może jednak okazać się przydatna w przypadku testowania niewielkich i dobrze zaprojektowanych programów.
Uwagi końcowe o przebiegu procesu testowania
W wyniku testowania często dokonuje się w programie zmian wynikających z wykrytych błędów. Zmiany te mogą, niestety, powodować pojawienie się kolejnych, nowych błędów. Konieczne jest zatem ponowne testowanie, nazywane testowaniem redundantnym lub testowaniem powrotnym. Do jego przeprowadzenia stosuje się zestawy danych testowych wykorzystywane podczas testowania pierwotnego. Należy zwrócić uwagę na możliwość rozpoczynania testowania powrotnego od bardziej złożonych zestawów danych i cofania się do testów prostszych dopiero w przypadku, gdy te testy zawiodą.
Testowanie należy powtarzać po każdej zmianie tekstu programu.
Dane testowe
Właściwy wybór danych wykorzystywanych do testowania programu może w znacznym stopniu ułatwić wykrywanie błędów. Dobry test pozwala czasem ustalić także ich źródło.
Pierwsze zestawy testowe powinny być jak najprostsze. Początkowo chodzi tylko o to, by stwierdzić, czy program w ogóle pracuje. W ten sposób można zweryfikować jego podstawową organizację. Kolejne testy powinny przebiegać dla danych coraz bardziej złożonych. Stopniowe testowanie ułatwia precyzyjne wyśledzenie błędu, gdy wyniki są niepoprawne.
Kategorie danych testowych
Dane testowe można ze względu na ich pochodzenie podzielić na kategorie:
dane spreparowane,
dane faktyczne z odpowiednimi modyfikacjami,
dane faktyczne w odpowiedniej ilości.
Spreparowane dane testowe to dane dobrane w celu przetestowania programu. Mogą być sterowane lub losowe.
Dane sterowane pomagają w sprawdzeniu sytuacji, które mogłyby się zdarzać rzadko. Ich zaletą jest także to, że odpowiednio dobrane minimalizują pracę konieczną do ręcznego sprawdzenia wyników testowania. Za wadę sterowanych danych można uznać fakt, że programista dobiera tylko takie dane, które według jego subiektywnego poglądu mogą stwarzać problemy.
Dane losowe tworzy się na ogół za pomocą specjalnych programów. Do zalet tego typu testów można zaliczyć możliwość wykrycia błędów, które bez ich stosowania nie byłyby zauważone. Jest to związane z tym, że programista nie ma bezpośredniego wpływu na dobór tych danych. Podstawową wadę danych losowych stanowi trudność w ręcznym sporządzaniu wyników służących do weryfikacji poprawności działania programu.
Zmodyfikowane dane faktyczne wnoszą element realności do procesu testowania. Selektywne modyfikacje rzeczywistych danych dają możliwość tworzenia specyficznych testów. W tym przypadku istnieje także możliwość zauważenia problemów, których nie pokażą dane spreparowane. Rozmyślne wprowadzanie błędów w tak przygotowanych testach pozwala sprawdzić jakość fragmentów programu odpowiedzialnych za kontrolę danych.
Dane rzeczywiste testują właściwą pracę programu w warunkach, w jakich ma być on wykorzystywany. Niestety, istnieje tu problem oceny poprawności generowanych wyników. Niewątpliwą zaletą jest możliwość ujawnienia nieporozumień, jakie mogły zdarzyć się między użytkownikiem i programistą w trakcie uzgadniania specyfikacji programu, nie wykrytych podczas testowania specyfikacji.
Przy stosowaniu wszystkich rodzajów danych testowych należy zwracać szczególną uwagę na poprawne wyznaczanie oczekiwanych wyników dla każdego zestawu danych. Błędy w ręcznym obliczaniu rezultatów mogą spowodować pozorne wykrycie błędu i skierować tok uruchamiania na niewłaściwe tory, zaburzyć lub znacznie wydłużyć ten proces.
Klasy danych testowych
Przetestowanie programu dla wszystkich możliwych zestawów danych nie jest wykonalne. Należy więc podzielić dane na określone klasy. Jako kryterium stosuje się rodzaj przynależności do dziedziny danych. Do różnych klas będą należały dane:
z dziedziny (z uwzględnieniem przypadków szczególnych),
spoza tej dziedziny (z lewej i z prawej strony, lub - jak kto woli - z góry i z dołu),
z krańców dziedziny.
Każdy test powinien reprezentować jedną z wyodrębnionych klas. Jeśli program prawidłowo obsłuży sprawdzane dane z jakiejś klasy, możemy uważać, że podobnie zachowa się w stosunku do pozostałych danych w tej klasie.
Praktyczne zasady dobierania zestawów danych testowych
W trakcie faktycznego testowania programu można wyróżnić trzy fazy:
testowanie sytuacji normalnych,
testowanie sytuacji krańcowych,
testowanie wyjątków.
Celem jest tu upewnienie się, że wszystkie dozwolone dane gwarantują uzyskanie poprawnych wyników, a dane niepoprawne spowodują wyprowadzenie odpowiedniego komunikatu o błędzie lub podjęcie odpowiedniej akcji awaryjnej.
Testowanie sytuacji normalnych odbywa się dla najbardziej powszechnych danych, dla których program był zaplanowany. Ma ono na celu wykazanie, że generowane rezultaty są poprawne.
Testowanie sytuacji krańcowych obejmuje sprawdzenie zachowania się programu na krańcach przedziałów akceptowalności danych. Wykonuje się je wtedy, gdy z wynikiem pozytywnym przetestowano sytuacje normalne. W przypadku danych numerycznych testuje się dolną i górną granicę zbioru wartości dopuszczalnych. Dane nienumeryczne powinny, na przykład, zawierać tylko jedną z wymaganych cech lub wszystkie cechy razem. Należy też sprawdzić zachowanie się programu, gdy danych nie ma lub jest ich bardzo dużo. Na ogół testowanie sytuacji krańcowych pozwala na wykrycie największej liczby błędów.
Testowanie wyjątków stanowi ostatnią fazę sprawdzania poprawności działania programu. Zestawy testowe obejmują dane nie mieszczące się w dopuszczalnym zakresie.
Szczególną uwagę należy zwrócić na możliwość akceptowania przez program błędnych danych i zwracania niepoprawnych wyników (mogących czasem wyglądać prawdopodobnie!). Każda sytuacja, której specyfikacja nie przewiduje, powinna być przez program zauważona i odpowiednio obsłużona (komunikatem lub przyjęciem zastępczej poprawnej wartości).
Środki pomocne w testowaniu
Wśród środków pomocnych w testowaniu szczególne miejsce zajmują programy pomagające zautomatyzować proces testowania. W niektórych systemach oprogramowania mogą znajdować się gotowe programy dostarczające danych do testowania (generatory danych testowych), programy tworzące pliki informacyjne, komparatory plików, programy profilujące, obwody testujące. Jeśli programy tego typu nie są dostępne, warto stworzyć je własnoręcznie. Przy odpowiedniej budowie będą mogły służyć wielokrotnie, zarówno do testowania programów podobnej klasy, jak i programów w ogóle.
Obecnie podstawowym i uniwersalnym narzędziem do testowania, stosowanym w laboratorium, są debuggery wbudowane w zintegrowane środowiska języków programowania firmy Borland lub dostarczane z nimi. Potrafią one z powodzeniem zastąpić wiele wyżej wspomnianych mechanizmów i narzędzi.
Pytania kontrolne
Co to jest testowanie? Jaki jest jego cel, przebieg?
Jaka jest istotna różnica między testowaniem a uruchamianiem?
Podaj zalety i wady testowania względem kodu.
Podaj zalety i wady testowania względem specyfikacji.
Podaj warunki konieczne zakończenia testowania względem kodu.
Na czym polega testowanie wstępujące?
Na czym polega testowanie zstępujące?
Jakie są inne, poza testowaniem wstępującym i zstępującym, metody testowania?
Co to są zaślepki? W jakiej metodzie testowania występują?
Co to są obwody sterujące? W jakiej metodzie testowania występują?
Podaj podział danych testowych ze względu na ich pochodzenie.
Podaj podział danych testowych ze względu na dziedzinę.
Do czego służą diagramy przepływu sterowania (DPS)?
Zadania dla ćwiczących
Zapoznanie się z podstawowymi i bardziej zaawansowanymi mechanizmami diagnostycznymi wbudowanymi w środowisko zintegrowane IDE Borland C lub Pascala:
możliwością pracy krokowej,
pułapkami (również warunkowymi),
zwykłym (watch) i obiektowym (inspect) oknem podglądu,
możliwością modyfikacji wartości zmiennych i kontynuowania przerwanego programu.
Zapoznanie się z mechanizmami automatycznej kontroli poprawności wykonania programu i sygnalizowania błędów wykonania, wbudowanymi w kompilator (sprawdzanie zakresów, przepełnienia stosu, błędów wejścia-wyjścia).
Zapoznanie się ze specyfikacją zewnętrzną i zasadą działania przykładowego programu lub modułu.
Poprawienie sygnalizowanych błędów kompilacji (syntaktycznych i semantycznych) w przykładowym programie lub module.
Przygotowanie systematycznych danych testowych.
Wykonanie testowania przykładowego modułu (modułów) wybraną metodą.
Wykonanie testowania całego programu przykładowego.
Wykrycie i usunięcie błędów wszystkich typów z programu przykładowego.
Zawartość sprawozdania
W sprawozdaniu powinny znaleźć się:
Lista błędów kompilacji programu przykładowego (wyjaśnienie przyczyn błędu, sposób poprawy, ewentualnie omówienie różnych sposobów poprawy danego błędu).
Systematyczne dane testowe, względem kodu lub specyfikacji, zgodnie z instrukcjami prowadzącego wraz z uzasadnieniem doboru danych na podstawie przedstawionych metod lub zasad doboru danych testowych oraz spodziewane wyniki odniesienia.
Opis przebiegu testowania i uruchamiania, obejmujący zestaw danych testowych, efekt uruchomienia programu dla tych danych, hipotezę o błędzie i jego kategorii, propozycję poprawy i efekt testowania powrotnego.
Listing poprawionego programu lub modułu.
Uwagi na temat specyfikacji, ewentualne jej zmiany i uściślenia powstałe w toku uruchamiania.
Omówienie wskazanego przez prowadzącego mechanizmu diagnostycznego, wbudowanego w środowisko IDE i sposobu posługiwania się nim w toku uruchamiania.
Wnioski.
Literatura
G. J. Myers: Projektowanie niezawodnego oprogramowania. WNT, Warszawa 1980.
A. Jaszkiewicz: Inżynieria oprogramowania. Helion, Gliwice 1997.
D. Van Tassel: Praktyka programowania. WNT, Warszawa 1989.
P. Naur: Zarys metod informatyki. WNT, Warszawa 1979.
W. M. Turski: Metodologia programowania. WNT, Warszawa 1982.
rozdział 2
Uruchamianie programów
Uruchamianie programów
Cel ćwiczenia
Opisane w tej instrukcji ćwiczenie ma na celu praktyczne zapoznanie się:
z typowymi systemowymi środkami wspomagającymi uruchamianie programów;
z innymi, uzupełniającymi (indywidualnymi) środkami ułatwiającymi ten proces;
z metodami i techniką uruchamiania programów poprzez poszukiwanie i poprawianie umieszczonych w podanym programie przykładowym błędów.
Istota uruchamiania
W przypadkach gdy program działając nie spełnia sensownych oczekiwań użytkownika, mamy do czynienia z błędami w tym programie. Mówiąc o błędzie można mieć na myśli objawy niepoprawnej pracy programu (błąd wtórny) bądź określone odstępstwo treści programu od postaci zapewniającej poprawne działanie (błąd pierwotny). Obecność w programie błędów pierwotnych ujawnia się w postaci błędów wtórnych, choć - niestety - zaobserwowane błędne zachowanie programu rzadko jednoznacznie wskazuje, z jaką przyczyną (błędem pierwotnym) mamy do czynienia.
Uruchamianie to proces znajdowania i usuwania z programu błędów pierwotnych (w zasadzie tych, które ujawniły się w czasie wykonywania programu). Do uruchamiania przystępujemy zwykle po stwierdzeniu dowodów lub symptomów istnienia błędu. Obecność błędów wykrywana jest zazwyczaj w czasie procesu testowania. Bardziej kłopotliwą sytuacją jest wystąpienie błędu po przekazaniu programu do eksploatacji. Dlatego należy dążyć poprzez właściwe i rzetelne testowanie programu do wykrycia jak największej liczby błędów jeszcze przed przekazaniem programu użytkownikowi. Problemy testowania zostały rozpatrzone w rozdziale pierwszym.
Kategorie i przyczyny błędów
W programach wystąpić mogą różnorakie błędy. Można zaproponować ich podział ze względu na wiele kryteriów: moment ich ujawnienia się, przyczynę itd.
Ze względu na moment ujawnienia się błędy można podzielić na:
Błędy kompilacji, sygnalizowane podczas kompilacji przez kompilator;
Błędy wykonania (ang. run-time errors), sygnalizowane podczas wykonywania programu przez sam program lub system operacyjny. Ich bezpośrednie przyczyny są różne. Błędy są najczęściej powodowane:
usiłowaniem wykonania niedozwolonej operacji arytmetycznej (np. dzielenie przez zero, pierwiastek z liczby ujemnej),
usiłowaniem wykonania niedozwolonej lub bezsensownej w danej chwili operacji wejścia-wyjścia (otwieranie nie istniejącego pliku do czytania, odwołanie do nie istniejącego lub niegotowego urządzenia),
uzyskaniem niedopuszczalnego wyniku (nadmiar arytmetyczny, przekroczenie zakresu typu),
brakiem kontroli danych wejściowych (rozróżnienie danych liczbowych i alfanumerycznych). W niektórych systemach operacyjnych, jak w nie używanym już systemie maszyn ICL 1900/ODRA 1300, błąd wykonania powodowało również przekroczenie przez zadanie przyznanego czasu procesora (np. z powodu zapętlenia programu lub zastosowania nieoptymalnego algorytmu).
Błędne wyniki, nie są one nigdy jawnie sygnalizowane - powinny być dostrzeżone podczas testowania, natomiast jeżeli nie wykonamy testowania rzetelnie, to zapewne ujawnią się podczas eksploatacji programu, co pociąga za sobą poważne następstwa.
Zaobserwowanie błędnej pracy programu rozpoczyna proces jego uruchamiania.
Przyczyna błędu może zaistnieć na określonym etapie pracy nad programem, natomiast symptomy mogą ujawnić się dopiero dużo później. Dlatego w trakcie uruchamiania szukając błędu należy liczyć się z koniecznością sięgnięcia głęboko wstecz. Na kolejnych etapach produkcji programu mogą zaistnieć błędy takie jak:
Błędy spowodowane złym sformułowaniem treści zadania. Wynikają one z niezrozumienia przez programistę potrzeb przyszłego użytkownika programu. Błędnie zostaje wówczas sformułowana specyfikacja zewnętrzna programu. W celu uniknięcia tego typu błędów specyfikacja zewnętrzna powinna być zapisana i przetestowana przed napisaniem programu (por. punkt 2.3.2).
Błędy analizy zadania, polegające najczęściej na wyborze niewłaściwego algorytmu, który rozwiązuje zadanie niewłaściwie lub niedobrze, lub na przeoczeniu pewnych przypadków, na przykład możliwości podania liczby ujemnej, napisu pustego, górnego ograniczenia jakiegoś przedziału mniejszego niż dolne ograniczenie (max < min) itp. Przykładem złego algorytmu może być rozbieżna metoda iteracyjnego rozwiązywania równania, użycie nieodpowiedniego wzoru (np. obliczanie wartości wyróżnika trójmianu kwadratowego ze wzoru: delta := b*b+4*a*c) bądź też algorytm obliczający wyniki poprawnie, ale zbyt wolno (co prowadzi do błędu wykonania - przekroczenia zadanego czasu wykonania). Błąd popełniony podczas analizy ujawni się w tekście programu jako błąd logiczny.
Błędy wynikające z niepoprawnego zakodowania algorytmu, to znaczy niepoprawnego przejścia z abstrakcyjnego opisu ciągu operacji, jakim jest algorytm, na tekst konkretnego programu w konkretnym języku programowania. Jest to kategoria, z którą najczęściej mamy do czynienia i która będzie omówiona później.
Błędy zaokrągleń spowodowane ograniczoną dokładnością reprezentacji liczb rzeczywistych. Występują również przy nie kontrolowanych konwersjach typów.
Błędna postać dokumentacji dla użytkownika, gdzie opis działania programu nie w pełni zgodny jest ze stanem faktycznym. Najczęstszą tego przyczyną jest nieuaktualnienie dokumentacji na bieżąco przy wprowadzaniu zmian w programie. Skutkiem może być na przykład podawanie na wejście programu w dobrej wierze danych o niewłaściwej postaci.
Na etapie kodowania (pisania tekstu programu) programista może popełnić najwięcej błędów. Można tu wspomnieć o takich kategoriach, jak:
Błędy syntaktyczne, czyli nieprzestrzeganie zasad składni języka programowania. Są to błędy bardzo popularne:
brak średnika, nawiasu, innego znaku interpunkcyjnego lub wymaganego słowa kluczowego czy deklaracji,
nieprawidłowy symbol, nawias lub niedozwolony znak w identyfikatorze.
Wykrywanie tych błędów jest jednym z najważniejszych zadań kompilatora, więc objawiają się zawsze jako błędy kompilacji. Muszą one być poprawione jeszcze przed próbą wykonania programu.
Błędy semantyczne są to błędy inne niż składniowe, lecz również wykrywalne przez mechanizmy języka programowania. Błędy te wynikają z niezrozumienia znaczenia zapisu. Semantyka języka to zbiór reguł opisujących znaczenie konstrukcji języka programowania. Błędy semantyczne powoduje niepełna znajomość lub niezrozumienie języka programowania i architektury komputera. Błędami semantycznymi są:
brak deklaracji,
niezgodność typów w wyrażeniu,
przekroczenie zakresu wartości w typie,
niedozwolona wartość argumentu operacji.
Inne błędy, nie należące do powyższych dwu kategorii, niewykrywalne przez mechanizmy języka programowania, zaliczamy do ogólnej kategorii błędów logicznych. Są one również często powodowane przez niedostateczną znajomość języka czy biblioteki. Błędy logiczne w programie prowadzą do błędnych wyników, czasami są również przyczyną błędów wykonania. Przykłady błędów logicznych:
użycie pętli repeat zamiast while;
użycie nieodpowiedniej procedury bibliotecznej;
użycie nieodpowiedniego wzoru;
brak nadania wartości początkowych zmiennym (np. trudne do wykrycia jest niezainicjowanie zmiennych wskaźnikowych, szczególnie po użyciu procedury dispose);
brak inicjalizacji, niewłaściwe indeksowanie lub zakończenie pętli;
zamiana dróg po rozgałęzieniu w programie (if-then-else).
Cały szereg błędów ma charakter przeoczenia lub pomyłki podczas wprowadzania tekstu programu do komputera i jego edycji. Błędy przepisywania mogą wykazywać cechy błędów syntaktycznych, semantycznych lub logicznych.
Testowanie a uruchamianie
Testowanie i uruchamianie są procesami często mylonymi. Być może dlatego, że pod koniec procesu produkcji programu przeplatają się. Istotą różnicy jest to, że testując nie wiemy, czy w programie znajdują się jakieś błędy, natomiast uruchamiając wiemy, że błąd jest.
Po napisaniu całego programu, gdy kompilator dostarczy już kod wynikowy, najczęściej następuje testowanie „na dym”. Dane testowe są często proste. Wykrywamy wtedy pewne grube, oczywiste błędy. Następnie przechodzimy do uruchamiania, kiedy to lokalizujemy błędy, staramy się je zrozumieć i wreszcie je usuwamy. Z kolei powracamy do testowania, przygotowując bardziej finezyjne dane testowe, co pozwala na ujawnienie drobniejszych lub rzadziej występujących (jednak przez to nie mniej ważnych) błędów. Po wykryciu kolejnych błędów powracamy do uruchamiania i w ten sposób testowanie przeplata się z uruchamianiem, przy czym dane testowe i błędy stają się coraz bardziej wyrafinowane.
Niektóre symptomy błędów i najczęstsze powody ich występowania
W ramach testowania wykonujemy program. Efekty mogą być różne. Jeżeli program nie działa poprawnie, to objawy mogą być następujące:
Program wykonuje się, lecz nie daje żadnych wyników. Sytuacja taka może wskazywać na istnienie pewnego błędu logicznego, np. skok do końca programu przed wyprowadzeniem wyników.
Program wykonuje się, ale kończy działanie sygnalizując pewien błąd wykonania (dzielenie przez zero, niepoprawne indeksy w tablicy, wyjście poza zakres wartości danego typu, błąd operacji wejścia-wyjścia, nadmiar lub niedomiar numeryczny itp.).
Program kończy działanie nie sygnalizując błędów wykonania, ale wyprowadza błędne wyniki. Jest to najczęstszy objaw istnienia błędu lub błędów różnego typu w programie. Należy wtedy zwrócić uwagę, czy kompilator generuje specjalny kod sprawdzający zakresy, przepełnienia itp. i czy sygnalizuje odpowiedni błąd wykonania. Jeżeli opcje kompilatora, kontrolujące automatyczne generowanie takiego kodu sprawdzającego, nie były włączone, należy je włączyć, a następnie kontynuować uruchamianie.
Program nie kończy działania i brak jest postępów wykonania. Oznacza to zazwyczaj, że program wykonuje nieskończoną pętlę - jak mówimy, zawiesza się.
Metody znajdowania błędów
Istnieje wiele różnych metod poszukiwania błędów w programie i technik do tego wykorzystywanych. Najczęściej stosuje się kombinację kilku z podanych niżej.
Metoda badania symptomów błędu (Browna i Sampsona) pozwala na zbadanie sprzeczności w wynikach. Metoda ta pomocna jest przy formułowaniu dokładniejszej hipotezy o błędzie lub przy ustalaniu istnienia więcej niż jednego błędu. Dane o błędzie wpisuje się do specjalnego formularza:
? Jest |
Jest |
Nie ma |
Co |
|
|
Gdzie |
|
|
Kiedy |
|
|
W jakim stopniu |
|
|
W rubryce Co opisuje się podstawowe symptomy błędu. W rubryce Gdzie precyzuje się, gdzie je zauważono Na początkowym etapie może tu chodzić o umiejscowienie błędu w wynikach. Później, w miarę rozwijania i pogłębiania hipotezy na temat umiejscowienia błędu można tu wpisywać obszar kodu programu, w którym znajduje się domniemana przyczyna. Rubryka Kiedy zawiera wskazówki mówiące o tym, kiedy symptomy występują, a kiedy nie, natomiast w rubryce W jakim stopniu wyznacza się zasięg symptomów. Porównanie zawartości kolumn Jest i Nie ma stanowi sedno tej metody, ponieważ ukazują one różnice i sprzeczności, na podstawie których zbuduje się hipotezę tłumaczącą przyczyny błędu.
Czasami niezastąpione jest czytanie i analizowanie napisanego programu. Dokładne przejrzenie sformułowania zadania, dokumentacji projektowej, algorytmu i samego programu zaraz po wprowadzeniu go do komputera pozwala znaleźć znaczny odsetek błędów. Analiza kodu programu jest nieunikniona po stwierdzeniu wystąpienia błędu. Problem stanowi ograniczenie objętości fragmentu programu, który trzeba będzie badać. Zastosowanie wymienionych dalej metod ułatwia rozwiązanie tego problemu.
Programowanie defensywne jest praktyką pisania programów w taki sposób, aby pojawienie się błędu było wcześnie wykryte i zasygnalizowane, i aby nie dopuścić do dalszego przetwarzania danych niepoprawnych w wybranych punktach programu, a w szczególności na początku przetwarzania w obrębie poszczególnych modułów. Sprawdza się postać, zakresy i długość danych wejściowych, sensowność wartości zmiennych, wartości różnorodnych sum kontrolnych, liczników transakcji itp., i natychmiast sygnalizuje wykryte błędy. Programowanie defensywne wydatnie ułatwia wykrycie, lokalizację i zrozumienie istoty błędów.
Do głównych zasad programowania defensywnego należy:
wzajemna nieufność - każdy moduł zakłada, że wszelkie otrzymywane przez niego dane mogą być niepoprawne, trzeba je więc sprawdzić,
bezzwłoczne wykrywanie błędów - najlepiej wykryć błąd możliwie najwcześniej, ponieważ ułatwia to zlokalizowanie jego źródła,
izolowanie błędów - stosuje się w tym celu w niektórych częściach systemu „grodzie”, dzięki którym błąd nie może spowodować szkód w innych częściach systemu; po prostu dane błędne nie są dalej przekazywane.
Istnieją środki techniczne lub metody stosowane podczas uruchamiania pozwalające na zgromadzenie informacji niezbędnej w celu lokalizacji błędu i rozpoznania jego przyczyn.
Dostępnym dla każdego środkiem jest umieszczanie w programie wydruków kontrolnych zawierających ślad wykonania programu, dalej - tzw. echo-test danych wejściowych, czy też informację, jak przebiegają bieżące obliczenia. Instrukcje generujące takie wydruki mogą wyglądać następująco:
begin
writeln ('Początek procedury sortuj');
… {ciało procedury }
writeln ('Koniec procedury sortuj');
end;
lub:
writeln ('Podano x = ', x);
writeln ('Podano c: ', c, ' (kod: ', ord(c), ')');
lub też:
writeln ('Wykonanie pętli dla i = ', i, ' j = ', j);
Najlepiej jest umieszczać wydruki kontrolne już na etapie pisania określonego fragmentu programu, kiedy programista może je rozmieścić w sposób bardziej przemyślany, niż później - dopiero podczas uruchamiania całego programu.
Przy lokalizowaniu błędu przydatne bywają wydruki generowane automatycznie, do których należą między innymi: komunikaty systemowe, ślad wykonania programu i zrzut zawartości pamięci. Obecnie na laboratorium (jak również szerzej) rzadko stykamy się z systemami generującymi takie informacje, pozostaje więc samodzielne generowanie potrzebnych informacji w sposób podany wyżej. Wydruki kontrolne wprowadzane przez samego programistę na etapie projektowania programu są zazwyczaj bardziej pomocne i efektywne niż wydruki osiągalne przy użyciu systemowych środków śladu automatycznego. Otrzymujemy wówczas mniejszą porcję danych wyjściowych, które można lepiej wykorzystać w procesie poszukiwania błędów.
Uruchamianie interakcyjne jest procesem wykonywania programu pod kontrolą innego specjalnego programu (debuggera) umożliwiającego zwykle:
ustawianie pułapki w dowolnym miejscu programu i zamrożenie jego stanu,
zatrzymanie wykonania programu po wystąpieniu błędu,
zbadanie i modyfikację wartości dowolnej zmiennej po zatrzymaniu programu,
modyfikację treści programu wynikowego (niekiedy także źródłowego),
wznowienie wykonania programu od dowolnej instrukcji.
Sposób postępowania przy uruchamianiu
Uruchamianie rozpoczyna się od stwierdzenia istnienia pewnych symptomów błędu i polega na umiejscowieniu błędu i na jego poprawieniu. Proces ten można ogólnie podzielić na kilka kroków:
Zrozumienie problemu, czyli dokładne zbadanie tego, co program zrobił poprawnie, i tego, co zrobił niepoprawnie, a następnie postawienie jednej lub więcej hipotez na temat natury błędu. Należy określić, czy otrzymane dane wynikowe pozwalają umiejscowić błąd. Jeśli nie, to trzeba napisać i wykonać dodatkowe testy. Ważne przy tym jest ustalenie danych wejściowych prowadzących do wystąpienia błędu (może być potrzebna rejestracja tych danych, gdy dane nie są powtarzalne lub reakcje programu są przypadkowe). Pożyteczną metodą przy lokalizacji błędu jest zbadanie występujących sprzeczności w wynikach, z wykorzystaniem omówionego wyżej sposobu analizy symptomów błędu.
Przemyślenie planu działania. Krok ten polega na zbudowaniu hipotezy tłumaczącej przyczyny błędu i obmyśleniu planu postępowania sprawdzającego słuszność tej hipotezy.
Realizacja planu, mającego na celu zbadanie słuszności hipotezy. Polega to głównie na napisaniu i wykonaniu odpowiednich testów.
Przemyślenie i ewentualna weryfikacja hipotezy. Jeżeli ustaliliśmy już domniemany błąd pierwotny, to przed próbą poprawienia trzeba przeanalizować ten błąd i upewnić się, czy rzeczywiście on jest powodem zauważonych symptomów. Przyczyna błędu może nie leżeć bezpośrednio w instrukcji, w której ujawnił się symptom, a mieć związek z wcześniejszymi działaniami programu (np. błąd dzielenia przez zero w danym wyrażeniu może być spowodowany przez wcześniejsze błędne przypisanie wartości zmiennej). Konieczne jest również sprawdzenie, czy poprawienie tego błędu nie spowoduje wystąpienia nowego błędu. Główną przyczyną trudności w uruchamianiu jest nastawienie psychiczne programisty, w wyniku którego widzi on to, co chce widzieć, nie zaś to, co jest w rzeczywistości. Jednym ze sposobów walki z tą postawą jest sceptycyzm wobec wszystkiego, co jest badane, a zwłaszcza wobec komentarzy i dokumentacji programu, oraz wcześniejsze określenie przewidywanych wyników testów programu.
Dokonanie poprawki w programie. Po precyzyjnym umiejscowieniu błędu przystępujemy do jego poprawienia. Nie należy ograniczać się tylko do likwidacji symptomów błędu, jak również należy unikać poprawek nieprzemyślanych, eksperymentalnych. Poprawek powinno się dokonywać z co najmniej taką systematycznością, z jaką sporządziliśmy projekt. Poprawki wnosimy w tekst źródłowy programu, po czym kompilujemy i linkujemy program. W większości środowisk programisty istnieją środki umożliwiające automatyczną kompilację wszystkich modułów zależnych od właśnie zmienionego. Należy pamiętać, że w pewnych szczególnych przypadkach bywają one zawodne; co za tym idzie, rozsądne bywa przekompilowanie całego projektu.
Sprawdzenie skuteczności poprawy. Jest to ostatni, bardzo ważny krok. Wiąże się on z wykonaniem całego programu lub odpowiedniego fragmentu.
Środki pomocne w uruchamianiu
Niemal zawsze programista ma dostęp do różnych środków wspomagających uruchamianie programów zapisanych w określonym języku. Stanowią one część środowiska danego języka programowania lub wchodzą w skład oddzielnych pakietów programowych.
Do środków ułatwiających uruchamianie programów należą:
debuggery,
mechanizmy nadzorujące poprawność wykonania programu lub poszczególnych operacji,
mechanizmy do generacji i interpretacji informacji diagnostycznej o przebiegu wykonania programu.
Debugger to program kontrolujący wykonanie innych programów; pozwala on na uruchamianie tych programów w sposób interakcyjny, śledzenie i gromadzenie różnorodnych informacji diagnostycznych. Typowy debugger umożliwia rozpoczęcie pracy programu, wstrzymywanie jej w wybranym punkcie lub pod określonym warunkiem, kontrolę i ewentualnie zmianę zawartości zmiennych oraz wznawianie obliczeń. Debugger języka wysokiego poziomu powinien mieć możliwość pokazania tekstu źródłowego uruchamianego programu z wyróżnioną instrukcją właśnie wykonywaną oraz zgodnego ze składnią i semantyką języka wyświetlania symbolicznej nazwy zmiennej lub wyrażenia wraz z jej aktualną wartością w odpowiedniej, czytelnej reprezentacji.
Mechanizmy nadzorujące poprawność wykonania programu wprowadza się do kodu programu na czas jego uruchamiania. Znaczną ich część usuwa się zwykle przed wytworzeniem wersji ostatecznej, przetestowanej, przeznaczonej do dystrybucji i eksploatacji. Ten dodatkowy kod nie realizuje bezpośredniego zadania programu, a jedynie kontroluje poprawność wykonania podstawowego kodu. Po jego usunięciu, a przed ostatecznym skierowaniem programu do użytkowania, należy przeprowadzić dodatkowe, końcowe testowanie, gdyż usunięcie dodatkowego kodu czasami może powodować uaktywnienie się błędów, które nie ujawniły się wcześniej.
Duże znaczenie ma kontrola indeksów tablic i zakresów zmiennych, która polega na sprawdzeniu, czy wartości poszczególnych indeksów mieszczą się w zadeklarowanym zakresie tablic, oraz czy wartości zmiennych mieszczą się w dopuszczalnych zakresach (np. typy okrojone w Pascalu). Najczęściej instrukcje realizujące tę operację wstawiane są automatycznie przez kompilator (w wyniku użycia odpowiedniej dyrektywy lub opcji kompilatora). Kompilatory dostarczają również standardowych procedur obsługi wykrytych w ten sposób błędów wykonania. Polegają one prawie zawsze na wypisaniu komunikatu informującego o błędzie wykonania i zakończeniu działania programu. Istnieje możliwość zdefiniowania własnych procedur obsługi poszczególnych typów błędów wykonania.
W wersji programu przeznaczonej do testowania i uruchamiania należy włączyć wszystkie mechanizmy automatycznego wykrywania błędów. Zupełnie niewłaściwe jest przekonanie, że wystarczy je wyłączyć, a błąd przestanie występować (czyli - jakby został usunięty). W takim przypadku błąd przestanie jedynie być sygnalizowany, ale program dalej będzie działał błędnie, na przykład zwiększany jednobajtowy licznik po stanie 255 przyjmie stan 0, zmienne dynamiczne będą używać wspólnego lub w ogóle nie przydzielonego obszaru pamięci, wyniki będą kierowane do pliku, który nie został utworzony i tak dalej. Wcześniej czy później, w takim programie lub jego wynikach da się zauważyć błąd, z tym, że będzie go o wiele trudniej zlokalizować.
Informacja diagnostyczna może być generowana ręcznie lub automatycznie. Istnieją narzędzia pozwalające na automatyczne gromadzenie informacji o wykonaniu uruchamianego programu, a następnie na ich interpretację. Do najczęściej występujących rodzajów informacji diagnostycznych należą:
Zrzut, który jest zapisem informacji o stanie programu w danym momencie czasu. Programując w języku wyższego poziomu korzystamy zwykle ze zrzutu symbolicznego (zapisu danych: nazwy w postaci symbolicznej, a wartości w reprezentacji odpowiedniej dla danego typu danej), a gdy nie jest on dostępny - ze zrzutu binarnego (zapisu danych w postaci ciągu liczb binarnych lub szesnastkowych). Jednym z typów zrzutu jest tzw. wydruk post-mortem, obejmujący wartości, jakie miały wszystkie zmienne w chwili zakończenia programu (zwykle w związku z błędem).
Ślad sterowania, który stanowi zapis drogi wykonania programu. Stosuje się go do sprawdzenia, czy instrukcje programu wykonują się w takiej kolejności, jak było przewidziane. Najczęściej drukowane są nazwy kolejno wywoływanych procedur lub funkcji.
Ślad zmiennych, polegający na kontrolowaniu i drukowaniu wartości kolejno przyjmowanych przez wybrane zmienne.
W przypadku braku programów pomocniczych do automatycznej generacji informacji diagnostycznej oraz debuggera możemy do programu jawnie wprowadzić instrukcje dodatkowe (na zasadzie takiej, jak w przypadku mechanizmów nadzoru), służące do wyprowadzania interesującej nas informacji diagnostycznej. Wygodne jest stosowanie instrukcji pokazu pozwalającej na wydrukowanie wartości zmiennej w ściśle określonym miejscu programu. Drukowana jest zwykle nazwa zmiennej wraz z jej wartością.
Program przykładowy
Poniżej prezentujemy krótki program zawierający szereg błędów, w tym zarówno błędy wykrywane przez kompilator, jak i błędy logiczne. Przedstawimy przykładowy przebieg uruchamiania tego programu.
Program ma za zadanie przeczytać z pliku o nazwie podanej jako parametr wywołania ciąg wyrazów i wypisać go na ekran po posortowaniu alfabetycznym. W każdej linii pliku wejściowego znajduje się jeden wyraz, którego długość nie przekracza 20 znaków. Ilość linii nie jest określona.
Program umieszcza przeczytane wyrazy w posortowanym drzewie binarnym zgodnie z regułą, że węzły o kluczu mniejszym znajdują się w lewym poddrzewie, a pozostałe w prawym. Kluczem węzła jest wyraz. Wyrazy są przechowywane bez straty pamięci. Drzewo budowane jest przez rekurencyjną funkcję wpisz. Drzewo jest wypisywane na wyjściu przez funkcję wypisz w naturalny, rekurencyjny sposób, tzn.: najpierw lewe poddrzewo, później dany węzeł i prawe poddrzewo, co daje rosnący porządek wyrazów na wyjściu.
Oto wyjściowa postać programu:
#include <fstream.h>
struct elem
{ char *s;
int n;
elem *lewy, *prawy;
} korzen
typedef elem * wsk;
void wpisz( char *s, elem **akt, int n ) // elem* przekazany
// "przez zmienną"
{
elem *p= *akt;
if (!p)
{
int n;
p = malloc( elem );
p->lewy = nil;
p->prwy = nil;
(*p).s = s;
p->n = n;
}
else
{
if ( p->s > s )
{
wpisz( s, &p->prawy, n++ );
}
else
if ( p->s != s ) )
{
wpisz( s, &p->lewy, n++ )
}
}
}
void wypisz( wsk p )
{
wypisz( p->lewy );
if (!p)
{
cout << p->numer << ": ";
wypisz( p->s );
}
wypisz( p->prawy );
}
void main (int argc, char *argv[])
{
int numer;
if (argc<1) return;
ifstream fin( argv[1] );
if (!fin) return;
char buf[2];
while (fin >> buf)
wpisz( buf, &korzen, numer );
wypisz( &korzen );
}
W pierwszej kolejności rozpatrzymy błędy wykrywane przez kompilator BC++ 3.1. Błędy pokazane są tutaj w celu praktycznej ilustracji wiadomości z poprzednich podrozdziałów. W praktyce większość błędów sygnalizowanych przez kompilator (jako że kompilatory języka C nie przerywają kompilacji po napotkaniu pierwszego błędu) jest spowodowana błędami występującymi w kodzie wcześniej. W związku z tym po poprawieniu pierwszych kilku sensownie jest skompilować tekst powtórnie. Przytoczone i omówione niżej błędy zostały otrzymane w ramach kilku kolejnych kompilacji, więc Czytelnik, samodzielnie wykonując ćwiczenie, może nie otrzymać niektórych komunikatów lub otrzymać inne. Celem naszym było jednak nie ścisłe prześledzenie procesu usuwania błędów z poniższego programu, lecz pod takim pretekstem pokazanie błędów, komunikatów i problemów, które najczęściej zdarzają się programistom języka C.
Linia: 7
Komunikat: Declaration syntax error
Analiza: Ogólny błąd składni jest sygnalizowany w wielu przypadkach. Najczęstszym i najbanalniejszym z nich jest brak jakiegoś znaku, np. ; , lub {}. W przypadkach prostych do rozpoznania kompilator sygnalizuje bardziej konkretnie “; expected” lub “) expected”. Niniejszy komunikat spowodowany jest brakiem średnika zamykającego deklarację struktury i zmiennej korzen.
Po poprawie:
Linia 6: korzen;
Linia: 14
Komunikat: Function 'malloc' should have a prototype
Analiza: Komunikat oznacza, że użyto pewnego identyfikatora w kontekście wskazującym na użycie funkcji, ale funkcja ta nie została jeszcze zadeklarowana (nie napotkano na jej prototyp). Jeżeli jest to funkcja napisana przez programistę, jej prototyp powinien znaleźć się powyżej pierwszego użycia, jeżeli jest to funkcja biblioteczna (a tak jest w tym przypadku), należy na początku pliku w dyrektywie #include włączyć plik nagłówkowy zawierający deklarację tej funkcji. Funkcja malloc zadeklarowana jest w pliku nagłówkowym alloc.h i stdlib.h
Po poprawie:
Linia 2 (wstawiona): #include <alloc.h>
Linia: 15
Komunikat: Undefined symbol 'nil'
Analiza: Komunikat oznacza, że ten identyfikator nie jest znany kompilatorowi. Najczęściej chodzi o brak deklaracji tego obiektu lub o pomyłkę literową. (W języku C wielkie i małe litery są rozróżniane - to również może być powodem.) W tej linii słowo kluczowe nil języka Pascal (oznaczające puste wskazanie) zostało pomyłkowo użyte zamiast stałej NULL używanej w C.
Po poprawie:
Linia 15: p->lewy = NULL;
Linia: 16
Komunikat: 'prwy' is not a member of 'elem'
Analiza: Identyfikator prwy został użyty w kontekście odwołania do składowej struktury elem, jednak w tej strukturze nie ma takiego pola. Jest to prosty błąd literowy.
Po poprawie:
Linia 16: p->prawy = NULL;
Linia: 27
Komunikat: Expression syntax
Analiza: Ogólny błąd składni wyrażeń. Najczęściej spowodowany jest błędnym położeniem lub ilością nawiasów, sąsiedztwem dwu operatorów lub brakiem średnika kończącego poprzednią konstrukcję. W tym przypadku chodzi o powód pierwszy - o jeden nawias zamykający za dużo.
Po poprawie:
Linia 27: if ( p->s != s )
Linia: 31
Komunikat: Statement missing ;
Analiza: Choć treść komunikatu oznacza najbanalniejszy błąd braku średnika zamykającego instrukcję, w tym przypadku tak nie jest. Jak wspomnieliśmy wyżej, wiele komunikatów błędów może być powodowanych błędami znajdującymi się powyżej. Komunikaty takie często mogą również być mylące. Uzupełnianie średnika w linii wskazywanej przez kompilator nic nie da. Dopiero usunięcie nadmiarowego nawiasu (co omówiono powyżej) i uzupełnienie średnika w linii 29 jest prawidłowym rozwiązaniem.
Linia: 33
Komunikaty: Size of 'wypisz' is unknown or zero
Undefined symbol 'wsk'
) expected
Analiza: Wszystkie te (mylące) komunikaty są spowodowane niezinterpretowaniem definicji typu wsk w linii 7 z powodu błędu w linii wyżej. W przypadku zupełnie niezrozumiałych komunikatów błędów, a szczególnie, gdy wiele dotyczy tej samej linii, należy przede wszystkim zająć się poprawieniem błędów poprzednich. Tu po poprawnym przetworzeniu linii 7 błędy te już nie wystąpią.
Inny przykład nieprawidłowych komunikatów:
Linia: 53 (ostatnia):
Declaration syntax error
Declaration missing ;
Compound statement missing }
Linia: 14
Komunikat: Improper use of typedef 'elem'
Analiza: Ten błąd jest najczęściej spowodowany użyciem identyfikatora typu w miejscu, gdzie należy użyć identyfikatora zmiennej. Sprawdzając prototyp funkcji malloc widzimy, że pobiera ona jeden parametr całkowitego typu size_t. Tu należy sięgnąć do semantyki, aby stwierdzić, że parametr ten oznacza rozmiar dynamicznie alokowanego obszaru, a instrukcja w linii 14 ma za zadanie utworzyć kolejną strukturę typu elem, więc należy jedynie dodać operator sizeof, pobierający rozmiar zmiennej podanego typu w bajtach.
Po następnej kompilacji w tej samej linii otrzymamy kolejny błąd:
Komunikat: Cannot convert 'void *' to 'elem *'
Analiza: Język C wymaga jawnego rzutowania typu zwracanego przez funkcję malloc (czyli void *) na odpowiedni typ wskaźnikowy.
Po poprawie:
Linia 14: p = (elem *) malloc( sizeof(elem) )
Nie zawsze na wymieniony komunikat (Cannot convert...) należy reagować mechanicznie. W linii 39 zastosowane jest kolejne wywołanie rekurencyjne funkcji wypisz, jednak po krótkim zastanowieniu widać, że funkcja ta powinna być wywołana rekurencyjnie dla lewego i prawego poddrzewa, natomiast napis p->s powinien być zwyczajnie wyprowadzony na wyjście. Poprawna funkcja wypisz znajduje się na wydruku poprawionej wersji programu.
Po poprawieniu większości prostych błędów syntaktycznych należy zająć się błędami, które wymagają poprawek w wielu liniach. Spójrzmy na linie zawierające odwołanie do zmiennej korzeń:
6: } korzen
8: void wpisz( char *s, elem **akt, int n ) // elem* przekazany
// "przez zmienną"
51: wpisz( buf, &korzen, numer );
W linii 51 sygnalizowany jest błąd niezgodności typów, jednak nie sposób podwójnie pobrać adresu zmiennej korzen (&&korzen), aby “dopasować” ją do wymagań funkcji. Pochopne utworzenie dodatkowej zmiennej typu elem* (której adres można pobrać i będzie on typu elem **) również nie jest rozwiązaniem słusznym. Należy bowiem raczej zmienić typ zmiennej korzen w linii 6 - pełni ona rolę korzenia drzewa, a taka zmienna zawsze jest typu “wskaźnik do węzła”. Spowoduje to również konieczność usunięcia operatora & w linii 52. W myśl zasad bezpiecznego programowania można wskaźnikowi nadać wartość NULL, mimo że C zapewnia zerowanie zmiennych statycznych.
Po usunięciu błędów kompilacji otrzymaliśmy program pozwalający się uruchomić i generujący pewne wyniki. Można było przystąpić do testowania. W wyniku testowania ustaliliśmy, że program zawiera dalsze błędy nie wykrywane przez kompilator - błędy logiczne. Poprawienie takich błędów wymaga już znajomości logiki konkretnego programu, podczas gdy dotąd bazowaliśmy na wiadomościach ogólnych o języku i komunikatach kompilatora.
W liniach 17, 22 i 27 znajdują się błędy semantyczne, polegające na błędnym posługiwaniu się napisami (char *). Błędy takie są charakterystyczne dla początkujących programistów w C, znających już język Pascal, a są szczególnie niebezpieczne, ponieważ nie są w ogóle sygnalizowane przez kompilator. W C operując na napisach powinniśmy posługiwać się tylko funkcjami z biblioteki string.h. Linie:
17: (*p).s = s;
22: if ( p->s > s )
są poprawne w C, ale oznaczają odpowiednio przypisanie i porównanie wskaźników do napisów, a nie samych napisów. Odpowiednie wywołania funkcji powinny wyglądać tak:
17: strcpy((*p).s,s);
22: if (strcmp( p->s, s ) > 0 )
Ta pierwsza instrukcja jest poprawna, jeżeli istnieje już odpowiednio duży obszar przydzielony wskaźnikowi s. Jeżeli nie (a tak jest w naszym programie przykładowym), należy najpierw przydzielić pamięć:
17: (*p).s = (char *) malloc( strlen(s)+1 );
strcpy((*p).s,s);
Zwracamy uwagę na parametr funkcji malloc. Funkcja strlen zwraca długość napisu bez kończącego go w pamięci bajtu `\0', natomiast przydzielając pamięć musimy go również wziąć pod uwagę. Zapominanie o tym jest kolejnym częstym i niebezpiecznym błędem w posługiwaniu się napisami w C. W linii 17 można było również zastosować funkcję, która tworzy nową kopię napisu i zwraca wskaźnik do przydzielonego obszaru:
17: (*p).s = strdup( s );
W pierwszej części funkcji wpisz znajdują się jeszcze dwa błędy logiczne. Po pierwsze, w linii 11 znajduje się warunek (!p), natomiast powinno być (p). Po drugie, w linii 14 nadajemy nową wartość zmiennej tymczasowej p i na niej operujemy, podczas gdy powinniśmy wykonywać operacje na przekazanym przez wskaźnik polu *akt. Należy w liniach 14-19 zamienić odwołania do zmiennej p wyrażeniami (*akt). We frazie else posługiwanie się zmienną p jest poprawne, ponieważ nie nadajemy jej nowej wartości, a jedynie korzystamy z wartości zainicjowanej wyżej.
Testując program można stwierdzić, że ewentualne powtórzenia wyrazów ma wejściu są ignorowane. Nie jest to właściwe w przypadku programu sortującego. W drugiej części funkcji wpisz znajdują się instrukcje warunkowe, powodujące dopisanie do drzewa wyrazów większych oraz mniejszych od danego, natomiast pozostałe (w tym przypadku równe) są ignorowane. Należy dopisać wywołanie rekurencyjne wpisujące również powtarzające się wyrazy. Podobnie zauważamy, że wyrazy są sortowane malejąco, a nie rosnąco. Należy zmienić znaki nierówności w tej funkcji.
Wątpliwości może budzić istnienie i użycie zmiennej numer i pola n. Kompilator nie sygnalizuje żadnych błędów, ponieważ wszystkie konstrukcje z użyciem tych obiektów są składniowo i semantycznie poprawne, lecz nie są sensowne. Intencją było, aby razem z wyrazem wyprowadzać numer linii, w której wystąpił w pliku wejściowym. Tak więc należy zwiększać go w programie głównym, a nie modyfikować przy każdym wywołaniu rekurencyjnym funkcji wpisz.
Nie tyle błędem, co niekonsekwencją jest niestosowanie zdefiniowanego w linii 7 typu wsk.
Oto postać programu po usunięciu wszystkich błędów:
#include <fstream.h>
#include <alloc.h>
#include <string.h>
struct elem
{ char *s;
int n;
elem *lewy, *prawy;
} *korzen = NULL;
typedef elem * wsk;
void wpisz( char *s, wsk *akt, int n )
{
wsk p = *akt;
if (!p)
{
*akt = (elem *) malloc( sizeof(elem) );
(*akt)->lewy = NULL;
(*akt)->prawy = NULL;
(*akt)->s = (char *) malloc (strlen(s)+1);
strcpy( (*akt)->s, s );
(*akt)->n = n;
}
else
{
if (strcmp( p->s, s ) <= 0 )
{
wpisz( s, &p->prawy, n );
}
else
{
wpisz( s, &p->lewy, n );
}
}
}
void wypisz( wsk p )
{
if (p)
{
wypisz( p->lewy );
cout << p->s << " [" << p->n << ′]′ << endl;
wypisz( p->prawy );
}
}
void main (int argc, char *argv[])
{
int numer=1;
if (argc<2) return;
ifstream fin( argv[1] );
if (!fin) return;
char buf[20];
while (!fin.eof() && fin >> buf)
wpisz( buf, &korzen, numer++ );
wypisz( korzen );
}
Pytania kontrolne
Co to jest uruchamianie, z jakich etapów się składa? Kiedy do niego przystępujemy?
Co to jest błąd pierwotny, a co wtórny?
Jaka jest istotna różnica między testowaniem a uruchamianiem?
Podaj podział błędów, ze względu na moment pojawienia się. Wyjaśnij każdą kategorię.
Podaj podział błędów popełnianych na kolejnych etapach tworzenia oprogramowania.
Podaj podział błędów, jakie występują w kodzie źródłowym programu. Wyjaśnij i podaj przykłady każdej kategorii.
Na czym polega programowanie defensywne?
Podaj mechanizmy i środki programowe wspomagające uruchamianie.
Podaj możliwości debuggera. W jaki sposób ułatwiają one uruchamianie?
Zadania dla ćwiczących
Zapoznanie się z mechanizmami automatycznej kontroli poprawności wykonania programu i sygnalizowania błędów wykonania, wbudowanymi w kompilator (sprawdzanie zakresów, przepełnienia stosu, błędów wejścia-wyjścia).
Zapoznanie się ze specyfikacją zewnętrzną i zasadą działania przykładowego programu lub modułu.
Poprawienie sygnalizowanych błędów kompilacji (syntaktycznych i semantycznych) w przykładowym programie lub module.
Przygotowanie systematycznych danych testowych.
Wykonanie testowania przykładowego modułu (modułów) wybraną metodą.
Wykonanie testowania całego programu przykładowego.
Wykrycie i usunięcie błędów wszystkich typów z programu przykładowego.
Zawartość sprawozdania
W sprawozdaniu powinny się znaleźć:
Lista błędów kompilacji programu przykładowego (wyjaśnienie przyczyn błędu, sposób poprawy, ewentualnie omówienie różnych sposobów poprawy danego błędu).
Systematyczne dane testowe, względem kodu lub specyfikacji, zgodnie z instrukcjami prowadzącego wraz z uzasadnieniem doboru danych na podstawie przedstawionych metod lub zasad doboru danych testowych oraz spodziewane wyniki odniesienia.
Opis przebiegu testowania i uruchamiania, obejmujący zestaw danych testowych, efekt uruchomienia programu dla tych danych, hipotezę o błędzie i jego kategorii, propozycję poprawy i efekt testowania powrotnego.
Listing poprawionego programu lub modułu.
Uwagi na temat specyfikacji, ewentualne jej zmiany i uściślenia powstałe w toku uruchamiania.
Omówienie wskazanego przez prowadzącego mechanizmu diagnostycznego, wbudowanego w środowisko IDE i sposobu posługiwania się nim w toku uruchamiania.
Wnioski.
Literatura
D. Van Tassel: Praktyka programowania. WNT, Warszawa 1989.
G. J. Myers: Projektowanie niezawodnego oprogramowania. WNT, Warszawa 1980.
A. Jaszkiewicz: Inżynieria oprogramowania. Helion, Gliwice 1997.
rozdział 3
Optymalizacja czasowa programów
Optymalizacja czasowa programów
Wstęp
Program, który nie musi być poprawny,
można uczynić tak sprawnym, jak tylko dusza zapragnie
Dennie Van Tassel
Pisząc programy zwykle chcemy, by były one sprawne. Przytoczona powyżej zasada Van Tassela przypomina jednak, że w dążeniu do usprawniania (optymalizacji) programów musimy zawsze mieć na uwadze, że podstawowym wymaganiem, jakie stawiamy programom, jest niezawodność. Gdy coś nie działa, jego sprawność nie ma żadnego znaczenia - to jeszcze jedna maksyma Van Tassela spośród zebranych w książce Praktyka programowania.
Programy należy dobrze projektować i pisać, przede wszystkim mając na uwadze ich poprawność i niezawodność, a niekoniecznie zważając zbytnio na sprawność. Następnie, jeżeli program jest rzeczywiście pożyteczny, jeżeli będzie wielokrotnie używany, jeżeli mieści się to w celach całego przedsięwzięcia - wtedy (i tylko wtedy) powinno się pomyśleć o optymalizacji. Projektując program należy zaplanować, jak sprawny musi on być. Programistycznym tandeciarzem jest ktoś, kto skraca o 10 mikrosekund czas wykonywania rzadko używanego programu, tracąc na to wiele godzin i ryzykując wprowadzenie błędów.
W sytuacjach, gdy działający program ma niezadowalającą efektywność czasową i wymagane jest jego przyspieszenie, możliwe są dwa podejścia:
zaprojektowanie programu od nowa z użyciem lepszych algorytmów bądź innych struktur danych,
próba usprawnienia fragmentów programu bez zmiany całego projektu.
Podejście (a) na ogół daje lepsze wyniki, ale wymaga większego nakładu czasu programisty. Czasami podejście (b) daje już zadowalające efekty przy mniejszym wysiłku programisty i właśnie tym podejściem zajmiemy się w niniejszym opracowaniu.
Kryteria wyboru fragmentu do optymalizacji
Jeżeli decydujemy się na optymalizację programu, przede wszystkim musimy ustalić, co należy w nim usprawnić. Podstawowym pytaniem, jakie zadać sobie należy przed przystąpieniem do optymalizacji programu, jest czy wysiłek, czas i koszty realizacji planowanych usprawnień znajdują uzasadnienie w odpowiednim zwiększeniu funkcjonalnej lub rynkowej wartości programu.
Optymalizując program warto jest mieć na uwadze następujące cztery aspekty:
Profil dynamiczny programu, czyli procent czasu zużywany przez każdy podprogram lub inny fragment programu. Nie warto ulepszać podprogramów, których wykonanie stanowi niewielki ułamek całkowitego czasu wykonania programu.
Procent optymalizacji możliwy do osiągnięcia w poszczególnych fragmentach programu. Nie warto ulepszać tych fragmentów programu, w których można uzyskać jedynie stosunkowo niewielką optymalizację wysokim kosztem.
Celowość optymalizacji różnych fragmentów programu: na przykład czasami rezygnuje się z usprawniania rzadko używanych opcji programu. Dla programów komercyjnych na opłacalność optymalizacji mogą mieć wpływ takie czynniki, jak konkurencyjność, atrakcyjność, reklama. W pewnych zastosowaniach, jak systemy czasu rzeczywistego, a także niektóre programy interakcyjne, zakłada się maksymalny dopuszczalny czas reakcji systemu, co może wymusić konieczność optymalizacji.
Czas (lub liczbę osobogodzin), który trzeba spędzić nad usprawnianiem poszczególnych fragmentów. Czas jest czynnikiem decydującym o opłacalności optymalizacji tych fragmentów.
W celu uzyskania pożądanej poprawy czasowej należy wybrać taki fragment, którego optymalizacja da istotne zmniejszenie czasu pracy programu. W wyniku badań stwierdzono, że w bardzo wielu programach występuje tzw. region krytyczny - stosunkowo niewielki objętościowo fragment kodu (ok. 10% całości), który jest wykonywany przez przeważającą część całego czasu pracy programu (ok. 90% całości). Wynik tych analiz jest znany jako
reguła 90-10.
Można podać trzy metody znajdowania regionów krytycznych:
wyznaczenie profilu dynamicznego,
pomiar czasu wykonywania pewnych fragmentów, np. podprogramów,
wygenerowanie histogramu z wykorzystaniem przerwań zegarowych.
Profil dynamiczny zawiera informację o częstości wykonywania poszczególnych instrukcji programu źródłowego dla określonego zestawu danych wejściowych. Jego wyznaczenie jest tą spośród wymienionych metod, którą stosunkowo najefektywniej da się zrealizować automatycznie, za pomocą specjalnie do tego celu przeznaczonego oprogramowania. W systemie Turbo Pascal profil uzyskuje się z wykorzystaniem programu Turbo Profiler. Czas wykonania fragmentów programu w systemie Turbo Pascal można zmierzyć używając wbudowanej procedury bibliotecznej GetTime oraz używając własnej funkcji skalującej odstęp czasu.
Po oszacowaniu procentowego udziału fragmentów programu w całym czasie działania (poprzez profil lub pomiar czasu) należy oszacować możliwe procentowe skrócenie czasu działania poszczególnych fragmentów. Na tej podstawie należy wybrać fragment do optymalizacji.
Przykład 3.1
Oto sposób pomiaru czasu wykonania pewnej procedury za pomocą podprogramu bibliotecznego Turbo Pascala, GetTime:
type
czas = record
godz, min, sek, sek100: word
end;
var
tp, tk: czas;
…
begin
with tp do
GetTime (godz, min, sek, sek100);
sort(tab);
with tk do
GetTime (godz, min, sek, sek100);
writeln('Czas wykonania procedury sort wynosi ',
OdstepCzasu (tk, tp) * 100);
end;
Oszacowanie procentu możliwych ulepszeń jest zadaniem trudnym i wymagającym od programisty doświadczenia. Jako wskazówkę proponuje się przeanalizowanie wybranych fragmentów pod kątem opisanych poniżej metod optymalizacji i stwierdzenie, które z nich można zastosować.
Czynnikiem utrudniającym ocenę sprawności programów jest zależność czasu realizacji (złożoności) algorytmów od danych wejściowych. Rozróżnia się złożoność optymistyczną, pesymistyczną i średnią. Warto jest dokonać osobnych pomiarów sprawności programu dla różnych zestawów danych, by określić minimalny, maksymalny i średni czas wykonania oraz rozrzut (odchylenie standardowe).
Przykład 3.2
Oto wyniki analizy pewnego hipotetycznego programu:
|
|
% czasu wykonania |
% możliwych ulepszeń |
zysk w % |
|
Fragment A |
50 |
5 |
2.5 |
|
Fragment B |
10 |
25 |
2.5 |
|
Fragment C |
1 |
100 |
1 |
O wyborze fragmentu do optymalizacji powinien zadecydować czynnik ekonomiczny; z tabeli wynika, że optymalizacja fragmentu C jest najmniej opłacalna.
Wybrane metody optymalizacji czasowej
W tym punkcie zostaną krótko scharakteryzowane niektóre metody optymalizacji.
Zwijanie
Zwijanie polega na wykonywaniu pewnych czynności na etapie kompilacji, a nie na etapie wykonywania programu. Takie czynności to np. inicjalizowanie zmiennych (w Turbo Pascalu możliwe dla zmiennych globalnych skalarnych i strukturalnych poprzez użycie odmiany deklaracji const), wyliczanie wartości wyrażeń, w których występują tylko stałe.
Zmniejszanie siły operacji
Metoda ta polega na zastępowaniu operacji czasochłonnych operacjami szybszymi, np. zamiast potęgowania używać mnożenia (np. a^3 zastąpić przez a*a*a), zamiast mnożenia używać dodawania (np. a*2 zastąpić przez a+a).
Usuwanie wyrażeń nadmiarowych
W metodzie tej powtarzające się wyrażenia lub podwyrażenia oblicza się tylko raz, ich wartość przypisuje się zmiennej, a następnie odwołuje się do wartości tej zmiennej.
Przykład 3.3
Ciąg instrukcji:
x := sqrt(z) / sin(y) + 16;
w := sqrt(z);
u := fun (sqrt(z) / sin(y));
zastępujemy przez:
t1 := sqrt(z);
t2 := t1 / sin(y);
x := t2 + 16;
w := t1;
u := fun (t2);
Usuwanie wyrażeń niezmienniczych
Metoda ta polega na jednokrotnym wyliczeniu przed pętlą wartości wyrażenia, którego wartość nie zależy od zmiennej sterującej, ani od innych zmiennych modyfikowanych w pętli.
Przykład 3.4
Konstrukcję:
for i := 1 to 100 do
a[i] := sin(y) * a[i];
zastępujemy przez:
t1 := sin(y);
for i := 1 to 100 do
a[i] := t1 * a[i];
Rozszczepianie
Metoda ta stosowana jest w przypadku, gdy wewnątrz pętli występuje instrukcja warunkowa, a wartość warunku nie zależy od zmiennej sterującej pętli.
Przykład 3.5
Konstrukcję:
for i := 1 to 100 do
if dodaj
then a := a + n
else a := a - n;
zastępujemy przez:
if dodaj then
for i := 1 to 100 do
a := a + n
else
for i := 1 to 100 do
a := a - n
Rozwijanie pętli
Rozwijanie polega na zwiększeniu kroku pętli w celu zmniejszenia liczby sprawdzeń warunku zakończenia pętli.
Przykład 3.6
Ciąg instrukcji:
i := 0;
while i < 300 do
begin
a[i] := 0;
i := i + 1;
end;
zastępujemy przez:
i := 0;
while i < 300 do
begin
a[i] := 0;
a[i+1] := 0;
a[i+2] := 0;
i := i + 3;
end;
Łączenie pętli
Przykład 3.7
Ciąg instrukcji:
for i := 1 to 100 do
a[i] := 0;
for i := 1 to 100 do
b[i] := 0;
zastępujemy przez:
for i := 1 to 100 do
begin
a[i] := 0;
b[i] := 0;
end;
Optymalizacja pętli zagnieżdżonych przez reorganizację
O ile jest to możliwe, należy pętle zorganizować w ten sposób, by pętle zewnętrzne były powtarzane najmniej razy.
Przykład 3.8
Konstrukcję:
for i := 1 to 20 do { otwarcie pętli 1 raz }
for j := 1 to 10 do { otwarcie pętli 20 razy }
for k := 1 to 5 do { otwarcie pętli 200 razy }
begin
…
end; { zamknięcie pętli dla k 1000 razy }
{ zamknięcie pętli dla j 200 razy }
{ zamknięcie pętli dla i 20 razy }
zastępujemy przez:
for k := 1 to 5 do { otwarcie pętli 1 raz }
for j := 1 to 10 do { otwarcie pętli 5 razy }
for i := 1 to 20 do { otwarcie pętli 50 razy }
begin
…
end; { zamknięcie pętli dla i 1000 razy }
{ zamknięcie pętli dla j 50 razy }
{ zamknięcie pętli dla k 5 razy }
W pierwszym przypadku było 221 otwarć pętli i 1220 zamknięć pętli, w przypadku drugim 56 otwarć i 1055 zamknięć pętli.
Optymalizacja instrukcji warunkowych
W instrukcjach warunkowych należy stosować człon else.
Przykład 3.9
Ciąg instrukcji:
if x = 1 then p1;
if x = 2 then p2;
if x = 3 then p3;
zastępujemy przez:
if x = 1 then p1 { jeśli x = 1 reszta nie jest wykonywana }
else if x = 2 then p2
else if x = 3 then p3;
W sytuacji, gdy kompilator generuje dla złożonych wyrażeń logicznych taki kod, że wartościowanie wyrażenia jest przerywane w momencie, gdy znana jest wartość wyrażenia, należy:
dla wyrażeń z operatorem and - podwyrażenie, które zazwyczaj ma wartość false, umieścić na początku wyrażenia,
dla wyrażeń z operatorem or - podwyrażenie, które zazwyczaj ma wartość true, umieścić na początku wyrażenia.
Odwołania do elementów tablic
W przypadku wielokrotnego odwołania do tego samego elementu tablicy, należy wartość tego elementu podstawić pod zmienną prostą (jest to szczególny przypadek metod opisanych w punktach 3.3.3 i 3.3.4).
Przykład 3.10
Instrukcję:
x := a[i] + a[i] * a[i];
zastępujemy przez:
ai := a[i];
x := ai + ai * ai;
Przykład 3.11
Konstrukcję:
for i := 1 to 100 do
a[i] := b[ k + 2 * j ];
zastępujemy przez:
bb := b[ k + 2 * j ];
for i := 1 to 100 do
a[i] := bb;
Odwołania do pól rekordów
W przypadku wielokrotnego odwołania do pojedynczego pola rekordu, którego adres musi być obliczany w trakcie wykonania programu, należy jego wartość podstawić pod zmienną prostą (jest to przypadek szczególny metody opisanej w punkcie 3.3.3). Adres miejsca w pamięci skojarzonego z polem rekordu jest obliczany jako suma adresu pierwszego pola w rekordzie i przesunięcia, które dla każdego z pól ma ustaloną wartość. Na ogół oba składniki adresu pola są ustalane na etapie kompilacji, przez co proponowana zmiana de facto spowalnia wykonanie programu, zamiast je przyspieszać. Konieczność obliczania adresu w czasie wykonywania programu zachodzi wyłącznie wtedy, gdy dostęp do danej typu rekordowego realizowany jest pośrednio - za pomocą zmiennej wskaźnikowej lub tablicowej, i tylko wtedy opisywana metoda może przyspieszyć wykonywanie programu.
W przypadku odwołania do wielu pól tego samego rekordu można używać instrukcji wiążącej with.
Przykład 3.12
Instrukcje:
x := 2 * a^[i].pole1;
y := 1 / a^[i].pole1;
z := a^[i].pole1 * a^[i].pole1;
zastępujemy przez:
pole1 := a^[i].pole1;
x := 2 * pole1;
y := 1 / pole1;
z := pole1 * pole1;
Rozwijanie procedur i funkcji
Istnieje metoda optymalizacji wywołań procedur i funkcji, która nosi nazwę rozwijania. Stosuje się ją dla krótkich podprogramów wywoływanych w niewielu miejscach. Polega ona na wstawieniu w miejsce wywołania podprogramu jego ciała po podstawieniu wartości parametrów.
Zastosowanie kompilatora optymalizującego
Zastosowanie kompilatora optymalizującego powoduje, że wiele spośród opisanych wyżej metod optymalizacji jest implementowanych automatycznie na etapie kompilacji, zwalniając programistę z konieczności modyfikowania swoich programów. Jest to szczególnie cenne z uwagi na fakt, że niektóre metody optymalizacji negatywnie wpływają na przejrzystość kodu źródłowego programów.
Większość współczesnych kompilatorów posiada bardzo zaawansowane funkcje optymalizacji kodu. Należy zwrócić uwagę na fakt, że często domyślnie stosowane są parametry pracy wyłączające niektóre lub nawet wszystkie metody optymalizacji - po to, by przyspieszyć kompilację na etapie uruchamiania i wstępnego testowania programu. Należy więc pamiętać o załączeniu odpowiedniego zestawu opcji optymalizujących, i to nie tylko przed wypuszczeniem końcowej wersji oprogramowania, ale przed przejściem do fazy zaawansowanego testowania. Jest to bardzo ważne: to, że przetestowałeś program kompilowany bez optymalizacji nie oznacza, że program zoptymalizowany również będzie poprawny. Włączenie optymalizacji kodu może zmienić zachowanie się programu w tych sytuacjach, w których to zachowanie nie jest jasno zdeterminowane; krótko mówiąc, może spowodować uaktywnienie się tych błędów programu, które w poprzedniej wersji przez przypadek nie dawały zauważalnych symptomów.
Podsumowanie
Znaczenie takich metod, jak: zwijanie wyrażeń, zmniejszanie siły operacji, usuwanie wyrażeń nadmiarowych i niezmienniczych, rozszczepianie, niektóre aspekty optymalizacji instrukcji warunkowych i odwołań do tablic i rekordów może być znikome w przypadku zastosowania kompilatora optymalizującego - przyjrzyj się opcjom, jakim dysponuje twój kompilator i wyciągnij z tego wnioski.
Niektóre metody optymalizacji mają negatywny wpływ na przejrzystość kodu źródłowego, a tym samym mogą wpłynąć na obniżenie niezawodności oprogramowania. Należy tu wymienić przede wszystkim rozwijanie pętli, a także procedur i funkcji, łączenie pętli, a w mniejszym stopniu również zwijanie wyrażeń, zmniejszanie siły operacji, czasami też nadmierne usuwanie wyrażeń nadmiarowych i niezmienniczych. Z drugiej strony wiele czynności optymalizujących może też wpłynąć na lepsze uporządkowanie tekstu programu; może to dotyczyć takich metod, jak usuwanie wyrażeń nadmiarowych i niezmienniczych, rozszczepianie, optymalizacja odwołań do elementów tablic i rekordów.
Najistotniejsze są optymalizacje dotyczące dużych i często wykonywanych fragmentów programów, szczególnie pętli. Zaakcentować tu należy optymalizację poprzez usuwanie wyrażeń niezmienniczych, rozszczepianie, a także optymalizację pętli i instrukcji warunkowych. Niektóre optymalizacje tego typu nie są wykonywane przez kompilator!
Czynniki uboczne wpływające na sprawność
programów
Wpływ na sprawność programów może mieć szereg czynników nie wynikających bezpośrednio z treści programu. W wielu takich przypadkach metody przedstawione w poprzednim punkcie nie znajdują zastosowania.
Należy się liczyć z możliwością zafałszowania pomiarów sprawności programów przez opisywane czynniki. Niektóre z nich mogą mieć charakter niedeterministyczny, co szczególnie utrudnia ich ocenę, a nawet wykrycie. Wiarygodność pomiarów można zwiększyć wykonując pomiary większej liczby wykonań analizowanych grup instrukcji, i określając średni czas wykonania oraz odchylenie standardowe (jeśli nie ścisłymi metodami statystycznymi - to chociaż „na oko”).
Eliminacja opóźnień wynikających z występowania czynników nie wynikających bezpośrednio z treści programu może na tyle skutecznie poprawić parametry programu, że dalsza jego optymalizacja może nie być konieczna (szczególnie polecamy analizę konfiguracji translatora - por. punkt 3.5). Z drugiej strony, program uznany za dostatecznie sprawny może, np. po przeniesieniu do nowego środowiska, znów wymagać optymalizacji.
Poniżej zostały opisane czynniki uboczne wpływające na sprawność programów. Opis rozpoczyna się od czynników najistotniejszych.
Operacje wejścia-wyjścia
Operacje wejścia-wyjścia są szczególnie czasochłonne. Czas ich wykonywania często w sposób istotny wpływa na czas działania programu. Skrajnym przykładem są operacje interakcyjne (np. wprowadzanie tekstu lub dialog z użytkownikiem).
Ze względu na wyjątkowo długi czas wykonywania operacje wejścia-wyjścia należy zawsze optymalizować oddzielnie od reszty kodu. Do ich optymalizacji można zastosować większość z opisanych w punkcie 3.3 metod.
Optymalizacja kodu towarzyszącego wywołaniom operacji wejścia-wyjścia jest najczęściej mało opłacalna. Na przykład, efekt zastosowania metody rozszczepiania (por. punkt 3.3.7) do następującego fragmentu programu:
for i:=1 to 100 do
if pisz then writeln(a[i])
else readln(a[i]);
nie będzie zauważalny, gdyż czas wykonywania instrukcji warunkowej jest zaniedbywalnie mały w porównaniu z czasem wykonywania instrukcji wejścia-wyjścia.
Bardzo się natomiast opłaca optymalizować same operacje wejścia-wyjścia. Szczególnie korzystna jest eliminacja niektórych operacji wejścia-wyjścia, możliwa dzięki zbuforowaniu raz wczytanych danych w pamięci. Wiele systemów programowych oferuje również mechanizmy symulujące działanie urządzeń wejścia-wyjścia za pomocą tzw. strumieni pamięciowych.
Dodatkowe czynniki wpływające na sprawność operacji wejścia-wyjścia
Czas wykonywania operacji wejścia-wyjścia, a w szczególności operacji dyskowych oraz graficznych, zależy zarówno od parametrów sprzętowych systemu komputerowego, w którym wykonujemy program, jak i od konfiguracji programowej. Do tej ostatniej zaliczyć można: sposób buforowania danych, wielkość bufora, potrzebę i rodzaj formatowania danych (zmiany reprezentacji danych), sprawność usług oferowanych przez system operacyjny (lub inne środowisko pracy programu). W zależności od środowiska i stosowanych narzędzi, programista może mieć wpływ na niektóre z tych czynników.
Bardzo często czas trwania operacji wejścia-wyjścia nie może być zdeterminowany. Dotyczy to operacji interakcyjnych, których czas trwania zależy od reakcji użytkownika; operacji dyskowych z zastosowaniem tzw. pamięci cache - czas ich trwania zależy od zawartości tej pamięci; operacji na dyskach sieciowych, na które wpływa obciążenie sieci, czy wreszcie operacji graficznych w środowiskach okienkowych, których czas realizacji jest zależny od chwilowego układu okienek na ekranie.
Translator i jego konfiguracja
Jest dość oczywiste, że o sprawności programu w dużej mierze decyduje użyty translator.
Translatory zwykle zawierają pewne opcje wspomagające uruchamianie programów, często wprowadzające jednak dość znaczny - i zupełnie niepotrzebny w gotowym produkcie - narzut czasowy. I tak, sprawność programu można poprawić usuwając dyrektywy kompilatora wprowadzające dodatkowe informacje dla debuggera, a także kontrolę zakresu tablic, stosu, przepełnienia itp. (oczywiście, powinniśmy to zrobić dopiero po ukończeniu procesu uruchamiania programu). Niektóre dyrektywy kompilatorów pozwalają na wykorzystanie cech charakterystycznych dla procesora, jak efektywniejsze tryby adresacji, wyrównywanie adresów do granicy słów i ich wielokrotności, zastosowanie rozkazów z nowszej serii procesora, czy wreszcie wykorzystanie koprocesora. Opcje włączające generowanie zoptymalizowanego kodu wynikowego opisaliśmy w punkcie 3.3.13.
Narzut wprowadzany przez system operacyjny
Nawet nie licząc jawnych odwołań do usług systemu operacyjnego, sam fakt istnienia i funkcjonowania systemu operacyjnego wpływa na czas wykonywania programu. Większość systemów operacyjnych wykonuje określone operacje równolegle z działaniem programu, w tak zwanym tle. W systemie MS-DOS obsługiwane są przerwania zegarowe; swój udział mają też tak zwane programy rezydentne (TSR). Nie jest to jednak duży narzut.
Zupełnie inaczej rzecz się ma z systemami graficznymi, jak Microsoft Windows, oraz systemami wielodostępnymi, jak Unix. Działanie programów może być czasem znacznie spowolnione, gdyż zarówno aktywność systemu operacyjnego, jak i uruchomione jednocześnie w systemie inne procesy użytkowe mogą wprowadzać znaczny narzut.
Obciążenie wprowadzane przez system operacyjny czasami się obniża poprzez rezygnację z niektórych usług lub warstw systemu i odwoływanie się wprost do operacji na niższym poziomie (np. bezpośredni dostęp do pamięci obrazu w MS-DOS lub tzw. 32-bitowy dostęp do dysku w Windows).
Wielozadaniowość i wielodostęp
Wiele systemów operacyjnych zapewnia wielozadaniowość i wielodostęp. Wiąże się to z podziałem czasu procesora pomiędzy różne procesy i zmniejsza sprawność poszczególnych wykonywanych programów. Zmniejszenie sprawności ma często charakter niedeterministyczny.
Pamięć wirtualna i nakładkowanie
Pamięć wirtualna to mechanizm umożliwiający procesorowi dostęp do przestrzeni adresowej większej niż wielkość zainstalowanej pamięci operacyjnej. Część zawartości pamięci wirtualnej, która nie mieści się w pamięci operacyjnej, jest przechowywana w pamięci dyskowej i może być załadowana w razie potrzeby do pamięci operacyjnej. Ten właśnie proces jest przyczyną dość znacznego i całkowicie niedeterministycznego obniżenia sprawności programów.
Podobny wpływ na obniżenie sprawności programów ma nakładkowanie.
Gospodarka pamięcią dynamiczną
Dynamiczne tworzenie i usuwanie zmiennych wprowadza pewien, stosunkowo niewielki, narzut czasowy. Dynamiczne utworzenie zmiennej wymaga przeglądania wolnych pozycji na stogu. Algorytmy przydziału pamięci dynamicznej, które minimalizują zjawisko tzw. fragmentacji pamięci, są realizowane nieco dłużej, a wydłużenie czasu ich trwania ma charakter niedeterministyczny.
Dostęp do zmiennych dynamicznych (za pomocą wskaźników) może być minimalnie wolniejszy od dostępu do zmiennych statycznych. Różnice są jednak na tyle niewielkie, że sprawność z tym związana nie powinna decydować o wyborze pomiędzy statycznym a dynamicznym przydziałem pamięci.
Niektóre systemy z dynamicznym przydziałem pamięci dla zmiennych, jak Lisp, Java, Smalltalk czy Clipper, realizują dodatkowe operacje związane ze zwalnianiem nie używanych już zmiennych (tzw. garbage collection).
Zmienne lokalne a zmienne globalne
Dostęp do zmiennych lokalnych trwa nieco dłużej niż dostęp do zmiennych globalnych. Wynika to z faktu, że zmienne globalne mają ustalone, bezwzględne adresy w pamięci, podczas gdy zmienne lokalne są tworzone dynamicznie w obszarze stosu maszynowego. Adres zmiennej lokalnej jest przy każdym dostępie obliczany na podstawie położenia stosu oraz przesunięcia charakterystycznego dla danej zmiennej.
Różnice są jednak na tyle niewielkie, że poza sytuacjami wyjątkowymi sprawność rozwiązania nie powinna decydować o zastosowaniu zmiennej globalnej zamiast lokalnej.
Uwagi końcowe
Pomiędzy optymalizacją programów a dążeniem do stworzenia przejrzystego (a więc bardziej niezawodnego) programu zachodzi głęboka sprzeczność. Na programiście spoczywa ciężar znalezienia rozwiązania kompromisowego.
Niemal każda z opisanych metod optymalizacji może ograniczyć zrozumiałość programu a nawet pogorszyć jakość konstrukcji całego programu. Do skrajnych przykładów należy podyktowana względami sprawnościowymi redukcja zmiennych lokalnych na rzecz zmiennych globalnych.
Najlepszym rozwiązaniem jest stosowanie nowoczesnych kompilatorów optymalizujących (np. kompilatory języka C/C++ firm Borland i Microsoft), które wprowadzają wiele spośród opisanych metod optymalizacji w sposób automatyczny, reorganizując wynikowy kod programu. Chroni to nie tylko przed pomyłkowym lub wynikającym z braku doświadczenia nieuwzględnieniem możliwości optymalizacji kodu. Programista może też świadomie unikać pewnych optymalizacji, zachowując większą przejrzystość kodu programu i jednocześnie wiedząc, że stosowna optymalizacja zostanie wprowadzona automatycznie przez kompilator. Należy się jednak zapoznać z możliwościami stosowanego kompilatora.
Programy przykładowe
Poniżej prezentujemy krótki program przykładowy, wyznaczający i wyświetlający wszystkie liczby pierwsze w zakresie od 1 do 1000, oraz kilka możliwych do przeprowadzenia w tym programie sposobów optymalizacji.
Oto wyjściowa postać programu:
#include <stdio.h>
int pierwsza(int n)
{
int i;
for (i = 2; i < n; i++)
if (n % i == 0)
return 0;
return 1;
}
void main(void)
{
int i, n = 1000;
for (i = 2; i <= n; i++)
if (pierwsza(i))
printf("%d\n", i);
}
Jak widać, program, aby stwierdzić, czy jakaś liczba jest, czy nie jest liczbą pierwszą, próbuje znaleźć jakiś jej podzielnik rozpatrując wszystkie liczby z przedziału 2..n-1. Program można dość znacznie usprawnić wykorzystując fakt, że podzielniki liczby n wykrywane są zawsze parami: n = p1 * p2. Szukając systematycznie różnych par podzielników można brać pod uwagę tylko te pary p1 i p2, w których p1
p2. Ponieważ wartość mniejszego podzielnika p1 nie może być większa niż
, zatem bezpiecznie można zmniejszyć zakres pętli, definiując ją w sposób następujący:
for (i = 2; i <= (int)sqrt(n); i++)
Następnie, usuwając wyrażenie niezmiennicze sqrt(n), oszczędzamy na wielokrotnym obliczaniu dość złożonej funkcji, i w efekcie otrzymujemy następujący program:
#include <stdio.h>
#include <math.h>
int pierwsza(int n)
{
int i, limit;
limit =(int)sqrt((float)n);
for (i = 2; i <= limit; i++)
if (n % i == 0)
return 0;
return 1;
}
void main(void)
{
int i, n = 1000;
for (i = 2; i <= n; i++)
if (pierwsza(i))
printf("%d\n", i);
}
Kolejna modyfikacja znowu bazuje na pewnej własności algorytmu: mianowicie po stwierdzeniu, że liczba nie jest podzielna przez 2, można już nie sprawdzać podzielności przez żadną inną liczbę parzystą. W poniższym programie zastosowano ponadto częściowe rozwinięcie pętli: dla liczb podzielnych przez 2, 3 lub 5 instrukcja pętli w ogóle nie będzie wykonywana:
#include <stdio.h>
int pierwsza(int n)
{
int i;
if (n % 2 == 0)
return (n==2);
if (n % 3 == 0)
return (n==3);
if (n % 5 == 0)
return (n==5);
for (i=7; i*i <= n; i+=2)
if (n % i == 0)
return 0;
return 1;
}
void main(void)
{
int i, n = 1000;
for (i=2; i<=n; i++)
if (pierwsza(i))
printf("%d\n", i);
}
Zauważ, że dość czasochłonne obliczenie pierwiastka kwadratowego zastąpiono dużo szybszym wyrażeniem warunkowym i * i <= n; nie może ono jednak zostać wyłączone z pętli jako wyrażenie niezmiennicze; takie rozwiązanie sprawdza się tylko wtedy, gdy liczba iteracji nie jest wielka. W przedstawionym algorytmie, przy poszukiwaniu liczb z zakresu 1..1000, założenie to jest na ogół słuszne, choć dopiero eksperyment wykazać może przewagę jednego lub drugiego podejścia w danej implementacji; wszak wszystko zależy od tego, z jak dobrej biblioteki korzystamy, by wyliczyć pierwiastek kwadratowy.
Podsumowując, należy zauważyć, że bardzo często znacznie lepszy skutek uzyskujemy stosując wydajniejszy algorytm (w tym przypadku wydatnie zmniejszyliśmy liczbę iteracji), podczas gdy drobne usprawnienia, jak usuwanie wyrażeń niezmienniczych czy rozwijanie pętli, mają mniejsze znaczenie dla sprawności programu. Na zakończenie przedstawiamy jeszcze jedno rozwiązanie, oparte na tzw. algorytmie Euklidesa. Jest ono znacznie wydajniejsze czasowo, wymaga jednak zastosowania dodatkowej tablicy (zatem jest mniej efektywne pamięciowo):
#include <stdio.h>
int pierwsze[1000];
#define MAX 1000
void main(void)
{
int j, poprzednia, aktualna;
pierwsze[0] = 2;
pierwsze[1] = 3;
poprzednia = 1;
aktualna = 3;
printf("Pierwsza %d = %d\n", 0, pierwsze[0]);
printf("Pierwsza %d = %d\n", 1, pierwsze[1]);
while(aktualna < MAX)
{
for(j = 0; j <= poprzednia; j++)
if ((aktualna % pierwsze[j]) == 0)
{
aktualna += 2;
break;
}
if(j <= poprzednia)
continue;
poprzednia++;
printf("Pierwsza %d = %d\n", poprzednia, aktualna);
pierwsze[poprzednia] = aktualna;
aktualna += 2;
}
}
Pytania kontrolne
Co to jest region krytyczny i reguła 90-10?
Podaj metody znajdowania regionu krytycznego.
Na czym polega zwijanie?
Co to jest zmniejszanie siły operacji?
Na czym polega usuwanie wyrażeń nadmiarowych i niezmienniczych?
Podaj metody optymalizacji pętli.
Na czym polega optymalizacja instrukcji warunkowych?
W jaki sposób można optymalizować odwołania do elementów tablic i do pól rekordów?
Podaj sposób optymalizacji podprogramów.
Po co stosuje się wielokrotne pomiary czasu wykonania tych samych instrukcji?
Dlaczego operacje wejścia-wyjścia należy optymalizować oddzielnie?
Od czego zależy czas wykonywania operacji wejścia-wyjścia?
Omów nie wynikające z treści programu czynniki, które mogą wpływać na czas wykonania programu?
Jak system operacyjny może wpływać na czas wykonania programów?
Jaka jest zależność między lokalnością obiektu a czasem odwołania do tego obiektu?
Podaj zalety korzystania z kompilatorów optymalizujących.
Zadania dla ćwiczących
Zmierzyć i porównać czasy wykonania wskazanego programu dla wybranych zestawów danych wejściowych i różnych kombinacji opcji kompilacji.
Zmierzyć i porównać czasy wykonania:
programu w postaci pierwotnej,
programu z pominięciem operacji wyprowadzania danych,
programu, w którym zastosowano głównie zmienne globalne, zmienne lokalne, zmienne nielokalne,
programu, w którym wykonano inne zmiany określone przez prowadzącego.
Pomiary przeprowadzić dla tych samych zestawów danych wejściowych i opcji kompilacji.
Zoptymalizować badany program. Prace rozpocząć od sporządzenia profilu i jego analizy.
Zawartość sprawozdania
W sprawozdaniu powinny się znaleźć:
Wyniki pomiarów zrealizowanych w zadaniach 1 i 2 oraz ich dyskusja.
Treść badanego programu w wersji początkowej.
Raport kompilacji programu po optymalizacji z zaznaczeniem wprowadzonych zmian.
Opis wprowadzonych zmian wraz z uzasadnieniem oraz ocena osiągniętego efektu.
LITERATURA
D. Van Tassel: Praktyka programowania. WNT, Warszawa 1989.
A. Jaszkiewicz: Inżynieria oprogramowania. Helion, Gliwice 1997.
DODATEK A
Diagramy przepływu sterowania
Diagramy przepływu sterowania
Jedną z faz testowania modułu jest sprawdzenie jego logiki. Odpowiedni dobór danych testowych często stanowi dość poważny problem. Chodzi o to, by przy jak najmniejszej liczbie testów doprowadzić do co najmniej jednorazowego wykonania każdej instrukcji w module. Należy więc tak przygotować dane testowe, by spowodować wykonanie każdego rozgałęzienia w schemacie modułu. Można do tego wykorzystać diagram przepływu sterowania.
Diagram przepływu sterowania (ang. flow diagram) jest to graf skierowany obrazujący strukturę rozgałęzień programu.
Każdy węzeł (oznaczany kółkiem) reprezentuje sekwencyjny fragment programu. Przez pojęcie sekwencyjnego fragmentu programu rozumiemy ciąg instrukcji znajdujących się między punktami decyzyjnymi i skokami, którego wykonanie zaczyna się zawsze od pierwszej instrukcji.
Krawędzie grafu odpowiadają połączeniom między segmentami programu, tworzonym poprzez przepływ sterowania podczas wykonania programu. Dla węzłów, po których następuje rozgałęzienie kodu, przyjmuje się, że gałąź górna jest wybierana w przypadku spełnienia warunku, gałąź dolna, gdy wynik testu jest negatywny.
test
Dla przykładowego fragmentu modułu zapisanego w technice pseudokodu:
...
<< fragment A >>
if (x = y) and (w = z) then
<< fragment B >>
else
<< fragment C >>
endif;
<< fragment D >>
...
diagram przepływu sterowania ma postać:
2 B
A 1 D
C
Na podstawie diagramu buduje się tablicę rozgałęzień, w której umieszczony jest opis warunków sprawdzanych w czasie wykonywania programu (oznaczenie węzła i decyzja).
W rozważanym przypadku tablica ma następującą postać:
węzeł |
decyzja |
1 |
x = y |
2 |
w = z |
Tablica rozgałęzień jest następnie wykorzystywana do sporządzenia macierzy pokrycia gałęzi. W macierzy tej oprócz opisu decyzji znajdują się dodatkowe kolumny oznaczone numerami zestawów danych testowych.
Przykładowa macierz wygląda następująco:
|
węzeł |
decyzja |
testy |
1 |
2 |
3 |
|
|
1 |
x = y |
TRUE |
|
|
|
|
|
|
|
FALSE |
|
|
|
|
|
2 |
w = z |
TRUE |
|
|
|
|
|
|
|
FALSE |
|
|
|
|
Należy także przygotować zbiór zestawów danych testowych. Każdy zestaw powinien posiadać swój numer w celu identyfikacji. Oto przykładowy zbiór zestawów testowych:
|
|
wartości zmiennej |
||
|
|
x |
y |
z |
|
zestaw 1 |
3 |
2 |
1 |
|
zestaw 2 |
1 |
2 |
3 |
|
zestaw 3 |
2 |
2 |
3 |
Wypełnianie macierzy odbywa się poprzez śledzenie przebiegów każdego testu przez strukturę sterowania. Jeżeli dany test sprawdza warunek - w odpowiednie miejsce macierzy wpisuje się krzyżyk.
Po umieszczeniu w macierzy pokrycia wszystkich zestawów danych i zaznaczeniu sprawdzanych przez nie testów trzeba sprawdzić, czy jest ona kompletna. Macierz uważamy za kompletną, jeżeli wszystkie gałęzie kodu zostały przynajmniej jeden raz wykonane, to znaczy wtedy, gdy w każdym jej wierszu znajduje się przynajmniej jeden krzyżyk.
Jeśli zachodzi taka konieczność, uzupełnia się macierz opracowując zestawy danych testowych, dobrane tak, by pokryć gałęzie dotychczas puste. Poniżej przedstawiona została wypełniona przykładowa macierz pokrycia oraz uzupełniony zbiór zestawów testowych.
|
węzeł |
decyzja |
testy |
1 |
2 |
3 |
|
4 |
|
1 |
x = y |
TRUE |
|
|
x |
|
x |
|
|
|
FALSE |
x |
x |
|
|
|
|
2 |
w = z |
TRUE |
|
|
|
|
x |
|
|
|
FALSE |
|
|
x |
|
|
gałąź nie pokryta przez zestawy 1, 2 oraz 3
|
|
wartości zmiennej |
||
|
|
x |
y |
z |
|
zestaw 1 |
3 |
2 |
1 |
|
zestaw 2 |
1 |
2 |
3 |
|
zestaw 3 |
2 |
2 |
3 |
|
zestaw 4 |
2 |
2 |
2 |
dla pokrycia gałęzi dotychczas pustej
Na podstawie uzupełnionej macierzy pokrycia można ocenić, czy zaprojektowane dane testowe są wystarczające do sprawdzenia wszystkich ścieżek.
Aby uniknąć niekorzystnej sytuacji przedłużania się procesu testowania sprawdza się także nadmiarowość zestawów testowych. Dane, których użycie nie wnosi w testowanie nic nowego - tzn. wykorzystujące tylko gałęzie pokrywane również przez inne testy, należy usunąć.
Rozpatrzmy to na przykładzie przedstawionej wyżej macierzy pokrycia:
|
węzeł |
decyzja |
testy |
1 |
2 |
3 |
4 |
|
1 |
x = y |
TRUE |
|
|
x |
x |
|
|
|
FALSE |
x |
x |
|
|
|
2 |
w = z |
TRUE |
|
|
|
x |
|
|
|
FALSE |
|
|
x |
|
jeden z tych zestawów danych testowych jest nadmiarowy
Ponieważ w diagramie przepływu sterowania mogą znajdować się także pętle, do minimalnego zestawu testów dla ścieżek dodaje się testy sprawdzające poprawność pętli. Na ogół liczba zestawów w pełni testujących pętle jest bardzo duża. Zachodzi więc potrzeba rozsądnego dobrania danych, tak by przy niewielkiej liczbie testów takie fragmenty programu można było sprawdzić i uznać za poprawne.
Każda pętla powinna być wykonana zero, jeden i maksymalną liczbę razy (oczywiście, jeśli struktura programu dopuszcza te przypadki). Jeśli nie da się określić ostatniej z tych wielkości, przyjmuje się jakąś odpowiednio dużą wartość. Po zaprojektowaniu zestawów danych testowych spełniających powyższe warunki umieszcza się je również w macierzy pokrycia gałęzi.
Może się zdarzyć, że któryś z nowo utworzonych testów jest równoważny wcześniejszemu. Wobec tego konieczne jest ponowne sprawdzenie nadmiarowości zestawów testowych.
Zgodnie z podstawowymi zasadami testowania, przed przystąpieniem do wykonywania programu, dla którego przygotowano diagram przepływu sterowania i zestawy danych testowych, należy przygotować spodziewane wyniki, by można je było porównać z rzeczywiście otrzymanymi.
Dodatek b
Wyznaczanie profilu dynamicznego
programów za pomocą Turbo Profilera
Wyznaczanie profilu dynamicznego programów za pomocą Turbo Profilera
Turbo Profiler firmy Borland International jest przykładem programu pozwalającego na wyznaczenie profilów dynamicznych.
Turbo Profiler pozwala na gromadzenie danych dotyczących czasu i częstości wykonywania różnych fragmentów programu (poszczególnych podprogramów, linijek programu źródłowego lub dowolnych innych fragmentów określonych przez użytkownika). Ponadto możliwe jest zbieranie danych na temat wykorzystania systemu plików, przerwań, nakładek, oraz kompletnych ścieżek wywołań podprogramów. Wszystko to dostępne jest w środowisku przyjaznym dla użytkownika, z wykorzystaniem techniki okienkowej i kontekstowych podpowiedzi.
Turbo Profiler można wykorzystać do optymalizacji programów kompilowanych za pomocą dowolnego kompilatora firmy Borland International (np. Borland/Turbo Pascal, C/C++ i Assembler), przy braku ograniczeń na rozmiar programu. Istnieje specjalna wersja programu przeznaczona do analizy programów pracujących w środowisku Microsoft Windows.
B.1. Czynności przygotowawcze
Przed przystąpieniem do analizy programu za pomocą Turbo Profilera należy wykonać pewne czynności przygotowawcze.
Aby wyznaczenie profilu dynamicznego programu przebiegało efektywnie i w sposób oddający istotne cechy programu, często trzeba dokonać niewielkich modyfikacji programu źródłowego. Dane testowe powinny być na tyle duże, aby program wykonany został w możliwie pełnym zakresie swoich możliwości. Jeżeli to tylko możliwe, należy zmodyfikować program tak, aby działał w sposób niezależny od klawiatury, myszki i innych urządzeń wejściowych, wprowadzających opóźnienia czasowe nieadekwatne do zastosowanych algorytmów.
Program należy skompilować z pełną informacją symboliczną. W Turbo/Borland Pascalu (wersja 7.0) wymaga to włączenia pozycji Debug information i Local symbols w gronie Debugging okna dialogowego Options|Compiler oraz opcji Standalone debugging w oknie dialogowym Options|Debugger lub opcji kompilacji {$D+,L+}. W Borland C/C++ należy włączyć opcje: Debug info in OBJs w dialogu Options|Compiler|Advanced code generation oraz Source Debugging/Standalone w dialogu Options|Debugger. Stosując kompilator liniowy należy włączyć przełączniki -y i -v.
W trakcie pracy Turbo Profiler wykorzystuje zarówno plik zawierający postać wynikową programu, jak i pliki źródłowe. Pliki źródłowe poszukiwane są kolejno:
w katalogu, gdzie program został skompilowany;
w katalogach zapisanych w opcji programu Options|Path for source;
w bieżącym katalogu;
w katalogu zawierającym program wynikowy, podlegający aktualnie analizie.
Jeżeli plik zawierający program źródłowy nie zostanie znaleziony, wówczas Profiler automatycznie otwiera okno Disassembly zawierające kod zapisany w assemblerze. W praktyce oznacza to, że albo nie skompilowano programu z pełną informacją symboliczną, albo że plik źródłowy jest zapisany w nieodpowiednim katalogu dyskowym.
B.2. Konfiguracja Turbo Profilera
Przed podjęciem pracy z programem Turbo Profiler należy zadbać o kilka ustawień konfiguracyjnych.
B.2.1. Wstępna konfiguracja Turbo Profilera - ustalenie znaczników
obszarów (areas)
Po uruchomieniu Turbo Profilera należy załadować plik z programem przeznaczonym do analizy. W tym celu wybieramy z głównego menu programu opcję File|Open. Turbo Profiler ładuje główny moduł źródłowy programu do okna o nazwie Module i ustawia kursor na początku głównego bloku wykonywalnego tego modułu. Od tej chwili program jest już właściwie gotowy do testowego wykonania i ustalenia profilu dynamicznego. W większości zastosowań niezbędne jest jednak odpowiednie skonfigurowanie programu.
Proces wstępnego konfigurowania programu polega na określeniu elementów tekstu programu, o których mają być gromadzone informacje, oraz określeniu rodzaju tych informacji i sposobu ich gromadzenia.
W tekście źródłowym programu wyznaczane są tzw. obszary. Przed instrukcją - wierszem programu, od której zaczyna się obszar, umieszcza się znacznik obszaru. Wszystkie wiersze znajdujące się za danym znacznikiem należą do danego obszaru, aż po następny taki znacznik.
Znaczniki rozpoczynające obszary można porównać do rogatek drogowych. Wykonywanie programu kontrolowanego przez Turbo Profiler polega na przechodzeniu kolejnych takich rogatek. Przekroczenie rogatki równoważne jest z wejściem na odcinek drogi kontrolowanej przez tę rogatkę (posterunek). Odcinek ten kończy się na następnym posterunku - rogatce. Każda rogatka rejestruje, jak długo przebywało się na drodze przez nią kontrolowanej oraz ile razy przekraczało się tę rogatkę.
Po załadowaniu nowego programu Turbo Profiler automatycznie wprowadza znaczniki obszarów, czyli, mówiąc innymi słowy, dzieli program na obszary. W zależności od rozmiaru programu (a właściwie jego tablicy symboli) obszary te pokrywają się bądź to z kolejnymi wierszami programu źródłowego, bądź też z całymi podprogramami.
Znaczniki początków obszarów wyświetlane są w postaci strzałek na lewo od odpowiednich linijek programu. Wskazując strzałki za pomocą myszy można usunąć niepotrzebne obszary. W analogiczny sposób można wprowadzać nowe znaczniki. Obie operacje wykonać też można umieszczając kursor tekstowy w odpowiedniej linijce i naciskając F2.
Znaczniki można efektywniej ustawiać i usuwać korzystając z tzw. menu lokalnego. Menu lokalne uzyskuje się za pomocą prawego przycisku myszy (w czasie, gdy kursor myszy znajduje się ponad okienkiem Module), lub używając klawisza Alt-F10. Opcja Add areas pozwala na dodanie obszarów odpowiadających wszystkim podprogramom w programie lub module, wszystkim wierszom w module lub podprogramie, wskazanemu podprogramowi lub wskazanemu wierszowi. Opcja Remove areas pozwala usunąć wszystkie istniejące obszary lub tylko odpowiadające wszystkim uprzednio zaznaczonym podprogramom w programie lub module, wszystkim wierszom w module lub podprogramie, czy wreszcie wskazanemu podprogramowi lub wierszowi.
Znacznik obszaru stanowi rodzaj pułapki na sterowanie. Kiedykolwiek Turbo Profiler wykonując program natrafi na taką pułapkę, realizuje związane z daną pułapką (obszarem) działania. Najczęściej jest to po prostu zarejestrowanie danych określających czas przebywania programu w poprzedzającym znacznik obszarze.
Za pomocą menu lokalnego można określić, jaka operacja powinna zostać wykonana po natrafieniu na pułapkę - znacznik obszaru. Służy do tego opcja Operation, której aktywacja powoduje rozwinięcie okienka dialogowego Area Options. Oprócz standardowego trybu reakcji (Normal), który polega na prostym zarejestrowaniu danych statystycznych dotyczących obszaru kontrolowanego przez znacznik, można spowodować chwilowe wstrzymanie wykonywania programu po natrafieniu na znacznik (Stop), a także wstrzymanie i wznowienie rejestrowania danych (Disable i Enable), co pozwala na wyłączenie pewnych obszarów z analizy realizowanej przez Turbo Profiler.
Za pomocą tego samego okienka dialogowego można znacznikowi obszaru przypisać jeden z dwóch systemów rejestracji i pomiaru czasu. Podczas rejestracji rozdzielnej (Separate Timing) na konto danego obszaru zaliczany jest wyłącznie czas wykonania instrukcji znajdujących się w obrębie tego obszaru, to znaczy pomiędzy znacznikiem początkowym i końcowym. W trybie łączonym (Combined Timing) na konto obszaru jest również zaliczany czas wykonania podprogramów wywołanych z wnętrza tego obszaru (mimo że treść tych podprogramów należy do innych obszarów). Zwykle bardziej przydatny jest tryb rozdzielny (i taki jest też ustawiany domyślnie przez Turbo Profiler), gdyż pozwala na precyzyjniejsze wyznaczenie regionu krytycznego. Tryb łączony pozwala zaś na całościowe spojrzenie na wydajność danego podprogramu, abstrahując od tego, na jakie mniejsze podprogramy jest on rozbity.
B.2.2. Uzupełniająca konfiguracja Turbo Profilera - tryb gromadzenia
danych
Dodatkową możliwość skonfigurowania procesu gromadzenia danych przez Turbo Profiler dają opcje zebrane w okienku dialogowym Profiling Options, generowanym przez opcję o tej samej nazwie, w podmenu Statistics w głównym menu programu. Przełączniki zebrane w gronie Profile mode pozwalają na wybór jednego z niżej opisanych trybów gromadzenia danych statystycznych o programie:
Tryb aktywny (active mode) oznacza gromadzenie pełnego zestawu informacji (czas wykonywania obszarów, liczba wykonań i ścieżka wywołań dla podprogramów) kosztem spowolnienia programu;
Tryb bierny (passive mode) oznacza gromadzenie danych wyłącznie na temat czasów wykonywania obszarów, pozwala jednak na wykonywanie programów z prawie pełną szybkością.
Za pomocą tego samego okienka dialogowego można zwiększyć liczbę przebiegów programu realizowanych w celu pomiaru czasu. Ograniczona rozdzielczość używanego zegara może prowadzić do błędnych wyników pomiarów bardzo krótkich odcinków czasowych; zwielokrotnienie pomiaru pozwoli uzyskać wyniki obarczone mniejszym błędem.
B.3. Tworzenie i przeglądanie statystyk
Po zaplanowaniu położenia i właściwości obszarów Turbo Profiler jest gotowy do zebrania danych statystycznych na temat czasu wykonania rozmaitych jego obszarów, a także częstości wykonania tych obszarów. Możliwe jest także zbieranie innych typów statystyk, co zwykle wymaga podjęcia dodatkowych czynności przygotowawczych. W kolejnych punktach omówione zostaną następujące typy generowanych przez Turbo Profiler statystyk:
profil dynamiczny - na jak długo i jak często sterowanie zostało przekazane do różnych obszarów;
częstości i ścieżki wywołań - jak często były wywoływane poszczególne podprogramy i jakie podprogramy je wywoływały;
statystyka nakładek - z jakich nakładek, jak często i kiedy korzystał analizowany program;
statystyka przerwań - z jakich przerwań, jak często i jak długo korzystał analizowany program;
statystyka plików - z jakich plików, w jaki sposób, jak często i jak długo korzystał analizowany program.
Aby zebrać jakiekolwiek dane statystyczne należy rozpocząć wykonywanie analizowanego programu za pomocą opcji Run|Run lub klawisza F9. Program zostanie wówczas wykonany (ze względu na narzut czasowy Turbo Profilera może to być nieco zwolnione).
Dla każdego typu statystyk, a także danych o charakterze niestatystycznym, Turbo Profiler rezerwuje osobny typ okienek. Okienka takie otworzyć można za pomocą opcji z podmenu Views, znajdującego się na belce głównego menu programu. Z każdym typem okienka związane jest specjalne menu lokalne, które otrzymuje się za pomocą kombinacji klawiszy Alt-F10 lub prawego przycisku myszy. Menu lokalne zawierają zestaw opcji reprezentujących operacje charakterystyczne dla obrazowanych w okienku danych. Część opcji służy też do przełączania pomiędzy okienkami, w szczególności obrazowania za pomocą innego typu środków (tj. w okienku innego typu) elementu danych zaznaczonego w okienku bieżącym. Nazewnictwo opcji tego rodzaju nie jest jednolite: w niektórych okienkach nazywają się one Inspect, a w innych przyjmują nazwy okienek, które otwierają.
B.3.1. Profil dynamiczny programu (Execution Profile)
Okienko przeznaczone do wyświetlania profilu dynamicznego (Execution Profile) znajduje się zwykle na ekranie od chwili aktywizacji Turbo Profilera. Jeśli mimo to okienka o takiej nazwie nie ma, wystarczy wybrać opcję menu głównego View|Execution Profile.
Okienko profilu dynamicznego jest podzielone na dwie części. W górnej znajdują się dane na temat całkowitego czasu wykonania programu (Total time), procentowego udziału sumy czasów wykonania obszarów ujętych w statystyce w całkowitym czasie wykonania programu (% of Total), a także dodatkowych informacji dotyczących sposobu skonfigurowania okienka.
W dolnej części okienka wyświetlane są (po wykonaniu programu) dane statystyczne dotyczące poszczególnych obszarów programu, obejmujące nazwę obszaru (numer wiersza programu źródłowego lub nazwę podprogramu), odpowiednie wartości liczbowe bezwzględne i względne (procentowe) oraz ilustrację w formie wykresu słupkowego.
Jeżeli jednocześnie w środowisku Turbo Profilera otwarte są okna Module (z tekstem źródłowym modułu) i Execution Profile, to oba te okna są ze sobą sprzęgnięte, tak że ustawienie kursora w określonym obszarze w oknie Module powoduje podświetlenie statystyki tego obszaru w oknie Execution Profile i odwrotnie.
Profil dynamiczny obejmuje analizę danych statystycznych różnego rodzaju:
Time to statystyka czasowa (w milisekundach);
Counts to statystyka zdarzeniowa, tj. liczba wykonań poszczególnych obszarów;
Both to obie powyższe statystyki pokazane jednocześnie;
Per call to statystyka czasu średniego, tj. wartość ilorazu czasu wykonania przez liczbę wykonań;
Longest to statystyka najdłuższego czasu pojedynczego wykonania danego obszaru;
Module to prezentacja, dla każdego obszaru w module, czasu przez jaki sterowanie przebywało w danym module (ten tryb ma znaczenie jedynie przy tzw. pasywnej analizie programu).
Wyboru jednej z powyższych statystyk dokonuje się za pomocą lokalnego menu związanego z oknem Execution Profile. Opcja Display powoduje wyświetlenie okienka dialogowego, pozwalającego określić jeden z wyżej opisanych typów statystyk, a także sposób sortowania wyświetlanych danych - alfabetycznie (Name), według położenia w pamięci operacyjnej (Address) oraz według wartości danych statystycznych związanych z obszarami (Frequency).
W razie potrzeby można ograniczyć liczbę wyświetlanych danych (tj. liczbę obszarów, o których wyświetlane są informacje) za pomocą filtrów (opcje Filter|All, Module i Current lokalnego menu). Wyświetlane mogą być dane na temat wszystkich obszarów (All) lub tylko obszarów należących do wskazanego modułu (Module). Możliwe jest też wyłączenie tego obszaru, który jest aktualnie podświetlony (opcja Current).
B.3.2. Statystyka częstości i ścieżek wywołań podprogramów (Callers)
Okienko Callers, w którym wyświetlana jest statystyka częstości i ścieżek wywołań podprogramów, otrzymuje się za pomocą opcji menu głównego View|Callers.
Aby Turbo Profiler zgromadził dane niezbędne do wyświetlenia tego rodzaju statystyki, należy go wpierw odpowiednio skonfigurować. Przede wszystkim trzeba załączyć (Enable) gromadzenie danych do tego rodzaju statystyki, za pomocą opcji menu głównego Statistics|Callers (Turbo Profiler domyślnie blokuje tę funkcję). Precyzyjniejszą konfigurację można otrzymać za pomocą menu lokalnego okienka Module, zawierającego tekst źródłowy programu. Wybieramy opcję Callers. Powoduje to rozwinięcie okienka dialogowego Stack trace. Ustalamy, czy tryb zbierania informacji zostanie określony tylko dla bieżącego podprogramu, dla wszystkich podprogramów bieżącego modułu, czy dla wszystkich podprogramów w programie (opcje: This routine, This module, All routines), po czym włączamy odpowiedni tryb. Opcja All callers pozwala na zgromadzenie danych o pełnej ścieżce wywołań (to jest o liście podprogramów, których kolejne wywołania spowodowały wywołanie danego). Opcja Immediate caller ogranicza zapamiętywane dane do podprogramu bezpośrednio wywołującego dany podprogram, a opcja None wyłącza zbieranie danych. Po uruchomieniu Turbo Profiler w sposób domyślny włącza tryb zbierania danych o pełnej ścieżce wywołań dla wszystkich podprogramów.
Po wykonaniu analizowanego programu okienko Callers będzie zawierało gotową statystykę, na którą składa się lista wywoływanych w obrębie programu podprogramów. Dla każdego podprogramu dana jest liczba wywołań oraz nazwa podprogramu lub podprogramów (z ewentualną pełną ścieżką wywołań), które dany podprogram wywołały.
Za pomocą opcji Sort z menu lokalnego prawej części okienka Callers można ustalić kolejność wyświetlania elementów w okienku: chronologicznie, to jest według kolejności wywoływania w programie (opcja Called) lub według liczby odwołań.
B.3.3. Statystyka nakładek (Overlays)
Okienko Overlays, w którym wyświetlana jest statystyka częstości odwołań do podprogramów nakładkowanych, otrzymuje się za pomocą opcji menu głównego View|Overlays.
Aby Turbo Profiler zgromadził dane niezbędne do wyświetlenia tego rodzaju statystyki, należy załączyć (Enable) gromadzenie danych na temat nakładkowania za pomocą opcji menu głównego Statistics|Overlays (Turbo Profiler domyślnie blokuje tę funkcję). Dodajmy, że uaktywnienie zarówno opcji View|Overlays, jak i Statistics|Overlays nie jest możliwe, jeśli załadowany program nie korzysta z systemu nakładkowania.
Po wykonaniu analizowanego programu okienko Overlays zawiera statystykę opisującą sposób wykorzystania systemu nakładek w dwóch ujęciach:
liczbowym (Count) - ile razy program ładował każdą nakładkę. Dodatkowo podawany jest rozmiar nakładek (dane są prezentowane w postaci liczb oraz histogramu);
historycznym (History) - kiedy program ładował poszczególne nakładki i w jakiej kolejności - opcja szczególnie przydatna przy wykrywaniu "migotania" nakładek.
Wyboru pomiędzy powyższymi dwoma sposobami ujęcia statystyki dokonuje się za pomocą opcji Display menu lokalnego okienka Overlays. Druga z opcji, Inspect, pozwala na załadowanie do okienka Module tekstu źródłowego zawierającego nakładkowany moduł.
B.3.4. Statystyka przerwań (Interrupts)
Okienko Interrupts, w którym wyświetlana jest statystyka częstości i sposobu odwołań do systemu przerwań, otrzymuje się za pomocą opcji menu głównego View|Interrupts.
Aby Turbo Profiler zgromadził dane niezbędne do wyświetlenia tego rodzaju statystyki, należy załączyć (Enable) gromadzenie danych na temat przerwań za pomocą opcji menu głównego Statistics| Interrupts (Turbo Profiler domyślnie włącza tę funkcję).
Po wykonaniu analizowanego programu okienko Interrupts zawiera statystykę, opisującą sposób i zakres wykorzystania przerwań.
W górnej lewej części okienka Interrupts wyświetlane są identyfikatory (numer oraz rodzaj, np. Video) poszczególnych przerwań wykorzystywanych przez program, dla których gromadzone są dane statystyczne. Listę tę można poszerzyć, wykorzystując opcję Add menu lokalnego górnej części okienka Interrupts i podając numer przerwania lub też opcję Pick, i wybierając z listy nazwę predefiniowanego przerwania (dostępne są: Video, Disk, Keyboard, DOS i Mouse). Opcje Remove i Delete All pozwalają na usunięcie jednego lub wszystkich przerwań. Ponadto, za pomocą menu lokalnego można określić, czy dane o wywołaniach przerwań w ogóle mają być gromadzone (opcja Collection) oraz czy zapamiętywać dane o wywołaniach poszczególnych podfunkcji (opcja Subfunctions).
Po zaznaczeniu kursorem jednego z przerwań w górnej lewej części okienka, w prawej części okienka pojawiają się informacje dotyczące tego przerwania: sposób gromadzenia danych (por. opcje Collections i Subfunctions) oraz liczba i łączny czas wykonania odwołań.
W dolnej części okienka Interrupts wyświetlane są szczegółowe informacje o przerwaniach, jakie nastąpiły podczas wykonania programu, z dokładnością do poszczególnych podfunkcji (por. opcję Subfunctions opisaną wyżej). Zestaw wyświetlanych informacji, poza identyfikatorem przerwania (podfunkcji), zależy od trybu wyświetlania:
Time - podawane są dane (liczbowe i w formie histogramu) o czasie przebywania sterowania w procedurach obsługi poszczególnych przerwań;
Calls - podawane są dane (liczbowe i w formie histogramu) o liczbie wystąpień (odwołań) poszczególnych przerwań (podfunkcji);
Both Time and Calls - kombinacja obu powyższych trybów;
Events - prezentacja kolejnych wywołań poszczególnych przerwań (podfunkcji) w porządku chronologicznym.
Wyboru jednego z powyższych trybów dokonuje się za pomocą opcji Display lokalnego menu dolnej części okienka Interrupts.
B.3.5. Statystyka plików (Files)
Okienko Files, w którym wyświetlana jest statystyka częstości i sposobu odwołań do systemu plików, otwiera się za pomocą opcji menu głównego View|Files.
Aby Turbo Profiler zgromadził dane niezbędne do wyświetlenia tego rodzaju statystyki, należy załączyć (Enable) gromadzenie danych na temat operacji plikowych za pomocą opcji menu głównego Statistics|Files (Turbo Profiler domyślnie włącza tę funkcję).
Po wykonaniu analizowanego programu okienko Files zawiera statystykę opisującą sposób wykorzystania systemu plików.
W górnej lewej części okienka jest wyświetlana lista plików, dla których gromadzone są dane statystyczne. Lista ta zawiera również pliki odpowiadające standardowym strumieniom wejścia-wyjścia. Wybór pliku przez umieszczenie kursora na nazwie pliku powoduje wyświetlenie w prawej części okienka informacji dotyczących tego pliku, obejmujących:
deskryptor pliku;
kiedy i na jak długo plik został otwarty;
czas trwania operacji otwarcia pliku;
liczba odczytów z pliku, liczba wczytanych bajtów i łączny czas trwania operacji odczytu;
liczba zapisów do pliku, liczba zapisanych bajtów i łączny czas trwania operacji zapisu;
czas trwania operacji zamknięcia pliku.
Za pomocą menu lokalnego górnej części okienka Files można wyłączyć zbieranie informacji o poszczególnych plikach (opcja Collection Disabled) lub przełączyć stopień szczegółowości danych (opcja Details Disabled powoduje, że dane dotyczące czasu odczytu lub zapisu nie będą gromadzone dla każdego pliku osobno). Ustawienie opcji When Full decyduje o reakcji Turbo Profilera na sytuację, w której brakuje pamięci na dalsze gromadzenie danych statystycznych: wykonywanie programu może zostać wówczas wstrzymane (Stop) lub nowe dane mogą być zapamiętywane w miejscu poprzednio zebranych (Wrap).
Dolna część okienka Files zawiera zestawienie danych o poszczególnych operacjach plikowych. Kolejno wykonywane operacje traktowane są jako zdarzenia i każde z nich (spośród zarejestrowanych, por. wyżej opisane opcje Collection i Details) znajduje tu swoje odzwierciedlenie. Dane o poszczególnych operacjach mogą być wyświetlane w dwóch trybach:
Graph - obok nazwy operacji (Open, Read itd.) podawany jest numer tzw. uchwytu (ang. handler) pliku, a także czas realizacji w formie liczbowej oraz jako histogram;
Detail - obok nazwy operacji (Open, Read itd.) podawane są te same dane, co powyżej, oraz określenie czasu, w którym operacja została wykonana. Brakuje prezentacji graficznej, informacja tekstowa jest jednak podawana w postaci pełnego zdania angielskiego (w przeciwieństwie do skrótowej formy w poprzednim trybie).
Wyboru pomiędzy powyższymi trybami dokonuje się za pomocą opcji Display menu lokalnego dolnej części okienka Files, które wyświetla specjalne okienko dialogowe. Można tu jeszcze wybrać sposób ułożenia poszczególnych zdarzeń: chronologicznie (Sort: Start time) lub według czasu trwania (Sort: Duration).
B.4. Przeglądanie danych niestatystycznych
Poza opisywanymi powyżej różnymi rodzajami zestawień statystycznych generowanych przez Turbo Profiler, wykorzystuje się też pewne rodzaje okienek przeznaczonych do wyświetlania informacji nie będących statystykami. Z jednym z takich okienek, przeznaczonym do wyświetlania tekstów źródłowych (Module), zetknęliśmy się już przy opisywaniu wstępnej konfiguracji Turbo Profilera.
B.4.1. Teksty źródłowe modułów - okienka typu Module
Po uruchomieniu Turbo Profilera i załadowaniu programu do analizy automatycznie wyświetlane jest okienko typu Module, zawierające tekst źródłowy głównego modułu programu. Analogiczne okienko można otworzyć w każdej chwili za pomocą opcji głównego menu programu View|Module lub naciskając klawisz F3. Turbo Profiler zapyta, który z należących do programu modułów źródłowych należy wczytać. Tej samej opcji menu używa się też w celu zmiany modułu znajdującego się w okienku Module.
Opisując wstępną konfigurację Turbo Profilera omówiono opcje dotyczące podziału programu na obszary, znajdujące się w menu lokalnym okienek Module. Opis jeszcze innych opcji znajduje się w rozdziale poświęconym statystykom częstości i ścieżek wywołań podprogramów. Obecnie opiszemy pewne opcje wspomagające pracę z samym tekstem źródłowym.
Opcja Line pozwala na szybkie umieszczenie kursora w wierszu tekstu o podanym numerze. Opcja Search wyszukuje w tekście podany łańcuch i umieszcza przy nim kursor, a opcja Next wyszukuje kolejne wystąpienie tego łańcucha. Opcja Goto umieszcza kursor w tym miejscu tekstu źródłowego modułu, który odpowiada rozkazowi znajdującemu się pod wskazanym przez użytkownika adresem.
Opcja Module zmienia wyświetlany w okienku moduł na inny spośród należących do programu modułów źródłowych (identycznie jak opcja głównego menu View|Module). Opcja File pozwala załadować jeden z plików źródłowych dla aktualnie analizowanego modułu (np. Plik typu include). Opcja Edit wywołuje zewnętrzny edytor (wybrany w trakcie konfigurowania). Edycji poddawany jest plik z okna Module.
B.4.2. Podział programu na obszary - okienka typu Areas
Za pomocą opcji menu głównego View|Areas tworzy się okienka prezentujące pełną informację na temat podziału programu na obszary. Wyszczególnione są identyfikatory wszystkich obszarów (numery linijek lub nazwy podprogramów) wraz z położeniem w pliku źródłowym, długością (w linijkach) i trybem gromadzenia danych.
Tryby gromadzenia danych w obszarach omówiono w punkcie poświęconym wstępnej konfiguracji Turbo Profilera. Opisane tam opcje menu lokalnego okienek z tekstem źródłowym: Add Areas, Remove Areas i Options są dostępne również w okienkach typu Areas. Opcja Sort menu lokalnego okienek Areas pozwala ustalić sposób sortowania elementów wyświetlanych w okienku.
B.4.3. Podprogramy - okienka typu Routines
Za pomocą opcji menu głównego View|Routines tworzy się okienka zawierające wyszczególnienie wszystkich podprogramów analizowanego programu.
B.4.4. Treść programu w formie kodu maszynowego 80x86 - okienka
typu Disassembly
Za pomocą opcji menu głównego View|Disassembly tworzy się okienka CPU 80x86 zawierające kod programu w formie rozkazów procesorów 80x86.
Menu lokalne okienka CPU 80x86 pozwala na przesunięcie kursora w miejsce odpowiadające podanemu przez użytkownika adresowi (opcja Goto) lub zawartości rejestru licznika rozkazów CS:IP podczas zatrzymania programu na pułapce (opcja Origin), przeniesienie kursora na adres docelowy zawarty w aktualnie wskazywanej instrukcji skoku, przerwania itp. (opcja Forward) lub poprzedni adres sprzed wykonania takiej instrukcji (opcja Previous).
B.4.5. Pliki tekstowe - okienka typu Text File
Za pomocą opcji menu głównego View|Text File tworzy się okienka zawierające dowolny, wskazany przez użytkownika plik tekstowy.
W menu lokalnym okienek tekstowych dostępne są opcje Goto, Search, Next, File i Edit, osiągalne również w okienku --> Module[Author:ZO] .
Opcje menu programu Turbo Profiler
Tabela B.1
Menu |
Opcje |
Opis |
≡ |
|
Menu informacji systemowej |
|
Repaint desktop |
Odświeżenie zawartości ekranu |
|
Restore standard |
Odtworzenie domyślnego stanu środowiska IDE |
|
About |
Wyświetlenie informacji o wersji Turbo Profilera |
File |
|
Menu operacji plikowych |
|
Open |
Otwarcie pliku z programem (*.EXE) przeznaczonym do analizy |
|
Change dir |
Zmiana aktualnego katalogu dyskowego |
|
Get info |
Wyświetlenie informacji o analizowanym programie i wykorzystaniu pamięci operacyjnej (nazwa pliku programowego, status wykonania, tryb działania Turbo Profilera, raport o wykorzystaniu pamięci, wersja systemu operacyjnego oraz aktualny czas i data systemowa) |
|
|
cd. tabeli B.1 |
Menu |
Opcje |
Opis |
|
DOS shell |
Tymczasowe przekazanie sterowania do systemu operacyjnego. Powrót przez wprowadzenie polecenia EXIT |
|
Quit (Alt-X) |
Zakończenie działania Turbo Profilera |
View |
|
Menu przeglądania statystyk i innych danych |
|
Module (F3) |
Otwarcie okienka Module z tekstem źródłowym modułów |
|
Execution profile |
Otwarcie okna Execution Profile przedstawiającego profil dynamiczny programu |
|
Callers |
Otwarcie okienka Callers zawierającego statystykę częstości i ścieżek wywołań podprogramów |
|
Overlays |
Otwarcie okna Overlays zawierającego statystykę systemu nakładek |
|
Interrupts |
Otwarcie okna Interrupts zawierającego statystykę systemu przerwań |
|
Files |
Otwarcie okna Files zawierającego statystykę operacji plikowych |
|
Areas |
Otwarcie okna Areas zawierającego specyfikację podziału programu na obszary |
|
Routines |
Otwarcie okna Routines zawierającego wyszczególnienie podprogramów w analizowanym programie |
|
Disassembly |
Otwarcie okna CPU 80x86 zawierającego kod analizowanego programu w w asemblerze 80x86 |
|
Text file |
Otwarcie okienka tekstowego zawierającego treść dowolnego pliku tekstowego |
Run |
|
Menu aktywizacji i wykonywania analizowanych programów |
|
Run (F9) |
Uruchomienie analizowanego programu i inicjalizacja gromadzenia danych statystycznych lub wznowienie programu po zatrzymaniu na pułapce |
|
Program reset (Ctrl-F2) |
Zakończenie wykonywania analizowanego programu i przygotowanie do ponownego uruchomienia programu od początku |
|
Arguments |
Ustalenie parametrów wykonania analizowanego programu |
Statistics |
|
Menu konfiguracyjne gospodarki rezultatami analiz |
|
Callers |
Przełącznik gromadzenia danych statystycznych o częstości i ścieżkach wywołań podprogramów |
|
Files |
Przełącznik gromadzenia danych statystycznych o systemie plików |
|
Interrupts |
Przełącznik gromadzenia danych statystycznych o systemie przerwań |
|
Overlays |
Przełącznik gromadzenia danych statystycznych o systemie nakładek |
|
Profiling options |
Ogólna konfiguracja procesu gromadzenia danych statystycznych. |
|
Accumulation |
Przełącznik gromadzenia informacji |
|
Delete all |
Kasowanie wszystkich statystyk zapisanych dla danej sesji |
|
Save |
Zachowanie w pliku dyskowym wszystkich statystyk z bieżącej sesji oraz informacji o zaznaczonych obszarach |
|
Restore |
Odtworzenie zawartości uprzednio zachowanego pliku danych statystycznych |
|
Menu obsługi drukowania raportów |
|
|
Statistics |
Wydrukowanie zawartości okien ze statystykami |
|
Module |
Wydrukowanie jednego z modułów źródłowych analizowanego programu |
|
Options |
Konfiguracja postaci wydruku. Ustalić tu można rozmiar drukowanej strony, skierować wydruk do pliku oraz określić postać graficzną wydruku |
Options |
|
Menu konfiguracji środowiska pracy Turbo Profilera |
|
Macro |
Rozwinięcie podmenu tworzenia i usuwania makropoleceń; obejmuje podopcje: Create - stworzenie nowego makropolecenia Stop recording - zakończenie rejestracji makropolecenia Remove - usunięcie wskazanego makropolecenia Delete all - usunięcie wszystkich makropoleceń |
|
Display options |
Konfiguracja parametrów graficznych środowiska IDE - sposób przełączania ekranu między IDE a analizowanym programem, rozdzielczość ekranu, rozmiar tabulatora i dopuszczalna długość identyfikatorów |
|
Path for source |
Wprowadzenie listy katalogów, w których Turbo Profiler będzie poszukiwał plików źródłowych analizowanych programów |
|
Save options |
Zapis różnych elementów konfiguracji Turbo Profilera (opcje, układ okienek na ekranie lub makropolecenia) do pliku konfiguracyjnego |
|
Restore options |
Odtworzenie uprzednio zapamiętanej konfiguracji systemu |
Window |
|
Menu obsługi okienek zintegrowanego środowiska pracy Turbo Profilera |
|
Zoom (F5) |
Zmiana rozmiaru aktywnego okienka na maksymalny lub przywrócenie do normalnej wielkości |
|
Next (F6) |
Aktywizacja następnego spośród otwartych okienek |
|
Next pane (Tab) |
Aktywizacja następnego elementu (fragmentu) aktywnego okienka |
|
Size/move |
Zmiana rozmiaru i położenia aktywnego okienka |
|
Iconize/restore |
Sprowadzenie aktywnego okienka do postaci ikony na ekranie lub przywrócenie do normalnej wielkości |
|
Close (Alt-F3) |
Zamknięcie aktywnego okienka |
|
Undo close |
Odtworzenie ostatnio usuniętego okienka |
|
User screen |
Przełączenie na ekran używany przez analizowany program |
|
... |
Szybkie przełączenie do dowolnego otwartego okienka |
Help |
|
Menu podpowiedzi systemowych - podpowiedzi kontekstowe dostępne przez F1 |
|
Index (Shift-F1) |
Prezentacja listy haseł dla których przewidziano teksty z podpowiedziami |
|
Previous topic (Alt-F1) |
Powrót do ostatnio wyświetlonego tekstu podpowiedzi |
|
Help to help |
Otwarcie okna zawierającego informacje o sposobach korzystania z systemu podpowiedzi |
DODATEK C
Środki ułatwiające uruchamianie
w systemACH Turbo Pascal 6.0/7.0
i Borland Pascal 7.0
Środki ułatwiające uruchamianie w systemACH Turbo Pascal 6.0/7.0 i Borland Pascal 7.0
Usunięcie błędów składniowych jest jedynie warunkiem koniecznym utworzenia poprawnego programu. Przygotowanie programu, którego działanie ściśle odpowiada założonym specyfikacjom, najczęściej wymaga jego uruchomienia. Zadanie to wspomaga symboliczny program uruchomieniowy wchodzący w skład środowiska zintegrowanego, nazywany dalej debuggerem wbudowanym lub po prostu debuggerem.
Do najważniejszych właściwości debuggera należy zaliczyć:
możliwość śledzenia przebiegu wykonania programu,
możliwość analizowania wyników wykonania w miarę ich generowania,
możliwość obserwowania przebiegu zmian wartości zmiennych i wyrażeń,
możliwość dynamicznego modyfikowania wartości zmiennych, w zależności od przebiegu wykonania programu.
Dzięki temu, że debugger stanowi część systemu zintegrowanego (obok edytora i kompilatora), typowy cykl uruchomieniowy składający się z edycji, kompilacji i uruchamiania może odbywać się bez opuszczania systemu. W przypadku, gdy możliwości debuggera są niewystarczające lub rozmiar programu jest zbyt duży, można posłużyć się debuggerem zewnętrznym.
C.1. Tworzenie informacji dla debuggera
Przed przystąpieniem do uruchamiania niezbędne jest wygenerowanie programu binarnego z dodatkowymi informacjami dla debuggera. W tym celu należy skompilować program z odpowiednimi opcjami kompilacji. Można ustawić je poprzez zaznaczenie odpowiednich pozycji w oknie dialogowym w menu Options|Compiler.
W czasie kompilacji kompilator tworzy listę używanych identyfikatorów, zwaną tablicą symboli. Lista ta zawiera nazwy zmiennych, stałych, typów, procedur i funkcji. Do uruchamiania potrzebna jest dodatkowa informacja o numerach wierszy w programie źródłowym dla każdego z symboli. Kompilator dodaje te informacje, jeśli zaznaczone jest pole Debug Information w oknie dialogowym Options|Compiler lub gdy w programie zostanie użyta dyrektywa kompilatora $D+.
C.1.1. Debugger wbudowany czy zewnętrzny
W oknie dialogowym Options|Debugger możemy wskazać kompilatorowi, czy ma generować informacje dla debuggera wbudowanego lub zewnętrznego (np. Turbo Debuggera).
C.1.2. Informacje o modułach
Przy uruchamianiu dużych programów zbudowanych z wielu modułów może okazać się, że informacja dla debuggera jest zbyt duża i nie mieści się w pamięci operacyjnej, uniemożliwiając uruchamianie. W tym przypadku można usunąć informację o symbolach lokalnych w modułach. Aby to uzyskać, należy wyłączyć opcję Local Symbols w oknie dialogowym Compiler Options lub umieścić w programie dyrektywę $L-.
Po wyłączeniu generacji informacji dla lokalnych symboli, nadal jest generowana pełna informacja dla symboli z sekcji interface, co umożliwia symboliczne uruchamianie programów korzystających z danego modułu.
C.2. Sterowanie wykonaniem programu
Jedną z najważniejszych zalet wbudowanego debuggera jest umożliwienie sterowania wykonywaniem programu. Dzięki możliwości sterowania wykonywaniem programu łatwiej można wydzielić część programu, która jest przyczyną problemów. Debugger udostępnia pięć podstawowych środków ułatwiających kontrolę wykonywania programu. Pozwala na:
wykonanie kroku zwykłego,
wykonanie kroku z wnikaniem,
wykonanie programu do wiersza z kursorem,
zastawienie pułapek,
przerwanie sesji uruchamiania programu.
Najmniejszą jednostką wykonania programu w czasie uruchamiania jest wiersz tekstu programu. Oznacza to, że można kontrolować wykonanie programu z dokładnością do pojedynczych wierszy programu. Jeśli więc w jednej wierszu programu zapisano więcej niż jedną instrukcję, niemożliwe jest wykonanie ich oddzielnie. Debugger zawsze informuje o tym, który wiersz będzie wykonywany jako następny przez jego wyróżnienie.
C.2.1. Krok zwykły
Krok zwykły (Step Over) jest najprostszą metodą wykonywania małych fragmentów programu. Wybranie opcji Run|Step Over lub naciśnięcie klawisza F8 powoduje wykonanie zaznaczonego wiersza programu (włączając wszystkie funkcje i procedury); w szczególności zapisana w jednym wierszu instrukcja pętli wykona się w całości. Po wykonaniu wyróżniany jest następny wiersz.
C.2.2. Krok z wnikaniem
Krok z wnikaniem (Trace Into/Step Into), podobnie jak krok zwykły, umożliwia wykonanie jednego wiersza programu. Jeśli jednak wyrażenie zawiera wywołanie podprogramu, to następuje jego wywołanie i możliwe jest krokowe wykonanie jego ciała. Analogicznie, jeżeli wiersz zawiera pętlę zapisaną w całości w tym wierszu, krok wykonany w tym trybie spowoduje wykonanie jednej iteracji. Krok z wnikaniem zostaje wykonany w wyniku wybrania opcji Run|Trace lub po naciśnięciu klawisza F7.
W przypadku kroku z wnikaniem odmiennie przebiega również proces rozpoczęcia wykonania programu oraz modułu. Wykonanie wiersza zawierającego słowo kluczowe begin z głównego bloku programu powoduje wywołanie kodu inicjalizacji (jeśli taki istnieje) dla każdego z modułów używanych przez program, w takiej kolejności, w jakiej występują one w wyrażeniu uses. Analogicznie, rozkaz wykonania begin z głównego bloku modułu powoduje wykonanie sekcji inicjalizacji każdego innego modułu przez niego użytego. Krok z wnikaniem umożliwia analizę pracy sekcji inicjalizacyjnych każdego z modułów.
Jeśli w programie używane są obiekty, debugger traktuje ich metody dokładnie tak, jak wywołanie zwykłej funkcji czy procedury. Wykonanie kroku w wierszu, w którym występuje wywołanie metody obiektu, spowoduje w kroku zwykłym jej wykonanie, a w kroku z wnikaniem umożliwi śledzenie wykonywania jej kodu.
C.2.3. Wykonywanie fragmentu programu do wiersza z kursorem
Z reguły nie chcemy wykonywać całego programu krokowo. Wtedy w celu przyspieszenia wykonywania fragmentów w danej chwili nieistotnych możemy nakazać wykonanie programu do miejsca określonego pozycją kursora tekstowego. Dokonujemy tego używając opcji Run|Go To Cursor, lub prościej - naciskając klawisz F4. Przed wykonaniem wiersza z kursorem nastąpi zatrzymanie i możliwe jest dalsze śledzenie lub praca krokowa. Zauważmy, że jest to bardzo wygodny sposób na dojście do miejsca, od którego rozpoczniemy dokładną analizę zachowania się programu.
C.2.4. Używanie pułapek
W systemie Turbo Pascal istnieje bardzo wygodne narzędzie do wstrzymywania wykonywania programu w zadanym miejscu, z możliwością określenia dodatkowych warunków. Miejsce zatrzymania określa tzw. pułapka, czyli wiersz zawierający wykonywalny kod (nie może to być komentarz lub deklaracja). Żeby zastawić pułapkę, należy przemieścić kursor tekstowy do wiersza, przed którym ma się zatrzymać wykonywanie programu. Zastawienie pułapki następuje przez wybranie operacji Toggle Breakpoint z lokalnego menu edytora (Debug|Toggle Breakpoints w wersji 6.0) lub przez naciśnięcie kombinacji Ctrl-F8. Wiersz, w którym zastawiona jest pułapka, zostaje wyróżniony. Pułapkę usuwamy poprzez ponowne wykonanie wyżej opisanej operacji. Można także określić warunki aktywizowania się pułapki. Warunkiem może być wyrażenie logiczne lub liczba przejść przez daną pułapkę bez zatrzymania. Po spełnieniu warunku program zostanie zatrzymany. System umożliwia również edycję opisu pułapek w oknie dialogowym aktywizowanym przez wybranie operacji Debug|Breakpoints|Edit.
C.2.5. Przerwanie sesji uruchamiania programu
Przerwanie sesji uruchamiania programu, na przykład w celu wznowienia uruchamiania programu, następuje w wyniku wybrania polecenia Run|Reset Program (Ctrl-F2).
C.3. Wyszukiwanie określonych punktów w programie
W systemie Turbo Pascal istnieją dwie (w Borland Pascalu trzy) metody wyszukiwania określonych punktów w programie. Pierwszą z nich jest wykonanie operacji Search|Find Procedure, która znajduje miejsce definicji szukanej procedury lub funkcji. Szczególnie przydatne jest to w przypadku uruchamiania danej procedury.
Druga metoda pozwala na prześledzenie historii wywołań podprogramów (wraz z parametrami aktualnymi) oraz na znalezienie miejsca ich wywołań w programie źródłowym. Jest to szczególnie przydatne, jeżeli przez pomyłkę zaczęliśmy śledzić kod podprogramu zamiast wykonać go krokowo. Wystarczy wówczas korzystając z historii wywołań znaleźć miejsce jego wywołania i po ustawieniu kursora w następnym wierszu wykonać program do tego wiersza (Run|Go to Cursor - F4). Okno dialogowe historii wywołań aktywizowane jest poprzez wybór Debug|Call Stack (Ctrl-F3). W pakiecie Borland Pascal w wersji 7.0 wprowadzono dodatkowe narzędzie, które nazywamy przeglądarką obiektów (Object Browser). Pozwala ona na znajdowanie definicji i odwołań do zmiennych, stałych, procedur, funkcji i obiektów. Ponadto może pokazywać hierarchię klas. Odpowiednie okno przeglądarki pojawia się w wyniku wybrania w menu Search jednej z opcji Object, Globals, Units lub Symbols. Pozwalają one na obserwację odpowiednio: wszystkich obiektów, wszystkich symboli globalnych i modułów w programie. Opcja Search|Symbol pozwala na podanie nazwy symbolu, o którym informacje chcemy uzyskać. Przeglądarka może pokazywać w zależności od rodzaju symbolu informacje w trzech typach widoków. Są to:
widok zakresu (scope view) - pokazuje informacje o wszystkich symbolach zadeklarowanych w zakresie programu, modułu lub obiektu,
widok odniesienia (reference view) - pokazuje miejsca, w których występują odwołania do danego symbolu; informacja ta zawiera nazwę pliku źródłowego oraz numer wiersza, w którym występuje,
widok dziedziczenia (inheritance view) - pokazuje hierarchię klas użytych w programie
Przełączanie pomiędzy widokami odbywa się poprzez naciśnięcie kombinacji odpowiednio Ctrl-I, Ctrl-S, Ctrl-O.
C.4. Obserwacja wyników działania programu
W trakcie uruchamiania programu bardzo użyteczna jest możliwość obserwacji wyników pojawiających się na ekranie.
System Turbo Pascal w każdym momencie uruchamiania pozwala przełączyć się pomiędzy ekranem środowiska a ekranem użytkownika. Efekt ten można uzyskać wybierając operację Debug|User Screen lub naciskając kombinację Alt-F5. Powrót do ekranu środowiska następuje po naciśnięciu dowolnego klawisza.
Dostępne są również trzy tryby przełączania automatycznego na ekran użytkownika na czas wykonania pojedynczego kroku. Wybór trybu następuje w oknie dialogowym pojawiającym się po wybraniu opcji Options|Debugger w sekcji Display swapping. Standardowo wybrane jest przełączanie inteligentne (Smart), które powoduje przełączanie się na ekran użytkownika tylko w przypadku wykonywania operacji generujących wyniki na ekran lub wywoływania procedur (nawet jeśli procedury te nie operują na ekranie). Po zakończeniu wykonania operacji ekran przełącza się na ekran środowiska. Można też nakazać przełączanie dla każdej instrukcji (Always) lub wyłączyć je całkowicie (None). W przypadku wykorzystywania procedur graficznych tej ostatniej opcji nie należy używać. Aktywizacja przełączania bezwarunkowego jest przydatna w przypadku używania operacji zapisujących bezpośrednio do pamięci ekranu.
System Turbo Pascal umożliwia również obserwację tekstowych wyników działania programu w dodatkowym oknie środowiska. Aby stworzyć takie okno lub uczynić je widocznym (nie przykrytym przez inne okna środowiska) należy wybrać operację Debug|Output.
W komputerach z dwoma kartami graficznymi możliwe jest całkowite rozdzielenie ekranu środowiska systemu od ekranu, na który program generuje swoje wyniki. Każdy z ekranów jest wtedy związany z jedną kartą graficzną. Pracę w tym trybie włączamy uaktywniając system Turbo Pascal z parametrem /D. Możliwość ta jest niezwykle przydatna w przypadku uruchamiania programów graficznych.
C.5. Obserwacja wartości wyrażeń i modyfikacja wartości zmiennych
Środowisko Turbo Pascala ma dwa narzędzia do badania wartości wyrażeń - okno obserwacyjne (Watches) i okno dialogowe obserwacji i modyfikacji (Evaluate and Modify). Analizowane wyrażenia mogą zawierać: stałe, zmienne wszystkich typów (łącznie z definiowanymi przez użytkownika), operatory i funkcje wbudowane takie jak abs, chr, succ, itp. (nb. w Turbo Pascalu funkcji tej grupy można używać w definicji stałych). W środowisku mamy dodatkowo możliwość podglądania wartości rejestrów procesora. Odpowiednie okno pojawia się po wywołaniu Debug|Register (w wersji 6.0 Window|Register).
C.5.1. Okno obserwacyjne
W oknie tym są pokazywane aktualne wartości wybranych przez użytkownika wyrażeń. Otwarcie okna następuje przez wybranie operacji Debug|Watch (Window|Watch w wersji 6.0) lub po nakazaniu podglądu wyrażenia. Dodanie wyrażenia do okna obserwacyjnego następuje przez wybranie operacji Debug|Add Watch (Debug|Watches|Add Watch w wersji 6.0) lub przez naciśnięcie sekwencji Ctrl-F7. Jeżeli aktywnym oknem jest okno obserwacyjne, wystarczy nacisnąć klawisz Ins. W oknie, które się w tym momencie pojawia, należy wpisać wybrane wyrażenie. Domyślnie przyjmowane jest słowo w pozycji kursora. Usunięcie wyrażenia z okna obserwacji polega na jego wybraniu i naciśnięciu klawisza Del lub Ctrl-Y. Można też usunąć wszystkie wyrażenia wybierając operację Clear all z lokalnego menu okna obserwacyjnego (Alt-F10) (tylko w wersji 7.0). Obserwowane wyrażenia można również poddawać edycji przez naciśnięcie Enter po wcześniejszym ich wybraniu.
C.5.2. Okno dialogowe wartościowania i modyfikacji
Aby zwartościować wyrażenie należy wybrać operację Debug|Evaluate lub nacisnąć kombinację Ctrl-F4. Wyświetlone zostanie okno dialogowe, po czym należy wprowadzić wyrażenie do modyfikacji. Domyślnie przyjmowane jest słowo wskazane kursorem (słowa następne można wczytać naciskając strzałkę w prawo). Jeżeli wyrażeniem jest zmienna typu prostego, skalarny element tablicy, skalarne pole rekordu itp., to wartość wyrażenia można zmienić, wpisując nową wartość w polu New Value. Przypisywana wartość może być wyrażona stałą, zmienną lub wyrażeniem. Należy zachować szczególną ostrożność przy modyfikacji indeksów tablic i wskaźników.
C.5.3. Format wydruku obserwowanych wyrażeń
Turbo Pascal pozwala na określenie sposobu wyświetlania informacji w oknach obserwacyjnych. W celu zmiany formatu należy po wyrażeniu (po przecinku) umieścić specyfikator formatu. W jego skład wchodzi opcjonalny licznik powtórzenia (liczba naturalna), za którym występują (również opcjonalnie) znaki formatujące przedstawione w tabeli C.1.
Tabela C.1
Znak formatujący |
Typy, dla których jest możliwa konwersja |
Funkcja |
$,H,X |
całkowite |
Przedstawia wartości w zapisie szesnastkowym. Właściwa wartość jest poprzedzona przedrostkiem $ |
C |
char, łańcuchy |
Przedstawia znaki specjalne ASCII o kodach z przedziału 0..31. Domyślnie pokazywane są one w formacie #xx |
D |
całkowite |
Przedstawia wartości dziesiętnie |
Fn |
Rzeczywiste |
Pokazuje liczbę rzeczywistą z dokładnością do n cyfr znaczących (n jest z zakresu 2..18, domyślnie n=11) |
NM |
wszystkie |
Przedstawia n bajtów rozpoczynając od adresu wskazywanego przez wyrażenie w postaci ciągu liczb szesnastkowych (zrzut pamięci). Ten znak formatujący może występować z innymi znakami: D - liczby przedstawiane są wtedy dziesiętnie, $, H, X - szesnastkowo, przy czym liczba jest poprzedzona przedrostkiem $. W przypadku użycia S lub C - obszar pamięci traktowany jest jak łańcuch znaków (ze znakami sterującymi lub bez) |
P. |
wskaźniki |
Przedstawia wskaźniki w formacie segment:offset zamiast Ptr(segment:offset) |
R |
rekordy, obiekty |
Poprzedza wartości pól ich nazwami |
S |
char, łańcuchy |
Pokazuje znaki specjalne ASCII 0..31 jako #xx. Jest to domyślny sposób wyświetlania łańcuchów i znaków. Użyteczny przy modyfikacji zrzutu pamięci (w połączeniu z nM) |
Dyrektywa warunkowa w Turbo Pascalu to {$IFDEF}, a w języku C - #ifdef.
Ćwiczenia Testowanie i Uruchamianie, ze względu na ścisły związek tych dwu czynności, są w aktualnym programie Laboratorium połączone i przeprowadzane jednocześnie, wobec czego pytania i zadania uzupełniają się wzajemnie i częściowo pokrywają.
Trudno jest zdefiniować wykrywalność błędów przez mechanizmy języków programowania. Na przykład w języku C, oprócz komunikatów o błędach, generowane są ostrzeżenia (ang. warnings) w wielu wątpliwych przypadkach, które mogą okazać się błędami różnych kategorii.
W niektórych systemach programowania brak nadania wartości początkowej jest traktowany jak błąd semantyczny, wykrywalny w czasie wykonywania programu.
Błędy przepisywania miały olbrzymie znaczenie w epoce kart dziurkowanych, dziś łatwiej je znaleźć i poprawić.
Należy unikać dokonywania poprawek bezpośrednio w kodzie wynikowym, uzyskanym z kompilacji programu, choć istnieją narzędzia to umożliwiające.
W środowiskach firmy Borland opcja Compile|Build all
W IDE Turbo Pascala dostępne opcje to: Options|Compiler|Range checking, Stack checking, IO, Overflow checking; W Borland C jedynie Options|Compiler|Entry/Exit Code|Stack options.
W systemie programowania Logitech Modula-2, przy błędzie wykonania generowany był tzw. post-mortem dump. Istniał również specjalny post-mortem debugger.
Ćwiczenia Testowanie i Uruchamianie, ze względu na ścisły związek tych dwu czynności, są w aktualnym programie Laboratorium połączone i przeprowadzane jednocześnie, wobec czego pytania i zadania uzupełniają się wzajemnie i częściowo pokrywają.
Program Turbo Profiler jest opisany w dodatku B.
Numer podfunkcji wyznaczany jest na podstawie zawartości rejestru AH procesora w momencie wywołania procedury obsługi przerwania.
Turbo Profiler konfiguruje się za pomocą osobnego programu o nazwie TFINST. Pozwala on, między innymi, zmodyfikować kolory elementów wizualnych Turbo Profilera, określić położenie plików, z którymi współpracuje program oraz zmienić parametry zintegrowanego środowiska programu.
14 Inżynieria programowania. Metody i ćwiczenia laboratoryjne
Rozdział 1: Testowanie programów 13
20 Inżynieria programowania. Metody i ćwiczenia laboratoryjne
Rozdział 2: Uruchamianie programów 19
80 Inżynieria programowania. Metody i ćwiczenia laboratoryjne
Rozdział 3: Optymalizacja czasowa programów 49
Dodatek A: Diagramy przepływu sterowania 57
Dodatek B: Wyznaczanie profilu dynamicznego programów za pomocą Turbo Profilera 71
Dodatek C: Środki ułatwiające uruchamianie w systemach Turbo Pascal 6.0/7.0... 79
Gromadzone przez Profiler dane to: czas wykonania programu oraz, dla każdego obszaru osobno, łączny czas wykonania działań 'związanych' z obszarem, współczynnik procentowego udziału 'czasu obszaru' w 'czasie programu', a także liczba 'wywołań' obszaru.
W trakcie sesji gromadzone są również informacje umożliwiające odtworzenie śladu wywołań procedur/funkcji danego programu, m.in. kompletne ścieżki (historie) wywołań tychże podprogramów. ścieżka taka jest zapisem drogi przekazywania sterowania w programie, prowadzącej do wywołania danego podprogramu. Historia wywołania danego podprogramu zawiera nazwy wszystkich podprogramów wywołujących kolejno siebie, aż do wywołania tego 'danego' podprogramu.
Pomiar czasu w środowisku Turbo Profiler'a odbywa się na zasadzie zliczania taktów zegarowych, generowanych przez zegar systemowy, lub (w trybie pasywnym analizy) przez lokalny generator ustawiany przez użytkownika (Statistics|Profiling options).
Czas spędzony w danym obszarze obliczany (timearea) jest według wzoru:
timearea = timertotal * countsarea / countstotal.
gdzie countsarea — liczba wykonań danego obszaru,
countstotal — globalna liczba wywołań obszarów,
timerglobal — globalna liczba taktów zegara.
true
false