RSS

Jak pisać niezawodne programy

Liczba odsłon: 103

Programowanie nie jest zadaniem prostym, a pisanie programów niezawodnych i wydajnych — tym bardziej. Można jednak nauczyć się paru technik pozwalających zminimalizować ryzyko powstania błędów w tworzonych programach.

Błędy występujące w programach dzielą się generalnie na błędy obliczeniowe i błędy zarządzania pamięcią. Te pierwsze skutkują błędnymi wynikami obliczeń, nieprzewidywalnym zachowaniem programu w pewnych sytuacjach lub natychmiastową awarią całego programu. Przykładami mogą być:

Bezwzględność występowania błędów tej kategorii powoduje, że – choć są uciążliwe – ich odszukanie nie jest bardzo skomplikowane, istnieją bowiem konkretne kombinacje danych wejściowych oraz stanu programu powodujące pojawienie się danego błędu w działaniu.

Błędy związane z zarządzaniem pamięcią są znacznie groźniejszym przeciwnikiem. Przekroczenie granicy zaalokowanego obszaru pamięci może skończyć się uszkodzeniem obcego bloku danych, nie używanego przez daną procedurę. Uszkodzenie zawartości stosu może skutkować absolutnie przypadkowymi skokami podczas powrotu z podprogramu. Krótko mówiąc, błędów związanych z obsługą pamięci nie da się bezpośrednio skojarzyć z konkretną instrukcją programu, a ich objawy występują z opóźnieniem i nieregularnie, często nawet sprowadzając podejrzenia na poprawny fragment kodu.

Techniki walki z błędami można podzielić na cztery kategorie:

  1. Zapobieganie błędom polega na takim projektowaniu programu i wykorzystywaniu narzędzi oferowanych przez język programowania, by większość często spotykanych błędów była unicestwiana już podczas projektowania lub wykrywana podczas kompilacji programu.
  2. Wykrywanie błędów polega na uzupełnieniu kodu programu opcjonalnymi dyrektywami diagnostycznymi, wyłączanymi w finalnej wersji programu za pomocą jednej opcji kompilatora. Ich zadaniem jest sprawdzanie sensowności stanu programu i zawartości pamięci (zgodnie z wiedzą programisty) i wykrywanie wszelkich niedomagań jak najwcześniej, zanim ich skutki zaczną oddziaływać na inne moduły, zacierając ślad.
  3. Unikanie skutków błędów polega na wprowadzaniu w program zabezpieczeń umożliwiających przetrwanie niektórych problemów. Przykładem może być weryfikowanie parametrów wejściowych funkcji i klas oraz poprawna obsługa sytuacji wyjątkowych (ang. exceptions).
  4. Testowanie modułów polega na tworzeniu specjalnych programów diagnostycznych, testujących poszczególne moduły programu w oderwaniu od reszty, sprawdzając ich działanie we wszystkich możliwych przypadkach (a szczególnie w przypadkach skrajnych). Często ten etap można znacząco usprawnić, stosując narzędzia wspomagające diagnostykę działania programu.

Zapobieganie błędom

Skuteczność zapobiegania błędom zależy od wiedzy programisty i możliwości stosowanego języka programowania i kompilatora. Oczywistym jest, że znacznie prościej jest wprowadzić do programu fatalne błędy pisząc go w asemblerze, niż w przypadku programowania w językach C# czy Java.

Na poziomie projektu programu warto podzielić go na moduły funkcjonalne i obiekty lub podprogramy realizujące funkcje elementarne. Takie odrębne części znacznie łatwiej programuje się (co już samo z siebie ogranicza liczbę potencjalnych błędów); ponadto rozdzielone moduły łatwiej poddają się testowaniu.

