R01.DOC

(110 KB) Pobierz
1









              Hipotetyczny kompilator              19



1.

Hipotetyczny kompilator

 

Zastanów się przez chwilę nad następującą kwestią: w jaki sposób powinny być konstruowane Twoje programy, by kompilator mógł precyzyjnie wskazać każdy popełniony przez Ciebie błąd? Oczywiście nie chodzi tu o — banalne przecież — wykrycie błędów syntaktycznych, lecz wychwycenie takich fragmentów, które — czy to z powodu Twojej nieuwagi, niedostatecznej wyobraźni, czy też po prostu pomyłki — mogą powodować błędy wykonania.

Ile słupków trzeba postawić na kilometrowej trasie, jeżeli stawia się je co 50 metrów? 20? A może 21? A na trasie dwukilometrowej? 40? 41? 42? Ile słupków należy użyć w ogrodzeniu o łącznej długości 300 metrów, jeżeli stawia się je co metr? 300? A może 301? Tego rodzaju wyliczenia stają się okazją do często popełnianego błędu, polegającego na tym, iż obliczony wynik różni się od poprawnego o jeden — i z tego powodu nazywanego „błędem pomyłki o jedynkę” (ang. offbyone error). Sytuacją pokrewną jest zastosowanie nieostrej relacji porównania (np. mniejsze lub równe…) zamiast relacji ostrej (mniejsze niż…); wspaniale byłoby, gdyby sytuacja ta została wykryta przez nasz hipotetyczny kompilator, na przykład w taki sposób:

–> linia 23:  while (j <= j)

                       ^^

pomyłka o jedynkę: powinno być ’<’

Kompilator ów mógłby również wykazywać się pewną dozą wyobraźni i ostrzegać programistę przed potencjalną możliwością zaistnienia błędu, na przykład:

-> linia 42: int itoa(int i, char *str)

                 ^^^^

błąd algorytmu: itoa załamie się dla i=-32768

 

-> linia 318: strCopy = memcpy(malloc(length), str, length);

                               ^^^^^^

błędny argument: memcpy załamie się, gdy malloc() zwróci NULL

Co prawda poruszamy się tutaj w kręgu hipotetycznych dociekań — gdyby jednak jakimś cudem udało się stworzyć opisywany kompilator, czy wówczas unikanie błędów w tworzonych programach stałoby się łatwiejsze? Czy tworzenie bezbłędnych programów byłoby wówczas czynnością banalną — przynajmniej w porównaniu z wysiłkami obecnych programistów?

Odpowiedź nasuwa się sama, gdy popatrzeć na dzisiejszych testerów oprogramowania, próbujących dociec przyczyn błędów sygnalizowanych w dostarczonym właśnie raporcie, czy też bombardujących nowo otrzy­maną wersję produktu ogromem mniej lub bardziej dziwacznych danych w nadziei natrafienia na jakieś symptomy błędnego zachowania. By te — syzyfowe niekiedy — wysiłki przynieść mogły spodziewane efekty, oprócz niekwestionowanej wiedzy fachowej i wieloletniego doświadczenia, konieczna jest jeszcze duża doza szczęścia.

Szczęścia?!

Niestety, tak. Testerzy oprogramowania poszukują błędów w całkowicie odmienny sposób, niż czyni to nasz hipotetyczny kompilator; nie są oni w stanie — i nawet nie próbują — wykrywać podejrzanych konstrukcji w rodzaju „pomyłki o jedynkę” czy też „czeskiego” błędu w zapisie którejś liczby czy łańcucha. Traktują oni raczej testowany produkt w kategoriach swoistej „czarnej skrzynki” — przekazują mu starannie spreparowane dane testowe i porównują produkowane wyniki ze spodziewanymi, obliczonymi wcześniej w sposób niezależny, bądź obserwują zewnętrzne przejawy zachowania się testowanego programu jako reakcję na taki, a nie inny sposób manipulowania klawiaturą, myszą itp. Błędy manifestujące się w sposób nachalny nie stanowią tu bynajmniej największego problemu; nie ma nic gorszego, niż błędy objawiające się od czasu do czasu, tylko w szczególnych warunkach — i właśnie zaistnienie owych „szczególnych warunków” akurat w czasie testu jest owym szczęśliwym, bo niemalże wygranym na loterii, zbiegiem okoliczności.

