Jak pisać kod programu,
aby był czytelny

Każdy pisać może ... (cz.2)

Ten sam algorytm w języku C można zakodować na zupełnie różne sposoby: tak, że widać od razu jakie operacje są realizowane w danym fragmencie programu, albo jako swoistą łamigłówkę. Najpierw trzeba jednak wiedzieć co to jest dobrze ułożony kod.

W języku angielskim określa się to terminem "code layout", czyli układ kodu. Przy czym omawiane tu elementy nie mają wpływu na generowany kod wynikowy, decydują natomiast o wizualnym obrazie pliku źródłowego, a tym samym wpływają na rozumienie programu przez czytelnika.

W istocie duża część elementów składających się na układ kodu może być przedmiotem dyskusji. Klasycznym przykładem są nawiasy klamrowe. Istnieją dwa główne style ich używania: styl K&R i styl Allmana. Oto dwa przykłady (z lewej K&R, z prawej - Allmana, stosowany w niniejszym tekście):

if (x>y) {        if(x>y)
                  {
}                 }

Pierwszy styl jest konsekwentnie stosowany przez K&R w obydwu wydaniach ich książki. Szkoda, że autorzy polskiego tłumaczenia tej książki to zignorowali, zamieniając wszędzie w blokach styl K&R na styl Allmana (można było przynajmniej wspomnieć o dokonanej konwersji we wstępie od tłumaczy). Sposób używania nawiasów klamrowych jest bowiem traktowany zazwyczaj jako integralny element stylu kodowania K&R. Na przykład C. L. Tondo i S. E. Gimpel, publikując zbiór rozwiązań zadań z książki K&R (został wydany równolegle z polskim tłumaczeniem tej książki), zachowali pieczołowicie styl oryginału.

Wybór między stylami używania nawiasów klamrowych nie jest bez znaczenia. Programiści mogą w różny sposób widzieć bloki (np. w programach pisanych w stylu K&R pierwsze nawiasy klamrowe mogą umykać wzrokowi). Byłem kiedyś świadkiem przejmowania przez nowo zatrudnionego programistę programu w języku C pozostawionego przez jego poprzednika - pierwszą rzeczą, jaką zrobił nowy programista, była zmiana w całym programie stylu stosowania nawiasów klamrowych z K&R na styl Allmana ("bo programu nie da się czytać").

Przyjęta konwencja stosowania nawiasów klamrowych wpływa na rozmiar wersji źródłowej programu, nie w sensie rozmiaru pliku (tu wpływ jest niewielki), ale jego postaci wizualnej, np. wydruku. Ponadto niektóre programy uruchomieniowe w trakcie pracy krokowej inaczej ustawiają kursor w obydwu przypadkach. Obydwie szkoły stosowania nawiasów klamrowych odnoszą się również do zapisu definicji struktur (także do unii i zdań switch). Istnieją też pewne wariacje obydwu stylów (np. styl Whitesmithsa jest wariacją stylu Allmana). Z braku miejsca nie będziemy tu ich omawiać.

Ze stosowaniem nawiasów klamrowych - i nie tylko - wiąże się następny istotny element układu kodu: wcięcia.

  • Każde zagnieżdżenie bloku powinno być zaznaczone wcięciem.

    Dobór rozmiaru tego wcięcia (np. znak tabulacji, 2 lub 4 spacje) jest kwestią subiektywną. Program pozbawiony wcięć czy "powcinany" niespójnie jest bardzo trudny do analizy. Oto trzy przykłady zapisu końców trzech bloków:

            ...              ...              ...
            }                }                }}}
        }                    }
    }                        }

    Wszystkie trzy zapisy są poprawne formalnie, ale tylko pierwszy jest klarowny i łatwy w czytaniu. Dwa następne są bardzo niejasne (pochodzą one z programów pisanych przez uczestników kursów programowania w języku C). Właściwie robione wcięcia pozwalają widzieć, do jakiego bloku należy konkretne zdanie.

  • W jednej linii powinna być tylko jedna operacja.

    Jednoliniowe konstrukcje typu:

    if (Licznik < MAXIMUM) Funkcja1 (Tekst, Licznik);

    nie są zalecane, jako niejasne. Jeszcze trudniejsze do odczytania są linie zawierające kilka zdań oddzielonych średnikami (patrz przykłady podane na początku tekstu). Jedna linia kodu nie powinna być również zbyt długa. Rozsądnym ogranicznikiem długości linii jest szerokość ekranu. Jeśli zdanie wykracza poza szerokość ekranu, lepiej je złamać i zapisać w dwóch liniach.

  • Deklaracje zmiennych tego samego typu, ale o różnym charakterze wskazane jest umieszczać w oddzielnych liniach, np.:
    int Licz1,Licz2;     /* LICZNIKI */
    int Tryb;            /* TRYB PRACY PROGRAMU */
  • Poszczególne elementy programu, tj. definicje funkcji, deklaracje zmiennych, zdania języka, argumenty wywołań funkcji, elementy wyrażeń itd. powinny być rozdzielone pustymi miejscami (liniami pustymi, spacjami).

Tak jak trudno jest zrozumieć szybko mówiącego człowieka, podobnie trudno czytać program "ściśnięty" (patrz przykłady na początku tekstu). Linie puste i spacje używane w kodzie w istocie decydują o jego obrazie. Dla przykładu, deklaracje zmiennych lokalnych w funkcji powinny być oddzielone przynajmniej jedną linią pustą od części wykonywalnej. Operatorów jednoargumentowych nie należy oddzielać spacją od argumentu (np. lepiej jest pisać a++ zamiast a ++). Przy operatorach dwuargumentowych wskazane jest umieścić między operatorem a argumentem jedną spację itd. W żadnym wypadku nie należy tworzyć konstrukcji typu:

x=y+++z;

