2.6 Deklaratory
"Z całej składni języka C najbardziej nie lubię składni deklaracji."
Bjarne Stroustrup
Na zakończenie tej części, ponieważ poznaliśmy już wszystkie sposoby deklarowania wszystkiego co się da, wspomnę teraz o największej bolączce C++ (burzliwe dyskusje ntt. prawdopodobnie trwają nadal, ale aktualny standard niczego jeszcze nie ulepszył w tej kwestii). Język C, jako język spartański, przyjął najprostszą możliwą postać deklaracji i w związku z tym jest ona pogmatwana na każdy możliwy sposób. Właściwie może nie byłby tak pogmatwana, gdyby nie operatory przedrostkowe, a właściwie tylko jeden - *. Problem w tym, że jest on w deklaratorach chyba najczęściej używany. Różni profesorzy, doktorzy i magistrzy na wyższych uczelniach uwielbiają gnębić studentów podając im deklarator i żądając objaśnienia, co taki deklarator deklaruje (jak sobie przypomnę koszmar lekcji języka polskiego z liceum i tłumaczenie "co autor miał na myśli", to mam trochę dziwne wzajemne skojarzenia... pocieszać się można tylko tym, że kompilator to i tak zrozumie zawsze jednoznacznie). Ze swojej strony tylko osobiście podpowiem, że staram się zawsze wprowadzać definicje pośrednie, jeśli muszę już używać jakichś skomplikowanych typów, jednak zazwyczaj cały kłopot sprowadza się do problemów z odpowiednim umieszczeniem i opakowaniem nawiasami jednego operatora - *.
W deklaratorach występują operatory, podobne do takich, jakie się używa potem na obiektach tak deklarowanych typów. A więc, * do wskaźników, [] do tablic i () do funkcji (& do referencji tutaj jest wyjątkiem, ale wydaje mi się, że ten operator jest wystarczająco czytelny). W sumie nawet deklaratory nie są czymś skomplikowanym. Tzn. nie byłyby, gdyby nie *.
I wbrew pozorom, * jest jedynym operatorem, z którym są takie problemy; operatory przyrostkowe są całkowicie bezproblemowe, a referencje mają zbyt ograniczone możliwości. Praktycznie w deklaracji jakiejkolwiek referencji nie ma żadnego innego operatora oprócz `&' - tablic referencji tworzyć się nie da, a referencji do tablicy lub funkcji aż tak często się nie używa.
Postać deklaratora wygląda następująco:
<typ czołowy> <operatory przedrostkowe> nazwa <operatory przyrostkowe>;
Przy czym operatory [] i () to operatory przyrostkowe, a * i & - przedrostkowe. Proszę zgadnąć zatem, co takiego może deklarować taki deklarator, który dla dodatkowego zmylenia zapiszę tak, jak "profesjonalni" programiści C:
int *tab[20];
A więc - czym jest `tab'? Wskaźnikiem do tablicy 20 elementów typu int, czy tablicą o 20 elementach typu `int*'?
Odpowiem może tak: co się stanie, jeśli dodamy odpowiednio nawiasy:
int (*tab)[20];
Otóż ta właśnie deklaracja deklaruje wskaźnik do tablicy ... itd., podczas gdy tamten poprzedni typ oznaczał właśnie to drugie.
Oczywiście, że w int* tab[20]; lepiej wiadomo, o co chodzi. Niestety to tylko pozór. Gdyby bowiem chcieć wstawić w tą deklarację nawiasy tam, gdzie one powinny być, należałoby je umieścić w następujący sposób:
int (*tab[20]);
I ciekaw jestem, komu skojarzyłoby się to z tablicą 20 int*?
Ale to jeszcze nic. Przykładowo, mamy w bibliotece standardowej C taką funkcję `signal' (zostanie ona dalej przedstawiona). Służy ona do ustanowienia nowej funkcji obsługującej konkretny sygnał wysłany do programu, zwracając starą. Funkcja ta ma posiadać nagłówek: `void sighandler( int )'. Funkcja `signal' zatem przyjmuje i zwraca wskaźnik do niej. Oto jej deklaracja (również dla utrudnienia, napiszę ją tak, jak "profesjonalni" progamiści to piszą, czyli bez wstawiania spacji wewnątrz nawiasów funkcji, jak to mam w zwyczaju):
void (*signal(int sig, void (*handler)(int)))(int);
No i jak? Podoba się? Tak jest (nawiasem mówiąc) podana ta deklaracja we sławetnej książce Kernighan & Ritche "Język ANSI C" (tzw. "biblii" programistów C). Nikt normalny przecież (a więc również twórcy bibliotek do GNU C) nie wpisałby czegoś takiego do kodu (od samego patrzenia na to można dostać świra :*). Tam deklaracja tej funkcji zawiera pewną deklarację pośrednią (tu pokazałem trochę uproszczoną):
typedef void (*__sighandler_t)( int );
I potem:
__sighandler_t signal( int, __sighandler_t );
I to już jest proste i zrozumiałe (__sighandler_t jest zresztą dalej w tym pliku nagłówkowym używane do jeszcze wielu innych deklaracji).
Gdyby kogoś interesowało, jak wstawiłem tamtą poprzednią deklarację funkcji `signal' do tego tekstu, to wyjaśniam, że napisałem to z pamięci, aczkolwiek nie od początku do końca. Nie skleiłem tego również z przedstawionych deklaracji pośrednich (szczerze powiedziawszy, miałbym z tym problemy, poza tym znalazłem lepsze rozwiązanie). Zanim jednak przedstawię szczegóły, przedstawię źródło całej koncepcji.
Bjarne Stroustrup we wspomnianej już książce wspomina, że pojawiła się koncepcja poprawy konstrukcji deklaratorów, gdzie zamiast operatora przedrostkowego `*' używałoby się przyrostkowego `->' (niestety nie dopracował tego pomysłu i w końcu nie znalazł się on w propozycji do standardu). Choć obowiązkowość tego "typu czołowego" nadal wprowadzałaby pewne zamieszanie, ale deklarator byłby jeszcze jakoś możliwy do odczytania. Na przykład wspomniana funkcja miałaby taki nagłówek:
void signal( int sig, void handler->( int ) )->( int );
Co prawda tutaj `void' jest typem zwracanym funkcji, do której wskaźnik zwraca funkcja `signal' (a nie typem zwracanym przez `signal'), całość bowiem typu zwracanego to jest i to, co jest przed nazwą funkcji, i to co jest za zamykającym nawiasem. Nie jest więc taka deklaracja wystarczająco czytelna, ale już o wiele bardziej, niż ta obowiązująca. Ma zresztą jedną dodatkową zaletę: w odróżnieniu od `*', nie wymaga nigdy nawiasów wymuszających kolejność interpretacji...
Radykalna propozycja, na której się oparłem, polegała dodatkowo na tym, że cały zlepek operatorów przyrostkowych (czyli stojących "za" nazwą) można przenieść przed typ czołowy, ale tylko wtedy, jeśli deklarator zawiera nazwę (bo nie musi; ta pierwsza deklaracja funkcji `signal' nie musiała mieć np. nazwy argumentu `handler'; mogłoby pozostać samo (*) ). Deklaracja taka wyglądałaby wtedy w następujący sposób:
->( int )void signal( int sig, ->( int )void handler );
Czytałoby się to (poczynając od funkcji i kończąc na typie zwracanym): "Funkcja signal, przyjmująca argument `sig' typu `int' oraz argument `handler' typu `wskaźnik do funkcji przyjmującej `int' i zwracającej `void' ', zwracająca wskaźnik do funkcji przyjmującej argument `int' i zwracającej `void'.
To i tak jeszcze mało. Tablica wskaźników do funkcji np. wyglądałaby tak:
[20]->( int )void tab;
Ewentualnie nic nie stoi na przeszkodzie, żeby umieszczać operatory część tu część tam (przy przenoszeniu należy pamiętać, że operatory muszą być ustawione w tej samej kolejności!):
->( int )void tab[20];
Czasem, gdyby zaszła potrzeba stworzenia takiej skomplikowanej deklaracji, zawsze się można posłużyć taką. Konwersja na postać akceptowalną przez kompilator jest banalnie prosta - należy najpierw umieścić wszystkie operatory przyrostkowe tam, gdzie "były", czy powinny być:
void tab[20]->( int );
po czym zamienić wszystkie `->' na `*' w ten sposób: w miejscu, gdzie jest `->' wstawić `)', po czem po tej operacji dodać `(*' przed nazwą. Jeśli wiesz już również, jak konwertować w drugą stronę, to wszystkim wyżej wspomnianym profesorom, doktorom i magistrom możesz pokazać mniej więcej ` _|_ '.
2.7 Niekompatybilności z językiem C
"Powoli i nie bez bólu zgodzono się na to, że nie powinno być niczym nie uzasadnionych niezgodności między językiem C++ a językiem ANSI C, ale też przyjęto, że istnieje coś takiego, jak uzasadniona niezgodność. [...] Później uznano taką zasadę: `język C++: tak bliski języka C, jak tylko możliwe, ale nie bliższy/ [...]. Miarą sukcesu takiej polityki jest to, że każdy przykład w książce Kernighana i Ritchie'ego jest napisany w języku C będącym podzbiorem języka C++."
Bjarne Stroustrup
Wstęp
Wiele z zamieszczonych tu informacji były już po części wspominane wcześniej. Może i nie będą specjalnie przydatne, ale warto się z nimi zapoznać przed zapoznaniem się z elementami biblioteki standardowej C. Nie wszystkie są specjalnie uciążliwe, postanowiłem tutaj jednak zebrać to wszystko "do kupy".
Struktury, wskaźniki, konwersje
W C panowały następujące zasady, które nie przetrwały w C++:
typ strukturalny i wyliczeniowy był poprzedzony słowem kluczowym identyfikującym (struct/union/enum); w konsekwencji nie zajmowało to identyfikatora w bieżącym zasięgu
struktura zagnieżdżona w innej ma w C++ nazwę z podaniem operatora zasięgu, podczas gdy w C była identyczna, jak gdyby była zadeklarowana normalnie w zewnętrznym zasięgu (extern "C" również udostępnia tą właściwość)
wskaźnik void* jest typem uniwersalno-wskaźnikowym; jest on niejawnie konwertowany na każdy typ wskaźnikowy (z zastrzeżeniami co do wariancji); w szczególności zresztą w C można konwertować niejawnie dwa dowolne wskaźniki między sobą i z wartością całkowitą, void* wyróżniłem z uwagi na to, że jest to powszechnie w C stosowane, bo kompilator nie rzuca wtedy ostrzeżeń.
Opuszczanie `int'; styl `K&R' definiowania argumentów funkcji
Jest to składnia (K&R to oczywiście "Kernighan & Ritchie", twórcy języka C; pierwotnie w języku C obowiązywała wyłącznie taka właśnie składnia) o następującej postaci (podam już przykładowo, bez schematów):
int strcpy( dest, source )
char *source, *dest;
{
...
}
Zaletą tego stylu jest to, że można podawać listę zmiennych, tak jak przy deklaracjach zmiennych lokalnych. Nie obowiązuje też oczywiście w takich deklaracjach żadna kolejność.
Posiada również wady. Czytelności tej składni niestety zarzucić nie można. W ogóle nie widać tego, jakie typy argumentów są oczekiwane na konkretnych pozycjach (nawet jeśli programista zachowa kolejność w deklaracjach). Założenia standardu C nie nakazują zachowania zgodności z normalnymi zapowiedziami; niektóre archaiczne kompilatory C nie dopuszczają innych zapowiedzi, niż bez specyfikacji argumentów. Z kolei gdyby używać tych specyfikacji, to ich synchronizacja jest w ten sposób dodatkowo utrudniona.
Ta składnia oczywiście nie zaakceptowała się w C++. Przytaczam to tylko jako ciekawostkę, którą nadal można spotkać w programach, których autorzy na siłę starają się dostosować swoje dzieła do kompilatorów z epoki kamienia łupanego. Nawet sami twórcy C uważają, że jest to anachronizm, który prawdopodobnie zniknie w którymś z następnych standardów.
W C, jak też w pierwotnych wersjach C++, istniała z trudem stłumiona zasada, że w deklaracjach czegokolwiek oprócz zmiennych, można było pominąć nazwę "typu czołowego", jeśli był nim int. W sumie dziś pozostała ona wyłącznie (w C) w formie ostrzeżeń kompilatora (np. jeśli deklaruje się funkcję, lub zmienną ze słowem static, extern czy auto). Proszę sobie wyobrazić, co w efekcie oznaczała ona w połączeniu ze stylem K&R:
fn( a, b, c ) char* b;
{
...
}
co odpowiada "normalnemu" nagłówkowi:
int fn( int a, char* b, int c );
extern "C"
Wspominałem już o tym niejednokrotnie, chciałbym tutaj jedynie uściślić kwestię używania extern "C". Dyrektywa ta pozwala na zaimportowanie kodu dostowowanego do C dla C++. Okoliczności, w których konieczne jest jego użycie to przede wszystkim deklaracje funkcji C. Nazwa takiej funkcji nie jest manglowana, a zewnętrznie funkcja jest traktowana jak funkcja C, co oznacza, że nie może być nią operator (tzn. właściwie to może, sprawdziłem to na g++ faktycznie, operator + miał zewnętrzną niezmanglowaną nazwę - __pl!) i nie może być przeciążona (nie znaczy to oczywiście, że nie da się jej przeciążyć, a jedynie, że tylko jedna z przeciążonych funkcji może być extern "C").
Brak manglowania nazwy jest jedną z istotniejszych rzeczy w extern "C". Podczas wiązania poszukje się funkcji o określonej nazwie, a nazwy funkcji z biblioteki standardowej C są przecie zapisane w "gołej" postaci.
Restrykcyjność
Poza tym, co już wymieniłem należy jeszcze pamiętać o następujących rzeczach:
w C++ używanie prototypów (zapowiedzi) funkcji jest absolutnie obowiązkowe (g++ w wersji 2.7.2 przyjmował, że niezapowiedziana funkcja ma deklarację "uniwersalną", o czym ostrzegał; aktualne wersje już generują błąd)
Pusta lista argumentów w C++ jest synonimem (void), natomiast odpowiednikiem () w C jest (...) w C++ (ma sens tylko z extern "C")
Globalne dane w C++ można zadeklarować dokładnie RAZ. Istnieje konkretnie podzial na obiekty o slabym i silnym wiazaniu, przy czym obiekty o slabym wiazaniu to tylko te generowane na potrzeby samego jezyka (czyli np. tablice metod wirtualnych, czy out-line wersje funkcji inline). Obiekty deklarowane jawnie podlegaja zawsze silnemu wiazaniu, wiec proba zlinkowania dwoch plikow deklarujacych symbol o tej samej nazwie zakonczy sie niepowodzeniem
Globalne stałe w C++ (const) mają wiązanie plikowe, podczas gdy w C było zewnętrzne. Aby stała w C++ miała wiązanie zewnętrzne, należy dodać modyfikator extern. Stała może być również deklarowana tylko raz, a zaimportowanie różni się od deklaracji tylko tym, że deklarowana jest inicjalizowana
Typ wyliczeniowy jest w C++ traktowany jako oddzielny typ, z tym tylko zastrzeżeniem, że konstruktor typu int może przyjmować dowolny enum, jednak niejawne konwersje enumów na int są niedozwolone
C++ restrykcyjnie pilnuje kwestii zgodności liczb całkowitych różniących się znakowością (signedness)
3. Zaawansowane programowanie w C++
3.1 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ć:
statyczny, tzn. jest tworzony na początku uruchomienia programu i usuwany przed samym jego zakończeniem; właściwość tę posiadają statyczne zmienne lokalne oraz zmienne globalne
automatyczny, tzn. jest tworzony na początku zawierającego go zasięgu i usuwany przed samym jego końcem; właściwość tę posiadają zwykłe zmienne lokalne
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 czasem jednak warto.
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
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:
Przydzielenie pamięci dla obiektu (allocation)
Utworzenie obiektu (construction)
Używanie obiektu (using)
Zniszczenie obiektu (destruction)
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 sie 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 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 pamieci.
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 najwazniejsza jest tu kwestia konstruktorow i destruktorow).
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 wspomne 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żdy 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 zwrócenie zera, jeśli nie wydłubie tyle pamięci, ile się żąda. Zatem ostrożnie eksperymentuj z obciążaniem komputera dużymi wymogami pamięci -- linuks 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++ (będzie jeszcze o tym mowa w drugiej części). 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 rozdziale. 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 "niską 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.
Później jednak, do rozwijającego się języka C dodano słowo oznaczające pusty wskaźnik, tzn. to samo co zero, ale używane przy wskaźnikach (nazwane NULL). Poza zapisem, 0 nie różniło się niczym od NULL, które zresztą na początku bywało też definiowane jako 0L (tradycyjne C nie miały jeszcze możliwości konwertowania typów, ani kontekstowej interpretacji literałów). 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ść jako jedyna mogła się dowolnie niejawnie konwertować z innym wskaźnikiem. 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 C nie tylko nie zabrania używać 0 zamiast NULL (0 nie musi być rzutowane na void*, jak NULL to czyni), 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. Nie wiem, jak Tobie, ale mnie jawi się idiotyzmem fakt, że mogę przypisać wartość void* do Klocek* bez rzutowania, 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. Nie potrafię również zrozumieć, co twórcy C chcieli przez to osiągnąć -- możliwość generowania ostrzeżeń przy niewłaściwym użyciu NULL, czy brak konieczności rzutowania rezultatu malloc? Osobiście uważam, że zamiast wprowadzać taki idiotyzm, lepiej było wprowadzić NULL jako słowo kluczowe, a do przydziału pamięci dorobić makro preprocesora np. ALLOC, które przyjmowałoby nazwę typu jako argument (makro samo wzięłoby go pod sizeof i użyło do rzutowania); możnaby też zrobić dodatkowe makro, które pozwalałoby na podanie funkcji, która służyłaby do przydziału pamięci.
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:
wysypanie się programu
prawidłowe odwołanie się do obiektu (czysty przypadek :*)
przekłamanie w danych jakiegoś przypadkowo trafionego obiektu
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 i różnych asercji wszędzie gdzie wlezie).
Oczywiście proszę nie zrozumieć mnie źle - nie jestem bynajmniej przeciwnikiem asercji. Należy jednak pamiętać, że asercje tylko wtedy mają sens, jeśli często może wystąpić sytuacja opisana asercją, bądź podejrzewamy jej wystąpienie (wtedy asercje są tylko robocze). Pozostawianie asercji 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:
zmienić język programowania, np. na Smalltalk lub Javę; tam pojęcie usunięcia obiektu w ogóle nie istnieje
użyć odśmiecacza Boehma; jest on dostępny -- przynajmniej dla gcc -- jako <gc/gc.h> i wrapper dla C++ <gc/gc_cpp.h>; ma bardzo duże możliwości i dużą swobodę decydowania o kwestii odśmiecania i usuwania obiektów, jednak dokumentacja pisze, że jest w stanie znaleźć PRAWIE wszystkie osierocone obiekty
użyć rgc, który przedstawię w punkcie "wzorce"; wymaga on jednak używania do tego celu obiektów tylko odpowiednio zadeklarowanego typu, a wskaźników też tylko określonego typu (tzn. tylko do zmiennych tych typów wskaźnikowych przypisania są monitorowane). Niestety należy ostrożnie go używać -- i tylko w razie konieczności -- do zdefiniowania współwłaścicielstwa do obiektu. Nadużycie go może spowolnić program (kazda operacja na wskazniku mptr jest dwukrotnie dłuższa w stosunku do zwykłego wskażnika). Poza tym NIGDY nie wolno dopuścić do takiej sytuacji, żeby dwa obiekty wskazywały na siebie nawzajem mptr-em; spowoduje to powstanie tzw. cyklicznej struktury, czyli grupy obiektów kwalifikujących się do usunięcia, ale niemożliwych do usunięcia automatycznie (a próba ręcznego ich usunięcia może spowodować wysypanie programu). Inną -- lepszą, działającą i przetestowaną -- wersję można znaleźć w bibliotece BOOST (dokumentacja jest TU).
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ć".