2. Typy danych

2.2. Typy proste

Typ logiczny

Nazwą typu logicznego jest bool. Typ może przechowywać tylko dwie wartości: fałsz (false) i prawda (true) uporządkowane w tej właśnie kolejności. Jest to typ arytmetyczny przeliczalny. Typ logiczny został wprowadzony jako nowy w języku C++ - w starym C jako logiczny fałsz przyjmowano wartość zero, jako prawdę wszystkie inne wartości. I tak też dokonywane jest rzutowanie w języku C++. W przypadku rzutowania zmiennej typu bool na typ liczbowy fałsz zostanie przedstawiony jako 0, natomiast prawda jako 1.

Fakt przechowywania tylko dwóch wartości sugerowałby wyjątkową oszczędność pamięci przy stosowaniu zmiennych tego typu – tak niestety jednak nie jest. Każda zmienna logiczna zajmuje w pamięci komputera co najmniej 1 bajt (a nie bit) – wynika to ze względów wydajności. W zdecydowanej większości architektur sprzętowych najmniejszą możliwą jednostką pamięci możliwą do przesłania między RAM a procesorem jest właśnie bajt.

Typ całkowity (stałoprzecinkowy)

Nazwą typu stałoprzecinkowego jest int. Typ int może przechowywać liczby całkowite (inaczej: stałoprzecinkowe) z pewnego określonego przedziału zależnego od połączenia procesora, systemu operacyjnego oraz kompilatora który wykorzystujecie.

W przypadku większości kompilatorów dla Windows, jeśli zdefiniujecie zmienną jako całkowitą, kompilator założy, że jest to 32-bitowa liczba ze znakiem. Zatem będziecie w stanie przechowywać w niej wartości z przedziału od -231 do 231-1, czyli od -2147483648 do 2147483647. Trochę ciężko zapamiętać, nie? Prościej skorzystać z faktu, że rzeczywista wartość maksymalna danego typu jest praktycznie zawsze dostępna przez odpowiednie makrodefinicje / szablony biblioteki standardowej. W przypadku int wartość minimalna i maksymalna są dostępne pod nazwami odpowiednio INT_MIN i INT_MAX, lub - lepiej - poprzez std::numeric_limits<int>::min i std::numeric_limits<int>::max.

Typ int występuje w kilku wariacjach, różniących się zakresem i faktem posiadania znaku lub nie. Dwie podstawowe modyfikacje to żądanie zmniejszenia ilości bitów i zakresu, czyli short, oraz żądanie zwiększenia liczby bitów i zakresu, czyli long. Drugi modyfikator to oznaczenie sposobu interpretacji najstarszego bitu w liczbie, czyli signed - oznacza że najstarszy bit oznacza znak liczby (innymi słowy – można przechowywać zarówno liczby dodatnie jak i ujemne) oraz unsigned - oznacza, że najstarszy bit wchodzi w skład liczby, i nie ma możliwości przechowywania wartości ujemnych.

Instnieje także typ long long - to jest long z modyfikatorem long

Jeśli nie podacie żadnego modyfikatora, C++ zakłada że typ jest typem zwykłym ze znakiem (czyli int jest równoważne signed int). Jeśli podacie tyko modyfikator typu, czyli short, long, signed lub unsigned – kompilator założy że zmienna będzie typu int. Wystarczy pisać np. long i będzie to oznaczało long int. Podobnie unsigned będzie oznaczać unsigned int.

Modyfikatory wielkości zmiennej short i long przez standard C++ są traktowane jako wyrażenie woli programisty, i nigdzie nie jest powiedziane, że za każdym razem w zmiennej typu long int da się przechować większą wartość niż w zmiennej typu int – zdziwicie się, lecz w większości przypadków te typy są sobie kompletnie równoważne. Dawniej jedyny wymóg nakładany przez standard języka można było w skrócie zapisać w następujący sposób:

1 = sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)

