Blog - Programowanie C/C++

Typy zmiennych – czyli deklaracje w „dobrym stylu”

Opublikowane wrzesień 2011 przez Włodzimierz Gromadzki w Programowanie C/C++.

W niniejszym artykule chciałbym się wypowiedzieć na temat typów zmiennych występujących w językach C/C++ i ich niejednoznaczności wynikającej ze standardu. Chcę również zwrócić uwagę na nadużywanie niektórych typów oraz na możliwe konsekwencje tego zjawiska. Zakładam, że Czytelnik wie, co to jest typ zmiennej i jakie typy występują w językach C/C++. Zainteresowanym podaję jedne z wielu źródeł, w których można przeczytać o zmiennych i ich typach w CC++.

Na początku jednak podkreślę, że brak jest jednoznacznej granicy określającej jaki sposób użycia typów zmiennych w programach pisanych w C/C++ można jeszcze zaliczyć do dobrego stylu programowania, a jaki już zdecydowanie nie. Wiele zależy od wyczucia programisty, ale przede wszystkim od jego praktyki. Nie ma bowiem ścisłej definicji, ani uniwersalnego zestawu reguł, których stosowanie gwarantowałoby programiście tworzenie kodów źródłowych w dobrym stylu. Dopiero z czasem, wraz z wielokrotnościami setek i tysięcy linii napisanego przez siebie kodu nabywa się biegłości w przewidywaniu konsekwencji takich, a nie innych sposobów zapisu. Ważne jest również, aby raz powstały kod łatwo było analizować i konserwować oraz modyfikować i rozwijać w przyszłości. Warto też zadbać na etapie projektowania i tworzenia kodu źródłowego o to, aby program zachowywał się zawsze w sposób deterministyczny i zgodny z założonym, aby działania tego nie pozostawiać przypadkowi oraz żeby już na etapie projektu eliminować źródła potencjalnych błędów czy nieoczekiwanych zachowań w przyszłości. Dodatkowo, podczas realizacji rozległych, komercyjnych projektów, w które zaangażowane są duże zespoły specjalistów, dobrze jest już na samym początku szczegółowo określić standardy tworzenia kodu, aby w rezultacie otrzymać spójne oprogramowanie o wysokiej jakości i niezawodności.

Jak widać – umiejętność programowania to nie tylko zdolność do stworzenia działającego programu, realizującego funkcjonalność określoną w specyfikacji wymagań. Ważny jest styl programowania, ponieważ dobry styl przekłada się wprost na dobre oprogramowanie. Na styl, o którym mówię składa się wiele elementów – odpowiednie posługiwanie się typami zmiennych jest jednym z nich.

Typy zmiennych w C/C++ - co wynika ze standardu języka

Na początek – typ int

Przeglądając kody źródłowe napisane w językach C i C++ zauważyłem, że nadużywany jest w nich typ int. Problem dotyczy zarówno ogólnie dostępnych, open sourcowych źródeł, jak i kodów poważnych programów komercyjnych, które miałem okazję oglądać w czasie mojej, długiej już, praktyki zawodowej. Dlaczego moim zdaniem nadużywany? Postaram się poniżej to wyjaśnić.

Weźmy dla przykładu prosty fragment kodu:

float    arrayB[340];

for (int i=0; i < sizeof(arrayB); i++)

 {

   arrayB[i] += 3;

 }

