Niezawodność Oprogramowania, R07, 1


0x08 graphic
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.

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; /* zachowaj poprzednią zawartość bajtu

* zajmowanego przez wartownika

*/

*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:

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ć 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.

0x01 graphic

Nie odwołuj się do pamięci,
która nie została Ci przydzielona.

0x01 graphic

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); /* zwalnia *pwnd */

pwnd = pwnd->pwndSibling;

}

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:

0x08 graphic

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ące.

0x01 graphic

Nie odwołuj się do zwolnionych bloków pamięci.

0x01 graphic

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

/* zapamiętuj kolejne cyfry w łańcuchu str "od końca".

* rozpocznij od takiej pozycji łańcucha, która uwzględnia

* największą możliwą wartość u

*/

pch = &str[5];

*pch = '\0';

do

*--pch = (u % 10) + '0';

while ((u /= 10) > 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 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 80×86 — 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 <= 65535);

/* zapamiętuj kolejne cyfry w tablicy strDigits "od końca" */

pch = &strDigits[5];

*pch = '\0';

do

*--pch = (u % 10) + '0';

while ((u /= 10) > 0);

strcpy(str, pch);

}

0x01 graphic

Nie używaj w roli buforów roboczych obszarów
przeznaczonych na informację wyjściową.

0x01 graphic

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

/* zapamiętuj kolejne cyfry w tablicy strDigits "od końca" */

pch = &strDigits[5];

ASSERT(*pch == '\0');

do

*--pch = (u % 10) + '0';

while ((u /= 10) > 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.

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.

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 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.

0x01 graphic

Unikaj przekazywania danych
za pomocą statycznych lub globalnych b
uforów.

0x01 graphic

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 <CMOVE — przesyłającą bajty pomiędzy obszarami w kierunku malejących adresów. Wynik użycia dwóch ostatnich funkcji jest w oczywisty sposób różny dla obszarów pokrywających się częściowo lub całkowicie; za prawidłowy wybór jednej z nich odpowiedzialny był programista, uwzględniający sposób nakładania się wspomnianych obszarów.

Ze względów efektywności funkcja CMOVE napisana była w zoptymalizowany sposób w języku asemblera, natomiast funkcję FILL napisano wprost w języku FORTH. W przełożeniu na C realizacja funkcji CMOVE była raczej oczywista.

/* CMOVE - przesyłanie pomiędzy obszarami

* w kierunku rosnących adresów

*/

void CMOVE(byte *pbFrom, byte *pbTo, size_t size)

{

while (size-- > 0)

{

*pbTo++ = *pbFrom++

}

Realizacja funkcji FILL była za to kompletnym zaskoczeniem:

/* FILL - wypełnianie obszaru pamięci */

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ła­da­ją­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 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));

/* 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?

0x01 graphic

Nie nadużywaj języka programowania.

0x01 graphic

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?

Syndrom APL

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<=1) ? (uCur?0:1) : (uCur==4)?2:(uCur+1));

}

Nie dość, że jest ona bardzo nieczytelna, to dodatkowo generuje daleki od efektywności kod maszynowy (co wcześniej wyjaśniłem). W cytowanej przed chwilą instrukcji:

return ((int)atou(str + (*str == '+')));

dodanie do wskaźnika liczby stanowiącej konwersję wyniku porównania może spowodować wygenerowanie efektywnego kodu pod warunkiem, że odnośna maszyna jest w stanie produkować bezpośrednio (tj. bez żadnych skoków) wartości 0 albo 1 w wyniku porównania. W przeciwnym razie przekład maszynowy przypominał będzie raczej tłumaczenie instrukcji:

return ((int)atou(str + ((*str == '+') ? 1 : 0 )));

Jako że operator ?: jest w swej istocie inną postacią zapisu instrukcji if, wyprodukowany kod maszynowy będzie miał jakość gorszą od tej, którą można by uzyskać, przy programowaniu swych intencji wyraźnie, po prostu, bez żadnych efektów specjalnych:

/* pomiń ewentualny znak '+' */

if (*str == '+')

str++;

return ((int)atou(str));

Innym sposobem uzyskania optymalnego kodu jest wykorzystanie częściowego wartościowania (ang. short-circuit evaluation) operacji boolowskiej, w szczególności alternatywy — jeżeli mianowicie pierwsze z wyrażeń połączonych operatorem || ma wartość TRUE, wynik alternatywy jest przesądzony i drugie wyrażenie nie jest w ogóle wartościowane. Jest tak w poniższym przykładzie:

(*str != '+') || str++; /* pomiń ewentualny znak '+' */

return ((int)atou(str));

Nie gwarantuje to jednak otrzymania kodu bardziej optymalnego niż przy użyciu instrukcji if. Oczywistość konstrukcji też jest tu mocno problematyczna. Wszak zasadniczym przeznaczeniem operatora || są wyrażenia boolowskie, zaś operatora ?: — wyrażenia warunkowe; jeżeli chcemy warunkowo wykonać pewien fragment kodu, należy po prostu użyć instrukcji if.

Jak więc widać, bardziej efektywny kod wynikowy daje się często uzyskać za pomocą mniej zwięzłych w zapisie instrukcji.

Bez udziwnień, proszę

Niektórzy „eksperci” od komputerów mają skłonność do „okrągłego” formułowania swych myśli — zamiast powiedzieć po prostu „Takie błędy mogą zawieszać system”, piszą „Tego rodzaju defekty oprogramowania mogą powodować utratę kontroli nad systemem albo wymuszać zakończenie jego pracy”. Używają terminów typu „aksjomatyczna weryfikacja programu” albo „taksonomia błędów”, jak gdyby stanowiły one element codziennego słownika programistów. W efekcie zasadnicza treść przesłania zostaje uwikłana w dziwaczną terminologię.

Podobne zjawisko daje się zaobserwować również na kanwie programistycznej. Imponujący (w zamyśle autora) kod staje się jedynie kodem nieczytelnym, jak w poniższym przykładzie:

void *memmove(void *pvTo, void *pvFrom, size_t size)

{

byte *pbTo = (byte *)pvTo;

byte *pbFrom = (byte *)pvFrom;

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

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.

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

0x01 graphic

Formułuj kod programu w taki sposób,
by jego zrozumienie nie wymagało kwalifikacji eksperta.

0x01 graphic

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

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

/* zapamiętuj kolejne cyfry w tablicy strDigits "od końca" */

pch = &strDigits[5];

ASSERT(*pch == '\0');

do

*--pch = (u % 10) + '0';

while ((u /= 10) > 0);

return(pch);

}

  1. 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?

  1. 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?

  2. 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;

.

.

.

}

Dlaczego mimo wszystko nie należy tej możliwości wykorzystywać, mimo iż może ona poprawić efektywność programu?

  1. 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?

  1. 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

.

.

.

  1. 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

.

.

.

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

APL (ang. A Programming Language) — popularny w latach 60. i 70. konwersacyjny język służący do wykonywania szybkich obliczeń na maszynach IBM/360, charakteryzujący się ekstremalną zwięzłością zapisu podyktowaną względami jak największej efektywności (przyp. tłum.).

W procesorach 80386 i lepszych możliwość taką dają instrukcje SETxx (przyp. tłum.).

140 Niezawodność oprogramowania

Dramaturgia rzemiosła 139

140 D:\Roboczy\Niezawodność oprogramowania\9 po skladzie 1\r07.doc

D:\Roboczy\Niezawodność oprogramowania\9 po skladzie 1\r07.doc 139

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.



Wyszukiwarka