Styl programowania a jakość programów

| Technika

Konwencje i wytyczne poprawnościowe w dziedzinie programowania, które narzucają programistom pewne reguły natury, na pierwszy rzut oka, czysto estetycznej, bywają czasem postrzegane jako zbędna uciążliwość. W rzeczywistości pełnią one inną funkcję: mają po prostu polepszać jakość kodu przez propagowanie najbardziej dalekowzrocznych z wypracowanych dotąd strategii programowania. Jest to szczególnie widoczne przy programowaniu w asemblerze, ale w niemal równym stopniu dotyczy wszystkich języków programowania.

Styl programowania a jakość programów

Takie wytyczne mogą obejmować reguły pozwalające uniknąć poprawnych, ale potencjalnie niebezpiecznych konstrukcji składniowych, nakazujące ograniczenie złożoności funkcji, procedur i podprogramów oraz wymagające konsekwentnego stosowania jednolitego stylu, tak w dziedzinie składni języka, jak i w komentarzach. Narzucającym się przykładem takiej reguły może być oklepana formułka "nie używaj GOTO": mimo że wszystko, co zawiera standard danego języka - wliczając w to nieszczęsne GOTO - zostało do niego załączone w jakimś celu i ma swoje zastosowania, dopuszczenie stosowania GOTO w ogóle może prowadzić do szczególnych przypadków nadużyć. Takie reguły mogą zatem wydatnie ograniczyć sposobność do popełniania przez programistę różnych trywialnych błędów, spowodować, że programy będą łatwiejsze do testowania oraz, przede wszystkim, polepszyć czytelność i zrozumiałość kodu źródłowego w dalszej perspektywie. Zestawy takich wytycznych są różne, ale mają wspólną cechę - zmieniają się z czasem. Na przykład zespół programistów może stwierdzić dostępność nowego narzędzia, którego stosowanie poprawia solidność kodu, i zalecić, żeby kierownictwo projektu wymogło na wszystkich podległych mu deweloperach używania tego narzędzia podczas dalszego procesu produkcji programu.

Jest też częstą cechą takich zestawów wytycznych, że składają się na nie ogólne reguły, których stosowanie jest wymuszane przede wszystkim na drodze weryfikacji kodu przez człowieka. A przecież stworzenie nowego standardu programowania, zawierającego dziesiątki szczegółowych regułek, których zastosowanie programista musi "ręcznie" sprawdzać, jest znakomitym sposobem na zmniejszenie wydajności powstawania programów, nawet jeśli zwiększa ich stabilność.

Istnieje wiele narzędzi do statycznej analizy kodu, które w znacznej mierze automatyzują proces sprawdzania, czy dany kod spełnia narzucone standardy programowania. Podobną analizę są w stanie zrobić również niektóre kompilatory. Co więcej, mimo że niektóre reguły są z konieczności związane z konkretnym językiem programowania, istnieją też obok nich takie, które można z powodzeniem stosować do programowania w każdym lub niemal każdym języku i które powinny stanowić integralną część zasad każdego dobrego stylu programowania. Zakładając, że wszystkie wytyczne, o których tu mowa, poprawiają jakość oprogramowania, za najlepsze należy uznać właśnie te, których weryfikacja może być przeprowadzona automatycznie i które jednocześnie mają zastosowanie przy każdym projekcie, w którym mamy do czynienia z tworzeniem programu od podstaw bądź rozwijaniem czegoś istniejącego.

Ostrzeżenia traktuj jak błędy

Kompilatory oraz inne narzędzia służące do wytworzenia i uruchomienia gotowego programu (jak linker) często wyświetlają ostrzeżenia (warnings), zamiast przerywać cały proces i sygnalizować błąd. Takie ostrzeżenie sygnalizuje programiście, że zastosowana konstrukcja jest wprawdzie, technicznie rzecz biorąc, do pomyślenia, ale jej użycie budzi pewne wątpliwości. Może to być na przykład sytuacja, kiedy programista wkracza na pewne niezbyt dobrze zdefiniowane obrzeża standardu języka. Podręcznikowym przykładem może być kolejność wykonywania inkrementacji i przypisań w języku C, w wyrażeniach typu:

a[i]=++i;