Programiści kodują czasami w ten sposób nie dlatego, by pomijając spację - oszczędzić na rozmiarze pliku źródłowego, ale by popisać się znajomością zasad działania kompilatora.

Sam kod wykonywalny funkcji powinien być w miarę możliwości pogrupowany po kilka linii (z ewentualnym komentarzem), po których następuje pusta linia i następna grupa operacji. Jedna ciągła nieprzerwana kolumna następujących po sobie zdań języka jest uciążliwa w czytaniu.

Układ kodu, a przynajmniej niektóre jego elementy, często wzbudzają dyskusje, jako że ich ocena jest w dużej mierze kwestią subiektywną. Wprawdzie przy wyborze rozwiązań optymalnych szuka się kryteriów obiektywnych, rozważając np. mechanizm pracy oka i sposób czytania przez niego kodu itd., ale oczywiście trudno tu o rozwiązania i wzorce powszechnie akceptowane. Przykładem może być przenoszenie zdania do następnej linii, które może być realizowane w różny sposób. Operator może być na końcu linii przenoszonej albo na początku następnej, wcięcie przeniesionego fragmentu może być różne itp. Każdy programista może tu mieć własny wzorzec odpowiadający jego sposobowi widzenia kodu, nabytym przyzwyczajeniom itd.

Dobrze ułożony moduł

Jak powinien wyglądać cały moduł w języku C? Podamy przykładowy format pliku, nie dyskutując szczegółów. Poniższa lista odpowiada zalecanej kolejności elementów w module (w obrębie grupy zalecana jest też podana kolejność elementów):

  • Nagłówek komentarzowy - zawiera:
    • opis modułu,
    • nazwę projektu,
    • datę utworzenia modułu,
    • nazwisko autora,
    • listę funkcji publicznych,
    • listę modyfikowanych zmiennych globalnych.
  • Definicje sterujące translacji - definicje dla kompilatora, np. określające wersję wynikową (wersja dla testowania, wersja użytkowa) lub środowisko systemowe (np. DOS, OS/2, UNX itp).
  • Włączane pliki definicyjne: pliki systemowe - powinny być ujęte w nawiasy <> pliki własne - powinny być ujęte w cudzysłowy ("")
  • Deklaracje zewnętrzne:
    • danych (będzie jeszcze o nich mowa),
    • funkcji.
  • Deklaracje wewnętrzne:
    • definicje danych publicznych,
    • deklaracje funkcji publicznych,
    • definicje danych prywatnych,
    • deklaracje funkcji prywatnych.
  • Funkcje:
    • definicje funkcji publicznych (alfabetycznie),
    • definicje funkcji prywatnych (alfabetycznie).

Układ ten wydaje się rozsądny (o sprawie deklarowania zmiennych publicznych będzie jeszcze mowa). Ale oczywiście mogą być od niego odstępstwa. Na przykład może być wygodniej definiować rodzaj tworzonej wersji w oddzielnym pliku - o nazwie np. wersja.h - włączanym do wszystkich modułów. Deklaracje zmiennych i funkcji publicznych mogą być przechowywane we własnym pliku definicyjnym (w istocie jest to wręcz zalecenie, będzie jeszcze o nim mowa). Same pliki definicyjne mogą znajdować się w specjalnym katalogu podawanym w zmiennej INCLUDE (zależy to także od zastosowanej technologii tworzenia projektu) itd.

Oddzielną sprawą są kryteria grupowania funkcji w modułach. Mogą one być różne. Jednym z nich jest podobieństwo operacji realizowanych w module. I tak na przykład funkcje w jednym module mogą realizować operacje konwersji znaków. Inny moduł może zawierać funkcje realizujące operacje na urządzeniach wejścia/wyjścia (obsługa plików dyskowych, operacje na ekranie). Innym kryterium może być fakt, że funkcje w module działają na tych samych danych, lub to, że stanowią zestaw operacji realizowanych kolejno, itp.

Rozmiar modułu nie powinien być zbyt duży. Oczywiście zależy on od różnych czynników i może być określany w różny sposób. Rozsądnym maksimum jest 1000 linii lub - odpowiednio - 20 stron wydruku lub 20 funkcji.

Pliki definicyjne

Już przy dwóch czy trzech modułach w projekcie, czasem nawet przy jednym, pojawia się potrzeba wprowadzenia własnych plików definicyjnych, zawierających definicje stałych, prototypy funkcji czy deklaracje zmiennych globalnych.

Zasadą podstawową i oczywistą jest, by w pliku definicyjnym nie było deklaracji alokujących pamięć dla zmiennych, w przeciwnym bowiem razie może grozić błąd podwójnie zdefiniowanej nazwy (w istocie plik przestaje wtedy być plikiem definicyjnym).

Początkujący programiści często tworzą pliki definicyjne wedle typu obiektów, jakie te pliki zawierają. I tak jeden plik zawiera prototypy wszystkich funkcji publicznych, drugi - deklaracje wszystkich zmiennych publicznych, trzeci - definicje wszystkich stałych zdefiniowanych w projekcie itd. Praktyka ta na pierwszy rzut oka może wydać się naturalna, ale w istocie jest zła. Rozważmy przykład.

Załóżmy, że mamy program składający się z kilku modułów. Niech jeden z modułów, FPLIKI.C zawiera funkcje realizujące operacje na plikach dyskowych. Niech będą zdefiniowane struktury BUFOR i PLIK. Definicje ich znajdują się w pliku definicyjnym PLIKI.H, który jest włączany za pomocą dyrektywy #include do pliku FPLIKI.C. Niech też będzie prototyp funkcji działającej na tych strukturach:

int Czytaj(PLIK *PlikWsk, BUFOR *BuforWsk);