Ktoś mógłby w tym miejscu stwierdzić, iż jego testerzy pracują w spo­sób o wiele bardziej wykoncypowany: posługują się mianowicie różnego rodzaju narzędziami profilującymi, generatorami danych przypadkowych, debuggerami dającymi wgląd w binarną postać programu i produkującymi na żądanie rozmaite migawki. Są to niewątpliwie narzędzia wielce produk­tywne, nie zmieniają jednak istoty pracy wykonywanej przez testerów, co najwyżej czyniąc ją wydajniejszą — przykładowo typowy profiler potrafi wykryć te rozgałęzienia programu, które nie zostały jeszcze przetestowane, co zmusza do opracowania nowych danych testowych, w wyniku czego sterowanie podąży wzdłuż żądanej ścieżki.

Nie ma to bynajmniej oznaczać, iż praca wykonywana przez testerów jest niecelowa, czy też niepotrzebna. Chodzi tu raczej o zwrócenie uwagi na ważny fakt, iż poszukiwanie błędów drogą testowania produktu na zasadzie „czarnej skrzynki” jest zadaniem bardzo trudnym, porównywal­nym z próbą ustalenia istoty i przyczyn choroby na podstawie li tylko samego wywiadu z pacjentem: zadaje się pytania, wysłuchuje odpowiedzi i wyciąga z nich wnioski, jednakże meritum sprawy kryje się w ciele (w duszy?) pacjenta. Każdej postawionej w ten sposób diagnozie towarzy­szyć muszą rozmaite wątpliwości — czy wywiad był wystarczająco ob­szerny? Czy zadałem właściwe pytania? Skoro więc faza testowania charakteryzuje się z założenia niską skutecznością wykrywania błędów — zwłaszcza w kontekście ich przyczyn — walka z błędami musi rozpocząć się znacznie wcześniej, czyli już na etapie projektowania.

Poznaj swój język programowania

Jakże często spotykamy się z pompatycznymi tekstami reklamowymi w rodzaju: „Nowy rewelacyjny edytor! Jeżeli zamierzasz napisać nową epopeję narodową lub tylko wysłać list do kolegi, ten edytor jest dla Ciebie. Nawet jeżeli ortografia nie jest Twoją mocną stroną, przed błędami uchroni Cię korektor oparty na słowniku zawierającym 230000 słów — to o 50000 więcej niż w edytorach konkurencyjnych. Nie zastanawiaj się — kup jeszcze dziś; wszak to najbardziej rewolucyjne narzędzie dla pisarzy od czasu wynalezienia długopisu!”. Niespotykana dotąd pojemność słow­nika ma porażać i jawić się jako panaceum na — nieuchronne w końcu — zwykłe, ludzkie omyłki.

Problem jednak w tym, iż to nie w pojemności słowników spoczywa ich funkcjonalność. Słowo „dosowa” jest czymś zupełnie zwyczajnym w tekście informatycznym i odnosi się najprawdopodobniej do aplikacji przeznaczonej dla systemu DOS, lecz już w ofercie hurtowni spożywczej może — błędnie zapisane — oznaczać wodę sodową. Uniwersalny słownik — niezależnie od tego, czy zawierać będzie słowo „dosowa” — będzie więc niedoskonały z punktu widzenia któregoś z ww. tekstów.

Najbardziej elastycznym rozwiązaniem jest posługiwanie się wieloma słownikami, z których każdy dostosowany jest (pod względem zawartości) do określonej dziedziny działalności ludzkiej — jak np. programowanie komputerów albo wyrób wody sodowej. Zastosowaniem tej idei na gruncie języków programowania są kompilatory posiadające konfigurowalne opcje wychwytywania z kodów źródłowych takich konstrukcji, które co prawda poprawne są z punktu widzenia składni języka, lecz z dużym prawdopodobieństwem stanowią przejaw popełnienia błędu. Oto przykład:

/* memcpy – kopiowanie pomiędzy nie nakładającymi się blokami pamięci */ 

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

{

  byte *pbTo   = (byte *)pvTo;

  byte *pbFrom = (byte *)pvFrom;

 

  while (size-- > 0);

    *pbTo++ = *pbFrom++;

 

  return (pvTo);

}