Wynik będzie tu zależny od implementacji języka, czyli program, nawet jeśli działa poprawnie, może się okazać nieprzenośny na inny kompilator. Innym powodem wyświetlenia ostrzeżenia może być obecność konstrukcji formalnie poprawnej, której wynik jest zależny od cech konstrukcyjnych maszyny, na której chcemy uruchomić nasz program. Tu z kolei podręcznikowym przykładem mogą być konwersje liczb całkowitych do wskaźników i na odwrót, o których sukcesie lub niepowodzeniu decyduje (obojętna z punktu widzenia składni języka) zależność pomiędzy np. liczbą bitów wskaźnika a rzeczywistym rozmiarem zmiennej typu int. Tego typu konstrukcje bywają zresztą przyczyną trudnych do wykrycia błędów. Pewność, że programiści nie ignorują ostrzeżeń kompilatora czy linkera przez niedbalstwo lub przeoczenie, uzyskujemy, wydając zalecenie, żeby nakazać kompilatorowi traktowanie ostrzeżeń jako błędów - większość kompilatorów oferuje taką opcję.

Standardy językowe

Większość kompilatorów ma też opcje pozwalające wymusić mniej lub bardziej pedantyczne sprawdzanie poprawności składniowej programu w ramach konkretnego standardu danego języka. Na przykład konsorcjum Motor Industry Software Reliability Association opublikowało wytyczne użycia języka C w programowaniu systemów mikroprocesorowych i mikrokomputerów jednoukładowych. Standard ten opracowano pierwotnie z myślą o przemyśle samochodowym (stąd nazwa konsorcjum), ale przyjął się też w innych gałęziach przemysłu, zwłaszcza tam, gdzie wymagania co do niezawodności systemów są szczególnie wysokie (np. w przemyśle lotniczym: koncern Lockheed Martin wydał własną, zaostrzoną wersję MISRA, znaną jako Joint Strike Fighter Air Vehicle C++ Coding Standards). Inne przykłady standardów tego typu to:

  • EC - opublikowany w 2003 roku podzbiór ISO C94 przeznaczony dla systemów embedded,
  • Cert C Coding Standard - opracowany na Carnegie Mellon University w Pittsburghu,
  • lista Security Development Lifecycle (SDL) Banned Function Calls opublikowana przez Microsoft.

Istnieją kompilatory, które są w stanie wymusić na programiście stosowanie się do takich zestawów reguł przez ograniczenie języka do podzbioru wykluczającego konstrukcje, o których się sądzi, że ich stosowanie może przyczyniać się do obniżenia jakości oprogramowania.

Niektóre z tych reguł mają status zaleceń, a ich pogwałcenie spowoduje raczej wyświetlenie ostrzeżenia niż przerwanie kompilacji z komunikatem o błędzie. Ale, jak to już zasugerowano wyżej, jeśli standard jest w użyciu, kompilatorowi i tak powinno się kazać wygenerować błąd po napotkaniu każdej konstrukcji, niezgodnej z tym standardem.

Ze względów praktycznych oczywiście nie zaleca się, żeby wszystkie zespoły deweloperskie przyjęły konkretny standard i fanatycznie trzymały się jego regułek. Raczej przeciwnie, są np. dobre powody, żeby niektórych z wytycznych takiego standardu MISRA nie traktować bardzo serio. Natomiast jest bardzo ważne, żeby programiści dostosowali kod do reguł standardu od razu po tym, jak kierownictwo projektu podejmie decyzję o wdrożeniu określonych reguł i zaleci stosowanie narzędzi wymuszających ich stosowanie w powyżej opisany sposób, tj. przez generowanie błędów zamiast ostrzeżeń o niezgodności składni programu z formalnymi wymogami standardu.

Statyczne analizatory kodu wyszukują problematyczne konstrukcje, analizując kod źródłowy programu. Dla samego języka C i C++ jest ich wiele, od bardzo prostych narzędzi darmowych (jak flawfinder), poprzez bardziej złożone (jak SPLINT), aż do zaawansowanych programów komercyjnych. Niezależnie od dokonanego wyboru trzeba oczywiście pamiętać, że narzędzia te są tylko automatem i jako takie mogą zostać użyte niewłaściwie. Statyczna analiza kodu, zwłaszcza w wykonaniu prostego narzędzia, może zaowocować zarzuceniem programisty masą nieistotnych (w konkretnym wypadku) komunikatów, na których usunięcie będzie on marnotrawił czas, zamiast skupić się na szybkim rozwiązaniu istotnych problemów. Z drugiej strony, kierownictwo projektu po zaleceniu programistom stosowania podobnego narzędzia powinno sprawdzić, co dokładnie zrobiono, żeby analizator przestał "narzekać" - w Internecie można znaleźć konkretne przykłady niewłaściwych (bądź zgoła nieuczciwych) decyzji podjętych przez programistów w sprawie modyfikacji kodu, który wzbudził wątpliwości analizatora.

Ogólniej, faza dostosowawcza zawsze generuje pewne koszty: czasu spędzonego na wprowadzaniu zmian do kodu i ponownym testowaniu, oraz przynosi pewne ryzyko, przede wszystkim - całkiem spore ryzyko wprowadzenia do programu nowych błędów. Z tego względu kierownictwo musi postępować ostrożnie przy narzucaniu programistom nowych wytycznych. Poniżej omówione, niektóre reguły standardu MISRA stanowią tego ilustrację.