co oznacza naszymi słowami, że każdy następny wielkościowo typ ma być nie mniejszy niż bezpośrednio go poprzedzający. Jak się można było spodziewać, tworzy to niezły misz-masz. 
Aktualnie standard C++ definiuje, że char ma co najmniej 1 bajt, short oraz int co najmniej 2 bajty, long i long long co najmniej 4 bajty.

W praktyce wykorzystywane są modele danych (zapisane jako trzy liczby oznaczające kolejno długości int, long oraz wskaźnika):
  • Systemy 32 bitowe:
    • LP32 or 2/4/4 (int 16 bitów, long i wskaźnik 32-bit) - Win16 API
    • ILP32 or 4/4/4 (int, long i wskaźnik mają po 32 bity) - Win32 API, Unix and Unix-like systems (Linux, macOS)
  • Systemy 64 bitowe:
    • LLP64 or 4/4/8 (int i long 32 bity, wkaźnik - 64 bity) - Win64 API
    • LP64 or 4/8/8 (int 32 bity, long o wskaźnik - 64 bity) - Unix and Unix-like systems (Linux, macOS)

Jak macie wątpliwości co do długości określonego typu na danej platformie, bądź też chcecie pewną długość wymusić - możecie stosować jawną deklarację długości korzystając z typów o stałej długości. Standard tutaje definiuje ich sporo, między innymi: 

  • int8_t, int16_t, int32_t, int64_t - typy ze znakiem
  • uint8_t, uint16_t, uint32_t, uint64_t - typy bez znaku,
  • int_fast16_t - zmienna będzie miała co najmniej 16 bitów, w rzeczywistości wykorzystana zostanie najszybrsza reprezentacja dopuszczalna przez daną architekturę, potencjalnie większa niż 16 bitów.
  • intmax_t - najdłuższa wspierana wersja int na danej architekturze
  • ....

Zainteresowanych po więcej szczegółów odsyłam do opisu standardu.

Do wykonywania obliczeń raczej stosujcie typy ze znakiem – podejście w stylu „zmienna x nie powinna przyjmować wartości ujemnej więc zdefiniuję ją jako unsigned int” może być przyczyną poważnych błędów – po przypisaniu do takiej zmiennej wartości ujemnej otrzymamy … brak błędu i bardzo dużą liczbę dodatnią. Rozsądnym stosowaniem typów bez znaku jest wykorzystywanie ich do indeksowania, a i to przy założeniu zachowania konsekwencji takiego podejścia (między innymi size_t z biblioteki standardowej jest akronimem typu bez znaku - co bywa kontestowane i krytykowane przez część społeczności programistów).

Oprócz samego definiowania zmiennych i stałych, istnieje także wiele sposobów zapisu literałów stałoprzecinkowych (wartości) w kodzie programu. Wartości możemy podawać dziesiętnie, ósemkowo lub szesnastkowo, wymuszając jednocześnie traktowanie liczby jako liczby ze znakiem lub bez. Liczby dziesiętne piszemy „normalnie”, liczby ósemkowe poprzedzamy cyfrą 0, a szesnastkowe parą znaków 0x. O ile z zapisem szesnastkowym nie ma problemów, to powinniście uważać na zapis ósemkowy:


int x{15};
int y{015};
if (x == y)
    cout << "Tego sie spodziewamy";
else
    cout << "a to jest";

15 i 015 to różne liczby!


Zapis dziesiętny Zapis ósemkowy Zapis szesnastkowy
0 00 0x0
2 02 0x2
83 0123 0x53


Dodatkowo, przy zapisie literałów możemy wymusić ich traktowanie jako liczby bez znaku (dodając U na końcu) lub jako liczby długiej (dodając L na końcu) lub bardzo długiej (LL). Przykładowe definicje zmiennych i stałych stałoprzecinkowych:


// zmienna całkowitoliczbowa ze znakiem
int a;
// zapis równoważny
signed int b;
// deklaracja zmiennej powiększonej:
extern long c;
// krótka liczba bez znaku
unsigned short d;
// inicjacja zmiennej liczbą w zapisie szesnastkowym
int e{0x0EF};
// inicjacja zmiennej liczbą w zapisie ósemkowym z wymuszeniem braku znaku
unsigned long f{0x12U};

Typ znakowy

W C++ występują dwa typy znakowe: char przeznaczony do trzymania znaków w kodowaniu ASCII (jednobajtowych), oraz wchar_t przeznaczony do trzymania znaków w kodowaniu UTF16 (dwubajtowy). Typ znakowy jest typem przeliczalnym i arytmetycznym.

W rzeczywistości C++ nie analizuje znaków w żaden sposób – przechowuje je, i operuje na nich tak jak na liczbach całkowitych. Dlatego też jest to typ arytmetyczny, i dlatego znaki możecie do siebie dodawać. Wartości znaków mogą być podawane jako literały znakowe (w pojedynczych apostrofach), albo bezpośrednio jako kody znaków. W przypadku podawania znaków jako literałów należy pamiętać o tym, że znak odwróconego ukośnika ma znaczenie specjalne, i służy do podawania kodów sterujących:

Typ znakowy można wykorzystywać również do operacji na małych liczbach całkowitych.

Pamiętajcie również, że istnieją w C++ dwa typy znakowe: signed char i unsigned char. Jest również typ char (bez modyfikatora) i jest on równoważny albo jednemu, albo drugiemu z nich (standard nie precyzuje, któremu) w związku z czym, nie należy zakładać nigdy sposobu, w jaki w danym kompilatorze wartości z zakresu -128 do -1 czy 128 do 255 będą traktowane przez typ char. W przypadku, gdy chce się używać zakresów typu char poza 0-127 należy jawnie określać char jako signed lub unsigned.


char z1{'a'}
char z2=  '\t'
char z3 = 48;

wchar_t wz = L'ab';

Liczby rzeczywiste

Typ liczb rzeczywistych występuje (podobnie jak int) w kilku wersjach różniących się wielkością i zakresem wartości: float, double i long double. Jest typem arytmetycznym, lecz nie jest typem przeliczalnym.

Najmniejszy z typów rzeczywistych, float, nie powinien być przez Was traktowany jako podstawowy typ zmiennoprzecinkowy (mimo że wiele podręczników ciągle traktuje go w ten sposób). Najczęściej float posiada tylko 6 (sic!) cyfr znaczących - wszelkie obliczenia na takich wartościach obarczone są ogromną niedokładnością wynikłą z konieczności przybliżania. Dla porównania – double zazwyczaj ma 15-16 cyfr znaczących.

Literały stałoprzecinkowe można wprowadzać również na kilka sposobów, które pokażemy na przykładzie liczby 123.4567:

Zapis Typ
123.4567 double
123.4567F lub 123.4567f float
123.4567L lub 123.4567l long double
1.234567e2 lub 123.4567E2 double


Wewnętrznie zmienna rzeczywista jest pamiętana w postaci dwu członów: podstawy a i wykładnika b i jest równa a * 10 b (a razy 10 do potęgi b), przy czym zarówno a jak i b muszą mieścić się w pewnym przedziale. Z tego faktu wynikają dwa ograniczenia - liczba bitów przeznaczona na pamiętanie podstawy a określa nam maksymalną możliwą precyzję zapamiętania liczby (ilość miejsc po przecinku), liczba bitów, jaka jest przeznaczona na pamiętanie b definiuje natomiast zakres zmienności zmiennej. Mówiąc inaczej, możecie zapamiętać dokładnie liczbę 0.000000000000001 oraz 100000000000000, natomiast nie można zapamiętać 100000000000000. 000000000000001 - część ułamkowa zostanie pominięta w tym przypadku. Co gorsza, jeśli dodacie te dwie liczby do siebie, w wyniku otrzymacie pierwszą z nich, a o spowodowanej poprzez zaokrąglenie niedokładności nie zostaniecie nawet poinformowani. 

