2.5 Deklaracje zmiennych i typy danych

Podstawowe typy danych

Najczęściej chyba używanym typem jest int (typ całkowity). Jest on poza tym domyślnym typem, jeżeli nie został podany. Skoncentrujemy się na razie na typach liczbowych. Typ char jest typem znakowym (JEDEN znak!), ale również liczbowym całkowitym, obsługującym zakres od -128 do 127 (uwaga: ta informacja może być nieprawdziwa jeśli chodzi o liczby ujemne; patrz niżej). Rozmiar (a więc i zakres) typu int zależy od implementacji, w przypadku systemów 32-bitowych ma on 4 bajty i obsługuje zakres -2147483648 do 2147483647. Jeśli masz ten tekst w edytorze `vim', możesz inkrementować lub dekrementować liczby na których stoi kursor klawiszami odpowiednio Control-a (pod screenem: Control-a a) i Control-x. Możesz sprawdzić, że dla tych liczb operacje te powodują "przekręcenie licznika".

Typ int może również posiadać modyfikatory: short oznacza liczbę krótką całkowitą (zazwyczaj 2 bajty), long długą (4 bajty). Podane wielkości jednak nie są standardowe, lecz zależne od implementacji. Wielkość danych konkretnego typu można sprawdzać za pomocą operatora sizeof:

Standard co do wielkości typów zakłada jedynie, że:

Istnieje również w niektórych kompilatorach typ long long (np. w gcc i ma on wielkość 8 bajtów). Jest to jednak typ niestandardowy.

Modyfikator unsigned oznacza, że liczba tego typu nie obsługuje liczb ujemnych, tzn. np. typ unsigned char ma zakres od 0 do 255, podobnie unsigned short int (jeśli jest 2-bajtowy) ma zakres od 0 do 65535. Dla symetrii istnieje również słowo signed. Zaznaczam od razu, że istnieje też taki typ (już biblioteczny, ale po wczytaniu dowolnego pliku nagłówkowego można uważać, że jest dostępny) size_t. Standard zakłada, że jest to typ wyniku operatora sizeof, a jest najczęściej identyczny z unsigned int.

Tu uwaga co do typu char: 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 (lub dokonać ustawienia odpowiednich opcji kompilatora, nie będzie to jednak przenośne).

Jak napisałem wcześniej, typ int jest używany domyślnie w przypadku, gdyby nie był podany (ale - UWAGA! - tylko jeśli podano modyfikator typu, czyli short, long, signed lub unsigned), toteż wystarczy pisać np. long i będzie to oznaczało long int. Podobnie unsigned będzie oznaczać unsigned int.

W C++ istnieje też typ logiczny (lub inaczej `boolowski', ewentualnie niektórzy mówią "buliński" :), który nazywa się `bool'. Wartości tego typu to jedynie prawda i fałsz, które są dostępne pod literałami `true' i `false'. Jeśli ktoś nie wie, skąd się wzięła nazwa `boolowski' (ang. boolean), to wyjaśniam, że od nazwiska matematyka, który opracował algebrę dla wartości logicznych, George'a Boole'a. Zaznaczam od razu, że typ `bool' jest typem wartości przyjmowanej przez operatory `&&', `||' i `!' i zwracanym przez ==, != itd. Jednak ten typ nie jest tak restrykcyjnie pilnowany jak to ma miejsce np. w pascalu; może być dowolnie niejawnie konwertowany na typ int, z kolei na ten typ można konwertować każdą wartość konwertowalną na całkowitą (true jest konwertowane na 1).

Liczby rzeczywiste mogą być reprezentowane przez jeden z typów zmiennoprzecinkowych: float, double, long double, zajmujących odpowiednio 4, 8 i 10 bajtów pamięci (przynajmniej na aktualnie popularnych kompilatorach i systemach operacyjnych; zastrzegam wciąż, że te wielkości są specyficzne, mają one oznaczać jedynie typ zmiennoprzecinkowy pojedynczej, podwójnej i rozszerzonej precyzji; podane tu długości są zgodne z jakąś tam normą IEEE :*).

Wszystkie te wymienione typy nazywane są typami WBUDOWANYMI (ang. built-in) lub ŚCISŁYMI (ang. strict) - pewnie dlatego, że są to typy, które są tak ścisłe, że ściskać ich już nie ma potrzeby :*) (patrz przekazywanie argumentów do funkcji). Typy te należą również do grupy zwanej "POD" ("Plain ol' data"). Obiekty takich typów podpadają pod dość luźne reguły, a konkretnie pod takie, pod jakie podpadają obiekty w C (będzie o tym mowa przy właściwościach rozszerzonych).

Deklaracje zmiennych i ich zasięg

Podstawowe klasy pamięci (nie wiem, czy to dobre określenie; po angielsku jest to "storage class", można by to też nazwać "klasą umieszczania") dla obiektów to globalna i lokalna. Obiekt lokalny jest dostępny wyłącznie w kontekście, w którym został zadeklarowany, zaś jego czas życia jest od wejścia do kontekstu do wyjścia z niego. Globalny zaś, deklarowany poza wszystkimi funkcjami, jest dostępny dla wszystkich funkcji. Deklaracja zmiennej ma bardzo prostą składnię:

<modyfikator> <typ> <nazwa zmiennej>;

Jeżeli deklarujemy kilka zmiennych tego samego typu, możemy podać ich listę: unsigned long int i, j, k; . Zmienna może być zainicjalizowana jakąś wartością:

int i = 0;
lub w notacji C++:
int i( 0 );

Zmienne typów POD - ZAPAMIĘTAJ - nie są automatycznie inicjalizowane; nie zainicjalizowana zmienna posiada wartość taką, jaka się jej trafiła w przeznaczonym dla niej kawałku pamięci. Zauważ, że wyniki wszystkich operacji na takich wartościach (z wyjątkiem przypisania) są niezdefiniowane. Wartość taką nazywamy WARTOŚCIĄ OSOBLIWĄ (ang. singular). Zapamiętaj to pojęcie, gdyż później będzie ono potrzebne. Z doświadczenia wiem, że największe głowy często miały problem ze zrozumieniem, co to takiego wartość osobliwa (i myliły często z wartością niewłaściwą). Wartość osobliwa to po prostu taka wartość o której nie tylko nic nie wiemy, ale nad której wartością program nie ma żadnej kontroli; innymi słowy, jest to wartość, której nikt nie nadał. Później się dowiesz jeszcze, że wartość osobliwa może istnieć nie tylko przez pozostawienie zmiennej niezainicjalizowaną; w programie taka wartość może również czasem POWSTAĆ.

Dla zmiennych lokalnych praktycznie nie ma różnicy pomiędzy przypisaniem a inicjalizacją (istnieją jednak wyrażenia dozwolone dla inicjalizacji, lecz zabronione dla przypisania!). Dla zmiennych globalnych różnica jest istotna - wartości, jakimi są inicjalizowane są zapisane w pliku programu i owe wartości inicjalizujące są do nich wpisywane jeszcze przed rozpoczęciem wykonywania programu. Mimo wszystko jednak pamiętaj, żeby ZAWSZE w miarę możliwości inicjalizować zmienne.

Tu od razu pewna dodatkowa uwaga co do inicjalizowania zmiennych globalnych. Wspomniałem na samym początku, że nie do końca jest prawdą, że instrukcje muszą zawierać się wewnątrz funkcji (takoż tylko w języku C jest prawdą, że pierwsza instrukcja programu to pierwsza instrukcja funkcji main). Otóż to jest właśnie ten szczególny przypadek. Zmienne globalne mogą być inicjalizowane również rezultatem wywołanej funkcji (czy też po prostu wyrażenia możliwego do zwartościowania tylko w czasie działania programu, a nie w czasie kompilacji). Kod taki wykonuje się jeszcze przed pierwszą instrukcją funkcji main. Opisana sytuacja dzieje się jednak tylko wtedy, gdy obliczenie wyniku funkcji podczas kompilacji jest fizycznie niewykonalne - nikt przecie nie zabroni kompilatorowi, żeby obliczył wartość funkcji podczas kompilacji, jeśli tylko umie stwierdzić na 100%, że ta funkcja jest bezstanowa.

Klasy pamięci - modyfikatory deklaracji zmiennych i stałych

Obiekty (zmienne lub stałe) mogą być w zależności od potrzeby umieszczane w pamięci w różny sposób (często również determinuje to ich zachowanie w programie). W rozdziale b była mowa o obiektach globalnych i lokalnych. Jednak to były obiekty o domyślnie przyznawanych właściwościach. Inne właściwości uzyskujemy przez odpowiednie modyfikatory. Oto one:

Modyfikatory `static' i `extern' wzajemnie się wykluczają, zwłaszcza, że oznaczają dwie całkiem przeciwne właściwości, z których jedna jest dla danego obiektu/funkcji domyślna. Mają one też inne znaczenia dla obiektów zmiennych i stałych. Np. static:

  1. dla zmiennych globalnych: oznacza, że zmienna taka jest wewnętrzna (czyli lokalna dla bieżącej jednostki kompilacji), w przeciwnym razie jest zewnętrzna i może być importowana przez inne pliki. To samo znaczenie ma dla definicji funkcji.
  2. dla zmiennych lokalnych: oznacza, że zmienna taka nie jest tworzona za każdym wywołaniem funkcji, lecz jest tworzona raz (jak globalna) a lokalny jest tylko jej identyfikator. Można ją zainicjalizować, jednak jest to inicjalizacja podobna do inicjalizacji zmiennej globalnej: wykonuje się tylko raz. Jeśli tego nie zrobimy, jest ona inicjalizowana tzw. konstruktorem domyślnym (wyjaśnię to później; dla typów POD oznacza zapisanie go zerami).