MISRA C:2004

Jak to bywa w przypadku konwencji użycia języka programowania, standard MISRA zawiera wiele reguł bardzo użytecznych oraz kilka takich, które są albo dyskusyjnej użyteczności, albo są zgoła niewłaściwe dla niektórych użytkowników i zastosowań.

MISRA 2004, standard mający 141 wytycznych, poprawił kilka wątpliwej wartości reguł zaproponowanych przez MISRA w roku 1998. Ale nawet standard MISRA 2004 jest obiektem krytyki jako stanowiący mieszankę regułek istotnych, mało istotnych oraz czysto stylistycznych. To z kolei powoduje wymierne efekty w postaci działania analizatorów kodu zgodnych z tym standardem, które mają skłonność do generowania zbędnego szumu w postaci masy komunikatów dotyczących spraw nieistotnych bądź mało istotnych, przez co programista, jak to ktoś określił, ma duże szanse niezauważenia lasu spoza drzew.

Drugim (formalnym) zarzutem do MISRA 2004 jest fakt, że opiera się on na standardzie ISO C 9899: 1990, znanym jako C90. Jednak jeszcze przed rokiem 2004 standard ten został zastąpiony przez ISO C99, a z natury rzeczy, jako że nowy standard ISO zastępuje całkowicie stary, wynika, że MISRA 2004 opiera się na standardzie języka C, który w roku 2004 nie był już standardem. Ten zarzut można odeprzeć, co niektórzy robią, twierdząc, że standard ISO C99 się po prostu nie przyjął, przynajmniej nie do roku 2004. W każdym razie dopiero standard MISRA 2012 (zaanonsowany w marcu 2013 roku) opiera się na ISO C99.

Dlatego jeśli przedsiębiorstwo ma się trzymać tego standardu, można i należy rozważyć jego tylko częściowe stosowanie, jednakże wybór tego podzbioru musi przedtem zostać starannie przemyślany. Jest też bardzo ważne, żeby program sprawdzający poprawność składni według reguł MISRA (często wbudowany bezpośrednio w kompilator) oferował możliwość wybiórczego włączania i wyłączania konkretnych regułek w obrębie poszczególnych modułów i funkcji tworzonego programu.

Poniżej prezentujemy próbkę niektórych reguł MISRA, która unaocznia niektóre z pułapek języka C, oraz wskazuje, jak można ich uniknąć.

Reguła 7.1: "nie należy używać stałych ósemkowych (innych niż zero) ani ósemkowych sekwencji kontrolnych (escape sequences)".

Poniższy przykład demonstruje użyteczność tej reguły:

a |= 256;
b |= 128;
c |= 064;

Pierwsze wyrażenie ustawia bit ósmy zmiennej a. Drugie wyrażenie ustawia bit 7 zmiennej b. Ale trzecie wyrażenie nie ustawia szóstego bitu zmiennej c. Stała 064 zaczyna się cyfrą zero, wobec czego jest interpretowana przez kompilator C jako stała ósemkowa. Ósemkowe 64 jest równe 0×34 w kodzie szesnastkowym; w związku z tym trzecie wyrażenie ustawia bity 2, 4 i 5 zmiennej c.

Liczby ósemkowe składają się z cyfr od 0 do 7, dlatego programista analizujący kod będzie miał skłonność do odruchowego rozumienia ich jako stałych dziesiętnych. MISA pozwala uniknąć tego kłopotu przez nałożenie wymogu, żeby wszystkie stałe były albo dziesiętne, albo szesnastkowe.

Reguła 8.1: "funkcje powinny być deklarowane przez prototypy, a prototyp powinien być widoczny zarówno dla definicji funkcji, jak i dla jej wywołań".

Standard MISRA precyzuje, że prototypy dla funkcji zewnętrznych powinny być zadeklarowane w pliku nagłówka (*.h) i załączone dyrektywą #include przez wszystkie moduły, które zawierają definicję funkcji oraz jej wywołania.

Trzeba zauważyć, że program weryfikujący zgodność składni z konwencją MISRA jest jedynie w stanie stwierdzić, że wywołanie danej funkcji jest poprzedzone przez jakiś prototyp, ale może nie być w stanie sprawdzić, czy wszystkie wywołania konkretnej funkcji są poprzedzone przez ten sam prototyp. Niewłaściwe prototypy mogą być przyczyną trudno wykrywalnych błędów, a to jest już gorsze niż brak prototypów w ogóle. Zastanówmy się nad następującym przykładem, w którym mamy zdefiniowaną w języku C funkcję oraz jej wywołanie, przy czym jedno i drugie umieszczono w oddzielnych modułach:

Moduł 1:

void read_temp_sensor(float *ret)
{
*ret = *(float *)0xfeff 0;
}

Moduł 2:

float poll_temperature(void)
{
extern float read_temp_sensor(void);
return read_temp_sensor();
}

Oba fragmenty kodu to doskonale poprawne ANSI/ISO C. Ale program nie będzie działał dobrze, bo definicja funkcji read_temp_sensor() oraz jej wywołanie nie zgadzają się ze sobą: wywołanie liczy na to, że funkcja zwraca jakąś wartość, natomiast w rzeczywistości definicja funkcji przewiduje zapisanie tej wartości pod adresem wskazanym przez parametr.

Powyższy przykład demonstruje skutki ulegania tylko jednemu złemu nawykowi, mianowicie nawykowi używania deklaracji funkcji extern tuż przed miejscem jej wywołania. Mimo że ścisłe trzymanie się standardu ANSI C wymaga zastosowania prototypu, sam standard nie precyzuje, gdzie taki prototyp należy umieścić. Reguła 8.6 MISRA: "funkcje należy deklarować na poziomie modułu", próbuje uniknąć tej pułapki przez zakaz deklarowania funkcji na poziomie funkcji. Poniższy przykład ilustruje sytuację, kiedy kod spełnia ten wymóg MISRA, niemniej program będzie działał tak samo źle, jak ten z przykładu powyżej:

extern float read_temp_sensor(void);
float poll_temperature(void)
{
return read_temp_sensor();
}

Chociaż MISRA w zasadzie nie zabrania deklarowania funkcji poza plikami nagłówkowymi, można byłoby wziąć taki zakaz pod uwagę jako rozsądne rozszerzenie standardu. Zadeklarowanie tej funkcji w pliku nagłówkowym z całą pewnością zmniejsza prawdopodobieństwo wystąpienia takiego błędu, ale nie jest całkowitym zabezpieczeniem, gdyż pliku nagłówkowego zawierającego deklarację nie można załączyć do modułu zawierającego (niekompatybilną) definicję.

W rzeczywistości jest tylko jeden sposób upewnienia się, że prototyp i definicja funkcji zgadzają się ze sobą: mianowicie wykrywanie takich niezgodności przez analizę całego programu. Taką analizę może przeprowadzić analizator kodu albo linker podczas konsolidacji gotowego programu.

Jeśli narzędziem weryfikacji ma być linker, kompilator przetwarzając wyżej wspomniany fragment kodu, powinien wstawić do kodu wynikowego znacznik, dajmy na to specjalny symbol albo specjalny wpis w informacji dla relokatora, który sygnalizowałby typ danej zwracanej przez funkcję oraz typy parametrów przyjmowanych przy wywołaniu. Podczas fazy konsolidacji linker może w takiej sytuacji porównać znaczniki dla nazwanych tak samo funkcji i zasygnalizować błąd w wypadku niezgodności typów.

To sprawdzenie zgodności oczywiście minimalnie wydłuży czas budowania gotowego programu (w zaniedbywalnym stopniu: linker i tak musi sprawdzić wszystkie wywołania funkcji w celu wygenerowania informacji dla relokatora), ale z drugiej strony gwarantuje, że typy parametrów i zwracanych wyników będą się zgadzać, co w sumie znacznie polepszy jakość stworzonego programu. Dodatkową zaletą jest możliwość sprawdzenia w ten sposób odwołań do bibliotek, których kod źródłowy może nie być dostępny do analizy. Oczywiście zakładamy, że biblioteki skompilowano z użyciem opisanego mechanizmu.

Reguła 8.9: "identyfikator użyty w więcej niż jednym module powinien mieć tylko jedną definicję".

Ten wymóg jest analogiczny do poprzedniego. Niezgodności definicji zmiennych mogą powodować błędy, których zwykły kompilator nie wychwyci. W poniższym przykładzie zmienna temperature może przyjmować wartości z zakresu od 0 do 255:

Moduł 1:

#include <stdio.h>
unsigned int temperature;
int main(void)
{
set_temp();
printf("temperature = %dn", temperature);
return 0;
}

Moduł 2:

unsigned char temperature;
void set_temp(void)
{
temperature = 10;
}

Bez przeprowadzenia dodatkowej kontroli składni, wybiegającej poza standard języka C, ten program skompiluje się bezbłędnie, mimo że definicje zmiennej temperature w obu modułach nie zgadzają się ze sobą. Na maszynie, w której słowa zorganizowane są w kolejności od najstarszego do najmłodszego bajtu (tzw. big endian), przy typie int o rozmiarze 32 bitów oraz typie char o rozmiarze ośmiu bitów, ta funkcja da wynik: temperature = 167772160

