R05.DOC

(195 KB) Pobierz
1









              Niekomunikatywne interfejsy              107



5.

Niekomunikatywne interfejsy

 

Jednym ze sposobów mających motywować pracowników Microsoftu do wydajniejszej pracy są różnego rodzaju łakocie, jak wafelki w czekoladzie, mleczko (również czekoladowe!), bezalkoholowe drinki i te popularne soczki owocowe w małych kartonikach; jeżeli jednak masz ochotę na cukierki, czekoladki itp., musisz sam kupić je sobie w jednym z automatów. Wrzucam więc dwie ćwierćdolarówki, wybieram na klawiaturze „45” i... zamiast upragnionej gumy do żucia otrzymuję jakiś batonik!

Nie, to nie awaria maszyny, lecz moja pomyłka — guma do żucia ma numer 21, lecz kosztuje 45 centów; ludzie dokonujący wszelakich zakupów mają naturalną skłonność do myślenia raczej w kategoriach pieniędzy, które przyjdzie im zapłacić, niż w kategoriach numerów katalogowych. Właśnie doświadczyłem tego na własnej skórze, odruchowo „wstukując” cenę zamiast numeru.

Mimo wszystko zdarzenia takie zawsze działają na mnie irytująco — gdyby projektant maszyny do sprzedawania słodkości świadom był opisanej tendencji i pomyślał choć 30 sekund dłużej, z pewnością zdecydowałby się na alfabetyczne kody produktów i uwolnił w ten sposób przyszłych klientów od przykrych niespodzianek. Sam projekt nie stałby się przez to ani droższy, ani bardziej skomplikowany.

Piszę tu o tym dlatego, iż wiele interfejsów użytkownika ma właśnie podobną naturę — autorzy bibliotek często implementują swe funkcje w taki sposób, iż istnieje możliwość ich użycia w sposób zgodny co prawda z zasadami składni, lecz jednocześnie sprzeczny z przeznaczeniem.

Błędów tych można by uniknąć, gdyby autorzy ci wczuli się w rolę programistów, dla których tworzone są wspomniane biblioteki i tworzyli swój kod w sposób bardziej jednoznaczny. Wyłania się stąd przy okazji kolejna prawda związana z niezawodnym programowaniem — nie wystarczy, by tworzony kod był bezbłędny, musi on być jeszcze bezpieczny w użyciu.

getchar() zwraca liczbę, nie znak

Tak się nieszczęśliwie składa, iż opisaną wyżej przypadłością dotknięta jest znakomita większość funkcji biblioteki standardowej, jak również wiele funkcji wykorzystujących tę bibliotekę. W charakterze przykładu rozpatrzmy popularną funkcję getchar — oto co napisali o niej Kernighan i Ritchie w swej znanej książce o języku C:

Rozpatrzmy następujący kod:

char c;

c = getchar();

if (c == EOF)

Na maszynach, w których konwersja typów odbywa się bez rozszerzenia znakowego, c będzie mieć zawsze wartość dodatnią — wszak definiowane jest jako char — gdy tymczasem EOF jest liczbą ujemną i warunek instrukcji if nigdy nie będzie spełniony. Aby opisanego kłopotu uniknąć, należy wynik zwracany przez funkcję getchar przypisywać zmiennym typu int, nie char.

Nic dodać, nic ująć. Funkcja getchar zwraca więc nie kod kolejnego znaku, lecz kod zakończenia operacji pobierania znaku — równy kodowi pobranego znaku w przypadku pomyślnego pobrania, a w przypadku błędu będący liczbą ujemną. Tymczasem nazwa funkcji — getchar, czyli „podaj znak” — sugeruje programiście coś wręcz przeciwnego, naturalne jest więc traktowanie jej wyniku jako kodu znaku; daje to znać o sobie w osobliwy sposób w sytuacji jakiegokolwiek błędu, którym może być na przykład wyczerpanie strumienia wejściowego.

Poniższa funkcja ilustruje inny, często spotykany problem:

/* strdup – tworzenie dynamicznej kopii łańcucha */

 

char *strdup(char *str)

{

  char *strNew;

 

  strNew = (char *)malloc(strlen(str)+1);

  strcpy(strNew, str);

 

  return (strNew);

}

Powyższa funkcja dokonuje kopiowania zawartości wskazanego łańcucha do specjalnie przydzielonego w tym celu obszaru pamięci. Będzie on działać poprawnie, o ile wywołanie funkcji malloc zakończy się sukcesem, tj. przydzieleniem bloku o żądanym rozmiarze. Gdy funkcja malloc zwróci wartość NULL, funkcja strdup zachowa się odmiennie od oczekiwań programisty i to niezależnie od zachowania się samej funkcji strcpy, która może wówczas zaniechać jakiegokolwiek kopiowania lub próbować adresować pamięć za pośrednictwem pustego wskaźnika.

Podstawowym nieszczęściem obydwu wzmiankowanych funkcji — getchar i malloc — jest to, iż wykorzystujące je programy lub funkcje długo mogą stwarzać pozory poprawnego działania nawet wówczas, gdy ich funkcjonowanie jest ewidentnie błędne. Błędy mogą wówczas ujawnić się zupełnie nieoczekiwanie, w sytuacji niekorzystnego zbiegu okoliczności — podobnie jak w przypadku zatonięcia Titanica. Podstawową przyczyną takiego stanu rzeczy jest coś, co można by nazwać „dualnym” charakterem zwracanego wyniku: funkcja getchar zwraca kod odczytanego znaku lub informację o błędzie, podobnie funkcja malloc zwraca wskaźnik do przydzielonego obszaru lub wartość zero jako informację o błędzie. Zaprezentowane przykłady ilustrują nic innego, jak tylko przejaw ignorowania drugiego z wymienionych aspektów owej „dualności” — informacja świadcząca o błędzie traktowana jest na równi z informacją użyteczną, i tak np. EOF ( czyli –1) utożsamiany jest ze znakiem o kodzie 255, zaś zerowy wynik funkcji malloc ze wskaźnikiem do obszaru o adresie zero.

Naturalnym rozwiązaniem tego problemu wydaje się zlikwidowanie wspomnianej dualności poprzez oddzielenie informacji o statusie wykonania operacji od użytecznego wyniku tejże operacji — i tak np. funkcja dostarczająca kolejnego znaku z wejścia mogłaby być zadeklarowana w taki sposób:

flag fGetChar(char *pch);   /* prototyp */

zaś jej użycie byłoby wówczas następujące:

char ch;

 

if (fGetChar(&ch))

   .

      /* ch zawiera kod kolejnego znaku */

else

  .

     /* napotkano koniec pliku, wartość ch jest nieokreślona */

Zlikwidowana została możliwość „pomylenia” charakteru zwracanego wyniku — wątpliwe jest, że nawet najbardziej niewprawny programista zaniecha sprawdzenia wyniku zwróconego przez funkcję fGetChar — pojawił się za to inny problem. Mianowicie funkcja posiada teraz dodatkowy parametr, będący wskaźnikiem do znaku; programista może łatwo zapomnieć o użyciu operatora referencji (&) i napisać po prostu:

if (fGetChar(ch))

Pomyłka taka zostanie jednak wykryta przez kompilator, o ile korzystać będziemy z prototypowania funkcji, opisanego w rozdziale 1. — niniejszy przykład jest więc kolejnym argumentem na rzecz jego konsekwentnego stosowania.

Nie sposób nie zadać w tym momencie pytania, czy nagminne występowanie w bibliotece standardowej „dualnych” funkcji, kodujących w zwracanym wyniku wiele aspektów swego funkcjonowania, nie jest przypadkiem przejawem programistycznej beztroski?

