C++ bez choletsterolu

Zaawansowane programowanie w C++

Wiadomości podstawowe

W tej części zostaną omówione właściwości C++ używane rzadziej, za to udostępniające dość użyteczne możliwości. Do niektórych z nich nawet wszystkie możliwości zastosowania nie zostały jeszcze wymyślone. Zanim jednak przejdę do takowych, podam jeszcze drobne uzupełnienia. Przede wszystkim zaś, dotychczas operowaliśmy obiektami tworzonymi jako zmienne lokalne; tu poznamy bardziej zaawansowane sposoby tworzenia obiektów, jak również dość istotnych reguł, o których należy podczas używania takich obiektów pamiętać.

Jedna z rzeczy, na jaką warto tutaj zwrócić uwagę, to że C++ swoje dość zaawansowane właściwości oparł na tym, co było w języku C. Jednak nie jest to nawet bazowane na teorii języka C, lecz raczej jest to udostępnienie prawie wszystkich możliwości C za pomocą tej samej składni i w ten sam sposób, lecz o zupełnie innych podstawach teoretycznych.

Zestawienie wiadomości o klasach pamięci; obiekty tymczasowe

Poznaliśmy już kilka sposobów tworzenia obiektów. Wiemy zatem, że obiekt może być:

Poza tymi dwiema język C++ udostępnia jeszcze klasę tymczasową i dynamiczną.

Klasa tymczasowa jest to dość niezwykła klasa. Jest ona zdecydowanie najmłodsza w C++. Jakiś czas temu w komitecie standaryzacyjnym X3J16 było wiele burzliwych dyskusji nt. tego, jaką trwałość należy zapewnić obiektom tymczasowym. Co to w ogóle jest?

Wiemy o tym, że obiekt tworzy się przez

<typ> <zmienna>( <argumenty> );

Jednak obiekt można utworzyć nie nadając mu żadnej nazwy, czyli:

<typ>( <argumenty> )

Co się wtedy dzieje? Otóż obiekt jest tworzony tylko na użytek bieżącej instrukcji. Do czego to zatem służy?

Spróbujmy sobie wyobrazić, że musimy zwrócić jakiś pośredni wynik. Przykładowo mamy takie wyrażenie:

 x = flog( add( a, b ) );

Funkcja add musi UTWORZYĆ NOWY obiekt z argumentów a i b. Gdzie go przechowamy? Kiedy ten obiekt musi być usunięty? Musi być ta wyprodukowana wartość gdzieś tymczasowo przechowana i natychmiast usunięta. Jeśli obiekt jest typu int, to jest to żaden problem, ale jeśli jest to macierz, to już nie jest to takie proste - obiekt należy naprawdę utworzyć i zniszczyć w odpowiednim momencie. I te "momenty" trzeba dokładnie określić.

No więc po tych wszystkich burzliwych dyskusjach, z których z trudem wymigano się od implementacji odśmiecacza, ustanowiono że obiekt tymczasowy istnieje do końca instrukcji, która go utworzyła. Jednak jest jeden wyjątek od tej reguły, o którym zaraz powiem.

Mimo wszystko, obiekty tymczasowe, zwłaszcza większych rozmiarów, są plagą w C++. Procedura np. zwracania obiektu przez funkcje zwykle bowiem przebiega w ten sposób, że obiekt jest kopiowany do obiektu tymczasowego, a potem znów kopiowany, żeby został użyty w instrukcji wywołującej funkcję. Pół biedy jeszcze jak obiekt jest przyjmowany przez stałą referencję. Jednak jeśli zrobilibyśmy tak:

x = add( a, b );

to funkcja wyprodukuje obiekt tymczasowy, który następnie zostanie skopiowany do obiektu x przez operator przypisania. Paskudnie. Dużo efektywniej wyjdzie to, jeśli zrobimy tak:

add( a, b, &x );

przekazując x przez wskaznik. Taka postać jest paskudna składniowo i mało czytelna. Ale z drugiej strony też nie ma się specjalnie czym przejmować, bo tutaj kompilatory zwykle całkiem dobrze optymalizują. Przykładowo gcc potrafi w powyższym przypadku wartości wewnątrz 'add' wpisać bezpośrednio do 'x' podanego tu do operatora przypisania.

Jednak jest jeszcze jedna możliwość, która jest właśnie tym wspomnianym wyjątkiem od zasady obsługi obiektów tymczasowych. Mianowicie można użyć zmiennej referencyjnej:

const complex& x = y + z;