Przykładowo, możemy sobie zrobić zmienną, która będzie zliczać wywołania funkcji:


#include <iostream>
using namespace std;

int compl( int );

int main()
{
        int a = 5, b = -10;

        cout << compl( a ) << ' ' << compl( b );
        cout << "\nFunkcja została wywołana " << compl( 0 ) << " razy.\n";

        return 0;
}

int compl( int c )
{
        static int ncall = 0;
        ncall++;
        if ( !c )
                return ncall;
        return -c;
}

Tak jeszcze na marginesie: zwracam uwagę, że w powyższym programie wywołanie compl z zerem, co powoduje odczyt zmiennej statycznej, jest umieszczone w następnej instrukcji po wywołaniu jej z innymi argumentami. Jest to dość istotne. Żeby wykonać pierwszą instrukcję należy najpierw obliczyć wszystkie elementy wyrażenia, a zatem w tym wypadku wywołać faktycznie umieszczone tam funkcje. Problem niestety w tym, że jeśli w jednym wyrażeniu zostały umieszczone dwa różne wywołania funkcji, to standard nie narzuca tu żadnej kolejności! Wcale nie jest powiedziane, że najpierw wywoła się compl z a, a potem z b (a na dodatek w większości kompilatorów jest dokładnie odwrotnie). Tu jednak nie ma to znaczenia, bo owe wywołania od siebie nie zależą. Natomiast wywołanie compl z wartością 0 jest jak najbardziej od nich zależne. Jeśli byśmy zatem wywołanie compl z zerem umieścili dalej w tej instrukcji, to mogłoby ono się wywołać jako pierwsze i okazałoby się, że ta funkcja została wywołana dopiero raz. Co zresztą byłoby jak najbardziej zgodne z prawdą.

