Jak unikać pułapek języka C?

Jak unikać pułapek języka C?

Dobry programista powinien wiedzieć jakie aspekty języka są niebezpieczne i umiejętnie sobie z nimi radzić.

W artykule przybliżę kilka niebezpiecznych sytuacji. Niektóre będą oczywiste, albo wręcz śmieszne, inne mogą dotyczyć aspektów z których wiele osób nie zdaje sobie spawy.

Mam nadzieję, że pozwoli to czytelnikowi wyrobić sobie odpowiedni instynkt pozwalający wykrywać potencjalnie niebezpieczne miejsca na bieżąco w trakcie pracy nad kodem.

Porównanie == i przypisanie =

Na pewno każdemu zdarzyło się kiedyś zrobić taki błąd. W ifie albo while zamiast porównania (a == b) wykonaliśmy przypisanie (a = b). Program skompilował się poprawnie, ale po uruchomieniu okazuje się, że warunek zawsze jest spełniony, albo program zatrzymuje się w nieskończonej pętli. Jeżeli porównujemy dwie zmienne, po prostu musimy być uważni. Często jednak zdarza się, że jedną z porównywanych wartości jest stała albo wynik funkcji. Możemy to wykorzystać. Jeśli napiszemy:

otrzymujemy trudny do wykrycia błąd logiczny, który może ujawnić się dużo później. Jeżeli natomiast dokonamy małej zmiany:

Pominięcie break w konstrukcji switch-case

Mamy kod:

Jeżeli var == 1, wykonają się instrukcje 1-3, a następnie także 4-6. Stanie się tak dlatego, że ze switcha wychodzimy po dojściu do instrukcji break. Początek nowego case jest tylko etykietą informującą kompilator gdzie skoczyć z początku switcha. Zwykle brak breaka nie jest celowy. Kompilatory często sygnalizują taką sytuację warningiem. Czasem jednak chcemy przechodzić do kodu z kolejnego case bez breaka. Wtedy należy dać to wyraźnie do zrozumienia w komentarzu:

Zdradliwe formatowanie tekstu

W tym przykładzie else odnosi się tak naprawdę do wewnętrznego ifa mimo, że wcięcia sugerują coś innego:

Return wykona się niezależnie od wartości a i b, bo ciałem ifa jest średnik:

Niebezpieczeństwo nieskończonej pętli, wskaźnik się nie inkrementuje przez średnik po while.

Czasem jednak średnik po while jest potrzebny:

Nawet jeśli nie mamy nawyku dodawania kodu w tej samej linijce co nawias zamykający }, dla pętli do-while warto zrobić wyjątek.

Dzięki temu od razu wiemy, że while jest częścią wyrażenia, które rozpoczęło się wcześniej, a nie oddzielną pętlą.

Niejednoznaczność operatorów