Jeśli prototyp tej funkcji zostanie wstawiony do pliku definicyjnego FUNC.PRO, zawierającego prototypy wszystkich funkcji globalnych w programie, to łatwo widzieć, co się stanie: każdy moduł, realizujący operacje zupełnie niezależne od działania na plikach (np. konwersję kodów) i odwołujący się chociażby do jednej funkcji globalnej, będzie po to musiał mieć włączony plik definicyjny PLIKI.H, by kompilator mógł poprawnie zinterpretować prototyp funkcji "Czytaj", w istocie dla danego modułu zupełnie zbędny. W ten sposób jeden plik definicyjny, wstawiony do modułu, wymusza wstawienie - przed nim - innych plików definicyjnych, te następnych itd. Może się okazać, że mały moduł, zawierający jedną prostą funkcję publiczną, będzie wymagał włączenia do niego wszystkich plików definicyjnych projektu.

Wniosek z tego, że pliki definicyjne powinny być związane z charakterem modułu, a nie z rodzajem definiowanych obiektów, i zawierać zarówno definicje stałych, deklaracje zmiennych, jak i prototypy funkcji. Jeśli pewne definicje będą się w plikach powtarzały, można to uwzględnić za pomocą dyrektyw kompilacji warunkowej (#ifdef, #ifndef). Standardowe pliki definicyjne mogą tu służyć jako pouczający przykład.

Deklarowanie zmiennych w pliku definicyjnym odpowiadającym ich charakterowi ma jeszcze inną, w istocie ważniejszą zaletę: ogranicza zasięg tych zmiennych do modułów, w których włączono explicite ten plik definicyjny (pomijamy możliwość, że autor modułu, nie włączając do niego pliku definicyjnego, zadeklaruje jawnie daną zmienną jako globalną zewnętrzną).

Zgodnie z tym, co podano wyżej przy opisie układu modułu, w pliku kodu najpierw powinny być włączane standardowe pliki definicyjne, potem własne. W nazwach plików definicyjnych nie powinno być podawanych nazw katalogów, co najwyżej katalog względem katalogu projektu lub katalogu zdefiniowanego za pomocą zmiennej INCLUDE (przykładem może być tradycyjny katalog SYS w katalogu INCLUDE).

Omówione dotąd elementy stylu kodowania programów w języku C, jakkolwiek bardzo istotne dla obrazu pliku źródłowego i tym samym dla czytelności programu, nie wpływały na kod wynikowy programu, przynajmniej bezpośrednio. Zajmiemy się teraz stosowaniem różnych konstrukcji językowych, które w dużej mierze odzwierciedlają się w kodzie wynikowym programu.

Poniżej podane są tylko wybrane zalecenia. Ich znaczenie jest różne: duża część z nich wpływa na czytelność programu, stosowanie się do innych powoduje zmniejszenie kodu lub czyni go bardziej niezawodnym, niektóre zaś dotyczą już tego, co nazywa się czasami technikami programistycznymi. Owa niespójność tych zaleceń jest wadą, ale zdecydowałem się ją pozostawić, by pokazać różnorodność kryteriów wybierania takich czy innych konstrukcji językowych.

  • W miarę możliwości należy upraszczać złożoność kodu (wspomniana zasada KISS).

    To zalecenie, jakkolwiek brzmi jasno, jest jednym z trudniejszych do sprecyzowania, jako że każdy programista ma własne wyobrażenie o tym, co jest złożone, a co nie. Oto przykład.

    Załóżmy, że trzeba zakodować operację, w której zmienna o nazwie "Stan" ma przyjmować wartość 1, jeśli zmienna o nazwie "Licznik" jest różna od zera, i wartość 0 w przeciwnym razie. Można to zapisać na kilka różnych sposobów (podane niżej nie wyczerpują bynajmniej wszystkich możliwości):

    1)     if (Licznik != 0)
               Stan=1;
           else
               Stan=0;
    2)     Stan=(Licznik!=0?1:0);
    3)     Stan=(Licznik!=0):
    4)     Stan=!!Licznik;

    Zazwyczaj w takich sytuacjach zaleca się stosowanie zapisu najbardziej klarownego, czyli - w tym przypadku - wersji 1. Definicja niejasności jest oczywiście kwestią interpretacji i wielu programistów uzna drugą wersję za lepszą od pierwszej, bo krótszą, a też jasną. Wersja trzecia wymaga od programisty, nie używającego takich konstrukcji na co dzień, chwili zastanowienia i dlatego jest gorsza (to samo dotyczy wersji 4). Jeszcze gorsze są wersje 2 i 3 pozbawione nawiasów, które - chociaż zbędne - ułatwiają jednak interpretację tych wyrażeń.

    Same wyrażenia warunkowe (wersja 2) uchodzą za mało czytelne i powinny być używane rozumnie (np. rozbudowane wyrażenia warunkowe użyte jako argumenty funkcji mogą być trudne do analizy i kłopotliwe w testowaniu).

    Przykładem upraszczania kodu jest niwelowanie złożonych odwołań do struktur. Załóżmy, że jedna z funkcji edytora tekstu otrzymuje wskaźnik na strukturę "Okno", która zawiera w sobie wskaźnik na strukturę "Tekst" opisującą tekst przetwarzany w oknie. Z kolei w strukturze "Tekst" jest wskaźnik na strukturę opisującą bieżący akapit (TenAkapit), która zawiera ostatni element - wskaźnik na tekst akapitu (SamTekst). Posługiwanie się w funkcji odwołaniami typu:

    Okno->Tekst->TenAkapit->SamTekst

    jest uciążliwe, zwłaszcza jeśli do wskaźnika "SamTekst" występuje więcej odwołań w funkcji. Lepiej zatem zdefiniować wskaźnik na znak (np. ParTekst) i nim posługiwać się w funkcji (zmniejsza to rozmiar kodu, upraszcza funkcję i czyni ją bardziej przejrzystą):

    char *ParTekst=Okno->Tekst->TenAkapit->SamTekst;

    Zapis powyższy, z kolejnymi wskaźnikami na struktury, jest też jaśniejszy niż zapis z mieszaniem operatorów struktur:

    char *ParTekst=Okno->Tekst.TenAkapit->SamTekst;

    Z tego też względu w kilkakrotnych zagnieżdżeniach struktur zazwyczaj lepiej jest stosować konsekwentnie wskaźniki na kolejne struktury, niż mieszać deklarowanie struktur i wskaźników na nie.

    A skoro mowa o strukturach: większość kompilatorów pozwala na ich kopiowanie przez proste przypisanie (w tego typu operacjach, w których znany jest explicite rozmiar struktury, kompilatory języka C dla komputerów klasy IBM PC - np. kompilator MICROSOFT i BORLAND C 3.1 - generują instrukcje repetycji REP MOVS). Przypisywame struktur jest operacją prostą i klarowną, i dlatego jest zalecane. Ponieważ jednak operacja taka może być nieprzenośna do kompilatorów, które nie pozwalają na przypisanie struktur, można dla niej utworzyć stosowną makroinstrukcję:

    #define KOPlUJ_S(x,y) (memcpy((char*)&(x),\ (char*)&(y),sizeof(x)))
  • Należy unikać głębokich zagnieżdżeń zdań for, while, if. Rozsądnym ogranicznikiem wydaje się liczba 3.
  • Należy ograniczać stosowanie zdań będących skokami.

    Wprawdzie zdań goto w zasadzie nie powinno być w programie w ogóle, ale bywa, że ich stosowanie jest wygodne, zwłaszcza przy obsłudze sytuacji błędnych (np. wyskok z podwójnie zagnieżdżonych pętli). Jeśli już użyte, zdania goto powinny być wykorzystywane tylko do skoków w przód i nigdy w środek zdań złożonych (przykład czwarty z początku tekstu jest tu podwójnie zły).

    Zdanie goto jest skokiem jawnym. Oprócz tego w języku C istnieją zdania będące skokami niejawnymi. Pierwszym jest zdanie continue, stosowane w pętlach i również niezalecane. Zamiast zatem pisać:

    for(...)
    {
      if (wyrażenie == 0)
        continue:
        /*  TU OPERACJE REALIZOWANE,
            GDY wyrażenie!= 0 */
    }

    lepiej jest kodować

    for(...)
    {
      if   (wyrażenie != 0)
      /*   TU OPERACJE REALIZOWANE,
           GDY wyrażenie != 0 */
    }

    Innym przykładem skoku niejawnego jest zdanie break. Ten przypadek może być dyskusyjny, ale wielu doświadczonych programistów uznaje eliminowanie skoków za zasadę na tyle ważną, by odstępstwa od niej traktować raczej jako wyjątki niż regułę. Zdanie break w pętli powinno być używane tylko do wyjścia z sytuacji niepoprawnej bądź nietypowej.

  • W zdaniach switch wskazane jest zawsze stosować opcję default i po każdej etykiecie case wstawiać zdanie break.

    To ostatnie zalecenie często wygodniej jest obejść, gdy przy różnych wartościach argumentu switch ma być realizowana ta sama operacja. Oto dość klasyczny przykład:

    switch(k)
    {
      ...
      case ENTER:
      case ESCAPE:
        return(k); /* KONIEC FUNKCJI */
      ...
    }
  • Należy unikać wyrażeń zawierających kilka operatorów || i &&.

    Bywają trudne do czytania, zwłaszcza jeśli wykorzystują hierarchię operatorów (operator && ma wyższy priorytet niż operator ||). Ujęcie poszczególnych składników wyrażenia w nawiasy czyni go nieco bardziej przejrzystym, ale i tak złożone wyrażenie z operatorami && i || czyta się trudno. Złożone wyrażenie warunkowe można rozbić na kilka krótszych albo zastosować inną konstrukcję językową.

  • Tam, gdzie jest to możliwe, należy używać operatorów skrótowych, na przykład:
    Tab[a+b+c]+=2;

    Nie oszczędzają one pamięci, ale czynią kod bardziej przejrzystym.

  • Tam, gdzie jest to naturalne, czy nawet możliwe, należy stosować rekursje.

    Kod rekursywny, niegdyś uważany za czasochłonny i niejasny, jest w istocie łatwiejszy do zrozumienia i szybszy w pisaniu niż kod iteracyjny. Jest też wygodny dla danych definiowanych rekursywnie.

  • Makroinstrukcje należy stosować ostrożnie i być świadomym, czym różnią się od funkcji i jakie skutki uboczne powodują. Makroinstrukcje mogą zawierać zdania wykonywalne języka, ale powinny być samodzielne, tj. realizować operacje tylko na argumentach makroinstrukcji. W żadnym przypadku nie powinny zawierać skoku (przykład czwarty z początku tekstu!). Jeśli w makroinstrukcji znajdują się zdania wykonywalne języka C, najlepiej ująć je w blok, który wykonuje się raz, np.:
    #define MAKRO      \
    do                 \
    {                  \
      ...              \
    } while (0)
  • W żadnym wypadku nie należy tworzyć konstrukcji, zakładających określoną kolejność obliczeń w sytuacji, gdy nie jest to jawnie określone.

    Oto dwa przykłady:

    Tab[i]=i++;
    Funkcja1(2*i,i-);

    W pierwszym zdaniu nie jest pewne, czy zmodyfikowany zostanie i-ty element tablicy, czy następny. W drugim nie ma pewności, jaka wartość zostanie przekazana jako pierwszy argument w wywołaniu funkcji "Funkcja1" (w obydwu przypadkach zależy to od kompilatora). Można oczywiście sprawdzić empirycznie, jak dane wyrażenie jest traktowane przez kompilator, ale bazowanie na wynikach takiego sprawdzenia może być ryzykowne (w innym kontekście lub przy innych opcjach kompilacji, nie mówiąc już o innym kompilatorze, może być generowany inny kod).

  • Jeśli funkcja wywoływana z argumentem tylko pobiera wartość tego argumentu, powinien on być przekazywany przez wartość.

    Przekazywanie argumentu przez wskaźnik powinno mieć miejsce tylko wtedy, gdy argument ma być modyfikowany. Nie należy stosować przekazywania argumentu przez wskaźnik na zasadzie "a nuż trzeba będzie argument modyfikować". Jest to praktyka zła, ponieważ poszerza niepotrzebnie faktyczny zakres dostępu do zmiennej i zwiększa możliwość błędu (będzie o tym jeszcze mowa). Jest to również wyjątkowo zły styl programowania, w którym programista nie jest pewny, jakie operacje dana funkcja będzie ostatecznie realizować (najlepiej przemyśleć jeszcze raz cały program).

  • Pisząc deklarację lub definicję funkcji, należy zawsze podawać typ funkcji, nawet jeżeli jest on int (jest to domyślny typ funkcji), oraz pisać pełne prototypy funkcji z nazwami zmiennych (prototypy są wtedy bardziej informujące).

    Parametry aktualne funkcji powinny mieć te same typy, co parametry formalne (podobnie powinno być z wartością zwracaną przez funkcję). Przy wszelkich zmianach typów identyfikatorów w wyrażeniach lub w argumentach funkcji należy tę zmianę formułować explicite (kompilatory nie zawsze wykrywają różnice typów w wyrażeniach). Innymi słowy: wszędzie, gdzie to jest konieczne, należy stosować rzuty (casts). Dotyczy to także wskaźników pustych.

  • Często przyjmuje się jako ogranicznik rozmiaru funkcji liczbę 50 linii kodu (lub szerzej: 20 - 100 linii) albo jednej strony wydruku, łącznie z komentarzami.

    Zalecenie to w pewnych sytuacjach może nie być przestrzegane (oczywiście nie w takich sytuacjach, jak w drugim z przykładów podanych na wstępie). Dla przykładu, funkcja edytora pobierająca znaki z klawiatury i wywołująca poszczególne funkcje edycyjne może w jednym zdaniu switch zawierać kilkadziesiąt etykiet case, odpowiadających różnym klawiszom specjalnym. Funkcja taka, ze zdaniami break i wolnymi liniami między etykietami case, może rozciągać się na więcej niż jedną stronę, i mimo to będzie całkowicie przejrzysta, zwłaszcza jeśli wartości przy etykietach case będą zdefiniowane symbolicznie i ułożone alfabetycznie. I vice versa, 10-liniowa funkcja może być tak zawikłana, że nie da się jej rozczytać.

    Z drugiej strony funkcje często składają się z jednego zdania, zwłaszcza return, którego argumentem jest odpowiednie wyrażenie. Oto funkcja porównywania ciągów znaków używana np. w programach sortowania:

    int Porownaj(char *p, char *q)
    {
      return(strcmp(p, q));
    }

    Taki sposób kodowania powoduje wprawdzie pewne zwiększenie rozmiaru kodu i - co przy sortowaniu ważniejsze - czasu działaniu programu, ale za to izoluje operacje porównywania ciągów od algorytmów sortujących. Dzięki temu zmodyfikowanie programu sortującego tak, by uwzględniał polskie znaki narodowe, sprowadzi się tylko do zmodyfikowania funkcji "Porównaj"

  • Podanie - jak w powyższym przykładzie - wywołania funkcji (strcmp) jako argumentu w innej funkcji jest naturalne. Podobnie rzecz się ma z niektórymi innymi funkcjami, np. ze standardową funkcją obliczania długości ciągu strlen.

    Na ogół jednak konstrukcje typu:

    Funkcja1(k, InnaFunkcja());

    nie są zalecane. Praktyka taka może być kłopotliwa czy wręcz niebezpieczna, jeśli funkcja "InnaFunkcja" może zwracać różne wartości, wymagające różnego traktowania. Przykładem może być standardowa funkcja atoi, która dokonuje konwersji ciągu cyfr w postaci znakowej na liczbę. Jeśli wiadomo, że w ciągu z cyframi w postaci znakowej nie ma błędu, można wywołania tej funkcji używać jako argumentu innej funkcji. Ale jeśli błędy będą mogły wystąpić, program będzie wymagał przeróbek (w przeciwnym razie grozi przeoczenie błędu, ze wszystkimi tego skutkami).

  • Jedna funkcja powinna realizować, najogólniej rzecz biorąc, jedną operację, ale za to realizować ją w sposób pełny.

    Dość dobrym przykładem jest podana wyżej funkcja "Porównaj". Przykładem jeszcze lepszym może być funkcja wczytująca plik i kopiująca go do powiązanej listy. Funkcja taka powinna zwracać gotową listę (np. w postaci wskaźników na początek i koniec listy), natomiast w przypadku braku pamięci zwalniać alokowaną częściowo listę. Innymi słowy: funkcja albo robi wszystko, co powinna zrobić, albo nic (jest to w istocie jedna z zasad programowania defensywnego).

  • Przy kompilacji nie należy maskować żadnego rodzaju ostrzeżeń i oczywiście wszystkie usuwać (sam poziom ostrzeżeń kompilatora powinien być ustawiony na najwyższy możliwy).

    A jeśli już decydujemy się na pozostawienie jakiegoś ostrzeżenia, powinno ono zostać dokładnie zanalizowane i odpowiednio skomentowane w kodzie źródłowym.

