intro 2


C++ bez cholesterolu: Zaawansowane programowanie w C++ 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ć".

Wyszukiwarka

Podobne podstrony:
intro
GRADIENT INTRO
Intro
intro
Intro (40)
Appendices01 Intro
tcpip intro
intro
1 Intro
4 Intro to lg morph LECTURE2014
intro 2
social?onomy intro
MR 362 ESPACE INTRO
33 ENVI Zoom Intro
INTRO
00 INTRO
macierze intro

więcej podobnych podstron