R07.DOC

(211 KB) Pobierz
1









              Dramaturgia rzemiosła              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.

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:

¨      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 0´0040:... — 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ć 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);        /* 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:

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.

 

 

 

 

 

 

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

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

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

 

...

Zgłoś jeśli naruszono regulamin