Jak to pokazano poprzednio na przykładzie prototypów funkcji, do wykrycia tego rodzaju błędu potrzebna jest analiza całego programu, a nie tylko każdego modułu z osobna. I znowu, takiej analizy mógłby dokonać linker.

Reguła 8.11: "powinno się używać typu pamięci static dla obiektów używanych w obrębie tylko jednego modułu".

W wyniku stosowania tej reguły, dwóch programistów pracujących na dwóch różnych modułach tego samego programu może użyć zmiennych o tych samych nazwach, ale o różnym przeznaczeniu. Gdyby zaniedbać dodania static do deklaracji tych zmiennych, zmiana w jednym module mogłaby wpłynąć na działanie kodu w drugim i vice versa (wykryciem tego typu konfliktu, jak powyżej, powinien zająć się linker).

Chociaż reguły MISRA 8.9 i 8.11 zapobiegają wielu błędom wynikającym z zastosowania niezgodnych definicji funkcji i zmiennych, oczywiście nie zapobiegają wszystkim tego typu kłopotom. Przykładem może być niezamierzone odwołanie do funkcji lub zmiennej wyeksportowanej przez bibliotekę.

Przy okazji można wspomnieć, że gromadzenie raz napisanych procedur do ponownego użytku w postaci bibliotek jest warte zalecenia w ramach przyjętego standardu programowania. Na podobnej zasadzie wiele systemów korzysta z bibliotek standardowych, wykonujących "stałe fragmenty gry": przykładem może być biblioteka standardowa języka C zapewniająca funkcje służące do obróbki ciągów tekstowych, zarządzania pamięcią oraz obsługująca wyjście na konsolę i wejście z tejże.

Jest bardzo prawdopodobne, że przy tworzeniu dużego programu będzie się używać pewnej liczby dedykowanych bibliotek. Takie biblioteki eksportują funkcje do wykorzystania przez kod aplikacji. Mogą się przy tym pojawić kłopoty wynikające z faktu, że programiści zajmujący się bibliotekami i programiści zajmujący się aplikacją mogą nie być w stanie dokładnie przewidzieć i uzgodnić, jakiego rodzaju zestawami funkcji eksportowanych z bibliotek będą się posługiwać w trakcie trwania całego projektu.

Biblioteka może definiować zmienne i funkcje globalne w celu wykorzystania ich tylko przez inne moduły tejże biblioteki. Ale w czasie konsolidacji takie globalne symbole traktowane są tak, jakby miały dotyczyć całego programu, w związku z czym linker może dokonać niewłaściwego przypisania znajdujących się wewnątrz aplikacji wywołań do definicji wewnętrznych symboli biblioteki, nieprzeznaczonych do wykorzystania przez aplikację.

Za przykład niech posłuży aplikacja używająca funkcji print. Programista tworzący aplikację może zakładać użycie biblioteki obsługującej drukowanie, przygotowanej przez zespół zajmujący się oprogramowaniem drukarki. Ale tymczasem inny zespół, w ramach tego samego projektu zajmujący się obsługą fontów, tworzy bibliotekę zawierającą zestaw dedykowanych funkcji. Programista aplikacyjny używa tej biblioteki, nie zdając sobie sprawy, że definiuje ona funkcję print przeznaczoną do użytku wewnętrznego. Jeśli nie istnieje mechanizm ograniczający eksport wewnętrznych symboli biblioteki do globalnej przestrzeni nazw aplikacji (a taki mechanizm powinien być częścią przyjętego standardu programowania), linker może przypisać wywołania print znajdujące się w aplikacji do niewłaściwej biblioteki, przez co program nie będzie działał.

Ten problem nie może być rozwiązany tylko przez kompilator czy linker. Jedno z możliwych rozwiązań to użycie dodatkowego programu w trakcie budowania aplikacji, który ukryje wewnętrzne definicje bibliotek tak, że będą one widoczne dla linkera w trakcie budowania konkretnej biblioteki, ale żeby nie przeszkadzały podczas konsolidowania całej aplikacji.

Niektóre języki programowania, jak C++ czy Ada, automatycznie wymuszają separację przestrzeni nazw i zgodność typów, podczas gdy inne, jak język C, dopuszczają tu znaczną dowolność. Co za tym idzie, wybór właściwego języka programowania może sprawić, że pewne reguły staną się banalne do wyegzekwowania.

Reguła 16.2: "funkcje nie powinny wywoływać samych siebie, ani pośrednio, ani bezpośrednio".