Akapitowanie w obrębie instrukcji while sugeruje tu powtarzanie instrukcji kopiowania kolejnych bajtów, jednakże z punktu widzenia kompilatora ciało instrukcji jest puste — wszystkiemu winien ów nieszczęsny średnik. Ogólnie rzecz biorąc niezamierzone puste instrukcje to jeden z najczęściej popełnianych błędów, stanowią więc idealną kandydaturę na jedną z kategorii ostrzeżeń produkowanych przez kompilatory języka C. Ostrzeżenia takie są jednak uciążliwe, gdy dana konstrukcja — w tym przypadku instrukcja pusta — jest świadomym produktem programisty; jeżeli więc kompilator nie umożliwia selektywnego włączania i wyłączania obszarów swej „czujności” w różnych miejscach pliku źródłowego, można posłużyć się chwytem zalecanym przez podręczniki programowania — zamiast pustej instrukcji użyć pustego bloku (który i tak najprawdopodobniej zostanie wyeliminowany w procesie optymalizacji), jak w poniższym przykładzie:

char *strcpy(char *pchTo, char *pchFrom)

{

  char *pchStart = pchTo;

 

  while (*pchTo++ = *pchFrom++)

    {}

 

  return (pchStart);

}

Uwalnia to od uciążliwych ostrzeżeń ze strony kompilatora, a dodatkowo rozwiewa wątpliwości co do zasadności pustego ciała instrukcji while.

Mimo wszystko możliwość selektywnego uaktywniania ostrzeżeń w różnych miejscach pliku źródłowego stanowi cenną zaletę kompilatorów (oczywiście tych, które tę możliwość oferują) i przypomina nieco możliwość używania różnych słowników w edytorach, zależnie od charakteru edytowanego tekstu.

Innym, równie powszechnym „idiomem” języka C, zwiastującym być może pomyłkę programisty jest niezamierzone przypisanie, które intencjonalnie miało być porównaniem, na przykład:

if (ch = '\t')

  ExpandTab();

Intencją programisty była tu prawdopodobnie zamiana znaku tabulacji na jego równoważnik, jednak formalnie jest to poprawne przypisanie znaku tabulacji do zmiennej ch.

Niektóre kompilatory, ze względu na opisywany błąd (polegający na zgubieniu jednego znaku z pary „==”) opcjonalnie zabraniają dokonywania prostych przypisań w ramach wyrażeń && i || oraz wyrażeń sterujących instrukcji if, for i while. Pojedynczy znak „=” nie jest w tym przypadku prawdopodobnym zwiastunem błędu programisty, w przeciwieństwie do równoważnej konstrukcji

while (*pchTo++ = *pchFrom++)

  {}

Nie uniemożliwia to oczywiście dokonywania takich przypisań, nakłada jednak wymóg uczynienia takiego przypisania częścią instrukcji porównania, najczęściej z zerem lub znakiem o kodzie 0:

while ((*pchTo++ = *pchFrom++) != '\0')

  {}

Należy zwrócić uwagę na ważny fakt, iż tego typu zabiegi nie powodują z reguły generowania dodatkowego kodu, ze względu na optymalizację dokonywaną przez większość kompilatorów — poprawienie czytelności kodu i jego uwiarygodnienie dokonuje się więc niejako za darmo.

I jeszcze jeden ciekawy przykład błędnego kodowania — gdy wiele lat temu uczyłem się języka C, zdarzyło mi się napisać takie oto wywołanie funkcji fputc():

fprintf(stderr, "Niemożliwe otwarcie pliku %s.\n", filename);

fputc(stderr, '\n');

Na pierwszy rzut oka nie widać w tym nic niezwykłego, jednakże argumenty wywołania funkcji fputc występują w niewłaściwej kolejności! To skutek mylnego przekonania, iż we wszystkich funkcjach operujących na strumieniach, identyfikator strumienia występuje jako pierwszy parametr.

xxxxxxxxxxxxxx

Niestety, prototypowanie nie oznacza całkowitego uwolnienia się od ryzyka związanego z błędnym przekazaniem argumentów do wywoływanych funkcji. W poniższym przykładzie

void *memchr(const void *pv, int ch, int size);

pomyłkowe przestawienie drugiego i trzeciego argumentu nie zostanie zauważone przez kompilator. Można wzmocnić funkcjonalność prototypu w tym względzie przez zdeklarowanie go w następujący sposób:

void *memchr(const void *pv, unsigned char ch, size_t size);

Drugi i trzeci argument różnią się teraz co do typu, więc łatwo będzie wykryć ich ewentualne przestawienie; niejako ubocznym efektem tego zabiegu będzie jednak pojawienie się ostrzeżeń kompilatora o niezgodności typu drugiego argumentu wywołania — można temu zapobiec poprzez zastosowanie jego rzutowania na docelowy typ unsigned char.

Na tego rodzaju błędy ANSI C oferuje na szczęście skuteczne antidotum — jest nim prototypowanie kodu.