Kwestie smaku

Wiele spraw jest dyskusyjnych i wywołuje nieraz zabawne spory. Tak jest np. ze zwiększaniem lub zmniejszaniem o 1 wartości argumentów w sytuacjach, gdy nie jest istotne, czy użyje się operatora przyrostkowego czy przedrostkowego. K&R używają w takich przypadkach wersji z przyrostkiem (np. x++), zaznaczając zarazem, że jest to wyłącznie kwestia smaku. A że smak jest sprawą bardzo indywidualną, zatem i poglądy na sprawę bywają różne. Zwolennicy konstrukcji ++x i --x wysuwają argument, że takie zdania czytają się zgodnie z sensem operacji: "zwiększ wartość x" i "zmniejsz wartość x". Z kolei za umieszczeniem tych operatorów za identyfikatorem (x++ i x--) przemawiać ma to, że takie zdania czyta się "x plus 1" i "x minus 1", co ma brzmieć bardziej zgodnie z "duchem programowania" niż "zwiększanie" czy "zmniejszanie" wartości itd., itp.

Używanie danych

O niektórych konkretnych zaleceniach związanych z deklarowaniem i używaniem danych w języku C była już mowa wyżej. Tu powiemy o kilku ogólniejszych sprawach.

Jedną z podstawowych zasad stosowania zmiennych w programach w języku C jest minimalizowanie ich zasięgu. Jeśli zmienna zdefiniowana zewnętrznie ma być widoczna tylko w tym module, gdzie jest zdefiniowana, powinna mieć klasę pamięci static. Konsekwencją tej zasady jest również zalecenie, by w sytuacji, gdy w bloku używa się np. zmiennej dla celów indeksacji, deklarować ją na początku bloku, np.:

{
    {
        int k;
        for (k=0;...)
    }
}

Takie konsekwentne przestrzeganie zalecenia o minimalizowaniu zasięgu nazw jest jednak traktowane jako przesadne i często obchodzone - zwłaszcza gdy jedna zmienna indeksowa jest używana w kilku kolejnych pętlach i zadeklarowanie jej na początku funkcji jest niemal naturalne. Z drugiej strony definiowanie zmiennej indeksowej daleko poza pętlą, którą indeksuje, może utrudniać czytanie tej pętli.

Naturalną konsekwencją zalecenia minimalizowania zasięgu zmiennych jest zalecenie następne: nie używać zmiennych globalnych. Początkującym programistom może to się wydać dziwne (wszak w większości prostych przykładów w podręcznikach istnieją i są wykorzystywane zmienne globalne). I też, prawdę rzekłszy, w prostych jednomodułowych programach zmienne globalne są naturalnym i w miarę bezpiecznym rozwiązaniem. Ale w dużych wielomodułowych programach, pisanych przez zespoły programistów, używanie zmiennych globalnych jest co najmniej dyskusyjne, a już opieranie wszystkiego na nich jest wyraźnie złą praktyką. Doświadczeni programiści prędzej czy później dochodzą do tego stwierdzenia sami. Zmienne globalne w języku C są podobne do dobra wspólnego. Nie mają swojego właściciela, który by o nie dbał. Każda funkcja może je zmodyfikować, chociażby przypadkowo. Oto dość pouczający przykład.

