Ostatnio straciłem pół dnia poprawiając wiele pozornie nie powiązanych ze sobą błędy w unit testach. Dokonana przeze mnie zmianie polegała w uproszczeniu na zmianie w kilku miejscach typu zmiennej z uint16_t na int32_t. Jak nietrudno się domyślić, przyczyna wszystkich błędów była wspólna i wiązała się z konwersją signed/unsigned. Linijka, która powodowała błąd wyglądała mniej więcej tak:

Opis błędu

Użycie -1 pomagało zainicjalizować zmienną na niedozwoloną wartość, żeby dało się ją odróżnić od wartości wpisywanych później podczas działania. Typ uint16_t jest bez znaku, więc to przypisanie tak naprawdę ustawia największą możliwą wartość dodatnią, czyli 65535. Dalej zmienna była wykorzystywana do obliczeń arytmetycznych, gdzie wykorzystywano ten fakt. Oczywiście po zmianie typu na signed wszystkie obliczenia się posypały.

Przypadek wydaje się banalny, ale w rzeczywistości był trochę zaciemniony przez dodatkowe warstwy abstrakcji. Zmiana polegała tak naprawdę na zmianie typu z angle_t zdefiniowanego jako uint16_t na typ degrees_t bazujący na bibliotece boost::units i używający jako bazowy typ int32_t. Wartość -1 była zaś w rzeczywistości stałą INVALID_ANGLE.

Przyczyną błędu było pójście na skróty i wykorzystanie zachowania dozwolonego przez kompilator, ale nieintuicyjnego dla człowieka. Dodatkowo intencja użycia -1 jako największa dozwolona liczba dodatnia nie była jasno zakomunikowana. Brakowało choćby komentarza z wyjaśnieniem. W podobnych wypadkach często używa się notacji szesnastkowej. Dzięki temu nie musimy pamiętać magic numberów, tylko korzystamy z bitowej reprezentacji liczb.

Jednak to również jest niepotrzebna komplikacja i czasem może nie być jasne, że używamy te liczby jako minimum i maximum, a nie na przykład do operacji bitowych. Na szczęście jest lepsze rozwiązanie.

Stałe z biblioteki standardowej

Właśnie w tym celu stworzona została biblioteka <limits.h>. Definiuje ona stałe dla wartości minimalnych i maksymalnych wszystkich całkowitych typów liczbowych, a więc char, short, int, long, long long. Jeżeli potrzebujemy podobnych definicji dla floatów, znajdziemy je w <float.h>.

Przy okazji taka ciekawostka – limits.h zawiera również stałą CHAR_BIT przechowującą liczbę bitów w bajcie. Tak, bajt niekoniecznie musi składać się z 8 bitów. Jest to po prostu najmniejsza komórka pamięci, jaką można zaadresować i może równie dobrze wynosić 4 bity jak i 32 bity. Co ciekawe nawet w dzisiejszych czasach zdarzają się architektury z niestandardową długością bajtu. O ciekawym problemie z tym związanym można poczytać tutaj.

Z biblioteką <limits.h> jest pewien problem – w większości zastosowań nie powinniśmy używać podstawowych typów, tylko typów o gwarantowanym rozmiarze z biblioteki <stdint.h> jak na przykład wspomniany wcześniej uint16_t. Ma to szczególne znaczenie, jeśli nasz kod ma być portowalny, ponieważ podstawowe typy mogą mieć różne rozmiary w zależności od architektury i systemu operacyjnego. Najbardziej znanym przykładem jest różnica w długości typu long na maszynach 64-bitowych między Windowsem a Linuxem (link z opisem problemu). Pytanie bonusowe: czy typ char domyślnie jest signed, czy unsigned? (odpowiedź pod linkiem)

Jeżeli chcemy używać wtedy stałych z <limits.h>, musimy wiedzieć jaki typ podstawowy ma używana przez nas zmienna. A bezpośrednie użycie tych stałych również uczyni nasz kod trudniejszym do portowania. Na szczęście w <stdint.h> mamy również makra dla typów o określonym rozmiarze. Natomiast jeżeli używamy C++, warto przyjrzeć się std::numeric_limits.

Podsumowanie

Jeżeli staramy się być sprytni (albo leniwi) i wykorzystujemy podejrzane konstrukcje, nigdy nie wiemy kiedy to się zemści na nas albo na innych osobach pracujących z tym kodem. Szczególnie jeśli nie opatrujemy ich komentarzem z odpowiednim wytłumaczeniem. Jednak najlepszym wyjściem zawsze jest wykonanie swojej pracy porządnie i napisanie takiego kodu, aby dało się zrozumieć, co autor miał na myśli. W przypadku ustawiania zmiennej na najwyższą możliwą wartość powinniśmy korzystać z biblioteki standardowej.

Na koniec jeszcze przydatna zasada tłumacząca kiedy stosować zmienne signed, a kiedy unsigned. Otóż zawsze kiedy traktujemy zmienną jako liczbę, a szczególnie jeśli wykonujemy operacje arytmetyczne używamy typu signed. Jeżeli traktujemy zmienną jako zbiór bitów np. rejestr sprzętowy, flagi w protokole komunikacyjnym, a szczególnie jeśli wykonujemy operacje bitowe – zawsze używamy unsigned. Poza tym ze względów performanceowych zmienna powinna mieć rozmiar domyślny dla architektury (czyli dla ARM, x86 32 bity). Dzięki temu nie są potrzebne dodatkowe instrukcje asemblerowe do obsługi mniejszych typów. Przy okazji dzięki temu eliminujemy 99% przypadków, kiedy początkowy typ okazuje się zbyt mały, albo jednak potrzebujemy do obliczeń liczb ujemnych.