Rekurencja to prawie zawsze zły pomysł w przypadku małych systemów mikroprocesorowych, grożący co najmniej przepełnieniem stosu. O ile bezpośrednie wywołania rekurencyjne są łatwe do znalezienia, o tyle wywołania pośrednie tego typu mogą być bardzo trudne do wykrycia.

Złożone programy aplikacyjne, posługujące się wielokrotnie zagnieżdżonymi wywołaniami funkcji oraz używające wskaźników do funkcji, mogą zawierać łatwe do przeoczenia odwołania rekursywne. To kolejny przypadek, kiedy program analizujący całość programu (taki jak linker), a nie tylko poszczególne moduły (jak kompilator), jest niezbędny do wykrycia tego typu odwołań. Ale poradzenie sobie z zadaniem wyszukania wszystkich przypadków odwołań pośrednich, takich jak dynamicznie przydzielanych wskaźników funkcji, tablic wskaźników funkcji, metod wirtualnych C++, może okazać się zadaniem przekraczającym możliwości narzędzi automatycznych, a to ze względu na niejednoznaczność celu takiego wywołania.

Programista powinien wykonać kilka prostych testów, żeby sprawdzić, jakie ograniczenia ma narzędzie do sprawdzania zgodności ze standardem MISRA. Jeśli producent tego narzędzia nie jest w stanie go ulepszyć lub przystosować do wymagań danego projektu, programista powinien albo spróbować zmienić narzędzie, albo przyjąć ostrzejsze reguły poprawnościowe w celu ograniczenia występowania pośrednich wywołań funkcji.

EC

EC jest standardem zaproponowanym w 2003 roku przez Lesa Hattona, wówczas pracownika naukowego University of Kent. Powstanie EC wynikło z krytyki standardu MISRA. W przeciwieństwie do tego ostatniego, EC opiera się na ISO C94 i (częściowo) na ISO C99 i ma tylko 20 prostych reguł. Parę przykładów poniżej:

Reguła 4.3.5: "każde wyrażenie musi dawać przynajmniej jeden efekt uboczny dla każdej ścieżki programu".

"Efekt uboczny" to modyfikacja pliku, zapis struktury danych albo dostęp do struktury typu volatile. Jeśli wyrażenie nie daje takiego efektu, oznacza to, że po prostu nic nie robi. Z tego powodu kompilatory często usuwają je z programu w procesie optymalizacji, nie sygnalizując tego ostrzeżeniem. Nie jest to najlepszy pomysł, gdyż jasne jest, że programista z całą pewnością nie zamierzał użyć wyrażenia, które nic nie robi. Klasyczny przykład:

j == i;

Jest to zwykła literówka, bo z całą pewnością miało tu być:

j = i;

Wzmianka o "każdej ścieżce programu" ma na celu zapobieżenie takim "kwiatkom":

(i > j) ? k++ : k;

co jest niepotrzebną komplikacją skutkującą zaciemnieniem kodu, gdyż zamiast tego wystarczy napisać:

if (i > j) k++;

Reguła 4.3.10: "zmienne lokalne nie powinny mieć tych samych nazw, co inne zmienne lokalne zdefiniowane w tej samej funkcji, nawet jeśli występują w różnych blokach programu".

Celem jest tu uniknięcie zamieszania utrudniającego analizę programu, jak w przykładzie poniżej:

int i = 4; /* i ma wartość 4 */
...
{
int i = 5; /* i ma wartość 5 */
...
}
/* i ma wartość 4 */

Reguła 4.3.19: "żadne makro wyglądające jak funkcja nie powinno używać parametru więcej niż raz".

Weźmy:

#define SQR(x) ((x)*(x))
...
z=SQR(y++);

Kryją się tu dwie niespodzianki mogące zaskoczyć programistę: po pierwsze, zmienna y zostanie zwiększona o 2. Po drugie, wynik obliczenia zależy od kolejności wykonywania inkrementacji i przypisań, a to, jak już o tym była mowa wyżej, jest zależne od implementacji kompilatora.

Niezgodność z niektórymi z tych wytycznych kompilatory czasem sygnalizują ostrzeżeniem, ale często - jak np. kompilator GNU C - dopiero na wyraźne życzenie operatora.

MISRA C++:2008

Standard MISRA dla C++ został opublikowany w 2008 roku i, jak się można było spodziewać, zawiera w sobie znaczną liczbę reguł obecnych w standardzie MISRA dla języka C. Składa się on jednakże z 228 wytycznych, co stanowi liczbę o połowę większą niż w przypadku MISRA C. Dodatkowe wytyczne dotyczą metod wirtualnych, obsługi stanów wyjątkowych, przestrzeni nazw, dostępu do danych hermetyzowanych i innych specyficznych cech języka C++.