Ponadto liczby są pamiętane w systemie dwójkowym a nie dziesiętnym. Uruchomcie sobie poniższy program: 


#include "iostream"

int main() {

    for (double i=-1; i<1; i+=0.1)
        std::cout << i << "\n";

    return 0;
}

Zapewne zauważycie - że program nigdy nie wyświetli 0. Dlaczego - bo w systemie dwójkowym 1/10 jest liczbą niewymierną (nie ma skończonego rozwinięcia), podobnie jak 1/3 w systemie dziesiętnym ... 

Sama dokładność typów zmiennoprzecinkowych także zależy od implementacji. Najczęściej spotykana postać zakłada, że: 

  • float jest pamiętany na 32 bitach (zgodnie z normą IEEE-754 32)
  • double jest pamiętany w 64 bitach (zgodnie z normą IEEE-754 64)
  • long double jest zależny od platformy, i może mieć 128 bitów (SPARC, ARM64), 80 bitów (większość implementacji dla procesorów rodziny x86-64), normalny format 64 bitowy analogiczny dla double (kompilator msvc firmy Microsoft) 

Jako ciekawostkę możecie poczytać sobie o proponowanych w standardzie C++ 23 typach o stałej dokładności (ang. fixed-width floating-point types).

Przykłady definicji zmiennych rzeczywistych:


double da{1.23};
double db{.23};
double dc = 1.;
double dd = 1.2e-12;

Wartość minimalna i maksymalna liczb zmiennoprzecinkowych zazwyczaj nie jest definiowana tak, jak to miało miejsce w przypadku typów prostych. W zamian za to można uzyskać do niej dostęp poprzez szablon numeric_limits w sposób pokazany w przykładzie Rozmiary typów podstawowych.

Typ bez wartości (void)

Ostatnim z typów prostych które występują w języku C++ jest void – typ oznaczający brak wartości. Nie jest to typ ani arytmetyczny, ani przeliczalny, co więcej – nie można stworzyć zmiennej ani stałej typu void. Jego główne zastosowania to albo oznaczenie że funkcja wykorzystana jako wyrażenie nie zwraca żadnej wartości, oraz do rzutowania wskaźników. Oba przypadki zostaną dokładniej wyjaśnione w dalszej części podręcznika.

Wyliczenia

Wyliczenia w C++ są najczęściej wewnętrznie pamiętane jako jedna z odmian liczb całkowitych. Nie są typem stricte podstawowym, bo wymagają wcześniejszej definicji typu (przed pierwszym użyciem), natomiast też nie są typem użytkownika (nie można definiować w pełni ich zachowania). My zamieszczamy je wśród typów podstawowych.

Wyliczenie definiuje się przy wykorzystaniu słowa kluczowego enum, i są typem przeliczalnym, ale uwaga – nie zalicza się ich do typów arytmetycznych. Ogólnie wyliczenia są przewidziane do przechowywania ściśle określonego, zdefiniowanego przez użytkownika zbioru wartości. Przy czym trzeba pamiętać, że każde wyliczenie jest oddzielnym typem, który może – ale nie musi – mieć nazwę.

Domyślnie C++ przypisuje wartości liczbowe nazwom elementów wyliczenia kolejno, poczynając od zera i z krokiem 1, lecz programista może jawnie podać wartości numeryczne przypisywane stałym symbolicznym.

Istnieje możliwość przekształcenia wyliczenia na liczbę całkowitą i odwrotnie, lecz bez kontroli zakresu - wynik przekształcenia stałej liczbowej spoza zakresu jest niezdefiniowany.

Przykłady definicji i wykorzystania wyliczeń:


// wyliczenie nienazwane
enum {ala, kot, dwa_koty };
// wyliczenie nazwane
enum asta {la, vista };

// wyliczenie z określonym zakresem
enum eee {aaa = 3, zzz = 9 };

eee zmienna = eee(3); // ok
eee zmienna = eee(101); // źle

