C++ bez cholesterolu

Zaawansowane typy danych

Obiekty, wartości i referencje

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ść (czyli jest obiektem). 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ść, tzn. umożliwia odczyt i zapis tej wartości. Referencja jest oczywiście w C++ pojęciem czysto językowym.

Nie przejmuj się, jeśli tego nie rozumiesz, bo faktycznie nie wydaje się to logiczne. Powiem więcej - wprowadzenie referencji w C++ (co jest nowością w stosunku do języka C) przyniosło w tym wszystkim drobny powiew logiki. Przykładowo w języku Tcl wygląda to trochę bardziej logicznie, mianowicie jeśli mamy tylko nazwę zmiennej, to jest to tylko nazwa (i to jest właśnie to coś, co określa się jako referencja). Żeby zaś uzyskać wartość tej zmiennej, należy jej nazwę poprzedzić symbolem '$'. Ale jeśli chcemy nadać zmiennej wartość musimy się posłużyć właśnie referencją, czyli w przypadku Tcl-a, jej nazwą (w Tcl-u będzie to "set zmienna wartość"). Czyli takie np. a=b w C++ będzie w Tcl-u "set a $b". Widać tutaj wyraźnie, gdzie potrzebna jest tylko wartość, a gdzie cała zmienna. Natomiast w C++ niestety w jednej składni mieszają się symboliczne wartości (liczby, napisy w cudzysłowiu, wartości wyliczeniowe, stałe) i zmienne, a w zależności od kontekstu, czyli od tego, jakiego typu od elementu wyrażenia oczekujemy na danym miejscu (np. czy int, czy int&), może być to odczyt wartości zmiennej, a może być uzyskanie właśnie samej zmiennej w celu np. zapisania w niej wartości. Nie chcę jednak sugerować, który sposób jest lepszy, bo w Tcl-u również wielu ludziom przeszkadza konieczność wstawiania znaku $ przed nazwą zmiennej, a nawet czasem się o tym zapomina, powodując trudne do wykrycia błędy. Zresztą taki sposób używania zmiennych jak w C++ istnieje chyba we wszystkich dzisiejszych językach, z wyjątkiem Tcl-a i Fortha.

Zatem 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 (gdzie typ decyduje o sposobie przeprowadzania operacji), a jego wewnętrzną reprezentacją. Wewnętrzna reprezentacja służy do przechowywania aktualnej wartości obiektu; jest to najczęściej spójny kawałek pamięci, który przechowuje jakąś zawartość, mającą reprezentować aktualną wartość obiektu. Mówię "najczęściej", bo na 100% to jest tak tylko w przypadku 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 typów bardziej skomplikowanych niekoniecznie tak musi być – zaznaczam tylko, żeby nie było nieporozumień.

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 POD w C++ odbywa się właśnie przez kopiowanie reprezentacji.

Wskaźniki

No dobrze, referencja referencją, ale jak się można domyślać, każdy obiekt, jako że ma swoją tożsamość, może zostać w jednoznaczny sposób zidentyfikowany. Referencja sama z siebie jeszcze do tego nie wystarczy. Referencja jest jedynie "typologicznym", że się tak wyrażę, interfejsem obiektu, ale jeszcze nie pozwala na stwierdzenie tożsamości.

Dlatego z referencjami nieodłącznie związane są wskaźniki. Referencja nie może być pojmowana w ogóle jako wartość; jest to jedynie "stwierdzenie faktu", że to coś, do czego się odnosi, jest obiektem. 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. Odnosząc się znów do języka Tcl - w Tclu odpowiednikiem wskaźników do zmiennych są po prostu napisy oznaczające nazwę zmiennej.

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 wskaźnika oznaczamy gwiazdką; 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 = &amp;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 :).

Po co nam referencje ?

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 zawartość wewnętrznej reprezentacji takich np. dwóch zmiennych zawierających liczby "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 (stosuje się tam w zamian określenie "l-wartość", zachowane nawet w standardzie C++), więc nie mówi się o ich istnieniu również w procesie nauczania języka.

Wariancja referencji

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ś hakerski 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 musi przekazywać przez wskaźnik i może zrobić tak, jakby tam był "int" (oczywiście zaznaczam, że podobne sztuczki kompilator może robić w bardzo ograniczonym zakresie; funkcje z różnych bibliotek muszą się jakoś umieć między sobą dogadywać). 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ę.