W dużym programie obsługi sprzedaży ratalnej znajdowała się zmienna globalna "licznik_rat". W jednym z modułów zdefiniowano zmienną klasy static o nazwie "licznik_dat". Na skutek błędu przy wprowadzaniu tekstu programu (litery r i d znajdują się - na klawiaturze obok siebie) zamiast "licznik_dat" wpisano "licznik_rat". Ponieważ do modułu dołączono plik definicyjny zawierający deklaracje wszystkich zmiennych globalnych w programie, zatem kompilator "widział" nazwę "licznik_rat" i wprowadził odpowiednie odwołanie - zamiast zmiennej klasy static w module modyfikowana była zmienna globalna. Błąd przez dość długi czas pozostawał nie wykryty.

Zamiast definiować zmienną globalną, z której korzysta kilka modułów, lepiej jest ukryć ją w jednym module (tj. zadeklarować tę zmienną z klasą pamięci static) i realizować dostęp do niej za pomocą odpowiednich funkcji (metoda ta, stosowana często w większych programach w języku C, jest w istocie jedną z zasad programowania obiektowego).

Innym zaleceniem dotyczącym używania danych jest, by - tam, gdzie istnieje alternatywa - używać tablic zamiast wskaźników. Oto dwie wersje kopiowania ciągów znakowych zakończonych zerami:

i=0;
while (a[i]=b[i])               while(*s++=*t++);
i++;

Druga wersja wydaje się prostsza i w zapisie, i w czytaniu. Tablica wymaga dwóch elementów: nazwy tablicy i indeksu, wskaźnik - tylko jednego. Ponadto w pętlach stosowanie wskaźników jest bardziej efektywne niż tablic (kod jest zazwyczaj krótszy i szybszy), a ponadto pozwala na autoindeksowanie, a przynajmniej ułatwia kompilatorowi jego zastosowanie. Ten argument jest zresztą dyskusyjny, jako że obecne kompilatory języka C, tłumacząc pętle, wcale nie tak łatwo generują autoindeksujące instrukcje maszynowe - jeśli w ogóle. Znacznie lepiej idzie im przy tłumaczeniu operacji przypisywania struktur, których rozmiar znają z góry (była już o tym mowa). Ale oczywiście, z samej zasady argument o większej efektywności wskaźników jest słuszny.

Medal ma wszakże i drugą stronę. Wskaźniki są bezwymiarowe. Mogą wskazywać na element w dowolnym miejscu pamięci i w danym fragmencie kodu może być niemożliwe stwierdzenie poprawności adresu. Tablice - przeciwnie - mają indeks, którego wartość, np. w trakcie testowania programu, łatwo sprawdzić, czy nie wykracza poza zdefiniowany rozmiar tablicy. Wartość indeksu może być przy tym wyliczana na podstawie dość złożonej formuły, która w przypadku wskaźników może nie być podawana explicite. Różnica między obydwoma podejściami staje się jeszcze wyraźniejsza, gdy tablica ma więcej wymiarów niż 1. Na przykład odwołanie Tab[i][j] jest znacznie czytelniejsze niż *p. W takich sytuacjach zazwyczaj wygodniej jest zrezygnować z wyższej efektywności na rzecz większej jasności programu.