Standard ANSI wymaga prototypowania wszystkich funkcji bibliotecznych, do których występują odwołania w tekście programu — w pliku nagłówkowym stdio.h znajduje się taki oto (lub podobny) prototyp funkcji fputc():

int fputc(int c, FILE *stream);

Umożliwia to wykrycie przez kompilator „zamienionych” argumentów wywołania. Chociaż ANSI C wymaga prototypowania jedynie funkcji bibliotecznych, nic nie stoi na przeszkodzie prototypowania wszystkich wywoływanych funkcji. Niektórzy programiści utyskują na wymóg prototypowania, co jest po części usprawiedliwione, zwłaszcza przy przenoszeniu projektów z „tradycyjnego” C — generalnie jednak ów dodatkowy wysiłek stanowi niezbyt wygórowaną cenę za możliwość „wyłapania” wielu błędów już na etapie kompilacji. W dodatku otrzymać możemy „premię” w postaci zoptymalizowanego kodu — standard ANSI zezwala bowiem kompilatorom na dokonywanie optymalizacji na podstawie informacji uzyskanej z prototypów.

Peter Lynch, jeden z najbardziej znanych menedżerów funduszy inwestycyjnych lat osiemdziesiątych zwykł mawiać, że zasadnicza różnica pomiędzy graczami a inwestorami polega na tym, iż ci ostatni wykorzystują każdą okazję inwestycyjną, nawet niewielką, w nadziei przetworzenia jej w znaczące zyski; gracze natomiast dążą do osiągnięcia wielkich zysków, polegając jedynie na szczęściu.

Wykorzystanie ostrzeżeń generowanych przez kompilatory jest właśnie taką drobną, choć wysoko opłacalną (i jednocześnie wolną od ryzyka) inwestycją.

Wykorzystaj możliwości oferowane przez ostrzeżenia kompilatorów.

Pożyteczne Narzędzie — Lint

Uniksowy program lint zaprojektowany został pierwotnie jako analizator programów w języku C, wykrywający wszelkie konstrukcje, które mogłyby powodować problemy z przenośnością kodu źródłowego pomiędzy kompilatorami. Funkcjonalność ta została z czasem wzbogacona w wyszukiwanie fragmentów podejrzanych o skrywanie pomyłek programisty — do kategorii tej należą m.in. opisywane wcześniej puste instrukcje, podejrzane przypisania i błędne argumenty wywołań funkcji.

Niestety, wielu programistów wciąż traktuje lint jako jedynie weryfikator przenośności kodu, nie wart w ogóle zachodu, generujący masę komunikatów, którymi nie warto się przejmować. Czas jednak zweryfikować tę opinię, a przynajmniej zadać sobie pytanie — które narzędzie bliższe jest opisywanemu hipotetycznemu, „inteligentnemu” kompilatorowi: wykorzystywany na co dzień kompilator C, czy właśnie lint?

Warto poświęcić trochę wysiłku, by doprowadzić kod źródłowy do postaci „zgodnej z lintem”, (czyli — nie powodującej generowania ostrzeżeń) i utrzymywać tę zgodność przy każdej modyfikacji kodu. Po tygodniu lub dwóch przestrzeganie owej zgodności ma szansę stać się nawykiem i nie będzie wymagać wielkiego wysiłku ze strony programistów.

Wykorzystaj program lint do wykrycia błędów,
które mogą pozostać niezauważone przez Twój kompilator.

To tylko kosmetyczne zmiany

Jeden z recenzentów niniejszej książki spytał mnie, dlaczego nie poruszam w niej tematyki testowania modułów (ang. unit testing). Odpowiedziałem mu, iż mimo ścisłego związku pomiędzy tworzeniem bezbłędnych programów a testowaniem modułów, to ostatnie jest zagadnieniem nieco innej kategorii — mianowicie „w jaki sposób tworzyć programy testowe do sprawdzania swego kodu”.

„Nie zrozumiałeś mnie” — rzekł wówczas mój rozmówca — „chodzi mi o zwrócenie uwagi na konieczność takich testów”. Okazało się wówczas, iż jeden z programistów w jego zespole często zaniedbuje testowanie swoich modułów przed dołączeniem ich do ”głównego kodu” uzasadniając to stwierdzeniem, iż „wprowadzane przez niego zmiany mają jedynie charakter kosmetyczny, a w ogóle to niczego nie dodawał do kodu, a jedynie ...

Zgłoś jeśli naruszono regulamin