Sprawdzaj samego siebie 29
2.
Wykrywanie przez kompilator rozmaitych „podejrzanych” konstrukcji, prawdopodobnie zawierających błędy logiczne, jest z pewnością bardzo użyteczne; zważywszy jednak na pokaźną liczbę pozostałych błędów czających się jeszcze w programie, można bez trudu skonstatować, iż tego rodzaju mechanizmy traktować można jedynie jako środki pomocnicze.
Przypomnijmy znajomy fragment z rozdziału 1.:
strCopy = memcpy(malloc(length), str, length);
Jest on przykładem konstrukcji, której błędne wykonanie zdarza się raczej rzadko — w tym przypadku wówczas, gdy funkcja malloc() zwróci wartość NULL. Jeżeli coś takiego nie zdarzy się podczas testów, produkt może zostać uznany za bezbłędny i skierowany do sprzedaży; potencjalną ofiarą zdradliwego błędu stanie się wówczas użytkownik końcowy.
Trudno oczekiwać, by kompilator, nie znający przecież intencji programisty, zdolny był wykrywać tego typu przypadki — niczym hipotetyczny kompilator opisany w rozdziale 1. Może to natomiast — i powinien — zrobić programista; wykrycie błędu „w zarodku” nie pozwoli na rozprzestrzenianie się jego konsekwencji w realizowanym programie.
Wstawmy więc niezbędną kontrolę do treści funkcji memcpy() — wszak do jej poprawnego funkcjonowania wymagane jest, by żaden ze wskaźników (źródłowy ani docelowy) nie był wskaźnikiem pustym (NULL):
/* 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;
if (pvTo == NULL || pvFrom == NULL)
fprintf(stderr, "Błędne argumenty wywołania memcpy\n");
abort();
}
while (size-- > 0)
*pbTo++ = *pbFrom++;
return (pvTo);
Użycie pustego wskaźnika do adresowania któregokolwiek z obszarów nie pozostanie teraz niezauważone — pojawił się za to nowy problem w postaci niemal dwukrotnego powiększenia rozmiaru kodu; można się zastanawiać, czy użyte lekarstwo nie okazało się gorsze od choroby.
Zauważmy jednak, iż dodatkowy kod sprawdzający „legalność” wskaźników jest przydatny jedynie na etapie testowania produktu — wyświetlenie zawartego w nim komunikatu na ekranie użytkownika końcowego może spowodować co najwyżej jego irytację. Konieczne jest więc utrzymywanie dwóch wersji programu — testowej i handlowej — w czym wydatnie dopomoże nam preprocesor. Można na przykład nakazać wykonywanie wspomnianego testu jedynie wówczas, gdy zdefiniowany będzie symbol DEBUG:
#ifdef DEBUG
fprintf(stderr, "Błędne argumenty wywołania memcpy\n"); abort(); }
#endif
Jeżeli każda z funkcji programu zawierać będzie chociażby minimum środków tego rodzaju, niebezpieczeństwo przemycenia nie wykrytych błędów zmniejszy się znacząco; jeżeli przeprowadzane testy nie wykazują żadnych błędów, można „wyłączyć” symbol DEBUG i skompilować produkt „do wersji handlowej”. Powrót do testowania w przypadku objawienia się ewentualnych błędów w wersji handlowej sprowadza się do przedefiniowania jednego tylko symbolu (i oczywiście ponownej kompilacji).
Utrzymuj dwie wersje produktu — testową i handlową.
Zastosowanie kompilacji warunkowej pozwala uniknąć niepotrzebnego generowania znacznych nieraz ilości dodatkowego kodu — nie zmienia to jednak w niczym faktu, iż przejrzysty dotąd kod funkcji memcpy() stał się nieco zagmatwany. Problem pogodzenia dwóch (zdawałoby się) sprzecznych wymagań — przejrzystości kodu i jego funkcjonalności — rozwiązują asercje, zewnętrznie przypominające wywołania funkcji o nazwie assert():
assert (pvTo != NULL && pvFrom != NULL);
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)
.
spowoduje błąd kompilacji. Ograniczenie to jest jak najbardziej zamierzone, asercje-instrukcje 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:
void _Assert(char , unsigned); /* prototyp */
#define ASSERT(f)
if (f)
{}
else
_Assert(__FILE__, __LINE__)
#else
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
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 K[1]) 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.
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. 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:
ASSERT (pvTo != NULL && pvFrom != NULL);
ASSERT (pbTo >= pbFrom+size || pbfrom >= pbTo+size)
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 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.
Przyjrzyjmy się raz jeszcze asercji badającej rozłączność obszarów obsł...
grzegorj