Słowo static ma jeszcze jedno szczególne znaczenie, które omówię przy strukturach. Dla stałych globalnych `static' oczywiście nie ma sensu, gdyż nadawana przez niego właściwość jest dla obiektów stałych właściwością domyślną. Dość szerokie znaczenie ma też słowo kluczowe `extern':

  1. jeśli poprzedza deklarację zmiennej (globalnej lub lokalnej) lub stałej, ale nie zainicjalizowanej, oznacza to, że tak zadeklarowany obiekt nie zawiera się fizycznie w danym pliku (`jednostce kompilacji'), lecz w innym (pojedynczym pliku obiektowym, bibliotece statycznej lub dynamicznej - dla UNIXa są to odpowiednio .o, .a i .so, dla DOS-a .obj, .lib i .dll). Deklaracja taka stanowi zatem tylko "zaimportowanie" obiektu. Można spotkać również funkcje poprzedzone tym modyfikatorem, ale nie ma on wtedy żadnego znaczenia.
  2. jeśli poprzedza deklarację stałej zainicjalizowanej, oznacza to, że taka stała posiada łączność zewnętrzną (więc z kolei extern przed stałą niezainicjalizowaną importuje taką stałą). W odróżnieniu od zmiennych, które można powstrzymać przez `static' przed eksportowaniem symbolu, stałe globalne wymagają extern, żeby eksportować symbol na zewnątrz.

Tu ważna uwaga nt. różnicy traktowania stałych przez C i C++: domyślną klasą dla zmiennych globalnych jest zewnętrzna. W C tak samo dla stałych, natomiast w C++ stałe mają domyślnie klasę wewnętrzną (tak jakby były zadeklarowane jednocześnie z modyfikatorem static). Jeśli plik ma być przeznaczony pod oba kompilatory, należy zawsze jawnie określić stałe modyfikatorem `extern' lub `static' (lub wybierać odpowiednie opcje przy pomocy sprawdzania, czy zostało zdefiniowane makro preprocesora __cplusplus, co jest techniką najczęściej stosowaną).

Dodatkowe znaczenie `extern' ma w wyrażeniu `extern "C"' lub `extern "C++"'. Oznaczają one jawnie język nadający reguły kompilacji danego fragmentu. Zaznaczam jednak, że owa konstrukcja jest przyjmowana wyłącznie przez C++. Język C posiada niektóre właściwości np. dotyczące struktur, które zostały usunięte lub zmienione w C++; dzięki tej dyrektywie można importować pliki napisane dla C w programie w C++. Często wystarczy nawet taka deklaracja:


extern "C" {
#include <clib.h>
}

Osobne znaczenie `extern "C"' ma dla funkcji, o czym była już mowa i będzie jeszcze poniżej.

Typowanie literałów

Istotną rzeczą jest znajomość typów stałych. Niekiedy (choć ostatnio coraz rzadziej) używa się w programach stałych, jak np. wspomniane 9.81. Wymienię więc może rodzaje literałów, sposób ich rozpoznawania i odpowiadające im typy:

I tutaj od razu chcę zwrócić uwagę na kilka rzeczy. Przede wszystkim, jak widać domyślnym typem dla 9.81 jest double, a nie float, jak niektórzy sądzą. Od razu też chciałbym zniechęcić wszystkich do zbyt poważnego traktowania typu float: posiada on tylko 6 (słownie: sześć!) cyfr znaczących. Oznacza to np., że liczba pi w typie float to tylko 3.14159. Toteż wszelkie obliczenia na takich wartościach obarczone są ogromną niedokładnością wynikłą z konieczności przybliżania. Następnie, można zapisać liczbę zmiennoprzecinkową, ale równą wartości całkowitej (tzn. o zerowej części ułamkowej) jako np. 12. lub 12.0. Przydaje się to czasem zamiast pisać (double)12.

Co do liczb całkowitych, to zwracam uwagę na to, że literały o typie int ulegają czasami też "interpretacji kontekstowej". To znaczy, w zależności od tego, jakiego typu oczekuje wyrażenie, jest to wartość oczekiwanego typu, a nie typu int. W innych okolicznościach (np. gdy podaje się w jakiś sposób wartość typu int, powiedzmy przez zmienną), następuje konwersja. Jest to co innego, niż interpretacja kontekstowa i jest obarczona luźniejszymi regułami. Interpretacja kontekstowa jednak - uwaga! - następuje tylko wówczas, gdy istnieje coś takiego, jak typ oczekiwany dla podwyrażenia (którym tutaj byłby ten literał). Jeśli zaś mamy do czynienia z dostosowaniem wyrażenia do typu podwyrażenia (będzie o tym mowa przy wzorcach, chcę tylko wspomnieć, że taka możliwość również istnieje, więc żeby się ktoś potem nie zdziwił), wtedy zawsze jest int, co ma swoje również przykre konsekwencje.

Kiedy więc mamy do czynienia z oczekiwanym typem dla podwyrażenia? Np. w operacji przypisania. Jeśli napiszemy unsigned x = 10; , to wtedy owo "10" ulega interpretacji kontekstowej i posiada typ unsigned int (mimo, że normalnie miałby typ int, a żeby wymusić typ unsigned trzeba by było napisać "10u"). Podobnie, można napisać long l = 10; i wtedy owo 10 zostanie zinterpretowane jako typ long. Jest to istotna różnica, bo nie następuje tutaj konwersja z int do long, tylko następuje zinterpretowanie 10 jako stałej typu long. Gdyby była konwersja, to mielibyśmy do czynienia z dodatkowymi operacjami i regułami, nierzadko również ostrzeżeniami kompilatora, który zwraca uwagę na naruszenie znakowości liczb całkowitych. Istnieje też jedna dość szczególna postać interpretacji kontekstowej, mianowicie literał 0 może być interpretowany jako wskaźnik. Zostanie to opisane dokładniej w następnym rozdziale.

Natomiast co do dostosowywania wyrażenia do typu podwyrażenia można parę rzeczy powiedzieć już teraz. Np. istnieją takie standardowe funkcje min i max, które zwracają najmniejszą i największą z dwóch wartości. Wartości te mogą być dowolnego typu, aby tylko można było je porównywać. Muszą być one jednak tego samego typu, albo typ należy jawnie wymusić (funkcje te są wzorcami). Zatem np. jak napiszemy std::max( 12, 16u), to wtedy typy elementów będą odpowiednio int i unsigned int. Jest to zatem niedopuszczalne i taka konstrukcja się nie skompiluje. Musimy albo zrobić tak, żeby obie wartości były tego samego typu, albo wymusić jawnie typ pisząc std::max<int>( 12, 16u ).

No i jeszcze jedno, a o czym chcę pisać to już chyba wiadomo po czcionce ;). No więc w jęzku C, na co chcę zwrócić szczególną uwagę, literał w apostrofach miał typ int, a nie char, jak w C++. Ma to swoje konsekwencje. Np. w programie w C możemy napisać 'FORM' i będzie to liczba całkowita, którą można podzielić na cztery takie znaki. Można takie coś np. odczytać z pliku jako jedną liczbę całkowitą i w takiej postaci porównać (to jest zresztą praktyczny przykład, jak na systemie Amigi rozpoznawało się typ pliku). W C++ oczywiście też można coś takiego uzyskać, ale też nie zawsze. Można np. napisać L'FORM', z tym tylko że będzie to typ wchar_t i jeśli nie będzie on na tyle pojemny, żeby zmieścić cztery elementy typu char (a z tego co wiem nie będzie, bo wchar_t to z reguły typ 16-bitowy), to kompilator takoż sypnie ostrzeżeniem i zrobi to, czego pragnie użytkownik najwyżej w przypływie dobrego humoru (ale nie sprawdzałem (int)'FORM'). Ma to jeszcze kilka innych konsekwencji, jak np. kwestie deklaracji funkcji z ctype.h)

No i jeszcze wróćmy trochę do tego wchar_t. Miałem się nim nie zajmować i takoż wiele o nim nie wspomnę, aczkolwiek warto. Co to jest unikod, to chyba nie muszę tłumaczyć, ostatnio jest zresztą bardzo modny. Przykładowo w języku Java typ char odpowiada w C++ typowi właśnie wchar_t. Ostatni standard, ISO C++ 98 wymaga od wszystkiego, co operuje na typie char (traktowanego jak znak), żeby operowało też na typie wchar_t. I wszystkie takie rzeczy istnieją, tzn. istnieje nagłówek <cwctype>, typ std::wstring, strumienie wcout, wcin, wcerr i wclog (i tak samo odpowiadające im klasy wios, wistream, wfstream itp.), wszystko operujące na tekstach unikodowych (tak dokładnie zresztą to podane wyżej nazwy są tylko aliasami; istnieją wzorce basic_string, basic_ios itd., które dopiero specjalizowane char stają się string i ios, a specjalizowane wchar_t - wstring i wios). Jest on jednak używany ostatnio raczej jako string tzw. dekoracyjny, czyli do wyświetlania najróżniejszych napisów w dowolnym języku z dowolnymi znakami. W tej chwili zresztą wszystkie biblioteki okienkowe (w tym również WinAPI) używają wyłącznie napisów unikodowych; tak samo zresztą również nazwy plików w ostatnio używanych systemach plików są wyłącznie unikodowe (zauważ więc, co to w praktyce oznacza: twoja nazwa w cudzysłowiu, którą podajesz jako ciąg znaków typu char, jest KONWERTOWANA na unikod w takich operacjach!). Jest z nim jednak drobny problem. Otóż ponieważ kompilatorów zgodnych tak w miarę w pełni ze standardem ISO C++ '98 nie jest wcale na pęczki, toteż ta kwestia niestety też jest trochę paskudna. Muszę oczywiście znów odwołać się do mojego ulubionego tematu czyli bibliotek okienkowych.

Otóż tu też istnieje spory rozjazd. Największy prezentuje biblioteka Qt. Wielu oczywiście narzeka na to, że jest ten cały QString, że niestandardowy (i ja ich oczywiście popieram!), ale ten QString istnieje już w Qt od dawna i to również od czasów, gdy o unikodzie w standardzie C++ nikt jeszcze nie słyszał (w szczególności, o samym standardzie nikt nie słyszał). Niestety mimo istnienia już od dawna kompilatorów, które honorują unikod (nawet gcc 2.95 gdyby odpowiednio przycisnąć, to honoruje!), Qt, jak to Qt, tkwi wciąż w konserwatyźmie. Tzn. mimo że bardzo im jest potrzebny string unikodowy i wymagają operowania wyłącznie na nim, to jednak QString nie tylko nie współpracuje z std::wstring, ale nawet z wchar_t; elementy QStringa są typu QChar, który jest opakowaniem short int, a nie wchar_t. QStringa nie można nawet zainicjalizować stringiem surowym o elementach wchar_t (czyli L"cos"). Można za to stringiem surowym o elementach typu char, co powoduje, że jest on (na bieżąco!) przekrecany na string unikodowy (operacja liniowego czasu, smaruje bajt po bajcie!).

W Gtkmm jest trochę inaczej - tam zdecydowano się na coś, co nazwano Glib::ustring. Nie jest to ani string, ani wstring; jest to string unikodowy zakodowany na UTF-8. Ma jednak interfejs identyczny z std::string.

Tworzenie aliasów do typów

Często zdarza się, że typ ma zbyt długą lub skomplikowaną nazwę i trzeba zrobić jakiś skrót do tej nazwy (albo nazwa się komuś nie podoba). Niewprawni początkujący zazwyczaj używają #define, jest to jednak surowo niezalecane. W przypadku tworzenia nowych nazw typów mamy do dyspozycji instrukcję typedef. Deklaracja takiego typu ma postać identyczną, jak deklaracja zmiennej takiego typu, ale jest poprzedzona słowem `typedef'. Przykład:


typedef unsigned int size_t;

Dodam jeszcze, że typ size_t istnieje w bibliotece i taka jest właśnie jego definicja. Według standardu, jest to typ danej zwracanej przez sizeof.

Typy wyliczeniowe

Nad typami wyliczeniowymi pracowano w C++ długo i namiętnie i przyznam szczerze, że średnio jestem zadowolony z ich ostatecznej koncepcji (gdyby były wyposażone w dziedziczenie, może byłyby trochę elastyczniejsze). Postać tego jest dość prosta. Deklarujemy sobie całkiem nowy typ, którego obiekty mogą przyjmować wartości określone odpowiednimi identyfikatorami:


enum Tydzien {
        PONIEDZIALEK, WTOREK, SRODA, CZWARTEK, PIATEK, SOBOTA, NIEDZIELA
};

Właściwie nic poza tym nt. tego typu nie da się powiedzieć (poza tym, że jest on typem posiadającym - zewnętrznie tylko - uprawnienia dostępne uniom). Pierwotnie wyliczenia w języku C były właściwie ekwiwalentem stałych statycznych (żeby nie powiedzieć o makrach preprocesora, co w języku C bezkonkurencyjnie króluje). Po prostu tam ten tak zwany typ zadeklarowany jako enum był ekwiwalentem `int' (jeśli mam być szczery - bo miałem też okazję operować tym w C - różniło się to od `int' wyłącznie tym, że w debuggerze miałem napisaną wartość danej zmiennej nie liczbowo, a poprzez własnie taką stałą). Oczywiście, również w C++ stałe te odpowiadają kolejnym wartościom typu `int', poczynając od zera. Można nawet im przypisać konkretne wartości całkowite np.:


enum eData {
        DATA_GRAM = 2, DATA_STREAM = 5, DATA_LAST
};

Stała `DATA_LAST' przyjmuje tutaj wartość 6. Niestety, ponieważ C++ jest językiem o ścisłej kontroli typów, zatem żadne bezpośrednie konwersje z typu int na typ wyliczeniowy nie są dozwolone (a jedynie w odwrotnym kierunku). Konstruktor typu int również dopuszcza każdy typ wyliczeniowy.

Oczywiście mimo tak restrykcyjnego traktowania typów przez C++, związek typu wyliczeniowego z typem int jest niezaprzeczalny. Choćby z tych względów, że wartości całkowite są wykorzystane do inicjalizowania stałych wyliczeniowych. Niestety na tym się praktycznie cały związek kończy. Typ wyliczeniowy właściwie niczego po `int' nie dziedziczy, w związku z tym wszelkie operatory arytmetyczne (choćby służyły nawet tylko do iteracji!) na tych typach działać nie będą. Oczywiście można zdefiniować własne, ale o tym przy właściwościach dodatkowych (przeciążanie operatorów). Najśmieszniejsze zresztą, że `int' można konwertować na typ wyliczeniowy przez konstruktor typu int lub operator static_cast (patrz niżej) nawet jeśli poda się taką wartość całkowitą, której nie odpowiada żadna stała zadeklarowana w danym typie wyliczeniowym (kompilator jest w stanie to stwierdzić tylko jeśli poda się stałą przez literał, a takie przypadki kłóciłyby się ze zdrowym rozsądkiem). Taką wartość typu wyliczeniowego nazywamy wartością NIEWŁAŚCIWĄ (ang. invalid).

Tu drobna uwaga: standard co prawda niczego takiego o zmiennych wyliczeniowych nie mówi; przyjmuje się że typ wyliczeniowy jest zrealizowany na bazie takiego typu całkowitego, który zmieści wszystkie podane tam wartości. Owszem, "nic" się nie stanie, jak skonwertujemy na danego enuma niezalabelowaną wartość. Niemniej jest to wartość o nieistniejącej reprezentacji (wartość całkowita NIE jest reprezentacją wartości zmiennej typu enumowego), zatem o nieistniejącej interpretacji logicznej (przecie po to się definiuje taki typ enumowy, żeby nadać liczbom interpretacje logiczne). Kompilatory zresztą zazwyczaj traktują rzecz dość poważniej. Gcc np. rzuca ostrzeżenie przy instrukcji switch ze zmienną takiego np. typu eData, gdzie NIE poda się default, JEDNAK nie w sytuacji, gdy będą 'case' dla wszystkich wartości enuma. Visual C++ pod debuggerem jeśli istnieje taka niezalabelowana wartość w zmiennej, to nie napisze jej wartości intowej, tylko "invalid value". No i ostatnia sprawa to kwestia możliwych wartości stanu - jeśli operujemy taką np. zmienną typu eData, to mamy prawo zakładać, że ma ona tylko 4 możliwe wartości (te, co zalabelowane plus wartość osobliwa), a to co niezalabelowane nigdy sie nie trafi.

Rzutowanie i konwersje

Czasami z wielu powodów podany wprost typ może zostać źle zinterpretowany. Np. jeśli chcemy wypisać na strumieniu znak, użyjemy konstrukcji:


        cout << 'a';

Jednak jak pamiętamy, choć typ char jest typem jednocześnie liczbowym i znakowym, to jego najczęstsza interpretacja jest jako typ znakowy. Jak zatem zmusić strumień cout, żeby zinterpretował nasz znak jako liczbę? Należy zrzutować (ang. cast) go na typ, który jest interpretowany jako liczbowy, np. int. W C zazwyczaj używało się zapisu: " (int)'a' ", jednak odradzam osobiście jego używanie (użytkownicy C++ nazywają to "obleśnym" rzutowaniem). W C++ mamy kilka możliwości: można użyć konstruktora typu:


        cout << int( 'a' );

lub operatora rzutowania, np. static_cast:


        cout << static_cast<int>( 'a' );

Zaznaczam jednak, że rzutowanie jest bardzo często nadużywane i w wielu wypadkach niepotrzebne. Tutaj akutat najlepszym rozwiązaniem było to pierwsze, ale konsruktory o takiej postaci nie są dostępne dla wszystkich typów (i dobrze!). Użycie z kolei rzutowania "obleśnego", zapożyczonego z C (czyli (int)'a') pozwoli nam na zrobienie każdej z wielu często dość odległych koncepcyjnie od siebie operacji, nawet dość idiotycznej (jest tylko kilka wyjątków, kiedy C++ nie pozwoli na przeprowadzenie takiego rzutowania; można wręcz przyjąć, że to jest najlepszy oręż w walce przeciwko statycznemu systemowi typów w C++, wobec którego C++ jest totalnie bezsilny). Bjarne Stroustrup w swojej książce "Projektowanie i rozwój języka C++" przytacza, że w takiej np. instrukcji `Y* y = (Y*)x' trudno powiedzieć, co programista chciał przez to uzyskać: czy wskaźnik do innego typu (powiązanego bądź nie), czy usunąć const, czy może jeszcze coś innego (typy mogą mieć między sobą powiązania, co szczegółowo zostanie opisane przy właściwościach dodatkowych). Co więcej, jej poprawność może często zależeć od innych, wcześniejszych instrukcji, co może spowodować, że w razie jakiejś modyfikacji w takich instrukcjach, operacja rzutowania może całkowicie zmienić znaczenie w sposób niezauważalny dla kompilatora, a cóż dopiero dla użytkownika. Wielu programujących (czy to z przyzwyczajenia z C, czy też ze zwykłego lenistwa, bo "tak jest krócej"), nadal używa "obleśnego" rzutowania. W C było to konieczne, gdyż tam innego rzutowania po prostu nie ma. W C++ jednak możemy dokładnie określić, co rzutujemy i dlaczego, aby ustrzec się przed popełnieniem ewentualnych błędów. Praktyczne przykłady wymagające rzutowania (często zmieniające sposób interpretacji zawartości kawałka pamięci) są o wiele bardziej niebezpieczne i wymagające ostrożności. Operator rzutowania static_cast jest pod tym względem bezpieczniejszy, gdyż jest łatwy do znalezienia i nie pozwala na dokonanie rzutowania naruszającego system typów lub wariancję (patrz niżej). W tym konkretnie wypadku można sobie pozwolić na krótką postać rzutowania; tutaj wszystko jest jasne i bezproblemowe, niestety w praktyce nie zawsze tak jest.

Operator static_cast zatem jest operatorem ogólnego rzutowania i stosuje się go wszędzie tam, gdzie można dokonać "legalnej" konwersji, tzn. takiej, która może być wykonana niejawnie lub której odwrotność może zostać wykonywana niejawnie. Również można go więc używać do dokonywania konwersji, której kompilator w danym przypadku nie może wykonać niejawnie, bądź dokona nie takiej konwersji, jaka nas w danym przypadku interesuje. Jest on jednak np. dużo łatwiejszy do ewentualnego znalezienia, gdyby stał się przyczyną błędu.

O konwersjach będę jeszcze mówił przy wskaźnikach. Istnieje bowiem (wraz z obleśnym) łącznie 5 operatorów rzutowania.

Przestrzenie nazw

Ponieważ wystąpiły już tutaj przykłady deklaracji, które tworzą identyfikator dostępny globalnie, poznajmy właściwość C++, która rozwiązuje problem konfliktów nazw takich identyfikatorów.

Mimo lokalizacji identyfikatorów, w dużych projektach nadal są problemy natury konfliktu nazw. Każdy chyba zdaje sobie sprawę z tego np., że propozycja wprowadzania nowych słów kluczowych do C++ zawsze napotyka na nieprzeparty opór. Powód jest prosty -- przy używaniu określonych słów zawsze ktoś "był pierwszy". Im starszy jest język, tym więcej jest słów, gdzie "był pierwszy" autor jakiegoś programu albo biblioteki. To bardzo utrudnia wprowadzanie również nowych elementów do standardowych bibliotek, jak również zmusza do używania bardzo długich nazw funkcji, co jest uciążliwe (proszę sobie obejrzeć interfejs do wątków POSIX-owskich). Często na ten problem się napotyka przy przenoszeniu programów pisanych w C, gdzie słowa takie, jak "class", "new", "this", czy "private" są bardzo chętnie wybierane na nazwy zmiennych lokalnych i pól struktur.

Jednym ze spektakularnych konfliktów przy wprowadzaniu słów kluczowych w C++, było słowo `throw', służące do zgłoszenia wyjątku. Prawie wszystkie języki programowania, które posiadają wyjątki, używają do tego słowa `raise'. Niestety w C++ nie było to możliwe, a jak ktoś nie wie dlaczego, niech zajrzy pod rozdział 2.11 i poczyta o standardowym nagłówku C `signal.h'. Widać też, że najnowsze słowa kluczowe (explicit i typename) są dość długie w porównaniu z innymi.

A teraz proszę sobie wyobrazić właściwość, dzięki której do biblioteki standardowej można było bezpiecznie i bezkonfliktowo dodać funkcje o nazwach takich jak: find, replace, copy, count, search, remove (funkcja o takiej nazwie zresztą istnieje też w stdio.h) i o podobnych poręcznie brzmiących nazwach. I przypomnieć sobie przy okazji, dlaczego we wszystkich przykładach przed funkcją main musiała wystąpić tajemnicza deklaracja:

using namespace std;

Instrukcja taka nakazuje zaimportować identyfikatory z przestrzeni nazw "std". Należą do niej m.in. deklaracje zawarte w <iostream>, np. cout czy endl. Gdyby bowiem tej instrukcji nie było, musielibyśmy pisać std::cout i std::endl. Przestrzenie nazw istnieją głównie z powodu zbyt często występującego w projektach pomieszania nazw (niektórzy to nawet wymyślali metody dodawania identyfikatorów do nazw klas, tzn. zawrzeć go w nazwie, np. swoje imie i nazwisko; kiedy i to nie pomagało to jeszcze datę urodzenia, ale to z kolei kolidowało z ustawą o ochronie danych osobowych i tak dalej).

Jak widać, przestrzeń nazw bardzo przypomina strukturę ze statycznymi deklaracjami. Są jednak istotne różnice, mianowicie przestrzenie nazw są otwarte. Gdyby takiej możliwości nie było, wtedy nie bardzo byłoby możliwe wybranie nagłówków do wczytania, gdyby się chciało zachować dla wszystkich z nich jedną przestrzeń nazw. Oto przykład:


        namespace nasza
        {
        int Funkcja();

        struct Szkapa
        {
                ...
        };
        }

I tak dalej. W takiej sytuacji użycie `Funkcja', czy `Szkapa' byłoby nieprawidłowe. Należy używać nasza::Funkcja i nasza::Szkapa. Jednak można sobie zastrzec, że pewne wybrane identyfikatory można stosować bez konieczności używania nagłówka:


using nasza::Szkapa;

I wtedy odpada konieczność używania nasza:: przed Szkapa. Można też zażądać używania wszystkich nagłówków w `globalnej' przestrzeni poprzez:


using namespace nasza;

Oczywiście deklaracja `using' jest czymś podobnym do zwykłej deklaracji; zatem może ona mieć zasięg lokalny (nie dalej, niż na plik!). Wszystkie nazwy będące w zasięgu globalnym identyfikuje się zatem - jak łatwo zgadnąć - przez jednoargumentowy ::.

Definicje przestrzeni nazw - podobnie jak klasy - można zagnieżdżać.

Przestrzenie nazw już teraz są częścią bibliotek standardowych. Odmiennie zatem, niż w starych nagłówkach typu <iostream.h>, gdzie wszystko lądowało w globalnej przestrzeni nazw, w przypadku <iostream> ląduje to w przestrzeni nazw std. Ponieważ w przykładach niczego skomplikowanego nie robimy, zatem najlepiej jest zaimportować sobie całe std.

Przestrzenie nazw takoż, jak widzieliśmy, nie są restrykcyjne. Użytkownik, w efekcie chciejstwa, może sobie poprzestawiać identyfikatory w przestrzeniach nazw jak mu się żywnie podoba. Np. mógłby sobie zachcieć wrzucenia funkcji C do konkretnej przestrzeni nazw i to nie tylko tak:

namespace posix {
#include <unistd.h>
}

ale również np. tak:

#include <stdio.h>

namespace stdc {
	using ::printf
	using ::puts;
	using ::remove;
	// ...
}

Czy takoż w wypadku biblioteki standardowej C++:

#include <list>
#include <vector>

namespace stl {
	using std::list;
	using std::vector;
}

Można też tworzyć czasem alternatywny kod np. do wersji debug czy coś w tym stylu. Załóżmy ewentualnie, że mamy osobną wersję bibliotek identyczne ze standardowymi, ale z dodatkowymi bajerami - np. powiedzmy, że mamy coś takiego jak:

namespace trial {
	// ... 
	class vector ...
}
// ...

	trial::vector v;
	// ...

Wtedy w razie, gdyby chcieć z powrotem przejść na std::vector wystarczyłoby napisać:

namespace trial = std;

I po takim małym zabiegu przestrzeń nazw trial jest aliasem do std, a identyfikatory w przestrzeni nazw trial funkcjonują jak gdyby nigdy nic.

No i ostatnia postać przestrzeni nazw, jaka nam została to odpowiednik modyfikatora static - czyli zastrzeżenia, że deklaracja nie podlega zewnętrznemu wiązaniu:

namespace {
	// tu statyczne deklaracje
}

Kiedyś nie doceniałem przestrzeni nazw, ale szczerze mówiąc jest to jedna z najważniejszych zalet C++. Dzięki tej właściwości można tworzyć nazwy identyfikatorów długie... i krótkie zarazem :). Bezpieczeństwo podczas wiązania zostaje oczywiście (i oczywiście tylko w ramach C++).