Efekt będzie identyczny, jakby w powyższym zapisie nie było znaku &. Jednak tylko efekt zewnętrzny. Wewnętrznie bowiem oszczędzamy jedno kopiowanie. Ten obiekt tymczasowy, który zwróciło dodawanie, zostanie utrzymany przez zmienną referencyjną, która została nim zainicjalizowana. Zatem tak schwytany obiekt tymczasowy będzie miał trwanie równe tej zmiennej referencyjnej. Warto pamiętać o tej właściwości, gdyż jest ona bardzo użyteczna z uwagi na wydajność programu.

Tworzenie obiektów tymczasowych jest niestety dość wrażliwe na błędy, czesto nawet nie wykrywane przez kompilator. Na przykład:

const char* tt = (string(x) + " było źle!").c_str();

Tutaj będzie tak: utworzy się tymczasowy obiekt z x, który zostanie przekazany jako argument do operatora +. Ten zwróci obiekt tymczasowy. Na rzecz tego obiektu wywołujemy c_str(), żeby uzyskać normalny wskaźnik char*, który jest przypisywany do tt. Tu z kolei instrukcja się kończy i obiekt tymczasowy, którego element przypisaliśmy jest usuwany. O dalszych losach takiego wskaźnika przeczytasz niedługo.

Zunifikowane zarządzanie obiektami; obiekty dynamiczne; konstruktory i destruktory

Oto druga, nie poznana jeszcze klasa pamięci. Teraz poznamy takie obiekty, których czasem życia musimy zarządzać sami, bądź komuś to zlecić (ta możliwość wyboru jest cechą charakterystyczną C++; ani C ani Java nie dają takiego wyboru: w pierwszym musisz obiektem zarządzać ręcznie, w drugi zaś w ogóle nie masz wolnej ręki w zarządzaniu obiektem; Java o wszystko zadba za Ciebie :*).

Obiekty dynamiczne nie są takoż niczym szczególnym w C++; w C również używało się obiektów dynamicznych, jednak istnieje pewna różnica w zarządzaniu nimi. C++ uściślił bowiem pojęcie zarządzania obiektami (nie tylko dynamicznymi) dzieląc je na następujące etapy:

  1. Przydzielenie pamięci dla obiektu (allocation)
  2. Utworzenie obiektu (construction)
  3. Używanie obiektu (using)
  4. Zniszczenie obiektu (destruction)
  5. Odzyskanie pamięci zajmowanej przez obiekt (recycling?)

W przypadku zmiennych lokalnych, pamięć przydzielana jest z odpowiednio przeznaczonego na to obszaru (zazwyczaj ze stosu; przydział takiej pamięci jest najszybszy; w przypadku typów ścisłych można też przydzielić zmiennej rejestr procesora). Odzysk pamięci następuje zatem przed zrealizowaniem powrotu z funkcji. Co jednak oznaczają te punkty "utworzenie obiektu" i "zniszczenie obiektu"? Dodam dla ciekawostki, że C nie znał takich pojęć... Przypomnę jednak, co pisałem o inicjalizacji zmiennej. Np. deklarujemy sobie zmienną:

int a( 5 );

Taka konstrukcja jest to wywołanie KONSTRUKTORA typu. Czy zatem przy `int a;' się on nie wywołuje? Ależ wywołuje się, tylko że nic nie robi. Gdyby nie było konstruktora bezargumentowego dla typu int, to taka deklaracja byłaby błędna. Tak też w przedstawionej deklaracji używamy KONSTRUKTORA typu int, który przyjmuje argument typu int (dokładnie to const int&, ale nie zagłębiajmy się w szczegóły ;*). Konstruktor jest oczywiście funkcją, a w tym wypadku podejmowaną przez niego akcją jest zapisanie owej zmiennej podaną jako argument wartością.

Konstruktor możemy też wywoływać bezpośrednio i wtedy tworzymy obiekt tymczasowy, np.:

cout << int( 'C' );

Wypisze nam kod ASCII znaku 'C' (wspominałem już operator obleśnego rzutowania; konstruktory są często przez wielu ludzi mylone z operatorem obleśnego rzutowania, czy wręcz nazywane jego `alternatywną formą' - np. Microsoft Visual C++ określa go jako "function-style casting"). Tzn. w niektórych okolicznościach to jest rzutowanie, mianowicie kiedy wykonuje się to dla typów ścisłych; w takim wypadku argumentem konstruktora może być wartość dowolnego typu, z którego można na ten konstruowany rzutować (obleśnie!).

Co to jest zniszczenie obiektu teraz nie będę dokładnie objaśniał, bo nie da się tego już przedstawić bez omawiania wprost właściwości obiektowych C++, dlatego przy tej okazji dopiero zajmę się tym tematem. Jednak owe pojęcia były potrzebne, aby uściślić różnicę pomiędzy zarządzaniem obiektem w C i C++. Język C oczywiście zna pojęcia przydziału i zwalniania pamięci i realizuje się to odpowiednio funkcjami malloc i free:

Klocek* k = malloc( sizeof (Klocek) );
... // używaj *k
free( k );

I tyle. Niestety istnieją tutaj w C++ dwa problemy. Funkcja `malloc' zwraca typ `void*'. Jak wiemy, typ void* w C++ trzeba by na ten `Klocek*' przekonwertować. To raz. Dwa, że przydział pamięci nie powoduje jeszcze utworzenia obiektu. Zwraca on jedynie wskaźnik do kawałka pamięci o rozmiarze takim jak dany typ (zresztą przecież podaliśmy to jako argument), ale to jeszcze nie znaczy, że wskaźnik, do którego nastąpiło przypisanie, wskazuje na obiekt. Staje się on nim dopiero po odpowiednim jego wypełnieniu (czyli skonstruowaniu, czy - mówiąc inaczej - inicjalizacji).

Podobnie, zanim zwolnimy pamięć przydzieloną na obiekt, musimy uwolnić go z odpowiednich powiązań z innymi obiektami (jeśli takowe posiada), zwolnić pamięć, która np. została przydzielona na wskaźnik, jakim było jedno z pól tego obiektu i tak dalej... I kiedy to wszystko jest gotowe dopiero wtedy obiekt staje się z powrotem tylko wycinkiem pamięci, którą się następnie zwalnia. Tu też jest podobny problem, jak z funkcją `malloc' (typy).

Tak na marginesie zwracam uwagę, ze C++ jest bodaj jedynym "oryginalnym" językiem, w którym obiekt przestaje być obiektem w momencie WYWOŁANIA DESTRUKTORA, a nie ZWOLNIENIA PAMIĘCI; w pozostałych językach, zwłaszcza z odśmiecaczem, destruktory albo trzeba wywoływać ręcznie (zresztą destruktorem możemy nazwać funkcję tak dla kaprysu), albo mogą się one nie wywołać w ogóle, albo ich po prostu nie ma i za zniszczenie obiektu uważa się zwolnienie po nim pamięci. W niektórych językach z odśmiecaczem istnieje jeszcze coś takiego, jak finalizator (funkcja wywoływana przed samym odzyskaniem pamięci po obiekcie). Ale finalizator nie jest odpowiednikiem destruktora; jest to pojęcie z zupełnie innej dziedziny. Ponieważ obiekt podlegający odśmiecaniu zostanie tylko "być może" usunięty, więc i finalizator się "być może" wywoła. Destruktor natomiast z założenia jest taką funkcją, która zostanie wywołana w ściśle określonym momencie i na 100%.

W C++ zatem używa się specjalnie do tego przeznaczonych operatorów `new' i `delete'. Użycie operatora new powoduje przydzielenie pamięci i wywołanie konstruktora. Symetrycznie, operator delete wywołuje destruktor dla obiektu, a następnie zwalnia pamięć.

int* ii = new int( 2 );
... // używaj *ii
delete ii;

W C++ panuje nadal - tak jak w C - ręczna gospodarka pamięcią. Inne języki, jak np. Smalltalk i Java, posiadają klasę dynamiczną z automatyczną gospodarką pamięci (w Smalltalku zresztą innej klasy pamięci nie ma), zwana odśmiecaniem (ang. garbage collection). Oznacza to, że obiekt sam się usuwa, kiedy już nic z niego nie korzysta. Zarówno ręczna, jak i automatyczna gospodarka pamięci mają swoje konsekwencje, o których będzie w następnym punkcie.

Oczywiście tworzenie dynamicznych zmiennych typu int nie ma sensu, ale dla większych obiektów jest to bardzo użyteczne. Rzutowanie w przypadku tych operatorów nie jest konieczne; operaror new zwraca wskaźnik do typu, jaki mu się poda jako argument, natomiast operator delete przyjmuje dowolny wskaźnik. Dla tablic mamy również specjalne wersje tych operatorów: new[] i delete[]:

int* tab = new int[20];
...
delete [] tab;

Tu możemy jednak podać (już legalnie ;*) rozmiar tablicy przez zmienną, a więc nie znany w momencie kompilacji. Proszę bezwzględnie pamiętać o używaniu operatora delete[]! Oba te operatory przyjmują wskaźnik, ale operator delete nie musi wiedzieć, czy wskaźnik trzyma pojedynczy obiekt, czy tablicę (tzn. nie ma obowiązku tego sprawdzać!), przez co nie wywoła destruktorów dla wszystkich obiektów, a jedynie dla pierwszego. Operatory new i delete możemy również przeciążać i deklarować ich specjalne wersje, ale o tym w następnym rozdziale.

