30
Ryzykowny biznes 109
6.
Gdybyś zaproponował doświadczonemu turyście górskiemu, stojącemu nad stromym zboczem, zjazd na nartach albo zejście po linie (do wyboru), z pewnością wybrałby trzeci sposób — zejście oznakowanym szlakiem. Czynnik ryzyka okazałby się bowiem dla niego ważniejszym kryterium, niż czas czy długość drogi.
Niestety, jakże często programiści, uwikłani w odwieczny konflikt pomiędzy zajętością pamięci a szybkością wykonywania kodu, nie biorą w ogóle pod uwagę stopnia ryzyka wynikającego z przyjętej metody kodowania. Programiści nie planują przecież popełniania błędów — nie sposób usłyszeć na przykład „implementuję właśnie algorytm sortowania szybkiego i mam zamiar umieścić w nim trzy błędy” — dlatego ślepo ufają, iż ewentualne błędy da się wykryć i zlikwidować niezależnie od tego, jaki sposób kodowania zostanie przyjęty.
Rozumowanie takie nie uwzględnia jednak pewnej istotnej kwestii — czy w sporządzonym w określonej konwencji kodzie wykrycie błędów będzie sprawą godzin lub dni, czy też tygodni lub miesięcy? I czy kod w określonej postaci daje się w ogóle testować?! W takim kontekście problematyka ryzykownego kodowania nabiera konkretnych wymiarów materialnych i wspomniany optymizm zaczyna się (na szczęście) chwiać w posadach.
W niniejszym rozdziale przedstawię kilka najbardziej znanych przykładów kodowania obarczonego wysokim ryzykiem błędów i oczywiście pokażę, w jaki sposób ryzyko to zmniejszyć lub wręcz wyeliminować.
Gdy komitet normalizacyjny ANSI wziął pod uwagę różnorodne wersje języka C przeznaczone dla różnych platform sprzętowych, stało się jasne, iż językowi temu daleko jest do cech języka przenośnego. Poważne różnice występowały nie tylko pomiędzy bibliotekami standardowymi, lecz także między preprocesorami i w ogóle między szczegółowymi definicjami składni poszczególnych wersji języka. Pierwszym poważnym krokiem w kierunku rzeczywistej przenośności języka było właśnie ujednolicenie (w dużym stopniu) przez wspomniany komitet tychże aspektów, jednak w dalszym ciągu własnemu losowi pozostawiony został inny podstawowy element języka — mianowicie wbudowane (intristic) typy danych. Konkretna definicja typów char, int i long pozostawiona została w gestii twórców konkretnych kompilatorów.
W efekcie wśród kompilatorów zgodnych z normą ANSI spotkać można kompilatory z 32-bitowym typem int i znakowym[1] (signed) typem char, jak i 16-bitowym typem int i bezznakowym (unsigned) typem char.
Spójrzmy na poniższy fragment:
char ch; ...
ch = 0xFF;
if (ch == 0xFF)
. . .
Warunek instrukcji if wydaje się tu być bezdyskusyjnie spełniony — to jednak tylko złudzenie. W rzeczywistości kwestia jego spełnienia zależy od konkretnej implementacji. Porównanie zmiennej znakowej z liczbą całkowitą poprzedzone jest konwersją tej zmiennej na typ int; jeżeli w danej implementacji typ char jest rozszerzalny znakowo (signed extension), wynikiem wspomnianej konwersji będzie (przy 16-bitowym typie int) 0´FFFF, nie 0´00FF i warunek w instrukcji if nie będzie spełniony.
Identycznie ma się rzecz z typem wskazującym na typ char:
char *pch;
.
if (*pch == 0xFF)
Typ char nie jest bynajmniej jedynym typem podatnym na tego rodzaju rozbieżność — podobny charakter mają pola bitowe. Zakres wartości pola zdefiniowanego jako:
int reg:3
Również zależy od konkretnej implementacji. Mimo iż pole to deklarowane jest jako int, może być ono polem znakowym lub bezznakowym i dopiero jawne zadeklarowanie tej cechy:
signed int reg:3
albo:
unsigned int reg:3
pozwala udzielić konkretnej odpowiedzi.
W podobny sposób, na pastwę konkretnej implementacji, skazane zostały typy short, int i long.
Pozostawienie wbudowanych typów danych poza domeną zabiegów normalizacyjnych nie było bynajmniej przejawem krótkowzroczności komitetu ANSI, lecz podyktowane zostało bardzo istotną przesłanką, jaką jest zgodność z istniejącym oprogramowaniem, dokładniej — jego kodem źródłowym. Poszczególne składniki tego oprogramowania powstały w zgodności z ustaleniami różnych implementacji; zabiegi normalizacyjne idące zbyt daleko i naruszające wspomniane ustalenia wymusiłyby nieuchronnie konieczność modyfikacji znacznych ilości istniejącego kodu.
Ujednolicenie reguł rządzących typami wbudowanymi naruszyłoby poza tym jedno z naczelnych założeń komitetu ANSI, którym jest „szybkość, nawet za cenę utraty przenośności — zgodnie z duchem języka C”. Jeżeli więc twórca kompilatora uzna, iż znakowe rozszerzenie zmiennej typu int da się na określonej maszynie zrealizować efektywniej niż rozszerzenie bezznakowe, powstaje wówczas implementacja ze znakowym typem char. Identycznie ma się rzecz z wyborem rozmiaru zmiennych typu int — decydująca jest zazwyczaj naturalna wielkość słowa maszynowego (16 albo 32 bity) oraz charakter pól bitowych.
Tak więc wbudowane typy danych stanowią nie lada problem przy każdym przenoszeniu oprogramowania pomiędzy różnymi platformami, jak również przy każdej zmianie kompilatora i oczywiście — przy wymianie oprogramowania pomiędzy różnymi firmami czy grupami programistów.
Nie ma to bynajmniej oznaczać, iż względy przenośności wymagają absolutnego wyrzeczenia się typów wbudowanych. Należy jedynie unikać wykorzystywania tych ich cech, które nie wynikają z normy ANSI. I tak na przykład ograniczenie zakresu zmiennych typu char do przedziału 0 ¸ 127 powoduje zgodność z każdą implementacją. Poniższa funkcja, jako nie czyniąca założeń o szczególnym zakresie danego typu jest więc zgodna z każdym kompilatorem:
char *strcpy(char *pchTo, chr *pchFrom);
{
char *pchStart = pchTo;
while ((*pchTo++ = *pchFrom++) != '\0')
{}
return (pchStart);
}
nie da się tego jednak powiedzieć o następującej funkcji:
/* strcmp - porównuje dwa łańcuchy
*
* zwraca:
* liczbę ujemną, gdy strLeft < strRight
* zero, gdy strLeft = strRight
* liczbę dodatnią, gdy strLeft > strRight
*/
int strcmp(const char *strLeft, const char *strRight)
for ( ; *strLeft == *strRight; strLeft++, strRight++)
if (*strLeft == '\0') /* koniec? */
return(0);
return ((*strLeft < *strRight) ? –1 : 1 );
Powodem nieprzenośności jest porównanie występujące w ostatniej linii; jego wynik zależny jest od tego, czy w danej implementacji typ char jest typem znakowym, czy też bezznakowym. Usterkę tę usuwa uprzednia (jawna) konwersja porównywanych wielkości na typ bezznakowy.
(*(unsigned char *)strLeft < *(unsigned char *)strRight)
Generalnie, aby uniknąć podobnych problemów, należy unikać używania „czystego” typu char; nie należy również nigdy używać „czystych” pól bitowych — tak kategoryczny wymóg związany jest z brakiem określenia ścisłego związku tych pól z typem int.
Jeżeli spojrzeć na specyfikację ANSI w sposób zachowawczy, można pokusić się o zdefiniowanie „uprzenośnionych” typów podstawowych, mających identyczne znaczenie niezależnie od rodzaju rozszerzeń (znakowe — bezznakowe) i reprezentacji liczb ujemnych (uzupełnienie do dwóch — uzupełnienie do jedynki):
char
0 – 127
signed char
–127(nie –128) –127
unsigned char
0 – 255
Rozmiar nieznany, lecz nie mniejszy niż 8 bitów
short
–32767 (nie –32768) –32767
signed short
–32767 –32767
unsigned short
0 – 65535
Rozmiar nieznany, lecz nie mniejszy niż 16 bitów
int
signed int
unsigned int
long
–2147483647 (nie –2147483648) –2147483647
signed long
–2147483647 – 2147483647
unsigned long
0 – 4294967295
Rozmiar nieznany, lecz nie mniejszy niż 32 bity
int i:n
0 – 2n–1 – 1
signed int i:n
–(2n–1 – 1) – 2n–1 – 1
unsigned int i:n
0 – 2n – 1
Rozmiar nieznany, lecz nie mniejszy niż n bitów
xxxxxxxxx
Stosowanie „przenośnych” typów danych rodzi naturalne obawy o pogorszenie efektywności. Jeżeli na przykład zakłada się, iż rozmiar (a zatem — zakres) typu int powinien odpowiadać wielkości słowa maszynowego na danej platformie, to być może rozmiar ten jest większy niż 16 bitów, więc przechowywać może liczby większe od 32767.
Na platformie 32-bitowej używanie typu long do przechowywania liczb z zakresu 0 – 4000 może więc wydawać się nieefektywne — czyż jednak przenośność kodu nie jest warta owej „nieefektywności”?
Poza tym, jeżeli na danej platformie typ int jest 32-bitowy, 32-bitowy będzie najprawdopodobniej również typ long, a zatem wspomniana nieefektywność będzie jedynie iluzoryczna.
Tym, którzy kwestionują w ogóle potrzebę pisania przenośnego kodu, można zaproponować do rozważenia następujący problem. Otóż, gdy układa się glazurę na podłodze domu, nie sposób nie brać pod uwagę prawdopodobnych gustów jego przyszłego nabywcy[2] — chyba, że bierze się pod uwagę możliwość ponownego remontu w obliczu rychłej sprzedaży. Podobnie, pisanie przenośnego kodu stanowi rozsądne posunięcie z punktu widzenia ewentualnej jego migracji (w przyszłości) na platformę bardziej nowoczesną — nie wymaga ono żadnych specjalnych przedsięwzięć, podobnie jak z punktu widzenia pracochłonności obojętny jest wzór kafelków układanych na podłodze.
Wiele subtelnych błędów programistycznych wynika stąd, iż konstrukcje programistyczne wyglądające prawidłowo na pierwszy rzut oka, w niektórych warunkach zaczynają zachowywać się dość dziwnie. Jako przykład posłuży poniższy fragment, inicjujący tablicę przeglądową na użytek makra tolower:
#include <limits.h> /* zawiera definicję UCHAR_MAX */
char chToLower[UCHAR_MAX+1];
void BuildToLowerTable(void) /* wersja ASCII */
unsigned char ch;
/* najpierw wypełnij tablicę zgodnie z funkcją
* tożsamościową
for (ch = 0; ch <= UCHAR_MAX; ch++)
chToLower[ch] = ch;
/* wypełnij teraz pozycje odpowiadające
* dużym literom
for (ch = 'A'; ch <= 'Z'; ch++)
chToLower[ch] = ch + 'a' – 'A';
#define tolower(ch) (chToLower[(unsigned char)(ch)])
Mimo iż wygląda to wszystko bardzo elegancko, to jednak wywołanie funkcji BuildToLowerTable spowoduje zapętlenie programu. Spójrz na wyrażenie testujące w pierwszej pętli — czy możliwe jest, by zmienna typu unsigned char przekroczyła kiedykolwiek wartość UCHAR_MAX? Oczywiście, że nie; inkrementacja tej zmiennej, zawierającej wartość UCHAR_MAX spowoduje ...
Wolf-1