Reguła 9.3.2: "funkcja składowa nie powinna zwracać uchwytów do pól prywatnych obiektu, jeśli nie są one stałymi".

Prosty przykład obiektu niespełniającego tego wymogu:

#include <stdint.h>
class temperature
{
public:
int32_t &gettemp(void) { return the_temp; }
private:
int32_t the_temp;
}
int main(void)
{
temperature t;
int32_t &temp_ref = t.gettemp();
temp_ref = 10;
return 0;
}

Jednym z głównych celów powstania języka C++ jest upowszechnianie przejrzystych i łatwych w utrzymaniu interfejsów aplikacji, a to przez zaoferowanie mechanizmów służących do ukrywania i hermetyzacji wybranych części obiektu. Obiekt w C++ typowo składa się z danych wewnętrznych (prywatnych) oraz widocznych z zewnątrz funkcji składowych. Funkcje zapewniają dostęp do obiektu z zewnątrz, pozwalając programistom zajmującym się implementacją obiektu na dowolne modyfikacje jego struktur wewnętrznych bez wpływania na to, w jaki sposób program-klient z nich korzysta.

W danym przykładzie funkcja składowa gettemp zwraca adres wewnętrznej struktury danych obiektu. Bezpośredni dostęp do takiej struktury przez program-klienta narusza podstawowe zasady programowania obiektowego w C++. Sposób oczywistego (i zgodnego z przepisami MISRA) ulepszenia tego przykładu byłby następujący:

#include <stdint.h>
class temperature
{
public:
int32_t gettemp(void) { return the_temp; }
void settemp(int32_t t) { the_temp = t; }
private:
int32_t the_temp;
}
int main(void)
{
temperature t;
t.settemp(10);
return 0;
}

Jeśli programista implementujący ten obiekt zdecyduje, że do zapisania temperatury wystarczy osiem bitów, może zmodyfi kować struktury wewnętrzne, co pozostanie bez wpływu na programy aplikacyjne korzystające z obiektu:

#include <stdint.h>
class temperature
{
public:
int32_t gettemp(void) { return the_temp; }
void settemp(int32_t t) { the_temp = t; }
private:
int8_t the_temp;
}

Implementacja z pierwszego przykładu wymagałaby w tym momencie modyfikacji po stronie programu aplikacyjnego korzystającego z obiektu, gdyż zmienia się rozmiar kluczowej zmiennej.

Embedded C++

Niektóre bardziej zaawansowane możliwości języka C++, takie jak dziedziczenie wielobazowe, mogą skutkować tym, że programiści będą używać konstrukcji niezbyt odpornych na błędy, ale za to trudnych do analizy i modyfikacji, a na dodatek nieefektywnych lub takich, dla których czas wykonywania i zajętość zasobów będą trudne do oszacowania. Z tego powodu konsorcjum producentów mikrokontrolerów (głównie Fujitsu, Hitachi, NEC i Toshiba) oraz narzędzi programistycznych opracowało specyfikację podzbioru języka C++ zwaną Embedded C++. Jest ona w obiegu już od około 15 lat.

Celem stworzenia Embedded C++ jest zaopatrzenie programistów z doświadczeniem w języku C w język programowania poszerzony o obiektowe możliwości C++, jednak pozbawiony bagażu tych jego cech, które mogą programistę prowadzić na manowce tworzenia kodu niestabilnego lub nieefektywnego. Mając to na uwadze, Embedded C++ usuwa z języka następujące cechy C++:

  • dziedziczenie wielobazowe,
  • obiekty wirtualne,
  • rzuty (casts) nowego typu,
  • modyfikator mutable,
  • przestrzenie nazw,
  • RTTI,
  • obsługę wyjątków,
  • szablony.

Przykładem uzasadnienia tego typu ograniczeń może być trudność w oszacowaniu czasu wykonania i zapotrzebowania na zasoby, jakich może potrzebować obsługa wyjątku. Kiedy wyjątek występuje, kod jego obsługi wygenerowany przez kompilator wywołuje destruktory dla wszystkich obiektów, jakie zostały automatycznie utworzone od chwili, gdy wykonano odnośny blok try.

Rozmiary i czas wykonania tego ciągu destruktorów, w przypadku bardzo skomplikowanych aplikacji, mogą być skrajnie trudne do oszacowania. Co więcej, kompilator tworzy kod obsługi wyjątku, żeby przywrócić kontekst stosu, jaki obowiązywał w chwili wywołania bloku try. Zapotrzebowanie tego wszystkiego na zasoby może być bardzo trudne do przewidzenia. Jako że obsługa wyjątków jest wkompilowana w standardową bibliotekę C++, ma ona wpływ na wielkość i "ciężar" nawet tych programów, które w ogóle z tego mechanizmu nie korzystają.