Owszem, pewnie ktoś zechce sprawdzić, czy mówię prawdę. Owszem, wskaźnik do stałej, przekazanej do funkcji przez referencję, będzie taki sam, jak ten wewnątrz tej funkcji (zatem przekazanie nastąpiło faktycznie przez wskaźnik). No bo tak ma być, owszem. Ale tak się stało tylko dlatego, że pobraliśmy adres. Jeśli się go nie pobierze, to wtedy nie wiadomo, co kompilator chciałby z nim zrobić. I owszem, w większości przypadków będzie prawdą, że referencja jest implementowana jako wskaźnik (przy przekazywaniu argumentów, a także jako pole w strukturze). Ale istnieją przypadki, gdzie kompilator tego zrobić nie musi i dlatego nie należy zakładać, że tak będzie zawsze (żeby się przypadkiem nie naciąć). Przykładowo, dla funkcji o wiązaniu zewnętrznym musi to zrobić tak, żeby inna jednostka kompilacji przyjęła to poprawnie. Ale jeśli funkcja jest "static" albo "inline", to kompilatora nie obowiązuje już żaden protokół przy przekazywaniu argumentów.

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 x;
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:

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 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. Jest takoż całkiem, mimo wszystko, poprawnym. Radzę jednak ściśle trzymać się nakazu wyłuskiwania wskaźnika, gdyż to uproszczenie czyni zapis niejednoznacznym. Ktoś np. mógłby próbować szukać funkcji o podanej nazwie i się zdrowo zdziwić (bo takiej nie znajdzie - przyp.AP.).

Zauważ, że wskaźnik do funkcji jest wskaźnikiem do funkcji o określonych typach argumentów i wartości zwracanej. Nie ma czegoś takiego, jak "uniwersalny" wskaźnik do funkcji. Dlatego też, do bardziej skomplikowanych funkcji, dobrze jest stosować typy pośrednie określane przez typedef:

typedef int func( int );
func* f;

która to deklaracja jest identyczna z tą poprzednią.

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).

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ć.

W języku C początkowo za wskaźnik na "cokolwiek" służył typ char*. Po co więc w języku C jest typ void*, nikt nie wie. Język C ma zresztą mnóstwo elementów w swojej składni, które zostały przyjęte "dla lepszej widoczności i czytelności", ale praktyczny pożytek z nich jest żaden. Oczywiście, void* to taki typ wskaźnikowy, którego wartości nie można wyłuskiwać. Tak jest w teorii. W praktyce np. gcc (a spodziewam się, że i w związku z tym kilka innych kompilatorów) bez odpowiednich opcji traktuje ten typ jak char*; pozwala go wyłuskiwać i pozwala wykonywać na nim arytmetykę (choć oczywiście jest to jak najbardziej wyłącznie rozszerzenie). Dodatkowo, zgodnie z ANSI C, niejawne konwersje między różnymi typami wskaźnikowymi i całkowitymi nie są błędem, zatem void* niczego tu i tak nie zmienia. Kompilatory wykorzystały to o tyle, że nie stosują ostrzeżeń do takich konwersji, jeśli uczestniczy w nich void* (w tym również konwersji z void* do wskaźnika na obiekt!). Ta ostatnia konwersja najczęściej odbywa się w przypadku malloc(), dlatego kompilator nie ostrzega. 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ę.

Wariancje i przeciwwariancje: const i volatile

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&. Natomiast 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 znaczy nie naruszając systemu typów – 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:

#include <iostream>
using namespace std;

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 róż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 [osoba z grup usenetowych udzielająca się w tematach C++ - przyp.AP.]) 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 jakaś 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.

Właściwości referencji z uwagi na wariancję posiadają jeszcze jeden aspekt – kto konkretnie jest w stanie kontrolować stan obiektu? Gdy ma się jednowątkowy program, to nic się nie dzieje, ale np. w programach wielowątkowych, czy też w jakichś bardzo niskopoziomowych, gdzie do WEWNĘTRZNEJ REPREZENTACJI może dobierać się jeszcze coś poza naszym programem (np. ktoś grzebie śrubokrętem :), wtedy można mieć kłopoty przez optymalizacje kompilatora. Wyobraźmy sobie np., że mamy zmienną typu int, zapisujemy to jakąś wartością, a kilka instrukcji dalej następuje jej odczyt. Kompilator "widzi", że pomiędzy tymi dwiema instrukcjami nikt nie modyfikował tej zmiennej, więc np. zostawi sobie tą wartość w rejestrze i w odpowiednim miejscu ją zwróci. Wolno mu tak, ale tylko w sytuacji że zakłada wyłączność programu do danego obiektu, zwłaszcza że oszczędzi sporo taktów procesora na tym, że nie będzie dokonywał odczytu wartości danej zmiennej z pamięci. Jeśli jednak takiej wyłączności nie ma, to program zacznie działać w sposób niezgodny z oczekiwaniem. Lub inaczej: może się zdarzyć, że dany wskaźnik naprawdę oznacza nam rzeczywisty adres w pamięci i jest to adres nie pamięci, ale rejestrów sprzętowych, które przesyłają gdzieś dalej dane, którymi zostaną zapisane. Możemy wiec w programie napisać ciąg instrukcji np.

*x = 10;
*x = 23;
*x = 0;

... w takim przypadku gdyby x było zadeklarowane jako "int*", to kompilator stwierdziłby, że programista zgłupiał i próbuje robić kilka instrukcji, z których tylko ostatnia ma jakiś efekt. Wiec resztę wychrzani (o czym oczywiście ostrzeże ;).