Ostatnia uwaga co do obiektów dynamicznych: proszę trzymać się wyznaczonej konwencji obsługi tych obiektów. Nie należy mieszać sposobów przydziału i zwalniania obiektów, tzn. obiekty przydzielone przez malloc należy zwalniać przez free, przydzielone przez new zwalniać przez delete, a przydzielone przez new[] zwalniać przez delete[]. Oczywiście, sposób przydzielania pamięci dla tablic typów ścisłych nie ma najmniejszego znaczenia. Niemniej po pierwsze ten sposób jest łatwiejszy i bezpieczniejszy w użyciu, a po drugie lepiej się w ogólnej kwestii przydzielania pamięci trzymać jednej konwencji (w przypadku delete[] chodzi o ilość destruktorów, natomiast jeśli chodzi o malloc – często operator new ma inną implementację, niż malloc, że nie wspomnę o możliwościach przeciążania tego operatora, no a poza tym najważniejsza jest tu kwestia konstruktorów i destruktorów).

Przypominam przy okazji, że uniksowa funkcja (nie istnieje w standardzie ANSI) `strdup' przydziela pamięć właśnie funkcją malloc. Gwoli ciekawostki zresztą w uniksowym C istnieją jeszcze różne inne dodatkowe funkcje GNU, które są dość użyteczne, jednak powodują przydział pamięci funkcją malloc, którą trzeba następnie zwolnić przez free. Nie znalazły się w standardzie z przyczyn oczywistych: nie można narzucać użytkownikowi sposobu przydzielania pamięci, że nie wspomnę o zasadzie spójności wywołań, która zostaje w takim wypadku naruszona. Poza tym są to w większości wrappery, ułatwiające posługiwanie się często łączonymi wywołaniami, które w C++ i tak można zorganizować w sposób o niebo wygodniejszy.

Przydział pamięci może się oczywiście nie powieść. W C (z braku innego wyboru) po prostu malloc zwraca wskaźnik pusty (zero) i trzeba było go sprawdzić po wykonaniu przydziału. Jednak odradzam zawracanie sobie głowy sprawdzaniem poprawności przydziału pamięci (zwłaszcza, że operator new w przypadku niepowodzenia nie zwraca zera; robi o wiele wiele gorsze rzeczy, o których przeczytasz w następnej części w rozdziale "Wyjątki"). Jest to może dobre dla początkujących (gangsterów), których uczy się, że "za każdym rogiem czai się błąd, żeby Cię zaatakować", jednak nawet początkujących nie widzę sensu uczyć czegoś, czego w późniejszej praktyce powinni się wystrzegać. Najważniejszym zresztą powodem jest to, że niepowodzenie przydziału pamięci to zazwyczaj błąd krytyczny, choć istnieją przypadki, kiedy program może zdecydować się "zaczekać", aż dostanie pamięć i przydałoby się wtedy, żeby new zwracało pusty wskaźnik - można to uzyskać przez napisanie:

Klocek* k = new(nothrow) Klocek;

Używanie tej właściwości wymaga wczytania nagłówka <new>.

Większość przypadków takiego błędu jednak zwyczajnie dyskwalifikuje program z dalszego wykonywania. Obsługę błędów przydziału pamięci odłóżmy zatem na później. Na razie proszę z łaski swojej eksperymentować na maszynach, które posiadają więcej, niż 1 MB pamięci :*)))))))))))))). Pamiętaj też, że nie zawsze system zdecyduje się na zgłoszenie niepowodzenia przydziału pamięci, jeśli nie wydłubie tyle pamięci, ile się żąda. Zatem ostrożnie eksperymentuj z obciążaniem komputera dużymi wymogami pamięci – Linux pozwala np. przydzielić 32MB pamięci dla tablicy na komputerze wyposażonym w 8MB ramu i 16MB partycji swap i program normalnie chodzi, dopóki nie próbuje się zapisać czegoś w ostatnim bajcie tej tablicy.

Zostawmy jednak temat przydzielania pamięci, bo obsługa obiektów w klasie dynamicznej jest o wiele bardziej problematyczna, niżby się zdawało. A źródło ma w kilku rzeczach, m.in. w takiej, a nie innej konwencji wskaźników, wróćmy więc może do nich.

Wiemy, że zmienna wskaźnikowa ma wartość osobliwą, jeśli nie zostanie zainicjalizowana. Jeśli przypiszemy jej wartość, którą weźmiemy "z powietrza", ale będzie to jakoś-tam przekonwertowana wartość liczbowa, to otrzymamy tylko wartość niewyłuskiwalną (najczęściej, bo można przyjąć, że jakieś tam prawdopodobieństwo trafienia z tym adresem na początek obiektu istnieje :*), ale właściwie można ją uważać za osobliwą. Wartość wyłuskiwalną uzyskamy, jeśli przypiszemy do tej zmiennej pobrany (operatorem &) wskaźnik do obiektu. Będzie taka też, gdy przypisze się jej wskaźnik do obiektu utworzonego przez new. Co się jednak stanie, gdy taki obiekt usuniemy?

Nie ma się co oszukiwać - po tej operacji zmienna wskaźnikowa, choć – ZAUWAŻ – nie zmieni wartości, będzie miała wartość osobliwą. Źródło wartości jest bowiem dokładnie tak samo nieznane, jakby ta zmienna w ogóle nie była inicjalizowana. Wartość osobliwą oczywiście uważa się za niewyłuskiwalną, czyli odwołanie się do wyłuskanej referencji jest zachowaniem niezdefiniowanym.

Oj... ileż ja się nasłuchałem "retorycznych" pytań, dlaczego operator delete nie zeruje wskaźnika, czy też "prostych" rozwiązań, że zamiast delete należy stosować zap():

#define zap( x ) delete x; x = 0

Jeśli, drogi czytelniku (czytelniczko), nie zauważyłeś bezdennej głupoty w tym rozwiązaniu (pomijam już sposób zdefiniowania go), to mała podpowiedź: co się stanie, jeśli wskaźnik na utworzony obiekt dynamiczny przypiszemy dwóm zmiennym wskaźnikowym?

Wśród wskaźników istnieje jeszcze taka wartość wskaźnika, która jest wartością niewyłuskiwalną, ale – ZAUWAŻ – jest wartością WŁAŚCIWĄ i nazywa się ona wskaźnikiem pustym. Aby taką wartość uzyskać, należy zmienną wskaźnikową zapisać zerem (zero adoptuje się do tego wskaźnika). Jego wartość tak naprawdę nie musi być ciągiem zerowych bitów. Dlaczego akurat `0', a nie jakaś wartość "specjalna", jak np. w javie `null'? Dlatego, że musi tu zostać podana taka wartość, która się zaadoptuje do podanego wskaźnika; nie może być to wartość określonego typu. Co prawda w C zostało wprowadzone słowo NULL (definiowane jako 0, a później jako (void*)0), jednak jego definicja z założenia nie pasuje do C++. Zatem w C++ do tego celu należy używać po prostu 0, przynajmniej póki co, bo jest możliwe w C++ zdefiniowanie takiego null-a, który będzie w C++ spełniał dobrze swoje zadanie.

