Skoro już poznaliśmy tworzenie prostych rzeczy, możemy przejść do spraw nieco bardziej skomplikowanych. Zanim jednak przejdziemy do bardziej zaawansowanych typów danych, poznajmy sposoby posługiwania się obiektami i wartościami w C++.
Przede wszystkim, czym jest jedno i drugie? Otóż wartość jest to "coś" na czym można operować w programie; jest w pewnym sensie czymś ulotnym, gdyż nie musi istnieć żadne "miejsce", w którym ona siedzi. Wartość jednak może też determinować stan jakiegoś obiektu. Obiekt zatem może "mieć" (a więc przechowywać) wartość. Ponieważ może być kilka takich obiektów, zatem każdy obiekt posiada tożsamość. To oznacza, że dwa obiekty mogą być "sobie równe" (tzn. mogą mieć równe wartości; to taki tylko "skrót myślowy" :), ale można też sprawdzić, czy jest to ten sam obiekt, czy też to są dwa różne obiekty. Zauważ, że wartość (np. taka wartość typu int) może być tylko wartością i w ogóle nie istnieć w kodzie wynikowym i podobnie jest np. ze strukturą, choć w programie występuje i operuje się na niej.
Po co nam to wszystko? Otóż pierwsza rzecz, o jakiej należy pamiętać to taka, że tylko obiekt może posiadać stan. Wartość z kolei reprezentuje ten stan. Na razie, ponieważ poznaliśmy wyrażenia i operacje, to zapamiętajmy teraz parę zasad, na jakich się to wszystko opiera. Otóż każdy najdrobniejszy element wyrażenia posiada swój typ. Wyrażenie, żeby było prawidłowe składniowo, musi być zbudowane zgodnie z wymaganiami dla konkretnych typów. Poznaliśmy już kilka typów, wiemy już jak deklarować zmienne, ale dotychczas nie powiedziałem, jakiego typu są te zmienne w wyrażeniu. Np. taka deklaracja:
int x, y;
deklaruje zmienne typu int. My je dajmy na to używamy w wyrażeniu:
y = x + 12;
W powyższej instrukcji najpierw wartościuje się "x + 12". Operator + wymaga w tym przypadku po obu stronach typu int. Zatem od "x" wymaga się typu int. No dobrze, powiedzmy, x może być tak traktowane. No ale dalej ten operator zwraca nam jakiś wynik i wykonuje się "int y = <wynik>". Po prawej stronie operatora mamy typ int. A po lewej? Czy też int? A jeśli tak, to czy można zrobić 12 = x + 2 (w końcu 12 też ma typ int) ?
Otóż, jak wszyscy dobrze wiedzą, nie można. Ale dlaczego? Ano dlatego, że po lewej stronie operatora przypisania musi w tym wypadku stać typ int& i taki właśnie typ jako wyrażenia mają symbole x i y. Cóż to takiego?
Nie ma się oczywiście co przerażać. Ten typ może być niejawnie konwertowany na int (choć w tym przypadku raczej należy mówić o "alternatywnej interpretacji" takiego wyrażenia) i z tego uzyskuje się wartość typu int wykorzystywaną do obliczeń (taka operacja ma miejsce w tym wyrażeniu). Jest to właśnie uzyskiwanie wspomnianej "wartości obiektu".
Jak wiec widać, typ int& jest to taki typ, który oznacza, że to, co owo wyrażenie reprezentuje, posiada swoją tożsamość. Konkretnie zaś, gdy wyrażenie typu int reprezentuje jakąś wartość, to wyrażenie typu int& reprezentuje sam obiekt (który posiada stan, a tym stanem jest aktualna jego wartość). Wartość TEGO typu nazywamy REFERENCJĄ. Referencja jest takim jakby "interfejsem" do czegoś, co przechowuje naszą wartość (czyli stan obiektu), tzn. umożliwia odczyt i zapis tej wartości. Referencja jest oczywiście w C++ pojęciem czysto językowym.
W szczególności, każdy obiekt w C++ składa się z dwóch rzeczy: wewnętrznej reprezentacji i referencji. Referencja jest właśnie niejako interfejsem pomiędzy wyrażeniem, które oczekuje wartości danego obiektu, a jego wewnętrzną reprezentacją. Wewnętrzna reprezentacja służy do przechowywania aktualnej wartości obiektu (czyli jego stanu). Reprezentacja wewnętrzna nie jest pojęciem ścisłym dla obiektów w C++. Tzn. ścisłą definicję posiada tylko dla typów... ścisłych (w standardzie określa się je jako "POD"). Tzn. mówiąc innymi słowy, takich którymi można operować w języku C. Dla obiektów tych typów jest to zatem kawałek pamięci, który przechowuje aktualną wartość obiektu.
Dla obiektów typów ścisłych zakłada się np., że można skopiować jego wewnętrzną reprezentację do reprezentacji innego obiektu tego samego typu i to spowoduje, że skopiuje się jego wartość. Dla bardziej skomplikowanych typów w C++ nie można tego przyjąć, dlatego też one zazwyczaj definiują swoje sposoby kopiowania, niemniej domyślny sposób kopiowania obiektów w C++ odbywa się właśnie przez kopiowanie reprezentacji. No dobrze, skoro więc wiemy, że obiekt posiada swoją wewnętrzną reprezentację, która (przynajmniej częściowo) składa się z jednego kawałka pamięci, więc można się jakoś do tego czegoś dostać.
Dlatego z referencjami nieodłącznie związane są wskaźniki. Referencja sama z siebie stanowi podstawę do IDENTYFIKACJI i TOŻSAMOŚCI obiektu. Jest to jednak taka dość "wirtualna" wartość. Co innego wskaźnik. Wskaźnik jest konkretną, znaną wartością, podobnie jak wartość typu int, którą możemy sobie zapamiętywać i przechowywać, a nawet wykonywać na niej operacje. Referencje zaś mamy tylko dostępną w danej chwili i nie można jej przechować, zmienić, ani nic podobnego.
Wróćmy jednak do źródła, bo sądzę, że niektórym zaczyna się już mieszać. Otóż wiemy, że zmienna zadeklarowana jako "int x;" posiada jako wyrażenie typ int&. Referencja zatem reprezentuje obiekt, ale nie mamy możliwości (poza samą deklaracją) wpływania na to, do czego ta referencja referuje. Wskaźnik zaś jest wartością, którą można w dowolnym momencie pobrać i zapamiętać. Typ takiej wartości w tym wypadku oznaczylibyśmy przez "int*", a pobieramy go przy pomocy operatora... &. Tzn. wyrażenie "&x" zwraca wartość typu int*, która to wartość jest wskaźnikiem na obiekt reprezentowany przez referencję oznaczoną nazwą "x". Symetrycznie, jeśli użyjemy operatora * na wartości wskaźnika (int*), to typem takiego wyrażenia jest int&. Operację, którą wykonuje operator & nazywamy pobieraniem adresu (a operator operatorem adresu), natomiast operacje z użyciem jednoargumentowego operatora * nazywamy wyłuskaniem lub dereferencją. Popatrzmy:
int x;
int* px = &x; // wskaźnik
*px = 0; // powoduje, że x == 0
Czym jest zatem wskaźnik? Jest to (najczęściej, bo standard odnosi się do tego ostrożnie) implementowane jako liczba całkowita, która oznacza adres w pamięci, gdzie przechowywany jest ów obiekt. Przynajmniej załóżmy coś takiego na użytek naszych rozważań (nie można tego traktować zbyt dosłownie, gdyż adres ten nie tylko może być wirtualny, ale kompilator w celach optymalizacyjnych często lubi "tylko udawać, że tak jest").
Gdybyśmy jednak dla uproszczenia przyjęli, że mówimy o typach ścisłych, to wskaźnik na większości kompilatorów jest adresem w pamięci, pod którym znajduje się jego wewnętrzna reprezentacja. Operatorem sizeof możemy z kolei zbadać, ile bajtów zajmuje owa reprezentacja. Wskaźnik ten z kolei można zrzutować na typ unsigned char* i w ten sposób dostajemy się do owej reprezentacji (standard o czymś takim wspomina). Przykładowo, na ostatnio modnych kompilatorach i systemach operacyjnych (choć 32-bitowe systemy już wychodzą z mody), taki typ int ma 4 bajty. Zatem sizeof (int) zwróci nam 4, a jej reprezentacja to tablica 4 bajtów (unsigned char), które pobieramy przez zrzutowanie wartości wskaźnika, pobranej przez operator &. Niestety radzę nie korzystać z tej wiedzy (mówię to tylko po to, żeby można było sobie wyobrazić, o czym mówię). Zakładając nawet, że na wystarczającej liczbie maszyn int ma 4 bajty, to jeszcze jest coś takiego jak endian, co oznacza, że dla int i = 1, wartość 1 może mieć na jednej maszynie pierwszy bajt, a na innej ostatni (a w niektórych przypadkach to nawet żaden :).
No dobrze, ale wróćmy do tych referencji. Mając taki typ "int", którego obiekt zajmuje te parę bajtów pamięci, mamy zdefiniowane również konkretne operacje, jakie można na wartości tego typu wykonywać. Zatem mamy to nasze +, -, *, / i tak dalej. Każda z tych operacji jest ściśle określona pod względem tego, co konkretnie takiego wyprawia z ich wewnętrzną reprezentacją (z int akurat jest dość prosta sprawa, bo operuje nimi sam procesor, ale to tutaj nie jest istotne). Jednak można tak nie dlatego, że pod tymi czterema bajtami siedzi coś szczególnego, tylko dlatego, że sposób wykonania operacji (takiego np. dodawania) jest zdeterminowany typem obiektu. Sposób zapisania wartości obiektu (czyli tej liczby całkowitej w tym wypadku) w owej wewnętrznej reprezentacji jest też zależne od owego typu. To typ decyduje o tym, jak traktuje swoją wewnętrzną reprezentację. Weźmy taki np. 'float'. Wg ostatnich kompilatorów (tzn. to jest też jakaś tam norma IEEE) owo float ma również 4 bajty. Ale zarówno wartość liczbowa takich np. dwóch liczb "40" i "4" będzie odmienna w przypadku typu int i float, jak też sposób dodania ich będzie przebiegał w zupełnie inny sposób.
Po co to wszystko piszę? Właśnie żeby uświadomić, po co istnieją referencje. Referencja posługuje się definicjami dostarczonymi dla konkretnego typu (czyli np. w jaki sposób wykonuje się dodawanie dla typów int) i na tej podstawie operuje zestawami zasobów pamięci, stanowiącymi wewnętrzną reprezentację tych zmiennych. Referencja odpowiada za operowanie wewnętrzną reprezentacją zgodnie z operacjami, które się jej wykonać każe. Zaraz pewnie usłyszę, że pewnie zamierzam sugerować jakoby bez referencji ten język "nie działał". Nie ukrywam, jest to prawda. Dlaczego zatem nie posiada ich język C (i nawet właściwie żaden inny język - PHP nie ma co liczyć)? Bo jest definicyjnie niekompletny :). A tak na poważnie - język C, jak i każdy inny język, posiada referencje, z tym tylko że w C są one ukryte przed użytkownikiem, więc nie mówi się o ich istnieniu również w procesie nauczania języka.
Wiemy już, że obiekty możemy deklarować jako zmienne lub stałe. Jak się można domyślać, tylko obiekt zmienny może posiadać stan, którego odczytanie zwróci wartość. Natomiast stała reprezentuje (przynajmniej teoretycznie) jedynie samą wartość. Jest zatem możliwe, że jeśli np. deklarujemy stałą typu int, to kompilator będzie na tyle inteligentny, że odwołania do tej stałej zamieni nie na odwołania do odpowiedniego obszaru pamięci, gdzie sobie zapisze podaną wartość, tylko po prostu na chama zwróci to, co podaliśmy tej stałej do inicjalizacji. W takim przypadku kod wynikowy nie będzie się różnił od takiego, w którym użyjemy #define. Ale... ale nie zapominajmy, że deklaracja tworzy obiekt. Nazwa tej stałej posiada też typ referencyjny (const int&), zatem możemy sobie pobrać wskaźnik do tej stałej (const int*). No ale co z nim możemy zrobić?
Tak naprawdę to nie możemy z nim zrobić nic. Teoretycznie taki wskaźnik powinien wskazywać na miejsce w pamięci, pod którym taka wartość jest zapisana. Jednak kompilator wcale nie musi gwarantować poprawności takiej wartości. Musi jedynie gwarantować, że wykonanie odczytu spod tej pamięci zwróci konkretną wartość, a to nie to samo. Zazwyczaj kompilatory co prawda aż tak nie kombinują; jeśli ktoś sobie żąda pobrania wskaźnika na stałą typu int, to kompilator mu taki obiekt zrobi. Nie zmieni to jednak faktu, że raczej nie będzie tego obiektu używał gdy nastąpi do niego odwołanie. Gdybyśmy z kolei chcieli w jakiś hackerski sposób spróbować coś zapisać do takiego obiektu (np. przez zrzutowanie const int* na int*) to w zależności od implementacji może nastąpić nieoczekiwane zachowanie (mimo zmiany, stała będzie miała nadal tą samą wartość), wylot programu na takiej próbie (stałe często są zapisywane w pamięci tylko do odczytu), ewentualnie na mniej wrażliwych systemach może doprowadzić do jego uszkodzenia.
W każdym razie od razu lojalnie uprzedzam: referencja to NIE JEST "ukryty wskaźnik". Bywa nim najczęściej, ale niekoniecznie; jak kompilator referencję zorganizuje to jest jego sprawa. Przykładowo gdy operuje się referencjami, to zmienna może np. siedzieć w rejestrze procesora (nie w każdym przypadku oczywiście, ale w wielu tak). W przypadku wskaźnika już nie, bo wskaźnik musi być wartością liczbową, którą można przechowywać. Jest też i inna sprawa: jak np. przekazujemy do funkcji argument typu const Klocek& (gdzie Klocek jest jakąś dużą strukturą), to kompilator zorganizuje oczywiście przekazanie przez wskaźnik. Ale przy const int& nie przekaże przez wskaźnik, bo byłoby to bez sensu; w takim przypadku przekaże to tak, jakby tam był typ "int". W zależności od chciejstwa, kompilator może również stwierdzić, że "obiekty do 8 bajtów przekazuję przez wartość, a większe przez wskaźnik". Widzimy więc, że już samo używanie const int& zamiast const int* ma wpływ na optymalizację.
Możemy też oczywiście sami deklarować zmienne typów referencyjnych. Choć oczywiście nazwanie deklarowanej referencji "zmienna" nie jest właściwe. Jest to jedyna deklaracja, której wyrażenie nie zwraca referencji. Tzn. nie tak; akurat zwraca, z tym tylko, że typ jest identyczny z typem w deklaracji (a nie z dodaniem &, jak to jest w innych typach). Jeśli zrobimy sobie coś takiego:
int& y = x;
to wtedy tworzymy sobie niejako DRUGĄ NAZWĘ (tu: y) do obiektu, któremu już utworzyliśmy referencje o nazwie x. W efekcie wyrażenie oznaczone zarówno jako 'x' jak i jako 'y' daje typ int& i na dodatek referuje do tego samego obiektu (czyli x i y różnią się tylko nazwą). Ponieważ jak wiemy obiekt w C++ składa się z wewnętrznej reprezentacji i referencji, mamy następującą sytuację: "int x" tworzy nowy obiekt, zatem przydziela mu pamięć na wewnętrzną reprezentację i tworzy referencję o nazwie "x". Druga deklaracja zaś pobiera wewnętrzną reprezentację zmiennej x i tworzy do niej referencję o nazwie y. Mamy więc jeden obiekt (bo jest jedna wewnętrzna reprezentacja), ale do niego są dwie referencje.
Oczywiście, referencje nie po to są wprowadzone do języka, aby uściślić jego definicje, lecz aby również zrobić z nich pożytek. Jednym z najważniejszych zastosowań dla referencji, jako programowalnej przez użytkownika, to przekazywanie argumentów do funkcji oraz zwracanie przez nie obiektów, które były przez funkcję obrabiane. Właściwie to ich wprowadzenie było konieczne z następujących powodów:
a. duże obiekty należało przekazywać przez wskaźnik, również do operatorów; przykładowo gdyby był to operator+, wywołanie miałoby postać &x + &y; dzięki referencjom nie musimy wstawiać znaków `&'
b. wiele operatorów, jak np. operator przypisania, powinny zwrócić obiekt, na którym operowały, aby umożliwić wielokrotne przypisanie: x = y = z; operator przypisania z typu obiektów x, y i z zwraca właśnie referencję do tego typu
Dodatkową możliwością jest tutaj oczywiście również przekazanie obiektu lokalnego do zmodyfikowania; odpada nam konieczność używania znaku `&' przed identyfikatorem obiektu (choć tej techniki stosować się nie zaleca, gdyż sposób przekazania argumentu jest w takim przypadku niemożliwy do odróżnienia; lepiej stosować wskaźnik). O referencjach będzie jeszcze mowa przy okazji przekazywania argumentów, a także dość ważna ich właściwość będzie przedstawiona przy obiektach tymczasowych.
Natomiast wskaźnik -- jak wspomniałem -- to zwykły typ danej, który również może tworzyć obiekty. Zatem możemy sobie go zadeklarować:
int* p = &y;
A wtedy 'p' jako wyrażenie będzie -- jak się można domyślać -- typu "int*&". Zmienna taka może nam przechowywać wskaźnik do jakiegoś obiektu. Jednak zauważ, że ten wskaźnik jest tutaj pobrany ze zmiennej i jego wartość jest poprawna tak długo, jak długo istnieje zmienna, do której owa wartość wskaźnikowa wskazuje.
Pisałem wcześniej (przy typach wyliczeniowych) o wartościach NIEWŁAŚCIWYCH. Otóż w przypadku wskaźników jest to dużo bardziej groźna sytuacja. Wskaźnik z reguły ma wartość właściwą tylko wtedy, jeśli jego wartość pochodzi z referencji do istniejącego obiektu. Zatem ważność adresu istnieje tak długo, jak długo istnieje obiekt, z którego referencji pobraliśmy adres. Oczywiście wskaźnik może mieć dowolną nawet bezsensowną wartość i nic się złego nie dzieje, dopóki ktoś nie próbuje go wyłuskiwać. Zatem wartość wskaźnika, która jest adresem istniejącego obiektu, nazywamy WYŁUSKIWALNĄ. Wartość właściwą zaś określamy jako wartość która powstała PO utworzeniu obiektu (do którego wskazuje), a ten obiekt jeszcze nie został usunięty. Wartość wskaźnika może być też niewyłuskiwalna całkiem celowo. Chodzi np. o wartość, która nie wskazuje na żaden obiekt, ale jest to np. jakaś szczególna wartość, która gdzieś może w wyniku czegoś powstać i chcemy potem to sprawdzić. Istnieje też wartość zerowa wskaźnika (powstaje przez przypisanie zmiennej wskaźnikowej zera całkowitego), która jest często wykorzystywana jako wartość, która z założenia "nie wskazuje na nic". Zatem, jak widzimy, wartość wskaźnika może być wykorzystana do różnych rzeczy, nie tylko wskazywania na konkretny obiekt. Jeśli jest to jakakolwiek inna wartość wskaźnika (czyli wskazuje "gdzieś w powietrze"), jest to wartość OSOBLIWA, gdyż jej źródło jest nieznane.
Parę uwag co do deklaratorów: normalnie w C++ powinno się pisać int *t (nie ma to co prawda znaczenia, ale tak się w C jeszcze przyjęło). Ja przylepiam * do typu z przyzwyczajenia - dla mnie typem jest int*, a zmienną deklarowaną t (zresztą zauważyłem, że taka konwencja jest wśród programistów C++ dość powszechna, również Bjarne Stroustrup takiego zapisu w swoich książkach używa, choć ja tego od nikogo nie zrzynałem). Jeżeli jednak podajemy listę zmiennych, to aby stworzyć zmienną typu int, zmienną wskaźnikową do int, oraz tablicę elementów typu int, musimy napisać:
int i, *t, tab[20];
Przyznaję od razu, że składnia wskaźników jest strasznie zamotana, przynajmniej jeśli chodzi o deklaratory. W deklaratorach niezbyt można przyjąć jakiekolwiek znaczenie słowne dla operatora `*'. Jego jedyną zaletą jest dobra zrozumiałość operatora `*' oznaczającego wyłuskanie (a takie użycie jest jednak częstsze). Zostanie to szczegółowo opisane w podrozdziale 5 (Deklaratory).
Wskaźnik można tworzyć dosłownie do wszystkiego, również do funkcji. Jest to bardzo przydatne do tworzenia tablic asocjacyjnych (ang. dispatch table), zwanych też (m. in. w STL-u) mapami. Tablica taka może zawierać wskaźniki na odpowiednie funkcje, które będą wybierane na podstawie odpowiedniego parametru-klucza. Wskaźnik do funkcji tworzymy w ten sposób:
int (*pfunc)( int );
Taka deklaracja tworzy zmienną `pfunc', która jest wskaźnikiem do funkcji przyjmującej i zwracającej typ int (wygodnie jest oczywiście utworzyć sobie alias do takiego typu przez użycie typedef). Jeśli teraz mamy funkcję
int fn( int );
możemy pobrać jej wskaźnik
pfunc = &fn;
a następnie ją wywołać:
a = (*pfunc)( 5 );
Oczywiście można też napisać " a = pfunc( 5 ) ", co jest zapisem uproszczonym. Radzę jednak ściśle trzymać się nakazu wyłuskiwania wskaźnika, gdyż to uproszczenie czyni zapis niejednoznacznym (C++ jest zresztą wśród języków programowania znany z niejednoznaczności składniowych, a przez twórców kompilatorów siarczyście klęty!), a na dodatek w C++ może oznaczać zupełnie co innego (np. jeśli taki wskaźnik jest polem struktury, to jeśli zamiast " (*a.func)( 5 ) " napiszemy " a.func( 5 ) " to będzie to zupełnie inaczej zinterpretowane przez kompilator - szczegóły przy właściwościach dodatkowych).
Wskaźniki do funkcji to sposób na najprostszą realizację programowania funkcjonalnego, dostępne również w C. Można dzięki temu potraktować funkcję jak obiekt, przekazać go gdzieś, a ktoś to w odpowiednim momencie wywoła. Jest to jednak bardzo prymitywne "funkcjonowanie" i C++ oferuje nam w tym względzie dużo większe możliwości (jeśli kogoś interesuje, polecam www.boost.org, wiele daje również biblioteka STL, opisana w rozdziale 5.3).
Wśród typów wskaźnikowych ciekawym typem jest typ `void*'. Istnieje on w C jako typ "uniwersalno-wskaźnikowy" i jest stosowany jako wskaźnik na "wszystko co się da". Oczywiście wyłuskanie takiego wskaźnika jest niedozwolone, jak ma to miejsce w przypadku każdego typu abstrakcyjnego (o tworzeniu typów abstrakcyjnych będzie w III-cim rozdziale), dlatego też trzeba go wpierw odpowiednio zrzutować.
Język C, przyjąwszy `void*' za typ uniwersalno-wskaźnikowy, nie wymaga jawnego konwertowania tego typu na inny typ wskaźnikowy; taka operacja może być wykonana niejawnie (prawdopodobnie ze względu na funkcję malloc). Szczerze nie potrafię zrozumieć, po co wprowadzono tak niebezpieczną właściwość, zamiast np. napisać makro preprocesora do przydzielania pamięci. Dzięki temu wszelkie naruszania systemu typów w C są praktykami na porządku dziennym, zwłaszcza że typizacja w C jest dość słaba (istnieje nawet taki dowcip, że jest ona dlatego tak słaba, żeby nie trzeba było tyle rzutować :). Nie muszę wspominać o tym, jak niebezpieczną rzeczą jest pozbywanie się statycznej kontroli typów i radzę się również o tym nie starać przekonywać (dla ciekawostki, słyszałem że programiści doświadczeni w ASEMBLERZE podchodzą z ogromną ostrożnością do wszelkich rzutowań). Dlatego C++ wymaga jawnego rzutowania pomiędzy każdymi dwoma nie powiązanymi hierarchicznie wskaźnikami (o powiązaniach hierarchicznych będzie przy właściwościach dodatkowych). Język C++ posiada wystarczająco dużo użytecznych właściwości, żeby statycznego sprawdzania typów nie trzeba było "obchodzić", co jest nagminną praktyką programujących w C (zresztą nie inaczej jest w Objective-C, języku mającym być podobno "bardziej obiektowym", niż C++), lecz wręcz wykorzystać.
Przypomnę od razu bardzo ważną rzecz - na typ void* można konwertować niejawnie dowolny typ wskaźnika, ale tylko do DANYCH. Wskaźnik do funkcji niestety nie podpada pod tą regułę. Oczywiście systemy operacyjne, w których wskaźnik na funkcję ma również 4 bajty, tak jak void*, korzystają z tej możliwości (jak np. uniksowa 'dlsym'), ja jednak zaznaczam od razu, że nie należy absolutnie nigdy na tym polegać (w C++ można się jeszcze bardziej nadziać na wskaźniki do metod, ale o tym później).
Poznaliśmy już operator rzutowania static_cast. Oto jest właśnie operator, który pozwala na rzutowanie pomiędzy dowolnymi dwoma typami wskaźnikowymi. Wg definicji jest to operator, który dokonuje takiego rzutowania, że obiekt może być użyty poprawnie dopiero po zrzutowaniu tego wskaźnika z powrotem na poprzedni typ. Nazywa się on `reinterpret_cast'.
#include <iostream>
using namespace std;
int main()
{
int a = 1;
float* f = reinterpret_cast<float*>( &a );
cout << f << endl; // ale jaja ;*)
}
Jak widać, pozwala on na zrobienie niemal dowolnej głupoty, niemniej nadal pilnuje wariancji (patrz niżej). Tylko ten operator również może być użyty do zrzutowania wskaźnika do obiektu na unsigned char*, które będzie reprezentować jego wewnętrzną reprezentację.
Lubię czasem rzucić jakimś fachowym słowem i widzieć, jak ktoś wytrzeszcza oczy. Uspokajam jednak zazwyczaj, że nie mówię niczego skomplikowanego ;).
No ale dobrze, wiemy już dwie rzeczy: że deklaracja stałej i tak deklaruje obiekt oraz że do takiego obiektu nie możemy niczego przypisać. Typ wyrażenia, którym oznaczyliśmy ową stałą, określa się jako "const int&". Co to oznacza? Oznacza po prostu, że referencja (bo musi być to referencja, gdyż tego wymaga język) nie pozwala na modyfikacje. Wszelkie operacje, które wymagają modyfikacji na podanym przez argument obiekcie, wymagają by był on podany przez ZMIENIALNĄ (ang. mutable) referencję. Dla int jest to przypisanie oraz operacje z "wbudowanym-przypisaniem", jak += itd.). Operatory te wymagają, by pierwszym argumentem (czyli tym, co stoi po lewej stronie operatora) było wyrażenie typu T&. A typ const T& się do T& niejawnie nie konwertuje (nie konwertuje się też legalnie na żaden sposób).
Tu proszę jednak zwrócić uwagę, że choć nadal jest to referencja, to jednak ona ma tylko zwrócić wartość (jest to jej funkcja jako interfejsu). Część implementacyjna jednak (odmiennie, niż to ma miejsce przy referencjach do zmiennych) niekoniecznie musi być podpięta do jakiejkolwiek wewnętrznej reprezentacji! No dobrze, ale skoro tak, to co zwróci operator & ? Ano coś tam zwróci. Jak się ktoś uprze, żeby ten obiekt stały nie był statyczny, to zwróci nawet jakiś rzeczywiście użyty przez program adres w pamięci :). Kompilator konkretnie MUSI tylko jedno w przypadku stałych referencji: zapewnić, że jej odczyt zwróci wartość; w przypadku pobranych tak wskaźników z kolei musi tylko zagwarantować, że odczyt spod tak wyłuskanego wskaźnika (gdzie by się on nie odbył) również zwróci wartość. Jednak wszelkie próby zapisywania takiej pamięci (jakimkolwiek hackerskim sposobem, bo legalnie to się tego w C++ nie da zrobić) odbywają się wyłącznie na odpowiedzialność użytkownika (włącznie z możliwością konieczności reinstalacji systemu lub oddania komputera do naprawy -- oczywiście trochę sobie żartuję, ale faktem jest, że systemy operacyjne, a zwłaszcza Windows, nie są na tyle odporne, żeby można było mieć pewność, że żaden program niczego nie spsuje; swego czasu widziałem programik, który losowo zapisywał komórki pamięci, a po jego uruchomieniu zdarzał się nawet "nieprawidłowy dysk" po restarcie).
Jak się można jednak domyślać, typ T może się niejawnie konwertować na const T&. W drugą stronę oczywiście też, ale w tym chyba nie ma niczego dziwnego. To jest właśnie podstawowy związek pomiędzy wartościami a obiektami (jeśli ktoś by pytał o coś takiego jak "typ" const T, to zaznaczam od razu: NIE MA czegoś takiego w C++; const jest tylko dla referencji). Popatrzmy na następujący przykład:
int f1( const int& z );
int f2( int );
int main( int argc, char** argv )
const int x = 10;
cout << f1( x ) << endl; // normalnie
cout << f2( 10 ) << endl; // też normalnie
cout << f2( x ) << endl; // const int& -> int
cout << f1( 10 ) << endl; // int -> const int&
return 0;
int f1( const int& z ) { return z + 10; }
int f2( int z ) { return z + 10; }
Jak zatem widać const oznacza pewną szczególną właściwość dla referencji. NIE dla obiektu. To, że samo const wprowadza celowe ograniczenia to jest tylko specjalne ułatwienie dla kompilatora. Jednak choć jest to tylko dla referencji, to ową właściwość przenosi wskaźnik (nie "posiada"). Oznaczenie jest też podobne: wskaźnik na stały obiekt typu int to wyrażenie (i wartość) typu "const int *". Oczywiście typ "const int*" jest typem rożnym od "int*". Niejawna konwersja również na podobnej zasadzie może się odbyć, mianowicie z "T*" do "const T*", a w druga stronę już nie. Jednak oczywiście T* jest tak samo zwykłym typem danej, zatem może być do niego referencja i sam typ też może deklarować obiekty stałe. Składnia jest nieco zamieszana; oznacza się to "T* const". Jeśli zatem zadeklarujemy sobie coś takiego:
int* const x = &y;
to wtedy wyrażenie 'x' ma typ "int* const&". To znaczy "stała referencja dla wskaźnika na int". Proszę się temu czemuś dokładniej przyjrzeć, bo z tym jest spore zamieszanie. Zatem przy wskaźnikach mamy taką sytuację: typ może być int* lub const int* i oba są typami wartości. Natomiast zmienna typu int* jest deklarowana jako int* t (i jej typem jest int*&), a stała jako int* const t (i jej typem jest int* const&). I proszę się od razu przyzwyczaić do kwestii referencji w tym wypadku, gdyż to const za gwiazdką dotyczy właśnie referencji obiektu, który się tu deklaruje. To samo również ma miejsce przy przekazywaniu argumentów do funkcji, również w ich zapowiedzi. Jeśli więc mamy tam np. tylko "int* const", to oznacza że w tym miejscu będzie przekazana wartość typu int*, a zmienna lokalna która ją dostanie będzie miała stałą referencję (EFEKT: const w tej zapowiedzi jest nieefektywne, a nawet w definicji funkcji może go nie być i wiele kompilatorów się do tego nie przypluje).
Rozważmy jednak sytuację, gdy na liście argumentów jest const int*, a my tam przekazujemy wartość typu int*. Ponieważ konwersja z int* do const int*, jak i z int& do const int& jest dozwolona, to w takim razie to oznacza, że można przekazywać wartość typu bez consta do funkcji, która wymaga tej z constem. Tak, czy nie? No ależ jak najbardziej. No ale co się stanie z obiektem, do którego ta wartość się odnosi? Nic. Co miałoby się z nim stać? Jest nadal tym samym obiektem. Tyle tylko że wewnątrz funkcji (tzn. poprzez referencje którą mu przez takie przekazanie do owego obiektu udostępniamy) nie można będzie dokonać w takim obiekcie zmian. Tylko dlatego oczywiście, że owa referencja na to nie pozwoli. Zatem jest to "lokalne" nadanie obiektowi praw "tylko do odczytu". Sprytne, nie?
Oczywiście wielu (np. Qrczak) na pewno zechce mi zarzucić, że standard mówi wyraźnie o istnieniu stałych obiektów (i mówi nawet o "kwalifikacji wariancyjnej typu", ang. "cv-qualification"). Owszem, to prawda. Jeśli tworzymy obiekt w taki sposób, że podczas deklaracji pierwotnie nadajemy mu od razu stałą referencję (wymaga się po prostu podania const w tej deklaracji), to już samo utworzenie obiektu pozwala kompilatorowi przyjąć pewne reguły. Owszem, jest to prawda, ale jest to prawda tylko dla obiektu podczas jego tworzenia. Gdy już ów obiekt mamy, to to, jak on jest wewnętrznie zorganizowany, czy istnieje być może jakąś optymalizacja z uwagi na ten sposób tworzenia, czy kompilator coś tam sobie dzięki temu uprościł, to już nas nie interesuje. Kiedy obiekt jest już utworzony, to po prostu mamy do niego stałą referencję. Ja powiedziałem, referencja jest tylko interfejsem, a jak ta referencja daje ten dostęp do obiektu i co ten "obiekt" ma w środku, to już szczegół implementacyjny (zwracam uwagę, że ponieważ referencja jest czymś czysto językowym, zatem nie tylko może mieć różną reprezentację w różnych kompilatorach, ale nawet w różnych miejscach programu referencja do tego samego typu może zostać przez kompilator zorganizowana inaczej!).
Czym jest zatem to, co standard nazywa "const object"? Językowo niczym szczególnym. Obiektem, jak każdy inny. Implementacyjnie jedynie mogą istnieć różnice (i najczęściej istnieją), bo właśnie po to język wprowadza ograniczenia, żeby kompilator mógł pewne rzeczy założyć z góry i wprowadzić owe różnice; dlatego właśnie standard zakłada istnienie czegoś takiego jak "const object", którego nie można zmieniać (a dokładnie, którego próba zmiany powoduje zachowanie niezdefiniowane). Znaczenie słowa 'const', gdy nie tworzy się referencji -- np. jest to typ zwracany funkcji -- istnieje tylko w jednym przypadku, który -- szczerze mówiąc -- bardzo mnie dziwi, że został dopuszczony do języka. Spójrzmy na deklarację takiej funkcji:
const Klocek GetKlocek( float, float );
Gdyby zamiast `Klocek' było `int', wtedy const nie zmieniałoby w najmniejszym stopniu znaczenia owej funkcji (a nawet wiele kompilatorów nie zwróciłoby uwagi na ewentualne zapomnienie o const). Jeśli jednak jest to `Klocek', który jest typem strukturalnym, to instancja takiego typu jest obiektem. Co oznacza, że niestety musi istnieć w pamięci. Ponieważ to, co GetKlocek zwraca to jest wartość, a nie referencja, więc teoretycznie skoro tego nie można interpretować jako Klocek& (a najwyżej const Klocek&), to const nie powinno mieć znaczenia. Niestety ma. Istnieje jeden przypadek, kiedy tak zwrócone `Klocek' można interpretować jako `Klocek&'. Będzie o tym przy właściwościach dodatkowych; na razie mówię o tym tylko dlatego, żeby nie było że uczę czegoś sprzecznego ze standardem.
Proszę jednak nie zwracać na to zbyt dużej uwagi i starać się w miarę możliwości nie korzystać z tego, że "obiekt" może być "stały lub zmienny". Stała lub zmienna może być referencja do takiego obiektu. Ponieważ można niejawnie konwertować referencję zmienną na stałą, to w takim razie jak się np. przez takie coś przekaże obiekt do funkcji, to funkcja za żadne skarby się nie dowie, że obiekt, do którego dostała referencję był pierwotnie utworzony jako zmienny (jak wspomniałem, owa kwestia jest tylko szczegółem implementacyjnym). W C można było jednak (w przypadku wskaźnika oczywiście) dokonać "obleśnego" rzutowania ze stałej na zmienną i tak człowiek się pozbywał consta (że program się najczęściej na tym wysypywał to inna sprawa). W C++ -- ponieważ obleśnego rzutowania używać nie wolno -- istnieje specjalny do takich rzutowań operator: const_cast. Pozwala on rzutować pomiędzy dwoma dowolnymi wskaźnikami lub referencjami, które różnią się tylko modyfikatorem wariancji. Niestety jest to nadal dokładnie takie samo EWIDENTNE naruszenie systemu typów. Poprawność takiej operacji zależy właśnie od tego, czy obiekt o tej stałej referencji był pierwotnie utworzony jako zmienny, a owa stała referencja to tylko stałość nadana lokalnie. Jednak jeśli funkcja dostała taki obiekt jako argument, to można jej przecież przekazać też obiekt pierwotnie utworzony jako stały. Wtedy zrzutowanie takiej referencji na zmienną i użycie jej do zapisu spowoduje zachowanie niezdefiniowane, o czym już wspominałem.
Ale właściwie po co się tyle nad tym rozwodzić? Operatora const_cast praktycznie też nigdy nie należy używać. Przecież po to chyba przekazuje się obiekt przez stałą referencję, żeby go wewnątrz NIE zmieniać. Obiekt ma być po prostu tylko do odczytu dla owej funkcji, nieważne czy "normalnie" jest on stały czy zmienny. Niektórym się to pewnie wydaje aż nazbyt oczywiste. Chciałoby się pewnie zadać pytanie, po co w ogóle istnieją nielegalne konwersje. No cóż, przede wszystkim męcząca kwestia zgodności, która jest jedną z bolączek C++, a także różne interfejsy funkcji, z którymi ma się do czynienia. Proponuję np. wyobrazić sobie problem, gdy obiekt taki jest potem kolejno przekazywany do innych pod-wywołań -- to TUTAJ najczęściej dochodzi do rzutowania. Jeśli więc ktoś próbuje to zmienić, to znaczy że coś jest źle rozplanowane. Niestety czasem nie da się tego uniknąć, bo nie na każdej funkcji interfejs użytkownik ma wpływ. Przykładowo funkcja traktuje obiekt tylko do odczytu lub do zapisu w zależności od wartości jednego z jej argumentów (ponieważ jednak "w niektórych przypadkach" coś zapisuje, więc żeby było uniwersalniej, żąda wskaźnika na zmienny obiekt). A my ją wywołujemy spod jakiejś innej funkcji, która ów obiekt dostała jako stały. Niestety system typów jest nieubłagalny. Bardzo często nie wiadomo, co z takim jajcem zrobić, a wśród funkcji systemowych windows takowych jest sporo. Tu właśnie należy użyć const_cast, jednakoż to jest i tak nadal ryzykowne.
...
skorupa96