Oddzielną sprawą jest stosowanie struktur bitowych. Umożliwiają one klarowną definicję pól bitowych i pozwalają jasno zapisać odwołania do nich. Na przykład:

{
    unsigned s1:2;
    unsigned s2:3;
    unsigned s3:3;
    unsigned s4:8;
} str;
...
str.s2=3;

Struktury bitowe są jednak z definicji nieprzenośne i często zaleca się, by zamiast nich stosować zmienne typu integralnego, odpowiednio maskowane i przesuwane. Konstrukcje takie, jakkolwiek w większym stopniu przenośne, są z kolei mniej jasne. Oto sekwencja, odpowiadająca powyższej operacji przypisania, wyraźnie mniej czytelna, nawet jeśli użyte w tych zdaniach stałe zdefiniuje się symbolicznie:

unsigned int str;
str &=0xffe3;
str |= 3<<2;

Wybór metody może być uwarunkowany charakterem projektu, jego przeznaczeniem, planowanymi rozszerzeniami itd. Bywają też sytuacje, gdy wybór jest względnie prosty. Załóżmy, że w programie istnieje kilka niezależnych stanów (m.in. A, B, C), z których każdy da się opisać za pomocą jednego bitu. Można tu wprawdzie posłużyć się strukturą z polami jednobitowymi:

struct stan
{
  StanA:1;
  StanB:1;
  StanC:1;
} StanPrg;

Instrukcja sprawdzająca, czy program jest w jednym ze stanów A, B lub C, miałaby postać:

if (StanPrg.StanA || StanPrg.StanB || StanPrg.StanC)

Równoważnym funkcjonalnie, ale - ze względu na efektywność - lepszym rozwiązaniem jest użycie dla opisania tych stanów zmiennej integralnej (np. unsigned int), w której poszczególne bity będą odpowiadały każdy innemu stanowi, i ustawianie ich, zerowanie lub testowanie za pomocą operatorów | lub &.

#define STAN_A 0x1
#define STAN_B 0x2
#define STAN_C 0x4
unsigned int StanPrg;

Przy takich definicjach warunek sprawdzający, czy program jest w jednym ze stanów A, B lub C, miałby postać:

if (StanPrg & (STAN_A | STAN_B | STAN_C))

W miarę możliwości należy ograniczać używanie unii, głównie ze względu na potencjalne trudności z przenoszeniem programu. Jeśli unia używana jest do przechowywania w tym samym polu pamięci wartości różnych typów, jest to konstrukcja przenośna. Należy jednak unikać używania unii do zmiany typu obiektu na inny. Przykładem może być unia "Czytany" pochodząca z programu napisanego przez początkującego programistę, który wykorzystał ją do konwersji liczby całkowitej na znak:

union
{
  int Liczba;
  char Znak;
} Czytany;

Program za pomocą funkcji bibliotecznej getchar wczytywał znak (liczbę całkowitą) do zmiennej "Czytany.Liczba" i potem pobierał go ze zmiennej "Czytany.Znak". Posługiwanie się konstrukcjami tego typu jest niewskazane nie tylko dlatego, że są one sztuczne, ale także, że mogą być nieprzenośne.

Jest też szereg innych zaleceń na temat stosowania zmiennych. I tak niewskazane jest umieszczanie w jednym wyrażeniu zmiennych znakowych i bezznakowych (wyrażenia takie mogą być nieprzenośne). Z podobnych przyczyn nie należy używać w odniesieniu do zmiennych znakowych operatorów arytmetycznych czy relacyjnych, np. by sprawdzić, czy dany znak jest literą wielką lub dokonać jego konwersji (lepiej jest użyć odpowiednich standardowych makroinstrukcji lub funkcji zdefiniowanych w pliku definicyjnym ctype.h), itd.

Standardy