Kwestia NULL

Obiecałem trochę się porozwodzić nad genezą i sensem stałej NULL z języka C, a także sensem jej istnienia w C++. Temat ten przeznaczyłem właściwie głównie dla osób znających już jakiś czas język C (oraz wszystkich tych, którzy uczyli się C++ starymi metodami), jednak przydatne mogą się dla wszystkich okazać informacje o konsekwencjach ręcznej gospodarki pamięcią, opisane w następnym punkcie. Skąd się zatem wzięło NULL i dlaczego nie ma go w C++ (tzn. jest, ale tylko przez wzgląd na wsteczną kompatybilność; jest wyłącznie częścią C, której w C++ nie wycofano ze względu na "znikomą szkodliwość społeczną")?

Nie ma co ukrywać oczywiście, że przodkiem C jest Pascal. Jak w każdym języku, który posiada wskaźniki, musiano dodać taką "wartość" wskaźnika, która nie wskazuje na żaden obiekt, zwana "wskaźnikiem pustym" (w Pascalu: nil; zresztą Pascal posiada typ uniwersalno-wskaźnikowy - czyli nieskonkretyzowany typ wskaźnikowy - o nazwie pointer). Jednak ponieważ język C z założenia miał być językiem do programowania systemowego, a nie do zabawy, stwierdzono że pusty wskaźnik będzie się oznaczać po prostu zerem i na tym się sprawa zakończyła.

Z tego, co zauważyłem, stałą NULL wprowadzono do języka C głównie z tego powodu, że w wielu implementacjach rozmiar wartości całkowitej był mniejszy, niż rozmiar wartości wskaźnikowej - wtedy zamiast 0 należało stosować 0L (tradycyjne C nie miały jeszcze możliwości konwertowania typów, ani kontekstowej interpretacji literałów). Twierdzenie, jakoby NULL miało cokolwiek wspólnego z "jawnym deklarowaniem w programie, o co naprawdę chodzi" (używa się tego często, żeby uzasadnić konieczność jawnego rzutowania), jest wierutną bzdurą. Głównie dlatego, że to całe "deklarowanie o co chodzi" to jest jak łysy grzywką o kant kuli - nigdy się jeszcze nie zdarzyło, żeby takie jawne rzutowanie przyczyniało się do polepszenia jakości oprogramowania, za to potrafi stać się źródłem dodatkowych błędów (gdyby nie rzutowano, być może kompilator by wykrył jakąś nieścisłość, być może nie zamierzoną przez programistę). NULL było więc czystym ideologizmem, zwłaszcza później, gdy C już wspierał automatyczne konwersje i 0 mogło być konwertowane na pusty wskaźnik, nawet gdyby jego wewnętrzna reprezentacja nie składała się z ciągu zerowych bitów.