Języki programowania i kompilatory udostępniają często funkcje zmniejszające ryzyko wystąpienia błędów. Na przykład, jeżeli język programowania pozwala na definiowanie wartości stałych podczas działania programu (słowo kluczowe const w językach C++, Java czy C#), oznaczenie zmiennych, których wartość wyznaczana jest jednokrotnie (a później tylko odczytywana) jako stałych umożliwia likwidację błędów polegających na przypadkowej modyfikacji ich zawartości (na przykład na skutek zamienienia symbolu == na znak = w wyrażeniu warunkowym). Podobnie oznaczenie parametrów podprogramu jako stałych pozwala korzystać z dostarczonych danych bez ryzyka wprowadzenia w nich przypadkowych zmian. Jest to szczególnie istotne, gdy parametry są wskaźnikami: omyłkowa zmiana zawartości zmiennej wskaźnikowej może spowodować trudne do wykrycia sfałszowanie zawartości pamięci.

Wykrywanie błędów

Wykrywanie błędów można realizować w sposób ciągły lub wyrywkowy. Analiza ciągła jest niezwykle skuteczna, lecz spowalnia działanie programu. Analiza wyrywkowa wymaga użycia programu uruchomieniowego (ang. debugger) i angażującego programistę śledzenia – wiersz po wierszu – biegu programu, pozwala jednak wykrywać błędy (często bardzo subtelne) bez konieczności modyfikowania kodu źródłowego czy spowalniania go dodatkowymi wyrażeniami diagnostycznymi.

Analiza ciągła polega najczęściej na wprowadzeniu do kodu źródłowego programu tak zwanych asercji (ang. assertions), czyli wyrażeń warunkowych badanych tylko w roboczej wersji programu. Spełnienie wyrażenia powoduje kontynuację działania programu, niespełnienie — zgłoszenie komunikatu błędu lub nawet przerwanie działania programu.

Zdefiniowanie odpowiedniej opcji kompilacji pozwala wyłączyć wszystkie asercje, usuwając je ze skompilowanego kodu wynikowego. W ten sposób wersja robocza, choć spowolniona, pozwala wykryć i zlikwidować źródła błędów; wersja finalna zaś, pozbawiona balastu kodu diagnostycznego, działa z pełną prędkością.

Sposób implementacji mechanizmu asercji można zademonstrować na przykładzie kodu stosowanego w językach C i C++:

#ifndef NDEBUG
#define assert(s) if (!(s)) report_assertion(#s, __FILE__, __LINE__);
#else
#define assert(s) ((void)0)
#endif

Unikanie skutków błędów

Niektóre błędy nie są spowodowane defektem w samym programie, lecz warunkami zewnętrznymi. Przykładami mogą być brak pamięci operacyjnej, błąd odczytu pamięci masowej, przepełnienie bufora wejściowego albo pojawienie się nieprawidłowych, sfałszowanych lub uszkodzonych danych. Dobrze napisany program nie powinien na przypadki skrajne reagować awarią, lecz omijać je i wycofywać się z niemożliwych do zrealizowania operacji.

Szczególnej uwagi wymagają wszelkie operacje wejścia/wyjścia. Urządzenia pamięci masowej oraz sieciowe mogą przestać funkcjonować w dowolnym momencie. Wszystkie operacje zapisu i odczytu danych muszą być monitorowane, a w razie wykrycia błędu program powinien zgłaszać sytuację wyjątkową, obsługiwaną przez wyższe poziomy kodu.

Z kolei fragmenty programu odbierające dane od użytkownika (szczególnie za pośrednictwem sieci) muszą być przygotowane na możliwość uzyskania danych uszkodzonych lub spreparowanych przez włamywacza. Wszystkie bufory muszą mieć ściśle określone i nieprzekraczalne pojemności, a pojawienie się na którymś z pól niedopuszczalnej wartości powinno powodować odrzucenie żądania i zgłoszenie komunikatu błędu.

Prawdziwie niezawodny program może też dokonywać weryfikacji parametrów wejściowych podprogramów. W przeciwieństwie do asercji, taka weryfikacja może aktywnie zapobiegać skutkom błędów (takich jak przekazanie pustego wskaźnika lub wartości spoza dopuszczalnego zakresu), reagując zgłoszeniem sytuacji wyjątkowej, możliwej do eleganckiego obsłużenia przez wyższy poziom kodu. Nie należy jednak przesadzać z tego typu zabezpieczeniami. Kod wyższego poziomu sam powinien zawierać zabezpieczenia uniemożliwiające przekazanie błędnych parametrów, a ciągła weryfikacja parametrów często wywoływanych podprogramów może znacząco spowolnić działanie programu. Dlatego tak intensywne zabezpieczenia warto stosować tam, gdzie kod wywoływany jest rzadko, a skutki pomyłki mogą być naprawdę duże.

Zabezpieczenia parametrów wejściowych nie będą nigdy idealnie skutecznie, gdyż niemożliwe jest wykrycie wszystkich potencjalnych błędów. Na przykład, o ile można zareagować na przekazanie do podprogramu wskaźnika pustego jako wskaźnika do bufora danych, niemożliwe jest wykrycie zbyt małego bufora lub niezerowego wskaźnika do niezaalokowanego bloku pamięci. O ile zatem warto wykrywać i niwelować błędy powstające na skutek pomyłek w programie lub sytuacji skrajnych, nie ma sensu próbować zabezpieczać podprogramów przed wszystkimi możliwymi błędami.

Testowanie modułów

Podział programu na odrębne moduły funkcjonalne realizujące pewne elementarne operacje pozwala na zwiększenie stopnia abstrakcji programowania, a więc zwiększa przyjemność pisania programu, czytelność kodu źródłowego i – w efekcie – jakość programu. Często wykorzystywane moduły poddawane są ciągłym poprawkom, usuwającym zauważone błędy lub zwiększającym ich odporność na nieprawidłowe dane lub niesprzyjające okoliczności. Korzystają na tym wszystkie programy korzystające z danego modułu.

Wydzielanie modułów funkcjonalnych pozwala również przygotować oprogramowanie diagnostyczne ściśle przystosowane do testowania danego modułu. Oprogramowanie takie najlepiej jest pisać równolegle z tworzeniem modułu, testując na bieżąco działanie poszczególnych podprogramów i wpływ kolejnych zmian na przetestowane już fragmenty. Może ono również służyć do oceniania wydajności poszczególnych operacji podstawowych, pomagając w zadaniu optymalizacji kodu lub wskazując najefektywniejszy sposób wykorzystania modułu.

Oprogramowanie diagnostyczne powinno testować działanie modułu co najmniej dla wszystkich kombinacji danych wejściowych gwarantujących realizację odmiennych ścieżek kodu. Na przykład podprogram wstawiający kolumnę do macierzy powinien zostać przetestowany co najmniej w następujących przypadkach:

Świadomy wybór przypadków testowych, dokonany na podstawie analizy algorytmu oraz implementacji, pozwala wychwycić od 90% do 100% błędów w czasie o kilka lub kilkanaście rzędów wielkości krótszym niż potrzebny na zbadanie wszystkich możliwych kombinacji danych wejściowych.

Oprogramowanie diagnostyczne testujące moduły może też zostać uruchomione pod kontrolą specjalistycznych narzędzi weryfikujących odwołania do pamięci i sposób wywoływania funkcji. Jednym z takich narzędzi diagnostycznych jest Valgrind, którego głównym (choć nie jednym) zadaniem jest wykrywanie błędów w gospodarowaniu pamięcią operacyjną. Ponieważ tego typu narzędzia zazwyczaj poważnie spowalniają działanie programu (od 50% do 4000% dłuższy czas realizacji operacji), używanie ich do testowania programu jako całości jest wysoce nieefektywne (choć zalecane, ze względu na możliwość zbdania interakcji wszystkich modułów). Test programu diagnostycznego używającego jednego modułu i wykorzystującego wszystkie oferowane przez ten moduł funkcje pozwala wykryć specyficzne dla tego modułu błędy i znacząco skrócić niezbędne później testy całościowe.

Tworzenie oprogramowania diagnostycznego do każdego modułu wydłuża czas opracowywania modułu dwu-trzykrotnie, lecz pozwala znacząco skrócić czas uruchamiania i testowania tego modułu oraz podnieść jakość oprogramowania wykorzystującego moduł. Ponieważ w razie wykrycia problemów programista i tak musi tworzyć dodatkowe programy testujące niektóre funkcje (tak zwane test cases) lub mierzące czas realizacji odwołań (ang. benchmarks), opracowanie odpowiednich narzędzi już na etapie projektowania i implementowania modułu stanowi poważną inwestycję w przyszłość.

Podsumowanie

Pisząc niezawodny, stabilny program poświęca się temu zadaniu znacznie więcej czasu, niż po prostu kodując „z pamięci”. Ryzykuje się również, że gotowy program będzie nieco większy i nieco wolniejszy, niż gdyby pozbawiony był jakichkolwiek zabezpieczeń. Po przekroczeniu jednak pewnego progu okazuje się, że dodawanie nowych funkcji do programu jest bajecznie proste i szybkie, gdyż wszystkie moduły są już przetestowane i zapewniają poziom abstrakcji na tyle wysoki, że zapisanie przygotowanego algorytmu nie wymaga długotrwałej walki z kodem. Również testowanie programu staje się coraz szybsze, gdyż większość błędów (nie wyeliminowanych procesem projektowym oraz zastosowaniem przetestowanych modułów funkcjonalnych) zostaje wykrytych podczas kompilacji lub pierwszego uruchomienia programu. Najważniejsze jednak, że gotowy program może działać niezawodnie całymi tygodniami bez przerwy, nie poddając się obciążeniu nawet w krytycznych warunkach.