Z tego powodu zestaw narzędzi Embedded C++ zaopatrzony jest również w biblioteki specjalnie stworzone z myślą o tym podzbiorze języka C++. Oszczędność zasobów jest też powodem, dla którego Embedded C++ nie przewiduje użycia szablonów: w niektórych przypadkach kompilator może wytworzyć z szablonu dużą liczbę instancji dla poszczególnych funkcji, co z kolei może prowadzić do nieoczekiwanej pamięciożerności gotowego programu.

Rzecz jasna, niektóre z usuniętych rzeczy mogą być skądinąd bardzo użyteczne. Na przykład rozsądne użycie szablonów może pozwolić na uniknięcie nadmiernego rozbuchania rozmiaru programu, oferując jednocześnie prostsze i łatwiejsze w utrzymaniu struktury kodu źródłowego. Dlatego wiele kompilatorów oferuje warianty standardu Embedded C++ i pozwala przedsiębiorstwom na ponowne dodanie do podzbioru języka tych możliwości, które można zaakceptować ze względu na bezpieczeństwo programowania, zwłaszcza jeśli możliwości tych używa się z głową (za przykład może posłużyć włączenie niektórych lub wszystkich zasad MISRA C++). Dajmy na to, kompilator C++ firmy Green Hills Soft ware pozwala na opcjonalne użycie szablonów, wyjątków i innych tego typu możliwości w ramach podzbioru Embedded C++ (także wraz ze sprawdzaniem zgodności ze standardem MISRA). Kompilatory zgodne z Embedded C++ oferują także m.in. IAR Systems oraz Freescale Semiconductors.

Trzeba nadmienić, że standard Embedded C++ przyjął się głównie w Japonii, i są głosy, że jest w zasadzie martwy poza tym krajem. Krytycy jako jedną z głównych wad wymieniają - wspomnianą powyżej - konieczność stosowania specjalnych bibliotek, niekompatybilnych ze standardowymi.

Jak sobie radzić ze złożonością kodu?

Wiele napisano o korzyściach płynących ze zmniejszania złożoności kodu na poziomie funkcji. Podzielenie modułu na mniejsze funkcje sprawia, że każda z takich funkcji oddzielnie jest łatwiejsza do zrozumienia, modyfikacji i późniejszego przetestowania. Zasadę nakazującą ograniczenie złożoności funkcji można łatwo wyegzekwować, używając kompilatora zdolnego przeprowadzić obliczenie złożoności kodu w pewnych umownych jednostkach, i zgłosić błąd, kiedy ta miara zostanie przekroczona.

Jako że kompilator i tak przetwarza i analizuje cały program, dodanie obliczenia złożoności cyklomatycznej (w rodzaju miary McCabe’a) nie powinno stanowić dla niego zbytniego obciążenia. Ponieważ kompilator jasno wskazuje, która funkcja stanowi problem, nie ma możliwości, by programista przypadkowo stworzył kod łamiący nakazaną regułę.

Wybór maksymalnej dopuszczalnej złożoności (mierzonej w wybranych jednostkach umownych) jest oczywiście rzeczą do dyskusji. Jeśli istniejący kod źródłowy jest jasno i logicznie podzielony na moduły, można wybrać taką wartość, która pozwoli go w większości skompilować. W takiej sytuacji nie ma przeszkód, żeby nowy kod również podlegał dokładnie tym samym restrykcjom.

Gdy pomiar złożoności zostanie zastosowany do dużej ilości kodu, na którym nie wykonywano nigdy dotąd takiej operacji, jest bardzo prawdopodobne, że jakaś niewielka liczba rozbudowanych funkcji nie "zda" takiego "egzaminu". Kierownictwo projektu powinno wtedy rozważyć, czy warto ryzykować zmiany w kodzie.

Modyfikacja kodu, który, mimo że bardzo złożony, jest dobrze przetestowany i pełni kluczową funkcję w projekcie, może grozić dodatkowymi kosztami wynikającymi z wprowadzenia nowych błędów, a z pewnością powoduje konieczność ponownego, intensywnego testowania. Z tego powodu narzędzie mierzące złożoność kodu powinno oferować możliwość wyłączenia wskazanych fragmentów kodu spod ogólnej reguły.

Takie wyjątki oczywiście muszą być zawsze zatwierdzone i udokumentowane. Standard nie powinien także pozwalać na zaistnienie takich wyjątków w kodzie powstałym już po wprowadzeniu nowych reguł. Tego typu zasady pozwalają skutecznie wprowadzić nowe reguły zarówno w zakresie nowych projektów, jak i zastosować je w rozsądny sposób do już istniejących.

Konrad Kokoszkiewicz

Zobacz również