Przy pracy nad dużymi projektami programistycznymi - dotyczy to oczywiście nie tylko języka C - zazwyczaj tworzy się standard kodowania, tj. zestaw reguł, jakich muszą przestrzegać programiści przy pisaniu kodu. Jest to niezbędne, w przeciwnym bowiem razie grozi chaos - zwłaszcza jeśli w danej firmie jest duża płynność kadr i kolejni programiści przejmują pracę po swych poprzednikach. Standardy obejmują bardzo różne aspekty kodowania i często są w pewnej mierze zależne od technologii pracy nad projektem, co jest zresztą oddzielnym problemem. Nie zawsze też bywają właściwe, szczególnie gdy idą zbyt daleko, czy też próbują ściśle normować dziedziny, które z zasady nie powinny być normowane. Wprowadzenie nazbyt ostrych i nieprzemyślanych rygorów może odnieść skutki przeciwne do oczekiwanych (patrz: przykład dość osobliwego ograniczenia złożoności deklaracji w programie - M. Kotowski, "Dekoracje w języku C (cz. 2), PCkurier 25/92, str. 157).

Inną sprawą jest codzienna praktyka. Terminy naglą i przychodzi moment, gdy znacznie ważniejsze jest, by pierwsza wersja programu działała, niż żeby napisany - czy też jeszcze pisany - kod był zgodny z narzuconymi regułami stylistycznymi. Czasami też fragment stworzony w pośpiechu i niedbale zostawia się z mocnym postanowieniem, że potem zakoduje się go lepiej. Ale często owego "potem" już nie ma, bo pojawiają się nowe i równie naglące zadania. Skutki takich praktyk wychodzą oczywiście po pewnym czasie i bywa, że poprawnie działający moduł łatwiej jest napisać od nowa, niż go modyfikować. Ale też rzadko zdarza się, żeby programiści mieli wystarczający czas na opracowanie dobrego standardu, co do którego wszyscy byliby zgodni, i później na pełne stosowanie się do jego wymogów (dotyczy to zwłaszcza dokumentacji projektu).

Styl wolny i obowiązkowy

Duża część opisanych wyżej zaleceń ma charakter dyskusyjny i ścisłe trzymanie się ich może być traktowane jako swoista ortodoksja. I rzeczywiście: wystarczy wziąć programy przykładowe dostarczane z jakimkolwiek kompilatorem, by widzieć, że ich autorzy wielu naturalnych zaleceń nie przestrzegają. Często kod jest stylistycznie niespójny: mieszane są style używania nawiasów klamrowych K&R i Allmana (nawet w obrębie jednej funkcji!), panuje dowolność w używaniu spacji i linii pustych, stałe programu podawane są w postaci liczbowej, a nie symbolicznej. Nazwy zmiennych i funkcji tworzone są w niespójny sposób, raz z wielką literą na początku, kiedy indziej nie (za to wielka litera znajduje się w środku nazwy). Bywa, że jeśli definicja funkcji znajduje się przed jej wywołaniami, nie podaje się jej prototypu. Nagłówki komentarzowe zarówno plików, jak i funkcji są często skąpe lub w ogóle ich nie ma. Widać wyraźnie, że programy są pisane przez różnych programistów o różnych stylach kodowania.

Uwagi te dotyczą w różnym stopniu różnych kompilatorów. Względnie najlepsze są programy przykładowe dołączane do kompilatorów MICROSOFT 6.0 i 7.0, a także BORLAND C 3.1. Ale i tu bywają programy- dziwolągi, w których np. obok dobrze napisanej, wręcz podręcznikowej funkcji rekurencyjnej, znajduje się fragment zakodowany niechlujnie i nieczytelnie. Gorsze stylistycznie są programy przykładowe dołączone do kompilatora WATCOM, a jeszcze więcej do życzenia pod względem stylistycznym pozostawiają programy przykładowe kompilatora TOPSPEED firmy Jensen & Partners (przeglądałem programy tej firmy z lat 1989-1990). Nie mają prawie wcale nagłówków komentarzowych, a nieliczne wyjątki zawierają jedynie nazwę programu oraz nazwę firmy i zastrzeżenie praw autorskich.

Rzecz jasna, można uznać to za przesadną drobiazgowość. W końcu w każdym programie znajdzie się zła czy przynajmniej wątpliwa konstrukcja stylistyczna. Z drugiej strony są to programy przykładowe, poświadczone autorytetem znanych firm i mające być wzorcem, zwłaszcza dla początkujących programistów. I dlatego ich styl jest szczególnie ważny.

Oczywiście nie należy popadać w skrajność i dumać nad każdym zdaniem języka, sprawdzając, czy jest pod każdym względem poprawne. Może bowiem stać się to, co z ludźmi, którzy wysłuchawszy krytyki językoznawcy, znajdującego w każdym wypowiadanym zdaniu błąd czy nieprawidłowość stylistyczną, w końcu w ogóle boją się odezwać (uwaga ta dotyczy przede wszystkim początkujących programistów).

Przede wszystkim trzeba widzieć względną wagę różnych zaleceń. Swobodne tworzenie nazw zmiennych czy brak komentarzy może zaciemnić program, czyniąc go nieczytelnym dla osób drugich. Ale jeśli program jest poprawnie zaprojektowany, łatwo go "uczytelnić", zmieniając kilka nazw i wprowadzając komentarze, przynajmniej w kluczowych miejscach kodu. Natomiast zupełnie innym grzechem jest niefrasobliwe stosowanie w programie skoków w przód i w tył czy ignorowanie efektów ubocznych wyrażeń. Może ono zaważyć na jakości programu oraz utrudnić, czy wręcz uniemożliwić jego testowanie i modyfikowanie, nie mówiąc już o przenoszeniu. Niedostrzeganie owych różnic i ścisłe przestrzeganie każdego, nawet łagodnie sformułowanego zalecenia, może doprowadzić do wylania dziecka z kąpielą. Przykładem może być unikanie struktur bitowych (jednego z owych, jak to określił Meadows, "zębów" języka C) w sytuacji, w których zastosowanie ich wręcz się nasuwa. Skutkiem mogą być zawiłe i nieczytelne operacje maskowania i przesuwania bitowego.

Niezależnie od dyskusyjnych elementów przedstawione zalecenia na temat dobrego stylu kodowania są w większości obiektywnie słuszne i przez nikogo nie kwestionowane, i można by o nich mówić na kursach programowania w języku C, jako o integralnych elementach wiedzy o języku oraz jego stosowaniu. Ale tego zazwyczaj się nie robi. Kursy podstawowe sprowadzają się najczęściej do opisywania elementów języka i podawania prostych konstrukcji programowych. Co więcej: różne sztuczki programowe i niejawne elementy języka prezentowane są jako swoiste atrakcje, pokaz możliwości języka i dowód kompetencji wykładowcy. Nie zawsze bywają komentowane zastrzeżeniem, że takich konstrukcji lepiej jest unikać. Skutkami tego bywają m.in. takie programy, jak te opisane na wstępie.

Trzymanie się wskazówek dobrego stylu kodowania nie zastąpi oczywiście doświadczenia i nie przemieni automatycznie złego czy niedoświadczonego programisty w dobrego, tak jak np. trzymanie się zasad stylistyki języka naturalnego nie uczyni człowieka dobrym mówcą czy pisarzem. Ale może wydatnie pomóc programiście tworzyć programy czytelne i poprawne, a przez to łatwiejsze do rozwijania i modyfikacji, a także bardziej odporne na błędy.

Pierwsza część artykułu.