Intencją autora było tutaj dzielenie przez wartość spod wskaźnika. Niestety kompilator C wybiera zawsze najdłuższy ciąg znaków tworzący poprawny operator, więc /* stanie się początkiem komentarza.

Łatwym sposobem na wyeliminowanie tego typu błędów jest odpowiednie stawianie spacji. Jeżeli mamy operator jednoargumentowy, nie stawiamy spacji po stronie argumentu, jeżeli operator jest dwuargumentowy, spację dodajemy po obu stronach. W ten sposób otrzymamy:

Stałe w trybie ósemkowym

Przyjrzyjmy się zapisowi:

  1. a = 64;
  2. a = 064;
  3. a = 0x64;

W każdej linijce zmiennej a została przypisana inna liczba. Linie 1 i 3 nie wymagają komentarza. Tryb dziesiętny i szesnastkowy są szeroko stosowane w różnych programach. Nie każdy natomiast zdaje sobie sprawę, że dodanie na początku niewinnego zera powoduje przejście na tryb ósemkowy, w którym 64 to 52 w trybie dziesiętnym. Często może się wydawać, że liczba z zerem na początku nadal występuje w trybie dziesiętnym. Dlatego zmienne oktalne nie powinny być w ogóle używane.

"Słaby" warunek wyjścia z pętli

Jeżeli pętla ma się wykonywać do momentu, aż zmienna osiągnie zadaną wartość należy zawsze dokonywać porównania za pomocą > < >= <=, a nie ==. Szczególnie ważne jest to, jeśli czekamy na wartość ustawianą w przerwaniu, lub modyfikujemy zmienną wewnątrz pętli.

Jeżeli zmienna i jest modyfikowana wewnątrz pętli, możliwe, że warunek wyjścia nie zostanie nigdy osiągnięty.

Jest to jedna z podstaw programowania defensywnego szczególnie ważnego w programowaniu embedded, gdzie zakłócenia EMI mogą czasem powodować przekłamania pamięci czy rejestrów rdzenia.

Warto również podkreślić, że zmienna kontrolna pętli for powinna być zmieniana jedynie w samym wyrażeniu for, a nie w ciele pętli. W przeciwnym razie kod staje się nieintuicyjny i ciężko powiedzieć ile razy tak naprawdę pętla zostanie wykonana. Tak samo wyrażenie for powinno być wykorzystywane jedynie do manipulowania na zmiennych kontrolnych.

Liczby zmiennoprzecinkowe

Liczby typu float i double zdecydowanie różnią się od zwyczajnych intów. Są przechowywane w pamięci w sposób wykładniczy. Przez to ciężko jest określić jaką dokładnie mają wartość. Jeżeli przypisujemy zmiennej float wartość 2.5, możliwe że tak naprawdę wynosi ona trochę więcej lub trochę mniej. Dlatego takie wyrażenie:

może w programie nigdy nie zostać spełnione. Jeżeli już wykonujemy porównania na floatach to tylko nierównościowe. Liczby typu float nie mogą też służyć do kontrolowania ilości wywołań pętli. Przez to, że nie jesteśmy pewni jakie dokładnie wartości przyjmują zmienne float, nie wiemy też ile razy wykona się pętla zanim spełniony będzie warunek wyjścia.

Na floatach nie powinno się również wykonywać operacji bitowych. Szczegóły implementacji liczb zmiennoprzecinkowych nie powinny być bezpośrednio wykorzystywane w kodzie, żeby zachować możliwość portowania.

Operacje dzielenia i modulo

Częstym błędem jest nieuwzględnienie faktu, że dzielenie na intach powoduje zaokrąglanie do liczby całkowitej. Dlatego operacja:

Da w wyniku 0, a nie 250. Operacje dzielenia i modulo mogą mieć niejasne działanie z liczbami ujemnymi. Jaki będzie wynik tej operacji:

a przyjmie wartość 2 czy -2? b przyjmie wartość -1 czy -2? Czy 5%-3 i -5%3 da taki sam wynik?

Operator przecinka

Operator przecinka jest bardzo rzadko używaną funkcją języka C. Możliwe, że część z Was nawet o nim nie słyszała. To nawet dobrze, bo powinno się go unikać.

Powoduje on, że kilka operacji jest wykonywanych w ramach jednego wyrażenia. w powyższym przykładzie z pętlą for pozwolił na obsługę dwóch zmiennych zamiast jednej. Przyjrzyjmy się teraz kolejnemu fragmentowi:

Funkcja fun została wywołana z parametrami a i c (jako wartość brana jest ostatnia operacja po przecinku), a dodatkowo zwiększyliśmy wartość zmiennej b. Taki zapis jest bardzo niebezpieczny. Wywołanie funkcji ma teraz dziwny efekt uboczny, a sam operator przecinka można pomylić z przecinkiem pomiędzy argumentami funkcji.

Operator przecinka umożliwia również zrobienie trudnej do znalezienia literówki:

Autorowi chodziło o przypisanie zmiennej a liczby 1.5, niestety operator przecinka sprawia, że zapisana wartość wyniesie 5.

Rozmiar struktury

Może się wydawać, że zmienne a i b powinny przyjąć taką samą wartość. W końcu struktura składa się z 3 bajtów, więc wyrażenia są jednoznaczne. W wielu procesorach np. ARM lub x86 struktury są jednak wyrównywane do jakiejś wartości np. 32bity albo 64bity. W takim wypadku rozmiar struktury będzie większy niż suma rozmiarów wszystkich jej elementów.

Atrybut packed może być używany w deklaracji struktury albo dodawany jako flaga kompilatora, żeby upychać strukturę na mniejszej przestrzeni. Wtedy w ramach jednej architektury dwie takie same struktury mogą mieć różne rozmiary. Do rozmiaru struktury zawsze trzeba się odwoływać przez sizeof całej struktury, a nie sumę rozmiarów elementów.

Instrukcje skoku

Komenda goto lub funkcje z biblioteki standardowej setjmp pozwalają na skoki do zupełnie innych miejsc w kodzie. Jest to dokładne odwzorowanie niskopoziomowego zachowania komputera. W asemblerze skok do etykiety był jedyną możliwością zmiany przepływu kodu. Za pomocą instrukcji skoku w asemblerze realizowane są pętle czy podprocedury.

Jednak C ma swoje mechanizmy tworzenia tego typu konstrukcji i używanie skoków w sposób bezpośredni może powodować jedynie kłopoty. Jeżeli w kodzie znajdują się instrukcje goto ciężko jest sprawdzić które części kodu wykonują się z jaką częstotliwością. Poza tym podczas debugowania ekran może nam nagle przeskoczyć w kompletnie inne miejsce i trudno przypomnieć sobie jakie instrukcje wykonywały się ostatnio. Instrukcja goto umożliwia też robienie dziwnych rzeczy takich jak wskakiwanie do środka pętli lub instrukcji warunkowej.

Makra

Makra są często używane do celów optymalizacji zamiast prostych funkcji. Mają jednak poważną wadę - nie działają tak jak funkcje, ograniczają się do podmieniania tekstu. Przeanalizujmy następujący przykład:

Nie wykona się on zgodnie z intencją autora. Aby uniknąć tego problemu, wystarczy wziąć wszystkie argumenty makra w nawiasy. Istnieją jednak sytuacje, w których nawet to nie pomoże:

Operacja ++ zostanie wykonana dwa razy i programista może nie zdawać sobie z tego sprawy. Nawet jeżeli makro napisaliśmy poprawnie i nie ma żadnych efektów ubocznych, i tak jest nieporęczne przy debugowaniu.

Dlatego najlepszym wyjściem jest używanie funkcji zamiast makr nawet do prostych zadań. Szczególnie, że kompilator i tak może zoptymalizować kod i makro nie będzie wcale wykonywać się efektywniej.

Preprocesor

Jeżeli jesteśmy już przy preprocesorze, to warto wspomnieć, że umożliwia on robienie wielu dziwnych rzeczy:

To jest tylko kilka przykładowych tricków. Możliwe jest tworzenie w ten sposób naprawdę ciekawych konstrukcji. Oczywiście pod żadnym pozorem nie można wykorzystywać ich w poważnych programach.

Inne niebezpieczne konstrukcje

Czy jeśli pierwszy warunek nie zostanie spełniony, program będzie sprawdzać dalsze warunki i inkrementuje zmienne c i d?

Czy inkrementacja wykona się przed czy po przypisaniu a nowej wartości?

Po wykonaniu instrukcji continue program przechodzi do kolejnej iteracji. Ale czy najpierw wykona się i++? Jeśli nie, będziemy mieli nieoczywistą nieskończoną pętlę.

Podsumowanie

Jak widać, podczas pisania w C czyha na nas całkiem sporo niebezpieczeństw. Niektóre wyglądają dosyć dziwnie i sprawiają wrażenie wymyślanych na siłę. Jednak każdego może czasem podkusić, żeby coś dodatkowo zoptymalizować, albo wykorzystać ciekawy trick.

Wtedy takie konstrukcje nagle przestają wydawać się dziwne i jakoś je używamy. Kiedy indziej zdarzają się sytuacje kiedy literówka po cichu zmienia działanie kodu. Jeżeli wiemy które sytuacje są potencjalnie niebezpieczne, łatwiej nam będzie usuwać błędy.

błąd, C, funkcja, goto, if, kompilacja, preprocesor, programowanie, while