Chciałbym zwrócić uwagę na fragment zapisu „for(int i=”. Ktoś mógłby zapytać: o co mi chodzi? O "i"? Zmienna iteracyjna jak każda inna. Nazwa co prawda krótka, ale trzeba być chyba fanatykiem długich nazw, żeby do tak krótkiej pętli, zmienną o bardzo małym, lokalnym zakresie nazywać powiedzmy iterator. Tak więc nie o „i”... Chcę zwrócić uwagę na coś innego: na typ int – fundamentalny typ całkowity, chyba najbardziej popularny ze wszystkich typów podstawowych w języku C/C++. Na pierwszy rzut oka typ udany, nazwa (skrót) krótka, dobrze oddaje znaczenie słowa integer. Ba, prawdopodobnie większość z programistów w swoim pierwszym programie w C/C++ użyła tego typu dla swojej pierwszej zmiennej. Wszystko to prawda ale...

Może najpierw zajrzyjmy do "standardu C++" - co w nim napisane jest na temat typu int:

"There are four signed integer types: 'signed char', 'short int', 'int', and 'long int'. In this list, each type provides at least as much storage as those preceding it in the list. Plain ints have the natural size suggested by the architecture of the execution environment."1

To co podkreśliłem mówi nam jasno, iż w czasie pisania programu w zasadzie wiemy tyle, że ilość bitów zajętych przez int, czyli jego pojemność informacyjna (albo tu: zakres iteracji), zależy od architektury komputera, na którym nasz program zostanie uruchomiony.

Załóżmy teraz, że ktoś próbuje uruchomić nasz wyżej napisany fragment programu na komputerze 8-mio bitowym. Teoretycznie mógłbym sobie wyobrazić, że wielkość int będzie bezznakowa i równa słowu maszynowemu, czyli też 8 bitów. W takim przypadku z pętli for(int i=0; i < 340; i++) nasz program nigdy nie wyjdzie, bo po i == 255 nastąpi i++ który da nam wartość 256. Wartość 256 obcięta do ośmiu bitów da zero, czyli pętla zacznie liczyć od początku, bez możliwości wyjścia z niej.

Nasz przykład jest oczywiście strasznie przejaskrawiony (może nawet tendencyjnie), ale chcę zwrócić uwagę na fakt, iż pozostawianie ważnych rzeczy na pastwę domysłów, może się źle skończyć.

W przeważającej większości dzisiejszych komputerów int posiada długość 32 bitów, co daje nam w naszej pętli for przestrzeń iteracyjną trochę ponad 2 miliardy – czyli dla naszego miniprogramu na pewno wystarczającą. Co jednak będzie, gdy zmienimy typ tablicy arrayBfloat na byte, a ilość elementów na 2 500 000 000? Taka tablica może teoretycznie zostać zaalokowana przez system operacyjny (nawet przez WindowsNT) i nasza pętla znów stanie się pętlą nieskończoną – choć "execution environment" jest jak najbardziej realne a nie wydumane i tendencyjnie ograniczone do 8-miu bitów. Ba, jeśli ciało funkcji lekko zmodyfikujemy:

byte *   memory;

...................

for (int i=0; i < 2500000000; i++)

 {

  memory[i] = 0;

 }

to mogę sobie wyobrazić, że w/w kod może służyć do wstępnego zerowania całej pamięci komputera.

Czego chcę dowieść w tych przykładach? Tego, że opieranie się na domyślnych wartościach, dość ogólnie precyzowanych w standardzie języka, wcale nie jest metodą na tworzenie przenośnego kodu. A jeśli przyjrzymy się definicji pozostałych typów pochodzących od int ('signed char', 'short int', 'int', i 'long int') jest jeszcze gorzej: nigdzie nie jest napisane, że short jest dwa razy mniejszy od int, a dwa signed char zmieszczą się w short int. Ta nieokreśloność pojemności bitowej poszczególnych typów jest, jak dla mnie, trochę irytująca, a co gorsza "The C++ Standards Committee" ani myśli coś z tym zrobić w nowej wersji języka, czyli C++0x, unikając tego tematu jak ognia.

Do typu int oraz innych typów bezznakowych wrócę jeszcze pod koniec niniejszego artykułu, a tymczasem chciałbym, abyśmy spojrzeli co w temacie typów robi Microsoft. Nie jestem fanem języka C# oraz wspomnianej firmy, ale muszę obiektywnie powiedzieć, że moim zdaniem MS wykazał się tu odwagą. W języku dosłownie bazowanym na C++ wprowadził nie tylko nowe, bardzo przydatne typy, ale zrobił porządek w już istniejących. Nim skrótowo opiszę typy z C#, wspomnę jeszcze, co standard w/w komitetu mówi nam o typie bool.

Typ Boolean

"Values of type bool are either true or false. [Note: there are no signed, unsigned, short, or long bool types or values.] As described below, bool values behave as integral types. Values of type bool participate in integral promotions."2

Jak widzimy informacji o długości zmiennej (ilości bitów potrzebnej do reprezentacji zmiennej typu bool) nie ma w ogóle. Co gorsze, nie ma także żadnej informacji odnośnie reprezentacji binarnej wartości typu true czy false. Można by się nawet spodziewać, że false jest reprezentowane jako 1 a true jako 0. Na szczęście w większości znanych mi kompilatorów false jest zerem, a true nim nie jest.

Co może wyniknąć z używania niejednoznacznie zdefiniowanych typów?

Wyobraźmy sobie, że łączymy ze sobą dwa komputery i zaczynamy przesyłać pomiędzy nimi dane binarne, bądź też przenosimy te dane między komputerami za pomocą jakiegokolwiek nośnika danych. Programy dla każdej ze stron pisali inni ludzie, umawiając się ze sobą, że przesyłane dane będą zawierać przykładowo jeden typ rekordu opisany za pomocą struktury o nazwie RecData:

struct    RecData

 {

char Nazwa[32];
int Rok;
short Miesiac;
bool Flaga;

 }

Jeśli nawet założymy, że oba komputery pracują w reprezentacji LittleEndian, to nadal nie ma żadnej gwarancji, że int po obu stronach zajmuje powiedzmy 4 bajty, a short 2 nie mówiąc już o totalnie niesprecyzowanej długości pola Flaga. W konsekwencji dane odczytane na drugim z komputerów, mogą zupełnie nie przypominać danych wysyłanych czy zapisanych na nośniku na pierwszym komputerze.

Typy fundamentalne w języku C#

Fragment tablicy typów wg [ECMA-334 4th Edition / June 2006 C# Language Specification]

Type Description Example
string String type; a string is a sequence of Unicode code units string s = "hello";
sbyte 8-bit signed integral type sbyte val = 12;
short 16-bit signed integral type short val = 12;
int 32-bit signed integral type int val = 12;
long 64-bit signed integral type long val1 = 12;
byte 32-bit unsigned integral type byte val1 = 12;
ushort 16-bit unsigned integral type ushort val1 = 12;
uint 32-bit unsigned integral type uint val1 = 12;
ulong 64-bit unsigned integral type ulong val1 = 12;
float Single-precision floating point type float val = 1.23F;
double Double-precision floating point type double val1 = 1.23;
bool Boolean type; a bool value is either true or false bool val1 = true;
char Character type; a char value is a Unicode code unit char val = 'h';
decimal Precise decimal type with at least 28 significant digits decimal val = 1.23M;

Jak widać większość najczęściej używanych typów ma ściśle określoną długość bitową, typ decimal ma określone minimum precyzji. O typach zmiennoprzecinkowych można powiedzieć, że spełniają standard ANSII/IEEE Std 754-1985 ,IEEE Standard for Binary Floating-Point Arithmetic.

Za Charlem Petzoldem, bazując na jego książce "Programowanie MICROSOFT WINDOWS w języku C#" ("Programming Microsoft Windows with C#", Microsoft Press wyd. 2001) można powiedzieć, że float ma 32 bity, double 64, zaś typ decimal zajmuje 128 bitów. Nawet o typie bool Petzold twierdzi, że typ ten można rzutować na integer, co da w wyniku wartość 0 dla false i 1 dla true – konkretnie i jednoznacznie.

Porządkujemy typy w C/C++

Zastanówmy się: jakie wnioski my, programiści C/C++, możemy wyciągnąć z tego, że C# posiada ściśle określoną reprezentację typów podstawowych? No cóż – myślę, że możemy po prostu wzorować się na nim, czyli mówiąc kolokwialnie "ściągać" prawie wszystko z wyjątkiem typów int i char.

Sposób jest dość prosty - po pierwsze unikamy jak ognia używania następujących typów:

  • int
  • unsigned int
  • char

Pierwsze dwa z powodu nieokreśloności ich długości, trzeci ze względu na Unicode.

Typ char - drobna dygresja

Warto przez chwilę zatrzymać się nad typem char. Zastanówmy się najpierw jak ten typ powstał. Cofając się kilkadziesiąt lat wstecz możemy się przekonać, że typ ten istniał już w takich językach jak PL/1, FORTRAN itp., czyli prawdopodobnie już od powstania pierwszych języków programowania. Przeznaczeniem tego typu było przechowywanie kodów liter do wydruku napisów. Drukowanie pierwszych napisów, teleks i inne urządzenia telekomunikacji to era 6-bitowych kodów stosowanych do przesyłania danych alfanumerycznych. Ponieważ na sześciu bitach było trochę ciasno, dołożono siódmy bit i powstały standardy ASCII, EBCDIC itp. Te standardy były idealnie dopasowane do krajów anglojęzycznych. Inni (np. Polska) musieli rezygnować z polskich znaków diakrytycznych. Komputery z tamtych czasów charakteryzowały się dziwnymi długościami słowa maszynowego, np. Honeywell 6000 miał słowo o długości 36 bitów, a nasza Odra tylko 24 bity. Jednak IBM stosował 16/32 bity i doceniał zalety liczb 2^n. Gdy n==3 mamy nasz bajt, czyli dość przyjemną porcję informacji. Zapamiętując w każdym bajcie jeden znak ASCII czy EBCDIC marnujemy jeden bit. Ludzie dość szybko zaczęli wykorzystywać dodatkowe 128 kombinacji jakie daje bajt na różne ciekawe znaki (semigrafika, czy inne strony kodowe). Wraz z nastaniem komputerów PC, które rozlały się po całym świecie i oczywiście Internetu, powstał problem zbyt małej pojemności bajtu jak na potrzeby języków orientalnych, których alfabet często nie mieści się w nawet 256 kombinacjach. Aż powstał Unicode - standard, który umożliwia obsługę wielkich alfabetów dla różnych nacji jednocześnie.

Ale tu pojawia się problem: nawet ośmiobitowy bajt nie jest wstanie pomieścić wszystkich kombinacji – zachodzi konieczność odejścia od równoważności char == bajt. Typ char musi być większy. I tak w standardzie C/C++ pojawia się typ w_char, zaś dla przykładu w C# char ma 16 bitów. Tak naprawdę Microsoft poszedł jeszcze dalej: w przeciwieństwie do C/C++, w języku C#, typ char nie jest już po prostu liczbą, jest typem służącym do tego co sugeruje nazwa, czyli do przechowywania znaków a nie liczb, na których rachujemy. I to podejście uważam za piękne. Od kiedy pamiętam z liczeniem na "literach" zawsze bywały kłopoty: np. dodając 1 do kodu litery 'p' domyślamy się, że otrzymamy kod następnej litery w alfabecie czyli 'r'. Podobnie dodając 1 do 'a' otrzymamy,... No właśnie: Polacy spodziewają się 'ą', zaś Anglicy 'b'. Podobne problemy pojawią się przy sortowaniu napisów. W zależności od języka zwykłe binarne sortowanie da nam różne wyniki. Wszystko dlatego, że poruszamy się we wnętrzu stron kodowych. Jeśli jednak wzniesiemy się na pewien poziom abstrakcji i potraktujemy obiekty typu char jako reprezentanta znaków/liter bez zaglądania do środka jak to jest kodowane, wszystko będzie OK. Natomiast sortowanie oraz inne potrzebne operacje na znakach możemy wykonać wówczas za pomocą odpowiedniego interfejsu (np. metod klasy string).

Własne typy danych w C/C++, czyli krok w kierunku unifikacji typów

Wracając do sposobu w jaki możemy wzorować się na typach zmiennych C#: stwórzmy przemyślany, dopasowany dla naszych potrzeb zestaw własnych typów, np.

typedef unsigned char byte; // 8 bitów bez znaku
typedef signed char sbyte; // 8 bitów ze znakiem
typedef unsigned short int ushort; // 16 bitów bez znaku
typedef signed short int sshort; // 16 bitów ze znakiem
typedef unsigned long int ulong; // 32 bity bez znaku
typedef signed long int slong; // 32 bity ze znakiem
typedef unsigned __int64 dulong; // 64 bity bez znaku
typedef signed __int64 dslong; // 64 bity ze znakiem

Inna propozycja:

typedef unsigned char byte; // 8 bitów bez znaku
typedef signed char sbyte; // 8 bitów ze znakiem
typedef unsigned short int ushort; // 16 bitów bez znaku
typedef signed short int sshort; // 16 bitów ze znakiem
typedef unsigned long int uint; // 32 bity bez znaku
typedef signed long int sint; // 32 bity ze znakiem
typedef unsigned __int64 ulong; // 64 bity bez znaku
typedef signed __int64 slong; // 64 bity ze znakiem

O ile pierwsza propozycja jest dość kompatybilna z 32-bitowym Windows, o tyle druga lepiej nadaje się do systemów 64-bitowych.

Jedną z w/w propozycji zapisujemy w pliku np. MyTypes.h i umieszczamy w specjalnym katalogu, w którym powinny być wszystkie nasze ogólne (niezależne od aktualnie tworzonej aplikacji) narzędzia czy rozszerzenia systemu. Katalog ten warto umieścić w zmiennych środowiskowych i od tej chwili wszystkie nowo tworzone programy powinny includować nasz plik MyTypes.h jako pierwszy (nawet przed Windows.h, jeśli w ogóle jest nam potrzebny).

W tym momencie bardziej spostrzegawczy Czytelnik zauważy, że przecież w tej pseudo-metodzie nie ma żadnej gwarancji, że typedef unsigned int uint; ma zawsze 32 bity, jak to jest napisane w komentarzu. Tu w pełni przyznaję rację – gwarancji nie ma żadnej, ale nie o to w tym miejscu chodziło. Plik MyTypes.h tworzymy dla każdego systemu, dla którego kompilujemy kod naszych programów. Jeżeli przyjdzie nam tworzyć program do kompilacji kompilatorem, w którym np. int jest 16-bitowy, zaś long int 32-bitowy, to po prostu tworzymy nowy plik MyTypes.h z odpowiednimi typami. Cały pomysł polega na tym, że istnieje tylko jeden plik z definicjami typów i tylko on będzie (choćby teoretycznie) zależał od środowiska czy kompilatora. Pozostała część kodu, często setki tysięcy linii, pozostaną nietknięte. Oczywiście można użyć w pliku MyTypes.h odpowiednio rozbudowanych zdań kompilacji warunkowej - szczegóły nie są ważne. Ważne jest tylko to, aby definicja stosowanych przez nas typów była w jednym miejscu.

Wielu Czytelników już pewnie nie wytrzymuje myśląc tak: przecież pliki includowane z Toolkitu Windows zawierają setki podobnych definicji. WORD, DWORD, LONG itp. to tylko najprostsze przykłady - po co więc tworzyć własne, następne definicje?

No cóż, za mną ponad 25 lat ciężkich doświadczeń przy pisaniu programów w C i C++. Ile kompilatorów czy systemów operacyjnych - nawet nie liczyłem. Kiedy zobaczyłem pierwszy ULONG czy DWORD też nie pamiętam. Pamiętam np., że pracowałem z plikami nagłówkowymi w których ULONG był definiowany makrodefinicją, dziś zaś Microsoft używa zdania typedef. BOOL raz jest typu int, raz typu char, a innym razem byte. Ponadto wszystko to, co jest pisane dużymi literami miało być wg. wielu standardów makrodefinicjami, więc zdania:

typedef unsigned long DWORD;
typedef int BOOL;

co najmniej nie spełniają tej zasady.

Wyobraźmy sobie, że piszemy aplikację, która niekoniecznie musi być kompilowana w środowisku MSVC, używając w kilku tysiącach linii nazwy ULONG. Musimy mieć gwarancję, że taki typ będzie zdefiniowany również w innych środowiskach. Po kilku takich niemiłych niespodziankach doszedłem do wniosku, że miast czekać na kłopoty lepiej wyjść im naprzeciw, definiując wszystko to, co potrzebne raz, a potem dopasowywać plik MyTypes.h w przypadku konieczności. Przekonałem do tego wielu współpracowników i od tej pory mieliśmy święty spokój - raz nauczywszy się nowych typów, nikt nie musiał się więcej zastanawiać czy ushort to 8 czy 16 bitów.

W/w sprawdza się w praktyce jedynie przy spełnieniu pewnego warunku – mianowicie, że większość kodu nad którym pracujemy została stworzona w naszej firmie, w zgodzie z powyższym standardem. Jeśli większość przydzielanych nam zadań polega na sklejaniu bibliotek i modułów pochodzących z różnych zewnętrznych źródeł, to niestety czasami okaże się, że jesteśmy zmuszeni dopasować się do używanych typów i standardów, niekoniecznie kompatybilnych z naszymi. Gdy nasza praca polega na dopisaniu kilkudziesięciu linii w module, który ma ich kilkanaście tysięcy, to niestety musimy to zrobić zgodnie z zastanym standardem. Jeśli jednak stosujemy zewnętrzną bibliotekę czy DLL’a, która stanowi mniej niż dwa procent naszego systemu, to czasami warto utworzyć wrapper, który nas odseparuje. Lepiej, aby jeden pracownik pomęczył się trochę z zewnętrznymi typami, niż narażać na stres i spowolnienie pracy nad komercyjnym projektem całą grupę. Czasami warto nawet przepisać coś na nowo, niż wprowadzać bałagan do czystego systemu.

O wyższości typów bezznakowych nad znakowymi – okiem praktyka

Na zakończenie chciałbym jeszcze rozwinąć wątek, od którego niejako zacząłem ten artykuł.

Po wielu latach praktyki zauważyłem, że wielu programistów nadużywa typów ze znakiem, np. wcześniej opisywany int. Dlaczego? Moim zdaniem dlatego, że ten typ najłatwiej się pisze – bo komu by się chciało dopisywać słowo unsigned jak bez niego też będzie działać. Jestem przekonany, że gdyby Kerninghan i Ritchie, tworząc pierwszy standard języka C , stworzyli typ uint a do niego dołożyli modyfikator signed, to dziś nie miałbym o czym pisać. Gdyby chociaż int był z definicji typem bezznakowym nie byłoby problemu. Teraz, przez przyzwyczajenie i uczenie się od innych niekoniecznie dobrych zachowań, wielu programistów, tych zwłaszcza z języka 'C', pisząc for(...) użyje z przyzwyczajenia typu int. Ponieważ int jest dość pojemny i przeniesie także wartości ujemne jeśli trzeba, to nawet w dzisiejszym obiektowym paradygmacie, wielu projektantów/programistów bez tracenia czasu na "zbędne przemyślenia" pakuje gdzie się da int’a, a jak nie jest pewny czy wystarczy to dla pewności poprawi long’iem (też ze znakiem) i jedziemy dalej. A potem, w implementacji metody, widzimy np. takie kwiatki:

int Klasa::Obliczenia(int a,long b)

 {

  ASSERT(a >= 0);

  ASSERT(a < 32);

  ASSERT(b >= 0);

  ASSERT(b < 0x10000);

.............................................

  if(jeśli było dobrze)

   return 1;

   else return 0;

 }

Jak widzimy parametry a i b nie powinny być zerem z definicji, ale do tego wniosku programista doszedł prawdopodobnie w trakcie pisania kodu funkcji, a nie na etapie projektowania. Dodatkowo można podejrzewać, że funkcja Obliczenia() mogłaby być typu bool a nie int, ale prawdopodobnie w tej szkole programowania wywołanie będzie wyglądać tak:

if (Obliczenia(a,b) == 1)

 {

   ..........................

 }

   else Error(......);

Analizując wiele tysięcy linii kodów źródłowych, dochodzę do wniosku, że w większości programów ponad 80% zmiennych typu signed mogłoby i powinno być typu unsigned. Większość zmiennych zliczających w naturalny sposób jest bez znaku, bo po prostu taka jest rzeczywistość. Liczba ludzi w tramwaju, liczba monet w kieszeni, liczba książek na półce, liczba sposobów zrobienia czegoś itd. jest ze swej natury liczbą naturalną (ja zakładam, że zero jest liczbą naturalną). W informatyce jest podobnie: ilość bajtów pamięci operacyjnej jest nieujemna, położenie kursora w pliku jest liczbą naturalną, wielkość pliku, liczba pixeli na ekranie nie mówiąc już o takich dziwolągach jak kod wciśniętego klawisza na klawiaturze. Nie mam najmniejszego pojęcia dlaczego scancode miałby przyjmować wartości ujemne i jak miałbym to interpretować. Czasami daje się zauważyć, że programiści starają się przez wartość ujemną przenieść informację o błędzie, tak jakby wykorzystanie wartości najstarszego bitu było nie do przyjęcia. Są to triki świadczące o złym stylu programowania. Jeśli funkcja może zwracać kod błędu, to zwraca sobą status wykonania, a wartości - poprzez parametr typu referencja lub wskaźnik. Przez uporczywe trzymanie się typów ze znakiem funkcja fseek(file, int offset, int typ) ograniczała maksymalną długość pliku do 2GB zamiast do 4GB tylko dlatego, żeby parametr offset mógł być ujemny. Nikt nie odważył się zmienić trzeciego parametru wyliczanego typ z SEEK_CUR na np. SEEK_BACK i SEEK_FORWARD, pozostawiając pełne 32 bity zakresu przez zmianę typu offset na unsigned.

Ciekawostką niech będzie fakt, że zamieniając w funkcji Obliczenia() typ parametrów signed na unsigned pozbywamy się dwóch zbędnych ASSERT’ów. W wielu miejscach kompilator przestanie nas straszyć ostrzeżeniem o porównywaniu typów znakowych z bezznakowymi. Gdybyśmy np. chcieli sprawdzać czy indeks do jakiejś tablicy nie wychodzi poza zakres, to w przypadku typów ze znakiem musimy dokonać dwóch porównań, zaś w przypadku unsigned tylko jednego. Po latach współpracy z dużą liczbą młodych programistów dało się u nich wyczuć zjawisko mniejszej ilości błędów, gdy programista przełączył się na myślenie bezznakowe.

Mówiąc ogólnie, zasada jest prosta: jeśli coś, co przedstawia sobą zmienna, przyjmuje wartości całkowite i nieujemne to musi być reprezentowana przez typ unsigned. Takich zmiennych jest w przeciętnym programie znakomita większość. Jeżeli coś może przyjmować wartości ujemne, to nie tylko powinno być reprezentowane przez typ signed, ale ponadto powinniśmy zwracać baczną uwagę na wszystkie operacje, w których ta zmienna jest używana. Są to często miejsca nie do końca przemyślane przez projektanta/programistę i oczywiście potencjalne źródło błędów, zaś schowanie ich w morzu niepotrzebnych int’ów zwiększa prawdopodobieństwo przeoczenia.

Podobnie na wzmożoną uwagę zasługują miejsca odejmowania dwóch liczb typu bezznakowego – wynik takiego odejmowania jest z zasady typem ze znakiem. Jeśli z natury problemu wynika, że wartość takiego odejmowania nie powinna być nigdy ujemna, to przed odejmowanie należy wstawić kod sprawdzający, czy odjemna nie jest mniejsza od odjemnika (ten test czasami warto zostawić nawet w wersji Release). Jeśli wynik odejmowania może być ujemny. to czasami warto sprawdzić czy wynik odejmowania nie przekroczy zakresu (dwa razy mniejszego) typu ze znakiem.

Stosowanie typów bezznakowych daje nam dwa razy większą pojemność zmiennej – w końcu dodajemy jeszcze jeden, najbardziej znaczący bit. W przypadku dłuższych typów często nie stanowi to problemu, jednak w przypadku liczb typu sbytebyte jest znacząca: 128 wartości, a 256 wartości czasami czyni dużą różnicę.

Pamiętajmy, gdy w systemie w którym 90% zmiennych jest typu unsigned zobaczymy zmienną typu signed, natychmiast zaczniemy rozważać przypadki, w których przyjmie ona wartości ujemne i dokładniej przemyślimy, co z tego może wyniknąć.

A co ze zmiennymi typu float czy double, a zwłaszcza z ich konwersją do zmiennych całkowitoliczbowych? Odpowiedź jest prosta – nic. Wszędzie, gdzie dokonujemy takiej konwersji musimy się dokładnie zastanowić. Ostatecznie typ zmiennopozycyjny służy do tego, co w świecie realnym posiada tzw. widmo ciągłe – czyli najczęściej reprezentowane jest przez liczby rzeczywiste. Operacja konwersji takiej wartości na zmienną o widmie dyskretnym jest sama w sobie operacją niezbyt bezpieczną - przede wszystkim następuje utrata precyzji. Po drugie, często wartość liczb typu double nie mieści się w zakresie reprezentowanym przez zmienne typu integer. Dlatego przed taką konwersją (często źródłem danych typu float są przetworniki pomiarowe) warto sprawdzić, czy zmienna mieści się dopuszczalnym zakresie. Możemy tego nie robić w środku systemu, gdy zmienna przeszła przez wszystkie filtry i testy. Zaś co do samej konwersji na signed czy unsigned – jeśli wartość zmiennoprzecinkowa reprezentuje sobą wielkość fizycznie nieujemną (np. waga) to możemy i w zasadzie powinniśmy ją konwertować do typu unsigned, choć tego rodzaju operacja jest raczej rzadko stosowana (np. w tablicowej reprezentacji funkcji etc.).

Podsumowanie

Jakie dobre praktyki programowania w C/C++ powinniśmy stosować odnośnie typów zmiennych, żeby programować w "dobrym stylu", a co najważniejsze ograniczyć ilość potencjalnych źródeł błędów i nieoczekiwanych zachowań pisanych przez nas programów?

  • Unikajmy typów int, unsigned intchar - pierwszych dwóch z powodu nieokreśloności ich długości, trzeciego ze względu na Unicode.
  • Zdefiniujmy własne typy danych, zgodne ze standardem, ale o ściśle określonej długości bitowej, dostosowane do środowiska w jakim programujemy oraz używanego kompilatora. Typy te umieśćmy w jednym miejscu, np. w pliku MyTypes.h, który w naszych programach zawsze includowany jest jako pierwszy. W przypadku zmiany środowiska czy kompilatora, jeśli będziemy musieli przystosować nasze typy, zmiana będzie mogła być wykonana w tym jednym miejscu – w pliku MyTypes.h. We wszystkich projektach, w których mamy wpływ na większość powstającego kodu źródłowego stosujmy zdefiniowane przez siebie typy.
  • Wszędzie, gdzie jest to możliwe i fizycznie uzasadnione, stosujmy typy bezznakowe zamiast znakowych. Projektujmy i programujmy zgodnie z zasadą: jeśli coś, co przedstawia sobą zmienna, przyjmuje wartości całkowite i nieujemne to musi być reprezentowana przez typ unsigned.

1[INTERNATIONAL STANDARD ISO/IEC 14882 Second edition 2003-10-15 Programming languages — C++], rozdział 3.9.1 "Fundamental types", pkt. 2, str. 53– wytłuszczenie moje

2Ibidem, pkt. 6, str. 54

Copyright © Ebitech Spółka z o.o.