r04.pdf

(231 KB) Pobierz
1
JAK WYKONUJE SIĘ TWÓJ KOD
79
4.
J AK WYKONUJE SIĘ
T WÓJ KOD
Omawiane w poprzednich rozdziałach metody „automatycznego” wykrywania
błędów — asercje, testy integralności podsystemów, itp. — stanowią narzędzia
niezwykle użyteczne i znaczenie ich naprawdę trudno przecenić, jednakże w nie-
których przypadkach okazują się one zupełnie „nieczułe” na błędy występujące w
testowanym kodzie. Przyczyna tego stanu rzeczy jest tyleż oczywista, co banalna;
wyjaśnijmy ją na (bliskim każdemu z nas) przykładzie zabezpieczenia domu czy
mieszkania.
Otóż najbardziej nawet wymyślne zabezpieczenie drzwi i okien okaże się zu-
pełnie nieprzydatne w sytuacji, gdy złodziej dostanie się do domu np. przez klapę
w dachu, czy też otworzy sobie drzwi dorobionym kluczem. Podobnie, najwraż-
liwszy nawet czujnik wstrząsowy zamontowany skrycie w magnetowidzie czy
komputerze nie uchroni przez kradzieżą np. drogocennej kolekcji obrazów. W
obydwu tych przypadkach zagrożenie pojawia się bowiem poza obszarami, na mo-
nitorowanie których zorientowane są urządzenia alarmowe.
Na identycznej zasadzie, najbardziej nawet wymyślne asercje, czy jeszcze bar-
dziej zaawansowane fragmenty kodu testujące występowanie spodziewanych wa-
runków, są coś warte jedynie wtedy, gdy w ogóle zostają wykonane! Brak alarmu
ze strony określonej asercji niekoniecznie świadczy o spełnieniu testowanego przez
tę asercję warunku, ale może być także wynikiem jej pominięcia; podobnie punkt
przerwania spowoduje zatrzymanie wykonywania programu jedynie wtedy, gdy
wykonana zostanie instrukcja, na której punkt ten ustawiono.
Wyjaśnia to poniekąd, dlaczego niektóre błędy potrafią skutecznie wymykać
się (niczym sprytne szczury) najgęstszej nawet sieci asercji czy punktów przerwań,
które tym samym stanowią tylko dodatkowy kłopot dla programisty, a także powo-
dują dodatkową komplikację i tak przeważnie już złożonego kodu.
C:\WINDOWS\Pulpit\Szymon\Niezawodność oprogramowania\r04.doc
79
228511893.005.png
80
NIEZAWODNOŚĆ OPROGRAMOWANIA
Uciekając się do małej metafory — skoro nie potrafimy schwytać grubego
zwierza w pułapkę, warto podążyć jego śladem; skoro sterowanie w naszym pro-
gramie omija ustanowione punkty przerwań i asercje, spróbujmy prześledzić jego
przebieg. Praca krokowa na poziomie zarówno kodu źródłowego, jak i instrukcji
maszynowych jest jedną z podstawowych funkcji każdego debuggera, jest też wbu-
dowana w znakomitą większość współczesnych środowisk projektowych.
U WIARYGODNIJ SWÓJ KOD
Opracowywałem kiedyś podprogram wykonujący specyficzną funkcję na potrzeby
większego projektu (środowiska programistycznego na Macintoshu). Podczas jego
rutynowego testowania znalazłem pewien błąd; jego konsekwencje dla innego
fragmentu wspomnianego projektu były tak poważne, iż pozostawało dla mnie za-
gadką, dlaczego nie został on dotąd wykryty, skoro powinien zamanifestować się
w sposób oczywisty.
Spotkałem się więc z autorem wspomnianego fragmentu i pokazałem mu
błędny fragment swojego kodu. Gdy także wyraził swe zdziwienie z powodu nie-
wykrycia widocznego jak na dłoni błędu, postanowiliśmy ustawić punkt przerwa-
nia w krytycznym miejscu kodu, a po zatrzymaniu — które naszym zdaniem mu-
siało nastąpić — kontynuować wykonywanie w sposób krokowy.
Załadowaliśmy nasz projekt, kliknęliśmy przycisk „Run” i... ku naszemu zdu-
mieniu program wykonał się w całości, bez zatrzymania! Wyjaśniało to skądinąd,
dlaczego błąd nie został zauważony, lecz samo w sobie nadal pozostawało rzeczą
zagadkową.
Ostatecznie przyczyna całego zamieszania okazała się być prozaiczna: po pro-
stu optymalizujący kompilator wyeliminował z kodu źródłowego instrukcje, które
uznał za zbędne; instrukcja, na której ustawiliśmy punkt przerwania miała nieszczę-
ście należeć do tego zestawu. „Wykonanie” kodu źródłowego krok po kroku (czy
raczej — próba takiego wykonania) uwidoczniłoby ten fakt w sposób nie budzący
wątpliwości.
Jako kierownik projektu, nalegam na programistów, by „krokowe” wykony-
wanie tworzonego przez nich kodu stanowiło integralny element jego testowania
— i, niestety, nazbyt często spotykam się ze stwierdzeniem, że przecież jest to
czynność czasochłonna i jako taka spowoduje wydłużenie pracy nad projektem.
To jednak tylko mała część prawdy: po pierwsze — dodatkowy czas przezna-
czony na krokowe testowanie kodu jest tylko drobnym ułamkiem czasu przezna-
czonego na stworzenie tegoż kodu; po drugie — uruchomienie programu w trybie
pracy krokowej nie jest w niczym trudniejsze od „normalnego” uruchomienia, bo-
wiem różnica tkwi zazwyczaj jedynie w... naciśniętych klawiszach; po trzecie (i
najważniejsze) — czas spędzony nad testowaniem programu stanowi swego ro-
dzaju inwestycję — w przeciwieństwie do czasu spędzonego na walkę z trudnymi
do wykrycia błędami, stanowiącego przykrą konieczność. W jednym z poprzed-
nich rozdziałów, pisząc o testowaniu metodą „czarnej skrzynki”, wyjaśniałem nie-
bagatelną rolę programowania defensywnego w walce z błędami — możliwość
obserwacji zachowania się własnego kodu dodatkowo zwiększa przewagę programi-
sty nad testerem obserwującym jedynie przetwarzanie danych przez „czarną skrzyn-
kę”. Śledzenie stworzonego (lub zmienionego) przez programistę kodu powinno za-
80
C:\WINDOWS\Pulpit\Szymon\Niezawodność oprogramowania\r04.doc
228511893.006.png
JAK WYKONUJE SIĘ TWÓJ KOD
81
tem stać się nieodłącznym elementem jego pracy i — choć może początkowo uciąż-
liwe — z czasem będzie po prostu pożytecznym nawykiem.
Nie odkładaj testowania krokowego do czasu, gdy pojawią się błędy.
P RZETESTUJ WSZYSTKIE ROZGAŁĘZIENIA
Praca krokowa, jak wszelkie inne narzędzia, może wykazywać zróżnicowaną skutecz-
ność w zależności od tego, jak umiejętnie jest stosowana. W szczególności — testo-
wanie kodu zwiększa prawdopodobieństwo uniknięcia błędów tylko wtedy, jeżeli
przetestuje się cały kod; niestety, w przypadku pracy krokowej sterowanie podąża
ścieżką wyznaczoną przez zachodzące aktualnie warunki — mowa tu oczywiście o
instrukcjach warunkowych, instrukcjach wyboru i wszelkiego rodzaju pętlach. Aby
więc przetestować wszystkie możliwe rozgałęzienia, należy przeprowadzić testowa-
nie przy np. różnych wartościach warunków instrukcji if , czy selektorów instruk-
cji switch .
Notabene pierwszymi ofiarami niedostatecznego testowania padają te frag-
menty kodu, które wykonywane są bardzo rzadko lub wcale — do tej ostatniej ka-
tegorii należą m.in. wszelkiego rodzaju procedury obsługujące błędy. Przyjrzyjmy
się poniższemu fragmentowi:
pbBlock = (byte *)malloc(32);
if (pbBlock == NULL)
{
obsługa błędu
.
.
.
}
xxxxxxxxxxxxxx
Programiści często pytają, jaki jest sens testowania każdej zmiany kodu spowodo-
wanej wzbogaceniem programu w nowe możliwości. Na tak postawione pytanie
można odpowiedzieć jedynie innym pytaniem — czy wprowadzone zmiany na
pewno, bez żadnych wątpliwości, wolne są od jakichkolwiek błędów? To prawda,
iż prześledzenie każdego nowego (lub zmodyfikowanego) fragmentu kodu wyma-
ga trochę czasu, lecz jednocześnie fakt ten staje się nieoczekiwanie przyczyną inte-
resującego sprzężenia zwrotnego — mianowicie programiści przywykli do konse-
kwentnego śledzenia własnego kodu wykazują tendencję do pisania krótkich i
przemyślanych funkcji, bowiem doskonale wiedzą, jak kłopotliwe jest śledzenie
funkcji rozwlekłych, pisanych bez zastanowienia.
Nie należy także zapominać o tym, by przy wprowadzaniu zmian do kodu już
przetestowanego zmiany te należycie wyróżniać. Wyróżniamy w ten sposób te
fragmenty, które istotnie wymagają testowania; w przeciwnym razie każda zmiana
kodu może pozbawić istniejący kod wiarygodności uzyskanej drogą czasochłonne-
go testowania — niczym odrobina żółci zdolnej zepsuć beczkę miodu.
C:\WINDOWS\Pulpit\Szymon\Niezawodność oprogramowania\r04.doc
81
228511893.007.png 228511893.008.png 228511893.001.png 228511893.002.png
82
NIEZAWODNOŚĆ OPROGRAMOWANIA
W prawidłowo działającym programie wywołanie funkcji malloc powoduje
przydzielenie tu 32-bajtowego bloku pamięci i zwrócenie niezerowego wskaźnika,
zatem blok uwarunkowany instrukcją if nie zostaje wykonany. Aby go naprawdę
przetestować, należy zasymulować błędną sytuację, czyli zastąpić wartością NULL
dopiero co przypisany wskaźnik:
pbBlock = (byte *)malloc(32);
pbBlock = NULL;
if (pbBlock == NULL)
{
.
obsługa błędu
}
Spowoduje to co prawda wyciek pamięci wywołany utratą wskazania na przy-
dzielony blok, jednakże na etapie testowania zazwyczaj można sobie na to pozwolić;
w ostateczności można wykonać wyzerowanie wskaźnika zamiast wywoływania
funkcji malloc :
/* pbBlock = (byte *)malloc(32); */
pbBlock = NULL;
if (pbBlock == NULL)
{
.
obsługa błędu
}
Na podobnej zasadzie należy przetestować każdą ze ścieżek wyznaczonych
przez instrukcje if z frazą else , instrukcje switch , jak również operatory && , ||
i ?: .
Pamiętaj o przetestowaniu każdego rozgałęzienia w programie.
Ż YWOTNE ZNACZENIE PRZEPŁYWU DANYCH
Pierwotna wersja stworzonej przeze mnie funkcji memset , prezentowanej
w rozdziale 2., wyglądała następująco:
void *memset(void *pv, byte b, size_t size)
{
byte *pb = (byte *)pv;
if (size >= sizeThreshold)
{
unsigned long l;
l = (b << 24) | (b << 16) | (b << 8) | b;
pb = (byte *)longfill((long *)pb, l ,size / 4);
82
C:\WINDOWS\Pulpit\Szymon\Niezawodność oprogramowania\r04.doc
228511893.003.png
JAK WYKONUJE SIĘ TWÓJ KOD
83
size = size % 4;
}
while (size-- > 0)
*pb++ = b;
return (pv);
}
Sprawdziłem jej działanie w tworzonej aplikacji wyzerowując fragmenty pa-
mięci o różnej wielkości, zarówno większej, jak i mniejszej od założonego progu
sizeThreshold . Wszystko przebiegało zgodnie z oczekiwaniami; wiedząc jednak
o tym, iż zero jest wartością w pewnym sensie wyjątkową, dla nadania testowi więk-
szej wiarygodności użyłem w charakterze „wypełniacza” innego wzorca — arbi-
tralnie wybranej wartości 0 × 4E . Dla bloków mniejszych niż sizeThreshold
wszystko było nadal w należytym porządku, jednak dla większych bloków wartość
nadana zmiennej l w linii
l = (b << 24) | (b << 16) | (b << 8) | b;
równa była 0 × 00004E4E zamiast spodziewanej 0 × 4E4E4E4E .
Rzut oka na asemblerową postać wygenerowanego kodu natychmiast ujawnił
rzeczywistą przyczynę takiego stanu rzeczy — otóż kompilator, którego używa-
łem, prowadził obliczenia wyrażeń całkowitoliczbowych w arytmetyce 16-bitowej,
uwzględniając jedynie 16 najmniej znaczących bitów wyrażeń (b << 16) i
(b << 24) , czyli po prostu wartość zero. W zmiennej l zapisywała się jedynie bi-
towa alternatywa wyrażeń b i (b << 8) .
A co z czujnością kompilatora ?
No właśnie. Kod prezentowany w niniejszej książce przetestowałem osobiście
używając pięciu różnych kompilatorów; żaden z nich, mimo ustawienia diagnosty-
ki na najwyższym możliwym poziomie, nie ostrzegł mnie, iż wspomniane instruk-
cje przesuwające 16-bitową wartość o 16, czy 24 bity powodują utratę wszystkich
znaczących bitów. Co prawda kompilowany kod zgodny był w zupełności ze stan-
dardem ANSI C, jednakże wynik wspomnianych konstrukcji niemal zawsze odbie-
ga od oczekiwań programisty — dlaczego więc brak jakichkolwiek ostrzeżeń?
Prezentowany przypadek wykazuje jednoznacznie konieczność nacisku na pro-
ducentów kompilatorów, by tego rodzaju opcje pojawiały się w przyszłych wersjach
ich produktów. Zbyt często my, jako użytkownicy, nie doceniamy siły swej argu-
mentacji w tym względzie...
Ten subtelny błąd zostałby niewątpliwie szybko wykryty przez testerów, cho-
ciażby ze względu na widoczne konsekwencje (czyli wypełnianie dużych bloków
„deseniem” 004E4E zamiast 4E4E4E4E ), jednakże poświęcenie zaledwie kilku mi-
nut na prześledzenie kodu pozwoliło wykryć ów błąd już na etapie tworzenia funk-
cji.
Jak pokazuje powyższy przykład, krokowe wykonywanie kodu źródłowego
może nie tylko wskazać przepływ sterowania, lecz także uwidocznić inny, niesa-
mowicie ważny czynnik, mianowicie zmianę wartości poszczególnych zmiennych
C:\WINDOWS\Pulpit\Szymon\Niezawodność oprogramowania\r04.doc
83
228511893.004.png
Zgłoś jeśli naruszono regulamin