Po wprowadzeniu do C statycznej kontroli typów (a raczej tylko jej drobnej namiastki, żeby programiści nie musieli za często rzutować :*) ustanowiono typ void* uniwersalno-wskaźnikowym (wcześniej tą rolę pełnił char*), którego wartość mogła się dowolnie niejawnie konwertować z innym wskaźnikiem (drobna uwaga - konwersje między innymi wskaźnikami, mimo że dopuszczone przez ANSI C, są zwykle obarczane ostrzeżeniami przez kompilator). Wszelcy ideologiczni wykładowcy języka C zaczęli bardzo restrykcyjnie wymagać używania tej "specjalnej stałej NULL", wskutek czego w programach w C jest ona często używana. Niewielu pozostało takich, którzy nie dali się nabrać na ten idiotyzm. Zwłaszcza że ANSI C nie tylko nie zabrania używać 0 zamiast NULL (0 nie musi być rzutowane na void*, jak NULL to ma zdefiniowane na wielu kompilatorach), ale też każdą wartość wskaźnika pozwala niejawnie konwertować na typ int (w C++ niejawnie skonwertuje się najwyżej na bool, natomiast w C konwersja pomiędzy dowolnym typem całkowitym – w tym char – a typem wskaźnikowym odbywa się niejawnie bez żadnych problemów). Jednak to, że void* jest typem uniwersalno wskaźnikowym, pozwoliło zdefiniować NULL jako (void*)0 (w celu umożliwienia sprawdzania poprawności jego użycia) i przypisywać rezultat malloc bez rzutowania. Ta nieszczęśliwa właściwość spowodowała, że NULL w C swoje zadanie spełnia lepiej, niż w C++.

Dlaczego nieszczęśliwa? Dlatego, że stanowi poważną wyrwę w systemie typów języka C. Dla mnie rzeczą niedopuszczalną jest, aby przypisać bez rzutowania wartość typu void* do zmiennej typu Klocek*, czyli w sposób niejawny złamać system typów (bo taka operacja spowoduje zwyczajnie zmianę interpretacji kawałka pamięci). Nie mówię, że takiej operacji nie powinno się wykonywać, ale w C++ wykonuje ją tylko i wyłącznie statyczne rzutowanie. Przyglądając się zresztą jakimkolwiek źródłom w C można jasno zauważyć, że nikt nigdzie nie dokonuje konwersji pomiędzy wskaźnikami bez rzutowania (w GTK wprowadzono nawet specjalne makra, żeby rzutować obiekt z typu "bazowego" na typ "pochodny"). Zabraniają tego takoż wszelkie standardy kodowania. No i oczywiście także kompilatory się czepiają, tylko że nie w sytuacji konwersji z udziałem void* (bo jest to często używane w związku z malloc).