Odpowiedzi na tak postawione pytanie należy poszukiwać raczej na gruncie historycznym i technologicznym niż warsztatowym: otóż w czasach, gdy każdy bajt pamięci był na wagę złota, zaś komunikacja pomiędzy podprogramami odbywała się za pośrednictwem rejestrów maszynowych, maksymalne „upychanie” informacji stanowiło warunek sine qua non zaprogramowania czegokolwiek, bez względu na czytelność i bezpieczeństwo użytkowania tworzonego kodu.

Takie podejście wydaje się czymś zupełnie naturalnym, jeżeli o programowaniu myśli się w kategoriach asemblera; języki algorytmiczne, do których oczywiście zalicza się również C, odsuwają jednak programistę od szczegółów technologicznych na rzecz myślenia w kategoriach rozwiązywanego problemu — a skoro tak, to niecelowe wydaje się naśladowanie niegdysiejszych praktyk wymuszonych przez uwarunkowania technologiczne. Wymogi czytelności i prostoty tworzonego kodu zdecydowanie przemawiają przeciwko nieprecyzyjnym interfejsom.

I jeżeli nawet język C bywa postrzegany jako „bliższy maszynie” niż inne języki algorytmiczne, nie należy zapominać, iż jest on przede wszystkim językiem algorytmicznym.

Unikaj łączenia informacji o odmiennym charakterze.

realloc() a gospodarka pamięcią

Kolejną funkcją, „cierpiącą na chorobę” nieprecyzyjnego interfejsu, jest funkcja realloc, dokonująca zmiany rozmiaru przydzielonego bloku pamięci (opisywałem ją szczegółowo w rozdziale 3.). Spójrzmy na poniższy fragment:

pbBuf = (byte *)realloc(pbBuf, sizeNew);

if (pbBuf != NULL)

.

  /* realokacja wykonana pomyślnie */

Co się stanie, jeżeli wykonanie funkcji realloc zakończy się niepowodzeniem i zwróci ona wartość NULL? Zniszczona zostanie zawartość zmiennej pbBuf zawierającej wskaźnik do oryginalnego bloku; jeżeli jest to jedyna zmienna wskazująca na wspomniany blok, następuje ponadto utrata łączności z tym blokiem — nie można go zwolnić i (co ważniejsze) traci się zapisaną w tym bloku informację.

Opisane niebezpieczeństwo nie wystąpiłoby, gdyby wynik zwrócony przez funkcję realloc przypisać tymczasowo osobnej zmiennej:

pbNewBuf = (byte *)realloc(pbBuf, sizeNew);

 

if (pbNewBuf != NULL)

  pbBuf = pbNewBuf

else

  .

W obydwu przypadkach zmienna pbBuf zawiera wskaźnik do bloku, zaś zawartość zmiennej pbNewBuff informuje o tym, czy realokacja dokonała się pomyślnie. Rozwiązanie takie kłóci się jednak nieco z logiką samej operacji — używanie dwóch zmiennych do adresowania pojedynczego bloku może wyglądać mniej więcej tak, jak udawanie się do restauracji jednym samochodem i wracanie innym. Powodem owej niespójności jest oczywiście niespójny interfejs funkcji realloc, podobnej pod tym względem do funkcji malloc.

Podobnie jak uczyniliśmy to z funkcją getchar, stwórzmy teraz odpowiednik funkcji realloc dokonujący „rozseparowania” obydwu informacji — statusu operacji i adresu przydzielonego bloku:

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);

}

Użycie roboczej zmiennej pbNew zapobiega utracie wskazania na oryginalny blok, gdy realokacja się nie powiedzie. Ostatecznie zmienna przekazana jako pierwszy parametr zawsze wskazuje na przedmiotowy blok pamięci, zaś status całej operacji przekazywany jest jako wynik funkcji:

if (fResizeMemory(&pbBuf, sizeNew)

.

  /* realokacja się udała, pbBuf wskazuje na poszerzony blok */

 

else

.

  /* realokacja nie udała się, pbBuf wskazuje na oryginalny blok */

Nie istnieje więc ryzyko utraty wskazania do bloku pamięci, lecz podobnie jak w przypadku funkcji fGetChar istnieje obowiązek sprawdzania zwracanego wyniku.

W prezentowanych, przyznajmy — dość prostych, przykładach łączenie informacji o zróżnicowanym charakterze dotyczyło wyniku funkcji, lecz w rzeczywistych zastosowaniach może ono równie dobrze dotyczyć jej parametrów. Kosztem „rozseparowania” informacji zwracanej przez funkcje getchar i realloc był jeden dodatkowy parametr, jednakże w przypadku rozseparowywania informacji niesionej przez poszczególne parametry liczba tych parametrów może wzrosnąć, niekiedy dość znacznie. Mimo to korzyści wynikające z jednoznaczności interfejsu warte są wykonania tej pracy, nawet kosztem pewnego pogorszenia czytelności. Odpowiedzialni programiści, myślący kategoriami użytkowników, zdecydowanie unikają interfejsów w rodzaju funkcji getchar i realloc.

Uniwersalny menedżer pamięci

Mimo iż funkcję realloc opisałem obszernie w rozdziale 3., powrócę do niej raz jeszcze z interesującego powodu — okazuje się otóż, iż poza realokacją bloku pamięci może ona także dokonywać alokacji i zwalniania bloków. Spójrzmy wpierw do dokumentacji:

void *realloc(void *pv, size_t size);

realloc zmienia rozmiar przydzielonego bloku zachowując zawartość jego początkowej części — o rozmiarze nie przekraczającym zarówno starego, jak i nowego rozmiaru;

¨       jeżeli nowy rozmiar bloku jest mniejszy od rozmiaru dotychczasowego, zwalniana jest nadwyżka pamięci na końcu bloku; adres bloku nie zmienia się i parametr pv pozostaje niezmieniony;

¨       jeżeli nowy rozmiar bloku jest większy od rozmiaru dotychczasowego, możliwe jest przydzielenie nowego bloku i skopiowanie do niego zawartości bloku istniejącego. Zwracany jest wskaźnik do bloku nowo przydzielonego. Końcówka nowo przydzielonego bloku, stanowiąca rozszerzenie w stosunku do bloku istniejącego, nie jest inicjowana w żaden sposób;

¨       jeżeli rozszerzenie bloku nie jest wykonalne, zwracana jest wartość NULL. Realokacja oznaczająca zmniejszenie bloku jest zawsze wykonalna;

¨       jeżeli pv ma wartość NULL, wywołanie funkcji realloc traktowane jest jako żądanie przydzielenia bloku o rozmiarze size (malloc(size)). Jeżeli przydział taki nie jest wykonalny, funkcja zwraca wartość NULL;

¨       jeżeli jako nowy rozmiar bloku (size) podaje się wartość zero i parametr pv ma wartość różną od NULL, wywołanie funkcji realloc traktowane jest jako żądanie zwolnienia bloku wskazywanego przez pv (identycznie jak free(pv)). Zwracana jest wartość NULL;

¨       jeżeli jako nowy rozmiar bloku (size) podaje się wartość zero i parametr pv ma wartość NULL, rezultat wywołania jest nieokreślony.

Czyżby oznaczało to, że funkcje malloc i free są niepotrzebne?

Zanim odpowiemy sobie na to pytanie, zwróćmy uwagę na rzecz niezmiernie istotną. Otóż funkcja realloc została zaprojektowana w celu zmiany rozmiaru istniejącego bloku; to, iż może ona dokonywać także „czystego” przydziału i „czystego” zwalniania bloku, jest wynikiem swoistego dążenia do kompletności — autorzy funkcji realloc musieli zdefiniować jej zachowanie w przypadku „nienaturalnych” wartości pv = NULL lub size = 0. Z ich punktu widzenia (czy raczej: w przyjętej przez nich konwencji) zmniejszenie bloku do zerowego rozmiaru jest równoznaczne ze zwolnieniem tegoż bloku, zaś rozszerzenie nieistniejącego bloku równoznaczne jest z rozszerzeniem bloku o ...

Zgłoś jeśli naruszono regulamin