Do czegoś takiego istnieje inny modyfikator wariancji, zwany `volatile'. Jeśli referencja ma taki modyfikator, to próba odczytu lub zapisu danej zmiennej spowoduje ZAWSZE dokonanie fizycznego odczytu lub zapisu. Modyfikator volatile może również współistnieć z const; w takim przypadku odczyt wartości z obiektu spowoduje również odczyt z pamięci i wartość nigdy nie zostanie "zakeszowana". Aby zdjąć modyfikator volatile należy również użyć const_cast, natomiast założyć można go bez rzutowania (podobnie jak const). W powyższym przykładzie jeśli x będzie zadeklarowane jako "volatile int*", to wtedy *x da w wyniku "volatile int&". Mając taką referencję, kompilator nie będzie się już wymądrzał i nie będzie też niczego optymalizował.

Ostrzegam też od razu, że język C++ na nic chyba nie jest tak wrażliwy, jak na przeciwwariancje. Nieprawidłowo zdefiniowana kwestia wariancji może nawet najbardziej ambitny projekt rozłożyć na łopatki. Wiele funkcji bibliotecznych nadaje obiektom atrybut const, tak tylko dla zapewnienia ochrony. Jeśli więc nie zapewnimy mechanizmów do uzyskiwania odpowiednich stałych typów i sposobów posługiwania się nimi (będzie to opisane w drugiej części), to kompilator stanie się dla nas koszmarem. Pamiętajmy zatem, że jeśli zakładamy że gdzieś posługujemy się wartością obiektu, to tej reguły musimy się trzymać już do końca, nawet jeśli ta reguła została narzucona przez funkcję lub typ biblioteczny. Starajmy się też ograniczać w miarę możliwości zmiany w miejscu (są mało logiczne).

Typy niekompletne

Tu nie ma nic skomplikowanego. W C++ istnieje coś takiego, jak "typ niekompletny". Można go zdefiniować np. w ten sposób:

  struct Typ;

I tyle. Powyższa deklaracja jest tzw. zapowiedzią typu i tworzy ów typ niekompletny. To oznacza, że istnieje nazwa tego typu, jest ona znana i można się nią posługiwać. Tyle że ponieważ nie jest to nic konkretnego, więc i nie jest to pełnoprawny typ. Dlatego właśnie jest to typ niekompletny.

Do czego można to wykorzystać? A dokładniej, w jaki sposób można się tym posługiwać? Otóż można tworzyć inne typy oparte na typie niekompletnym. Oczywiście nie wszystkie. Należą do nich przede wszystkim wskaźniki i referencje. Można tworzyć również tablice (oczywiście tylko jako extern bez podania rozmiaru). Można też używać ich w zapowiedziach funkcji.

W ogólności, można z typem niekompletnym zrobić wszystko, jeśli tylko nie wymaga to znajomości budowy takiego typu. Nie można więc:

W przypadku tworzenia obiektów chodzi o wszelkie przejawy tworzenia obiektów, w tym również obiektów tymczasowych. To oznacza, że jeśli jakaś funkcja zwraca wartość takiego typu, to oczywiście zapowiedź jej możemy dostarczyć, ale wywołać już jej nie możemy. Pewnie ktoś spyta, po co ją zapowiadać. A nie zapominajmy, że może być ta funkcja w grupie innych, wczytywanych z pliku nagłówkowego. Co do referencji zaś, to owszem, można ją uzyskiwać (obowiązują tu te same reguły, co w przypadku wskaźnika), ale nie możemy się do niej odwołać. Przykładowym odwołaniem mogłoby być np. przekazanie tej referencji gdzieś jako obiekt przez wartość. Musiałoby wtedy nastąpić kopiowanie, a więc odwołanie do konstruktora kopiującego obiektu, czyli de facto jego składowej.

Co do "arytmetykowania wskaźników", to chodzi tutaj o traktowanie wskaźnika jako iteratora do tablicy, o czym przeczytasz w następnym rozdziale. Ponieważ jestem zagorzałym wrogiem określenia "arytmetyka wskaźników", toteż nie znajdziesz w tych materiałach innego podejścia do tego zagadnienia, niż właśnie takie. Niemożliwość używania typów niekomplenych w tym przypadku jest związana wciąż z tym samym, co reszta: operacje takie polegają na rozmiarze obiektu tego typu, a w przypadku typów niekomplenych rozmiar ten jest przecie nieznany.

Używanie typów niekompletnych jest niekiedy konieczne, zwłaszcza jeśli tworzymy struktury rekurencyjne. Podobnie zresztą jak zapowiadanie funkcji, jeśli są to dwie funkcje, które się wzajemnie do siebie odwołują.

Jednym z typów niekompletnych, który nigdy nie posiada "wersji kompletnej", jest typ void. Przynajmniej wedle standardu, void jest uważany za typ niekompletny. Aczkolwiek jest od tego parę wyjątków: nie można tworzyć referencji do void. Poza tym, void posiada wszelkie cechy typu niekompletnego.