Twórcy C++ nie mogli sobie z założenia pozwolić na żadne "dziury w płocie", uważając je za uzasadnioną niezgodność. Nie ma on zatem ani czegoś takiego, jak typ uniwersalno-wskaźnikowy, ani specjalna stała NULL (a przynajmniej nie ma w niej nic specjalnego). W tym języku przyjęto po prostu, że literał `0' (i - niestety - również dowolne bezstanowe wyrażenie o wyniku 0, np. 2-2!) może być niejawnie konwertowany na dowolny typ wskaźnikowy, tworząc wartość zwaną "pustym wskaźnikiem", ale - UWAGA! - typu tego wskaźnika. Bo - z uwagi na statyczny system typów języka C++ - tylko tak można to pojęcie w nim prawidłowo określić. Dlatego w C++ NULL definiuje się jako 0, gdyż jego wycofanie byłoby nieuzasadnioną niezgodnością z C.

Najlepszy dowcip jednak polega na tym, że NULL jako "wartość określonego typu" bardziej pasuje do języków takich, jak Smalltalk. W C NULL się przyjęło, ale każdy doświadczony programista wie, że jego używanie to czysta hipokryzja. Smalltalk - zacznijmy od tego - w ogóle nie posiada typizacji, zatem każde odniesienie do obiektu to wartość tego samego typu. Dlatego właśnie w Smalltalku ustanowiono jeden taki obiekt, którego nie ma (coś takiego, jak urządzenie, którego nie ma np. /dev/null). Czyli ten obiekt tak naprawdę jest, ale jego odniesienie jest taką "wartością szczególną" wśród wartości odniesień i to wszystko (mamy taką wartość zawsze dostępną, możemy sobie jakąś wartość odniesienia z nią porównywać, możemy ją zwracać jako oznaczenie błędu, podawać jako nie-obiekt itd.). Zasadniczą zatem różnicą pomiędzy NULL z C i nil ze Smalltalka jest to, że Smalltalkowe nil odnosi się do fizycznie istniejącego obiektu, a NULL nie.

Zresztą cały bajer właśnie w tym, iż mało kto zdaje sobie sprawę z konsekwencji tego, że NULL jest niewyłuskiwalne. Równie dobrze jak 0 mogłoby to być 2. To, że oznacza się to jako 0 i zawsze oznacza wartość niewyłuskiwalną, jest tylko kwestią w pewnym sensie małej "umowy" między bibliotekami a użytkownikiem (jest to więc - zauważ - kwestia bibliotek, a NIE języka!), istnieje tylko "językowe wspomaganie" do tej kwestii. Jakiż to problem, żeby - również w C - np. dla typu `struct Klocek' utworzyć globalną zmienną typu `struct Klocek*' o nazwie null_klocek, która wskazywałaby na jakiś faktycznie istniejący obiekt, ale który by w przypadku odwołania do niego notował takie zdarzenie jako błąd? Twórca biblioteki nie jest zobowiązany do stosowania NULL. NULL jest tylko o tyle dobre, że jest już zdefiniowane. I jest to po prostu tylko wartość, jaka nigdy nie zostanie zwrócona jako właściwy adres i może być zwracana jako wynik błędu i podawana jako nie-obiekt (bardzo często używane w funkcjach uniksowych). Niemniej używanie NULL nie różni się praktycznie niczym od używania zera. Zupełnie sprzecza się to z koncepcją null-a (czy nil-a) z innych języków obiektowych. Różnica między nil a NULL jest mniej więcej taka, jakby nil było plikiem /dev/null, a NULL plikiem linku symbolicznego do jakiegoś pliku, który nie istnieje (tzw. sierota) i o którym wiemy, że nigdy nie wskazuje na istniejący plik. W Smalltalku, czy Objective-C można wysłać dowolny komunikat do nil-a, a nil się wcale nie obrazi. W C natomiast (i tak samo w C++) na 100% wiemy, że ta właśnie wartość nigdy nie wskazuje obiektu i na 100% próba odwołania się do tego wskaźnika (na systemach wyposażonych w sprzętową ochronę pamięci) zakończy się wysypaniem programu.

Ponieważ C++ jest językiem silnie typizowanym, ze wszystkimi tego konsekwencjami, toteż tam wskaźnik/referencję musimy utworzyć do konkretnego typu, a za pośrednictwem takiego wskaźnika można wywołać na rzecz obiektu tylko takie metody, które dla tego typu zdefiniowano. Właściwość ta przesądziła o tym, że NULL-a jako wartość konkretnego typu w C++ zrobić się nie da. Jedyne sensowne NULL dla typu np. Klocek to jest taka stała:

const Klocek* KNull = 0;

Niemniej znam lepszą i uniwersalną definicję null, która spełnia to, co NULL w C, a przy paru dodatkowych prostych definicjach częściowo i to, co w Smalltalku. Pokażę ją przy wzorcach. Niemniej nic nie stoi na przeszkodzie, żeby używać 0, zwłaszcza że NULL ma dokładnie taką w C++ definicję (zatem używając 0 unikasz hipokryzji i oszukiwania się). Przynajmniej wg standardu, bo niektóre kompilatory definiują ją jako specjalne słowo kluczowe (np. __null w g++). Nie zmienia to jednak faktu, że programy zachowują się tak samo, a takie coś jak np. rozstrzyganie przeciążenia dla podanego argumentu NULL jest zawsze na korzyść typu int, a w takich kompilatorach tylko owocują ostrzeżeniami. Przedstawiona dalej wersja null oczywiście rozstrzyga tą niejednoznaczność na korzyść typu wskaźnikowego.

Konsekwencje ręcznej gospodarki pamięcią

Jak wspomniałem, dla zerowej wartości wskaźnika przyjmuje się, że jest to zawsze wartość niewyłuskiwalna, a odwołanie się do niej zawsze zaowocuje wysypaniem programu. Żadnego z tych założeń nie można jednak przyjąć dla wartości osobliwych. Odwołanie się bowiem do nich może mieć jedną z trzech konsekwencji:

W pierwszym i trzecim przypadku mamy do czynienia z wartością niewyłuskiwalną. Jednak zerowa wartość wskaźnika powoduje tylko to pierwsze. Tu widać właśnie tą dodatkową właściwość zerowego wskaźnika. Druga możliwość tak naprawdę nigdy nie występuje, bo nawet jeśli wskaźnik wskazuje na obiekt "swojego" typu, to procedura, która z niego korzysta, "ma na myśli" zupełnie inny obiekt – jest to zatem wartość niewłaściwa.