Współcześnie, zamiast starego enum zaleca się stosowanie enum class. Ogólnie zasady mapowania wartości przypisanych na nazwy pozostają bez zmian, natomiast zmieniono dwie cechy:

  • w przypadku korzystania z enum class nie ma niejawnej konwersji na int
    • nie można porównywać z int-ami, oraz z innymi wyliczeniami
    • nie można inicjować wartością int
  • nazwa wartości w nowych wyliczeniach może się powtarzać.


enum class Color { RED, GREEN, BLUE };
int main() {
  Color c = Color::RED; //OK
  c = BLUE; //Błąd!
  int x = Color::RED; //Błąd!
}

Rozmiar i ograniczenia wybranych typów podstawowych

Poniższy program wyświetli Wam informację o wszystkich podstawowych typach arytmetycznych przeliczalnych na Waszej platformie.


#include "iostream"
#include "climits"
#include "numeric"

using namespace std;

volatile int char_min = CHAR_MIN;

int main()
{
    cout << "Rozmiar typu bool:  " << sizeof(bool) << " bajtow\n";
    cout << "Liczba bitow do pamietania znaku: " << CHAR_BIT << '\n';
    cout << "Rozmiar typu char: " << sizeof(char) << " bajtow\n";
    /// stara składnia
    cout << "Wartosci dla signed char: min: " << SCHAR_MIN << " max: " << SCHAR_MAX << '\n';
    /// zalecana składnia
    cout << "Wartosci dla unsigned char min: 0 max: " << (int)numeric_limits<unsigned char>::max() << '\n';
    cout << "Domyslnym typem dla znakow jest ";
    if (char_min < 0)
        cout << "signed";
    else if (char_min == 0)
        cout << "unsigned";
    else
        cout << " ? dziwny jakis";
    cout << "\n\n";

    cout << "Rozmiar typu short int: " << sizeof(short) << " bajtow \n";
    cout << "Wartosci dla signed short: min: " << numeric_limits<short>::min() << " max: " << numeric_limits<short>::max() << '\n';
    cout << "Wartosci dla unsigned short min: 0 max: " << numeric_limits<unsigned short>::max() << "\n\n";

    cout << "Rozmiar typu int: " << sizeof(int) << " bajtow\n";
    cout << "Wartosci dla signed int: min: " << numeric_limits<int>::min() << " max: " << numeric_limits<int>::max() << '\n';
    cout << "Wartosci dla nsigned int: min: 0 max: " << numeric_limits<int>::max() << "\n\n";

    cout << "Rozmiar typu long int: " << sizeof(long) << " bajtow\n";
    cout << "Wartosci dla signed long: min: " << numeric_limits<long>::min() << " max: " << numeric_limits<long>::max() << '\n';
    cout << "Wartosci dla unsigned long: min: 0 max: " << numeric_limits<long>::max() << "\n\n";

    cout << "Rozmiar typu long long: " << sizeof(long long) << " bajtow\n";
    cout << "Wartosci dla signed long long: min: " << numeric_limits<long long>::min() << " max: " << numeric_limits<long long>::max() << '\n';
    cout << "Wartosci dla unsigned long long: min: 0 max: " << numeric_limits<long long>::max() << "\n\n";

    cout << "Rozmiar typu float: " << sizeof(float) << '\n';
    cout << "Wartosci dla float: min: " << numeric_limits<float>::min();
    cout << " max: " << numeric_limits<float>::max() << "\n\n";

    cout << "Rozmiar typu double: " << sizeof(double) << '\n';
    cout << "Wartości dla double: min: " << numeric_limits<double>::min();
    cout << " max: " << numeric_limits<double>::max() << "\n\n";

    cout << "Rozmiar typu long double: " << sizeof(long double) << '\n';
    cout << "Wartosci dla long double: min: " << numeric_limits<long double>::min();
    cout << " max: " << numeric_limits<long double>::max() << "\n\n";

    return EXIT_SUCCESS;
}