143 35 6MB
Polish Pages [201] Year 2002
SPIS TREŚCI
3
SPIS TREŚCI Przedmowa do wydania polskiego ................................................................. 7 Wstęp
......................................................................................................... 13
Dwa najważniejsze pytania .......................................................................................................... 14 Nazewnictwo ............................................................................................................................... 15
Rozdział 1. Hipotetyczny kompilator ........................................................... 19 Poznaj swój język programowania .............................................................................................. 21 Pożyteczne Narzędzie — Lint ..................................................................................................... 25 To tylko kosmetyczne zmiany ..................................................................................................... 25 Nigdy więcej błędów ................................................................................................................... 26
Rozdział 2. Sprawdzaj samego siebie ........................................................... 29 Przypowieść o dwóch wersjach ................................................................................................... 30 Asercje ......................................................................................................................................... 31 „Niezdefiniowane” oznacza „nieprzewidywalne” ....................................................................... 34 Zagadkowe asercje ...................................................................................................................... 35 Kompatybilność kontrolowana .................................................................................................... 37 Gdy niemożliwe staje się możliwe .............................................................................................. 41 Nic o nas bez nas ......................................................................................................................... 43 Co dwa algorytmy, to nie jeden ................................................................................................... 46 Usuwaj błędy jak najwcześniej .................................................................................................... 50
Rozdział 3. Ufortyfikuj swoje podsystemy ................................................... 57 Jest błąd, nie ma błędu ................................................................................................................. 58 Zutylizuj swoje śmieci ................................................................................................................. 60 Jestem już gdzie indziej ............................................................................................................... 64 Kontroluj wykorzystanie pamięci ................................................................................................ 67 Spójrz na to, czego nie widać ...................................................................................................... 70 Wybieraj rozsądnie ...................................................................................................................... 74 Szybki czy bezbłędny .................................................................................................................. 75 Teraz lub później ......................................................................................................................... 75
E:\TMP\Doc\dd0d3c80-2a78-4ac4-b7d7-4d08cff661bc.Doc
3
4
NIEZAWODNOŚĆ OPROGRAMOWANIA
Rozdział 4. Jak wykonuje się Twój kod ....................................................... 79 Uwiarygodnij swój kod................................................................................................................ 80 Przetestuj wszystkie rozgałęzienia ............................................................................................... 81 Żywotne znaczenie przepływu danych ........................................................................................ 83 Czy czegoś nie przeoczyłeś ......................................................................................................... 85 Spróbuj, a polubisz ...................................................................................................................... 86
Rozdział 5. Niekomunikatywne interfejsy ................................................... 89 getchar() zwraca liczbę, nie znak ................................................................................................. 90 realloc() a gospodarka pamięcią .................................................................................................. 92 Uniwersalny menedżer pamięci ................................................................................................... 94 Nieprecyzyjne parametry ............................................................................................................. 96 Fałszywy alarm ............................................................................................................................ 99 Czytanie pomiędzy wierszami ................................................................................................... 101 Ostrzegaj przed niebezpieczeństwem ........................................................................................ 103 Diabeł tkwi w szczegółach ........................................................................................................ 106
Rozdział 6. Ryzykowny biznes .................................................................... 109 int intowi nierówny .................................................................................................................... 110 Nadmiar i niedomiar .................................................................................................................. 114 „Projekt” czy „prawie projekt” .................................................................................................. 116 Po prostu robią, co do nich należy ............................................................................................. 118 Przecież to to samo .................................................................................................................... 122 ?: to także if ............................................................................................................................... 123 Precz z redundancją ................................................................................................................... 126 Wysokie ryzyko, bez odwrotu ................................................................................................... 127 Przeklęta niespójność................................................................................................................. 131 Nie przypisuj zmiennym informacji diagnostycznych ............................................................... 133 Nie warto ryzykować ................................................................................................................. 135
Rozdział 7. Dramaturgia rzemiosła ............................................................ 139 Szybkość, szybkość ................................................................................................................... 140 Złodziej otwierający zamek kluczem nie przestaje być złodziejem ........................................... 142 Każdemu według potrzeb .......................................................................................................... 144 Nie uzewnętrzniaj prywatnych informacji ................................................................................. 146 Funkcje-pasożyty ....................................................................................................................... 148 Programistyczne śrubokręty ...................................................................................................... 151 Syndrom APL ............................................................................................................................ 153 Bez udziwnień, proszę ............................................................................................................... 154 Na śmietnik z tymi wszystkimi trikami ..................................................................................... 156
Rozdział 8. Reszta jest kwestią nawyków .................................................. 161 Hokus-pokus, nie ma błędu ....................................................................................................... 161 Zrób dziś, co masz zrobić jutro .................................................................................................. 163 Doktora!!! .................................................................................................................................. 164 Jeśli działa, nie poprawiaj .......................................................................................................... 165 Funkcja z wozu, koniom lżej ..................................................................................................... 167 Elastyczność rodzi błędy ........................................................................................................... 167 Spróbuj ...................................................................................................................................... 169 Święty Harmonogram ................................................................................................................ 170 „Tester” — nazwa w sam raz dla testera .................................................................................. 171 Programista zawinił, testera powiesili ....................................................................................... 173 Zdefiniuj swe priorytety ............................................................................................................ 174
4
E:\TMP\Doc\dd0d3c80-2a78-4ac4-b7d7-4d08cff661bc.Doc
SPIS TREŚCI
Epilog
5
....................................................................................................... 179
Dodatek A. Lista kontrolna kodowania ..................................................... 181 Dodatek B. Podprogramy zarządzania pamięcią ...................................... 187 Dodatek C. Odpowiedzi ............................................................................... 195
E:\TMP\Doc\dd0d3c80-2a78-4ac4-b7d7-4d08cff661bc.Doc
5
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 7
PRZEDMOWA DO WYDANIA POLSKIEGO
Z błędami programistycznymi jest trochę tak, jak z potworem z jeziora Loch Ness — nikt go tak naprawdę nie widział, co wcale nie upoważnia do kategorycznego wnioskowania o jego nieistnieniu; gdyby jednak komuś udało się zaobserwować go choć raz (i oczywiście fakt ten udokumentować), potwór przestałby być potworem, stając się kolejną pozycją w hierarchii Regnum Zoa i nie wzbudzając odtąd żadnych sensacji. Na tej samej zasadzie żaden program nie jest bezbłędny, nawet jeżeli wydaje się być takowym z bardzo dużym prawdopodobieństwem, bezspornie udowodnić można bowiem jedynie i s t nie n ie błędów, nigdy zaś ich brak. W przeciwieństwie do legendarnego potwora, błędy programistyczne nie mają żadnego posmaku sensacji, za to niosą ze sobą całkiem realne — i poważne — konsekwencje, w postaci straconego czasu, nadwerężonej reputacji twórców oprogramowania, zmarnowanych pieniędzy, a nawet nadszarpniętego zdrowia. Skoro więc zdecydowaliśmy się powierzyć komputerom i informatyce najważniejsze z aspektów naszego życia doczesnego, musimy jednocześnie wypracować rozwiązania chroniące nas przed owymi konsekwencjami. Niewystarczające okazują się tu rozwiązania oczywiste, jak wytężona uwaga programistów, szczególna staranność w wykonywaniu pracy itp.; wszystko to rzecz jasna jest jak najbardziej pożądane, lecz musi być dodatkowo wsparte stosowną me to d o lo g ią tworzenia oprogramowania. Klasyczne ujęcie algorytmiczne, z rzeczywistym problemem na jednym biegunie, a sformalizowanym opisem jego rozwiązania na drugim, musi być uzupełnione trzecim elementem — owym demonem ludzkiej omylności, odciskającym swe piętno na adekwatności wspomnianego opisu. Pierwszym z założeń wspomnianej metodologii jest wybór takiego stylu programowania, który a priori zmniejsza prawdopodobieństwo popełniania błędów. Często zdarza się na przykład tak, iż wskutek zwykłej pomyłki przy
E:\TMP\Doc\325af539-3a41-4d25-9f33-a945d93e6363.Doc
7
NIEZAWODNOŚĆ OPROGRAMOWANIA
8
wpisywaniu kodu programu otrzymujemy inną poprawną konstrukcję, nie wzbudzającą żadnej podejrzliwości ze strony kompilatora, lecz całkowicie sprzeczną z naszymi intencjami. Jeżeli świadomi tego faktu zastosujemy taki styl programowania, by błąd typograficzny równoważny był w większości przypadków błędowi s yn t a k t ycz n e mu , znakomita większość takich „czeskich błędów” wykryta zostanie już na etapie kompilacji. Drugie założenie opiera się na praktycznym spostrzeżeniu, iż najgroźniejszymi błędami są błędy głęboko ukryte, manifestujące się bardzo rzadko i niespodziewanie. Błędy o wyraźnie widocznych i dających się reprodukować na żądanie konsekwencjach łatwiej mogą zostać rozpoznane i usunięte — należy więc dążyć do nadawania tworzonemu programowi jak największej „wrażliwości” w tym względzie. I wreszcie — nawet najgorszy błąd w oprogramowaniu nie okaże się skrajnym nieszczęściem, jeżeli nie spowoduje utraty cennych danych. Zdając sobie sprawę z mniejszej lub większej zawodności tworzonego programu pamiętajmy o tym, iż bezpieczeństwo przetwarzanych danych jest zadaniem najważniejszym. Wymienione trzy założenia, akcentujące (kolejno) odporność, wrażliwość i bezpieczeństwo tworzonego oprogramowania składają się na swoistą filozofię zwaną p r o g r a mo wa ni e m d efe ns y wn y m — nazwa ta wydaje się wystarczająco dobitnie odzwierciedlać ów „obronny” aspekt programowania, jakim jest walka programisty ze skutkami własnej niedoskonałości. Mimo dość ogólnego charakteru przedstawionych zasad, filozofia ta nie jest jednak wyłącznie zbiorem pobożnych życzeń, lecz z powodzeniem stosowana jest w praktyce — czego jednym z dowodów jest właśnie niniejsza książka. Jej autor, doświadczony programista, kierujący niegdyś zespołem programistycznym w firmie Microsoft, w przejrzysty sposób prezentuje rozmaite detale programowania defensywnego na prostych przykładach wyrażonych w języku C. Mimo pewnej tendencji do formułowania prezentowanych stwierdzeń w postaci lakonicznych reguł, treść książki bardzo daleka jest od zbędnego „teoretyzowania”. Jej wyjątkowo praktyczny charakter przejawia się również w tym, iż obok zalecanych wzorców postępowania programistycznego autor nie szczędzi przykładów zachowań niepożądanych, których był świadkiem podczas wieloletniej współpracy z licznym gronem programistów o zróżnicowanych cechach charakterologicznych. W szczególny sposób akcentuje pierwszorzędną rolę programisty w dziele tworzenia niezawodnych programów i wyraźnie piętnuje tendencję do spychania odpowiedzialności za błędy na zespoły testujące. Skoro mowa o różnorodnych zabiegach mających na celu zwiększenie niezawodności tworzonego oprogramowania, to nie mogę powstrzymać się od pewnej refleksji osobistej. Rozpocznę od przypomnienia pewnego znanego w świecie programistów błędu, którego konsekwencją było unicestwienie projektu kosztującego miliony dolarów. Były wczesne lata sześćdziesiąte i jedynym dostępnym językiem algorytmicznym był osławiony FORTRAN. Poniższy fragment DO 10 I = 1,10 ... instrukcje ...
8
E:\TMP\Doc\325af539-3a41-4d25-9f33-a945d93e6363.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 9
10
CONTINUE
powoduje dziesięciokrotne wykonanie ciągu instrukcji dla zmieniającej się od 1 do 10 zmiennej I. Jeżeli jednak pomyłkowo zamienić przecinek na kropkę,
pierwsza z cytowanych instrukcji zmienia się w instrukcję DO 10 I = 1.10
dokonującą — uwaga — przypisania wartości 1.10 do zmiennej o nazwie DO10I. Język FORTRAN zezwala bowiem zarówno na używanie zmiennych bez ich deklarowania, jak i na dowolne wplatanie spacji do nazw zmiennych (spacje takie ignorowane są przez kompilator). Następujący dalej ciąg instrukcji wykonywany jest j ed no k r o t nie , dla przypadkowej (lub wynikającej z poprzednich obliczeń) wartości zmiennej I. Tak błaha i jednocześnie kosztowna pomyłka nie mogłaby się wydarzyć ani w C, ani w Pascalu, gdzie obowiązkowe jest zarówno deklarowanie wszystkich używanych zmiennych, jak i wymóg „spójności” identyfikatorów. Przykład ten udowadnia bardzo istotną tezę — tę mianowicie, iż jednym z elementów programowania defensywnego może być wyb ó r o d p o wi ed n ie go j ęz yk a p ro gr a mo wa n ia . Autor książki pomija tę kwestię zupełnym milczeniem, ograniczając się wyłącznie do języka C — co jest poniekąd zrozumiałe wobec faktu, iż język ten był bodaj jedynym powszechnie stosowanym w firmie Microsoft w czasach, gdy powstawał oryginał niniejszej książki. Tak się składa, iż moim ulubionym językiem programowania jest Pascal, z którym związany jestem niemal od samych jego początków (dokładniej — od jego pierwszej dostępnej w Polsce implementacji na CDC CYBER 70). Wielokrotnie słyszałem i czytałem, jak programiści posługujący się językiem C++ odmawiają Pascalowi miana „poważnego” języka, ograniczając jego rolę wyłącznie do celów edukacyjnych. Jakby na przekór tym opiniom niedawno ukazała się szósta już wersja (opartego na rozbudowanym Pascalu) Delphi, nie to jest jednak najważniejsze: celem moich osobistych wywodów jest zwrócenie uwagi na fakt, iż Pascal jako taki charakteryzuje się znacznie wi ęk sz y m s to p nie m d ef e n s y wn o śc i niż C czy C++ — kto nie wierzy, niech przeanalizuje prezentowane w niniejszej książce przykłady rozmaitych błędów i zastanowi się, które z nich nie mogłyby wystąpić w Pascalu (nie zezwalającym np. na używanie instrukcji przypisania w roli wyrażeń, czy samodzielne definiowanie przeciążonych operatorów). Być może ów argument w obronie zasłużonego Pascala okaże się komuś pomocny w wyborze pomiędzy Delphi a C++Builderem jako potencjalnymi narzędziami do realizacji określonego projektu. I jeszcze jedno: zdecydowaliśmy się na wydanie niniejszej książki, mimo iż jej oryginał powstał przed niemal ośmioma laty. W technologii informatycznej to przecież cała epoka — tymczasem prawie całość prezentowanego materiału wciąż zachowuje swą aktualność, co z jednej strony jest dowodem na istnienie pewnych uniwersalnych idei tkwiących u samej istoty tworzenia oprogramowania, z drugiej natomiast wymaga odrobiny dystansu czytelnika do pewnej szczególnej kwestii. Otóż w sytuacji rozpowszechnienia się systemów operacyjnych wykorzystujących chroniony (protected) tryb pracy procesora (m.in. Windows 9x) wiele błędów programistycznych prowadzi do wykonywania operacji nielegalnych z punktu widzenia samej architektury komputera, wskutek czego błędne programy często
E:\TMP\Doc\325af539-3a41-4d25-9f33-a945d93e6363.Doc
9
10
NIEZAWODNOŚĆ OPROGRAMOWANIA
kończą się błędem ochrony dostępu, zamiast działać w sposób losowy i (co ważniejsze) destrukcyjny; można zaryzykować stwierdzenie, iż wzrost wspomnianej „defensywności” nie ominął także sprzętu komputerowego. Szczególnie podatnymi na awaryjne zakończenie są programy dokonujące odwoływania się do „nie swoich” obszarów pamięci lub posługujące się wskaźnikami „wiszącymi”, zerowymi albo niezainicjowanymi. Z treści książki można wywnioskować, iż autor obraca się raczej w kręgu trybu adresowania bezpośredniego (real mode — chociaż nie brak wzmianek o błędach ochrony dostępu) , w którym błędy tego rodzaju często przechodzą niemal niezauważone. Nie zmienia to oczywiście w niczym faktu, iż programy przejawiające wspomniane zachowanie są programami ewidentnie błędnymi, niezależnie od platformy systemowej, na której są uruchamiane. Zagadnienie niezawodności oprogramowania, a tym bardziej — metodologii programowania prowadzącej do owej niezawodności — wciąż jest zagadnieniem znacznie mniej sformalizowanym niż wiele innych zagadnień algorytmicznych. Niniejsza książka nie pretenduje więc do miana jakiegoś oficjalnego podręcznika na ten temat (taki podręcznik trudno byłoby zrealizować w formie książki o tak małej objętości), stanowi raczej rezultat osobistych (i notabene bardzo pouczających) doświadczeń autora. Jest więc rzeczą naturalną, iż wielu programistów-praktyków mogłoby wzbogacić jej treść o własne uwagi, czy nawet zakwestionować zasadność niektórych poglądów autora. Ciekawi jesteśmy opinii naszych Czytelników w tym względzie i oczekujemy na nie w naszej internetowej księdze gości. Zaawansowanym Czytelnikom proponujemy ponadto pouczające doświadczenie, polegające na przejrzeniu kodu źródłowego jakiegoś popularnego oprogramowania — na przykład Delphi, czy rozmaitych odmian Linuksa — i zwrócenie uwagi na te jego fragmenty, których określona postać podyktowana jest względami defensywności oprogramowania. Być może zaowocuje to większą niezawodnością własnego, tworzonego teraz i w przyszłości, oprogramowania. Mamy nadzieję, iż niniejsza książka przyczyni się do wielu sukcesów w walce z plagą dokuczliwych błędów, krążących wciąż po oprogramowaniu i czyhających na nic nie podejrzewających programistów. Jeżeli przyczyni się ona do uniknięcia chociaż jednej nieprzespanej nocy albo zapobieże stracie wakacji, to będzie oznaczać, że było warto — z nadzieją na co pozostajemy. W imieniu wydawnictwa Andrzej Grażyński w listopadzie 2001
10
E:\TMP\Doc\325af539-3a41-4d25-9f33-a945d93e6363.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 11
E:\TMP\Doc\325af539-3a41-4d25-9f33-a945d93e6363.Doc
11
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 13
WSTĘP Czytając swego czasu książkę D. Knutha o popularnym wówczas edytorze TEX1 jego autorstwa, natknąłem się w przedmowie na takie oto stwierdzenie, które mocno mnie zastanowiło: Jestem przekonany, iż ostatni błąd w TEX został wykryty i usunięty 27 listopada 1985 roku. Obiecuję nagrodę w wysokości 20,48 $ pierwszej osobie, której udałoby się znaleźć jeszcze jakiś błąd — kwota ta stanowi dwukrotność nagrody poprzednio przyrzeczonej i będzie podwajana co roku; niech będzie to wyrazem mojego zaufania do własnych programów.
Nie warto dociekać, skąd taka, a nie inna (20,48 czy też 40,96) kwota nagrody; zdumiewająca jest natomiast siła zaufania, jakim Knuth darzy tworzone przez siebie programy. Jak myślisz, ilu znanych Ci programistów zdobyłoby się na podobną deklarację? Zauważ — wiara w to, iż żaden błąd nie zostanie wykryty w TEX w ciągu kolejnych kilku lat graniczy niemal z pewnością, iż edytor ten nie zawiera już żadnych błędów. Może zabrzmi to mało optymistycznie, ale dla wielu programistów zaufanie takie nie jest bynajmniej sprawą wartą zachodu. Spychają oni całą (lub prawie całą) odpowiedzialność za błędy na zespoły testujące oprogramowanie — ich zdaniem testerzy nie mają nic innego do roboty, podczas gdy zasadniczym zadaniem programistów jest tworzenie oprogramowania. Odpowiedzialni menedżerowie nie mają dużych szans na zmianę takiego sposobu myślenia — programista zmuszony do testowania swych własnych wytworów świadom jest faktu, iż i tak trafią one niedługo do zespołu testującego.
1
D.Knuth: „TEX: The Program”.
E:\TMP\Doc\ac9c69f0-17fc-4055-b44c-7c7e29eca8f2.Doc
13
14
NIEZAWODNOŚĆ OPROGRAMOWANIA
Tymczasem to właśnie programista może w znacznym stopniu przyczynić się do tego, iż wykrywanie błędów i walka z nimi staną się zadaniami łatwiejszymi i bardziej skutecznymi — tę właśnie tezę staram się udowodnić w niniejszej książce poprzez ilustrowanie swych wywodów konkretnymi przykładami. Wszyscy popełniamy błędy i programiści nie są w tym względzie wyjątkiem, szkopuł jednak w tym, by — świadomi własnej niedoskonałości — przyjęli oni taki styl pracy, w którym popełniane przez nich pomyłki łatwe były do wykrycia i poprawienia. Należy za wszelką cenę unikać sytuacji, w której pomyłka, głęboko ukryta — niczym bomba z opóźnionym zapłonem — eksploduje przed oczami niczego nie spodziewającego się użytkownika programu.
DWA NAJWAŻNIEJSZE PYTANIA Nie sposób na ogół udowodnić, iż dany program jest całkowicie bezbłędny, za to każdy znaleziony w nim błąd jest dowodem czegoś wręcz przeciwnego. Programiści zadają sobie wówczas dwa, niezmienne od dziesięcioleci, zasadnicze pytania: Co mogłem — jako programista — uczynić, by ten błąd wykryty został automatycznie? W jaki sposób mógłbym ustrzec się przed popełnianiem takich błędów? Oczywiście banalna odpowiedź mogłaby brzmieć „lepsze przetestowanie” (we własnym zakresie, jeszcze przed przekazaniem programu zespołowi testującemu), chodzi tu jednak o coś diametralnie innego — o wypracowanie pewnych szczególnych technik programowania, ułatwiających wczesne wykrywanie całych klas rozmaitych błędów lub przynajmniej czyniących łatwiejszymi poszukiwania ich przyczyny. Może wówczas upowszechni się to, co pewnie wielu programistów skwituje uśmiechem niedowierzania — przed przekazaniem programu zespołowi testującemu programista sam przekonany będzie o jego (programu) bezbłędności. Niektóre ze wskazówek i zaleceń zawartych w treści niniejszej książki sprzeciwiają się wielu powszechnie przyjętym praktykom programowania i mogą prowokować do stwierdzeń w rodzaju „nikt tak nie pisze” lub „wszyscy łamią tę regułę”. Warto wówczas zastanowić się nad przyczyną — jeżeli „nikt tak nie pisze”, to dlaczego? Czy przypadkiem stare nawyki nie okazują się silniejsze od racjonalności? Wszak coś, co 30 lat temu idealne było dla FORTRAN-u, niekoniecznie musi być najlepszym pomysłem na gruncie programowania strukturalnego czy obiektowego. Nieprzypadkowo mówię tu o „wskazówkach” i „zaleceniach”, bowiem niniejsza książka nie pretenduje do miana kodeksu reguł programowania. Niektórzy programiści skłonni są przestrzegać rozmaitych zaleceń — w rodzaju unikania instrukcji goto — z konsekwencją godną dekalogu, co nie wydaje się do końca uzasadnione. To prawda, iż niczym nieskrępowane faszerowanie kodu programu instrukcjami goto może zamienić go w talerz spaghetti (ruszysz coś z jednej strony i natychmiast coś rusza się z drugiej), ponadto instrukcje skoku generalnie utrudniają kompilatorom dokonywanie optymalizacji, istnieją jednakże przypadki, w których rezygnacja z ogólnie przyjętych zasad na rzecz
14
E:\TMP\Doc\ac9c69f0-17fc-4055-b44c-7c7e29eca8f2.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 15
prymitywnego goto poprawia zarówno czytelność, jak i efektywność programu; niewolnicze trzymanie się „reguł” może całą sprawę jedynie pogorszyć.
NAZEWNICTWO Nazwy zmiennych, funkcji itp. stosowane w niniejszej książce stanowią przykład tzw. „konwencji węgierskiej” opracowanej we wczesnych latach 70. przez Charlesa Simonyiego. Nazewnictwo wynikające z tej konwencji charakteryzuje się dużym stopniem komunikatywności, istotnej zwłaszcza przy weryfikowaniu dużych programów. Zgodnie z konwencją węgierską część nazwy zmiennej stanowi zapis jej typu. Nic w tym szczególnie odkrywczego, wszak większość programów stosuje oznaczenie c lub ch dla zmiennych znakowych, b dla zmiennych bajtowych, i — dla całkowitych itp.; notacja węgierska rozszerza tę ideę na wszystkie rodzaje danych w programie, na przykład: char ch; byte b; flag f; symbol sym;
/* /* /* /*
znajomy typ znakowy */ bajt, czyli unsigned char */ flaga, przyjmująca wartości "prawda" albo "fałsz" */ dowolna struktura symboliczna */
Konwencja ta nie definiuje przy tym konkretnych skrótów na oznaczenie konkretnych kategorii i typów danych — wymaga jednakże, by przyjęte w tym względzie ustalenia stosowane były konsekwentnie w całych programie. Użyteczność konwencji węgierskiej uwidacznia się szczególnie w odniesieniu do typów wskaźnikowych, szczególnie wskaźników „wyższych rzędów”. Otóż nazwa zmiennej typu wskaźnikowego rozpoczynać się musi od litery p, po której następuje skrót charakterystyczny dla wskazywanego typu bazowego — w odniesieniu do prezentowanego przed chwilą przykładu wygląda to następująco: char *pch byte *pb flag *pf symbol *psym
/* /* /* /*
wskaźnik wskaźnik wskaźnik wskaźnik
do do do do
znaku */ bajtu */ flagi */ struktury */
Wskaźniki „wyższych rzędów”, czyli po prostu „wskaźniki do wskaźników” stosują tę regułę rekurencyjnie, na przykład: char **ppch
/* wskaźnik do wskaźnika do znaku */
Każda kolejna litera p na początku nazwy wynika z kolejnego stopnia „zagnieżdżenia” wskazywania. Nawet jeżeli konstruowane w ten sposób nazwy trudne są do wymówienia, są one jednak wystarczające do tego, by w krótkiej nazwie zmiennej zawrzeć wystarczające informacje na temat jej typu. Nie tylko ułatwia to studiowanie programów, lecz umożliwia proste rozróżnienie pomiędzy zmiennymi różnych typów. Przykładowo parametrami funkcji bibliotecznej strcpy są dwa wskaźniki do znaku, zatem jeden z możliwych jej prototypów mógłby wyglądać następująco:
E:\TMP\Doc\ac9c69f0-17fc-4055-b44c-7c7e29eca8f2.Doc
15
NIEZAWODNOŚĆ OPROGRAMOWANIA
16
char *strcpy(char *pchTo, char *pchFrom);
/* prototyp */
Ponieważ jednak obszary wskazywane przez wspomniane parametry nie są dowolnymi obszarami znakowymi, lecz łańcuchami z zerowymi ogranicznikami, sama zaś notacja węgierska przyczyniać się powinna do lepszego zrozumienia kodu, bardziej komunikatywny wydaje się tu zapis: char *strcpy(char *strTo, char *strFrom);
/* prototyp */
Nie inaczej ma się rzecz z nazwami funkcji i tablic — z jednym wszakże wyjątkiem: otóż zgodnie z oryginalną notacją węgierską nazwa funkcji powinna rozpoczynać się od wielkiej litery, lecz dla spójności prezentowanego kodu postanowiłem odstąpić od tej zasady i zastosować charakterystyczne dla typów przedrostki nazw funkcji na równi ze zmiennymi prostymi. Tak więc gdyby zapisać w notacji węgierskiej prototypy funkcji (przykładowo) malloc i realloc, mogłyby one wyglądać tak: void *pvNewBlock(size_t size); void *pvResizeBlock(void *pv, size_t sizeNew);
/* prototyp */ /* prototyp */
Powróćmy jeszcze do nazw „zagnieżdżonych” wskaźników: *ppb = pbNew;
Chociaż na pierwszy rzut oka może to wyglądać dziwnie, zauważ, iż „gwiazdki” mogą znosić się z sąsiadującymi literami p — stosując tę zasadę do powyższej instrukcji przypisania otrzymamy: pb = pbNew;
czyli zgodną co do typów instrukcję przypisania. W analogiczny sposób redukować się mogą (z literami p) operatory & i ->. Spójrz na poniższe instrukcje: pb = &b; b = psym->bLength;
W wyniku wspomnianej redukcji otrzymamy: b = b; b = sym.bLength;
a więc przypisania bajtów do bajtów. I jeszcze jedno. Trudno co prawda podać taką definicję „błędu w programie”, która zadowoliłaby wszystkich Czytelników niniejszej książki, na jedną rzecz chciałbym wszakże zwrócić uwagę: należy odróżniać błędy wprowadzane (bezwiednie) przez programistę podczas tworzenia kodu od błędów pozostających w tym kodzie wówczas, gdy programista uzna go za (przypuszczalnie) bezbłędny — treść niniejszej książki koncentruje się na tej drugiej kategorii. Innymi słowy —
16
E:\TMP\Doc\ac9c69f0-17fc-4055-b44c-7c7e29eca8f2.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 17
trudno spodziewać się, by programiści produkowali bezbłędny kod każdorazowo, gdy tylko usiądą do klawiatury, lecz powinni dążyć do usunięcia wszystkich popełnionych przez siebie błędów przed scaleniem swego kodu z „głównymi” źródłami.
XXXXXXXXXX Stosowana w tej książce konwencja nazewnicza jest tak naprawdę tylko namiastką propozycji Ch. Simonyiego. Na podstawie reguł dotychczas przedstawionych nie sposób na przykład rozstrzygnąć, czy pch[] jest wskaźnikiem do tablicy znakowej, czy też tablicą wskaźników do znaków. Pełna specyfikacja konwencji węgierskiej jednoznacznie rozstrzyga takie i podobne wątpliwości; jeżeli jesteś zainteresowany jej szczegółami, zajrzyj do pracy doktorskiej Ch. Simonyiego dostępnej pod adresem: www.parc.xerox.com/publications/pubs-hst.html (rok 1976). Wśród programistów notacja węgierska nie cieszy się jednakowym poważaniem: jedni uważają ją za największy wynalazek od czasów programowania strukturalnego, inni — wręcz wyśmiewają.
E:\TMP\Doc\ac9c69f0-17fc-4055-b44c-7c7e29eca8f2.Doc
17
18
18
NIEZAWODNOŚĆ OPROGRAMOWANIA
E:\TMP\Doc\ac9c69f0-17fc-4055-b44c-7c7e29eca8f2.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 19
1. HIPOTETYCZNY KOMPILATOR
Zastanów się przez chwilę nad następującą kwestią: w jaki sposób powinny być konstruowane Twoje programy, by kompilator mógł precyzyjnie wskazać każdy popełniony przez Ciebie błąd? Oczywiście nie chodzi tu o — banalne przecież — wykrycie błędów syntaktycznych, lecz wychwycenie takich fragmentów, które — czy to z powodu Twojej nieuwagi, niedostatecznej wyobraźni, czy też po prostu pomyłki — mogą powodować błędy wykonania. Ile słupków trzeba postawić na kilometrowej trasie, jeżeli stawia się je co 50 metrów? 20? A może 21? A na trasie dwukilometrowej? 40? 41? 42? Ile słupków należy użyć w ogrodzeniu o łącznej długości 300 metrów, jeżeli stawia się je co metr? 300? A może 301? Tego rodzaju wyliczenia stają się okazją do często popełnianego błędu, polegającego na tym, iż obliczony wynik różni się od poprawnego o jeden — i z tego powodu nazywanego „błędem pomyłki o jedynkę” (ang. off-by-one error). Sytuacją pokrewną jest zastosowanie nieostrej relacji porównania (np. mniejsze lub równe…) zamiast relacji ostrej (mniejsze niż…); wspaniale byłoby, gdyby sytuacja ta została wykryta przez nasz hipotetyczny kompilator, na przykład w taki sposób: –> linia 23:
while (j linia 318: strCopy = memcpy(malloc(length), str, length); ^^^^^^ błędny argument: memcpy załamie się, gdy malloc() zwróci NULL
Co prawda poruszamy się tutaj w kręgu hipotetycznych dociekań — gdyby jednak jakimś cudem udało się stworzyć opisywany kompilator, czy wówczas unikanie błędów w tworzonych programach stałoby się łatwiejsze? Czy tworzenie bezbłędnych programów byłoby wówczas czynnością banalną — przynajmniej w porównaniu z wysiłkami obecnych programistów? Odpowiedź nasuwa się sama, gdy popatrzeć na dzisiejszych testerów oprogramowania, próbujących dociec przyczyn błędów sygnalizowanych w dostarczonym właśnie raporcie, czy też bombardujących nowo otrzymaną wersję produktu ogromem mniej lub bardziej dziwacznych danych w nadziei natrafienia na jakieś symptomy błędnego zachowania. By te — syzyfowe niekiedy — wysiłki przynieść mogły spodziewane efekty, oprócz niekwestionowanej wiedzy fachowej i wieloletniego doświadczenia, konieczna jest jeszcze duża doza szczęścia. Szczęścia?! Niestety, tak. Testerzy oprogramowania poszukują błędów w całkowicie odmienny sposób, niż czyni to nasz hipotetyczny kompilator; nie są oni w stanie — i nawet nie próbują — wykrywać podejrzanych konstrukcji w rodzaju „pomyłki o jedynkę” czy też „czeskiego” błędu w zapisie którejś liczby czy łańcucha. Traktują oni raczej testowany produkt w kategoriach swoistej „czarnej skrzynki” — przekazują mu starannie spreparowane dane testowe i porównują produkowane wyniki ze spodziewanymi, obliczonymi wcześniej w sposób niezależny, bądź obserwują zewnętrzne przejawy zachowania się testowanego programu jako reakcję na taki, a nie inny sposób manipulowania klawiaturą, myszą itp. Błędy manifestujące się w sposób nachalny nie stanowią tu bynajmniej największego problemu; nie ma nic gorszego, niż błędy objawiające się od czasu do czasu, tylko w szczególnych warunkach — i właśnie zaistnienie owych „szczególnych warunków” akurat w czasie testu jest owym szczęśliwym, bo niemalże wygranym na loterii, zbiegiem okoliczności. Ktoś mógłby w tym miejscu stwierdzić, iż jego testerzy pracują w sposób o wiele bardziej wykoncypowany: posługują się mianowicie różnego rodzaju narzędziami profilującymi, generatorami danych przypadkowych, debuggerami dającymi wgląd w binarną postać programu i produkującymi na żądanie rozmaite migawki. Są to niewątpliwie narzędzia wielce produktywne, nie zmieniają jednak istoty pracy wykonywanej przez testerów, co najwyżej czyniąc ją wydajniejszą — przykładowo typowy profiler potrafi wykryć te rozgałęzienia programu, które nie zostały jeszcze przetestowane, co zmusza do opracowania nowych danych testowych, w wyniku czego sterowanie podąży wzdłuż żądanej ścieżki. Nie ma to bynajmniej oznaczać, iż praca wykonywana przez testerów jest niecelowa, czy też niepotrzebna. Chodzi tu raczej o zwrócenie uwagi na ważny fakt, iż poszukiwanie błędów drogą testowania produktu na zasadzie „czarnej skrzynki” jest zadaniem bardzo trudnym, porównywalnym z próbą ustalenia istoty i przyczyn choroby na podstawie li tylko samego wywiadu z pacjentem: zadaje się pytania, wysłuchuje odpowiedzi i wyciąga z nich wnioski, jednakże meritum sprawy kryje się w ciele (w duszy?) pacjenta. Każdej postawionej w ten sposób
20
E:\TMP\Doc\b8722553-7735-46c1-b613-af1bace902e2.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 21
diagnozie towarzyszyć muszą rozmaite wątpliwości — czy wywiad był wystarczająco obszerny? Czy zadałem właściwe pytania? Skoro więc faza testowania charakteryzuje się z założenia niską skutecznością wykrywania błędów — zwłaszcza w kontekście ich przyczyn — walka z błędami musi rozpocząć się znacznie wcześniej, czyli już na etapie projektowania.
POZNAJ SWÓJ JĘZYK PROGRAMOWANIA Jakże często spotykamy się z pompatycznymi tekstami reklamowymi w rodzaju: „Nowy rewelacyjny edytor! Jeżeli zamierzasz napisać nową epopeję narodową lub tylko wysłać list do kolegi, ten edytor jest dla Ciebie. Nawet jeżeli ortografia nie jest Twoją mocną stroną, przed błędami uchroni Cię korektor oparty na słowniku zawierającym 230000 słów — to o 50000 więcej niż w edytorach konkurencyjnych. Nie zastanawiaj się — kup jeszcze dziś; wszak to najbardziej rewolucyjne narzędzie dla pisarzy od czasu wynalezienia długopisu!”. Niespotykana dotąd pojemność słownika ma porażać i jawić się jako panaceum na — nieuchronne w końcu — zwykłe, ludzkie omyłki. Problem jednak w tym, iż to nie w pojemności słowników spoczywa ich funkcjonalność. Słowo „dosowa” jest czymś zupełnie zwyczajnym w tekście informatycznym i odnosi się najprawdopodobniej do aplikacji przeznaczonej dla systemu DOS, lecz już w ofercie hurtowni spożywczej może — błędnie zapisane — oznaczać wodę sodową. Uniwersalny słownik — niezależnie od tego, czy zawierać będzie słowo „dosowa” — będzie więc niedoskonały z punktu widzenia któregoś z ww. tekstów. Najbardziej elastycznym rozwiązaniem jest posługiwanie się wieloma słownikami, z których każdy dostosowany jest (pod względem zawartości) do określonej dziedziny działalności ludzkiej — jak np. programowanie komputerów albo wyrób wody sodowej. Zastosowaniem tej idei na gruncie języków programowania są kompilatory posiadające konfigurowalne opcje wychwytywania z kodów źródłowych takich konstrukcji, które co prawda poprawne są z punktu widzenia składni języka, lecz z dużym prawdopodobieństwem stanowią przejaw popełnienia błędu. Oto przykład: /* memcpy – kopiowanie pomiędzy nie nakładającymi się blokami pamięci */ void *memcpy(void *pvTo, void *pvFrom, size_t size) { byte *pbTo = (byte *)pvTo; byte *pbFrom = (byte *)pvFrom; while (size-- > 0); *pbTo++ = *pbFrom++; return (pvTo); }
Akapitowanie w obrębie instrukcji while sugeruje tu powtarzanie instrukcji kopiowania kolejnych bajtów, jednakże z punktu widzenia kompilatora ciało instrukcji jest puste — wszystkiemu winien ów nieszczęsny średnik. Ogólnie rzecz
E:\TMP\Doc\b8722553-7735-46c1-b613-af1bace902e2.Doc
21
NIEZAWODNOŚĆ OPROGRAMOWANIA
22
biorąc niezamierzone puste instrukcje to jeden z najczęściej popełnianych błędów, stanowią więc idealną kandydaturę na jedną z kategorii ostrzeżeń produkowanych przez kompilatory języka C. Ostrzeżenia takie są jednak uciążliwe, gdy dana konstrukcja — w tym przypadku instrukcja pusta — jest świadomym produktem programisty; jeżeli więc kompilator nie umożliwia selektywnego włączania i wyłączania obszarów swej „czujności” w różnych miejscach pliku źródłowego, można posłużyć się chwytem zalecanym przez podręczniki programowania — zamiast pustej instrukcji użyć pustego bloku (który i tak najprawdopodobniej zostanie wyeliminowany w procesie optymalizacji), jak w poniższym przykładzie: char *strcpy(char *pchTo, char *pchFrom) { char *pchStart = pchTo; while (*pchTo++ = *pchFrom++) {} return (pchStart); }
Uwalnia to od uciążliwych ostrzeżeń ze strony kompilatora, a dodatkowo rozwiewa wątpliwości co do zasadności pustego ciała instrukcji while. Mimo wszystko możliwość selektywnego uaktywniania ostrzeżeń w różnych miejscach pliku źródłowego stanowi cenną zaletę kompilatorów (oczywiście tych, które tę możliwość oferują) i przypomina nieco możliwość używania różnych słowników w edytorach, zależnie od charakteru edytowanego tekstu. Innym, równie powszechnym „idiomem” języka C, zwiastującym być może pomyłkę programisty jest niezamierzone przypisanie, które intencjonalnie miało być porównaniem, na przykład: if (ch = '\t') ExpandTab();
Intencją programisty była tu prawdopodobnie zamiana znaku tabulacji na jego równoważnik, jednak formalnie jest to poprawne przypisanie znaku tabulacji do zmiennej ch. Niektóre kompilatory, ze względu na opisywany błąd (polegający na zgubieniu jednego znaku z pary „==”) opcjonalnie zabraniają dokonywania prostych przypisań w ramach wyrażeń && i || oraz wyrażeń sterujących instrukcji if, for i while. Pojedynczy znak „=” nie jest w tym przypadku prawdopodobnym zwiastunem błędu programisty, w przeciwieństwie do równoważnej konstrukcji while (*pchTo++ = *pchFrom++) {}
Nie uniemożliwia to oczywiście dokonywania takich przypisań, nakłada jednak wymóg uczynienia takiego przypisania częścią instrukcji porównania, najczęściej z zerem lub znakiem o kodzie 0: while ((*pchTo++ = *pchFrom++) != '\0') {}
22
E:\TMP\Doc\b8722553-7735-46c1-b613-af1bace902e2.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 23
Należy zwrócić uwagę na ważny fakt, iż tego typu zabiegi nie powodują z reguły generowania dodatkowego kodu, ze względu na optymalizację dokonywaną przez większość kompilatorów — poprawienie czytelności kodu i jego uwiarygodnienie dokonuje się więc niejako za darmo. I jeszcze jeden ciekawy przykład błędnego kodowania — gdy wiele lat temu uczyłem się języka C, zdarzyło mi się napisać takie oto wywołanie funkcji fputc(): fprintf(stderr, "Niemożliwe otwarcie pliku %s.\n", filename); … … fputc(stderr, '\n');
Na pierwszy rzut oka nie widać w tym nic niezwykłego, jednakże argumenty wywołania funkcji fputc występują w niewłaściwej kolejności! To skutek mylnego przekonania, iż we wszystkich funkcjach operujących na strumieniach, identyfikator strumienia występuje jako pierwszy parametr.
xxxxxxxxxxxxxx Niestety, prototypowanie nie oznacza całkowitego uwolnienia się od ryzyka związanego z błędnym przekazaniem argumentów do wywoływanych funkcji. W poniższym przykładzie void *memchr(const void *pv, int ch, int size);
pomyłkowe przestawienie drugiego i trzeciego argumentu nie zostanie zauważone przez kompilator. Można wzmocnić funkcjonalność prototypu w tym względzie przez zdeklarowanie go w następujący sposób: void *memchr(const void *pv, unsigned char ch, size_t size);
Drugi i trzeci argument różnią się teraz co do typu, więc łatwo będzie wykryć ich ewentualne przestawienie; niejako ubocznym efektem tego zabiegu będzie jednak pojawienie się ostrzeżeń kompilatora o niezgodności typu drugiego argumentu wywołania — można temu zapobiec poprzez zastosowanie jego rzutowania na docelowy typ unsigned char. Na tego rodzaju błędy ANSI C oferuje na szczęście skuteczne antidotum — jest nim prototypowanie kodu. Standard ANSI wymaga prototypowania wszystkich funkcji bibliotecznych, do których występują odwołania w tekście programu — w pliku nagłówkowym stdio.h znajduje się taki oto (lub podobny) prototyp funkcji fputc(): int fputc(int c, FILE *stream);
Umożliwia to wykrycie przez kompilator „zamienionych” argumentów wywołania. Chociaż ANSI C wymaga prototypowania jedynie funkcji bibliotecznych, nic nie stoi na przeszkodzie prototypowania wszystkich wywoływanych funkcji. Niektórzy programiści utyskują na wymóg
E:\TMP\Doc\b8722553-7735-46c1-b613-af1bace902e2.Doc
23
24
NIEZAWODNOŚĆ OPROGRAMOWANIA
prototypowania, co jest po części usprawiedliwione, zwłaszcza przy przenoszeniu projektów z „tradycyjnego” C — generalnie jednak ów dodatkowy wysiłek stanowi niezbyt wygórowaną cenę za możliwość „wyłapania” wielu błędów już na etapie kompilacji. W dodatku otrzymać możemy „premię” w postaci zoptymalizowanego kodu — standard ANSI zezwala bowiem kompilatorom na dokonywanie optymalizacji na podstawie informacji uzyskanej z prototypów. Peter Lynch, jeden z najbardziej znanych menedżerów funduszy inwestycyjnych lat osiemdziesiątych zwykł mawiać, że zasadnicza różnica pomiędzy graczami a inwestorami polega na tym, iż ci ostatni wykorzystują każdą okazję inwestycyjną, nawet niewielką, w nadziei przetworzenia jej w znaczące zyski; gracze natomiast dążą do osiągnięcia wielkich zysków, polegając jedynie na szczęściu. Wykorzystanie ostrzeżeń generowanych przez kompilatory jest właśnie taką drobną, choć wysoko opłacalną (i jednocześnie wolną od ryzyka) inwestycją.
Wykorzystaj możliwości oferowane przez ostrzeżenia kompilatorów.
POŻYTECZNE NARZĘDZIE — LINT Uniksowy program lint zaprojektowany został pierwotnie jako analizator programów w języku C, wykrywający wszelkie konstrukcje, które mogłyby powodować problemy z przenośnością kodu źródłowego pomiędzy kompilatorami. Funkcjonalność ta została z czasem wzbogacona w wyszukiwanie fragmentów podejrzanych o skrywanie pomyłek programisty — do kategorii tej należą m.in. opisywane wcześniej puste instrukcje, podejrzane przypisania i błędne argumenty wywołań funkcji. Niestety, wielu programistów wciąż traktuje lint jako jedynie weryfikator przenośności kodu, nie wart w ogóle zachodu, generujący masę komunikatów, którymi nie warto się przejmować. Czas jednak zweryfikować tę opinię, a przynajmniej zadać sobie pytanie — które narzędzie bliższe jest opisywanemu hipotetycznemu, „inteligentnemu” kompilatorowi: wykorzystywany na co dzień kompilator C, czy właśnie lint? Warto poświęcić trochę wysiłku, by doprowadzić kod źródłowy do postaci „zgodnej z lintem”, (czyli — nie powodującej generowania ostrzeżeń) i utrzymywać tę zgodność przy każdej modyfikacji kodu. Po tygodniu lub dwóch przestrzeganie owej zgodności ma szansę stać się nawykiem i nie będzie wymagać wielkiego wysiłku ze strony programistów.
Wykorzystaj program lint do wykrycia błędów, które mogą pozostać niezauważone przez Twój kompilator.
24
E:\TMP\Doc\b8722553-7735-46c1-b613-af1bace902e2.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 25
TO TYLKO KOSMETYCZNE ZMIANY Jeden z recenzentów niniejszej książki spytał mnie, dlaczego nie poruszam w niej tematyki testowania modułów (ang. unit testing). Odpowiedziałem mu, iż mimo ścisłego związku pomiędzy tworzeniem bezbłędnych programów a testowaniem modułów, to ostatnie jest zagadnieniem nieco innej kategorii — mianowicie „w jaki sposób tworzyć programy testowe do sprawdzania swego kodu”. „Nie zrozumiałeś mnie” — rzekł wówczas mój rozmówca — „chodzi mi o zwrócenie uwagi na konieczność takich testów”. Okazało się wówczas, iż jeden z programistów w jego zespole często zaniedbuje testowanie swoich modułów przed dołączeniem ich do ”głównego kodu” uzasadniając to stwierdzeniem, iż „wprowadzane przez niego zmiany mają jedynie charakter kosmetyczny, a w ogóle to niczego nie dodawał do kodu, a jedynie usunął kilka zbędnych instrukcji; tak banalne zmiany z pewnością nie mogą powodować żadnych błędów”. Niestety, to zdumiewające, iż niektórzy programiści ufni (zadufani?) w swoje nadzwyczajne zdolności nie próbują nawet (o zgrozo!) skompilować poprawionego kodu! Tymczasem trudność wykrycia błędów rośnie gwałtownie wraz ze wzrostem rozmiaru kodu źródłowego, łatwiej więc wykrywać je w małych modułach niż w finalnym produkcie niebotycznej nieraz wielkości; drobne zaniedbanie w tym względzie mści się niekiedy okrutnie — konsekwencje niewinnego przejawu lenistwa urastają wówczas do rozmiarów monstrualnych. Pozostaje żywić nadzieję, iż tego rodzaju beztroska nie jest (i nie będzie) w świecie programistów zjawiskiem powszechnym.
NIGDY WIĘCEJ BŁĘDÓW Poszukiwanie i usuwanie błędów w programach jest czynnością zdecydowanie mniej twórczą niż tworzenie nowych programów — i jeżeli nawet istnieją programiści , którzy twierdzą inaczej, to z pewnością zaliczają się oni do wyjątków; co więcej — wielu programistów przypuszczalnie wyrzekłoby się dożywotnio wielu ulubionych smakołyków w zamian za gwarancję, iż odtąd tworzone przez nich programy wolne będą od błędów. Łakomczuchy mogą spać jednak spokojnie, wolni od kłopotliwych dylematów — wszak wspomnianą gwarancję wsadzić można póki co między bajki. Skoro więc nie sposób ustrzec się przed popełnianiem błędów, warto przynajmniej ułatwić ich wyłapywanie i usuwanie. Pomyśl przez chwilę o opisywanym na początku hipotetycznym kompilatorze, o programie lint, o testowaniu modułów — jakich kwalifikacji programistycznych wymagałoby ich wykrycie? Niemal żadnych, nieprawdaż? Wynika stąd niezmiernie ważny wniosek — gdy tworzysz kod źródłowy, staraj się nadawać mu taką postać, by ewentualne błędy popełnione przez Ciebie łatwe były do wykrycia (i usunięcia) nawet dla początkującego programisty.
E:\TMP\Doc\b8722553-7735-46c1-b613-af1bace902e2.Doc
25
26
NIEZAWODNOŚĆ OPROGRAMOWANIA
PODSUMOWANIE Etap projektowania aplikacji stanowi najlepszą okazję do wykrywania błędów; należy wykorzystać wówczas wszelkie dostępne narzędzia do automatycznego wykrywania „podejrzanych” miejsc w kodzie źródłowym i starać się nadać mu postać sprzyjającą ich wykrywaniu. Należy dążyć do prostoty kodowania, aby zrozumienie treści programu i wykrycie ewentualnego błędu nie wymagało zaawansowanych kwalifikacji programistycznych.
POMYŚL O TYM 1. Załóżmy, iż skorzystałeś z opcji kompilatora zabraniającej dokonywania przypisań w ramach warunku instrukcji while; dlaczego spowoduje to sygnalizację błędu pierwszeństwa operatorów w poniższym fragmencie? while (ch=getchar() != EOF) . . .
2. Widziałeś już, jak za pomocą opcji kompilatora wykrywać można niezamierzone instrukcje puste i błędne przypisania; jakich opcji kompilatora można by — Twoim zdaniem — użyć w celu wykrycia następujących błędów, równie powszechnych jak wspomniane przed chwilą? Jak należałoby przekształcić odnośne fragmenty kodu, by „wyciszyć” generowanie ostrzeżeń? a. if (flight == 063) — wbrew pozorom flight nie jest tu porównywalne z wartością 63, ale 51, bowiem liczba rozpoczynająca się zerem traktowana jest przez kompilator jako liczba ósemkowa (68351). b. if (pb != NULL & *pb != 0FF) — z powodu błędnego użycia & zamiast && wartościowane są obydwa wyrażenia, co może (a w trybie chronionym musi — przyp. tłum.) doprowadzić do błędu w sytuacji, gdy pb równe jest NULL. c. quot = numer/*pdenom — wskutek pomyłki programisty sekwencja /*
traktowana jest tutaj jako początek komentarza. d. word = bHigh 0) *pbTo++ = *pbFrom++; return (pvTo); }
assert nie jest jednak funkcją, lecz makrem — aktywnym tylko w czasie „debuggowania” programu i powodującym przerwanie wykonania w przypadku wywołania z argumentem równym false. Użycie w tej roli makra zamiast funkcji ma ponadto istotny aspekt techniczny — generalnie powoduje mniejsze „zamieszanie” pod względem wykorzystania pamięci, zmiany przepływu sterowania i różnorodnych efektów ubocznych. Definicja makra assert znajduje się w pliku assert.h. Dla niektórych programistów jest ona jednak niewystarczająca, więc przedefiniowują ją w rozmaity sposób — na przykład tak, by wystąpienie błędu nie powodowało awaryjnego kończenia programu, lecz przekazywało sterowanie do debuggera wraz z pozycjonowaniem go na błędnej instrukcji; w niektórych rozwiązaniach alternatywą dla zakończenia programu jest jego opcjonalne kontynuowanie, jak gdyby nic się nie stało. Jeżeli jednak nie zadowala Cię istniejące makro assert, powinieneś raczej zdefiniować (pod inną nazwą) jego funkcjonalny odpowiednik zamiast zmieniać istniejącą definicję; w niniejszej książce taką „alternatywną asercję” opatrzyliśmy nazwą ASSERT. Różni się ona od oryginalnego assert pewnym drobny szczegółem: w przeciwieństwie do niego (jako wyrażenia) jest instrukcją, zatem próba jej zamiennego użycia w poniższej konstrukcji if (assert(p != NULL), p->foo != bar) .
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
31
NIEZAWODNOŚĆ OPROGRAMOWANIA
32
. .
spowoduje błąd kompilacji. Ograniczenie to jest jak najbardziej zamierzone, asercjeinstrukcje są bowiem konstrukcjami zdecydowanie mniej podatnymi na błędy niż asercje-wyrażenia — po cóż więc wprowadzać takie elementy funkcjonalności, które prawdopodobnie i tak nie będą używane? A oto szczegóły naszego makra ASSERT: #ifdef DEBUG void _Assert(char , unsigned);
/* prototyp */
#define ASSERT(f) if (f) {} else _Assert(__FILE__, __LINE__) #else #define ASSERT(f) #endif
Jeżeli zdefiniowany jest symbol DEBUG, makro ASSERT rozwijane jest do instrukcji if o dwóch cokolwiek interesujących szczegółach: po pierwsze — dziwnie wyglądający pusty blok wynika z konieczności wygenerowania kompletnej instrukcji if...else po to, by uniknąć jej przypadkowego „sklejenia” z ewentualną inną, „wiszącą” instrukcją if; po drugie — na końcu definicji brak średnika, przez co musi być on jawnie użyty w wywołaniu makra: ASSERT(pvTo != NULL && pvFrom != NULL);
W przypadku, gdy argument wywołania ASSERT okaże się fałszem, wywołana zostanie funkcja wypisująca komunikat do strumienia stderr i kończąca wykonanie programu: void _Assert(char *strFile, unsigned uLine) { fflush(NULL); fprintf(stderr, "\nNiespełniona asercja: %s, linia %u\n", strFile, uLine); fflush(stderr); abort; }
Przed wypisaniem komunikatu o niespełnionej asercji, należy wypisać ewentualne komunikaty oczekujące jeszcze w buforach — stąd początkowa instrukcja fflush(NULL). Generowany przez ASSERT komunikat zawiera nazwę pliku i numer linii, w której znajduje się niespełniona asercja, na przykład: Niespełniona asercja: string.c, linia 153
32
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
33
To jeszcze jedna różnica w stosunku do oryginalnego assert wypisującego in extenso znakową reprezentację badanego warunku: Assertion failed: pvTo != NULL && pvFrom != NULL File string.c, line 153
Wygląda to efektownie, niemniej jednak wypisywane łańcuchy zajmują cenne miejsce w ograniczonym (najczęściej do 64 K1) segmencie danych globalnych; ograniczenie się tylko do wskazania lokalizacji niespełnionej asercji w niczym nie utrudnia jej odnalezienia i zidentyfikowania, przy braku dodatkowych (być może znacznych) obciążeń pamięci. Niezależnie od implementacji, asercje stanowią wygodny środek weryfikacji tego, iż oczekiwane (w danym miejscu kodu) warunki rzeczywiście są spełnione; kontynuowanie wykonania w sytuacji ich niespełnienia może owocować trudnym do przewidzenia zachowaniem programu, lepiej więc przerwać jego wykonywanie i zająć się poszukiwaniem przyczyn rozminięcia się faktycznego stanu rzeczy z oczekiwaniami programisty — w miejscu bądź co bądź precyzyjnie zlokalizowanym.
Wykorzystaj asercje do weryfikacji parametrów przekazywanych do wywoływanych funkcji.
„NIEZDEFINIOWANE” OZNACZA „NIEPRZEWIDYWALNE” Wykorzystywana już wielokrotnie funkcja memcpy przeznaczona jest do kopiowania danych pomiędzy nie nakładającymi się obszarami pamięci — w definicji ANSI C wyraźnie czytamy, iż „kopiowanie pomiędzy obszarami nierozłącznymi daje nieprzewidywalne wyniki” 2. W niektórych książkach napotkać można jednak stwierdzenia odmienne — w „Standard C”3 czytamy, iż „elementy tablic mogą być pobierane i zapamiętywane w dowolnej kolejności”4. Nasuwa się wniosek, iż zaufanie gwarancjom zawartym w poprzednim zdaniu oznacza uzależnienie się od konkretnego kompilatora, a być może — nawet od jego określonej wersji, w przeciwnym razie „kopiowanie” pomiędzy obszarami posiadającymi przynajmniej jeden wspólny bajt może okazać się działaniem cokolwiek różnym od kopiowania; po przeprowadzeniu prostego eksperymentu myślowego nietrudno bowiem skonstatować, iż przy kopiowaniu nakładających się obszarów istotny jest kierunek przetwarzania obszarów (od adresów niższych ku wyższym, albo odwrotnie), zależnie od wzajemnego położenia obydwu obszarów.
1 2 3 4
Autor oryginału ma tu na myśli programy trybu rzeczywistego (przyp. tłum.). „If copying takes place between objects that overlap, the behavior is undefined”. P. J. Plauger, J. Brodie „Standard C”, Wyd. Microsoft Press, 1989. „The elements of the arrays can be accessed and stored in any order”.
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
33
NIEZAWODNOŚĆ OPROGRAMOWANIA
34
Prezentowane przez nas implementacje funkcji memcpy konsekwentnie dokonują kopiowania w kierunku rosnących adresów. Jakkolwiek istnieją programiści lubujący się wręcz w wykorzystywaniu nieudokumentowanych mechanizmów, to jednak większość programistów woli inteligentnie unikać takich praktyk; niektórzy wręcz utożsamiają przymiotnik „nieudokumentowany” z „nielegalny”. Powróćmy do naszej funkcji memcpy()— skoro nie gwarantuje ona poprawnego działania dla nakładających się obszarów, aż prosi się zastosowanie asercji do wykluczenia takiego nakładania: /* memcpy – kopiowanie pomiędzy nie nakładającymi się obszarami */ void *memcpy(void *pvTo, void *pvFrom, size_t size) { byte *pbTo = (byte *)pvTo; byte *pbFrom = (byte *)pvFrom; ASSERT (pvTo != NULL && pvFrom != NULL); ASSERT (pbTo >= pbFrom+size || pbfrom >= pbTo+size) while (size-- > 0) *pbTo++ = *pbFrom++; return (pvTo); }
Uwaga (od tłumacza) Większość Czytelników natychmiast rozpozna w powyższej asercji znajomą „arytmetykę na wskaźnikach” charakterystyczną dla rzeczywistego trybu adresowania — porównuje się mianowicie adres początku każdego z obszarów z adresem końca obszaru konkurencyjnego; w istocie, testowanie nakładania obszarów adresowanych w trybie chronionym odbywa się w zupełnie inny sposób. Przykładowo w produktach firmy Borland — m.in. w Turbo C i Turbo Pascalu — przyjęto założenie, iż wskaźniki o różnych częściach segmentowych wskazują zawsze na rozłączne obszary; dokumentacja zastrzega przy tym, iż jakkolwiek zasadę tę nietrudno złamać, to jednak taki przypadek wymaga specjalnych, wykoncypowanych zabiegów i jako taki nie został uwzględniony przy konstrukcji funkcji kopiujących. Przykład funkcji memcpy dobitnie obrazuje niebezpieczeństwo związane z wykorzystaniem mechanizmów nieudokumentowanych lub charakterystycznych jedynie dla niektórych wersji kompilatorów. Najlepiej mechanizmów takich oczywiście unikać, jeżeli jednak z pewnych względów decydujemy się na ich użycie, konieczne jest zweryfikowanie ich poprawności w konkretnym środowisku, najlepiej za pomocą stosownych asercji opatrzonych dodatkowo odpowiednimi komentarzami wyjaśniającymi. Zasada ta nabiera szczególnego znaczenia w przypadku tworzenia różnego rodzaju bibliotek. Korzystający z nich programiści dążą niekiedy do rozwiązywania swych problemów programistycznych na zasadzie prób i błędów — natrafiwszy na funkcję spełniającą ich oczekiwania, nie zdają sobie być może sprawy z faktu, iż funkcja ta może bazować na rozwiązaniach nieudokumentowanych. Wyjaśnia to
34
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
35
poniekąd zadziwiający fakt, iż pomimo deklarowanej pełnej zgodności nowej wersji biblioteki z wersją poprzednią, 50% aplikacji działających bezproblemowo ze starszą — odmawia współpracy z nowszą wersją; owa „pełna zgodność” ogranicza się bowiem zazwyczaj do mechanizmów udokumentowanych.
Usuń ze swych programów odwołania do mechanizmów nieudokumentowanych lub zweryfikuj ich adekwatność za pomocą odpowiednich asercji.
ZAGADKOWE ASERCJE Przyjrzyjmy się raz jeszcze asercji badającej rozłączność obszarów obsługiwanych przez funkcję memcpy: ASSERT (pbTo >= pbFrom+size || pbfrom >= pbTo+size)
„Rozłączność” została tutaj wyrażona przez fakt, iż koniec jednego z obszarów znajduje się pod adresem mniejszym niż początek drugiego, przy czym obszar o mniejszym adresie może być obszarem źródłowym albo docelowym, stąd alternatywa (||). Tego wszystkiego musi się jednak domyślać programista czytający cudzy kod, tymczasem lakoniczny komentarz wyjaśniający istotę sprawy z pewnością ułatwiłby mu zadanie. Niestety, programiści tworzący podobne asercje często o komentarzach zapominają — informując o niebezpieczeństwie nie wyjaśniają na czym ono polega. To tak, jakby w gęstym lesie napotkać przybitą do drzewa ogromną tablicę „NIEBEZPIECZEŃSTWO!” Jakie niebezpieczeństwo? Walące się drzewa? Zapomniane pole minowe? Wielka Stopa? W efekcie ostrzeżenie takie zostaje zazwyczaj zignorowane (z różnym skutkiem, ale to już zupełnie inna sprawa) — podobnie programiści niechybnie zignorują niezrozumiałą dla nich asercję. Ponadto asercja ta, wykrywająca nakładanie się obszarów, nie jest wystarczająca dla kogoś, kto mimo nakładania się obszarów chciałby jednak wykonać kopiowanie pomiędzy nimi. Odpowiednim środkiem do tego celu jest w takim przypadku funkcja memmove — i o tym właśnie informować powinien stosowny komentarz: /* jeżeli obszary nakładają się, użyj funkcji memmove */ ASSERT (pbTo >= pbFrom+size || pbfrom >= pbTo+size)
Gdy opatruje się asercje komentarzami — szczególnie wówczas, gdy zawierają one wskazówki co do rozwiązania zaistniałych problemów — należy jednak zachować ostrożność: komentarze mają bowiem wyjaśniać nieoczywiste sprawy innym programistom, a nie powodować ich dezorientację. Ewentualne nieprzydzielenie pamięci nie jest tutaj błędem programistów, który należałoby im sygnalizować na etapie testowania, lecz zdarzeniem niezależnym od kodu, które wymaga odpowiedniego obsłużenia! Najbardziej banalnym sposobem owej obsługi mogłoby być np. zwrócenie pustego wskaźnika — w każdym razie przedmiotową asercję należy bezwzględnie usunąć:
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
35
NIEZAWODNOŚĆ OPROGRAMOWANIA
36
/* strdup – tworzenie kopii łańcucha */ char *strdup(char *str) { char *strNew; ASSERT(str != NULL); strNew = (char *)malloc(strlen(str)+1); if (strNew != NULL) strcpy(strNew, str); return (strNew);
}
Nieuzasadnione asercje Asercje bywają niekiedy nadużywane, wykorzystuje się je bowiem do testowania nie tyle rzeczywistych błędów, co raczej niespełnionych warunków. Oto jeden z przykładów: /* strdup – tworzenie kopii łańcucha */ char *strdup(char *str) {
char *strNew; ASSERT(str != NULL); strNew = (char *)malloc(strlen(str)+1); ASSERT(strNew != NULL) strcpy(strNew, str); return (strNew); }
Pierwsza z asercji jest jak najbardziej na miejscu, realizuje ona zapewnienie, iż przekazany wskaźnik do łańcucha nie jest wskaźnikiem pustym; takie bowiem założenie przyjęto przy tworzeniu kodu programu i należy upewnić się co do jego przestrzegania. Warunek testowany przez drugą asercję jest za to zupełnie innej natury: przed przystąpieniem do kopiowania łańcuchów sprawdza się mianowicie, czy funkcja malloc rzeczywiście przydzieliła żądaną pamięć. Problem w tym, iż spełnienia tego warunku nie sposób zapewnić na etapie tworzenia kodu!
Szanuj czas innych ludzi — dokumentuj nieoczywiste asercje. Nie sygnalizuj błędów tam, gdzie ich nie ma.
KOMPATYBILNOŚĆ KONTROLOWANA Przenośność oprogramowania jest jego niewątpliwie pożądaną cechą, często jednak możliwości oferowane przez konkretne środowisko bywają na tyle atrakcyjne, iż warto je wykorzystać nawet za cenę częściowej nieprzenośności. Spójrzmy wpierw na poniższy przykład:
36
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
37
/* memset – wypełnianie obszaru wartością bajtu */ void *memset(void *pv, byte b, size_t size) {
byte *pb = (byte *)pv; while (size-- > 0) *pb++ = b; return (pv); }
Procedura ta zapełnia docelowy obszar bajt po bajcie i nie zawiera elementów uzależniających ją od konkretnego środowiska, będzie więc poprawnie działać na dowolnym (bajtowym) komputerze po skompilowaniu przez dowolny kompilator ANSI C. Jednakże każdy niemal komputer posiada możliwość operowania większymi jednostkami danych niż bajty — słowami dwu-, cztero-, ośmiobajtowymi itp. Docelowy obszar można by więc zapełniać nie bajtami, lecz całymi czterobajtowymi słowami, co np. na komputerze z procesorem serii 68000 przyspieszy niemal czterokrotnie całą operację: /* procedura longfill zapełnia pamięć wartościami typu "long" */ /* jako wynik zwracając wskazanie na obszar typu "long" */ /* następujący bezpośrednio po ostatnio zapisanym */ long longfill(long*pl, long l, size_t size); /* prototyp */ void *memset(void *pv, byte b, size_t size) { byte *pb = (byte *)pv; if (size >= sizeThreshold) { unsigned long l; l = (b 0;) l = (l 0) *pb++ = b; return (pv); }
Wygląda to nieco bardziej „przenośnie”, jednak ewentualne przenoszenie funkcji do innego środowiska i tak wymaga przestudiowania całego jej kodu. W kodzie tym kryje się ponadto jeszcze jedna nieprzyjemna niespodzianka, związana ze specyfiką konkretnego środowiska sprzętowego: otóż procesory serii 68000 wymagają, by adresowane przez nie słowa wyrównane były na granicy parzystego adresu. Jeżeli więc wskaźnik przekazany jako pierwszy argument wywołania wskazywać będzie na adres nieparzysty, ryzykujemy wystąpienie błędu adresowania. Ponadto procesory 68020, 68030 i 68040 efektywniej obsługują słowa czterobajtowe, jeżeli są wyrównane na granicy adresu podzielnego przez 4; na szczęście z tym problemem poradzić sobie można względnie łatwo, kopiując (bajt po bajcie) składający się z 1 – 3 bajtów kawałek położony przed granicą wyrównania. To wszystko ma oczywiście sens jedynie w środowisku wspomnianej serii procesorów, dlatego jako dodatkowego środka ostrożności użyjemy symbolu kompilacji warunkowej MC6800; jeżeli nie będzie on zdefiniowany (czyli kompilacja odbywać się będzie np. na PC), funkcja zostanie skompilowana w taki sposób, iż wypełnianie obszaru odbywać się będzie bajt po bajcie. Dwie asercje posłużą ponadto do zweryfikowania rozmiaru bajtu i zmiennej typu long oraz upewnienia się, iż wartość sizeThreshold większa jest od 3: void *memset(void *pv, byte b, size_t size) { byte *pb = (byte *)pv; #ifdef MC680x0 if (size >= sizeThreshold) { unsigned long l;
38
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
39
ASSERT (sizeof(long) == 4 && CHAR_BIT ==8); ASSERT (sizeThreshold >= 3); /* sprowadź wskaźnik pb do granicy adresu podzielnego przez 4 */ /* wypełniając bajty poprzedzające tę granicę */ while (((unsigned long)pb & 3) != 0) { *pb++ = b; size--; } /* spakuj teraz wzorzec wypełniania do słowa czterobajtowego */ /* i wypełnij resztę obszaru */ l = (b 0) *pb To++ = b; } else *pbTo++ = b; } return (pbTo); }
Funkcja dekompresująca wypisuje do bufora wyjściowego kolejne znaki napotkane w buforze wejściowym do momentu natrafienia na specjalny, zarezerwowany kod oznaczony tutaj jako bRepeatCode. Kod ten rozpoczyna trójbajtową sekwencję reprezentującą ciąg powtarzających się znaków — drugi
40
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
41
bajt stanowi licznik powtórzeń, zaś odnośny znak zapisany jest w trzecim bajcie; na taki właśnie ciąg przetwarza ową sekwencję funkcja dekompresująca. Jest oczywiste, iż nie opłaca się kompresować ciągów krótszych niż trzy powtórzenia — przekształcenie pojedynczego powtórzenia („aa”) na sekwencję specjalną (bRepeatCode;2;”a”)wydłuża tekst o jeden bajt, więc żadna to kompresja, z kolei „aaa” wygląda czytelniej niż (bRepeatCode;3;”a”). Można więc śmiało założyć, iż licznik (tj. drugi bajt) wspomnianej sekwencji musi mieć wartość nie mniejszą niż 4 i oczywiście ująć to w formie odpowiedniej asercji: . . . { /* wypisz do bufora wskazywanego przez "pbTo" */ /* "size" kopii znaku pamiętanego w "b" */ size = (size_t)*pbFrom++; ASSERT(size >= 4); while (size-- > 0) *pb To++ = b; } . . .
Wygląda to całkiem logicznie i rzeczywiście funkcjonuje poprawnie — jednakże tylko do czasu, gdy w kompresowanym strumieniu wejściowym pojawi się znak bRepeatCode. Przepisanie go wprost do kompresowanego strumienia (na równi z pozostałymi znakami wejściowymi) spowoduje niezłe zamieszanie, bowiem przy dekompresji zostanie on potraktowany jako specjalny znacznik sekwencji, nie zaś zacytowany znak z wejścia. Jeżeli przykładowo w kompresowanym strumieniu po znaku bRepeatCode wystąpią kolejno znaki „A” i „B”, to procedura dekompresująca napotkawszy później sekwencję (bRepeatCode; „A”; „B”) spowoduje 65-krotne wypisanie znaku „B” (znak „A” posiada kod ASCII 65). Wynika stąd prosty wniosek, iż napotkany w kompresowanym strumieniu pojedynczy znak bRepeatCode (a także jego dwu- lub trzykrotne powtórzenie) musi być zakodowany w postaci sekwencji specjalnej (bRepeatCode;licznik;bRepeatCode), gdzie licznik wyjątkowo może mieć wartość 1, 2 lub 3. Nasza prosta asercja nieco się przez to komplikuje: ASSERT(size >= 4 || (size > 0) && (b == bRepeatCode));
Tak więc początkowo „niemożliwa” wartość licznika staje się teraz jak najbardziej uzasadniona.
Wykorzystaj asercje do wykrywania „niemożliwych” sytuacji.
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
41
NIEZAWODNOŚĆ OPROGRAMOWANIA
42
NIC O NAS BEZ NAS Wyobraź sobie, iż zlecono Ci realizację procedury awaryjnego sterowania reaktorem jądrowym, a dokładniej — obsługę podsystemu chłodzenia awaryjnego w przypadku przegrzania się rdzenia. Procedura obsługi ma podejmować rutynowe działania, określone drobiazgowo w formie tabeli decyzyjnej, i w przypadku ich nieskuteczności alarmować obsługę. Koncepcja alternatywna zakłada alarmowanie obsługi każdorazowo, gdy czujniki wykryją podwyższoną temperaturę rdzenia — niezależnie od tego, jak efektywnie poradzi sobie z tą awarią wspomniana procedura. Rdzenie reaktorów nie przegrzewają się bowiem przy normalnej ich eksploatacji — każde przegrzanie może więc zwiastować poważną nieprawidłowość, która nie może pozostać niezauważona przez obsługę. Druga z opisanych koncepcji nosi na gruncie programowania nazwę „programowania defensywnego”, co notabene trafnie oddaje charakter zmagań programistów, testerów i użytkowników z uporczywymi błędami. Każda „nienormalna” sytuacja w programie (na przykład ujemna wartość zmiennej, która z założenia jest sumą kwadratów liczb rzeczywistych) ma swoją przyczynę (w tym przypadku — błędy zaokrągleń wynikłe z wyboru źle uwarunkowanego algorytmu) i jako taka powinna być sygnalizowana (na przykład programistom na etapie testowania) nawet wówczas, gdy nie stanowi zagrożenia dla dalszego wykonania (bo np. wartość odchylenia standardowego nie jest tu istotna). Niestety, jakże często programiści — szczególnie ci zaawansowani — wykazują tendencję do nadmiernego „automatyzowania” obsługi owych nienormalnych sytuacji i być może pozbawiają się w ten sposób szansy na wyłapanie głęboko ukrytych błędów. By rzecz całą nieco przybliżyć, zmodyfikujmy kod naszej funkcji dekompresującej: byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom) { byte b, *pbEnd; size_t size; pbEnd = pbFrom+sizeFrom; /* pbEnd wskazuje bajt następny */ /* za ostatnim bajtem bufora */ while (pbFrom != pbEnd) { b = *pbFrom++; /* odczyt 1 */ if (b == bRepeatCode) { /* wypisz do bufora wskazywanego przez "pbTo" */ /* "size" kopii znaku pamiętanego w "b" */ size = (size_t)*pbFrom++; /* odczyt 2 */ do *pb To++ = b; while (--size != 0);
42
/* odczyt 3 */
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
43
} else *pbTo++ = b; } return (pbTo); }
Na pierwszy rzut oka wydaje się, iż wprowadzone modyfikacje nie wnoszą nic nowego. Zgodnie z warunkiem pętli zewnętrznej przetwarzanie bufora trwa tak długo, aż wskaźnik bieżącej pozycji (pbFrom) nie zrówna się ze wskaźnikiem końca (pbEnd). „Na starcie” iteracji pbFrom jest zawsze mniejszy niż pbEnd, w pętli jest on inkrementowany o 1, nie ma więc obawy, iż „rozminie się” on z pbEnd. Niby to całkiem logiczne — a jednak nie od rzeczy będzie zapytać, co stanie się wówczas, gdy rozminięcie takie mimo wszystko nastąpi? Niemożliwe? No to popatrz: załóżmy, iż na ostatniej pozycji skompresowanego ciągu występuje znak bRepeatCode. Instrukcja oznaczona jako /* odczyt 1 */ pobiera ten znak, nadając wskaźnikowi pbFrom wartość pbEnd. Instrukcja oznaczona jako /* odczyt 2 */ „odczytuje” nieistniejący licznik powtórzeń, znowu inkrementując pbFrom, zaś w instrukcji oznaczonej jako /* odczyt 3 */ następuje jego dalsza, wielokrotna inkrementacja. W efekcie po wykonaniu tegoż „obrotu” pętli pbFrom wskazuje daleko poza pbEnd, z którym nie ma już szans się spotkać i pętla kręci się praktycznie w nieskończoność (dokładniej — w trybie adresowania rzeczywistego dzięki „zawinięciu” się segmentu wokół granicy 64 K obydwa wskaźniki w końcu się spotkają, w trybie chronionym nastąpi prawdopodobnie przekroczenie dopuszczalnego rozmiaru segmentu — przyp. tłum.). Pierwotna wersja (ta z użyciem operatora < ) wyklucza takie patologiczne zachowanie. Jeszcze bardziej niepewnie wygląda pętla do...while iterująca powtórzenie znaku — co się stanie, jeżeli z jakiejkolwiek przyczyny początkową wartością licznika size będzie zero? Iteracja w pierwotnej wersji oparta na instrukcji while jest na taką ewentualność przygotowana. Mamy więc dwie wersje tej samej funkcji: pierwotną, opartą na podejściu defensywnym, i „ryzykowną”, pozbawioną takiej filozofii. Co prawda żadna z nich nie obsłuży poprawnie przypadku wystąpienia znaku bRepeat Code na ostatniej lub przedostatniej pozycji ciągu wejściowego, lecz pierwsza z nich zatrzyma się najdalej na znaku o adresie pbEnd+1, zaś druga ma szansę kręcić się jeszcze bardzo długo; pierwsza stwarza niebezpieczeństwo wypisania co najwyżej 255 niepotrzebnych znaków, druga z dziką determinacją przetwarzać będzie całą dostępną pamięć operacyjną! I to wszystko z powodu nieoczekiwanego wystąpienia znaku bRepeatCode — które bezwzględnie powinno być sygnalizowane za pośrednictwem stosownej asercji. Należy mianowicie powrócić do „defensywnej” wersji funkcji, a po zakończeniu pętli zewnętrznej sprawdzić, czy wskaźnik pbFrom zrównał się z pbEnd: byte *pbExpand(byte *pbFrom, byte *pbTo, size_t sizeFrom) { byte b, *pbEnd;
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
43
NIEZAWODNOŚĆ OPROGRAMOWANIA
44
size_t size; pbEnd = pbFrom+sizeFrom; /* pbEnd wskazuje bajt następny */ /* za ostatnim bajtem bufora */ while (pbFrom < pbEnd) { b = *pbFrom++; . . . } ASSERT(pbFrom == pbEnd); return (pbTo); }
CO DWA ALGORYTMY, TO NIE JEDEN Błędne dane wejściowe, czy też nieuzasadnione założenia to bynajmniej nie jedyne źródła błędów. Nawet bezbłędna funkcja, gdy otrzymuje bezsensowne dane, musi produkować bezsensowne wyniki. W przypadku funkcji tak prostych, jak memcpy lub memset pomyłka tego rodzaju jest raczej mało prawdopodobna, lecz w przypadku funkcji bardziej skomplikowanych szanse na popełnienie błędu wzrastają niepomiernie. Tworzyłem swego czasu prosty disasembler dla procesora 68000 jako jeden z programów narzędziowych dla programistów używających macintoshy. Generalnie w przypadku disasemblerów szybkość nie jest czynnikiem krytycznym, za to poprawność produkowanych wyników jest wymogiem bezwzględnym; użyłem więc algorytmu sterowanego tablicą — ze względu na prostotę i łatwość testowania — wprowadzając do niego pewną liczbę asercji aktywnych oczywiście tylko w trybie testowania. Zdajesz sobie zapewne sprawę z faktu, iż funkcjonalny opis procesora odbywa się w kategoriach struktury jego rozkazów i drobiazgowego ich rozbioru na poszczególne bity; jednym z elementów owego „rozbioru” dla konkretnego rozkazu jest zazwyczaj wzorzec bitowy umożliwiający jednoznaczne odróżnienie tego rozkazu od innych. Na przykład — rozkaz ADD procesora 68000 daje się przedstawić w następującym formacie:
Bity 15 – 12 mają tu ustaloną postać 1101, co pozwala zidentyfikować ów rozkaz jednoznacznie, niezależnie od postaci pozostałych 12 bitów. „Ustalone bity” rozkazu nie muszą tworzyć spójnej grupy, lecz występować w różnych miejscach rozkazu, jak np. w rozkazie DIVS:
44
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
45
W celu rozpoznania konkretnego rozkazu, należy użyć koniunkcji bitowej maskującej nieistotne bity: if ((inst & 0xF000) == 0xD000) ...to jest rozkaz ADD... .
if ((inst & 0xF1C0) == 0x81C0) to jest rozkaz DIVS...
W taki właśnie sposób funkcjonować miał mój disasembler. Oczywiście bezsensem byłoby tworzenie ciągu 142 instrukcji if — o wiele praktyczniejszym rozwiązaniem jest w tym przypadku tablica, której poszczególne elementy, odpowiadające poszczególnym rozkazom procesora, składają się (kolejno) z maski bitowej, żądanego wzorca bitowego i adresu funkcji dokonującej szczegółowego dekodowania rozkazu. Oto przykładowy początek i koniec owej tablicy: /* /* /* /*
każdy element tablicy IdInst zawiera kolejno maskę bitową nakładaną na słowo rozkazowe, oczekiwany wynik tego maskowania oraz adres funkcji, której powierzane jest szczegółowe dekodowanie zidentyfikowanego rozkazu.
static identity idInst[] = { { 0xFF00, 0x0600, pcDecodeADDI { 0xF130, 0xD100, pcDecodeADDX { 0xF000, 0xD000, pcDecodeADD { 0xF000, 0x6000, pcDecodeBcc { 0xF1C0, 0x4180, pcDecodeCHK { 0xF100, 0xB100, pcDecodeEOR { 0xFF00, 0x0C00, pcDecodeCMPI { 0xF1C0, 0x81C0, pcDEcodeDIVS { 0xF138, 0xB108, pcDecodeCMPM
*/ */ */ */
}, }, }, }, }, }, }, }, },
. . . { 0xFF00, 0x4A00, pcDecodeTST }, { 0xFFF8, 0x4E58, pcDecodeUNLK }, { 0x0000, 0x0000, pcDecodeError } };
Funkcja deasemblująca przegląda tę tablicę sekwencyjnie w poszukiwaniu pasującego rozkazu, po czym przekazuje ów rozkaz do szczegółowego zdekodowania: /* * * * *
pcDisasm Funkcja deasembluje pojedynczą instrukcję i wypełnia strukturę opcode wynikami dekodowania. Wartością zwracaną przez funkcję jest zaktualizowany licznik rozkazów.
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
45
NIEZAWODNOŚĆ OPROGRAMOWANIA
46
* * * Przykład wywołania: * pcNext = pcDisasm(pc, &opc); * */ instruction *pcDisasm(instruction *pc, opcode *popcRet) { identity *pid; instruction inst = *pc; for (pid = &idInst[0]; pid->mask != 0; pid++) { if ((inst & pid->mask) == pid->pat) break; } return (pid->pcDecode(inst, pc+1, popcRet)); }
Jako wynik funkcja zwraca uaktualniony licznik rozkazów — jest to konieczne, gdyż nie wszystkie instrukcje procesora 68000 mają taką samą długość. Powróćmy teraz do sprawy zasadniczej, czyli do weryfikacji produkowanych wyników. Nawet przy założeniu, iż funkcja pcDisasm poprawnie rozpoznaje kod instrukcji, nie ma gwarancji, iż bezbłędnie przebiega dekodowanie szczegółowe oraz aktualizacja licznika rozkazów (każdy, kto zajmował się deasemblacją dobrze wie, co oznaczać może „przekłamanie” licznika o jeden bajt). Można by to sprawdzić przez umieszczenie w każdej ze szczegółowych funkcji dekodujących stosownej asercji; nie twierdzę, iż jest to niewykonalne, lecz nieco wygodniejszym rozwiązaniem będzie niezależne sprawdzenie danych zwracanych przez wspomniane funkcje. Po chwili zastanowienia nietrudno skonstatować, iż wymagałoby to stworzenia drugiego disasemblera; aby więc sprawdzić poprawność wykonanej czynności, musimy ją wykonać powtórnie — co wygląda na błędne koło! Tymczasem metodologia ta nie tylko nie jest bezsensowna, lecz nawet powszechnie stosowana w praktyce. W popularnym Excelu automat przeliczający zawartość komórek (po zmianie którejś komórki) musi być skonstruowany niezwykle efektywnie, w szczególności — musi on rozpoznawać i pomijać te komórki, które przeliczenia nie wymagają. Optymalizacje wprowadzane do procedur przeliczających stanowią bardzo dobrą okazję do popełnienia rozmaitych błędów, konieczne są więc mechanizmy, które — w trybie testowania programu — zdolne będą zweryfikować poprawność przeliczenia. Istotnie — jeżeli skompilować i uruchomić program w tym trybie, po zakończeniu automatycznego przeliczania przywracana jest poprzednia zawartość komórek i uruchamiane przeliczenie testowe, tym razem obejmujące wszystkie komórki zawierające formułę; jakakolwiek rozbieżność w rezultatach obydwu przeliczeń wskazuje na niechybny błąd i jest natychmiast sygnalizowana. Podobne rozwiązanie zastosowano w popularnym edytorze Word w odniesieniu do układu strony, przy czym sprawa jest tu nieco trudniejsza — w przeciwieństwie bowiem do Excela kolejne wersje Worda owocują znaczącymi nowościami wpływającymi na układ strony. Sposób weryfikowania poprawności Worda jest jednak identyczny — niezwykle efektywny algorytm, zrealizowany w
46
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
47
asemblerze, porównywany jest (pod względem rezultatów) ze znacznie mniej efektywną, lecz prostszą do zrozumienia i przez to pewniejszą procedurą testową. Moja funkcja pcDisasmAlt realizująca „kontrolną” deasemblację zrealizowana została w postaci ciągu instrukcji switch prowadzących do wyizolowania żądanego wzorca bitów identyfikującego kod instrukcji; jest to rozwiązanie zdecydowanie bardziej „rozwlekłe” od wariantu opartego na tablicy, lecz znacznie od niego pewniejsze, naśladuje ono bowiem prymitywny sposób postępowania „na piechotę”. Nie chciałbym Cię tutaj zanudzać szczegółami konstrukcji funkcji pcDisasmAlt, przedstawię więc od razu sposób jej użycia: instruction *pcDisasm(instruction *pc, opcode *popcRet) { identity *pid; instruction inst = *pc; instruction *pcRet; for (pid = &idInst[0]; pid->mask != 0; pid++) { if ((inst & pid->mask) == pid->pat) break; } pcRet = pid->pcDecode(inst, pc+1, popcRet); #ifdef DEBUG { opcode opc; /* porównaj wyniki deasemblacji */ ASSERT(pcRet == pcDisasmAlt(pc, &opc)); ASSERT(compare_opc(popcRet, &opc) == SAME); } #endif return (pcRet); }
Zasadniczo włączanie sekwencji testujących do głównego kodu powinno odbywać się bez jakichkolwiek modyfikacji tego ostatniego, często jednak wymóg ten jest niemożliwy do zrealizowania w sensie dosłownym — i tak w funkcji pcDisasm konieczne stało się użycie dodatkowej zmiennej lokalnej pcRet. Tego rodzaju zabiegi powinny być stosowane z rozwagą, w każdym razie są one bezpieczne tak długo, dopóki nie zostaje złamana zasada, iż kod testujący powinien być wykonywany oprócz kodu zasadniczego, nigdy zamiast niego; w rozdziale 3. przedstawię przykład złamania tej zasady. Nie namawiam nikogo do tworzenia dwóch wersji każdej funkcji programu — byłoby to tak samo śmieszne, jak marnotrawienie długich godzin na „groszowe” optymalizacje mające rzekomo doprowadzić do uzyskania jak najefektywniejszej wersji.
xxxxxxxxxxxx
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
47
48
NIEZAWODNOŚĆ OPROGRAMOWANIA
Nieco wcześniej w niniejszym rozdziale, przy okazji opisywania makra ASSERT stwierdziłem, iż asercja powinna mieć raczej postać makra niż funkcji, bowiem wywoływanie funkcji może powodować rozmaite skutki uboczne i niekorzystnie oddziaływać na kod zasadniczy. Cóż więc w takim razie mają znaczyć dwie poniższe asercje? ASSERT(pcRet == pcDisasmAlt(pc, &opc)); ASSERT(compare_opc(popcRet, &opc) == SAME);
Niekonsekwencja jest tutaj jedynie pozorna. Otóż funkcja pcDisasmAlt wywoływana jest nie przez samą asercję, lecz przez używającego tej asercji programistę. Wywołanie to jest bezpieczne w kodzie zasadniczym, nie ma więc obawy, iż przestanie takim być w ramach asercji. Zamiast tego warto ograniczyć owe zabiegi do funkcji najważniejszych, których działanie musi być bezwzględnie poprawne niezależnie od innych okoliczności; w Excelu będzie to automat przeliczający, w Wordzie konstruktor układu strony, w oprogramowaniu bazodanowym procedury wyszukiwania i ekstrakcji danych, i ogólnie w każdym programie — te fragmenty, które gwarantują użytkownikowi zachowanie jego danych w poprawnej postaci. W przełożeniu na kod źródłowy — warto poszukać w programie tzw. wąskich gardeł (ang. bottlenecks), czyli miejsc, przez które sterowanie przepływa szczególnie intensywnie. Obliczenia dokonywane w takich miejscach powinny być weryfikowane przez alternatywny, niezależny algorytm — należy jedynie pamiętać, by użyć do tego celu istotnie innego algorytmu, nie zaś innej implementacji algorytmu zasadniczego.
Wykorzystaj niezależny algorytm do weryfikacji obliczeń dokonywanych przez algorytm zasadniczy.
USUWAJ BŁĘDY JAK NAJWCZEŚNIEJ Funkcja pcDisasm dokonuje deasemblacji każdego z rozkazów w dwóch wyraźnie rozdzielonych fazach: najpierw za pomocą tablicy IdInst rozpoznawany jest kod rozkazu, następnie dedykowana funkcja (określona za pomocą tejże tablicy) dokonuje szczegółowej analizy pozostałych pól. Zakłada się przy tym, iż dany wzorzec bitowy zawsze jednoznacznie identyfikuje określony rozkaz; o tym, iż założenie to nie zawsze jest prawdziwe, można się łatwo przekonać na przykładzie dwóch poniższych rozkazów:
48
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
49
Algorytm „maskujący” po stwierdzeniu (na podstawie jednej z pozycji tablicy idInst), iż nałożona na słowo rozkazowe maska 0F100 daje wynik 0B100, rozpoznaje ów rozkaz jako EOR — nawet wówczas, gdy ma do czynienia z rozkazem CMPM. Przyczyną takiego ewidentnie błędnego zachowania jest oczywiście nie dość
ogólna maska testująca; zauważ, iż opisany błąd nie wystąpiłby, gdyby pozycja odpowiadająca rozkazowi CMPM występowała w tablicy idInst wcześniej niż pozycja odpowiadająca rozkazowi EOR. Na szczęście błąd ten może zostać szybko wykryty, jeżeli rezultat dekodowania rozkazu weryfikowany jest niezależnie przez inny, alternatywny algorytm (czyli funkcję pcDisasmAlt) — oczywiście pod warunkiem, iż ten ostatni nie powiela błędnego założenia przyjętego w algorytmie zasadniczym. Tym samym staje się jasne wymaganie, by ów alternatywny algorytm był istotnie innym algorytmem, nie zaś inną wersją algorytmu zasadniczego. Mniej pocieszające jest to, iż opisywany błąd należy do kategorii błędów objawiających się bardzo rzadko: nie zostanie wykryty, jeżeli deasemblowany kod nie będzie zawierał rozkazu CMPM. Co więcej — jeżeli użytkownik nie użyje dodatkowo innego disasemblera (dla porównania wyników) błąd ten najprawdopodobniej pozostanie przez niego niezauważony! Wszak disasembler poprawnie kończy swą pracę, produkując rozsądnie wyglądający listing. W nie lepszej sytuacji są testerzy — gdyby nie weryfikacje dokonywane przez alternatywny algorytm, błąd mógłby pozostać niewykryty (teoretycznie) na zawsze. Nieporównywalnie większe szanse ma więc programista tworzący disasembler — jest więc jasne, iż powinien on tę przewagę wykorzystać i nie spychać całej odpowiedzialności na zespół testujący. Jednak wykrycie błędu przez asercję odwołującą się do funkcji pcDismasm Alt to osobna sprawa — cóż jednak należałoby uczynić, by tego typu błędom zapobiegać już na samym początku? Przyczyną błędnego rozpoznawania rozkazu CMPM była nie dość ogólna maska testująca, a ściślej — nieodpowiednia kolejność elementów tablicy idInst. Otóż maski oparte na większej liczbie bitów jawnych powinny w tej tablicy znajdować się na wcześniejszych pozycjach, w szczególności maska testująca rozkaz CMPM (bitowo 1011xxx1xx001xxx — osiem jawnych bitów) powinna poprzedzać maskę dla rozkazu EOR (1011xxx1xxxxxxxx — tylko pięć jawnych bitów). Poniższa funkcja dokonuje sprawdzenia, czy jest tak istotnie: void CheckIdInst(void) { identity *pid, *pidEarlier; instruction inst; /* dla każdej pozycji w tablicy ... */ for (pid = &idInst[0]; pid->mask != 0; pid++) { /* ... sprawdź, czy któraś z pozycji poprzedzających */
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
49
NIEZAWODNOŚĆ OPROGRAMOWANIA
50
/* nie posiada bardziej ogólnej maski
*/
for (pidEarlier = &idInst[0]; pidEarlier < pid; pidEarlier++) { inst = pid->pat | (pidEarlier->pat & ~pid->mask); if ((inst & pidEarlier->mask) == pidEarlier->pat) ASSERT(bitcount(pid->mask) < bitcount(pidEarlier->mask)); } } }
Funkcja ta powinna zostać wywołana na samym początku programu (w funkcji main) — jakakolwiek „kolizja” pozycji w tablicy idInst spowoduje wówczas
„zadziałanie” stosownej asercji.
Nie czekaj, aż błąd da znać o sobie; spróbuj mu zawczasu zapobiec. Jak każde narzędzie, również i asercje mogą być nadużywane. Właściwe ich wykorzystanie jest zawsze sprawą konkretnego zastosowania, lecz także osobistego stylu programisty — dla wielu programistów asercje badające np. niezerowość dzielnika są czymś wręcz niezbędnym, równie wielu uważa je natomiast za coś śmiesznego. Nie należy usuwać asercji z kodu programu po zakończeniu jego testowania; przydadzą się one w przyszłości, podczas implementowania nowych funkcji.
50
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
51
xxxxxxxxxxxxxxxx Ale uwaga: umieszczenie asercji w kodzie intensywnie wykorzystywanych bibliotek może spowodować istną lawinę komunikatów o błędach, co może łatwo zdezorientować niczego nie spodziewających się testerów. Pamiętam, jak jeden z kolegów domagał się przywrócenia przeze mnie poprzedniej wersji testowanej biblioteki (wersja ta nie używała jeszcze asercji), gdyż liczba komunikatów generowanych przez asercje w nowej wersji była tak ogromna, iż zachodziło podejrzenie co do ich sensowności — kolega twierdził wręcz, iż w tak dużej masie komunikatów, większość z nich to po prostu fałszywe alarmy. Poprosiłem go wówczas o znalezienie przynajmniej jednego z takich „fałszywych alarmów”; zgodził się, acz niechętnie. Po wnikliwej analizie okazało się, iż przyczyną „odpalania” asercji w mojej bibliotece były błędy nie w niej samej, lecz w intensywnie wykorzystującym ją projekcie. Cała sprawa miała ponadto oczywisty aspekt psychologiczny. Istna lawina niespotykanych dotąd komunikatów mogła rodzić podejrzenie, iż autorzy projektu, wobec nieustannego nacisku na terminowe jego ukończenie, popełnili ostatnio jakiś potworny błąd; nie wpadliby w panikę, gdyby wyjaśniono im, iż przyczyną wszystkiego są nieobecne poprzednio asercje.
PODSUMOWANIE Należy utrzymywać dwie (równoważne) wersje oprogramowania — handlową i testową. Należy używać asercji dla upewnienia się, iż zakładany w danym miejscu kodu warunek rzeczywiście zachodzi, zaś niemożliwe do wystąpienia zdarzenie rzeczywiście nie występuje. Nie należy używać asercji do weryfikowania sytuacji, które nie zależą od programistów, lecz od warunków zewnętrznych panujących w czasie wykonania programu. Sytuacje takie nie mogą być bowiem traktowane jak błędy programistyczne, lecz zdarzenia wyjątkowe, które należy obsłużyć. Poprawność parametrów wywołań funkcji powinna być weryfikowana za pomocą asercji; im bardziej przemyślana definicja funkcji, tym łatwiejsza owa weryfikacja. Przy tworzeniu programu należy zdawać sobie sprawę z przyjętych milcząco założeń; założenia te powinny być zweryfikowane za pomocą asercji. Jeżeli prawdziwość jakiegoś założenia nie potwierdzi się, należy usunąć z kodu programu wszelkie uzależnienia od niego. Niektóre fragmenty kodu są bardziej podatne na błędy niż inne. Starając się zlokalizować te fragmenty , zastanów się nad metodami automatycznego wykrycia potencjalnych błędów. Programowanie defensywne jest techniką ze wszech miar pożądaną, jednak związane z nim mechanizmy zabezpieczeń przyczyniają się do ukrywania istniejących błędów. Aby osłabić tę niekorzystną tendencję, wskazane jest
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
51
NIEZAWODNOŚĆ OPROGRAMOWANIA
52
posiłkowanie się asercjami do wykrywania zdarzeń, których wystąpienie zostało uznane za teoretycznie niemożliwe.
POMYŚL O TYM 1. Załóżmy, że stworzyłeś nafaszerowaną asercjami testową wersję biblioteki, jednak z pewnych względów nie chcesz udostępnić testerom kodu źródłowego; informacje o lokalizacji błędu (w postaci „plik-linia”) wypisywane przez makro ASSERT będą więc dla nich bezużyteczne. W jaki sposób zaprojektowałbyś własne makro ASSERTMSG, by niespełniona asercja powodowała wypisanie czytelnej informacji o błędzie, w rodzaju „niespełniona asercja w funkcji memcpy — bloki nakładają się”? 2. Każde wywołanie makra ASSERT powoduje wygenerowanie nowego łańcucha z nazwą pliku źródłowego; w pliku zawierającym (powiedzmy) 73 asercje wygenerowane zostaną więc 73 oddzielne łańcuchy o identycznej zawartości. W jaki sposób zmieniłbyś definicję makra ASSERT, by dla każdego pliku źródłowego odnośny łańcuch generowany był co najwyżej jednokrotnie? 3. Na czym polega nieprawidłowość użycia asercji w poniższym fragmencie kodu? /* getline – wczytuje do bufora linię ograniczoną znakiem \n */ void getline(char *pch) { int ch; /* ch musi być deklarowane jako int */ do ASSERT((ch = getchar()) != EOF); while ((*pch++ = ch) != '\n'); }
4. Kiedy programiści wzbogacają typ wyliczeniowy nową wartością, często zapominają o uzupełnieniu instrukcji switch nowym wariantem związanym z dodaną wartością. W jaki sposób wykorzystanie asercji mogłoby być pomocne w wykrywaniu błędów tego rodzaju? 5. Funkcja CheckIdInst weryfikuje poprawną kolejność elementów tablicy idInst, lecz nie usuwa to bynajmniej wszystkich błędów związanych z tą tablicą — jest ona naszpikowana wartościami liczbowymi, łatwo więc o pomyłkę przy ich wpisywaniu. W jaki sposób wzbogaciłbyś funkcję CheckIdInst, aby ułatwić automatyczne wyłapanie takich pomyłek? 6. Jak widzieliśmy nieco wcześniej, pole trybu adresu efektywnego rozkazu EOR nie może mieć wartości 001, gdyż wówczas mielibyśmy do czynienia z rozkazem CMPM. To nie jedyne ograniczenie związane z rozkazem EOR: dodatkowo w dwubitowym polu trybu nie może się znajdować wartość 11 (gdyż byłby to wówczas rozkaz CMPA.L); jeżeli pole trybu adresu efektywnego ma postać 111, w sąsiadującym z nim polu rejestru dopuszczalne
52
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
SPRAWDZAJ SAMEGO SIEBIE
53
są tylko wartości 000 lub 001. Ponieważ funkcja pcDecodeEOR nie powinna zostać wywołana w przypadku niespełnienia choćby jednego z tych ograniczeń — w jaki sposób wzbogaciłbyś asercje wykrywające niepoprawne dane w funkcji pcDisasm? 7. W jaki sposób użyłbyś alternatywnego algorytmu do zweryfikowania poprawnego działania funkcji wykonujących następujące działania: a. Sortowanie szybkie (ang. quicksort), b. Przeszukiwanie binarne, c. Konwersję liczby całkowitej na postać znakową.
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
53
54
54
NIEZAWODNOŚĆ OPROGRAMOWANIA
E:\TMP\Doc\c4f4828e-b171-482d-9862-b1a53414e2c3.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 57
3. UFORTYFIKUJ SWOJE PODSYSTEMY
Na stadion zdolny pomieścić 50000 widzów można wejść jedną z (zaledwie) kilku bram, obsługiwanych łącznie przez kilkunastu (kilkudziesięciu) bileterów. Podobne „bramy” skrywa w sobie każdy system operacyjny — za ich pośrednictwem programy użytkowe korzystają z całego bogactwa usług jego podsystemów. Weźmy jako przykład system plików: większość operacji plikowych sprowadza się do podstawowych czynności — otwierania, zamykania, tworzenia, odczytu, zapisu itp. Te elementarne operacje realizowane są jednak przez kod o znacznym stopniu złożoności, wykonujący skomplikowane zadania w rodzaju gospodarowania pamięcią dyskową, rozstrzygania konfliktów w warunkach wielodostępu czy też obsługi urządzeń zewnętrznych (np. drukarek) w sposób dla nich specyficzny. Złożoność ta nie jest jednak zmartwieniem programisty — jego interakcja z systemem plików ogranicza się do wspomnianych „bramek”, stanowiących „punkty wejścia” do podprogramów realizujących poszczególne usługi. Nie mniej skomplikowanym podsystemem jest podsystem zarządzania pamięcią. Proste — z punktu widzenia użytkownika — operacje alokowania, zwalniania i zmiany rozmiaru przydzielonych bloków pamięci realizowane są przez procedury o znacznym stopniu złożoności, zwłaszcza w systemach wielozadaniowych. Z punktu widzenia użytkownika wszystko sprowadza się jednak do wywoływania właściwych procedur. Punkty wejścia do usług systemowych stanowią doskonały przykład „wąskich gardeł” szczególnie nadających się (zgodnie z sugestiami zawartymi w poprzednim
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
57
58
NIEZAWODNOŚĆ OPROGRAMOWANIA
rozdziale) do kontrolowania poprawności wykonania, ściślej — do sprawdzania, czy odwołanie się do danej usługi dokonywane jest w sposób prawidłowy. Błędy związane z niewłaściwą obsługą pamięci bywają szczególnie uciążliwe, najczęściej bowiem objawiają się sporadycznie i samo doprowadzenie do ich powtórnego wystąpienia jest już nie lada sztuką. Oto przykład kilku — wziętych z życia — błędnych „zachowań” programu, które w konsekwencji skutkować mogą wspomnianymi błędami: odwoływanie się do przypadkowej zawartości nowo przydzielonego, nie zainicjowanego jeszcze bloku, odwoływanie się do zawartości zwolnionego bloku, wywołanie funkcji realloc dokonującej przemieszczenia bloku, a następnie odwoływanie się do poprzedniej instancji tego bloku, przydzielenie bloku i utrata dostępu do niego, z powodu niezapamiętania zwróconego wskaźnika, odczyt lub zapis poza granicami przydzielonego bloku. Załóżmy teraz, iż ktoś zlecił Ci zadanie napisania funkcji malloc, free i realloc dla standardowej biblioteki C (w końcu ktoś kiedyś napisał te funkcje...); w pierwszym odruchu pomyślisz zapewne o asercjach, które mogłyby zapobiec (przynajmniej częściowo) opisanym patologiom, jednak po krótkim zastanowieniu stanie się oczywiste, iż asercje są w tej sytuacji bezsilne — opisane zachowania są nie do wykrycia z poziomu wspomnianych funkcji! Należy zatem pomyśleć o innych mechanizmach testowych.
JEST BŁĄD, NIE MA BŁĘDU Wypadałoby w tym miejscu zademonstrować rzeczywisty kod funkcji malloc, free i realloc wzbogacony wspomnianymi mechanizmami testowymi, nie zrobię tego jednak z dwóch powodów: po pierwsze zaciemniłoby to nieco klarowność treści książki, po drugie — nawet jeżeli producent używanego przez Ciebie kompilatora udostępnia kod źródłowy swej biblioteki, to kod ten może różnić się od tego, który ja posiadam, i który zaprezentowałbym tutaj. Zamiast więc ingerować w implementację funkcji systemowych, osiągniemy żądany efekt w sposób znacznie prostszy — skonstruujemy mianowicie funkcje-otoczki służące identycznym celom i właśnie w ich treści zaimplementujemy stosowne testy. Rozpocznijmy od równoważnika funkcji malloc: /* fNewMemory – przydziela blok pamięci */ flag fNewMemory(void **ppv, size_t size) { byte **ppb = (byte **)ppv; *ppb = (byte *)malloc(size) return (*ppb != NULL);
58
/* Udało się ? */
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 59
}
Na pierwszy rzut oka funkcja fNewMemory wygląda na skomplikowaną — głównie za sprawą argumentu typu void ** — jednak jej wywołanie jest znacznie prostsze: zamiast standardowej konstrukcji if ((pbBlock = (byte *)malloc(32)) != NULL pbBlock wskazuje na przydzielony blok pamięci else blok nie został przydzielony – funkcja zwróciła NULL
można napisać if (fNewMemory(&pbBlock, 32)) pbBlock wskazuje na przydzielony blok pamięci else blok nie został przydzielony – pbBlock równy jest NULL
Ponadto wynik funkcji malloc niesie ze sobą dwojakiego rodzaju informacje: o przydzieleniu bądź nieprzydzieleniu bloku oraz adres (ewentualnie) przydzielonego bloku. Funkcja fNewMemory rozdziela te kategorie — jej wynik informuje tylko o statusie operacji („przydzielono — nie przydzielono”), sam zaś adres przydzielonego bloku przekazywany jest pod postacią parametru. Zastanówmy się teraz, jak można by wzbogacić treść funkcji fNewMemory, by ułatwić wykrycie pierwszego z przytoczonej listy błędów — odwoływania się do przypadkowej zawartości nowo przydzielonego bloku. Wszelka „przypadkowość” jest wrogiem numer jeden skutecznego poszukiwania błędów, zatem funkcja fNewMemory mogłaby inicjować przydzielony blok wartością zerową. Wydaje się to posunięciem rozsądnym, w rzeczywistości jednak ma poważną wadę — mianowicie wykazuje tendencję do ukrywania błędów. Jeżeli (przykładowo) któreś z pól przydzielonej struktury powinno być inicjowane wartością zerową i programista czynność tę zaniedba, pozostanie to niezauważone, gdyż rzeczone pole od początku będzie miało zerową zawartość. Samo zlikwidowanie „losowości” jest jednak niezłym pomysłem — w rezultacie wspaniałym kompromisem będzie następujące posunięcie: zainicjowanie zawartości przydzielonego bloku, lecz jakąś „neutralną” zawartością, w każdym razie nie zerem. Moim zdaniem w programach przeznaczonych dla Macintosha wartością taką może być 0A3. Jeżeli jakikolwiek fragment bloku zostanie zinterpretowany jako wskaźnik 0A3A3, to przy odwołaniu do danych dwu- i czterobajtowych wygenerowany zostanie wyjątek. Z kolei próba wykonania rozkazu 0A3A3 wygeneruje wyjątek „niezdefiniowanej pułapki na linii A”. Komputery PC nie wymagają wyrównywania wskaźników — Microsoft stosuje w swoich aplikacjach (na etapie testowania) wypełnianie przydzielanych bloków wartością 0CC, co w przypadku próby wykonania bloku jako instrukcji spowoduje skierowanie wykonania do debuggera. Oto zmodyfikowana treść funkcji fNewMemory: #define bGarbage
0xA3
flag fNewMemory(void **ppv, size_t size) {
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
59
NIEZAWODNOŚĆ OPROGRAMOWANIA
60
byte **ppb = (byte **)ppv; ASSERT(ppv != NULL && size != 0); *ppb = (byte *)malloc(size); #ifdef DEBUG { if (*ppb != NULL) memset(*ppb, bGarbage, size); } #endif return (*ppb != NULL) }
Dodatkowym warunkiem badanym w asercji jest niezerowy rozmiar żądanego przydziału — zgodnie bowiem ze standardami ANSI żądanie „zerowego” przydziału powoduje skutki nieokreślone. Jeżeli teraz, testując program wykorzystujący funkcję fNewMemory, zobaczysz obszar wypełniony wzorcem 0A3A3A3A3A3A3, będzie to z dużym prawdopodobieństwem obszar niezainicjowany.
Wyeliminuj losowe zachowania programu; nadaj pojawiającym się błędom cechę powtarzalności.
ZUTYLIZUJ SWOJE ŚMIECI Zajmijmy się teraz zwalnianiem przydzielonej pamięci: void FreeMemory(void *pv) { free(pv); }
Zgodnie ze standardem ANSI, wywołanie funkcji free ze wskaźnikiem nie reprezentującym przydzielonego obszaru powoduje skutki nieokreślone, warto by więc przed wywołaniem funkcji free zbadać legalność wskaźnika pv. Tylko jak?! Niestety, podsystem zarządzania pamięcią nie umożliwia tego. Zakładając jednak poprawność wskaźnika, stajemy przed problemem znacznie poważniejszym. Jeżeli mianowicie zwalniany obszar stanowi część większej struktury (np. węzeł drzewa) to po jego zwolnieniu wszystkie wskazujące na niego wskaźniki stają się bezużyteczne; jeżeli o tym zapomnimy i będziemy używać ich nadal, może się zdarzyć, iż w naszym drzewie jeden z węzłów będzie po prostu zwolnionym obszarem — czego konsekwencje objawić się mogą dość nieoczekiwanie. Procedury zwalniające obszar mogą bowiem nie zmieniać jego zawartości (by nie tracić czasu na zbędne operacje), jeżeli jednak wypełnić ów obszar (przed wywołaniem funkcji free) jakimś charakterystycznym wzorcem, będzie się on po zwolnieniu odróżniał od „normalnego” węzła i próba jego dalszego używania ma dużą szansę na szybkie wykrycie.
60
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 61
Tylko jak?! Nawet jeżeli jakiś wskaźnik reprezentuje przydzielony obszar, nie mamy możliwości stwierdzenia, jaki jest rozmiar tego obszaru. Wygląda to beznadziejnie. Jednak niezupełnie. Załóżmy tymczasowo istnienie funkcji sizeofBlock, która otrzymawszy wskaźnik do przydzielonego bloku zwraca rozmiar tego ostatniego; przy okazji przeprowadzana jest oczywiście weryfikacja poprawności rzeczonego wskaźnika — jeżeli nie wskazuje on na dynamicznie przydzielony obszar, funkcja sizeofBlock wypisuje stosowny komunikat (za pośrednictwem asercji). Funkcję taką nietrudno skonstruować, jeżeli dysponuje się kodem źródłowym podsystemu zarządzania pamięcią; nawet jednak przy braku źródeł można sobie nieźle poradzić, co niebawem udowodnię. Zatem bezpośrednio przed zwolnieniem obszaru wypełnijmy go tym samym wzorcem, którego użyliśmy w funkcji fNewMemory: void FreeMemory(void *pv) { ASSERT (pv != NULL); #ifdef DEBUG { memset(pv, bGarbage, sizeofBlock(pv)); } #endif free(pv); }
Asercja badająca niezerowość wskaźnika pv nie jest tu konieczna — zgodnie ze standardem ANSI funkcja free wywołana z zerowym wskaźnikiem (jak parametrem) nie wykonuje żadnych czynności. Osobiście jednak nie darzę zaufaniem przekazywania zerowego wskaźnika w sytuacji, gdy wskaźnikowi temu przypisuje się jakieś znaczenie; niekiedy sytuacja taka może wskazywać na błąd w programowaniu. Nie jest to jednak kwestia szczególnie istotna — jeżeli nie podzielasz mojego zdania, możesz po prostu wspomnianą asercję usunąć. Przyjrzyjmy się teraz czynności nieco bardziej skomplikowanej, mianowicie zmianie rozmiaru przydzielonego obszaru. Standardowo zadanie to wykonuje funkcja realloc, dla której stworzyliśmy taką otoczkę: flag fResizeMemory(void **ppv, size_t sizeNew) { byte **ppb = (byte **)ppv; byte *pbNew; pbNew = (byte *)realloc(*ppb, sizeNew); if (pbNew != NULL) *ppb = pbNew; return (pbNew != NULL); }
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
61
NIEZAWODNOŚĆ OPROGRAMOWANIA
62
Podobnie jak w przypadku funkcji fNewMemory, wynik funkcji informuje o powodzeniu całej operacji; adres ewentualnie przydzielonego nowego obszaru przekazywany jest jako parametr. W przeciwieństwie jednak do funkcji realloc, która w przypadku niemożności rozszerzenia bloku zwraca NULL, funkcja fResizeMemory zwraca (jako parametr) adres obszaru oryginalnego, jednocześnie informując (poprzez wynik) o niepowodzeniu całej operacji: if (fResizeMemory(&pbBlock, sizeNew) udało się, pbBlock wskazuje na nowy blok else nie udało się, pbBlock wskazuje na blok oryginalny
Funkcje realloc i fResizememory są o tyle interesujące, iż skrywają w sobie funkcjonalność obydwu operacji — przydziału i zwalniania pamięci (zależnie od tego, czy żądamy zmniejszenia, czy też rozszerzenia bloku). Wzbogacimy teraz funkcję fResizeMemory w dwa elementy: w przypadku zmniejszania obszaru wypełnimy charakterystycznym wzorcem jego zwalnianą końcówkę; w przypadku rozszerzania obszaru wypełnimy tym wzorcem jego dodaną końcówkę: flag fResizeMemory(void **ppv, size_t sizeNew) { byte **ppb = (byte **)ppv; byte *pbNew; #ifdef DEBUG size_t sizeOld; #endif ASSERT (ppb != NULL && sizeNew != 0); #ifdef DEBUG { sizeOld = sizeofBlock(*ppb); /* jeśli zmniejszanie bloku, wypełnij końcówkę */ if (sizeNew < sizeOld) memset((*ppb)+sizeNew, bGarbage, sizeOld-sizeNew); } #endif pbNew = (byte *)realloc(*ppb, sizeNew); if (pbNew != NULL) { #ifdef DEBUG { /* jeżeli rozszerzanie bloku, wypełnij wzorcem /* dodaną końcówkę
*/ */
if (sizeNew > sizeOld) memset(pbNew+sizeOld, bGarbage, sizeNew-sizeOld); }
62
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 63
#endif *ppb = pbNew; } return (pbNew != NULL); }
Jeżeli teraz w obszarze, który właśnie miał do czynienia z funkcją fResizeMemory, napotkamy ciąg bajtów 0A3, z dużym prawdopodobieństwem
będzie to (pod)obszar zwolniony lub nowo przydzielony — a więc taki, którego zawartości nie wolno przypisywać żadnego znaczenia.
Posprzątaj swoje śmieci, aby nie mogły one zostać użyte jako materiał pełnowartościowy.
JESTEM JUŻ GDZIE INDZIEJ Przypuśćmy teraz, iż podlegający rozszerzeniu obszar jest jednym z węzłów drzewa; co stanie się, jeżeli żądający tego rozszerzenia programista nie uwzględni faktu, iż użyta przez niego funkcja realloc (albo fResizeMemory) mogła zmienić adres bloku? Otóż wszystkie wskaźniki uprzednio wskazujące na odnośny węzeł w dalszym ciągu wskazywać będą na jego poprzednią instancję, która być może niczym nie różni się od poprawnych danych i jako taka nie wzbudza żadnych podejrzeń. Może warto wobec tego wypełnić tę instancję charakterystycznym wzorcem, aby się od poprawnych danych wyraźnie odróżniała? Oto przykładowe rozwiązanie: flag fResizeMemory(void **ppv, size_t sizeNew) { . . pbNew = (byte *)realloc(*ppb, sizeNew); if (pbNew != NULL) { #ifdef DEBUG { /* jeżeli nastąpiła zmiana adresu bloku, */ /* wypełnij wzorcem jego poprzednią instancję */ if (pbNew != *ppb) memset(*ppb, bGarbage, sizeOld); /* jeżeli rozszerzono blok, wypełnij wzorcem */ /* jego dodaną końcówkę */
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
63
NIEZAWODNOŚĆ OPROGRAMOWANIA
64
if (sizeNew > sizeOld) memset(pbNew+sizeOld, bGarbage, sizeNew-sizeOld); } #endif *ppb = pbNew; } return (pbNew != NULL); }
Powyższy kod skrywa jednak dość niebezpieczną pułapkę: zwróć uwagę, iż w przypadku, gdy funkcja realloc zmieni adres bloku, poprzednia jego instancja nie jest już przydzielona do programu — tymczasem my dokonujemy wypełniania jej wzorcem, działając de facto na zwolnionym obszarze pamięci! Niektóre systemy zarządzania pamięcią nie przywiązują żadnego znaczenia do zawartości zwalnianego bloku i takie postępowanie w niczym im nie zaszkodzi, niektóre jednak kolekcjonują zwolnione bloki (np. grupują je w listę łączoną) umieszczając w ich obszarze informacje związane z owym kolekcjonowaniem (najczęściej jest to rozmiar bloku i wskaźnik do następnego bloku na liście). Beztroskie wpisywanie zawartości do zwolnionego bloku może więc spowodować zamazanie owych informacji i w efekcie załamanie całej gospodarki pamięcią, ergo — powyższą funkcję należy potraktować jedynie w kategoriach przykładu, jak postępować nie należy. Błędy wynikające z nieuwzględnienia faktu, iż funkcja realloc może zmieniać adres przedmiotowego obszaru, są szczególnie dokuczliwe z jeszcze jednego powodu — mają szansę ujawnić się tylko wówczas, gdy zmiana taka faktycznie nastąpi. Celowo napisałem „mają szansę”, sam bowiem przypominam sobie długie miesiące spędzone na poszukiwaniu błędu spowodowanego (jak się później okazało) taką właśnie przyczyną — oglądając nie wzbudzający podejrzeń obszar, nie zdawałem sobie sprawy, iż oglądam właśnie jego poprzednią, zwolnioną już instancję. Gdyby każde wywołanie funkcji realloc powodowało zmianę adresu bloku, błąd ten mógłby zostać wykryty nie w ciągu kilku miesięcy, lecz być może w ciągu kilku godzin! Zmodyfikujmy więc funkcję fResizeMemory w taki sposób, by (w trybie testowania programu) realokowany blok był zawsze przesuwany: flag fResizeMemory(void **ppv, size_t sizeNew) { byte **ppb = (byte **)ppv; byte *pbNew; #ifdef DEBUG size_t sizeOld; #endif ASSERT(ppb != NULL && sizeNew != 0); #ifdef DEBUG { sizeOld = sizeofBlock(*ppb); /*
64
Jeżeli blok jest zmniejszany, przed zwolnieniem
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 65
* wypełnij go wzorcem. Jeżeli blok jest rozszerzany, * wymuś ponowną alokację (zamiast rozszerzania w miejscu) * Jeżeli obydwa rozmiary (stary i nowy) są takie same, * nic nie rób. */ if (sizeNew < sizeOld) memset((*ppb)+sizeNew, bGarbage, sizeOld-sizeNew); else if (sizeNew > sizeOld) { byte *pbForceNew; if (fNewMemory(&pbForceNew, sizeNew)) { memcpy(pbForceNew, *ppb, sizeOld); FreeMemory(*ppb); *ppb = pbForceNew; } } } #endif pbNew = (byte *)realloc(*ppb, sizeNew); . . }
Upiekliśmy więc dwie pieczenie na jednym ogniu — każdorazowa zmiana rozmiaru bloku połączona jest z jego faktyczną realokacją (zmianą adresu), ponadto poprzednia instancja bloku wypełniana jest przed zwolnieniem charakterystycznym wzorcem. Pewne zdziwienie budzić może fakt, iż przy powiększaniu bloku dwukrotnie próbuje się przeprowadzić realokację: najpierw w wytłuszczonym fragmencie kodu, później w końcowym wywołaniu funkcji realloc; to ostatnie nie powoduje żadnych dodatkowych efektów, bowiem żądany rozmiar bloku jest taki sam jak istniejący. Czyż nie prościej byłoby zakończyć całą sprawę wcześniej, po faktycznym wykonaniu realokacji? if (fNewMemory(&pbForceNew, sizeNew)) { memcpy(pbForceNew, *ppb, sizeOld); FreeMemory(*ppb); *ppb = pbForceNew; return(TRUE); }
Rzeczywiście, można by tak postąpić; stanowiłoby to jednak pogwałcenie reguły mówiącej, iż dodatkowy, testowy kod powinien wykonywać się jako uzupełnienie kodu zasadniczego, nie zaś zamiast niego — nawet wówczas, gdy pewne czynności wykonywane byłyby przez to w sposób redundantny. Gdy przedstawiałem powyższą koncepcję znajomym programistom, twierdzili oni, iż popadam z jednej skrajności w drugą — konsekwentne, każdorazowe „przesuwanie” bloków w pamięci jest wcale nie lepsze niż jego zupełny brak. To cenne spostrzeżenie wymaga jednak kilku słów komentarza. Otóż permanentne czynienie czegokolwiek jest tak samo złe, jak konsekwentne tego unikanie — pod
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
65
NIEZAWODNOŚĆ OPROGRAMOWANIA
66
warunkiem jednakże, iż ma miejsce w obydwu wersjach kodu: zasadniczej (handlowej) i testowej. Tymczasem wersja handlowa charakteryzuje się nikłą dynamiką w zakresie przemieszczania bloków — co stanowi zupełne przeciwieństwo żywiołowego charakteru wersji testowej w tym względzie. Jeżeli bowiem coś zdarza się rzadko w wersji zasadniczej, powinno być (dla równowagi) wymuszane w wersji testowej.
Jeżeli w programie coś zdarza się bardzo rzadko, spraw, by zdarzało się regularnie.
KONTROLUJ WYKORZYSTANIE PAMIĘCI Najpoważniejszym problemem związanym z gospodarką pamięcią jest — z punktu widzenia testowania programu — zupełny brak informacji na temat rozmiaru przydzielonego bloku. Rozmiar ten znany jest oczywiście w momencie dokonywania przydziału (tj. wywoływania funkcji malloc), lecz na dalszym etapie wykonania mamy już do czynienia tylko z samym wskaźnikiem. Nie sposób przecenić użyteczności funkcji sizeofBlock, bez której nie mogłyby powstać funkcje FreeMemory i fResizeMemory, i której implementację obiecałem przedstawić jeszcze w tym rozdziale. Nietrudno sobie wyobrazić, o ile ułatwione byłoby testowanie programu, gdyby dostępna była informacja o wszystkich przydzielonych obszarach — ich adresach i rozmiarach. Informacji takiej nie udostępnia rzecz jasna sam system zarządzania pamięcią, skoro jednak dokonaliśmy przechwycenia funkcji malloc, free i realloc, możemy wzbogacić treść funkcji-otoczek w implementację takich właśnie mechanizmów — będziemy mianowicie rejestrować każdy przydział, zwolnienie i realokację bloku. Skonstruowanie wspomnianej funkcji sizeofBlock będzie wówczas zadaniem banalnym. Jednym z zewnętrznych przejawów mechanizmów kontrolujących będzie funkcja fValidPointer sprawdzająca, czy istnieje blok o adresie określonym przez pierwszy parametr, i czy jego rozmiar nie jest mniejszy od wskazanego przez drugi parametr. Oto prosty przykład jej wykorzystania: void FillMemory(void *pv, byte b, size_t size) { ASSERT(fValidPointer(pv, size)); memset(pv, b, size); }
Przedstawiona asercja jest znacznie bardziej funkcjonalna niż jedynie badanie niezerowości wskaźnika w ramach funkcji memset (patrz rozdział 2.). Związane z nią wywołanie funkcji fValidPointer pociąga oczywiście za sobą pewne koszty (w zakresie czasu wykonania i zajętości pamięci), jest to jednak cena, jaką płaci się za dodatkowe bezpieczeństwo; można zresztą ograniczyć obowiązywanie przedstawionej definicji funkcji tylko do wersji testowej, a w wersji handlowej utożsamić ją z funkcją memset, na przykład tak:
66
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 67
#define FillMemory(pb,b,size)
memset((pb),(b),(size))
Osobiście jednak odradzałbym tego rodzaju praktyki. Mechanizmy administracyjne, których implementację opisujemy, dają znacznie większe możliwości testowe niż wypełnianie obszarów pamięci charakterystycznym wzorcem; stwierdzenie bowiem, czy dany wskaźnik identyfikuje blok pamięci o dostatecznie dużym rozmiarze jest bardzo łatwe — wystarczy wywołać funkcję fValidPointer. Przejdźmy do szczegółów — zarządzanie ową „administracyjną” informacją odbywać się będzie w trojaki sposób. Po pierwsze, alokacji nowego bloku musi towarzyszyć alokacja odpowiedniego bloku „administracyjnego”; po drugie, wraz ze zwolnieniem danego bloku należy również zwolnić odpowiadający mu blok administracyjny; po trzecie wreszcie — przy realokacji bloku należy uaktualnić informację zapisaną w stosownym bloku administracyjnym. Czynności te wykonywane będą przez trzy następujące funkcje: /* utwórz blok administracyjny dla bloku danych */ /* o adresie pbNew i rozmiarze sizeNew */ flag fCreateBlockInfo(byte *pbNew, size_t sizeNew); /* zwolnij blok administracyjny związany z blokiem danych */ /* o adresie pb */ void FreeBlockInfo(byte *pb); /* aktualizuj informację związaną z blokiem danych o dotychczasowym */ /* adresie pbOld – nowym adresem bloku jest pbNew, nowym jego */ /* rozmiarem jest sizeNew */ void UpdateBlockInfo(byte *pbOld, byte *pbNew, size_t sizeNew);
Dokładna implementacja tych funkcji nie jest w tej chwili istotna; jeżeli jesteś nią zainteresowany, jeden ze sposobów jej realizacji znajdziesz w dodatku B; zasadniczą sprawą jest natomiast ich „wplecenie” w treść naszych trzech funkcjiotoczek. Rozpocznijmy od zwalniania bloku: void FreeMemory(void *pv) { #ifdef DEBUG { memset(pv, bGarbage, sizeofBlock(pv)); FreeBlockInfo(pv); } #endif free(pv); }
W ramach funkcji fResizeMemory stosowny blok administracyjny powinien być uaktualniony tylko wtedy, gdy funkcja realloc pomyślnie dokona zmiany rozmiaru bloku:
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
67
NIEZAWODNOŚĆ OPROGRAMOWANIA
68
flag fResizeMemory(void **ppv, size_t sizeNew) { . . .
pbNew = (byte *)realloc(*ppb, sizeNew); if (pbNew != NULL) { #ifdef DEBUG { UpdateBlockInfo(*ppb, pbNew, sizeNew); /* jeżeli rozszerzono blok, wypełnij wzorcem */ /* jego dodaną końcówkę */ if (sizeNew > sizeOld) memset(pbNew+sizeOld, bGarbage, sizeNew-sizeOld); } #endif *ppb = pbNew; } return (pbNew != NULL); }
Modyfikacja funkcji fNewMemory będzie nieco bardziej skomplikowana. Podczas jej realizacji system musi przydzielić dwa bloki pamięci — żądany blok danych i związany z nim blok administracyjny; jeżeli nie powiedzie się przydział bloku administracyjnego, należy zwolnić przydzielony uprzednio blok zasadniczy i zasygnalizować niepowodzenie, zwracając (jako wynik) wartość FALSE: flag fNewMemory(void **ppv, size_t size) { byte **ppb = (byte *)ppv; ASSERT (ppv != NULL && size != 0); *ppb = (byte *)malloc(size); #ifdef DEBUG { if (*ppb != NULL) { memset (*ppb, bGarbage, size); /* jeżeli nie uda się przydzielić bloku administracyjnego */ /* należy zwolnić przydzielony blok zasadniczy */ /* i zasygnalizować niepowodzenie */ if (!fCreateBlockInfo(*ppb, size) { free(*ppb); *ppb = NULL; } } } #endif return (*ppb != NULL); }
To wszystko — w dodatku B znaleźć możesz ponadto implementacje funkcji sizeofBlock i fValidPointer.
68
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 69
Utrzymywanie dodatkowych informacji administracyjnych w dużym stopniu ułatwia testowanie.
SPÓJRZ NA TO, CZEGO NIE WIDAĆ Jednym z powszechnych błędów — zgodnie z przytoczoną na początku rozdziału listą — jest odwołanie do nieistniejącego bloku. Opisane przed chwilą mechanizmy administracyjne pozwalają z łatwością wykryć i zasygnalizować taką sytuację. Załóżmy teraz, iż w strukturze drzewiastej położenie jednego z węzłów uległo zmianie, lecz nie zostały uaktualnione związane z nim wskaźniki. Czy jest to błąd? Zdecydowanie tak. Czy ten błąd jest łatwy do wykrycia? Tak, lecz tylko pod warunkiem, iż błędne wskaźniki w ogóle zostaną użyte; jeżeli bowiem brak będzie odwołań do rzeczonego węzła, dotychczas przedstawione mechanizmy są wobec tego błędu bezsilne. Sytuacją odwrotną do użycia nieprawidłowych wskaźników jest zagubienie adresu istniejącego bloku. W sekwencji: *ppb = (byte *)malloc(size); *ppb = (byte *)malloc(size + 4);
blok alokowany jako pierwszy zostaje stracony, nie istnieje bowiem identyfikujący go wskaźnik. Co prawda błędne konstrukcje występują zazwyczaj w postaci mniej oczywistej niż powyższa — gdyby pomyłkowo „wykomentować” jedną z linijek w funkcji fResizeMemory if (fNewMemory(&pbForceNew, sizeNew)) { memcpy(pbForceNew, *ppb, sizeOld); /* FreeMemory(*ppb); */ *ppb = pbForceNew; return(TRUE); }
poprzednia instancja bloku nie ulegnie zwolnieniu i zajęta przez nią pamięć pozostanie stracona aż do zakończenia wykonania programu. Takie zagubione bloki są przypadkiem jeszcze gorszym niż nieprawidłowe wskaźniki, bowiem z jednej strony brak jest do nich odwołań (z konieczności), z drugiej natomiast ich skutki — zwane popularnie „wyciekami pamięci” (ang. memory leaks) — dają o sobie znać dopiero po dłuższym czasie, gdy rozmiar „straconej” pamięci osiąga odczuwalne rozmiary. Wykrywanie zagubionych bloków pamięci staje się jednak łatwe, jeżeli dysponujemy opisanymi w poprzednim podrozdziale danymi administracyjnymi. Z każdym blokiem administracyjnym należy mianowicie związać flagę odwołania, która będzie ustawiana każdorazowo, gdy specjalna procedura administracyjna zasygnalizuje odwołanie do powiązanego bloku danych. Aby wykryć zagubione bloki, należy najpierw wyzerować flagi odwołania we wszystkich blokach administracyjnych, następnie zasymulować odwołania do wszelkich możliwych
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
69
70
NIEZAWODNOŚĆ OPROGRAMOWANIA
bloków, których wskaźnikami dysponujemy w programie; te z bloków administracyjnych, w których flaga odwołania nie będzie ustawiona, reprezentują właśnie zagubione bloki danych. Ta metoda skuteczna jest również w odniesieniu do nieprawidłowych wskaźników — procedura administracyjna, zamierzająca ustawić flagę odwołania w stosownym bloku administracyjnym, po prostu nie znajdzie go i zasygnalizuje błąd. Aby zrealizować opisaną „administracyjną kontrolę odwołań”, musimy zdefiniować trzy poniżej wymienione funkcje. Ich dokładna implementacja nie jest istotna (jeden ze sposobów znajdziesz w dodatku B), zaś spełniane przez nie czynności wyjaśnione są w komentarzach: /* zeruje flagę odwołania dla wszystkich bloków */ void ClearMemoryRefs(void); /* ustawia flagę odwołania dla bloku danych */ /* wskazywanego przez pv */ void NoteMemoryRef(void *pv); /* poszukuje bloków, dla których nie są ustawione */ /* flagi odwołania */ void CheckMemoryRefs(void);
Zastosowanie opisanej metody wyjaśnimy na przykładzie drzewa binarnego. Jego każdy węzeł, oprócz innych informacji, zawiera wskaźniki do lewego i prawego poddrzewa oraz wskaźnik do opisującej go nazwy w postaci łańcucha ASCIIZ: typedef struct SYMBOL { struct SYMBOL *psymRight; struct SYMBOL *psymLeft; char *strName; . . . } symbol;
Węzeł taki reprezentować może na przykład jeden z symboli języka programowania w wewnętrznych strukturach kompilatora. Dla każdego z węzłów drzewa zasymulować należy odwołanie do trzech wymienionych wskaźników psymRight, psymLeft i strName. Spośród trzech najbardziej popularnych metod „przechodzenia” drzewa — INORDER, POSTORDER i PREORDER — ostatnia z wymienionych wydaje się najodpowiedniejsza, bowiem zgodnie z nią „odwiedzenie” danego węzła następuje przed odwiedzeniem jego poddrzew, co w przypadku istnienia wadliwych węzłów wykrywa ten z nich, który znajduje się najbliżej korzenia. void NoteSymbolRefs(symbol *psym) { if (psym != NULL) { /* sprawdź węzeł, zanim zejdziesz do jego poddrzew */
70
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 71
NoteMemoryRef(psym); NoteMemoryRef(psym->strName); /* sprawdź poddrzewa NoteSymbolRefs(psym->psymRigth); NoteSymbolRefs(psym->psymLeft);
*/
} }
Aby sprawdzić całe drzewo, należy wpierw wyzerować wszystkie flagi odwołań, a następnie wywołać tę rekurencyjną funkcję dla korzenia drzewa: void CheckBinaryTreeIntegrity(symbol *root); { /* wyzeruj flagi odwołania */ ClearMemoryRefs(); /* przejdź całe drzewo metodą PREORDER */ NoteSymbolRefs(root); /* zweryfikuj brak zagubionych bloków */ CheckMemoryRefs() }
Kompletna weryfikacja wszystkich danych rzeczywistego programu będzie mieć postać o wiele bardziej skomplikowaną — oto jeden z przykładów, pochodzący z asemblera dla procesora 68000: void CheckMemoryIntegrity(void) { ClearMemoryRefs(); NoteSymbolRefs(psymRoot); NoteMacroRefs(); . . .
NoteCacherefs(); Note VariableRefs(); CheckMemoryRefs() }
Pozostaje jeszcze kwestia — w którym momencie dokonywać takiej weryfikacji? Wszak sytuacja na forum gospodarki pamięcią zmieniać się może wiele razy w ciągu ułamka sekundy. Odpowiedź na to pytanie zależna jest od konkretnego programu, na pewno jednak absolutnym minimum jest przeprowadzenie testu po utworzeniu lub zwolnieniu każdego skomplikowanego obiektu; dobrą okazję stanowią ponadto wszelkie pętle oczekiwania, np. na naciśnięcie klawisza przez użytkownika.
Dokonaj ewidencji danych swojego programu.
xxxxxxxx
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
71
NIEZAWODNOŚĆ OPROGRAMOWANIA
72
Prezentowane przykłady charakteryzują się wręcz przytłaczającym rozmiarem kodu testowego — najpierw asercje, potem funkcje-otoczki, następnie ewidencja bloków pamięci i wreszcie kontrola odwołań — nie od rzeczy więc będzie zadać sobie pytanie, czy po takich zabiegach wciąż mamy do czynienia z tym samym programem? Otóż co prawda tekst programu został znacznie rozbudowany, lecz jego funkcje pozostały niezmienione: to fakt, iż testowa wersja funkcji fResizeMemory żongluje blokami pamięci z większą dynamiką niż wersja zasadnicza, jednak ostateczny efekt jej wywołania pozostaje taki sam — rozszerzenie bloku z zachowaniem dotychczasowej jego zawartości, być może połączone ze zmianą adresu. Podobnie funkcja fNewMemory alokuje więcej pamięci w wersji testowej (ze względu na dodatkowy blok administracyjny), lecz i tak użytkownik otrzymuje blok danych o rozmiarze nie mniejszym od żądanego. Różnica w ilości alokowanej pamięci mogłaby być istotna, gdyby użytkownik żądał np. przydzielenia dokładnie 21 bajtów pamięci — takiej funkcji nie potrafi jednak wykonać niemal żaden z istniejących menedżerów pamięci, w tym i funkcja malloc. Dodatkowe alokacje pamięci na potrzeby testowania programu mogą jednak stanowić problem w sytuacji, gdy pamięci zaczyna brakować — na szczęście są to jednak przypadki skrajne, poza tym nie należy zapominać, iż celem całego procesu testowania jest jak najszybsze znalezienie tkwiących w programie błędów, nie zaś jak najefektywniejsze wykorzystanie pamięci.
WYBIERAJ ROZSĄDNIE Dr Robert Cialdini w jednej ze swoich książek1 zwraca uwagę na pewien psychologiczny aspekt podejmowania decyzji. Zaleca on mianowicie sprzedawcom salonów odzieżowych, by swoim klientom prezentowali najpierw kostium za 500 dolarów, a dopiero potem sweter za 80; sweter wyda się wówczas stosunkowo tani i klient na pewno da się na niego namówić. Gdyby rozpocząć prezentację od swetra, klient zapytałby po prostu „dlaczego tak drogo” i poprzestał na zakupie za (powiedzmy) 35 dolarów. Zdaniem Cialdiniego sprawa ta jest oczywista dla każdego, kto zechce pomyśleć o niej chociaż przez 30 sekund, ale — pyta — ilu ludzi myśli dzisiaj w ten właśnie sposób? Nieważne, czy Dr Cialdini ma rację2, w każdym razie kolejność prezentacji towarów jest przykładem pewnego wyboru, a programiści tworzący złożone aplikacje zmuszeni są do dokonywania rozmaitych wyborów nieustannie. Jedną z takich — wydawałoby się oczywistych — decyzji był wybór wzorca wypełniającego blok pamięci, którego zawartości nie należy przypisywać żadnego znaczenia; wykazałem wówczas, dlaczego wartość 0 wypełniającego bajtu jest zdecydowanie złym wyborem i dlaczego 0A3 będzie (przypuszczalnie) lepsza w tej roli od pozostałych 254 konkurentek. Z kolei przy weryfikowaniu integralności 1 2
72
R.Cialdini „How and Why People Agree to Things” (Morrow, 1984). Tłumacz uważa stanowisko Dr Cialdiniego za wręcz obraźliwe — nie jesteśmy wszak głupkami, którymi można dowolnie manipulować.
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 73
węzłów drzewa binarnego stanęliśmy przed wyborem algorytmu jego przechodzenia; wychodząc z założenia, iż „defekt” w samym węźle jest pilniejszy do zasygnalizowania niż defekt w jego poddrzewach, wybraliśmy metodę PREORDER, zgodnie z którą dopiero po gruntownym przebadaniu węzła przystępuje się do badania jego poddrzew. Jeżeli więc przyjdzie Ci podjąć decyzję związaną ze szczegółami implementacji jakiegoś algorytmu, pomyśl przez chwilę nad takim wyborem, który ułatwi walkę z ewentualnymi błędami, nie zaś przysporzy dodatkowych kłopotów.
Starannie zaplanuj swoje testy; niczego nie zostawiaj przypadkowi.
SZYBKI CZY BEZBŁĘDNY Jest zrozumiałe, iż testowe wersje programów mogą być znacznie wolniejsze i bardziej pamięciożerne od wersji zasadniczych. Nic w tym dziwnego, wszak użytkownicy kupują oprogramowanie w nadziei zaspokojenia swych potrzeb, zaś celem wersji testowej jest „wyłapanie” tkwiących w programie błędów i to bez względu na to, jak dalece pozostaje on w tyle za wersją zasadniczą pod względem szybkości. Należy jednak zachować ostrożność, gdy kieruje się testową wersję do beta-testerów — swego czasu zaprzestali oni pracy nad nową wersją jednego z produktów Microsoftu twierdząc, iż jest ona wspaniała, lecz wolna niczym leniwiec trójpalczasty; w takich wypadkach należy rozważyć ograniczenie lub usprawnienie kodu testowego, bądź opatrzyć interfejs użytkownika stosowną klauzulą wyjaśniającą. Jeżeli jednak szybkość programu i jego wymagania sprzętowe pozostają — mimo dodania kodu testowego — w granicach akceptowalnych przez użytkowników końcowych, można uczynić wspomniany kod częścią wersji zasadniczej i w takiej postaci udostępnić ją użytkownikom. Jeżeli na skutek dodatkowych testów wykryte zostaną ewentualne błędy, wygrani będą wszyscy — użytkownicy i producent; gdy obawiasz się natomiast zniechęcenia użytkowników wersją niezbyt szybką nie zapominaj, iż wersja zawierająca błędy jest czymś znacznie gorszym. Nie ma w tym nic nadzwyczajnego — popularny Excel wyposażony jest w rozmaite testy podsystemu zarządzania pamięcią, i to testy bardziej wnikliwe niż prezentowane w tym rozdziale. Nie oznacza to, iż użytkownicy Excela nigdy nie doświadczyli błędów z jego strony, lecz błędy te prawie nigdy nie zdarzyły się w tych częściach kodu, które wspomagane były przez dodatkowe testy.
TERAZ LUB PÓŹNIEJ Błędy czające się w programie i tak kiedyś dadzą znać o sobie — teraz lub później. Wykrycie błędu przez zespół programistów i testerów jest niewątpliwie sytuacją jakościowo różną od wystąpienia błędu w czasie normalnej eksploatacji programu. Kwestię „teraz lub później” ograniczyć można również do samego procesu
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
73
74
NIEZAWODNOŚĆ OPROGRAMOWANIA
testowania: błąd może być wykryty zawczasu — dzięki umiejętnie skonstruowanym testom — lub później, gdy testerom uda się natrafić na sprzyjające mu warunki. Przypomnę — gdyby stosowne testy znalazły się w tworzonym przeze mnie asemblerze, znalezienie błędu spowodowanego przesuwaniem bloku zajęłoby kilka godzin, nie zaś (niemal) cały rok; nie byłyby przy tym potrzebne żadne szczególne kwalifikacje programistyczne ani też sprzyjające okoliczności; po prostu błąd zostałby wykryty automatycznie. Oto, na czym polega tworzenie bezbłędnych programów.
PODSUMOWANIE Przyjrzyj się wykorzystywanym przez siebie podsystemom i zastanów się, w jaki sposób programiści mogliby ich użyć niezgodnie z przeznaczeniem. Dodając odpowiednie asercje i testy weryfikacyjne przyczynisz się do łatwego wykrycia błędów, które inaczej długo pozostałyby niewykryte. Powtarzalność błędów niewątpliwie sprzyja ich wykrywaniu; jeżeli więc w zasadniczej wersji programu coś, co może stać się przyczyną błędu, ma nikłą szansę zaistnienia, spraw, by w wersji testowej zdarzało się regularnie. Jeżeli jakiś obszar ma przypadkową zawartość, wypełnij go charakterystycznym wzorcem, by program nie mógł przypisywać znaczenia nic nie znaczącym śmieciom. Zaplanuj swoje testy w taki sposób, by ich przydatność nie była uzależniona od jakichś szczególnych kwalifikacji programistycznych; komunikaty towarzyszące wykryciu błędu powinny być użyteczne nawet dla początkujących programistów. Jeżeli to możliwe, zrealizuj swoje testy w ramach samych podsystemów, nie zaś na poziomie odwołań do nich; wykorzystaj do tego celu funkcje-otoczki, obudowujące wywołania systemowe niezbędnymi testami. Zastanów się dobrze, nim usuniesz z programu jakiś test tylko dlatego, iż spowalnia on wykonanie lub zwiększa zużycie pamięci; bądź świadom tego, iż efekty te nie wystąpią w wersji zasadniczej. Jeżeli negatywne skutki testu nie są do zaakceptowania nawet na etapie testowania, należy spróbować uczynić ów test efektywniejszym lub mniej pamięciożernym.
POMYŚL O TYM 1. Długi ciąg wartości 0A3 może świadczyć o tym, iż masz właśnie do czynienia z blokiem niezainicjowanym albo zwolnionym. W jaki sposób zmieniłbyś kod testujący, aby rozróżnić te dwie kategorie? 2. Częstym błędem programistycznym jest przekraczanie granic przydzielonego bloku pamięci przy wypełnianiu go danymi. W jaki sposób rozszerzyłbyś kod testujący, by wykryć błędy tego rodzaju?
74
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 75
3. Oto przykład dość subtelnego błędu, który w razie zaistnienia nie zostanie wykryty przez funkcję CheckMemoryIntegrity. Załóżmy, iż zostaje zwolniony blok pamięci (np. węzeł drzewa), pozostawiając „wiszące” wskaźniki, które programista zapomniał wyzerować; chwilę później funkcja fNewMemory przydziela dokładnie ten sam blok (tj. pod tym samym adresem) — rzeczone wskaźniki stają się znowu poprawne, chociaż tak naprawdę wskazują one już zupełnie inny blok, z którym logicznie nie mają żadnego związku; w poprawnym programie wskaźniki te powinny być wyzerowane. Ponieważ w kategoriach funkcji fValidPointer „poprawność” owych wskaźników nie budzi wątpliwości, programista odnosi mylne wrażenie, iż „coś” zniszczyło mu przydzieloną pamięć, wypełniając ją znajomym wzorcem. Tak naprawdę powtórne przydzielenie dopiero co zwolnionego bloku zdarza się znacznie częściej, niż można by przypuszczać — jak więc rozszerzyłbyś swój system testowy, aby tego rodzaju błędy stały się wykrywalne? 4. Za pomocą funkcji NoteMemoryRef można zweryfikować poprawność każdego wskaźnika (identyfikującego przydzielony blok), jak jednak zweryfikować rozmiar bloku? Załóżmy na przykład, iż pewien wskaźnik wskazuje na 18-znakowy łańcuch, lecz zgodnie z informacją zapisaną w bloku administracyjnym, przydzielony blok ma długość 15 bajtów i vice versa — gdy program korzysta z założenia, iż przydzielony blok jest 15-bajtowy, informacja w bloku administracyjnym wykazuje przydział 18 bajtów. Jak zmodyfikowałbyś testy integralności, by zdolne były weryfikować również długość bloku? 5. Zgodnie z implementacją zamieszczoną w dodatku B funkcja NoteMemory Ref bezbłędnie ewidencjonuje fakt odwołania się do bloku, nie umożliwia jednak wykrycia pięciokrotnego odwołania się do bloku w sytuacji, gdy zgodnie z logiką programu odwołanie to powinno być co najwyżej jednokrotne. Na przykład, wiązana lista dwukierunkowa utrzymuje dwa wskaźniki dla każdego węzła: jeden w węźle poprzedzającym, drugi w węźle następnym; większość iteracji po elementach listy powinno skutkować jednokrotnymi odwołaniami do każdego z elementów, odwołania wielokrotne mogą być oznaką ewentualnego błędu. Jak zmodyfikowałbyś system kontroli integralności, by sygnalizował wielokrotne odwołania do wybranych bloków, generalnie nie zabraniając jednak wielokrotnych odwołań? 6. Przedstawione w tym rozdziale techniki i środki służące testowaniu przeznaczone są raczej na użytek programistów. Ponieważ jednak programiści wykazują tendencję do spychania części odpowiedzialności na testerów — jakie środki programistyczne mogłyby Twoim zdaniem ułatwić testerom wykrywanie niektórych błędów, na przykład przekroczenie granic przydzielonej pamięci? PROJEKT: Przyjrzyj się poszczególnym podsystemom w którejkolwiek stworzonej przez Ciebie aplikacji i zastanów się, jakie rodzaje dodatkowej kontroli zaimplementowałbyś w jej testowej wersji, by łatwiej wykryć ewentualne błędy, popełniane najczęściej w związku ze wspomnianymi podsystemami?
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
75
76
76
NIEZAWODNOŚĆ OPROGRAMOWANIA
E:\TMP\Doc\ade68f12-55ec-4613-852a-d25b89bcd489.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 79
4. JAK WYKONUJE SIĘ TWÓ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 niektó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ę zupeł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 monitorowanie których zorientowane są urządzenia alarmowe. Na identycznej zasadzie, najbardziej nawet wymyślne asercje, czy jeszcze bardziej zaawansowane fragmenty kodu testujące występowanie spodziewanych warunkó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 powodują dodatkową komplikację i tak przeważnie już złożonego kodu.
E:\TMP\Doc\4f72f901-4795-466c-8d25-a1d394e8d069.Doc
79
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 programie 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ż wbudowana w znakomitą większość współczesnych środowisk projektowych.
UWIARYGODNIJ 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 zagadką, 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 niewykrycia widocznego jak na dłoni błędu, postanowiliśmy ustawić punkt przerwania w krytycznym miejscu kodu, a po zatrzymaniu — które naszym zdaniem musiało nastąpić — kontynuować wykonywanie w sposób krokowy. Załadowaliśmy nasz projekt, kliknęliśmy przycisk „Run” i... ku naszemu zdumieniu 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 prostu 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” wykonywanie 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 przeznaczony na krokowe testowanie kodu jest tylko drobnym ułamkiem czasu przeznaczonego na stworzenie tegoż kodu; po drugie — uruchomienie programu w trybie pracy krokowej nie jest w niczym trudniejsze od „normalnego” uruchomienia, bowiem różnica tkwi zazwyczaj jedynie w... naciśniętych klawiszach; po trzecie (i najważniejsze) — czas spędzony nad testowaniem programu stanowi swego rodzaju inwestycję — w przeciwieństwie do czasu spędzonego na walkę z trudnymi do wykrycia błędami, stanowiącego przykrą konieczność. W jednym z poprzednich rozdziałów, pisząc o testowaniu metodą „czarnej skrzynki”, wyjaśniałem niebagatelną rolę programowania defensywnego w walce z błędami — możliwość obserwacji zachowania się własnego kodu dodatkowo zwiększa przewagę programisty nad testerem obserwującym jedynie
80
E:\TMP\Doc\4f72f901-4795-466c-8d25-a1d394e8d069.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 81
przetwarzanie danych przez „czarną skrzynkę”. Śledzenie stworzonego (lub zmienionego) przez programistę kodu powinno zatem 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.
PRZETESTUJ WSZYSTKIE ROZGAŁĘZIENIA Praca krokowa, jak wszelkie inne narzędzia, może wykazywać zróżnicowaną skuteczność w zależności od tego, jak umiejętnie jest stosowana. W szczególności — testowanie 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ć testowanie przy np. różnych wartościach warunków instrukcji if, czy selektorów instrukcji switch. Notabene pierwszymi ofiarami niedostatecznego testowania padają te fragmenty kodu, które wykonywane są bardzo rzadko lub wcale — do tej ostatniej kategorii 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 spowodowanej 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 wymaga trochę czasu, lecz jednocześnie fakt ten staje się nieoczekiwanie przyczyną interesującego sprzężenia zwrotnego — mianowicie programiści przywykli do konsekwentnego ś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
E:\TMP\Doc\4f72f901-4795-466c-8d25-a1d394e8d069.Doc
81
NIEZAWODNOŚĆ OPROGRAMOWANIA
82
fragmenty, które istotnie wymagają testowania; w przeciwnym razie każda zmiana kodu może pozbawić istniejący kod wiarygodności uzyskanej drogą czasochłonnego testowania — niczym odrobina żółci zdolnej zepsuć beczkę miodu. 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 przydzielony 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;
82
E:\TMP\Doc\4f72f901-4795-466c-8d25-a1d394e8d069.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 83
if (size >= sizeThreshold) { unsigned long l; l = (b 0 *str = '\0'; ReverseStr(strStart); }
Na pierwszy rzut oka nic się tu nie zmieniło — w dalszym ciągu liczba jest „negowana” jak poprzednio, po czym dalsza konwersja powierzona zostaje funkcji przetwarzającej liczbę nieujemną. Powyższy kod działa jednak bez zarzutu, znowu trochę z powodu osobliwości arytmetyki uzupełnień do dwóch. Jeżeli (zakładamy
116
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
27 ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 117
arytmetykę 16-bitową) zmienna i ma na wejściu do funkcji IntToStr wartość – 32768 (szesnastkowo 08000), instrukcja: i := -i
nie zmienia co prawda tej wartości, lecz do funkcji UnsToStr przekazywana jest wartość (uwaga) 32768, a to za sprawą rzutowania na typ unsigned. To, iż powyższy kod jest poprawny, nie zmienia faktu, iż wciąż jest on daleki od elegancji. Po pierwsze, wykorzystuje cechę charakterystyczną konkretnej implementacji. Po drugie — zakłada on, iż –32768 jest poprawną wartością typu int, co nie jest prawdą, przynajmniej w odniesieniu do przenośnych typów danych. Najlepszym rozwiązaniem jest więc pozostanie przy pierwotnej wersji funkcji IntToStr, uzupełnionej o eliminowanie wspomnianej wartości. void IntToStr(int i, char *str) { /* jeżeli "i" wykracza poza zakres typu int, * użyj funkcji LongToStr */ ASSERT( i >= -32767 && i pwndSibling == NULL); if pwndParent == NULL) { /* dodaj okno do listy okien najwyższego poziomu */ pwndNewBorn->pwndSibling = pwndRootChildren; pwndRootChildren = pwndNewBorn; } else { /* jeśli Parent jest pierwszym oknem potomnym, * rozpocznij nowy łańcuch rodzeństwa, w przeciwnym razie * dodaj okno potomne na koniec istniejącego łańcucha rodzeństwa */
118
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
27 ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 119
if (pwndParent->pwndChild == NULL) pwndParent->pwndChild = pwndNewBorn; else { window *pwnd = pwndParent->pwndChild; while (pwnd->pwndSibling != NULL) pwnd = pwnd->pwndSibling; pwnd->pwndSibling = pwndNewBorn; } } }
Niezależnie więc od faktu, iż struktura okien reprezentowana jest w postaci drzewa binarnego, powyższy kod zdaje się być charakterystyczny raczej dla przetwarzania list jednokierunkowych. Ponieważ okno najwyższego poziomu — to reprezentujące cały ekran — nie posiada rodzeństwa, nie ma tytułu, nie może być przesuwane, ukrywane ani usuwane, w reprezentującym go węźle jedynym polem mającym jakiekolwiek znaczenie byłby tylko wskaźnik okien potomnych — paska menu i okien aplikacji. To widocznie skłoniło kogoś do swoiście pojętej oszczędności: zamiast przyporządkować wspomnianemu oknu korzeń całej struktury drzewiastej, zrezygnowano w ogóle z jego reprezentowania, przez co struktura reprezentująca system okien stała się jednokierunkową listą drzew binarnych najwyższego poziomu, wskazywaną przez zmienną pwndRootChildren. Ceną płaconą za tę w sumie drobną oszczędność stała się znaczna komplikacja kodu: w miejsce jednego drzewa binarnego mamy teraz do czynienia z dwiema różnymi strukturami: listą łączoną drzew najwyższego poziomu oraz z samymi drzewami stanowiącymi elementy tej listy. W dodatku, jako że we wspomnianej strukturze brak jest węzła reprezentującego okno najwyższego poziomu (to reprezentujące cały ekran), w funkcjach otrzymujących wskaźnik do okna jako parametr jest ono reprezentowane przez wskaźnik pusty NULL. Zostały przez to pogwałcone co najmniej trzy reguły niezawodnego programowania: pierwsza, wymagająca jednoznaczności parametrów i zakazująca przekazywania „specjalnych” wartości w rodzaju NULL, druga, ostrzegająca przed implementowaniem projektu w przybliżeniu i trzecia — nowa w naszych rozważaniach — zachęcająca do skumulowania określonej czynności w jednym miejscu kodu. Jeżeli rozpatrujemy czynność dodania nowego okna do struktury, powinna być ona wykonana przez ściśle określony fragment kodu, tymczasem funkcja AddChild podzielona jest w istocie na trzy fragmenty. Zjawiska takie sprzyjają wręcz popełnianiu błędów i należy je eliminować — czyli starać się zaprogramować określoną czynność w jednym „kawałku” kodu. Fakt, iż niekiedy względy efektywności skłaniają do łamania tej zasady — sam to uczyniłem w rozdziale 2., realizując funkcję memset w postaci dwóch pętli: „bajtowej” i „czterobajtowej” — jednak aby łamać zasady, trzeba mieć ku temu wyraźny powód. Aby więc zaimplementować oryginalną strukturę okien, a nie tylko jej przybliżenie, zrezygnujmy z pierwotnej optymalizacji i uczyńmy okno
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
119
NIEZAWODNOŚĆ OPROGRAMOWANIA
120
reprezentujące cały ekran korzeniem naszego drzewa binarnego. Korzeń ten wskazywany będzie przez zmienną pwndDisplay, która tym samym przejmie rolę niepotrzebnej już zmiennej pwndRootChildren. W funkcjach otrzymujących wskaźnik okna jako parametr, okno-korzeń będzie odtąd reprezentowane przez pwndDisplay, nie przez NULL, i przetwarzane będzie na równi z pozostałymi oknami: /* pwndDisplay wskazuje na okno–korzeń, alokowane * na etapie inicjalizacji programu */ window *pwndDisplay = NULL; void AddChild(window *pwndParent, window *pwndNewBorn) { /* nowe okno może mieć okna potomne, * nie może jednak mieć rodzeństwa */ ASSERT(pwndNewBorn->pwndSibling == NULL); /* jeśli Parent jest pierwszym oknem potomnym, * rozpocznij nowy łańcuch rodzeństwa, w przeciwnym razie * dodaj okno potomne na koniec istniejącego łańcucha rodzeństwa */ if (pwndParent->pwndChild == NULL) pwndParent->pwndChild = pwndNewBorn; else { window *pwnd = pwndParent->pwndChild; while (pwnd->pwndSibling != NULL) pwnd = pwnd->pwndSibling; pwnd->pwndSibling = pwndNewBorn; } }
Dzięki zmianie reprezentacji struktury uproszczeniu uległa zarówno funkcja AddChild, jak i wszystkie inne funkcje zmuszone do specjalnego traktowania okna reprezentowanego przez wskaźnik NULL. Poprawiony został także błąd polegający
na tym, iż okno reprezentujące cały ekran wstawiane było pierwotnie tak, iż przykrywało wszystkie inne okna. W oryginalnej wersji biblioteki błąd ten „naprawiono” w ten sposób, iż lista okien najwyższego poziomu przetwarzana była „od tyłu” — co spowodowało wprowadzenie nowych błędów.
Implementuj określoną czynność w jednym miejscu kodu.
120
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
27 ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 121
PRZECIEŻ TO TO SAMO Mimo iż ostatnia wersja funkcji AddChild jest zdecydowanie lepsza od swego pierwowzoru, jej treść nadal rozbita jest na dwa kawałki, stanowiące dwa aspekty instrukcji if. Sytuacja taka rodzi zawsze podejrzenie, iż obydwa fragmenty wykonują tę samą czynność, tyle że w różny sposób — mimo iż w wielu przypadkach podejrzenie to jest nieuzasadnione, a sama instrukcja if jest jak najbardziej na miejscu. Łatwiej jest bowiem zaimplementować oddzielne fragmenty projektu, uwarunkowane różnymi okolicznościami niż połączyć je w jeden spójny model. Zasadniczą czynnością wykonywaną przez funkcję AddChild jest skanowanie łańcucha rodzeństwa aż do napotkania ostatniego węzła, czyli węzła, w którym wskaźnik do następnego węzła-brata jest pusty. Dodawanie nowego węzła przebiegało odmiennie w zależności od tego, czy łańcuch był pusty (należało wówczas uaktualnić zmienną wskazującą jego początek), czy też nie (należało wówczas uaktualnić odpowiednie pole ostatniego węzła w łańcuchu). Był to więc proces „zorientowany na węzły”, mimo iż główną rolę odgrywały w nim raczej „wskaźniki do następnego węzła”: rozpoczynamy od wskaźnika do pierwszego węzła w łańcuchu, następnie przechodzimy do wskaźnika wskazującego drugi węzeł — i tak dalej, aż do napotkania pustego wskaźnika, któremu przypisujemy wskazanie do nowo dodanego węzła. Taki proces „zorientowany na wskaźniki” nie wymaga specjalnego wyróżniania pustego łańcucha, zbędna staje się więc wspomniana instrukcja if. void AddChild(window *pwndParent, window *pwndNewBorn) { window **ppwndNext; /* wskaźnik do aktualnie badanego wskaźnika */ /* nowe okno może mieć okna potomne, * nie może jednak mieć rodzeństwa */ ASSERT(pwndNewBorn->pwndSibling == NULL); /* przejdź cały łańcuch rodzeństwa, analizując kolejne wskaźniki * do "następnych" węzłów. Kolejno analizowane wskaźniki wskazywane * są przez zmienną ppwndNext, wskazującą początkowo na wskaźnik do * pierwszego węzła potomnego. Napotkanie pustego wskaźnika oznacza * koniec listy — wskaźnikowi temu przypisywane jest następnie * wskazanie do nowo dodawanego węzła */ ppwndNext = &pwndParent->pwndChild; while (*ppwndNext != NULL) ppwndNext = &(*ppwndNext)->pwndSibling; *ppwndNext = pwndNewBorn; }
Powyższy kod jest poniekąd znajomy, przypomina bowiem przeszukiwanie listy łączonej z użyciem „wartownika” nie pełniącego poza tym żadnej roli i nie reprezentującego żadnej danej. W powyższej funkcji elementem owej „listy” są
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
121
NIEZAWODNOŚĆ OPROGRAMOWANIA
122
analizowane wskaźniki, zaś ostatni, pusty wskaźnik pełni rolę „wartownika”. Fakt, iż nie jest to typowe przechodzenie listy, do którego przywykliśmy na co dzień, ale to już sprawa punktu widzenia — podobnie jak z soczewkami okularów: wypukłe są, czy wklęsłe? Zależy, z której strony się patrzy. Ostateczna wersja funkcji AddChild okazała się więc najzgrabniejsza — i jednocześnie najefektywniejsza. Zawiera ona zdecydowanie mniej kodu, a koszt wykonania pętli nie zwiększył się w zauważalny sposób, mimo większej liczby operatorów * i &. Jeśli nie wierzysz, skompiluj i porównaj.
Pozbądź się niepotrzebnych instrukcji if.
?: TO TAKŻE IF Programiści zdają się niekiedy zapominać, iż operator ?: to tylko inny zapis instrukcji if. Nie wnosi on, w porównaniu z instrukcją if, nic nowego do kodu, mimo to dla niektórych programistów stanowi jedyny sposób zapisu wszelkich instrukcji warunkowych. Oto przykład zaczerpnięty z kodu MS Excela — prezentowana funkcja wylicza następny stan wielostanowego pola wyboru: /* uCycleCheckBox – zwraca nowy stan pola wyboru * * Funkcja dotyczy dwojakiego rodzaju pól: * * dwustanowych – o stanie zmieniającym się wg kolejności * 0,1,0,1 ... * trójstanowych – o stanie zmieniającym się wg kolejności * 2,3,4,2,3 ... * * i wylicza następny stan na podstawie stanu aktualnego */ unsigned uCycleCheckBox(unsigned uCur) { return ((uCur 0). Subtelna, ale jakże znacząca różnica. Innym przykładem „ryzykownego idiomu” z kategorii „polepszanie efektywności” są operacje przesuwania bitowego, efektywniejsze od mnożenia i dzielenia przez potęgi liczby 2. Po przyjrzeniu się następującemu fragmentowi funkcji memset z rozdziału 2.: pb = (byte *)longfill((long *)pb, l ,size / 4); size = size % 4;
niejeden pomyśli zapewne „jakże nieefektywne!” i zaproponuje natychmiast wersję efektywniejszą: pb = (byte *)longfill((long *)pb, l ,size >> 2); size = size & 3;
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
127
NIEZAWODNOŚĆ OPROGRAMOWANIA
128
Tak się jednak składa, że w stosunku do wielkości typu bezznakowego (unsigned) tego typu optymalizacja wykonywana jest przez większość kompilatorów, na równi z eliminowaniem dodawania zera i mnożenia przez jedynkę. „Ręczna” optymalizacja nie wnosi tu zatem nic nowego, może poza drobnym zaciemnieniem czytelności. Z wyrażeniami typu znakowego (signed) sprawy mają się zgoła inaczej. W poniższym wyrażeniu: midpoint = (upper + lower) / 2;
Kompilator pracujący na platformie stosującej arytmetykę uzupełniania do 2 nie zastosuje wspomnianej optymalizacji, bowiem wynik bitowego przesunięcia liczby ze znakiem może różnić się od wyniku jej dzielenia przez potęgę dwójki. Jeżeli jednak mamy gwarancję, iż wyrażenie (upper + lower) zawsze będzie nieujemne, można bez ryzyka zamienić dzielenie na przesunięcie: midpoint = (upper + lower) >> 1;
Istnieje jednak znacznie bezpieczniejszy sposób optymalizacji, oparty na rzutowaniu typów: midpoint = (unsigned)(upper + lower) / 2;
Wypróbuj go w swoim kompilatorze. Intencją takiego rzutowania nie jest bynajmniej dyktowanie kompilatorowi, co ma robić, lecz raczej danie mu pewnego zezwolenia na traktowanie wyrażenia jako zawsze nieujemnego. A swoją drogą, który z tych dwóch sposobów wydaje Ci się bardziej zrozumiały? Bardziej przenośny? Wbrew pozorom „ręczne” optymalizowanie mnożenia i dzielenia za pomocą przesunięć jest operacją bardziej podatną na błędy niż można by przypuszczać w pierwszej chwili. Widziałem już mnóstwo błędów polegających na nieprawidłowym wyborze kierunku przesunięcia, na błędnym obliczeniu liczby przesuwanych bitów, jak również na przeoczeniu pierwszeństwa operatorów, kiedy to wyrażenia: a = b + c/4
konwertowane były beztrosko na: a = b + c>>2
Wymieniłem tylko niektóre z „ryzykownych idiomów” języka C. Jedynym sposobem znalezienia ich w tworzonym kodzie jest uważna analiza każdego zaistniałego błędu i zadanie sobie pytania, o którym pisałem już w rozdziale 2. — „w jaki sposób mogłem uniknąć tego błędu?”. W ten sposób można w krótkim czasie „dorobić się” osobistej listy „idiomów”, z których konsekwencjami przyszło się zmagać.
Nie przeceniaj znaczenia efektywności
128
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
27 ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 129
Microsoft był jedną z niewielu firm posiadających aplikacje dla Macintosha, wyprodukowanego przez Apple w 1984 roku. Być „pierwszym u drzwi” to niewątpliwie zaszczyt, lecz w tym przypadku oznaczało to także komplikacje. Aby zdążyć z gotowymi aplikacjami na moment, gdy Macintosh zostanie ostatecznie ukończony, należało oczywiście przygotowywać je i testować na komputerze, który sam był dopiero w fazie tworzenia. Nie stanowiło to problemu do chwili, gdy Apple wprowadził pierwszą poważną poprawkę do systemu operacyjnego Macintosha i odkrył, iż kłóci się ona z aplikacjami Microsoftu. W efekcie Apple poprosił Microsoft o dostosowanie aplikacji do nowej wersji systemu, opisanej w dokumentacji Inside Macintosh. W przypadku Excela oznaczało to zmianę jednej z krytycznych, ręcznie optymalizowanych procedur asemblerowych, co wiązało się z wydłużeniem czasu jej realizacji o 12 cykli zegarowych. Wywiązała się więc debata pomiędzy zwolennikami szybkości, a konformistami nalegającymi, by ulec prośbom Apple. Ostatecznie któryś z programistów umieścił w jednej z funkcji licznik wywołań i poddał Excela trzygodzinnym torturom; zgodnie ze wskazaniami licznika funkcja wywołana została 76000 razy. Przemnożenie tej wartości przez 12 dodatkowych cykli dawało (przy zegarze 10 MHz) ok. 0,1 sekundy. Jedna dziesiąta sekundy na trzy godziny — to nie było zbyt dotkliwe. Ostatecznie zmiany proponowane przez Apple zostały więc dokonane. Morał z tej historii taki, iż zbytnia troska o lokalną efektywność rzadko okazuje się naprawdę uzasadniona. Wskazane jest raczej poszukiwanie możliwości jej zwiększenia na poziomie globalnym i w kontekście zastosowanych algorytmów — bo na to rzeczywiście mamy ogromny wpływ.
Unikaj ryzykownych idiomów języka programowania.
PRZEKLĘTA NIESPÓJNOŚĆ Spójrz na poniższą instrukcję, zawierającą jeden z najczęstszych błędów — błąd pierwszeństwa operatorów. word = highstrWndTitle, strNewTitle); }
Oczywistą niedogodnością takiego podejścia jest pewna strata pamięci, zarazem jednak zyskujemy trochę pamięci ze strony kodu, gdyż operacja jest zawsze wykonalna i nie istnieje potrzeba obsługi błędów. Mamy tu więc do czynienia z dość ciekawym kompromisem — rozmiar danych kontra rozmiar kodu.
Unikaj wywoływania funkcji zwracających informację o błędzie.
NIE WARTO RYZYKOWAĆ Zapewne już rozumiesz, co miałem na myśli, gdy nazwałem programowanie „ryzykownym biznesem”. Cała treść niniejszego rozdziału obraca się wokół wyboru pomiędzy kodowaniem ryzykownym, a kodowaniem porównywalnym pod względem rozmiaru i szybkości, lecz mniej podatnym na błędy. To jeszcze nie koniec. Proponuję, byś przejrzał swe listingi i zastanowił się nad własnymi nawykami w zakresie kodowania. Które z tych zwyczajów wynikają z Twojego własnego przekonania, które natomiast przejąłeś nieświadomie od kolegów? Pamiętaj: programiści początkujący traktują takie rzeczy jak zastępowanie dzielenia przesuwaniem bitów jako „triki”; dla doświadczonego programisty jest to zawsze kwestia przemyślanej decyzji. Które podejście jest Twoim zdaniem bardziej odpowiednie?
PODSUMOWANIE Wybieraj starannie typy danych. Nawet jeżeli standard ANSI wymaga obsługi typów wbudowanych — char, int, long — w każdej implementacji, to
132
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
27 ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 133
jednocześnie nie precyzuje szczegółów tej implementacji. Chroń więc swoje aplikacje przed błędami, które mogą stąd wyniknąć. Pamiętaj, iż nawet poprawny algorytm może wykonywać się błędnie z powodu nie do końca określonej charakterystyki sprzętu, na którym uruchomiono program. W szczególności przetestuj zawsze możliwość wystąpienia nadmiaru lub niedomiaru w swych programach. Upewnij się, iż implementacja jest całkowicie zgodna z Twoim projektem, a nie jest jedynie jego przybliżeniem. Implementacje „przybliżone” to najczęściej implementacje błędne. Każda funkcja powinna wykonywać dobrze zdefiniowane zadanie, a co ważniejsze, powinna wykonywać je w jeden, ściśle określony sposób. Jeżeli wszelkie możliwe dane obsługiwane są przez ten sam, uniwersalny fragment kodu, zmniejsza się ryzyko popełnienia błędów. Potencjalnie każda instrukcja if może być sygnałem ostrzegającym, iż kod wykonuje więcej czynności niż faktycznie powinien. Staraj się dostrzegać możliwości skomasowania w jeden fragment obydwu aspektów instrukcji warunkowych. Niekiedy może to wymagać zmiany struktur danych, a niekiedy po prostu innego spojrzenia na dane — przypomnij sobie: wypukłe, czy wklęsłe są soczewki okularów? Nie zapominaj, że instrukcja warunkowa może występować pod postacią wyrażeń sterujących pętli while i for. Operator ?: to nic innego, jak zakamuflowana instrukcja if. Bądź świadom występujących w języku programowania „ryzykownych idiomów”; staraj się ich unikać na rzecz konstrukcji niezawodnych. Mimo iż za cenę pewnego ryzyka możemy niekiedy zwiększyć efektywność aplikacji, to jednak przypadki takie są rzadkie i raczej nieopłacalne z punktu widzenia niezawodności. Unikaj w wyrażeniach mieszania operatorów należących do różnych grup. Jeżeli masz wątpliwości co do pierwszeństwa operatorów, używaj nawiasów. Obsługa błędów aplikacji jest zawsze przypadkiem wyjątkowym, staraj się więc unikać wywoływania funkcji, które mogą sygnalizować błędy. Jeżeli nie możesz tego zrobić, staraj się centralizować obsługę błędów — zwiększa to szansę ich wychwycenia. W niektórych przypadkach możliwe jest wyeliminowanie możliwości wystąpienia błędów poprzez wykonanie pewnych czynności na etapie inicjalizacji aplikacji, bądź też za pomocą zmian w jej projekcie.
POMYŚL O TYM 1. Jaki jest stopień przenośności „czystego” jednobitowego pola?
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
133
NIEZAWODNOŚĆ OPROGRAMOWANIA
134
2. W czym podobne są funkcje zwracające wynik boolowski do jednobitowych pól? 3. W jednym z punktów niniejszego rozdziału zmieniłem funkcję AddChild usuwając zmienną pwndRootChildren i wprowadzając zmienną pwndDisplay, która wskazuje na dynamicznie przydzieloną strukturę window, będącą korzeniem drzewa. Alternatywnym wyjściem byłoby zadeklarowanie korzenia drzewa w sposób statyczny; jak myślisz, dlaczego tak nie postąpiłem? 4. Niekiedy programiści, kierowani względami efektywności, chcą zastępować pętle w rodzaju: while (wyrażenie) { A; if (f) /* f jest wyrażeniem stałym */ B; else C; D; }
następującą konstrukcją: if (f) while (wyrażenie) { A; B; D; } else while (wyrażenie) { A; C; D; }
gdzie A i D oznaczają ciągi instrukcji. Druga konstrukcja jest zdecydowanie efektywniejsza, na ile jednak jest ona bardziej ryzykowna w porównaniu z pierwowzorem? 5. Studiując specyfikację ANSI bez trudu znajdziesz deklaracje funkcji używających niemal identycznych argumentów, na przykład : int strcmp(const char *s1,
const char *s2);
Na ile ryzykowne jest używanie tak podobnych nazw? Jaki sposób zmniejszenia tego ryzyka mógłbyś zaproponować? 6. Jak miałeś okazję się przekonać, ryzykowne jest używanie pętli: while (pch++ = pchStart)
7. Ze względu na efektywność, jak również oszczędność kodowania, programiści często stosują rozmaite „skróty”, na przykład: a. printf(str); zamiast printf(„%s”, str); b. f = 1–f; zamiast f = !f; c. ch = *str++ = getchar(); zamiast dwóch oddzielnych przypisań. Jak myślisz, dlaczego praktyki takie nie są zalecane? 8. Makro tolower, funkcja uCycleCheckBox, a także algorytm deasemblacji w rozdziale 2. — wszystkie one działają na podstawie tablicy przeglądowej. Jakie są generalnie zalety i wady stosowania tablic przeglądowych? 9. Przy założeniu, że Twój kompilator nie stosuje automatycznie przesunięć bitowych do realizacji mnożenia i dzielenia przez potęgi dwójki — dlaczego nie powinieneś explicite stosować przesunięć bitowych w tym celu? 10. Jedna ze „złotych” reguł programowania brzmi: nie narażaj danych użytkownika na utratę. Załóżmy więc, że w celu zachowania pliku danych użytkownika zamierzasz utworzyć roboczy bufor w pamięci operacyjnej. Co jednak uczynić, by zachować integralność danych użytkownika w sytuacji niewystarczającej pamięci, czyli niemożności przydziału bufora? PROJEKT: Sporządź listę wszystkich ryzykownych (Twoim zdaniem) konstrukcji języka C — błędne instrukcje wyboru, arbitralne instrukcje goto, wartościowanie tego samego argumentu makra więcej niż raz itp. — i przedstaw wszystkie za i przeciw używania tych konstrukcji w praktyce. Następnie, dla każdego elementu listy, zaproponuj okoliczności, które muszą być spełnione, by używanie tego elementu warte było poniesionego ryzyka.
E:\TMP\Doc\5bda2e50-86a1-4581-922b-d044b1c58a5e.Doc
135
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 139
7. DRAMATURGIA RZEMIOSŁA
Podczas pisania noweli fantastycznej, z pewnością dążyłbyś do tego, by jej treść była jak najbardziej tajemnicza, niesamowita, by z każdej stronicy wiało grozą, a włos jeżył się na głowie. Nie mógłbyś napisać ot tak, po prostu „śledzili potwora przez dwa tygodnie i w końcu go dopadli”, bo to banalne i usypiające; czytelnik raczej powinien czuć przez skórę bicie serca wystraszonego Erroneusa (czy jak mu tam było...) w miarę, jak zbliżają się do niego jego najwięksi wrogowie — Debuggerzy (czy jakoś tak...). A czytelnik z zapartym tchem wciąż zadaje sobie pytanie „Uda mu się, czy nie?” Niespodzianki, suspensy, groza… Faktycznie, to wszystko jest jak najbardziej stosowne w literaturze fantastycznej, lecz programiści powinni o tym zapomnieć, przynajmniej w trakcie tworzenia kodu programu. Wbrew pozorom, tak beznamiętny język jak C (i każdy inny język programowania) również posiada pewne środki dramaturgiczne (zwane przez profanów po prostu „trikami”), mające rzekomo świadczyć o doświadczeniu, fantazji, odkrywczości itp. programisty, jednak nie służą one nijak ostatecznemu celowi, jakim jest stworzenie bezbłędnego programu. „Nudny i monotonny” styl wyrazowy programu z pewnością nie znudzi ani nie uśpi bezdusznego komputera, może za to zaoszczędzić wielu kłopotów tym, którzy z komputerem tym będą mieć do czynienia. W niniejszym rozdziale zademonstruję kilka przykładów owej fantazji programistycznej. Wszystkie one są ciekawe, efektowne i nieoczywiste — i wszystkie zawierają pewne subtelne błędy.
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
139
NIEZAWODNOŚĆ OPROGRAMOWANIA
140
SZYBKOŚĆ, SZYBKOŚĆ Przyjrzyjmy się raz jeszcze funkcji memchr z poprzedniego rozdziału, w jej bezbłędnej wersji: void *memchr(void *pv, unsigned char ch, size_t size) { unsigned char *pch = (unsigned char *)pv; while (size-- > 0) { if (*pch == ch) return (pch); pch++; } return (NULL) }
Każdy „obrót” pętli while związany jest z dwoma testami: pierwszy na niezerowość zmiennej size, drugi na równość porównywanych znaków; gdyby dało się wyeliminować którykolwiek z tych testów, uzyskalibyśmy niemal dwukrotne przyspieszenie pętli. Jedną z najbardziej ulubionych zabaw programistów można by nazwać „Jak to przyspieszyć?” Zabawa taka nie jest wprawdzie niczym nagannym, lecz, jak pokazuje to treść poprzedniego rozdziału, potrafi niekiedy wyprowadzić na manowce. Spróbujmy więc przyspieszyć naszą funkcję memchr. Załóżmy mianowicie, iż w przeszukiwanym obszarze na pewno znajduje się poszukiwany znak i jego znalezienie będzie warunkiem zakończenia pętli. Test (size-- > 0) stanie się wówczas niepotrzebny, a wykonanie pętli istotnie skróci się prawie dwa razy. Jak jednak zapewnić obecność poszukiwanego znaku w przeszukiwanym obszarze? Należy po prostu umieścić go bezpośrednio za ostatnim bajtem przeszukiwanego obszaru i zwiększyć o 1 liczbę przeszukiwanych bajtów. Dziecinnie proste, nieprawdaż? void *memchr(void *pv, unsigned char ch, size_t size) { unsigned char *pch = (unsigned char *)pv; unsigned char *pchPlant; unsigned char chSave; /* pchPlant wskazuje na bajt następujący bezpośrednio po ostatnim * bajcie przeszukiwanego obszaru. Pełni on rolę "wartownika" * gwarantującego, iż poszukiwany znak zawsze zostanie znaleziony. */ pchPlant = pch + size; chSave = *pchPlant;
140
/* zachowaj poprzednią zawartość bajtu * zajmowanego przez wartownika */
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 141
*pchPlant = ch;
/* umieść wartownika na swoim miejscu */
while (*pch != ch) pch++; *pchPlant = pchSave;
/* przywróć zawartość zniszczoną przez * wartownika */
return ((pch == pchPlant) ? NULL : pch); }
Funkcja memchr w swym nowym wcieleniu wygląda efektownie — nie zapomniano nawet o odtworzeniu zawartości bajtu przeznaczonego chwilowo dla wartownika. W rzeczywistości jednak ta postać funkcji rodzi więcej wątpliwości, niż Batman posiada gadżetów. Rozpocznijmy od najważniejszych: jeżeli pchPlant wskazuje na pamięć tylko do odczytu, próba zapisu wartownika, jeżeli nawet nie spowoduje naruszenia ochrony dostępu, na pewno okaże się nieskuteczna; w rezultacie pętla while może się nie zatrzymać; jeżeli bajt *pchPlant znajduje się w zakresie pamięci związanej ze sprzętem (np. w pamięci karty graficznej, czy pomocniczej pamięci BIOS-u w obszarze 00040:... — przyp. tłum.), jego zmiana może powodować różne skutki uboczne, np. zatrzymanie (lub uruchomienie) dyskietki, zniekształcenie wyświetlanego obrazu itp.; jeżeli przeszukiwany obszar znajduje się dokładnie na końcu przydzielonej programowi pamięci, pchPlant wskazywać będzie nieistniejącą (lub: nielegalną) lokalizację; próba zapisania wartownika na pewno okaże się nieskuteczna i z dużym prawdopodobieństwem spowoduje błąd ochrony dostępu; jeżeli bajt wskazywany przez pchPlant znajduje się w obszarze pamięci współdzielonym przez różne procesy, zapisanie wartownika (o ile w ogóle będzie możliwe) może zdezorganizować pracę innych programów, i vice versa — inne procesy mogą nieoczekiwanie zmienić zawartość wspomnianego bajtu. Ostatnia z wymienionych okoliczności jest szczególnie dotkliwa, oznacza bowiem zwiększone ryzyko załamania całego systemu — jest ono tym większe, im więcej jest uruchomionych jednocześnie procesów. Wystarczy na przykład, by zapisanie wartownika zniszczyło zawartość bloków sterujących przydziałem pamięci; jeżeli nie zapobiegnie temu system ochrony, sparaliżowane zostaną wszystkie procesy. A co się stanie, jeżeli każdy (lub tylko niektóre) z uruchomionych procesów wykorzystywać będzie funkcję memchr w opisywanym tu wariancie? Podobne wątpliwości można by mnożyć w nieskończoność. I pomyśleć, że tych wszystkich kłopotów można łatwo uniknąć, jeżeli przestrzegać się będzie jednej podstawowej zasady: nie odwołuj się do pamięci, która nie została Ci przydzielona. Pod pojęciem „odwołania” należy tu rozumieć
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
141
NIEZAWODNOŚĆ OPROGRAMOWANIA
142
zarówno zapis, jak i odczyt — ten ostatni nie zdezorganizuje raczej pracy innych procesów, lecz może spowodować załamanie programu wskutek błędu ochrony dostępu.
Nie odwołuj się do pamięci, która nie została Ci przydzielona.
ZŁODZIEJ OTWIERAJĄCY ZAMEK KLUCZEM NIE PRZESTAJE BYĆ ZŁODZIEJEM Poniższy fragment ilustruje kolejny przejaw programistycznej fantazji: void FreeWindowTree(window *pwndRoot) { if (pwndRoot != NULL) { window *pwnd; /* zwolnij okna potomne w stosunku do pwndRoot */ pwnd = pwndRoot->pwndChild; while (pwnd != NULL) { FreeWindowTree(pwnd); pwnd = pwnd->pwndSibling; }
/* zwalnia *pwnd
*/
if (pwndRoot->strWndTitle != NULL) FreeMemory(pwndRoot->strWndTitle); FrreeMemory(pwndRoot); } }
Przywileje związane z danymi Na ogół nie pisze się o tym w podręcznikach dla programistów, ale z każdym wykorzystywanym w aplikacji fragmentem pamięci związane są pewne implikowane uprawnienia do odczytu i zapisu. Uprawnienia te mają naturę czysto koncepcyjną — nie są w żaden sposób przydzielane przez system, czy nadawane deklaracjom zmiennych za pomocą jakichś klauzul, są natomiast wynikiem określonej koncepcji projektowej. Aby zrozumieć lepiej to zagadnienie, rozpatrzmy przykład abstrakcyjnej umowy („protokołu”) pomiędzy programistą tworzącym jakąś funkcję, a programistą tę funkcję wywołującym i jednocześnie deklarującym, co następuje: Jeżeli ja, Wywołujący, przekazuję tobie, Wywoływanemu, wskaźnik do obszaru wejściowego, ty zobowiązujesz się do zachowania nienaruszalności tego obszaru, czyli do niezapisywania w nim żadnej zawartości. Jeżeli ja, Wywołujący, przekazuję tobie, Wywoływanemu, wskaźnik do obszaru wyjściowego, ty zobowiązujesz się traktować przekazaną zawartość tego obszaru jako całkowicie przypadkową i zobowiązujesz się nie odczytywać jej, a jedynie zapisać w niej informację wynikową. Wreszcie — ja, Wywołujący, zobowiązuję się do niezmieniania zawartości obszarów zawierających wyprodukowaną przez ciebie, Wywoływanego, informację wyjściową i określonych jako „tylko do odczytu”. Zobowiązuję się ponadto do nieodwoływania się do wspomnianej informacji w inny sposób, jak tylko za pośrednictwem odwołań do przechowującej je pamięci.
142
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 143
Czyli krótko „ty nie przeszkadzasz mnie, ja nie przeszkadzam tobie”. Naruszenie implikowanych uprawnień dostępu zawsze stwarza ryzyko użycia niezgodnie z przeznaczeniem kodu, który stworzony został pod warunkiem ich przestrzegania. Programiści przestrzegający tych reguł nie muszą natomiast obawiać się, iż tworzone przez nich programy będą zachowywać się błędnie w nietypowych warunkach. W powyższej funkcji brak jest co prawda odwołań do „nie swojej” pamięci, lecz pętla while skrywa inną interesującą osobliwość: wyróżniona pogrubioną czcionką instrukcja powoduje m.in. zwolnienie obszaru wskazywanego przez pwnd, a kolejna instrukcja jak gdyby nigdy nic odwołuje się do jednego z pól tegoż obszaru. Swoją drogą trudno mi zrozumieć intencje programistów odwołujących się do zwolnionych bloków pamięci — czym bowiem różni się to od otwierania zapasowym kluczem pokoju hotelowego, z którego właśnie się wyprowadziłeś lub od wybierania się na przejażdżkę samochodem, który właśnie sprzedałeś? Odwołanie takie będzie poprawne tak długo, jak długo zwolniony obszar zachowywać będzie swą zawartość. Jednak z punktu widzenia programisty to, co dzieje się ze zwolnionymi blokami pamięci jest sprawą czystego przypadku — nawet w środowisku jednozadaniowym procedury gospodarujące pamięcią mogą zapisywać w zwolnionych blokach własne informacje sterujące1.
Nie odwołuj się do zwolnionych bloków pamięci.
1
Tak było między innymi w Turbo Pascalu 6.0 i Borland Pascalu 7.x (w trybie REAL) — funkcjonujące przez lata programy stworzone za pomocą Turbo Pascala 4.0 załamywały się po skompilowaniu ich w środowisku 7.0. Po wyeliminowaniu odwołań do zwolnionych („przed chwilą przecież”) bloków pamięci opisany problem przestał istnieć (przyp. tłum.).
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
143
NIEZAWODNOŚĆ OPROGRAMOWANIA
144
KAŻDEMU WEDŁUG POTRZEB W poprzednim rozdziale zaprezentowałem następującą implementację funkcji UnsToStr: void UnsToStr(unsigned u, char *str) { char *strStart = str; do *str++ = (u % 10) + '0'; while ((u /= 10) > 0 *str = '\0'; ReverseStr(strStart); }
Powyższy kod jest całkowicie poprawny i zrozumiały, jednak niektórym programistom z pewnością nie spodoba się fakt, iż kolejne cyfry wynikowej reprezentacji generowane są „od tyłu” i w związku z tym konieczne jest użycie funkcji ReverseStr. Wydaje się to stratą czasu, której można by uniknąć poprzez budowanie wynikowego łańcucha w odwrotnym kierunku: void UnsToStr(unsigned u, char *str) { char *pch /* jeśli u znajduje się poza zakresem, użyj UlongToStr */ ASSERT(u 0); strcpy(str, pch); }
Na pierwszy rzut oka powyższy kod może wydać się bardzo elegancki — jest przecież bardziej efektywny i łatwiejszy do zrozumienia. strcpy jest przecież szybsze od ReverseStr, szczególnie jeżeli użyć kompilatora realizującego wywołania funkcji jako rozwinięcia inline. Tak naprawdę to jednak tylko pozory; funkcja zawiera bardzo poważny błąd. Jak myślisz, jak duży jest fragment pamięci wskazywany przez str? Zgodnie z opisywanym przed chwilą kontraktem pomiędzy Wywołującym, a Wywoływanym powinien on być dostatecznie duży, aby zmieścić tekstową reprezentację liczby
144
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 145
przekazanej przez parametr u. „Zoptymalizowana” wersja funkcji zakłada jednak, iż jest on dostatecznie duży do pomieszczenia reprezentacji największej możliwej liczby akceptowalnej przez tę funkcję, czyli 65535. Wywołajmy naszą funkcję w sposób następujący: DisplayScore() { char strScore[3]; /* UserScore przyjmuje wartości od 0 do 25 */ UnsToStr(UserScore, strScore); . . . }
No właśnie: skoro UserScore nigdy nie przekracza wartości 25, jej tekstowa reprezentacja nie będzie nigdy dłuższa niż dwa znaki; jeżeli uwzględnić zerowy ogranicznik, wystarczająca okazuje się trójelementowa tablica znaków. Opisywana wersja funkcji wymaga jednak bezwzględnie tablicy sześcioelementowej — w efekcie powyższe wywołanie dokona zniszczenia zawartości trzech bajtów następujących bezpośrednio za tablicą strScore. Na maszynach ze stosem „rosnącym w dół” — jak 8086 — grozi to zniszczeniem ramki wywołania i(lub) adresu powrotu z funkcji DisplayScore. Jeżeli jednak bezpośrednio po tablicy strScore występowałyby jeszcze inne zmienne lokalne, zniszczeniu uległoby kilka z nich, powrót z funkcji DisplayScore nastąpiłby zupełnie normalnie i błąd mógłby pozostać długo nie zauważony. Już słyszę kontrargumenty, iż wszystkiemu winien jest autor funkcji DisplayScore, deklarujący tablicę w sposób bezzasadnie oszczędny, uwzględniający tylko własne potrzeby i nie biorący pod uwagę wymagań funkcji konwertującej. Powinien on raczej przygotować się na najdłuższy łańcuch, jaki funkcja ta jest w stanie zwrócić — a skoro tego nie czyni, działa na własne ryzyko i (co ważniejsze) praktykuje ryzykowny sposób kodowania. Tymczasem żadne w tym ryzyko — po prostu żądam wyprodukowania pewnej informacji wyjściowej i dostarczam bufor wystarczający do jej zmieszczenia. Jedyne, co ryzykuję, to oczekiwanie ze strony wywoływanej funkcji, iż to ja dostarczę jej buforów roboczych! Mimo wszystko koncepcję zoptymalizowanej funkcji da się jeszcze uratować, jeżeli użyje się roboczej tablicy lokalnej, a obszar wskazywany przez str wykorzysta się jedynie do przesłania końcowego wyniku: void UnsToStr(unsigned u, char *str) { char strDigits[6]; char *pch /* jeśli u znajduje się poza zakresem, użyj UlongToStr */ ASSERT(u 0); strcpy(str, pch); }
Nie używaj w roli buforów roboczych obszarów przeznaczonych na informację wyjściową.
NIE UZEWNĘTRZNIAJ PRYWATNYCH INFORMACJI Być może niektórym programistom ostatnia z prezentowanych wersji funkcji UnsToStr jawi się jeszcze jako nieefektywna. Zamiast przesyłać informację z bufora roboczego do bufora wynikowego, można by zwrócić wskaźnik do łańcucha tkwiącego już przecież w buforze roboczym i oszczędzić w ten sposób trochę czasu. Poniższy fragment ilustruje tę koncepcję — naturalnie bufor roboczy jest teraz zmienną statyczną: char *strFromUns(unsigned u); { static char *strDigits = '?????'; char *pch /* jeśli u znajduje się poza zakresem, użyj UlongToStr */ ASSERT(u 0); return(pch); }
Podstawową wadę powyższej wersji natychmiast demaskuje kilkakrotne jej wywołanie: strHighScore = strFromUns(HighScore); strThisScore = strFromUns(Score);
Po zrealizowaniu powyższego fragmentu obydwie zmienne — strHigh Score i strThisScore — wskazują na ten sam łańcuch, stanowiący reprezentację zmiennej Score; po łańcuchu stanowiącym reprezentację zmiennej HighScore nie pozostało ani śladu.
146
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 147
Można by w tym momencie bronić koncepcji stwierdzeniem, iż funkcja jest całkowicie poprawna, a wszystkiemu winien jest programista nie przechowujący w bezpieczny sposób pierwszego łańcucha; gdy wywołuje funkcję po raz drugi świadom jest przecież jego zniszczenia. Taki punkt widzenia nie jest jednak zgodny z podstawowym wymaganiem przedstawionym w rozdziale 5.: nie wystarczy, by funkcja działała prawidłowo; musi ona jeszcze chronić programistów przed popełnianiem oczywistych błędów. W dodatku opisywany błąd nie zawsze występuje w sposób tak banalny; w poniższym przykładzie: strHighScore = strFromUns(HighScore); strThisScore = FormattedScore(Score);
Pierwotny łańcuch zostanie zniszczony, jeżeli funkcja FormattedScore wywoływać będzie (bezpośrednio lub pośrednio) funkcję strFromUns. Najgorsze w tym wszystkim jest jednak to, iż wewnątrz funkcji strFromUns ukrywa się bomba z opóźnionym zapłonem, która niespodziewanie eksploduje na którymś etapie rozwoju projektu. Aby mianowicie wywołanie funkcji było bezpieczne, spełnione muszą być dwa poniższe wymagania:
XXXXXXXXXXXXXXXXXX Problem związany z funkcją strFromUns jest przykładem szerszego zagadnienia, mianowicie niekontrolowanego współdzielenia globalnego zasobu. Przykładami tak wykorzystywanych zasobów są właśnie globalne bufory robocze. Opisana sytuacja nie zmieniłaby się, gdyby bufor wykorzystywany przez funkcję strFromUns usunąć z pamięci statycznej i przydzielić za pomocą funkcji malloc na początku wykonywania programu — nie zmieniłby się bowiem globalny charakter tegoż bufora. Wynika stąd kolejna zasada: nie przekazuj danych w globalnych buforach, chyba że jest to absolutnie konieczne. znajdujący się w buforze roboczym łańcuch jest już niepotrzebny, nie jest bowiem wykorzystywany żaden wskaźnik do niego — na wszystkich poziomach wywołania funkcji; tak długo, jak długo istotna będzie zawartość jakiegokolwiek wskaźnika do ostatnio wyprodukowanego łańcucha w buforze roboczym, nie wolno ponownie wywołać funkcji strFromUns, bezpośrednio ani pośrednio. Zignorowanie którejkolwiek z tych zasad oznacza ryzyko popełnienia błędu. Wyobraź sobie teraz pracę programisty rozbudowującego istniejący projekt używający funkcji strFromUns: gdy zmieni lub doda jakikolwiek łańcuch wywołań, będzie zmuszony każdorazowo sprawdzać, czy spełnione są wspomniane reguły. Mniejsza o to, iż niewesoła to perspektywa i raczej poważne utrudnienie pracy, znacznie ważniejsze jest to, czy wspomniany programista świadom jest istniejącego zagrożenia? Przecież o samej funkcji strFromUns mógł on nawet nie słyszeć! Ponadto — przyczyny tego, iż wskaźnik strHighScore nie wskazuje na reprezentację zmiennej HighScore programista ów będzie raczej
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
147
148
NIEZAWODNOŚĆ OPROGRAMOWANIA
poszukiwał wewnątrz funkcji strFromUns (wszak to ona wiąże ze sobą wymienione zmienne), gdy tymczasem leży on zupełnie gdzie indziej, mianowicie w sposobie wykorzystywania funkcji.
Unikaj przekazywania danych za pomocą statycznych lub globalnych buforów.
FUNKCJE-PASOŻYTY Gdy stosuje się złą praktykę przekazywania danych w globalnych buforach, można mimo wszystko uniknąć błędów, jeżeli postępuje się ostrożnie i ma się odrobinę szczęścia. To, o czym chcę teraz napisać, jest przykładem czegoś na kształt pasożytnictwa na gruncie programistycznym. Mowa tu o funkcjach, które uzależniają swe działanie od (uwaga) wewnętrznych szczegółów implementacyjnych innych funkcji! To nie tylko przejaw ryzyka, ale skrajnej nieodpowiedzialności — pasożyt ginie wraz ze śmiercią żywiciela; zmieniając więc implementację jednej funkcji unicestwiamy możliwość działania bazujących na niej funkcji-pasożytów. Najbardziej wyrazisty przykład takiej „pasożytniczej” funkcji, jaki utkwił mi w pamięci, wiąże się z językiem FORTH. Na przełomie lat 70. i 80. grupa robocza pod nazwą FORTH Interest Group udostępniła freeware’ową wersję tego języka opartą na standardzie pod nazwą FORTH-77. Standard ten definiował trzy funkcje standardowe: FILL — wypełniającą blok pamięci podanym bajtem, CMOVE — przesyłającą bajty pomiędzy obszarami w kierunku rosnących adresów i 0) { *pbTo++ = *pbFrom++ }
Realizacja funkcji FILL była za to kompletnym zaskoczeniem: /* FILL – wypełnianie obszaru pamięci */
148
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 149
void FILL(byte *pb, szie_t size, byte b) { if (size > 0) { *pb = b; CMOVE(pb, pb+1, size-1); } }
Jak przed chwilą pisaliśmy, w przypadku kopiowania pomiędzy nakładającymi się obszarami istotny jest kierunek poruszania się po kopiowanym obszarze: jeżeli obszar docelowy rozpoczyna się pod wyższym adresem niż obszar źródłowy, kopiowanie musi być przeprowadzone w kierunku malejących adresów, w przeciwnym razie w obszarze docelowym otrzymamy nie kopię obszaru źródłowego, lecz powtórzenia jego fragmentów. W szczególności — jeżeli obszar docelowy położony jest o jeden bajt dalej od obszaru źródłowego, otrzymamy kopię pierwszego bajtu obszaru źródłowego; tę właśnie cechę kopiowania wykorzystuje funkcja FILL. Stwierdzenie zawarte w ostatnim zdaniu jest jednakże prawdziwe tylko pod warunkiem, iż kopiowanie odbywa się bajt po bajcie; gdyby ktoś postanowił zoptymalizować funkcję CMOVE i przesyłać dane w porcjach dwu- lub czterobajtowych, nie uzyskalibyśmy żądanego efektu i funkcja FILL przestałaby działać poprawnie. W efekcie przestałyby poprawnie działać wszystkie funkcje wywołujące funkcję FILL (bezpośrednio lub pośrednio), mimo iż optymalizacja funkcji CMOVE nie wprowadziłaby do niej żadnych błędów! Aby takiej sytuacji zapobiec, należałoby wprowadzić do funkcji CMOVE komentarz wyjaśniający całą sprawę i zakazujący dokonywania w niej jakiejkolwiek optymalizacji. To jednak rozwiązałoby problem zaledwie w połowie. Wyobraź sobie, iż opracowujesz system sterowania robotem przemysłowym. Urządzenie to posiada cztery stopnie swobody — współrzędne każdej z jego czterech osi mogą przyjmować wartości całkowite z przedziału 0 – 255. Najprostszy z możliwych projektów mógłby wykorzystywać 32-bitowe słowo w pamięci wejścia-wyjścia — każdy z jego bajtów określałby niezależnie współrzędne jednej z osi. Sprowadzenie wszystkich osi do pozycji „wyjściowej” (0,0,0,0) następowałoby po wyzerowaniu wszystkich czterech bajtów, co można by uczynić w sposób następujący: FILL(pbRobotArm, 4, 0);
/* robot idzie spać */
I tu spotkałaby Cię niemiła niespodzianka — robot zacząłby zachowywać się w sposób losowy. Powyższa instrukcja wcale bowiem nie zeruje wszystkich czterech bajtów, lecz jedynie pierwszy z nich, wpisując „śmieci” w pozostałe trzy bajty! Stanie się to jasne, jeżeli przyjrzymy się dokładnie, w jaki sposób działa funkcja FILL. Otóż po wpisaniu zera do pierwszego bajtu odczytuje ona ów bajt i kopiuje jego zawartość do drugiego bajtu. Owa zawartość wcale nie będzie jednak zerem — lecz aktualną pozycją pierwszej osi, która nie zdążyła jeszcze (w ułamku
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
149
NIEZAWODNOŚĆ OPROGRAMOWANIA
150
sekundy) przyjąć pozycji zerowej! Podobnie ma się rzecz z kopiowaniem pomiędzy kolejnymi parami bajtów. Można by powiedzieć, iż wszystkiemu winny jest specyficzny charakter pamięci wejścia-wyjścia — w przeciwieństwie do pamięci konwencjonalnej nie otrzymujemy przy odczycie wartości uprzednio zapisanej. To prawda, lecz opisany problem w ogóle by nie wystąpił, gdyby funkcja FILL skonstruowana została w sposób bardziej naturalny — ot, choćby tak, jak funkcja memset w swej najprostszej postaci z rozdziału 2. W swej obecnej postaci funkcja FILL jest przejawem owej fantazji twórczej, o której pisałem na początku rozdziału; jak wyraźnie widać, poza efektem czysto zewnętrznym fantazja ta przynosi raczej opłakane skutki. Poniższy fragment jest z pewnością banalny, lecz działa nawet w odniesieniu do pamięci wejścia-wyjścia: void FILL(byte *pb, size_t size, byte b) { while (size-- > 0) *pb++ = b; }
PROGRAMISTYCZNE ŚRUBOKRĘTY Jednym z najbardziej przydatnych narzędzi przy odnawianiu ścian może okazać się — śrubokręt. Podważamy nim wieko puszki z farbą, a następnie mieszamy nim farbę; wiem coś o tym, mam bowiem w domu całą kolekcję różnokolorowych śrubokrętów. Dlaczego jednak ludzie używają śrubokrętu do mieszania farby, chociaż śrubokrętowi nie wychodzi to wcale na zdrowie, a poza tym istnieją efektywniejsze sposoby mieszania? Otóż śrubokręt ma tę niezaprzeczalną zaletę, iż na ogół zawsze jest pod ręką. Podobną rolę spełniają pewne triki programistyczne — wygodne w użyciu, pewnie działające i używane w celach zupełnie innych niż te, do których zostały stworzone. Spójrz na poniższy fragment, wykorzystujący wynik porównania jako część obliczanego wyrażenia: unsigned atou(char *str) /* bezznakowa wersja atoi */ /* atoi – konwertuje łańcuch znaków ASCII na liczbę typu int */ int atoi(char *str) { /* str ma następujący format: * * "[białe znaki][+/-]cyfry" * */ while (isspace(*str)) str++; if (*str == '-') return (-(int)atou(str+1));
150
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 151
/* pomiń ewentualny znak '+' */ return ((int)atou(str + (*str == '+'))); }
W ostatniej instrukcji return widzimy pominięcie ewentualnego znaku + poprzez dodanie wyniku porównania do wskazania na łańcuch docelowy. Ponieważ zgodnie z normą ANSI wynikiem każdego operatora relacyjnego może być tylko 0 lub 1, więc w zależności od tego, czy znak + występuje, czy też nie, wyrażenie: str + (*str == '+')
równe jest (odpowiednio) str+1 albo str+0, czyli str. Programiści stosujący podobne triki, powołując się na normę ANSI, nie uświadamiają sobie tej oczywistej prawdy, iż norma ta nie stanowi wyroczni w każdej sprawie, między innymi w kwestii niezawodnego programowania — podobnie jak tabela podatkowa nie zawiera żadnych wskazówek odnośnie tego, skąd masz zdobyć pieniądze na zapłacenie należnego podatku i czy zabieranie Ci ostatnich pieniędzy jest społecznie uzasadnione. Zarówno bowiem norma ANSI, jak i tabela podatkowa, stanowią przejaw litery prawa, jednocześnie abstrahując od jego ducha. Prawdziwy problem nie leży jednak w samym kodzie, lecz w pewnej pozie programistów, niekiedy wręcz snobujących się na tak kuriozalne konstrukcje; czy utrwalanie takich nawyków nie jest szkodliwe? Jak mają się podobne nawyki do idei programowania defensywnego?
Nie nadużywaj języka programowania.
Standardy się zmieniają Gdy na rynku pojawiła się wersja 83 języka FORTH (FORTH-83), wielu programistów skonstatowało, iż ich programy, stworzone w zgodzie ze standardem FORTH-77, przestały poprawnie pracować. Przyczyna tego stanu była jasna — z różnych względów technologicznych zmieniono reprezentację wartości TRUE z 1 na –1. Miało to opłakane skutki dla programów zakładających, iż TRUE tożsame jest z jedynką. Programiści używający FORTHA nie byli pod tym względem odosobnieni. Podobna niespodzianka spotkała użytkowników popularnego w latach 70. i 80. UCDS Pascala, gdy język ten wkroczył na arenę mikrokomputerów — programiści otrzymali uaktualnienie kompilatora, po którego zastosowaniu wiele programów odmówiło współpracy, właśnie ze względu na implementację wartości TRUE. Powinno to stanowić pewną przestrogę dla użytkowników języka C, polegających na konkretnej reprezentacji wybranych wartości — kto wie, jakie zmiany czekają nas w przyszłych wersjach?
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
151
NIEZAWODNOŚĆ OPROGRAMOWANIA
152
SYNDROM APL2 Programiści nie do końca świadomi tego, w jaki sposób kod w języku C tłumaczony jest na język maszynowy, dążąc do nadania przekładowi maksymalnej zwięzłości starają się minimalizować objętość kodu źródłowego. Fakt, iż mniejszy objętościowo kod źródłowy oznacza na ogół mniejszy rozmiar kodu wynikowego, jednakże relacja ta niekoniecznie musi obowiązywać na poziomie poszczególnych linii. Czy pamiętasz „zwięzłą” funkcję uCycleCheckBox z rozdziału 6.? unsigned uCycleCheckBox(unsigned uCur) { return ((uCur pbFrom) ? tailmove : headmove)(pbTo, pbFrom, size); return (pvTo); }
Powyższy kod jest całkowicie poprawny z punktu widzenia języka C, stanowi jednak przykład kodu tyleż zgrabnego, co trudnego do zrozumienia i konserwacji. Czyż nie prościej byłoby napisać to w taki sposób: void *memmove(void *pvTo, void *pvFrom, size_t size) { byte *pbTo = (byte *)pvTo; byte *pbFrom = (byte *)pvFrom; if ((pbTo > pbFrom) tailmove(pbTo, pbFrom, size);
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
153
NIEZAWODNOŚĆ OPROGRAMOWANIA
154
else headmove(pbTo, pbFrom, size); return (pvTo); }
Oto inny przykład kodu mogącego wywołać wątpliwości programisty: while (wyrażenie) { int i = 33; /* deklaracje zmiennych lokalnych */ vchar str[20]; . . . }
Czy wartość 33 nadawana jest zmiennej i przy każdym obrocie pętli, czy tylko przy jej rozpoczynaniu? Bywa, iż nawet doświadczeni programiści muszą się chwilę zastanowić, by poprawnie odpowiedzieć na to pytanie. Kwestia ta staje się jednak bezprzedmiotowa po przepisaniu pętli w następujący sposób: while (wyrażenie) { int i; /* deklaracje zmiennych lokalnych */ vchar str[20]; i = 33; . . . }
Programiści nazbyt często nie uświadamiają sobie podstawowej prawdy, iż oprócz tzw. użytkowników końcowych (ang. end users) istnieje również druga grupa odbiorców tworzonego przez nich oprogramowania — grupę tę tworzą inni programiści, których zadaniem będzie utrzymywanie i rozwijanie otrzymanego kodu źródłowego, nieraz przez długie lata.
Kim są programiści konserwujący oprogramowanie? Zgodnie z przyjętą w firmie Microsoft praktyką, rozmiar kodu nowo tworzonego przez danego programistę jest wprost proporcjonalny do znajomości produktu, którego kod ów dotyczy — i oczywiście ogólnych kwalifikacji programistycznych. Większa ilość samodzielnie tworzonego kodu oznacza jednocześnie mniejsze zaangażowanie w konserwację kodu tworzonego przez innych programistów. Programiści znający nowy produkt słabo lub nie znający go wcale spędzają więc większość czasu na czytaniu cudzego kodu, poprawianiu cudzych błędów i wprowadzaniu drobnych poprawek — gdy nie zna się dobrze nowego produktu, trudno decydować o jego generalnych zmianach.
154
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 155
Wydaje się to rozsądną praktyką, wszak programiści o większych kwalifikacjach i lepszej znajomości produktu ponoszą większą odpowiedzialność za jego powstawanie. Programiści zajmujący się konserwacją oprogramowania stworzonego przez swoich „bardziej wykwalifikowanych” kolegów muszą być jednak w stanie je zrozumieć, a to wymaga zrozumiałego kodowania, wolnego od wszelkich trików, udziwnień i niejasności. Podstawowym wymogiem, dyktowanym przez względy niezawodności oprogramowania, jest tworzenie kodu źródłowego w taki sposób, by jego rozwijanie nie napotykało na niepotrzebne trudności i nie stwarzało łatwych okazji do popełniania błędów. Wydaje się to oczywiste — mniej oczywiste jest natomiast to, iż jeżeli kod programu będzie zrozumiały jedynie dla ekspertów od programowania, nie będzie on z pewnością łatwy w utrzymaniu (tym bardziej, iż zadanie konserwacji kodu powierza się raczej programistom mniej wykwalifikowanym, a nie ekspertom).
Formułuj kod programu w taki sposób, by jego zrozumienie nie wymagało kwalifikacji eksperta.
NA ŚMIETNIK Z TYMI WSZYSTKIMI TRIKAMI W niniejszym rozdziale przyjrzeliśmy się kilku przykładom kodowania wyglądającym efektownie na pierwszy rzut oka, lecz gdy spogląda się na te przykłady po raz drugi (czy nawet — piąty) niełatwo jest dostrzec czające się w nich subtelne błędy lub efekty uboczne. Mimo zewnętrznej efektowności praktyczna przydatność tak stworzonego kodu staje się wątpliwa, jeżeli wziąć pod uwagę względy jego niezawodności i koszty przyszłego utrzymania. Jeżeli więc tworzony przez Ciebie kod wyda Ci się w pewnym momencie nieco „trikowy”, zatrzymaj się na chwilę i spróbuj poszukać innego rozwiązania. Jeżeli bowiem dany fragment kodu faktycznie produkuje żądane wyniki, fakt ten musi być widoczny w sposób oczywisty. W kodzie, którego poprawność jest w jakimś stopniu zakamuflowana, mogą bowiem czaić się równie głęboko zakamuflowane błędy. I to właśnie jest najważniejszym powodem tworzenia kodu prostego, łatwego do zrozumienia, pozbawionego efektownych „wodotrysków”. Postępując zgodnie z tą ideą ułatwiasz pracę i sobie, i innym.
PODSUMOWANIE Nie zapisuj danych (nawet tymczasowo) do pamięci, która nie jest przydzielona do Twojego programu. Jeżeli sądzisz, że odczytywanie danych z
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
155
156
NIEZAWODNOŚĆ OPROGRAMOWANIA
takiej pamięci może być sensowne, przypomnij sobie kłopoty z kopiowaniem pomiędzy komórkami pamięci wejścia-wyjścia. Nie odwołuj się do bloku pamięci po jego zwolnieniu — jego zawartość mogła ulec zniszczeniu przez inne programy lub procedury zarządzające pamięcią. Przekazywanie danych za pomocą globalnych buforów może być niekiedy uzasadnione względami efektywności, lecz wiąże się z wieloma trudnościami. Tworzone przez wywoływaną funkcję dane, użyteczne dla funkcji wywołującej, powinny być przekazywane bezpośrednio do tej ostatniej. Jeżeli jednak dane te rezydują w globalnych buforach, należy chronić je przed zniszczeniem tak długo, jak długo są potrzebne. Nie uzależniaj poprawnego działania tworzonej funkcji od konkretnych szczegółów implementacyjnych innych funkcji. Używaj języka programowania zgodnie z przeznaczeniem, posługując się jego naturalnymi konstrukcjami i unikając niejasnych idiomów — nawet wówczas, gdy standardy języka gwarantują ich poprawne działanie. Pamiętaj, iż standardy nie są wieczne i mogą się zmieniać. To nieprawda, iż zwięzłe konstrukcje językowe powodują generowanie równie zwięzłego przekładu. Należy więc dobrze się zastanowić przed przystąpieniem do przekształcania czytelnej instrukcji, zajmującej kilka linii kodu w nieczytelną konstrukcję mieszczącą się w jednej linii. Efektywność przekładu może nic na tym nie zyskać i przysłowiowa skórka okaże się niewarta wyprawki. Unikaj tworzenia kodu przypominającego kontrakty pisane przez prawników — nie można wymagać, by zrozumienie programu wymagało kwalifikacji eksperta; musi on być zrozumiały przez przeciętnego programistę.
POMYŚL O TYM 1. Programiści bardzo często modyfikują argumenty wywołania funkcji (w jej treści — przyp. tłum.). Dlaczego nie kłóci się to z implikowanymi regułami dostępu do danych wejściowych? 2. Pamiętając o ryzyku związanym z używaniem globalnego bufora przez funkcję strFromUns zastanów się, czy poniższa wersja używająca globalnego wskaźnika stwarza jakieś dodatkowe niebezpieczeństwo? char *strFromUns(unsigned u); { static char *strDigits = '?????'; char *pch /* jeśli u znajduje się poza zakresem, użyj UlongToStr */ ASSERT(u 0); return(pch); }
3. Napotkałem kiedyś kod dokonujący szybkiego zerowania wszystkich zmiennych lokalnych w następujący sposób: void DoSomething(...) { int i; int j; int k; memset(&k, 0, 3*sizeof(int));
/* wyzeruj i, j oraz k */
… }
Ten kod może poprawnie funkcjonować w niektórych implementacjach, ale podobnych konstrukcji należy generalnie unikać. Dlaczego? 4. Mimo iż część systemu operacyjnego komputera może być zapisana w pamięci tylko do odczytu, bezpośrednie odwoływanie się do tej pamięci z pominięciem interfejsu systemowego niesie ze sobą pewne ryzyko. Dlaczego? 5. Język C umożliwia pomijanie niektórych argumentów funkcji w jej wywołaniu, na przykład: . . .
DoOperation(opNegAcc); /* nie ma potrzeby przekazywania * argumentu "val" */ . . .
void DoOperation(operation op, int val) { switch (op) { case opNegAcc: accumulator = - accumulator; break; case opAddVal: accumulator += val; break; . . .
}
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
157
NIEZAWODNOŚĆ OPROGRAMOWANIA
158
Dlaczego mimo wszystko nie należy tej możliwości wykorzystywać, mimo iż może ona poprawić efektywność programu? 6. Co w istocie weryfikuje poniższa asercja i jaka jest jej bardziej czytelna postać? Przypomnij sobie poniższy fragment funkcji memmove: ((pbTo > pbFrom) ? tailmove : headmove)(pbTo, pbFrom, size);
W jaki sposób poprawić jej czytelność, z zachowaniem koncepcji prezentowanej przez autora? 7. Poniższy fragment w języku asemblera pokazuje najczęstszy sposób wywoływania funkcji. Na czym polega ryzyko związane z tego rodzaju konstrukcjami? move r0,#PRINTER call Print+4 . . .
Print: move r0,#DISPLAY
; (instrukcja 4-bajtowa) ; r0 zawiera identyfikator urządzenia
. . .
8. Poniższy fragment kodu, podobnie jak fragment z poprzedniego ćwiczenia, zależny jest od wewnętrznej implementacji funkcji Print, lecz ma poza tym jeszcze jedną niepożądaną cechę. Jaką? instClearR0 = 0x36A2 ; kod zerujący rejestr r0 . . . call Print+2 ; wyjście na drukarkę . . . Print: move r0,#instClearR0 ; (instrukcja 4-bajtowa) comp r0,#0 ; 0 - drukarka, 0 – ekran . . .
158
E:\TMP\Doc\b7932825-6bf9-4107-b78c-15d8b22565b6.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 161
8. RESZTA JEST KWESTIĄ NAWYKÓW
Poprzednie rozdziały niniejszej książki poświęcone były rozmaitym technikom ułatwiającym wykrywanie błędów i zwiększającym szansę ich uniknięcia. Same środki techniczne nie są jednak magiczną różdżką, zdolną załatwić wszystko za programistę; nie zdadzą się one na wiele, jeśli nie będą wsparte pożądanymi nawykami i właściwą praktyką ze strony programistów. Jest skądinąd oczywiste, iż skompletowanie drużyny piłkarskiej złożonej z najlepszych nawet graczy nie daje gwarancji zwycięstwa. Potrzebny jest jeszcze codzienny trening — i odpowiednia podbudowa, finansowa i nie tylko; to ostatnie ma się co prawda nijak do samej techniki kopania piłki, lecz odpowiednio warunkuje ludzką motywację, niezbędną do odnoszenia wszelkich sukcesów. W niniejszym rozdziale omówię kilka typowych barier sprawiających, iż możliwości wynikające z metodologii niezawodnego oprogramowania nie przynoszą spodziewanych efektów. Pierwszym, bodaj najważniejszym krokiem do przezwyciężenia tychże barier jest po prostu uświadomienie sobie ich istnienia.
HOKUS-POKUS, NIE MA BŁĘDU Jakże często programiści, pytani o kwestię usunięcia jakiegoś wykrytego wcześniej błędu, odpowiadają, iż błąd ten... zwyczajnie zniknął. Sam przecież prezentowałem kiedyś ten sposób myślenia, dopóki mój kierownik nie uświadomił mi oczywistej prawdy, iż błędy nie znikają same z siebie; jeżeli błąd nie daje znać o sobie i brak go także w raporcie sporządzanym przez testerów, to może to być spowodowane jedną z trzech następujących przyczyn:
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
161
NIEZAWODNOŚĆ OPROGRAMOWANIA
162
poprzedni raport sporządzony przez testerów sam był błędny — stwierdzenie przedmiotowego błędu było wynikiem pomyłki i błąd ten faktycznie nie istnieje; błąd został usunięty przez innego programistę; błąd nadal istnieje, lecz nie ujawnia się. Obowiązkiem programisty odpowiedzialnego za usunięcie błędu jest rozpoznanie, która z wymienionych okoliczności jest w danym przypadku prawdziwa. Jeżeli błędu nie usunięto, lecz przestał się on manifestować w wyraźny sposób, może to być skutek dodania lub zmodyfikowania kodu programu; zdarza się często, iż zespół testujący i programista używają dwóch różnych wersji programu. Obowiązkiem programisty jest wówczas powrót do wcześniejszych „źródeł” i wyjaśnienie, dlaczego błąd przestał się pojawiać — być może wprowadzone zmiany stworzyły po prostu mniej sprzyjające warunki do jego występowania. Otwiera to prostą drogę do poprawienia błędu w nowej wersji.
Błędy nigdy nie znikają samoczynnie.
Zbędny wysiłek? Programiści często narzekają na konieczność powrotu do starszych wersji kodu źródłowego w poszukiwaniu określonego błędu, traktując to jak zwykłą stratę czasu. Tymczasem właśnie porównanie różnych wersji kodu źródłowego pozwala na stwierdzenie, iż błąd faktycznie został poprawiony. Poza tym — które z rozwiązań jest bardziej dobroczynne dla projektu: stwierdzenie, iż błąd został definitywnie wyeliminowany, czy zaklasyfikowanie go jako „nieujawniający się” i skierowanie programu z powrotem do testowania? Gdy przyjąć tę drugą ewentualność — co powinni uczynić testerzy: próbować doprowadzić do ujawnienia się błędu, czy też pozostawić go jako „niewykrywalny” w nadziei, że zostanie poprawiony w przyszłości? Obydwa te warianty są zdecydowanie gorszym wyborem od porównywania kodu źródłowego.
ZRÓB DZIŚ, CO MASZ ZROBIĆ JUTRO Gdy rozpocząłem swą pracę w grupie Excela, powszechną praktyką było odkładanie wykrywania i usuwania błędów do czasu, aż projekt zostanie ukończony. Nietrudno dociec przyczyny takiego sposobu myślenia — presji na terminowe ukończenie projektu nie towarzyszyła równie silna presja na poprawianie błędów. Jeżeli więc błędy nie powodowały zawieszenia systemu albo nie zmuszały grupy programistów do nieprzerwanej pracy przez dwa dni i noce, stanowiły one zadanie drugorzędne wobec tworzenia kodu.
162
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 163
Podejście takie stwarza wiele niewiadomych, spośród których najważniejszą jest termin faktycznego ukończenia projektu. Jakże bowiem można określić termin usunięcia wszystkich błędów z ukończonego właśnie kodu, jeżeli liczba tych błędów wynosi niemal dwa tysiące?! A przecież, gdy poprawia się stare błędy, wprowadza się zazwyczaj (lub tylko powoduje ujawnienie) wiele nowych — wszystko to stwarza warunki, którym grupa testująca może po prostu nie podołać w krótkim czasie. Inną typową konsekwencją odkładania na później testowania projektu jest sytuacja, kiedy to kierownicy działu rozwoju (ang. Development) długo — niekiedy przez kilka miesięcy — nie mogą doczekać się przekazania „niemal całkowicie ukończonego projektu”. Wszak projekt „wydaje się” pracować poprawnie, dlaczego więc trzeba czekać pół roku na usunięcie „jakichś drobnych usterek”? Twierdzący tak menedżerowie nie wiedzą nic o błędach wykraczania poza przydzieloną pamięć, posługiwania się „wiszącymi” wskaźnikami, ponadto „sprawdzając produkt” uruchamiają w istocie niewielką część jego funkcji. A przecież na ogół oni sami znajdują się pod presją innych klientów... Powyższe rozważania skłaniają więc do następujących konkluzji: odkładanie wyszukiwania błędów „na później”, w obawie przed stratą czasu, jest „oszczędnością” zdecydowanie źle pojętą; wszak znacznie łatwiej znajduje się błędy w kodzie stworzonym dzień wcześniej, niż w kodzie stworzonym w ubiegłym roku; poprawianie błędów „na bieżąco” ma tę dodatkową zaletę, iż programista świadom popełnionych (i poprawionych) błędów nie popełnia ich zazwyczaj po raz drugi; błędy programistyczne stanowią przyczynę swoistego ujemnego sprzężenia zwrotnego: przystąpienie do następnego etapu projektu bez usunięcia błędów tkwiących w etapie właśnie zakończonym stwarza ryzyko, iż wprowadza się do zastosowania funkcje zaimplementowane połowicznie lub wręcz źle zaimplementowane; w momencie, gdy zaczynają się ujawniać tego konsekwencje, staje się oczywiste, iż pierwotny pośpiech skutkuje spowolnieniem całej pracy. I odwrotnie — staranne przetestowanie zakończonego etapu pozwala uniknąć straty czasu w wymiarze znacznie większym niż ten przeznaczony na testowanie; gdy utrzymuje się liczbę spodziewanych błędów na możliwie niskim poziomie, można łatwiej przewidzieć ostateczne ukończenie projektu. Jeżeli w kodzie, którego pisanie właśnie zakończono, ukrywają się (powiedzmy) 32 błędy, to sytuacja ta jest możliwa do opanowania w rozsądnie dającym się przewidzieć czasie; jeżeli ukrywające się błędy liczyć można w tysiącach, sytuacja staje się dramatyczna. I niekiedy nie ma innego wyjścia, jak skierowanie do rozpowszechniania produktu jedynie w części „odpluskwionego”.
Nie odkładaj diagnostyki na później.
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
163
NIEZAWODNOŚĆ OPROGRAMOWANIA
164
DOKTORA!!! Anthony Robbins w jednej ze swych książek („OBUDŹ W SOBIE OLBRZYMA”) opowiada historię pięknej lekarki, która stojąc na brzegu rwącej rzeki usłyszała wołanie tonącego człowieka o pomoc. Nie namyślając się długo, wskoczyła do rzeki, wyłowiła topielca, ułożyła go na brzegu i za pomocą sztucznego oddychania usta-usta doprowadziła go do przytomności. Wtem znowu usłyszała wołanie o pomoc — tym razem topiło się dwóch mężczyzn. Również ich wyłowiła, przywróciła do przytomności — i znów wołanie o pomoc, tym razem topiło się czterech chłopaków. Ich również wyłowiła, a zaraz potem usłyszała, jak ośmiu tonących... W efekcie lekarka, zajęta wyławianiem coraz to nowych ofiar, nie miała czasu na udzielenie pomocy już wyłowionym. Przypomina to jako żywo sytuację programisty bombardowanego błędami w takim tempie, iż nie jest on w stanie nadążyć z rozpoznawaniem przyczyn powodujących te błędy. Namiastkę takiej lawiny błędów mieliśmy okazję obserwować w rozdziale 7., przy okazji omawiania funkcji strFromUns. Po usunięciu pierwszego błędu, spowodowanego umieszczeniem bufora w pamięci statycznej, pojawiły się błędy związane nie z samą funkcją strFromUns, lecz jej wywoływaniem. Określenie przyczyny tych błędów nie jest już takie łatwe — czy była nią funkcja strFromUns, czy może funkcje ją wywołujące? Doświadczyłem takiej sytuacji osobiście, podczas przenoszenia kilku mechanizmów „windowsowego” Excela do Excela dla komputerów Macintosh (wówczas były to dwa oddzielne produkty, posiadające całkowicie odrębny kod źródłowy). Gdy po zaimplementowaniu pierwszej funkcji przystąpiłem do testowania, jedna z funkcji zasygnalizowała błąd otrzymania pustego wskaźnika (NULL), którego otrzymać nie powinna. Gdy udałem się z tym problemem do autora kodu, stwierdził on po prostu, iż przedmiotowa funkcja nie jest przygotowana na pusty wskaźnik; jeżeli go otrzyma (mimo, iż nie powinna), należy natychmiast zakończyć jej działanie za pomocą „szybkiego wyskoku”. if (pb == NULL) return (FALSE);
Błąd nie tkwił więc w samej funkcji, lecz na zewnątrz niej i wydawałoby się, że został poprawiony. Mnie jednak opisane postępowanie wydało się raczej usuwaniem objawów niż przyczyny; wróciłem do swego biura i zacząłem szukać przyczyny powodującej, iż argument wywołania funkcji jest pustym wskaźnikiem. Zanim tę przyczynę rozpoznałem, znalazłem dwa inne błędy tego samego typu. Innym razem, znalazłszy błąd w kodzie źródłowym, stwierdziłem, iż kilka oglądanych przeze mnie funkcji powinno się załamać, tymczasem funkcje te wykonywały się bezbłędnie. Przyczyną było połowiczne (lokalne) poprawienie bardziej ogólnego, globalnego błędu.
Usuwaj przyczyny, nie objawy.
164
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 165
JEŚLI DZIAŁA, NIE POPRAWIAJ „Nawet jeżeli to działa, i tak należy to poprawić” zdarza się niekiedy słyszeć zatroskany głos programistów. Niezależnie od tego, jak pewnie działa określony fragment kodu, czują się oni wręcz zmuszeni wtrącić do niego przysłowiowe trzy grosze. Jeżeli przyszło Ci kiedyś pracować z programistami uporczywie reformatującymi całe pliki źródłowe stosownie do swoich upodobań, doskonale wiesz, co mam na myśli. Podstawowy problem związany z „adiustacją” kodu polega na tym, iż ponownie sformatowany kod jest w istocie kodem nowym, zmienionym, a więc być może zawierającym nowe błędy. Aby zrozumieć wagę tego stwierdzenia, przyjrzyjmy się ponownie znajomemu fragmentowi: char *strcpy(char *pchTo, char *pchFrom) { char *pchStart = pchTo; while ((*pchTo++ = *pchFrom++) != 0) {} return (pchStart); }
Niektórzy czują w tym momencie nieodpartą chęć zmiany 0 na '\0' w instrukcji while. Porównywanie znaku z liczbą całkowitą może się bowiem wydawać wynikiem pomyłki — wszak łatwo zgubić znak „\”, nie to jest jednak najważniejsze. Któż jednak po wprowadzeniu tak prostej zmiany podejmie się ponownego przetestowania zmienionego przecież kodu? Można by przypuszczać, iż tak „kosmetyczne” zmiany nie wnoszą do kodu żadnych błędów — przynajmniej wówczas, gdy zmieniony kod nadal kompiluje się bezbłędnie. Zresztą, czy tak delikatne zabiegi, jak zmiana nazwy zmiennej lokalnej w ogóle mogą powodować problemy? Okazuje się, że mogą. Śledzenie programu w poszukiwaniu błędu zaprowadziło mnie kiedyś do wnętrza funkcji posiadającej lokalną zmienną o nazwie hPrint kolidującą z identycznie nazwaną zmienną globalną. Ponieważ funkcja jeszcze niedawno działała poprawnie, spojrzałem do wcześniejszej wersji kodu i stwierdziłem, że wspomniana zmienna lokalna nazywała się wówczas hPrint1. Ponieważ brak było zmiennych lokalnych o nazwach hPrint2, hPrint3 itd. usprawiedliwiających końcową jedynkę, ktoś widocznie uznał tę jedynkę za wynik pomyłki i postanowił „poprawić” błąd. W świetle powyższego, całkowicie zasadne okazuje się następujące ostrzeżenie: jeżeli napotkasz jakiś fragment kodu wyglądający Twoim zdaniem ewidentnie źle lub wyglądający na niepotrzebny, zachowaj jak najdalej idącą ostrożność. Niejednokrotnie zdarzało mi się widzieć kod wyglądający śmiesznie lub wręcz karykaturalnie — jedynym powodem jego istnienia było... zniwelowanie błędów tkwiących w kompilatorze! Oczywiście fragmenty takie powinny być odpowiednio skomentowane, jednak brak komentarzy do niczego jeszcze nie upoważnia. Jeżeli w poniższym fragmencie:
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
165
NIEZAWODNOŚĆ OPROGRAMOWANIA
166
char chGetNext(void) { int ch; ch = getchar(); return (chRemapChar(ch)); }
dokonamy niewinnego usunięcia „zbędnej” zmiennej ch: char chGetNext(void) { ch = getchar(); return (chRemapChar(getchar())); }
wprowadzimy trudny do wykrycia błąd, gdy chRemapChar będzie makrem wartościującym wielokrotnie swój argument.
Nie usuwaj istniejących fragmentów kodu, jeżeli nie jest to niezbędne dla poprawności projektu.
FUNKCJA Z WOZU, KONIOM LŻEJ Ostrzeżenie przed nieuzasadnionym usuwaniem fragmentów kodu jest szczególnym przypadkiem zasady zabraniającej dokonywania jakichkolwiek modyfikacji (lub dopisywania) kodu bez wyraźnej przyczyny. Jeżeli nawet wydaje Ci się to nieco dziwne, przypomnij sobie, ile razy, spoglądając na jakiś fragment kodu, zadawałeś sobie pytanie „na ile istotny dla całego programu jest ten właśnie fragment?” Zdarza się, iż wiele funkcji tkwiących w kodzie programu jest z punktu widzenia projektu po prostu zbędnych; pojawiły się tam one niejako „dla kompletności” lub na wyraźne życzenie użytkownika albo po prostu dlatego, iż ich odpowiedniki znajdują się w produktach konkurencyjnych. Niewątpliwie tkwi w tym jakiś przejaw dążenia do programistycznej doskonałości, czy chociażby tylko tworzenia lepszych produktów; rodzi się jednak pytanie o znaczenie określenia „lepszy”: lepszy dla produktu, czy tylko technicznie odkrywczy? Bywa, iż te dwa przypadki istotnie idą ze sobą w parze, bywa, że jest inaczej. Nie chciałbym być źle zrozumiany: nie występuję bynajmniej przeciwko inwencji programistów, jestem jednak zdecydowanym przeciwnikiem zbędnego kodu zwiększającego liczbę potencjalnych błędów.
Nie implementuj niepotrzebnych funkcji.
166
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 167
ELASTYCZNOŚĆ RODZI BŁĘDY Jednym ze sposobów na zmniejszenie ryzyka popełniania błędów jest rezygnacja z niepotrzebnej elastyczności projektu. Idea ta, w mniej lub bardziej wyraźnej postaci, towarzyszy nam od początku niniejszej książki — i tak w rozdziale 1. nasz hipotetyczny kompilator ostrzegał nas przed stosowaniem ryzykownych i redundantnych idiomów języka C; w rozdziale 2. wprowadziłem instrukcję ASSERT, gdyż nie stwarza ona ryzyka błędnego użycia w wyrażeniach; w rozdziale 3. zabroniłem przekazywania pustego wskaźnika do funkcji FreeMemory i postawiłem odpowiednią asercję na straży tej reguły — mimo iż użycie pustego wskaźnika byłoby tu całkiem legalne. Podobnemu celowi służyło wyeliminowanie w rozdziale 5. niewątpliwie elastycznej funkcji realloc, na rzecz odrębnych funkcji dokonujących (odpowiednio) alokacji, zmniejszenia, zwiększenia i zwolnienia bloku. Równie ryzykowne, co nadmiernie elastyczne funkcje, są nadmiernie elastyczne ogólne cechy tworzonego kodu. Elastyczność ta prowadzi bowiem do wielu osobliwych sytuacji, całkowicie legalnych z punktu widzenia programu, lecz łatwych do przeoczenia przez programistę testującego kod. Gdy implementowałem obsługę kolorów w Excelu dla Macintosha II, przeniosłem z „windowsowego” Excela kod umożliwiający użytkownikowi określenie koloru tekstu wyświetlanego w danej komórce. W tym celu istniejący kod w rodzaju: $#.##0.00
/* wyświetl 1234.5678 jako $1.234.57 */
należało poprzedzić znacznikiem koloru — poniższa specyfikacja: [blue]$#.##0.00
powoduje wyświetlenie zawartości komórki w kolorze niebieskim. Składnia wydała się tu jednoznacznie określona — znacznik koloru powinien poprzedzać specyfikację formatu — tymczasem gdy przystąpiłem do testowania, stwierdziłem, iż specyfikacje w rodzaju: $#.##0.00[blue] $#.##[blue]0.00 $[blue]#.##0.00
również funkcjonują bezbłędnie — znacznik koloru mógł być umieszczony gdziekolwiek w specyfikacji. Indagowany na tę okoliczność autor wersji oryginalnej (windowsowej) stwierdził, iż „sytuacja taka jest wynikiem określonej konstrukcji pętli analizującej składnię”, i że nie widzi on nic złego w (jakkolwiek by było) dodatkowej elastyczności. Wydawało się, iż dotarcie do wspomnianej pętli skanującej i wymuszenie położenia znacznika koloru na początku specyfikacji nie będzie trudne — faktycznie, okazało się, iż wymaga to jednej dodatkowej instrukcji if. Analizując wraz z kolegą dokładniej cały problem stwierdziliśmy jednak, iż obecna konstrukcja pętli skanującej wymuszona jest innymi okolicznościami, mającymi swe źródło na
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
167
NIEZAWODNOŚĆ OPROGRAMOWANIA
168
wyższym poziomie kodu; „poprawiając” pętlę skanującą usuwalibyśmy więc nie błąd, ale jego objawy. I tak, po dziś dzień, Microsoft Excel umożliwia wpisywanie znacznika koloru w dowolnym miejscu specyfikacji.
Nie implementuj nadmiernej elastyczności.
Przenoszony kod to nowy kod Jednym z wniosków wysnutych przeze mnie z niepowodzeń związanych z przenoszeniem Excela z Windows na Macintosha jest ten, iż nie należy zaniedbywać testowania kodu zaadaptowanego do nowego środowiska. Ufny w to, iż windowsowy kod Excela został już gruntownie przetestowany, skopiowałem go do wersji dla Macintosha, poczyniłem kilka zmian niezbędnych do połączenia go z resztą kodu i sprawdziłem pobieżnie jego funkcjonowanie. I to był podstawowy błąd, bowiem wersja dla Windows sama była jeszcze w fazie rozwoju, a był to ten okres w historii Microsoftu, kiedy testowanie tworzonego kodu konsekwentnie odkładane było na później. Niezależnie więc od tego, jaką metodą implementowane są funkcje danego projektu — czy jest to kod tworzony „od zera”, czy też przenoszony z innych środowisk — zawsze należy go gruntownie przetestować. To, że błędy zaobserwowane na Macintoshu istniały już w wersji Excela dla Windows, nie stanowi żadnego usprawiedliwienia. Lenistwo zdecydowanie się tu nie opłaciło.
SPRÓBUJ Jakże często na rozmaitych grupach dyskusyjnych, na pytania w rodzaju „Nie wiem jak zrealizować...”, spotyka się charakterystyczne odpowiedzi w stylu „Czy próbowałeś...”. Jeden z dyskutantów pyta na przykład „Jak sprawić, by kursor zniknął z ekranu”, na co inny odpowiada mu „Spróbuj ustalić współrzędne kursora poza ekranem”, inny sugeruje „Spróbuj ustawić wysokość kursora na zero”, jeszcze inny podpowiada „Kursor jest po prostu bitmapą — nadaj jej zerową wysokość i zerową szerokość”, itd. Spróbuj, spróbuj, spróbuj... Propozycja taka może być co najwyżej traktowana jako pouczenie, nie zaś jako udokumentowane rozwiązanie. Cóż jednak złego w samym „próbowaniu”? Oczywiście nic, jednakże jego wyniki adekwatne są jedynie dla środowiska, w którym próba jest przeprowadzana i nie ma żadnej gwarancji, iż będą one takie same na innym komputerze, w innej wersji kompilatora, czy nawet — o innej porze dnia. Na tej właśnie zasadzie niektórzy programiści odczytują zawartość zwolnionych bloków pamięci, ponieważ wielokrotne próby utwierdziły ich w przekonaniu, iż zawartość tychże bloków zachowywana jest bezpośrednio po ich zwolnieniu; wynikom swych obserwacji nadają oni tym samym rangę obowiązującej reguły. Nie można jednak budować na tym fundamencie kariery zawodowej — rozwiązania oparte wyłącznie na obserwacjach bazują często na niezdefiniowanych
168
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 169
lub źle zdefiniowanych efektach ubocznych. À propos udokumentowanym sposobem jego ukrycia jest wywołanie:
kursora
—
SetCursorState(INVISIBLE);
Nie „próbuj”; po prostu przeczytaj. Uczestnicy grup dyskusyjnych często pytają o kwestie, które jasno opisane są w rozmaitych podręcznikach. Spośród wielu mniej lub bardziej „uczonych” odpowiedzi najwłaściwsze okazują się te w rodzaju „Zobacz Inside Macintosh, tom IV, strona 32”. Przed przystąpieniem do rozwiązywania problemu należy więc zapoznać się z dokumentacją. Fakt, iż jest to zajęcie trudniejsze i mniej fascynujące niż eksperymentowanie „na żywo”, czy zadawanie pytań innym użytkownikom; można się jednak wówczas dużo dowiedzieć o danym systemie operacyjnym lub języku programowania.
Nie polegaj na doświadczeniach nabytych wyłącznie drogą obserwacji. Poświęć swój czas na znalezienie właściwego rozwiązania.
ŚWIĘTY HARMONOGRAM Pisałem już wcześniej o przewadze testowania tworzonego kodu na bieżąco nad odkładaniem testowania do ukończenia projektu. Niektórzy programiści istotnie wykazują tendencję do wpisywania kolejnych partii kodu bez troszczenia się o jego poprawność, inni z kolei implementują tuziny różnych funkcji bez weryfikowania na bieżąco ich poprawności. Nie byłoby w tym nic złego pod warunkiem, iż cały stworzony kod byłby w końcu gruntownie przetestowany — ale tu właśnie pojawia się pewien problem. Wyobraźmy sobie programistę, który ma pięć dni czasu na zaimplementowanie pięciu funkcji. Ma on do wyboru dwie możliwości: poświęcić każdy dzień na stworzenie i przetestowanie jednej funkcji lub stworzyć wszystkie pięć funkcji od razu i poświęcić ewentualną „końcówkę” piątego dnia na przetestowanie ich wszystkich. Jak myślisz, która z wymienionych opcji zostanie z większym prawdopodobieństwem wybrana przez programistę? I która z nich prawdopodobnie daje lepsze wyniki? Załóżmy, iż programista faktycznie stworzył — bez przetestowania — pięć funkcji w pięciodniowym terminie określonym przez harmonogram. W pewnym momencie uświadamia on sobie, iż na testowanie zabrakło mu już czasu; w porozumieniu z kierownikiem projektu otrzymuje więc dodatkowe dwa dni. Jak myślisz, czy te dwa dni poświęcone zostaną na gruntowne sprawdzenie kodu źródłowego, czy też może tylko na „uwiarygodnienie” jego działania, czyli wyrywkowe przetestowanie metodą „czarnej skrzynki”? Wszystko zależy oczywiście od konkretnego projektu, środowiska programistycznego i oczywiście kwalifikacji i odpowiedzialności programisty, w każdym razie mamy tu do
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
169
170
NIEZAWODNOŚĆ OPROGRAMOWANIA
czynienia z wydłużeniem realizacji projektu i naruszeniem harmonogramu; ów święty, nietykalny harmonogram może w takich przypadkach stać się powodem tego, iż testowanie przeprowadzane jest w niepełnym zakresie. Prawdziwą przyczyną jest jednak nie sam harmonogram, lecz odkładanie testowania na koniec. Odkładanie takie powoduje, iż po napisaniu całego kodu niezwykle trudno jest określić rozsądny termin potrzebny na jego przetestowanie; przy testowaniu na bieżąco ewentualne opóźnienie w stosunku do harmonogramu daje się określić znacznie łatwiej i znacznie łatwiej jest nad nim zapanować. Jeżeli w trakcie wspomnianych pięciu dni programiście uda się zaimplementować i przetestować jedynie trzy funkcje (spośród pięciu założonych) i otrzyma on dwa dodatkowe dni na stworzenie i przetestowanie pozostałych dwóch funkcji, rokowania są znacznie bardziej pomyślne niż w przypadku perspektywy testowania wszystkich pięciu funkcji — testowanie jest bowiem z reguły znacznie trudniejsze i bardziej czasochłonne niż samo tworzenie kodu.
Twórz i testuj swój kod w małych fragmentach. Zawsze testuj swój kod na bieżąco, nawet jeżeli powodować to będzie opóźnienia względem harmonogramu.
„TESTER” — NAZWA W SAM RAZ DLA TESTERA Jak już pisałem w rozdziale 5., jedną z przyczyn, dla których programiści traktują w kategoriach znaku wynik zwracany przez funkcję getchar, jest jej nazwa — jak wiadomo myląca, bowiem funkcja zwracać może również wartości ujemne nie stanowiące kodu znaku, lecz informację diagnostyczną. Podobnie mylące jest określanie programistów weryfikujących zachowanie się gotowego produktu mianem „grupy testującej” (lub po prostu mianem „testerów”), rodzi to bowiem u niektórych programistów nieuzasadnione oczekiwania, iż grupa ta będzie w stanie znaleźć i poprawić wszystkie znajdujące się w kodzie błędy. Aby wykazać bezzasadność takich oczekiwań, zapomnijmy na chwilę o komputerach i przyjrzyjmy się procedurze odbioru zbudowanego właśnie domu. Panuje tu podobny podział pracy, jak przy tworzeniu oprogramowania: robotnicy wykonują swą pracę, zaś inspektorzy weryfikują poprawność jej rezultatów. Inspektorzy nie są jednak w stanie zweryfikować samej pracy robotników, gdyż nie obserwowali jej na bieżąco; nie mogą więc oni wykryć np. faktu, iż niedbały elektryk nie sprawdził poprawności połączeń, nie przeprowadził pomiarów zerowania gniazdek itp.
170
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 171
Niepotrzebny wysiłek? Gdy spogląda się pobieżnie na proces tworzenia oprogramowania, można odnieść wrażenie, iż programiści i testerzy dublują się nawzajem wykonując tę samą pracę — usuwanie błędów tkwiących w aplikacjach. Faktycznie, ostateczny cel pracy obydwu tych grup jest identyczny, inne są jednak drogi dochodzenia do tego celu. Programiści rozpoczynają testowanie swego kodu od sprawdzenia przepływu sterowania i przepływu danych, weryfikując, instrukcja po instrukcji, zachowanie się każdej funkcji. Upewniwszy się co do poprawności każdej z nich z osobna, posuwają się o krok wyżej, weryfikując poprawność współdziałania poszczególnych funkcji w ramach konkretnego podsystemu. Kolejnym krokiem jest testowanie poprawnego działania podsystemów w ramach aplikacji. Programiści testujący aplikacje wykonują więc swą pracę niejako „od środka na zewnątrz”. Elektryk nie może więc zaniedbywać takich testów i zakładać, że „jeżeli pojawią się problemy, inspektorzy na pewno mnie o nich poinformują” (w przeciwnym razie powinien poszukać sobie innego zajęcia). Na tej samej zasadzie testerzy nie są w stanie zweryfikować stworzonego kodu lepiej, niż może to zrobić jego autor. W szczególności, testerzy nie potrafią dodawać do kodu asercji sprawdzających poprawność przepływu danych; nie są w stanie przeprowadzić testowania podsystemów — takiego, jak w przypadku menedżera pamięci w rozdziale 3.; nie są w stanie wykonać programu w sposób krokowy z gwarancją, iż przetestowana zostanie każda z możliwych ścieżek przepływu sterowania. Zadaniem testerów jest raczej wykrywanie usterek w funkcjonowaniu produktu, weryfikacja jego tzw. kompatybilności wstecznej (czyli zgodności z wcześniejszymi wersjami) i ogólne spojrzenie na produkt z perspektywy użytkownika, któremu pewne funkcje mogą wydać się sztuczne, zbyt mało elastyczne itp. Tak więc testerzy nie mają prawa stwierdzić, iż „program jest bezbłędny”; mogą oni co najwyżej zapewnić, iż „program wydaje się działać bezbłędnie” — a to już jest różnica. A może się zdarzyć i tak, iż w danej chwili grupy testującej... po prostu nie będzie, bowiem tworzący ją programiści skierowani zostali do wykonania jakiegoś nie cierpiącego zwłoki projektu. Zdarzyło się tak kiedyś w Microsofcie, może się także zdarzyć gdziekolwiek. Praca grupy testującej rozpoczyna się natomiast na poziomie najwyższym — testowana aplikacja traktowana jest jako „czarna skrzynka” przetwarzająca określone dane na określone wyniki, a weryfikacji podlega poprawność tego „przetwarzania”.
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
171
NIEZAWODNOŚĆ OPROGRAMOWANIA
172
xxxxxxxxxx Przypomina to raczej okrążanie wroga z dwóch stron niż dublowanie pracy. Zastosowanie dwóch metod postępowania w celu wyeliminowania błędów znacznie zwiększa szansę powodzenia w osiągnięciu tego celu. Testerzy sprawdzają także, które zasygnalizowane błędy zostały poprawione, które z „poprawionych” uprzednio błędów wystąpiły ponownie, które błędy przestały się objawiać samoczynnie itp. Na następnym etapie swej pracy schodzą oni o jeden poziom niżej, sprawdzając za pomocą tzw. narzędzi pokrywających (ang. code coverage tools), które fragmenty kodu nie zostały jeszcze przetestowane i budując na tej podstawie nowe testy. Postępowanie takie ma wszelkie cechy postępowania zstępującego — z zewnątrz do środka.
Nie oczekuj, iż testerzy poprawią wszystkie Twoje błędy.
PROGRAMISTA ZAWINIŁ, TESTERA POWIESILI Najczęstszą reakcją programistów na znalezienie przez testerów błędu w ich kodzie jest uczucie ulgi — „jak to dobrze, iż wykryli oni ten błąd przed wypuszczeniem produktu na rynek”. Zdarza się równie często, iż programiści reagują nerwowo na uwagi testerów odnośnie coraz co nowych błędów znajdowanych w kodzie („dlaczego oni nie dadzą mi spokoju?”). W dodatku, jako że testerzy stanowią ostatnie ogniwo w „łańcuchu wytwórczym” aplikacji, to właśnie oni obarczani są najczęściej odpowiedzialnością za opóźnienia w terminie przekazania produktu. Błędy popełniane są jednak przez programistów, nie przez testerów, ci ostatni nie mogą więc być traktowani jak winowajcy; są oni raczej posłańcami niosącymi niepokojące wieści. Zamiast złości, bardziej odpowiednią reakcją programisty na wiadomość tego typu powinno być raczej... niedowierzanie („przetestowałem kod tak dokładnie, iż znalezienie w nim błędu wydawało się wręcz niemożliwe”), a następnie wdzięczność za usunięcie błędu przed skierowaniem aplikacji do rozpowszechniania.
Nie obwiniaj testerów za swe własne błędy.
Nie ma głupich błędów Zdarza się także słyszeć opinię, iż błędy sygnalizowane przez testerów są po prostu śmieszne, i że w ogóle zawracają oni głowę programistom jakimiś głupimi błędami. Tymczasem nie jest sprawą testerów ocena, jak poważny (czy niepoważny) jest
172
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 173
znaleziony błąd — są oni zobowiązani do raportowania wszystkich błędów. Pozornie „głupi” błąd może bowiem powodować całkiem poważne konsekwencje. Nie to jest jednak najważniejsze, czy dany błąd jest poważny, czy nie; istotne jest to, iż uszedł on uwadze programisty. Wykrycie każdego błędu, niezależnie od jego powagi, może ustrzec programistę przed popełnianiem go w przyszłości. Tak więc błąd może być mało poważny; bardzo poważny jest natomiast fakt jego wystąpienia.
ZDEFINIUJ SWE PRIORYTETY Przyglądając się uważnie streszczeniom rozdziałów w punktach Podsumowanie, mogłeś odnieść wrażenie, iż niekiedy przeczą one sobie nawzajem. Cóż, w naszym niedoskonałym świecie tak już bywa, iż osiągnięcie założonego celu jest zazwyczaj sztuką kompromisu, czyli wyboru pomiędzy sprzecznymi możliwościami. Wiedzą o tym doskonale programiści skazani na przykład na wybór pomiędzy algorytmem szybkim, lecz pamięciożernym, a wolnym, lecz zadowalającym się małym rozmiarem pamięci. Mniej oczywiste jest istnienie innego kompromisu programistycznego — mianowicie wyboru pomiędzy algorytmem zgrabnym, lecz ryzykownym, a algorytmem bardziej obszernym, lecz łatwym do weryfikacji. Ponieważ konsekwencje takiego wyboru nie ujawniają się natychmiast — a dopiero po wystąpieniu błędów lub w przypadku rozbudowy projektu — wybór ten nie zawsze jest należycie przemyślany. Aby umiejętnie pogodzić sprzeczne wymagania, należy wpierw określić ich priorytety. Programowanie bez założonych priorytetów jest jak wycieczka bez założonego celu — turysta, znalazłszy się na kolejnym przystanku, zastanawia się „dokąd teraz pójść?”. Gdy decydujemy się na zdefiniowanie priorytetów, musimy znać ich uwarunkowania, a te się zazwyczaj zmieniają. I tak w latach 70., gdy mikrokomputery były niesamowicie wolne, a każdy bajt ich pamięci był skarbem na wagę złota, wymogi czytelności, prostoty itp. w naturalny sposób przegrywać musiały z żywotnymi wymogami jak największej szybkości i efektywnego wykorzystania pamięci. Minęło sporo czasu, mamy dwudziesty pierwszy wiek, niemal tysiąckrotnie (!) szybsze procesory i tysiąckrotnie pojemniejsze pamięci (od tych sprzed trzydziestu lat) i filozofia składania czytelności programu na ołtarzu efektywności zdaje się tracić rację bytu. Szybkie procesory z pojemnymi pamięciami mogą realizować nieefektywne nawet algorytmy w sposób akceptowalny dla użytkownika, postęp technologiczny ma się jednak nijak do kodu niepewnego, najeżonego błędami — a trzeba pamiętać, iż wymagania dzisiejszych użytkowników mikrokomputerów są znacznie wyższe od tych sprzed trzydziestu lat. Oczywiście nie istnieje jedyna, absolutnie dobra, lista priorytetów — w przeciwnym razie bezprzedmiotowa stałaby się cała niniejsza dyskusja — w charakterze przykładu rozpatrzmy więc dwójkę hipotetycznych programistów — Jacka i Jill — przyglądając się osobistemu wyborowi ich priorytetów.
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
173
NIEZAWODNOŚĆ OPROGRAMOWANIA
174
Jack
Jill
poprawność
poprawność
globalna efektywność
łatwość testowania
rozmiar
globalna efektywność
lokalna efektywność
łatwość utrzymania/czytelność
wygoda osobista
spójność kodowania
łatwość utrzymania/czytelność
rozmiar
indywidualizacja
lokalna efektywność
łatwość testowania
indywidualizacja
spójność kodowania
wygoda osobista
Zastanówmy się, jak powyższy wybór priorytetów odzwierciedla się w tworzonym przez nich kodzie. Obydwoje stawiają na pierwszym miejscu poprawność kodu, tu jednak kończy się zgodność między nimi. Dla Jacka najważniejsze są względy „techniczne” — efektywność i rozmiar — podczas gdy względy niezawodności są przez niego traktowane raczej marginalnie. Dla Jill najważniejsze jest tworzenie poprawnego kodu — teraz i w przyszłości, stąd względy czytelności i łatwości utrzymania; efektywność i rozmiar mają dla niej znaczenie o tyle, o ile rzeczywiście są krytyczne dla danego projektu. Umieszczenie łatwości testowania na wysokim (drugim) miejscu jest wyrazem jej przekonania, iż nie sposób w łatwy sposób weryfikować programu, którego nie da się łatwo testować. Wziąwszy pod uwagę przedstawione priorytety — która z wymienionych osób chętniej stosować się będzie do przedstawionych w niniejszej książce zasad niezawodnego programowania, w szczególności: ustawiania opcji kompilatora w sposób zapewniający maksymalny poziom diagnostyki, używania asercji i weryfikowania współpracy podsystemów, śledzenia wykonywanego kodu i weryfikowania na bieżąco szczegółów jego funkcjonowania, konstruowania interfejsów funkcji w sposób jednoznaczny i intuicyjny, nawet za cenę pewnej rozbudowy kodu źródłowego, używania przenośnych typów danych, unikania „ręcznych” optymalizacji o charakterze technologicznym (jak np. zastępowanie dzieleń i mnożeń przesunięciami bitowymi), unikania trików w rodzaju tych opisanych w rozdziale 7.? Kto z nich z większym zainteresowaniem czytywać będzie literaturę traktującą o stylu programowania, wyszukiwaniu błędów, weryfikacji oprogramowania itp.? Kto z większym upodobaniem cyzelować będzie poszczególne linie kodu, by były
174
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 175
one jak najbardziej zwięzłe? Kto przejawiać będzie skłonność do myślenia raczej w kategoriach całego produktu niż poszczególnych instrukcji kodu źródłowego? Jeżeli jesteś programistą, warto, byś sam zadał sobie postawione pytania i sporządził własną listę priorytetów.
Zdefiniuj listę swych priorytetów i trzymaj się jej.
Sam pisałeś Gdy pytam programistów, dlaczego ten fragment kodu napisany został w taki, a nie inny sposób — często dziwaczny — zdarza mi się niekiedy słyszeć „och, nie wiem — może czułem się wtedy gorzej?”. Może tak, może nie; może po prostu człowiek nie miał czasu zjeść śniadania? Nie to jest jednak główną przyczyną, a raczej brak jasno zdefiniowanych priorytetów — bo to właśnie one warunkują wybór takiej, a nie innej konwencji kodowania. Jeżeli więc przyłapiesz się na tym, iż nie za bardzo potrafisz uzasadnić konkretną formę stworzonego przez siebie kodu, warto, byś zweryfikował listę swych priorytetów programistycznych.
PODSUMOWANIE Błędy nie powstają z niczego, nigdy też samoistnie nie znikają; mogą one co najwyżej przestać się objawiać w wyraźny sposób. Zawsze warto zweryfikować zmiany dokonane w kodzie źródłowym, chociaż wymaga to zazwyczaj powrotu do poprzednich jego wersji. Nie odkładaj weryfikacji kodu na później. Wiele projektów nie ujrzało nigdy światła dziennego właśnie z powodu nie kończącej się lawiny błędów w „ukończonym” produkcie. Nie pozwól, by stało się to udziałem również Twojego oprogramowania; staraj się utrzymywać liczbę spodziewanych błędów na możliwie najniższym poziomie. Napotkawszy błąd w kodzie źródłowym zastanów się, czy przypadkiem nie jest on tylko objawem błędu bardziej poważnego. Łatwo jest bowiem usunąć symptomy, nie należy jednak rezygnować z wykrycia prawdziwej przyczyny. Nie wprowadzaj do aplikacji zbędnego kodu ani niepotrzebnych poprawek. Jeżeli Twoi konkurenci wprowadzają efektowne, lecz nikomu niepotrzebne mechanizmy — pozwól im na stratę czasu i nie powtarzaj ich błędów. Pamiętaj, iż elastyczność nie jest tożsama z łatwością użytkowania. Mechanizmy nadmiernie elastyczne — jak funkcja realloc, czy opisane wcześniej „kolorowanie” komórek Excela — są jednocześnie nadmiernie trudne pod względem wyszukiwania błędów.
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
175
176
NIEZAWODNOŚĆ OPROGRAMOWANIA
Unikaj stosowania w swych programach rozwiązań stanowiących jedynie wynik obserwacji. Poszukuj rozwiązań udokumentowanych. Zdefiniuj listę własnych priorytetów programistycznych i porównaj ją z przykładami prezentowanymi w niniejszym rozdziale. PROJEKT: Jeżeli jesteś kierownikiem zespołu programistycznego, przekonaj swych programistów do stworzenia wspólnej dla wszystkich listy priorytetów programistycznych. Jeżeli Twoja firma zatrudnia programistów o zróżnicowanych kwalifikacjach, być może musisz rozważyć stworzenie kilku list, adekwatnych do poszczególnych poziomów kwalifikacji.
176
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 177
E:\TMP\Doc\936a5407-f396-4027-85c1-f40a4656fe16.Doc
177
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 179
EPILOG I tak oto dotarliśmy do końca. Jeżeli w dalszym ciągu dziwisz mi się, iż w ogóle uważam za możliwe tworzenie bezbłędnych programów, to muszę Ci powiedzieć, że wcale tak nie uważam. Nie można tworzyć programów absolutnie bezbłędnych, można jednak znacznie zbliżyć się do tego ideału. Podobnie jak w przypadku malowania mieszkania, można bardzo uważać, by nie pochlapać farbą podłogi, mebli — i mimo wszystko trochę nabrudzić; wystarczy jednak zadać sobie uprzednio trochę trudu i zabezpieczyć meble, okna, dywany itp. folią lub gazetami, a przesadna ostrożność stanie się zbędna. Niniejsza książka nie jest w stanie uchronić Cię przed popełnianiem błędów, bo nie jest w stanie tego uczynić żadna lista reguł i wytycznych. Stosując się jednak do przedstawionych wskazówek, analizując prezentowane przykłady znacznie zwiększysz swe szanse w nierównej walce z przejawami własnej niedoskonałości. Jako że człowiek uczy się na własnych błędach (a przynajmniej powinien to robić), gdy doświadczamy różnorodnych skutków ujawnionego błędu, należy zastanowić się, co zrobić, by nie popełniać tego błędu w przyszłości. Na zakończenie pewna historia, która zdarzyła się naprawdę. Swego czasu, pracując nad automatem przeliczającym Excela, przewijając zawartość pliku, omyłkowo usunąłem jedną linię kodu. Nieświadom tego faktu dołączyłem swój plik do „oficjalnych” źródeł. Gdy złośliwy i trudny do wykrycia błąd dał znać o sobie, znalezienie jego przyczyny (brakującej linii) zajęło trochę czasu. A można by uniknąć całego kłopotu, gdybym zastosował jakikolwiek system kontroli wersji — usunięcie linii zostałoby natychmiast wykryte. Przez pięć lat używania takiego systemu zdarzyło mi się popełnić trzy poważne błędy i pięć mniej ważnych, wymagających niewielkich poprawek w kodzie. Trzy błędy na pięć lat to naprawdę niewiele — i to wszystko za cenę naprawdę niewielkiego wysiłku. Reasumując — nie sposób sprawić, by błędy programistyczne całkowicie przestały się pojawiać, można jednak i trzeba doprowadzić do tego, by czas pomiędzy kolejnymi ich pojawieniami się stawał się coraz dłuższy. Pomocna w tym dziele może okazać się lista kontrolna zamieszczona w dodatku A, zawierająca
E:\TMP\Doc\cb10a349-3b27-4a0d-b3cd-c3ffcb5e1f80.Doc
179
180
NIEZAWODNOŚĆ OPROGRAMOWANIA
najważniejsze zagadnienia dla programisty dążącego do tworzenia niezawodnego oprogramowania. I nie wolno zapominać o najważniejszym:
Nie pozwól, by raz usunięty błąd ponownie dał Ci się we znaki.
180
E:\TMP\Doc\cb10a349-3b27-4a0d-b3cd-c3ffcb5e1f80.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 181
A LISTA KONTROLNA KODOWANIA
Aby krótko przypomnieć najważniejsze zagadnienia niniejszej książki, sporządziłem listę kontrolną zawierającą najważniejsze zagadnienia dotyczące projektu, implementacji, opcji testowych, testowania i poprawiania błędów. Zrezygnowałem przy tym z przypominania rzeczy oczywistych, jak odpowiednie ustawienie opcji kompilatora, utrzymywanie testowej wersji programu (obok jego wersji handlowej), konieczność usuwania pojawiających się błędów na bieżąco itd. Warto przynajmniej pobieżnie przeczytać ją co jakiś czas, szczególnie przed przystąpieniem do rozbudowy lub modyfikacji istniejącego kodu.
PROJEKT Gdy wybiera się określoną koncepcję projektową, nie należy kierować się wyłącznie kryterium efektywności lub rozmiaru kodu. Należy także uwzględnić ryzyko związane z implementowaniem i konserwacją kodu oraz upewnić się, iż kod ten istotnie realizuje założone cele projektowe. Należy wówczas zadać sobie następujące pytania: Czy projekt uwzględnia wszelkie możliwe sposoby zachowania się programu, czy też istnieją w nim elementy losowe lub niezdefiniowane? Czy projekt zawiera elementy nadmiernej elastyczności lub czyni nieuzasadnione założenia? Czy jakiekolwiek dane przekazywane są za pośrednictwem statycznych lub globalnych buforów? Czy jakakolwiek funkcja uzależnia swe poprawne działanie od szczegółów implementacyjnych innych funkcji? Czy każda funkcja wykonuje tylko jedno, ściśle określone zadanie?
E:\TMP\Doc\0a8b63b0-150e-4e57-9c8c-c41d3cb394c9.Doc
181
182
NIEZAWODNOŚĆ OPROGRAMOWANIA
Czy projekt określa sposób postępowania z danymi o specjalnej postaci? Czy kod dokonujący tej specjalnej obsługi jest odizolowany od reszty kodu? Czy każdy z parametrów — i ewentualny wynik — funkcji reprezentuje dobrze określoną informację, czy też łączy użyteczne dane z informacjami diagnostycznymi? Czy sposób zdefiniowania każdej z funkcji właściwie sugeruje poprawny sposób jej użycia? Czy w projekcie istnieją funkcje, które mogą zwracać informację o błędzie zamiast oczekiwanego wyniku? Czy jest możliwe takie ich przedefiniowanie, by zawsze kończyły się bezbłędnie? Pamiętaj, iż każdy przypadek sygnalizacji błędu musi być obsłużony przez funkcję wywołującą. I najważniejsze: czy możliwe jest automatyczne zweryfikowanie projektu za pomocą testowania jednostek? Jeżeli nie, należy wybrać inny projekt.
IMPLEMENTACJA Po zaimplementowaniu projektu należy upewnić się, iż implementacja jest wystarczająco solidna i odporna na błędy. Czy implementacja realizuje projekt sensu stricto, czy tylko jego przybliżenie? Nawet drobne uchybienia pod tym względem niosą ze sobą poważne ryzyko błędów. Przypomnij sobie zachowanie funkcji IntToStr w sytuacji, gdy konwertowana liczba jest najmniejszą liczbą ujemną. Czy przy tworzeniu kodu poczyniono jakieś nieuzasadnione założenia? Czy użyto nieprzenośnych typów danych? Czy implementacja w jakikolwiek sposób zależna jest od konkretnych cech sprzętu, użytego kompilatora, czy systemu operacyjnego? Czy obliczane wyrażenia mogą powodować nadmiar lub niedomiar? Czy używane są jakiekolwiek ryzykowne idiomy języka C? Czy kod zawiera zagnieżdżone operatory ?:? Czy istnieją w kodzie wyrażenia łączące operatory z różnych grup? Czy kod napisany jest w sposób zrozumiały dla programisty o przeciętnych kwalifikacjach? Czy każda z czynności zawartych w projekcie wykonywana jest w ściśle określonym miejscu kodu, czy też istnieje kilka fragmentów wykonujących (w różny sposób) tę samą operację? Czy występują fragmenty dokonujące obsługi „przypadków szczególnych” i czy można je wyeliminować dzięki użyciu innego algorytmu? Czy istnieją instrukcje if, które można wyeliminować? Czy wywoływana jest jakakolwiek funkcja, której wykonywanie może nie zakończyć się pomyślnie? Czy można zmienić projekt w taki sposób, by taki przypadek wyeliminować i uniknąć kłopotliwej obsługi błędu?
182
E:\TMP\Doc\0a8b63b0-150e-4e57-9c8c-c41d3cb394c9.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 183
Czy występują w kodzie odwołania do nieprzydzielonych obszarów pamięci, w szczególności — pamięci zwolnionej? Czy implementacja nie narusza prywatnych danych należących do innych aplikacji? Czy implementacja funkcji określa ograniczony rozmiar buforów wejściowych i wyjściowych? (Funkcja wywołująca mogła ograniczyć ten rozmiar do wartości niezbędnej z punktu widzenia wywołania.)
ELEMENTY TESTOWE W APLIKACJI Wyposażenie kodu aplikacji w asercje (i inny kod diagnostyczny) może przyczynić się do zmniejszenia czasu niezbędnego do znalezienia i poprawienia błędu. Czy funkcja zawiera asercje weryfikujące poprawność jej parametrów? Jeżeli nie jest możliwe zweryfikowanie określonego parametru, z powodu niedostatecznej informacji o nim, czy istnieją inne, dodatkowe środki kontrolujące integralność funkcji? Czy przyjęte w implementacji założenia weryfikowane są za pomocą stosownych asercji? Czy kontrolowane są przypadki nadużywania mechanizmów specyficznych dla określonego środowiska? Programowanie defensywne przyczynia się do łatwiejszego wykrywania wewnętrznych błędów aplikacji — czy w wersji testowej programu istnieją związane z tym asercje? Czy każda z asercji jest samodokumentująca? Jeżeli nie, należy opatrzyć ją stosownymi komentarzami. W przeciwnym razie programiści, nie rozumiejąc zadania spełnianego przez asercję, uznają ją za zbędną i usuną z kodu. Czy przydział i zwalnianie pamięci w wersji testowej połączone jest z wypełnianiem przydzielonych i zwalnianych bloków odpowiednim wzorcem? Wyeliminowanie losowej zawartości pamięci przyczynia się do powtarzalności błędów wynikających z niewłaściwego zarządzania nią. Czy procedury organizacyjne zwalniające bloki pamięci niszczą jednocześnie ich zawartość? Czy wyniki produkowane przez użyty algorytm dają się zweryfikować za pomocą innego algorytmu? Czy w programie istnieją dane, których poprawność można by zweryfikować już przy starcie? Czy program zawiera tablice przeglądowe i czy ich zawartość można w jakiś sposób zweryfikować?
TESTOWANIE Jest sprawą niezmiernie ważną, by programiści gruntownie testowali tworzony przez siebie kod, nawet jeżeli miałoby to powodować opóźnienia w harmonogramie.
E:\TMP\Doc\0a8b63b0-150e-4e57-9c8c-c41d3cb394c9.Doc
183
184
NIEZAWODNOŚĆ OPROGRAMOWANIA
Czy kod kompiluje się bez ostrzeżeń? Jeżeli używasz programu lint lub innego narzędzia diagnostycznego, czy przeprowadziłeś wszystkie dostępne testy? Czy przetestowane zostały wszystkie jednostki kodu? Czy wykonanie kodu prześledzone zostało za pomocą pracy krokowej? Czy zweryfikowana została poprawność przepływu danych? Czy kod został poddany adiustacji lub reformatowaniu? Czy został później przetestowany ponownie? Czy dla nowo stworzonego kodu opracowano testy weryfikujące poprawną współpracę jednostek?
POPRAWIANIE BŁĘDÓW Gdy przystępuje się do wykrycia przyczyny błędu raportowanego przez testerów, należy każdorazowo rozważyć następujące kwestie: Czy raportowany błąd istotnie występuje? Jeżeli nie, należy pamiętać, iż błędy nie znikają samoczynnie, a jedynie przestają się objawiać w sposób powtarzalny wskutek zmian poczynionych w kodzie. Być może błąd został poprawiony przez innego programistę. Podczas analizy kodu źródłowego, należy zawsze używać tej jego wersji, z której korzystają testerzy. Czy dana okoliczność jest przyczyną błędu, czy tylko jego objawem? W jaki sposób mogłem uniknąć popełnienia tego błędu? W jaki sposób mogę go uniknąć w przyszłości? W jaki sposób mógłbym doprowadzić do automatycznego wykrycia tego błędu? Jakie zmiany w testowej wersji produktu należałoby wprowadzić w związku z tym?
184
E:\TMP\Doc\0a8b63b0-150e-4e57-9c8c-c41d3cb394c9.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 185
E:\TMP\Doc\0a8b63b0-150e-4e57-9c8c-c41d3cb394c9.Doc
185
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 187
B PODPROGRAMY ZARZĄDZANIA PAMIĘCIĄ
Kod zamieszczony w niniejszym dodatku stanowi implementację prostego systemu rejestrującego operacje związane z dynamicznym zarządzaniem pamięcią — przydział, zwalnianie i realokację bloków oraz kontrolę odwołań do nich. System zrealizowany został na podstawie jednokierunkowej listy łączonej, której każdy element reprezentuje jeden dynamicznie przydzielony blok wraz z jego podstawowymi atrybutami — adresem początku i rozmiarem. Poszczególne elementy tworzone są za pomocą funkcji malloc i dla prostoty wstawiane na początku listy — kolejność poszczególnych bloków w liście nie jest bowiem istotna. Być może implementacja ta nie jest specjalnie efektywna — bardziej zaawansowana struktura z rodzaju B-drzewa, czy drzewa AVL z pewnością byłaby tu bardziej odpowiednia — jednak podstawowym celem niniejszego dodatku jest przedstawienie podstawowych koncepcji zarządzania pamięcią i efektywność nie jest tu czynnikiem decydującym. block.h #ifdef DEBUG /* -----------------------------------------------------* blockinfo jest strukturą zawierającą informację o przydzielonym * bloku pamięci. Każdy przydzielony blok posiada swą reprezentację * w postaci bloku blockinfo w kronice przydziału pamięci */ typedef struct BLOCKINFO
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
187
NIEZAWODNOŚĆ OPROGRAMOWANIA
188
{ struct BLOCKINFO *pbiNext; byte *pb; /* początek bloku */ size_t size; /* rozmiar bloku */ flag fReferenced /* było odwołanie do bloku? */ } blockinfo; flag fCreateBlockInfo(byte *pbNew, size_t sizeNew); void FreeBlockInfo(byte *pbToTree); void UpdateBlockInfo(byte *pbOld, byte *pbNew, size_t sizeNew); size_t sizeofBlock(byte *pb); void void void flag
ClearMemoryRefs(void); NoteMemoryRef(void *pv); CheckMemoryRefs(void); fValidPointer(void *pv, size_t size);
#endif block.c #ifdef DEBUG /* Funkcje zawarte w niniejszym pliku dokonują porównywania * dowolnych wskaźników. Norma ANSI nie zapewnia przenośności * takiego kodu. * * Poniższe makra izolują samo porównywanie wskaźników. * Zakłada się "płaski" model adresowania pamięci, w stosunku * do którego daje się zastosować zwykłe porównywanie liczbowe — * poniższe definicje są więc nieadekwatne do modelu "segment:offset" * charakterystycznego dla komputerów 80x86. */ #define #define #define #define #define
fPtrLess fPtrGrtr fPtrEqual fPtrLessEq fPtrGrtrEq
(pLeft, (pLeft, (pLeft, (pLeft, (pLeft,
pRight) pRight) pRight) pRight) pRight)
((pLeft) ((pLeft) ((pLeft) ((pLeft) ((pLeft)
< > == =
(pRight)) (pRight)) (pRight)) (pRight)) (pRight))
/*------------------------------------------------------*/ /* *** prywatne dane i funkcje *** */ /*------------------------------------------------------*/ /*-----------------------------------------------------* pbiHead wskazuje na jednokierunkową listę wiązaną * zawierającą informacje diagnostyczne dla menedżera * pamięci */ static blockinfo *pbiHead = NULL; /*----------------------------------------------------* pbigetBlockInfo(pb) * * pbigetBlockInfo przeszukuje kronikę pamięci w celu znalezienia * bloku wskazywanego przez pb oraz związanej z nim struktury * blockinfo w kronice pamięci. * * uwaga:
188
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 189
* pb MUSI wskazywać na przydzielony blok, w przeciwnym razie * nie zostanie spełniona asercja. Niniejsza funkcja kończy się * sukcesem albo załamuje na skutek niespełnionej asercji, nigdy * nie zwracając informacji o błędzie. * * * blockinfo *pb; * … * pbi = pbiGetBlockInfo(pb); * //pbi->pb wskazuje na początek bloku * //pbi->size jest rozmiarem bloku */ static blockinfo *pbigetBlockInfo(byte *pb) { blockinfo *pbi; for (pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext) { byte *pbStart = pbi->pb; byte *pbEnd = pbi->pb + pbi->size – 1; if (fPtrGtrEq(pb, pbStart) && fPtrLessEq(pb, pbEnd)) break; } /* Niemożliwe znalezienie bloku */ ASSERT(pbi != NULL); return (pbi); } /*------------------------------------------------------*/ /* *** funkcje publiczne *** */ /*------------------------------------------------------*/ /* fcreateBlockinfo(pbNew, sizeNew) * * Niniejsza funkcja tworzy element kroniki związany z blokiem pbNew. * Funkcja zwraca TRUE jeżeli operacja się powiedzie i FALSE * w przeciwnym razie * * * if (fCreateBlockInfo(pbNew, sizeNew)) * // udało się, utworzono pozycję kroniki * else * // błąd – nie utworzono pozycji, zwolnij pbNew * */ flag fCreateBlockInfo(byte *pbNew, size_t sizeNew) { blockinfo *pbi; ASSERT(pbNew != NULL && sizeNew != 0); pbi = (blockinfo *)malloc(sizeof(blockinfo));
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
189
NIEZAWODNOŚĆ OPROGRAMOWANIA
190
if (pbi != NULL) { pbi->pb = pbNew; pbi->size = sizeNew; pbi->pbiNext = pbiHead; pbiHead = pbi; } return (flag)(pbi != NULL); } /*-------------------------------------------------------/* FreeBlockInfo(pbToFree) * Niniejsza funkcja zwalnia pozycję kroniki związaną z blokiem * wskazywanym przez pbToFree. pbToFree MUSI wskazywać na * przydzielony blok, w przeciwnym razie nie zostanie spełniona * asercja. */ void FreeBlockInfo(byte *pbToFree); { blockinfo *pbi, *pbiPrev; pbiPrev = NULL; for (pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext); { if (fPtrEqual(pbi->pb, pbToFree)) { if (pbjPrev == NULL) pbiHead = pbi->pbiNext; else pbiPrev->pbiNext = pbi->pbiNext; break; } pbiPrev = pbi; } /* jeśli pbi = NULL, pbToFree nie wskazuje na poprawny blok */ ASSERT(pbi != NULL); memset(pbi, bGarbage, sizeof(blockinfo)); free(pbi); } /* UpdateBlockInfo(pbOld, pbNew, sizeNew); * Niniejsza funkcja poszukuje w kronice bloku identyfikowanego przez * pbOld i uaktualnia informację o bloku, który teraz znajduje się * pod adresem pbNew i ma rozmiar sizeNew. pbOld MUSI wskazywać na * przydzielony blok, w przeciwnym razie nie zostanie spełniona * asercja. */ void UpdateBlockInfo(byte *pbOld, byte *pbNew, size_t sizeNew) { blockinfo *pbi;
190
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 191
ASSERT(pbNew != NULL && sizeNew != 0); pbi = pbiGetBlockInfo(pbOld); ASSERT(pbOld == pbi->pb); pbi->pb = pbNew; pbi->size = sizeNew; } /*-------------------------------------------------------/* sizeOfBlock(pb) * * sizeofBlock zwraca rozmiar bloku wskazywanego przez pb. * pb MUSI wskazywać na przydzielony blok, w przeciwnym razie nie * zostanie spełniona asercja. */ size_t sizeofBlock(byte *pb) { blockinfo *pbi; pbi = pbiGetBlockInfo(pb); ASSERT(pb == pbi->pb); return (pbi->size); } /* /* /* /* /*
----------------------------------------------------------*/ Poniższe funkcje używane są do odnalezienia "wiszących" */ wskaźników i zagubionych bloków pamięci. Dyskusja nad ich */ treścią znajduje się w rozdziale 3. */ ----------------------------------------------------------*/
/* ---------------------------------------------------------* ClearMemoryRefs(void) * * ClearMemoryRefs resetuje wskaźniki odwołania dla każdego * bloku notowanego w kronice * */ void ClearMemoryRefs(void) { blockinfo *pbi; for (pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext); pbi->fReferenced = FALSE } /* ---------------------------------------------------------* NoteMemoryRef(pv) * * NoteMemoryRef rejestruje odwołanie do bloku zawierającego * adres pv. pv nie musi wskazywać na początek bloku, może wskazywać * na którykolwiek jego bajt * */
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
191
NIEZAWODNOŚĆ OPROGRAMOWANIA
192
void NoteMemoryRef(void *pv) { blockinfo *pbi; pbi= pbiGetBlockInfo((byte *)pv); pbi->fReferenced = TRUE; } /* ---------------------------------------------------------* CheckMemoryRefs(void) * Niniejsza funkcja przegląda kronikę w celu znalezienia bloków, * do których nie nastąpiły jeszcze odwołania. W przypadku * znalezienia bloku funkcja sygnalizuje ten fakt za pomocą * stosownej asercji */ void CheckMemoryRefs(void) { blockinfo *pbi; for (pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext) { /* prosty test na poprawność wskazywanego bloku */ ASSERT(pbi->pb != NULL && pbi->size != 0); /* brak rejestracji odwołania do bloku pbi * oznacza wyciek pamięci */ ASSERT(pbi->fReferenced); } } /* ---------------------------------------------------------* fValidPointer(pv, size) * Niniejsza funkcja dokonuje sprawdzenia, czy wskaźnik pv wskazuje * na przydzielony obszar pamięci i czy pomiędzy pv a końcem bloku * znajduje się co najmniej "size" bajtów. Niespełnienie któregoś * z tych warunków powoduje niespełnienie asercji — funkcja nigdy nie * zwraca wartości FALSE. * * Powodem, dla którego funkcja zawsze zwraca wartość TRUE, jest * możliwość wywoływania jej wewnątrz makra ASSERT. */ flag fValidPointer(void *pv, size_t size) { blockinfo *pbi; byte *pb = (byte *)pv; ASSERT(pv != NULL && size != 0) pbi = pbiGetBlockInfo(pb); ASSERT(fPtrLessEq(pb + size, pbi->pb + pbi->size); return (TRUE);
192
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 193
} #endif
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
193
194
194
NIEZAWODNOŚĆ OPROGRAMOWANIA
E:\TMP\Doc\2049a70e-d49b-4235-8c4d-d30f39459006.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 195
C ODPOWIEDZI
ROZDZIAŁ 1. 1. Błąd polega na nieuwzględnieniu reguł pierwszeństwa operatorów — przedmiotowa instrukcja interpretowana jest przez kompilator jako: while (ch = (getchar() != EOF))
Jest to więc próba przypisania wartości do zmiennej ch, którą to próbę kompilator traktuje jako błędne użycie operatora „=” zamiast „==”. 2a. Najprostszym sposobem uchronienia się przed niespodziewanym użyciem liczb ósemkowych jest wprowadzenie opcjonalnego zakazu używania takich liczb za pomocą odpowiedniej opcji kompilatora. 2b. Kompilator mógłby wychwytywać wszystkie przypadki użycia operatorów & oraz | w instrukcji if oraz złożonych porównaniach w sytuacji, gdy brak jest jawnego porównania z wartością 0. Tak więc instrukcja: if (u & 1)
/* czy u jest nieparzyste? */
musiałaby zostać zmieniona na if ((u & 1) != 0)
/* czy u jest nieparzyste? */
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
195
NIEZAWODNOŚĆ OPROGRAMOWANIA
196
2c.
W przypadku poszukiwania niezamierzonych początków komentarza podejrzany jest każdy komentarz rozpoczynający się od znaku alfabetycznego albo nawiasu otwierającego. Jeśli nakażesz kompilatorowi wychwytywać wszystkie takie przypadki, łatwo pozbędziesz się ostrzeżeń z jego strony, wprowadzając co najmniej jedną spację pomiędzy gwiazdką w ograniczniku a wspomnianym alfabetykiem lub nawiasem: /*Ten komentarz powodować będzie generowanie ostrzeżenia */ /* Ten nie, ze względu na spację rozdzielającą */ /*-----Ten komentarz także nie wzbudzi zastrzeżeń-------*/
Należy ponadto eliminować z kodu wątpliwe przypadki i jasno wyrażać swe intencje zapisem: quot = numer/ *pdenom
albo quot = numer/(*pdenom)
zamiast quot = numer/*pdenom
2d. Kompilator mógłby na przykład kwestionować wszelkie przypadki łączenia operatorów bitowych z arytmetycznymi, jeżeli brak jest nawiasów wyznaczających explicite kolejność obliczeń. Instrukcja: word = bHigh mask != 0; pid++) { /* upewnij się, że wzorzec jest zgodny z maską */ ASSERT((pid->pat & pid->mask) == pid->pat); . . .
Należy zweryfikować, za pomocą odpowiednich asercji, poprawność ustawień związanych z instrukcją reprezentowaną przez inst: instruction *pcDecodeEOR(instruction inst, instruction *pc, opcode *popc) { /* czy przypadkowo nie jest to instrukcja CMPM lub CMPA.L ? */ ASSERT(eamode(inst) != 1 && mode(inst) != 3); /* jeśli tryb nierejestrowy, zezwolenie tylko na tryb * abs word i abs long */ ASSERT(eamode(inst) != 7 || eareg(inst) == 0 || eareg(inst) == 1)); . . .
Generalnie sposobem sprawdzenia poprawności jednego algorytmu jest użycie innego algorytmu — do danych oryginalnych i(lub) do danych wyprodukowanych przez pierwszy algorytm. I tak poprawność sortowania szybkiego można zweryfikować, przez kontrolę uporządkowania danych wynikowych — sposób owej „kontroli” zdecydowanie różni się od sortowania, mamy więc istotnie do czynienia z zupełnie innym algorytmem. Sortowanie binarne można zweryfikować za pomocą prostego szukania liniowego — należy
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
201
NIEZAWODNOŚĆ OPROGRAMOWANIA
202
sprawdzić, czy obydwa algorytmy dają identyczny wynik. W przypadku funkcji itoa dokonującej konwersji liczby całkowitej na jej reprezentację znakową, należy otrzymany łańcuch znaków przekształcić z powrotem na postać binarną i porównać wynik z wartością oryginalną.
ROZDZIAŁ 3. 1. Przez zastosowanie dwóch różnych wzorców wypełniania — innego dla pamięci nowo przydzielonej i innego dla pamięci właśnie zwalnianej: #define bNewGarbage 0xA3 #define bFreeGarbage 0xA5
Funkcja fResizeMemory może dokonywać zarówno przydziału, jak i zwalniania pamięci, stosowane więc będą obydwa powyższe wzorce — chyba, żeby zdefiniować na tę okazję jeszcze dwa inne. 2. Jednym ze sposobów jest periodyczne testowanie bajtów następujących bezpośrednio za przydzielonym blokiem w celu upewnienia się, iż ich zawartość nie uległa zmianie. Należy w tym celu zwiększyć rozmiar każdego przydzielanego bloku o wielkość owego obszaru testowego i ustalić wypełniający go wzorzec. I tak na przykład przy ustaleniu wielkości obszaru testowego na 1 bajt i zapotrzebowaniu na blok o rozmiarze 36 bajtów, należy w rzeczywistości przydzielić 37 bajtów i ostatni bajt wypełnić żądanym wzorcem; podobnie należy postąpić w przypadku realokacji bloku. Doskonałą okazją do wykonywania wspomnianego testu na niezmienność dodatkowego obszaru są wywołania funkcji sizeofBlock, fValid Pointer, FreeBlockInfo, NoteMemoryRef i CheckMemoryRefs — fakt przekroczenia legalnych granic pamięci można by wykrywać za pomocą stosownych asercji. Oto jeden z przykładów implementacji przedstawionej idei. Wartość sizeofDebugByte określa wielkość obszaru testowego (tu: jeden bajt), natomiast wartość bDebugByte — wypełniający go wzorzec. Przedefiniowaniu podlegają funkcje fNewMemory i fResizeMemory: #define bDebugByte 0xE1 /* obszar testowy istnieje tylko w wersji testowej programu */ #ifdef debug #define sizeofDebugByte 1 #else #define sizeofDebugByte 0 #endif . . .
flag fNewMemory(void **ppv, size_t size) {
202
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 203
byte **ppb = (byte **)ppv; ASSERT(ppv != NULL
&&
size
!= 0);
*ppb = (byte *)malloc(size + sizeofDebugByte); #ifdef DEBUG { if (*ppb != NULL) { *(*ppb + size) = bDebugByte; memset(*ppb, bGarbage, size); . . .
flag fResizeMemory(void **ppv, size_t sizeNew) { byte **ppb = (byte **)ppv; byte *pbNew; . . .
pbNew = (byte *)realloc(*ppb, sizeNew = sizeofDebugByte); if (pbNew != NULL) { #ifdef DEBUG { *(pbNew + sizeNew) = bDebugByte; UpdateBlockInfo(*ppb, pbNew, sizeNew); . . .
Wspomniana asercja testująca niezmienność obszaru testowego mogłaby mieć następującą postać: ASSERT(*(pbi->pb + pbi->size) == dDebugByte);
Pełna
implementacja funkcji sizeofBlock, fValidPointer, FreeBlockInfo, NoteMemoryRef i CheckMemoryRefs znajduje się w dodatku B. 3. Jednym ze sposobów zapobiegania błędom tego typu jest uruchomienie dodatkowego mechanizmu gospodarowania pamięcią. Polega on na niezwalnianiu wykorzystanych bloków, lecz składowaniu ich w puli bloków zwolnionych. Bloki te pozostają przydzielone z punktu widzenia systemu operacyjnego, lecz nie są wykorzystywane przez aplikację, która w razie zapotrzebowania na pamięć pobiera ją „normalnie” od systemu. Pula bloków zwolnionych utrzymywana jest tak długo, aż możliwe stanie się zweryfikowanie poprawności całej gospodarki pamięcią za pomocą wywołania funkcji CheckMemoryRefs. Po przeprowadzeniu weryfikacji funkcja
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
203
NIEZAWODNOŚĆ OPROGRAMOWANIA
204
CheckMemoryRefs zwraca systemowi wszystkie bloki znajdujące się w puli
bloków zwolnionych. Opisany scenariusz wymaga modyfikacji funkcji CheckMemoryRefs i FreeMemory i powinien być stosowany jedynie wtedy, gdy faktycznie w aplikacji występują tego rodzaju problemy z niewyzerowanymi wskaźnikami. Opisane rozwiązanie łamie bowiem zasadę, zgodnie z którą wersja testowa programu powinna różnić się od wersji handlowej jedynie obecnością dodatkowego kodu, nie zaś modyfikacjami w wykonywanym kodzie. 4. Należy rozróżnić dwa rodzaje wskaźników: wskaźniki do całych bloków i wskaźniki do subalokacji wewnątrz bloku. Dla wskaźników wskazujących na kompletne bloki najbardziej wiarygodny test, jaki można przeprowadzić, to sprawdzenia, czy istotnie wskaźnik wskazuje na początek bloku i czy jego rozmiar zgodny jest z wartością zwracaną przez sizeofBlock. Dla subalokacji możliwe jest jedynie sprawdzenie, czy wskazywany obszar znajduje się wewnątrz jakiegoś bloku i czy jego rozmiar nie wykracza poza górną granicę tegoż bloku. Tak więc zamiast pojedynczej funkcji NoteMemoryRef rejestrującej odwołania do obszarów pamięci w ogólności, należałoby użyć dwóch funkcji, przeznaczonych (odpowiednio) dla całych bloków i dla subalokacji: /* ---------------------------------------------------------* NoteMemoryRef(pv, size) * * NoteMemoryRef rejestruje odwołanie do obszaru pamięci * o rozmiarze size identyfikowanego przez wskaźnik pv. * pv nie musi wskazywać na początek bloku, może wskazywać * na obszar stanowiący subalokację wewnątrz bloku. * * Dla zarejestrowania odwołania do całego bloku należy * wykorzystać funkcję NoteMemoryBlock */ void NoteMemoryRef(void *pv, size_t size); . . . /* ---------------------------------------------------------* NoteMemoryBlock(pv, size) * * NoteMemoryBlock rejestruje odwołanie do kompletnego bloku * pamięci identyfikowanego przez wskaźnik pv i posiadającego * długość size. * pv musi wskazywać na początek bloku * */ void NoteMemoryRef(void *pv, size_t size) . . .
204
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 205
5. Należy zastąpić flagę fReferenced w strukturze blockinfo licznikiem nReferenced i zamiast rejestrować odwołania, zliczać je. Z weryfikacją integralności odwołań, dokonywaną przez funkcję CheckMemoryRefs, sprawa jest nieco trudniejsza, należy bowiem odróżnić legalne wielokrotne odwołania od odwołań niedozwolonych. Jedno z możliwych rozwiązań polega na wprowadzeniu do struktury blockinfo znacznika (tag) określającego bliżej charakter bloku. Procedura CheckMemoryRefs dokonywałaby konfrontacji tego znacznika z wartością licznika odwołań i rozstrzygała o jej legalności. Poniżej prezentujemy zarys implementacji tej idei; w dodatku B znajdują się szczegółowe komentarze wyjaśniające znaczenie poszczególnych parametrów odnośnych funkcji. /* blocktag jest typem uwzględniającym wszelkie możliwe typy bloków * w kontekście dozwolonych wartości licznika odwołań. * Funkcja ClearMemoryRefs ustawia tę wartość na tagNone dla każdego * bloku; funkcja NoteMemoryRef ustawia ten wskaźnik zgodnie * z drugim parametrem wywołania */ typedef enum { tagNone, tagSymName, tagSymList, tagListNode, /* węzeł listy musi mieć licznik odwołań * ustawiony dokładnie na 2 */ . . . } blocktag; void ClearMemoryRefs(void) { blockinfo *pbi; for (pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext); pbi->nReferenced = 0 pbi->tag = tagNone } void NoteMemoryRef(void *pv) { blockinfo *pbi; pbi= pbiGetBlockInfo((byte *)pv); pbi->nReferenced++; /* nie można zmieniać typu bloku */ ASSERT(pbi->tag == tagNone || pbi->tag == tag);
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
205
NIEZAWODNOŚĆ OPROGRAMOWANIA
206
pbi->tag = tag } void CheckMemoryRefs(void) { blockinfo *pbi; for (pbi = pbiHead; pbi != NULL; pbi = pbi->pbiNext) { /* prosty test na poprawność wskazywanego bloku */ ASSERT(pbi->pb != NULL && pbi->size != 0); /* test na wyciek pamięci. Jeśli licznik odwołań do bloku * jest równy zero, aplikacja prawdopodobnie utraciła * łączność z nim. Kryterium legalności wartości * wskazywanej przez licznik odwołań jest specyficzne * dla konkretnego typu bloku */ switch (pbi->tag) { default: /* dla większości bloków legalne jest tylko * pojedyncze odwołanie */ ASSERT(pbi->nReferenced == 1); break; case tagListNode: ASSERT(pbi->nReferenced == 2); break; . . . } } }
6. W środowisku MS-DOS, Windows i na Macintoshu programiści symulują sytuację niedostatku pamięci przez uruchomienie, równolegle z testowaną aplikacją, jakiegoś programu, którego głównym zadaniem jest „pożeranie” pamięci. Technika ta nie jest jednak specjalnie użyteczna, jeżeli testowaniu podlega wybrana cecha aplikacji. Lepszym rozwiązaniem jest wówczas wbudowanie symulacji niedostatku pamięci bezpośrednio w menedżer pamięci. Oprócz błędów pamięci aplikacja narażona jest jednak na wiele innych błędów — błędy dyskowe, brak papieru w drukarce, zajęta linia telefoniczna, itp. Tak naprawdę potrzebne więc jest coś na kształt „uogólnionego” symulatora błędów. Symulator taki mógłby być reprezentowany przez strukturę failureinfo określającą szczegóły pozorowanego błędu; samo
206
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 207
symulowanie błędnej sytuacji powierzone zostałoby specjalnej funkcji fFakeFailure. I tak na przykład funkcje fNewMemory i fResizeMemory wyposażone w taką możliwość prezentowałyby się następująco: flag fNewMemory(void **ppv, size_t size) { byte **ppb = (byte **)ppv; #ifdef DEBUG if fFakeFailure(&fiMemory)) { *ppb = NULL; return (FALSE); } #endif . . . } flag fResizeMemory(void **ppv, size_t sizeNew) { byte **ppb = (byte **)ppv; byte *pbNew; #ifdef DEBUG if fFakeFailure(&fiMemory)) { return (FALSE); } #endif . . . }
fiMemory jest tutaj globalną strukturą typu failureinfo, za pośrednictwem
której tester prowadzący testowanie jednostek (unit testing) określa (mówiąc skrótowo) scenariusz symulowania błędów. Najprostszy z takich scenariuszy mógłby zakładać pewną liczbę poprawnych wywołań, po których następowałaby pewna liczba wywołań kończących się (symulowanym błędem); funkcja ustalająca parametry scenariusza mogłaby być wywołana na przykład w taki sposób: SetFailures(&fiMemory, 5, 7);
co oczywiście oznaczałoby, iż w pięciu najbliższych wywołaniach funkcja fFakeFailure zwróci wartość FALSE (czyli testowany kod pozostawiony zostanie własnemu losowi), natomiast siedem następnych zakończy się zwróceniem wartości TRUE. Wywołanie:
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
207
NIEZAWODNOŚĆ OPROGRAMOWANIA
208
SetFailures(&fiMemory, 0, UINT_MAX);
oznacza przy tych założeniach permanentne symulowanie błędów, zaś wywołanie: SetFailures(&fiMemory, UINT_MAX, 0);
powoduje całkowite pozostawienie testowanego kodu własnemu losowi. Mechanizm symulowania błędów mógłby też być całkowicie dezaktywowany na pewien czas, kiedy testowany program wykonuje fragmenty kodu nie sprawiające żadnych kłopotów; do selektywnego aktywowania i dezaktywowania służyłyby funkcje DisableFailures i EnableFailures: DisableFailures(&fiMemory); ... /* bezproblemowy fragment kodu */ EnableFailures(&fiMemory);
Poniżej przedstawiamy przykładową implementację czterech wspomnianych funkcji: typedef struct { unsigned nSucceed; unsigned nFail; unsigned nTries; int lock; } failureinfo;
/* /* /* /*
liczba bezbłędnych wywołań liczba wywołań z symulowanym błędem liczba dotychczasowych wywołań gdy dodatnie, symulator jest wyłączony
*/ */ */ */
void SetFailures(failureinfo *pfi, unsigned nSucceed, unsigned nFail); { /* jeśli nFail == 0, wymagane jest nSucceed = UINT_MAX */ ASSERT(nFail != 0 || nSucceed == UINT_MAX); pfi->nSucceed pfi->nFail pfi->nTries pfi->lock
= = = =
nSucceed; nFail; 0; 0;
} void EnableFailures(failureinfo *pfi) { ASSERT(pfi->lock > 0); pfi->lock--; } void DisableFailures(failureinfo *pfi) { ASSERT(pfi->lock >= 0 && pfi->lock < INT_MAX); pfi->lock++; } flag fFakeFailure(failureinfo *pfi) { ASSERT(pfi != NULL);
208
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 209
if (pfi->lock > 0) return(FALSE); /* uaktualnij licznik wywołań */ if (pfi->nTries != UINT_MAX) pfi->nTries++; if (pfi->nTries nSucceed) return (FALSE); if (pfi->nTries - pfi->nSucceed nFail) return (TRUE); return(FALSE); }
ROZDZIAŁ 5. 1. Funkcja strdup cierpi na tę samą przypadłość, co funkcja malloc — błąd sygnalizowany jest przez zwrócenie wartości NULL zamiast poprawnego wyniku. Wartość ta może być omyłkowo potraktowana na równi z użytecznym wynikiem, jeżeli użytkownik nie uwzględni jej wystąpienia. Lekarstwem na tę przypadłość jest oddzielenie informacji o błędzie od adresu nowo utworzonego łańcucha: char strDup
/* wskaźnik do kopiowanego łańcucha */
if (fStrDup(&strDup, str ToCopy)) // udało się, strDup wskazuje na nowy łańcuch else // nie udało się, strDup ma wartość NULL
2. Funkcja zwracająca znak z wejścia standardowego będzie jeszcze bardziej intuicyjna od funkcji fGetChar, jeżeli zamiast „dualnej” wartości TRUE – FALSE zwracać będzie numeryczny kod błędu. Oto przykład: /* kody przykładowych błędów, które sygnalizować może funkcja errGetChar */ typedef enum { errNone = 0, errEOF, errBadRead, . . .
} error;
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
209
210
NIEZAWODNOŚĆ OPROGRAMOWANIA
void ReadSomeStuff(void) { char ch; error err; if ((err = errGetChar(&ch)) == errNone) // udało się, ch zawiera kolejny znak z wejścia else // nie udało się, err zawiera kod błędu . . .
W ten oto sposób zamiast kategorycznej informacji „dobrze – źle”, otrzymujemy szczegółową informację o statusie operacji. Jeżeli mimo wszystko tak szczegółowy poziom informacji nie jest w danej sytuacji potrzebny, można zadowolić się jedynie testem na okoliczność zgodności kodu tego statusu z wartością errNone: if (errGetChar(&ch) == errNone) // udało się, ch zawiera kolejny znak z wejścia else // nie udało się, brak dokładniejszych informacji o błędzie . . .
3. Problem z wykorzystaniem funkcji strncpy sprowadza się do niespójności jej zachowania. Zwracany przez nią łańcuch niekiedy zakończony jest zerowym ogranicznikiem, niekiedy nie. Ponieważ funkcja strncpy wymieniana jest często w towarzystwie funkcji ogólnego przeznaczenia (general purpose), niektórzy użytkownicy również mogą uważać ją za funkcję tej kategorii. Jest to opinia błędna — jedynym powodem, dla którego funkcja ta w ogóle brana jest pod uwagę, jest jej powszechne używanie stanowiące spadek po implementacjach C poprzedzających normę ANSI. 4. Funkcje wstawialne, będące równie efektywnymi w użyciu jak makra (pod warunkiem używania dobrego kompilatora), wolne są jednocześnie od efektów ubocznych związanych z wartościowaniem parametrów wywołania. 5. Niebezpieczeństwem związanym z parametrem przekazywanym przez referencję jest fakt, iż programista wywołujący funkcję posiadającą taki parametr może nie zdawać sobie sprawy z możliwości zmiany tego parametru przez ciało funkcji. W poniższej konstrukcji: if (fResizeMemory(pb, sizeNew)) // udało się, pb wskazuje na nowy blok
przez referencję przekazywany jest parametr pb. Programista przyzwyczajony do tego, iż w „tradycyjnym” C wszystkie parametry przekazywane były przez wartość, może nawet nie domyślać się, iż wartość wskaźnika pb mogła ulec zmianie.
210
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 211
Wprowadzenie operatora referencji parametru wymaga również większej dyscypliny od programistów tworzących funkcje. W „tradycyjnym” C wszystkie parametry przekazywane były przez wartość, więc programiści, wolni od podobnych obaw, mogli w nieskrępowany sposób wykorzystywać parametry funkcji w charakterze zmiennych roboczych; w przypadku przekazania parametru przez referencję wszelkie jego zmiany odzwierciedlają się w parametrze aktualnym. 6. Z punktu widzenia użytkownika wywołującego funkcję strcmp konwencja zwracania wyniku porównania jest mało czytelna. Przede wszystkim nie można określić konkretnej wartości zwracanej przez funkcję w przypadku nierówności łańcuchów; usunięcie tej niedogodności pozwoliłoby zdefiniować synonimy STR_LESS, STR_GREATER i STR_ EQUAL oznaczające (kolejno) pierwszeństwo (alfabetyczne) pierwszego łańcucha, pierwszeństwo drugiego łańcucha i równość łańcuchów. W sytuacji, gdy nie badamy relacji pomiędzy łańcuchami, lecz testujemy ko n kr e t ną relację (np. równość), użyteczne może okazać się zaimplementowanie trzech odrębnych funkcji porównujących fStrLess, fStrGreater i fStrEqual: if (fStrLess(strLeft, strRight)) if (fStrGreater(strLeft, strRight)) if (fStrEqual(strLeft, strRight))
Rozwiązanie to ma tę zaletę, iż wspomniane funkcje mogą być zdefiniowane jako makra stanowiące otoczki funkcji strcmp: #define fStrLess(strLeft, strRight) (strcmp(strLeft, strRight) < 0)
\
#define fStrGreater(strLeft, strRight) (strcmp(strLeft, strRight) > 0) #define fStrEqual(strLeft, strRight) (strcmp(strLeft, strRight) = 0)
\ \
Ideę tę można rozwinąć przez zdefiniowanie funkcji fStrLessOrEqual, fStrGreaterOrEqual i fStrNotEqual odpowiadających operatorom = i !=. Zyskujemy w ten sposób na czytelności bez jakiejkolwiek straty rozmiaru i efektywności kodu.
ROZDZIAŁ 6. 1. Przenośny zakres jednobitowego pola to 0 – 0, co nie jest specjalnie użyteczne. Kłopot bowiem w tym, iż pojedynczy bit „1” może być — zależnie od implementacji — traktowany jako „liczba ze znakiem” i wówczas ma on wartość –1, bądź też jako „liczba bez znaku” i wówczas traktowany jest jako 1. Jeżeli więc chcemy potraktować tę sytuację „w sposób przenośny”, to można to uczynić jedynie w kategoriach „zerowy — niezerowy” i ograniczyć
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
211
NIEZAWODNOŚĆ OPROGRAMOWANIA
212
wszelkie porównania wyłącznie do porównań z wartością 0. Poniższe konstrukcje są więc konstrukcjami przenośnymi: if (psw.carry == 0) if (psw.carry != 0) if (!psw.carry) if (psw.carry)
Rezultat poniższych porównań jest natomiast zależny od konkretnej implementacji: if (psw.carry == 1) if (psw.carry != 1) if (psw.carry == -1) if (psw.carry != -1)
2. Funkcje zwracające wartość boolowską podobne są w pewnym sensie do pola jednobitowego — nie można bowiem określić, czym w danej implementacji jest wartość TRUE. Można bezpiecznie przyjąć, iż wewnętrzną reprezentacją wartości FALSE jest zero. Jeśli chodzi o wartość TRUE, to w roli tej może wystąpić każda niezerowa wartość, niekoniecznie równa (pod względem reprezentacji) wartości TRUE w danej implementacji. Podobnie więc jak w przypadku pola bitowego, przenośność funkcji zwracającej wynik boolowski można postrzegać jedynie w kategoriach „FALSE–inny niż FALSE” — poniższe testy są więc konstrukcjami przenośnymi: if (fNewMemory(…) == FALSE) if (fNewMemory(…) != FALSE) if (!fNewMemory(…))
/* zalecane */
if (fNewMemory(…))
/* zalecane */
Wynik poniższego testu jest jednak zależny od implementacji: if (fNewMemory(…) == TRUE)
/* ryzykowne */
Stąd wniosek, iż należy unikać porównywania wartości boolowskich z wartością TRUE. 3. Uczynienie korzenia drzewa strukturą globalną ma tę niedogodność, iż nadaje mu specyficzny status, wymagający specjalnego traktowania. Spójrzmy na przykład na poniższą funkcję, zwalniającą poddrzewo o wskazanym korzeniu: void FreeWindowTree(window *pwndRoot) { if (pwndRoot != NULL) {
212
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 213
window *pwnd, *pwndNext; ASSERT(fValidWindow(pwndRoot)); for (pwnd = pwndRoot->pwndChild; pwnd != NULL; pwnd = pwndNext) { /* przechowaj wskaźnik przed zwolnieniem bloku pamięci */ pwndNext = pwnd->pwndSibling; FreeWindowTree(pwnd); } if (pwndRoot->strWndTitle != NULL) FreeMemory(pwndRoot->strWndTitle); FreeMemory(pwndRoot); } }
Powyższa funkcja, po zwolnieniu całego poddrzewa, zwalnia w końcu jego korzeń — i tu pojawia się problem: korzeń całej struktury, jako statyczny, nie może zostać zwolniony, nie jest więc możliwe zwolnienie całej struktury okien za pomocą pojedynczego wywołania FreeWindowTree(&wndDisplay)
(wndDisplay jest nazwą przedmiotowego korzenia). Aby uczynić funkcję FreeWindowTree uniwersalną, należałoby jej ostatnią instrukcję zmienić następująco: if (pwndRoot != &wndDisplay) FreeMemory(pwndRoot);
4. Zgodnie z treścią rozdziału 6., jest to postępowanie zdecydowanie napiętnowane z punktu widzenia niezawodnego programowania — oznacza bowiem konieczność uwzględnienia „specjalnych” wartości argumentu. Druga wersja kodu jest o tyle ryzykowna, iż zawiera p o wi elo ne fragmenty kodu: wyrażenie, A i D. W pierwszej wersji fragmenty te wykonywane są niezależnie od wartości f; w drugiej wersji A i D muszą być testowane oddzielnie, co (z wyjątkiem przypadku, gdy są one identyczne) zwiększa prawdopodobieństwo popełnienia błędu. Ponadto, mimo iż z punktu widzenia kodu źródłowego fragmenty A i D stanowią powielone ciągi instrukcji, to jednak inaczej może to wyglądać z punktu widzenia kompilatora tłumaczącego i optymalizującego ciągi ABD i ACD jako całość. I d e nt yc z ne fragmenty A i D są wówczas testowane w r ó żn yc h warunkach, co w praktyce oznacza podwójną robotę. 5. Druga wersja stwarza także problemy z utrzymaniem i rozbudową kodu, pod względem znajdowania i usuwania błędów — zmiany w zakresie fragmentów A i D muszą być wprowadzane do kodu źródłowego dwukrotnie. Wynika stąd
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
213
NIEZAWODNOŚĆ OPROGRAMOWANIA
214
kolejna wskazówka dotycząca niezawodnego programowania: minimalizuj różnice przez maksymalizowanie fragmentów wspólnego kodu. „Pomylenie” dwóch podobnie brzmiących nazw nie jest zazwyczaj wychwytywane przez kompilator, jeżeli opatrywane tymi nazwami obiekty są tego samego rodzaju. Poniższy fragment: int strcmp(const char *s1, const char *s2) { for ( ; *s1 == *s2; s1++, s2++) { if (*s1 == '\0') /* koniec? */ retunr (0); } return ((*(unsigned char *)s2 < *(unsigned char *)s1) ? –1 : 1); }
jest błędny, ponieważ w ostatniej instrukcji return nazwy s1 i s2 zamienione są miejscami, jednak ze względu na ich podobieństwo trudno jest ten fakt zauważyć. Ich zamiana na jakieś bardziej znaczące odpowiedniki — na przykład strLeft i strRight — uczyniłaby kod bardziej czytelnym i zmniejszyła prawdopodobieństwo opisanej pomyłki. 6. Standard ANSI gwarantuje adresowalność bajtu następującego bezpośrednio po nazwanej tablicy, nie daje jednak analogicznej gwarancji dotyczącej bajtu p o p r zed zaj ą ce go tablicę. Nie można więc zapewnić adresowalności bajtu poprzedzającego blok pamięci przydzielony przez funkcję malloc. I tak na przykład procesory serii 8086 adresują pamięć dwuczłonowo, w postaci segment:przesunięcie, gdzie przesunięcie jest dwu- lub czterobajtową liczbą całkowitą bez znaku. Inkrementacja i dekrementacja adresu sprowadza się do inkrementacji (dekrementacji) przesunięcia — jeżeli więc funkcja malloc przydzieli blok na granicy segmentu (czyli z przesunięciem równym 0), to adres „poprzedzającego” ten blok bajtu będzie miał postać segment:0FFFF (względnie segment: 0FFFFFFFF). „Dekrementacja” wskaźnika (pch) wskazującego na początek bloku (pchStart) nie będzie więc w istocie dekrementacją, lecz gigantyczną inkrementacją i pch nigdy nie będzie mniejsze od pchStart. Błąd ten jest o tyle zdradliwy, iż nie wystąpi w przypadku, gdy przydzielony blok ma niezerowe przesunięcie. 7a. printf(str) i printf("%s",str) dają różne wyniki w sytuacji, gdy str zawiera znaki %, zostają one bowiem potraktowane jako specyfikatory
formatu. 7b. Użycie f = 1–f zamiast f = !f zakłada milcząco, iż f może przyjmować wyłącznie wartości 0 albo 1 — tylko wówczas nastąpi „przełączenie” boolowskiej wartości f. Instrukcja f = !f zapewnia przełączenie przy dowolnej wartości. 7c. Podstawowe ryzyko wynikające z wielokrotnych przypisań wiąże się z nieoczekiwanymi konwersjami danych. W poniższej instrukcji: ch = *str++ = getchar();
214
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 215
nawet jeżeli programista przezornie zadeklaruje ch jako int, to i tak wynik zwracany przez getchar przypisany będzie najpierw łańcuchowi, a to oznacza konwersję tego wyniku na typ char. Jeżeli więc funkcja getchar zwróci wynik EOF, nie zostanie on przypisany poprawnie zmiennej ch. Notabene to jeszcze jeden argument przeciwko funkcjom łączącym użyteczną informację z informacjami diagnostycznymi. 8. Generalnie tablice przeglądowe przyczyniają się do zmniejszenia objętości kodu i poprawy jego efektywności, wprowadzają też do kodu pewną tendencję „unifikacyjną” — każda wielkość, niezależnie od swego charakteru, może być traktowana jednolicie jako „element tablicy”. Używanie tablic przeglądowych wymaga jednak dodatkowej pamięci na same tablice, ponadto duże i złożone tablice przeglądowe stanowią doskonałą okazję do popełniania błędów — dotyczy to nie tyle pięcioelementowej tablicy używanej przez funkcję uCycleCheckBox, lecz raczej tablicy sterującej pracą deasemblera z rozdziału 2. Używanie tablic przeglądowych jest więc bezpieczne tylko wówczas, gdy istnieje możliwość weryfikacji zawartych w nich danych. 9. Jeżeli twórcy konkretnego kompilatora nie zaimplementowali w nim optymalizowania mnożeń i dzieleń za pomocą przesunięć bitowych, to prawdopodobnie zabieg taki byłby niecelowy z punktu widzenia danej platformy sprzętowej. A jeżeli tak, to „ręczne” optymalizowanie kodu w ten sposób niczego w nim nie poprawi, a może wprowadzić doń trudne do wykrycia błędy. Jeżeli podejrzewasz, iż brak automatycznej optymalizacji jest wynikiem kiepskiej jakości kompilatora, należy po prostu zaopatrzyć się w lepszy kompilator. 10. Aby zagwarantować bezpieczeństwo zawartości pliku, należy dokonać przydziału wspomnianego bufora jeszcze przed otwarciem pliku, a w każdym razie przed wprowadzeniem do niego jakichkolwiek zmian. W przypadku niemożności przydziału bufora, należy otworzyć plik tylko do odczytu albo w ogóle zrezygnować z otwarcia. Jeżeli aplikacja przetwarza kilka ważnych plików, opisany bufor należy przydzielić na początku realizacji programu, w rozmiarze wystarczającym na zaspokojenie wszystkich potrzeb związanych z buforowaniem zmian. I nie ma co ubolewać nad ewentualnym bezproduktywnym zajęciem pamięci z tego tytułu, gdyż jest ono niczym wobec groźby utraty cennych danych.
ROZDZIAŁ 7. 1. Poniższa funkcja modyfikuje wartość obydwu stanowiących wskaźniki do informacji wejściowej:
swych
argumentów,
char *strcpy(char *pchTo, char *pchFrom) { char *pchStart = pchTo; while (*pchto++ = *pchFrom++) {}
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
215
NIEZAWODNOŚĆ OPROGRAMOWANIA
216
retunr (pchStart); }
Modyfikacja ma jednak miejsce tylko z punktu widzenia samej funkcji — parametry pchTo i pchFrom przekazywane są przez wartość i tak naprawdę modyfikowane są ich lokalne kopie tworzone ad hoc przez funkcję. Z punktu widzenia funkcji wywołującej żadna modyfikacja więc nie następuje. Niektóre języki programowania — na przykład FORTRAN — nie stosują przekazywania parametrów przez wartość, więc zmiana tych parametrów w ciele wywoływanej funkcji widoczna jest także dla funkcji wywołujących. 2. Podstawowy problem wynika tu z pewnego zabiegu optymalizacyjnego stosowanego przez kompilatory, nazywanego składaniem stałych (ang. constant folding). Polega on na tym, iż ws z ys t k ie odwołania do określonej stałej w całym kompilowanym kodzie odnoszą się do jej po j ed yncz ej kopii. Jeżeli więc kilka funkcji deklaruje w swym wnętrzu wskaźnik do łańcucha "?????", każdy z tych wskaźników wskazywać będzie to samo miejsce w pamięci i modyfikacja wskazywanego łańcucha przez którąkolwiek z tych funkcji stanie się nieoczekiwanie widoczna dla funkcji pozostałych. Niektóre kompilatory posuwają się w optymalizacji stałych łańcuchów jeszcze dalej, nie tworząc osobnego łańcucha, jeżeli jest on końcówką łańcucha już istniejącego — i tak na przykład na potrzeby kodu używającego łańcuchów „pascal”, „scal” i „cal” wystarczy utworzyć jeden łańcuch — „pascal”. Nieoczekiwana zmiana łańcuchów wskazywanych przez deklarowane wskaźniki daje wówczas jeszcze bardziej zdumiewające efekty. Aby uniknąć opisanego efektu, należy deklarować tablice znaków, nie zaś wskaźniki do łańcuchów: char *strFromUns(unsigned u) { static char strDigits[] = '?????';
Nawet jednak to nie jest całkowicie bezpieczne, bowiem przy wpisywaniu konkretnej liczby znaków łatwo o pomyłkę — chyba, że używa się do tego celu różnych znaków, na przykład „12345”zamiast „?????”. Należy także uwzględnić fakt, iż zerowy ogranicznik łańcucha może zostać przypadkowo zniszczony. Zamiast więc deklarować konkretną zawartość bufora, należy raczej zadeklarować jego konkretny rozmiar i explicite zapisywać zerowy ogranicznik: char *strFromUns(unsigned u); { static char strDigits[6]; /* 5 cyfr + '\0' */ . . . pch = &strDigits[5]; *pch == '\0';
3. Przedstawiony przykład wykorzystuje fakt sąsiadującego ułożenia w pamięci kolejno deklarowanych zmiennych, a więc cechę konkretnej implementacji i to
216
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 217
jest głównym czynnikiem związanego z nim ryzyka. Całą operację należałoby wykonać po prostu tak: i = 0; j = 0; k = 0;
albo tak: i = j = k = 0;
Założenie, iż lokalne zmienne sąsiadują ze sobą w pamięci operacyjnej jest bardzo ryzykowne ze względu na różnego rodzaju optymalizację wykonywaną przez kompilator — niektóre z tych zmiennych mogą zostać umieszczone nie w pamięci, lecz w rejestrach procesora. Kod, w zamyśle programisty zerujący zmienne lokalne, w rzeczywistości zerować może ważne informacje organizacyjne, na przykład kod powrotu do funkcji wywołującej. Spełnienie wspomnianego założenia staje się wówczas kwestią nie konkretnej implementacji, ale wręcz… konkretnej funkcji! Ponadto, jeżeli intencją programisty stosującego przedstawiony trik było polepszenie efektywności kodu, to jego usiłowania okazują się chybione ze względu na to, iż operacje pomocnicze związane z wywołaniem funkcji memset i wartościowaniem jej argumentów są daleko bardziej czasochłonne niż wyzerowanie trzech zmiennych. 4. To, iż zawartości pamięci ROM nie można zmienić w danym egzemplarzu komputera, nie oznacza, iż będzie ona identyczna w innym (na przykład nowszym) komputerze, na którym przyjdzie nam uruchamiać przedmiotowy program. Ponadto w miarę wykrywania ewentualnych błędów w oprogramowaniu zapisanym w pamięci ROM producenci opracowują zazwyczaj stosowne łaty (ang. patches) ładowane w sposób rezydentny do pamięci RAM i dostępne za pośrednictwem interfejsu systemowego. Odwoływanie się do pamięci ROM w sposób bezpośredni oznacza konsekwentne ignorowanie tych łat. 5. Jeżeli „oprogramowanie stałe” (np. BIOS) zapisane jest w pamięci EPROM, to jego uaktualnienie może spowodować, iż program pracujący dotąd bezproblemowo odmówi współpracy — i to na tym samym komputerze! W przeciwieństwie bowiem do strategii „łatania” wymazana zostaje z pamięci ta wersja BIOS-u, od której uzależniony jest wspomniany program. Założenie o możliwości opuszczenia parametru val jest tego samego rodzaju, co założenie przyjmowane przez funkcję FILL odnośnie określonego sposobu realizacji funkcji CMOVE. Załóżmy mianowicie, iż programista zmienił funkcję DoOperation w sposób następujący: void DoOperation(operation op, int val) { if (op < opPrimaryOps) DoPrimaryOps(op, val); else if (op < opFloatsOps) DoFloatOps(op, val); else .
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
217
NIEZAWODNOŚĆ OPROGRAMOWANIA
218 . . }
Niezależnie od parametru op parametr val jest teraz zawsze wykorzystywany, lecz niektóre funkcje wywołujące funkcję DoOperation nie są na to przygotowane. Co więc nastąpi, gdy funkcja odwoła się do parametru val, który nie został przekazany w wywołaniu? To oczywiście zależy od konkretnej implementacji, w szczególności może wystąpić błąd ochrony dostępu przy próbie modyfikowania chronionej przed zapisem ramki wywołania na stosie. Zamiast więc pomijać nieistotne parametry, należy raczej nadawać im wartość 0 i uwzględniać w wywołaniu. Wartość ta może być weryfikowana za pomocą stosownych asercji, na przykład: case opNegAcc: ASSERT(val == 0); accumulator = - accumulator; break;
6. Asercja ta weryfikuje fakt posiadania przez zmienną f jednej z wartości odpowiadających w danej implementacji stałym TRUE i FALSE. Trudno zrozumieć intencje programisty optymalizującego ją w sposób tak wyśrubowany w kodzie testowym — w dodatku kosztem kompletnego zaciemnienia czytelności — w każdym razie powinna ona być zapisana w najbardziej naturalnej postaci: ASSERT(f == TRUE || f == FALSE);
7. Intencją programisty był wybór jednej z dwóch funkcji wywoływanej z tym samym zestawem parametrów. Ideę tę zrealizujesz bardziej czytelnie, przypisując wybrany adres wskaźnikowi roboczemu i używając tego wskaźnika do ostatecznego wywołania żądanej funkcji: void *memmove(void *pvTo, void *pvFrom, size_t size) { void (*pfnMove)(byte *, byte *, size_t); byte *pbTo = (byte *)pvTo; byte *pbFrom = (byte *)pvFrom; pfnMove = (pbTo > pbFrom) ? tailmove : headmove; (*pfnMove)(pbTo, pbFrom, size); return (pvTo); }
8. Mówiąc prosto, prezentowany przykład bazuje na konkretnej implementacji funkcji Print, dokładniej — na czynnościach wykonywanych w efekcie skoku 4 bajty wyżej od jej punktu wejścia. Jeżeli już nie da się uniknąć skakania do wnętrza funkcji, to fakt ten powinien być przynajmniej oczywisty dla programisty konserwującego kod — należy wówczas zdefiniować etykietę w miejscu, do którego odbywa się skok:
218
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
ERROR! USE THE HOME TAB TO APPLY NAGŁÓWEK 1 TO THE TEXT THAT YOU WANT TO APPEAR HERE. 219
move r0,#PRINTER call PrintDevice . . . PrintDisplay: move r0,#DISPLAY PrintDevice:
; w r0 znajduje się ; identyfikator urządzenia
. . .
9. Zamiast skoku do wnętrza funkcji mamy teraz do czynienia ze skokiem do wnętrza instrukcji! Jest to uzasadnione jedynie na komputerach z tak małą pamięcią, iż każdy jej bajt jest na wagę złota. Ale z pewnością nie jest to sposób na uniknięcie błędów w tworzonych programach!
E:\TMP\Doc\98a00f11-4a62-4536-b94f-345dda442384.Doc
219