Wielu twierdzi, że odwołanie się do osobliwej wartości wskaźnika powoduje najczęściej wysypanie się programu. Niestety, to jest rzecz najmniej groźna dla programów – tutaj błąd jest wręcz banalnie prosty do znalezienia; na uniksach wystarczy debuggerem odtworzyć środowisko i stos programu i miejsce wyłożenia się mamy jak na dłoni. O wiele groźniejsze (osobiście twierdzę zatem, że – zgodnie z prawem Murphy'ego – występujące znacznie częściej) jest przekłamanie w danych obiektu. Jeśli bowiem pamięć, na którą wskazuje wskaźnik, została usunięta i ponownie przydzielona jakiemuś obiektowi, to próba zapisania wartości przez osobliwy wskaźnik na domniemane pole obiektu (wskaźnik+delta) może w zupełnie niekontrolowany sposób dokonać modyfikacji wnętrza obiektu, tworząc z kolei nową wartość osobliwą. A programista szukający błędu w takim programie po zauważeniu przekłamania najczęściej głupieje. Znalezienie takiego błędu to naprawdę ciężki kawałek chleba. Dlatego właśnie ostrzegam o tym od razu.

Nie ma się jednak co przerażać. Sprawa jest w miarę prosta (mówię "w miarę", bo jest to proste do wyjaśnienia, choć bardzo ciężkie do wymuszenia w praktyce!). Trzeba do minimum ograniczać liczbę wskaźników, które wskazują na jeden obiekt o ograniczonym trwaniu. Zauważ bowiem, że im więcej wskaźników wskazuje na obiekt, tym więcej wskaźników stanie się osobliwymi w razie usunięcia obiektu, zatem więcej trzeba pilnować (a rozpasanie się i mnożenie w wyniku tego błędów owocuje zwykle w przypadku niedoświadczonych programistów w C wstawianie instrukcji sprawdzania poprawności wszędzie gdzie wlezie, co oczywiście i tak nigdy nie pomaga).

Oczywiście proszę nie zrozumieć mnie źle - nie jestem bynajmniej przeciwnikiem sprawdzania poprawności wskaźników. Należy jednak pamiętać, że sprawdzania tylko wtedy mają sens, jeśli często może wystąpić sprawdzana sytuacja, bądź podejrzewamy jej wystąpienie (wtedy tylko robocze). Pozostawianie sprawdzania w kodzie również ma sens tylko na początku funkcji lub bezpośrednio po jakichś wywołaniach lub w kolejnych iteracjach - ma to sprawdzić, czy funkcja została wywołana zgodnie z zaplanowanym protokołem, czy wywołana funkcja zwróciła to, co zwrócić powinna (np. jeśli funkcja ma posortować zbiornik, to sprawdzić, czy on jest rzeczywiście posortowany) lub też czy poprzednia iteracja utworzyła to, co powinna. Natomiast ciągłe i nagminne sprawdzanie pewnych warunków prowadzi zarówno do nadmiernego obciążenia programu, jak i skrzywienia psychicznego, że nie wspomnę o tym, że jest to też przyznanie się do braku panowania nad projektem. Projekt najlepiej jest od razu rozplanować tak, żeby zrobienie czegoś źle było fizycznie niemożliwe (jest to trudne, ale nie jest niemożliwe, ani jakoś szczególnie pracochłonne).

Jeśli dopilnowanie symetrii operowania pamięcią dynamiczną jest trudne, masz kilka wyjść z tej sytuacji:

Języki posiadające odśmiecacz jednocześnie nie posiadają czegoś takiego, jak pojęcie usunięcia obiektu (w przeciwieństwie do odśmiecacza Boehma). Języki te są więc zdolne zapewnić, że nigdy żaden wskaźnik nie będzie miał wartości osobliwej. Jednak ma to również swoje konsekwencje, spowodowane tym, że obiekowi nie można kazać "natychmiast zjeżdżać z systemu". Można tylko odniesieniu kazać "nie wskazywać na niego", a obiekt zostanie usunięty jeśli nakaże się to wszystkim wskaźnikom. Być może niektórzy twierdzą, że nie ma nigdy takiej sytuacji, żeby trzeba było natychmiast usunąć obiekt, ale ja osobiście nie jestem zwolennikiem "jedynie słusznych" sposobów projektowania, zwłaszcza gdy opierają się na "wspieraniu lenistwa i bałaganiarstwa" programistów. Zauważ zresztą, że mnożenie ilości wskaźników w przypadku ręcznej gospodarki pamięci oznacza mnożenie wartości potencjalnie osobliwych, ale w przypadku automatycznej - mnożenie klientów obiektu, których trzeba będzie "upominać".