BAZA WIEDZY
KURSY
Bazy danych w PHP
Kurs AdvancedAJAX
Kurs ASP
Kurs ASP.NET
Kurs C++
Kurs CSS
Kurs HTML
Kurs HTML drugi
Kurs JavaScript
Kurs MySQL
Kurs PHP
Kurs RSS
Kurs XHTML
Obiekty DOM
MANUALE
CSS1 - W3C
DOM - w budowie
PHP 2005
PHP 2006
Wyrażenia regularne
SHOUTBOX
STAT
Online: 31 | UU: 523

Wskaźniki na zmienne

Trudno zliczyć, ile razy stosowaliśmy zmienne w swoich programach. Takie statystyki nie mają zresztą zbytniego sensu - programowanie bez użycia zmiennych jest przecież tym samym, co prowadzenie samochodu bez korzystania z kierownicy ;D

Wiele razy przypominałem też, że zmienne rezydują w pamięci operacyjnej. Mechanizm wskaźników na nie jest więc zupełnie logiczną konsekwencją tego zjawiska. W tym podrozdziale zajmiemy się właśnie takimi wskaźnikami.

Używanie wskaźników na zmienne

Wskaźnik jest przede wszystkim liczbą - adresem w pamięci, i w takiej też postaci istnieje w programie. Język C++ ma ponadto ścisłe wymagania dotyczące kontroli typów i z tego powodu każdy wskaźnik musi mieć dodatkowo określony typ , na jaki wskazuje. Innymi słowy, kompilator musi znać odpowiedź na pytanie: "Jakiego rodzaju jest zmienna, na którą pokazuje dany wskaźnik?". Dzięki temu potrafi zachowywać kontrolę nad typami danych w podobny sposób, w jaki czyni to w stosunku do zwykłych zmiennych.

Obejmuje to także rzutowanie między wskaźnikami, o którym też sobie powiemy.

Wiedząc o tym, spójrzmy teraz na ten elementarny przykład deklaracji oraz użycia wskaźnika:

// deklaracja zmiennej typu int oraz wskaźnika na zmienne tego typu
int nZmienna = 10 ;
int * pnWskaznik; // nasz wskaźnik na zmienne typu int\
// przypisanie adresu zmiennej do naszego wskaźnika i użycie go do
// wyświetlenia jej wartości w konsoli

pnWskaznik = &nZmienna; // pnWskaznik odnosi się teraz do nZmienna
std::cout << *pnWskaznik; // otrzymamy 10, czyli wartość zmiennej

Dobra wiadomość jest taka, iż mimo prostoty ilustruje on większość zagadnień związanych ze wskaźnikami na zmiennej. Nieco gorszą jest pewnie to, że owa prostota może dla niektórych nie być wcale taka prosta :) Naturalnie, wyjaśnimy sobie po kolei, co dzieje się w powyższym kodzie (chociaż komentarze mówią już całkiem sporo).

Oczywiście najpierw mamy deklarację zmiennej (z inicjalizacją), lecz nas interesuje bardziej sposób zadeklarowania wskaźnika, czyli:

int * pnWskaznik;

Poprzez dodanie gwiazdki ( * ) do nazwy typu int informujemy kompilator, że oto nie ma już do czynienia ze zwykłą zmienną liczbową, ale ze wskaźnikiem przeznaczonym do przechowywania adresu takiej zmiennej. pWskaznik jest więc wskaźnikiem na zmienne typu int , lub, krócej, wskaźnikiem na (typ) int .

A zatem mamy już zmienną, mamy i wskaźnik. Przydałoby się zmusić je teraz do współpracy: niech pWskaznik zacznie odnosić się do naszej zmiennej! Aby tak było, musimy pobrać jej adres i przypisać go do wskaźnika - o tak:

pnWskaznik = &nZmienna;

Zastosowany tutaj operator & służy właśnie w tym celu - do uzyskania adresu miejsca w pamięci, gdzie egzystuje zmienna. Potem rzecz jasna zostaje on zapisany w pnWskaznik ; odtąd wskazuje on więc na zmienną nZmienna .

Na koniec widzimy jeszcze, że za pośrednictwem wskaźnika możemy dostać się do zmiennej i użyć jej w ten sam sposób, jaki znaliśmy dotychczas, choćby do wypisania jej wartości w oknie konsoli:

std::cout << *pnWskaznik;

Jak z pewnością przypuszczasz, operator * nie dokonuje tutaj mnożenia, lecz podejmuje wartość zmiennej, z którą połączony został pnWskaznik ; nazywamy to dereferencją wskaźnika . W jej wyniku otrzymujemy na ekranie liczbę, którą oryginalnie przypisaliśmy do zmiennej nZmienna . Bez zastosowania wspomnianego operatora zobaczyliśmy wartość wskaźnika (a więc adres komórki w pamięci), nie zaś wartość zmiennej , na którą on pokazuje. To oczywiście wielka różnica.

Zaprezentowana próbka kodu faktycznie realizuje zatem zadanie wyświetlenia wartości zmiennej nZmienna w iście okrężny sposób. Zamiast bezpośredniego przesłania jej do strumienia wyjścia posługujemy się w tym celu dodatkowym pośrednikiem w postaci wskaźnika.

Samo w sobie może to budzić wątpliwości co do sensowności korzystania ze wskaźników. Pomyślmy jednak, że mając wskaźnik możemy umożliwić dostęp do danej zmiennej z jakiegokolwiek miejsca programu - na przykład z funkcji, do której przekażemy go jako parametr (w końcu to tylko liczba!). Potrafimy wtedy zaprogramować każdą czynność (algorytm) i zapewnić jej wykonanie w stosunku do dowolnej ilości zmiennych , pisząc odpowiedni kod tylko raz .

Więcej przekonania do wskaźników na zmiennej nabierzesz wówczas, gdy poznasz je bliżej - i temu właśnie zadaniu poświęcimy teraz uwagę.

Deklaracje wskaźników

Stwierdziliśmy, że wskaźniki mogą z powodzeniem odnosić się do zmiennych - albo ogólnie mówiąc, do danych w programie. Czynią to poprzez przechowywanie numeru odpowiedniej komórki w pamięci, a zatem pewnej wartości . Sprawia to, że wskaźniki są w rzeczy samej także zmiennymi.

Wskaźniki w C++ to zmienne należące do specjalnych typów wskaźnikowych .

Taki typ łatwo poznać po obecności przynajmniej jednej gwiazdki w jego nazwie. Jest nim więc choćby int * - typ zmiennej pWskaznik z poprzedniego przykładu. Zawiera on jednocześnie informację, na jaki rodzaj danych będzie nasz wskaźnik pokazywał - tutaj jest to int . Typ wskaźnikowy jest więc typem pochodnym , zdefiniowanym na podstawie jednego z już wcześniej istniejących.

To definiowanie może się odbywać ad hoc , podczas deklarowania konkretnej zmiennej (wskaźnika) - tak było w naszym przykładzie i tak też postępuje się najczęściej. Dozwolone (i przydatne) jest aczkolwiek stworzenie aliasów na typy wskaźnikowe poprzez instrukcję typedef ; standardowe nagłówki systemu Windows zawierają na przykład wiele takich nazw.

Deklarowanie wskaźników jest zatem niczym innym, jak tylko wprowadzeniem do kodu nowych zmiennych - tyle tylko, iż mają one swoiste przeznaczenie, inne niż reszta ich licznych współbraci. Czynność ich deklarowania, a także same typy wskaźnikowe zasługują przeto na szersze omówienie.

Nieodżałowany spór o gwiazdkę

Dowiedzieliśmy się już, że pisząc gwiazdkę po nazwie jakiegoś typu, uzyskujemy odpowiedni wskaźnik na ten typ. Potem możemy użyć go, deklarując właściwy wskaźnik; co więcej, możliwe jest uczynienie tego aż na cztery sposoby:

int * pnWskaznik;
int *pnWskaznik;
int *pnWskaznik;
int * pnWskaznik;

Widać więc, że owa gwiazdka "nie trzyma się" kurczowo nazwy typu (tutaj int ) i może nawet oddzielać go od nazwy deklarowanej zmiennej, bez potrzeby użycia w tym celu spacji.

Wydawałoby się, że taka swoboda składniowa powinna tylko cieszyć. W rzeczywistości jednak powoduje najczęściej trudności w rozumieniu kodu napisanego przez innych, jeżeli używają oni innego sposobu deklarowania wskaźników niż "nasz". Dlatego też wielokrotnie próbowano ustalić jakiś jeden, słuszny wariant w tej materii. i w zasadzie nigdy się to nie udało!

Podobnie rzecz ma się także z umieszczaniem nawiasów klamrowych po instrukcjach if , else oraz nagłówkach pętli.

Jeśli więc chodzi o dwa ostatnie sposoby, to generalnie prawie nikt nich nie używa i raczej nie jest to niespodzianką. Nieużywanie spacji czyni instrukcję mało czytelną, zaś ich obecność po obu stronach znaku * nieodparcie przywodzi na myśl mnożenie, a nie deklarację zmiennej.

Co do dwóch pierwszych metod, to w kwestii ich używania panuje niczym niezmącona dowolność. Poważnie! W kodach, jakie spotkasz, na pewno będziesz miał okazję zobaczyć obie te składnie. Argumenty stojące za ich wykorzystaniem są niemal tak samo silne w przypadku każdej z nich - tak przynajmniej twierdzą ich zwolennicy.

Temu problemowi poświęcony jest nawet fragment FAQ autora języka C++.

Zauważyłeś być może, iż w tym kursie używam pierwszej konwencji i będę się tego konsekwentnie trzymał. Nie chcę jednak nikomu jej narzucać; najlepiej będzie, jeśli sam wypracujesz sobie odpowiadający ci zwyczaj i, co najważniejsze, będziesz go konsekwentnie przestrzegał. Nie ma bowiem nic gorszego niż niespójny kod.

Z opisywanym problemem wiąże się jeszcze jeden dylemat, powstający gdy chcemy zadeklarować kilka zmiennych - na przykład tak:

int * a, b;

Czy w ten sposób otrzymamy dwa wskaźniki (zmienne typu int * )?. Pozostawiam to zainteresowanym do samodzielnego sprawdzenia 1 . Odpowiedź nie jest taka oczywista, jak by się to wydawało na pierwszy rzut oka, zatem stosowanie takiej konstrukcji pogarsza czytelność kodu i może być przyczyną błędów. Czuje się więc w obowiązku przestrzec przed nią:

Nie próbuj deklarować kilku wskaźników w jednej instrukcji, oddzielając je przecinkami.

Trzeba niestety przyznać, że język C++ zawiera w sobie jeszcze kilka podobnych niejasności. Będę zwracał na nie uwagę w odpowiednim czasie i miejscu.

Wskaźniki do stałych

Wskaźniki mają w C++ pewną, dość oryginalną cechę. Mianowicie, nierzadko aplikuje się do nich modyfikator const , a mimo to cały czas możemy je nazywać zmiennymi. Dodatkowo, ów modyfikator może być doń zastosowany aż na dwa różne sposoby.

Pierwszy z nich zakłada poprzedzenie nim całej deklaracji wskaźnika, co wygląda mniej więcej tak:

const int * pnWskaznik;

const , jak wiemy, zmienia nam zmienną w stałą . Tutaj mamy jednak do czynienia ze wskaźnikiem na zmienną, zatem działanie modyfikatora powoduje jego zmianę we. wskaźnik na stałą :)

Wskaźnik na stałą (ang. pointer to constant ) pokazuje na wartość, która może być poprzez ten wskaźnik jedynie odczytywana .

Przypatrzmy się, jak wskaźnik na stałą może być wykorzystany w przykładowym kodzie:

// deklaracja zmiennej i wskaźnika do stałej
float fZmienna = 3.141592 ;
const float * pfWskaznik;
// związanie zmiennej ze wskaźnikiem
pfWskaznik = &fZmienna;
// pokazanie wartości zmiennej poprzez wskaźnik
std::cout << *pfWskaznik;

Przykład ten jest podobny do poprzedniego: za pośrednictwem wskaźnika odczytujemy tu wartość zmiennej. Dozwolne jest zatem, aby ów wskaźnik był wskaźnikiem na stałą - jako taki więc go deklarujemy:

const float * pfWskaznik;

Rożnica, jaką czyni modyfikator const , ujawni się przy próbie zapisania wartości do zmiennej, na którą pokazuje wskaźnik:

*pfWskaznik = 1.0 ; // BŁĄD! pfWskaznik pokazuje na stałą wartość

Kompilator nie pozwoli na to. Decydując się na zadeklarowanie wskaźnika na stałą (tutaj typu const float * ) uznaliśmy bowiem, że będziemy tylko odczytywać wartość, do której się on odnosi. Zapisywanie jest oczywiście pogwałceniem tej zasady.

Powyższa linijka byłaby rzecz jasna poprawna, gdyby pfWskaznik był zwykłym wskaźnikiem typu float * .

 

Jeżeli wskaźnik na stałą jest dodatkowo wskaźnikiem na obiekt , to na jego rzecz możliwe jest wywołanie jedynie stałych metod . Nie modyfikują one bowiem pól obiektu.

Wskaźnik na stałą umożliwia więc zabezpieczenie przed niepożądaną modyfikacją wartości, na którą wskazuje. Z tego wzgledu jest dosyć często wykorzystywany w praktyce, chociażby przy przekazywaniu parametrów do funkcji.

1 Można skorzystać z podanego wcześniej linka do FAQa.

Stałe wskaźniki

Druga możliwość użycia const powoduje nieco inny efekt. Odmienne jest wówczas także umiejscowienie modyfikatora w deklaracji wskaźnika:

float * const pfWskaznik;

Takie ustawienie powoduje mianowicie zadeklarowanie stałego wskaźnika zamiast wskaźnika na stałą.

Stały wskaźnik (ang. const(ant) pointer ) jest nieruchomy , na zawsze przywiązany do jednego adresu pamięci.

Ten jeden jedyny i niezmienny adres możemy określić tylko podczas inicjalizacji wskaźnika:

float fA;
float * const pfWskaznik = &fA;

Wszelkie późniejsze próby związania wskaźnika z inną komórką pamięci (czyli inną zmienną) skończą się niepowodzeniem:

float fB;
pfWskaznik = &fB; // BŁĄD! pfWskaznik jest stałym wskaźnikiem

Zadeklarowanie stałego wskaźnika jest bowiem umową z kompilatorem, na mocy której zobowiązujemy się nie zmieniać adresu , do którego tenże wskaźnik pokazuje.

Pole zastosowań stałych wskaźników jest, przyznam szczerze, raczej wąskie. Mimo to mieliśmy już okazję korzystać z tego rodzaju wskaźników - i to niejednokrotnie. Gdzie?

Otóż stałym wskaźnikiem jest this , który, jak pamiętamy, pokazuje wewnątrz metod klasy na aktualny jej obiekt. Nie ogranicza on w żaden sposób dostępu do tego obiektu, jednak nie pozwala na zmianę samego wskazania ; jest więc trwale związany z tym obiektem.

Typem wskaźnika this wewnątrz metod klasy klasa jest więc klasa * const .

W przypadku stałych metod wskaźnik this nie pozwala także na modyfikację pól obiektu, a zatem wskazuje na stałą. Jego typem jest wtedy const klasa * const , czyli mikst obu rodzajów "stałości" wskaźnika.

Podsumowanie deklaracji wskaźników

Na sam koniec tematu deklarowania wskaźników tradycyjnie podam trochę wskazówek dotyczacych składni oraz stosowalności praktycznej.

Składnie deklaracji wskaźnika możemy, opierając się na przykładach z poprzednich paragrafów, przedstawić następująco:

[ const ] typ * [ const ] wskaźnik ;

Możliwość występowania lub niewystępowania modyfikatora const w aż dwóch miejscach deklaracji pozwala stwierdzić, że z każdego typu możemy wyprowadzić łącznie nawet cztery odpowiednie typy wskaźnikowe. Ich charakterystykę przedstawia poniższa tabelka:

typ wskaźnikowy

nazwa

dostęp do pamięci

zmiana adresu

typ *

wskaźnik (zwykły)

odczyt i zapis

dozwolona

const typ *

wskaźnik do stałej

wyłącznie odczyt

dozwolona

typ * const

stały wskaźnik

odczyt i zapis

niedozwolona

const typ * const

stały wskaźnik do stałej

wyłącznie odczyt

niedozwolona

Tabela 12. Zestawienie typów wskaźnikowych

Czy jest jakiś prosty sposób na zapamiętanie, która deklaracja odpowiada jakiemu rodzajowi wskaźników? No cóż, może nie jest to banalne, ale w pewien sposób zawsze można sobie pomóc. Przede wszystkim patrzmy na frazę bezpośrednio za modyfikatorem const .

Dla stałych wskaźników (przypominam, że to te, które zawsze wskazują na to samo miejsce w pamięci) deklaracja wygląda tak:

typ * const wskaźnik ;

Bezpośrednio po słowie const mamy więc nazwę wskaźnika , co razem daje const wskaźnik . W wolnym tłumaczeniu znaczy to oczywiście 'stały wskaźnik' :)

W przypadku wskaźników na stałe forma deklaracji przedstawia się następująco:

const typ * wskaźnik ;

Używamy tu const w ten sam sposób, w jaki ze zmiennych czynimy stałe. W tym przypadku mamy rzecz jasna do czynienia ze 'wskaźnikiem na zmienną', a ponieważ const przemienia nam 'zmienną' w 'stałą', więc ostatecznie otrzymujemy 'wskaźnik na stałą'. Potwierdzenia tego możemy szukać w tabelce.

Niezbędne operatory

Na wszelkich zmiennych można w C++ wykonywać jakieś operacje i wskaźniki nie są w tym względnie żadnym wyjątkiem. Posiadają nawet własne instrumentarium specjalnych operatorów, dokonujących na nich pewnych szczególnych działań. To na nich właśnie skupimy się teraz.

Pobieranie adresu i dereferencja

Wskaźnik powinien na coś wskazywać - to znaczy przechowywać adres jakieś komórki w pamięci. Taki adres można uzyskać na wiele sposobów, w zależności od tego, jakie znaczenie ma owa komórka w programie. Dla zmiennych właściwą metodą jest użycie operatora pobierania adresu , oznaczanego znakiem & (ampersandem).

Popatrzmy na niniejszy przykład:

// zadeklarowanie zmiennej oraz odpowiedniego wskaźnika
unsigned uZmienna;
unsigned * puWskaznik;
// pobranie adresu zmiennej i zapisanie go we wskaźniku
puWskaznik = &uZmienna;

Wyrażenie &uZmienna reprezentuje tutaj wartość liczbową, będącą adresem miejsca w pamięci , w którym rezyduje zmienna uZmienna . Typem tej zmiennej jest unsigned ; wyrażenie &uZmienna jest natomiast przynależne typowi wskaźnikowemu unsigned * . Przypisujemy go więc zmiennej tego typu, czyli wskaźnikowi puWskaznik . Odtąd odnosi się on do naszej zmiennej liczbowej i może być użyty w celu odwołania się do niej.

Prezentowany tu operator & jest więc unarny - żądą tylko jednego argumentu: obiektu, którego adres ma uzyskać. Zwraca go w wyniku, zaś typem tego rezultatu jest odpowiedni typ wskaźnikowy - zobaczyliśmy to zresztą na powyższym przykładzie.

Przypominam, że adres zmiennej możemy przypisać jedynie do niestałego ("ruchomego") wskaźnika.

Mając wskaźnik, chciałoby się odwołać do komórki w pamięci, czyli zmiennej, na którą on wskazuje. Potrzebujemy zatem operatora, który dokona czynności odwrotnej niż operator & , a więc wydobędzie zmienną spod adresu przechowywanego przez wskaźnik. Dokonuje tego operator dereferencji , symbolem którego jest * (asterisk albo po prostu gwiazdka). Czynność przez niego wykonywaną nazywamy więc dereferencją wskaźnika.

Wystarczy spojrzeć na poniższy kod, a wszystko stanie się jasne:

// zapisanie wartości w komórce pamięci, na którą pokazuje wskaźnik
*puWskaznik = 123 ;
// odczytanie i wyświetlenie tej wartości
std::cout << "Wartosc zmiennej uZmienna: " << *puWskaznik;

Widzimy, że operator ten jest także unarny , co w oczywisty sposób różni go od operatora mnożenia, który w C++ jest przecież reprezentowany przez ten sam znak. Argumentem operatora jest naturalnie wskaźnik , przechowujący adres miejsca w pamięci, do którego chcemy się dostać. W wyniku działania tego operatora otrzymujemy możliwość odczytania oraz ewentualnie zapisania tam jakiejś wartości.

Typ tej wartości musi się jednak zgadzać z typem wskaźnika: jeżeli u nas był to unsigned * , to po dereferencji zostanie typ unsigned , akceptujący tylko dodatnie liczby całkowite. Podobnie z wyrażenia *puWskaznik możemy skorzystać jedynie tam, gdzie dozwolone są tego rodzaju wartości.

Wyrażenie *pWskaznik jest tu tak zwaną l-wartością (ang. l-value ). Nazwa bierze się stąd, iż taka wartość może występować po lewej (ang. left ) stronie operatora przypisania. Typowymi l-wartościami są więc zmienne, a w ogólności są to wszystkie wyrażenia, za którymi kryją się konkretne miejsca w pamięci operacyjnej i które nie zostały opatrzone modyfikatorem const .
Dla odróżnienia, r-wartość (ang. r-value ) jest dopuszczalna tylko po prawej (ang. right ) stronie operatora przypisania. Ta grupa obejmuje oczywiście wszystkie l-wartości, a także liczby, znaki i ich łańcuchy (tzw. stałe dosłowne) oraz wyniki obliczeń z użyciem wszelkiego rodzaju operatorów (wykorzystujących tymczasowe obiekty).

 

Pamiętajmy, że zapisanie danych do komórki pokazywanej przez wskaźnik jest możliwe tylko wtedy, gdy nie jest on wskaźnikiem do stałej .

Natura operatorów & i * sprawia, że najlepiej rozpatrywać je łącznie. Powiedzieliśmy sobie nawet, że ich funkcjonowanie jest sobie wzajemnie przeciwstawne . Ilustruje to dobrze poniższy diagram:


Schemat 33. Działanie operatorów: pobrania adresu i dereferencji

Warto również wiedzieć, że pobranie adresu zmiennej oraz dereferencja wskaźnika są możliwe zawsze , niezależnie od typu tejże zmiennej czy też wskaźnika. Dopiero inne związane z tym operacje, takie jak zachowanie adresu w zmiennej wskaźnikowej lub zapisanie wartości w miejscu, do którego odwołuje się wskaźnik, może napotykać ograniczenia związane z typami zmiennej i/lub stosowanego wskaźnika.

Wyłuskiwanie składników

Trzeci operator wskaźnikowy jest nam już znany od wprowadzenia OOPu. Operator wyłuskania -> (strzałka) służy do wybierania składników obiektu, na który wskazuje wskaźnik. Pod pojęciem 'obiektu' kryje się tu zarówno instancja klasy, jak i typu strukturalnego lub unii.

Ponieważ znamy już doskonale tę konstrukcję, na prostym przykładzie prześledzimy jedynie związek tego operatora z omówionymi przed chwilą & i * .

Załóżmy więc, że mamy taką oto klasę:

class CFoo
{
public :
int Metoda() const { return 1 ; }
};

Tworząc dynamicznie jej instancję przy użyciu wskaźnika, możemy wywołać składowe metody:

// stworzenie obiektu
CFoo* pFoo = new CFoo;
// wywołanie metody
std::cout << pFoo->Metoda();

pFoo jest tu wskaźnikiem, takim samym jak te, z których korzystaliśmy dotąd; wskazuje na typ złożony - obiekt. Wykorzystując operator -> potrafimy dostać się do tego obiektu i wywołać jego metodę, co też niejednokrotnie czyniliśmy w przeszłości.

Zwróćmy jednakowoż uwagę, że ten sam efekt osiągnęlibyśmy dokonując dereferencji naszego wskaźnika i stosując drugi z operatorów wyłuskania - kropkę:

// inna metoda wywołania metody Metoda() ;D
(*pFoo).Metoda();
// zniszczenie obiektu
delete pFoo;

 

Nawiasy pozwalają nie przejmować się tym, który z operatorów: * czy . ma wyższy priorytet. Ich wykorzystywanie jest więc zawsze wskazane, o czym zresztą nie raz wspominam :)

Analogicznie, można instancjować obiekt poprzez zmienną obiektową i mimo to używać operatora -> celem dostępu do jego składowych:

// zmienna obiektowa
CFoo Foo;
// obie poniższe linijki robią to samo
std::cout << Foo.Metoda();
std::cout << (&Foo)->Metoda();

Tym razem bowiem pobieramy adres obiektu, czyli wskaźnik na niego, i aplikujemy doń wskaźnikowy operator wyłuskania -> .

Widzimy zatem wyraźnie, że oba operatory wyłuskania mają charakter mocno umowny i teoretycznie mogą być stosowane zamiennie. W praktyce jednak korzysta się zawsze z kropki dla zmiennych obiektowych oraz strzałki dla wskaźników, i to z bardzo prostego powodu: wymuszenie zaakceptowania drugiego z operatorów wiąże się przecież z dodatkową czynnością pobrania adresu albo dereferencji. Łącznie zatem używamy wtedy dwóch operatorów zamiast jednego, a to z pewnością może odbić się na wydajności kodu.

Konwersje typów wskaźnikowych

Dwa poznane operatory nie wyczerpują rzecz jasna asortymentu operacji, jakich możemy dokonywać na wskaźnikach. Dosyć często zachodzi bowiem potrzeba przypisywania wskaźników, których typy są w większym lub mniejszym stopniu niezgodne - podobnie zresztą jak to czasem bywa dla zwykłych zmiennych. W takich przypadkach z pomocą przychodzą nam różne metody konwersji typów wskaźnikowych, jakie oferuje C++.

Matka wszystkich wskaźników

Przypomnijmy sobie definicję wskaźnika, jaką podaliśmy na początku rozdziału. Otóż jest to przede wszystkim adres jakiejś komórki (miejsca) w pamięci. Przy jej płaskim modelu sprowadza się to do pojedynczej liczby bez znaku.

Na przechowywanie takiej liczby wystarczyłby więc tylko jeden typ zmiennej liczbowej! C++ oferuje jednak możliwość definiowania własnych typów wskaźnikowych w oparciu o już istniejące, inne typy. Cel takiego postępowania jest chyba oczywisty: tylko znając typ wskaźnika możemy dokonać jego dereferencji i uzyskać zmienną, na którą on wskazuje. Informacja o docelowym typie wskazywanych danych jest więc niezbędna do ich użytkowania.

Możliwe jest aczkolwiek zadeklarowanie ogólnego wskaźnika (ang. void pointer lub pointer to void ), któremu nie są przypisane żadne informacje o typie. Taki wskaźnik jest więc jedynie adresem samym w sobie, bez dodatkowych wiadomości o rodzaju danych, jakie się pod tym adresem znajdują.

Aby zadeklarować taki wskaźnik, zamiast nazwy typu wpisujemy mu void :

void * pWskaznik; // wskaźnik, który może pokazywać na wszystko

Ustalamy tą drogą, iż nasz wskaźnik nie będzie związany z żadnym konkretnym typem zmiennych. Nic nie wiadomo zatem o komórkach pamięci, do których się on odnosi - mogą one zawierać dowolne dane .

Brak informacji o typie upośledza jednak podstawowe właściwości wskaźnika. Nie mogąc określić rodzaju danych, na które pokazuje wskaźnik, kompilator nie może pozwolić na dostęp do nich. Powoduje to, że:

Niedozwolone jest dokonanie dereferencji ogólnego wskaźnika typu void * .

Cóż bowiem otrzymalibyśmy w jej wyniku? Jakiego typu byłoby wyrażenie *pWskaznik ? void ?. Nie jest to przecież żaden konkretny typ danych. Słusznie więc dereferencja wskaźnika typu void * jest niemożliwa.

Ułomność takich wskaźników nie jest zbytnią zachętą do ich stosowania. Czym więc zasłużyły sobie na tytuł paragrafu im poświęconego?.

Otóż mają one jedną szczególną i przydatną cechę, związaną z brakiem wiadomości o typie. Mianowicie:

Wskaźnik typu void * może przechowywać dowolny adres z pamięci operacyjnej.

Możliwe jest zatem przypisanie mu wartości każdego innego wskaźnika (z wyjątkiem wskaźników na stałe). Poprawny jest na przykład taki oto kod:

int nZmienna;
void * pWskaznik = &nZmienna; // &nZmienna jest zasadniczo typu int*

Fakt, że wskaźnik typu void * to tylko sam adres, bez dodatkowych informacji o typie, przeznaczonych dla kompilatora, sprawia, że owe informacje są tracone w momencie przypisania. Wskazywanym w pamięci danym nie dzieje się naturalnie żadna krzywda, jedynie my tracimy możliwość odwoływania się do nich poprzez dereferencję.

Czy przypadkiem czegoś nam to nie przypomina?. W miarę podobna sytuacja miała przecież okazję zainstnieć przy okazji programowania obiektowego i polimorfizmu. Wskaźnik do obiektu klasu pochodnej mogliśmy bowiem przypisać do wskaźnika na obiekt klasy bazowej i używać go potem tak samo, jak każdego innego wskaźnika na obiekt tej klasy.

Tutaj typ void * jest czymś rodzaju "typu bazowego" dla wszystkich innych typów wskaźnikowych. Możliwe jest zatem przypisywanie ich wskaźników zmiennym typu void * . Wówczas tracimy wprawdzie wiedzę o pierwotnym typie wskaźnika, ale zachowujemy to, co najważniejsze: adres przechowywany przez wskaźnik

Przywracanie do stanu używalności

Cały problem z ogólnymi wskaźnikami polega na tym, że przy ich pomocy nie możemy w zasadzie zrobić niczego konkretnego. Dereferencja nie wchodzi w grę z powodu niedostatecznych informacji o typie danych, na które wskaźnik pokazuje. Żeby móc z tych danych skorzystać, musimy więc przekazać kompilatorowi niezbędne informacje o ich typie. Dokonujemy tego poprzez rzutowanie.

Operacja rzutowania wskaźnika typu void * na inny typ wskaźnikowy jest przede wszystkim zabiegiem formalnym. Zarówno przed nią, jak i po niej, mamy bowiem do czynienia z adresem tej samej komórki w pamięci. Jej zawartość jest jednak inaczej interpretowana.

Dokonanie takiego rzutowania nie jest trudne - wystarczy posłużyć się standardowym operatorem static_cast :

// zmienna oraz ogólny wskaźnik, do której zapiszemy jej adres
int nZmienna = 17011987 ;
void * pvoid = &nZmienna;
// ponowne wykorzystanie owego adresu we wskaźniku na typ unsigned
// stosujemy rzutowanie, aby przypisać mu wskaźnik typu void*

unsigned * puLiczba = static_cast < unsigned *>(pvoid);
// wyświetlenie wartości pokazywanej przez wskaźnik
std::cout << *puLiczba; // wynikiem jest wartość zmiennej nZmienna

W powyższym przykładzie wskaźnik typu int * zostaje najpierw zredukowany do void * , by potem poprzez rzutowanie zostać zinterpretowany jako unsigned * . Cały czas pokazuje on oczywiście na to samo miejsce w pamięci, tyle że w toku programu jest ono traktowane na różne sposoby.

Między palcami kompilatora

Chwileczkę! Przecież tą drogą możemy zwyczajnie oszukać kompilator i sprawić, że zacznie on traktować jakiś typ danych jako zupełnie inny, nawet całkowicie niezwiązany z tym oryginalnym!

Istotnie - za pośrednictwem wskaźnika typu void * możliwe jest dosłownie zinterpretowanie ciągu bitów jako dowolnego typu zmiennych. Dzieje się tak dlatego, że podczas rzutowania nie jest dokonywane żadne sprawdzenie faktycznej poprawności typów. static_cast nie działa tak jak dynamic_cast i nie kontroluje sensowności oraz celowości rzutowania.

Zakres stosowalności dynamic_cast jest zaś, jak pamiętamy, ograniczony tylko do typów polimorficznych. Skalarne typy podstawowe z pewnościa nimi nie są, dlatego nie możemy do nich używać tego typu rzutowania.

Potencjalnie więc dostajemy do ręki brzytwę, którą można się nieźle pokaleczyć. W określonych sytuacjach potrzebne jest jednak takie dosłowne potraktowanie pewnego rodzaju danych jako zupełnego innego. Pośrednictwo typu void * w niskopoziomowych konwersjach między wskaźnikami staje się wtedy kłopotliwe.

Z tego powodu (a także z potrzeby całkowitego zastąpienia rzutowania w stylu C) wprowadzono do C++ kolejny operator rzutowania - reinterpret_cast / Potrafi on rzutować dowolny typ wskaźnikowy na dowolny inny typ wskaźnikowy i nie tylko. Konwersje przy użyciu tego operatora prawie zawsze nie są więc bezpieczne i powinny być stosowane wyłącznie wtedy, gdy zależy nam na mechanicznej zmianie (bit po bicie) jednego typu danych w inny.

Jeżeli chodzi o przykłady, to chyba jedynym bezpiecznym zastosowaniem reinterpret_cast jest zapisanie adresu pamięci ze wskaźnika do zwykłej zmiennej liczbowej:

int * pnWskaznik;
unsigned uAdres = reinterpret_cast < unsigned >(pnWskaznik);

W innych przypadkach stosowanie tego operatora powinno być wyjątkowo ostrożne i oszczędne.

Kompletnych informacji o reinterpret_cast dostarcza oczywiście reinterpret_cast_Operator.htm">MSDN . Jest tam także ciekawy artykuł , wyjaśniający dogłębnie różnice między tym operatorem, a zwykłym rzutowaniem static_cast .

Istnieje jeszcze jeden, czwarty operator rzutowania const_cast . Jego zastosowanie jest bardzo wąskie i ogranicza się do usuwania modyfikatora const z opatrzonych nim typów danych. Można więc użyć go, aby zmienić stały wskaźnik lub wskaźnik do stałej w zwykły.
Bliższe informacje na temat tego operatora można naturalnie znaleźć we const_cast_Operator.htm">wiadomym źródle :)

Wskaźniki i tablice

Tradycyjnie wskaźników używa się do operacji na tablicach. Celowo piszę tu 'tradycyjnie', gdyż prawie wszystkie te operacje można wykonać także bez użycia wskaźników, więc korzystanie z nich w C++ nie jest tak popularne jak w jego generacyjnym poprzedniku. Ponieważ jednak czasem będziemy zmuszeni korzystać z kodu wywodzącego się z czasów C (na przykład z Windows API), wiedza o zastosowaniu wskaźników w stosunku do tablic może być przydatna. Obejmuje ona także zagadnienia łańcuchów znaków w stylu C, którym poświęcimy osobny paragraf.

Już słyszę głosy oburzenia: "Przecież miałeś zajmować się nauczaniem C++, a nie wywlekaniem jego różnic w stosunku do swego poprzednika!". Rzeczywiście, to prawda. Wskaźniki są to dziedziną języka, która najczęściej zmusza nas do podróży w przeszłość. Wbrew pozorom nie jest to jednak przeszłość zbyt odległa, skoro z powodzeniem wpływa na teraźniejszość. Z właściwości wskaźników i tablic będziesz bowiem korzystał znacznie częściej niż sporadycznie.

Tablice jednowymiarowe w pamięci

Swego czasu powiedzieliśmy sobie, że tablice są zespołem wielu zmiennych opatrzonych tą samą nazwą i identyfikowanych poprzez indeksy. Symbolicznie przedstawialiśmy na diagramach tablice jednowymiarowe jako równy rząd prostokątów, wyobrażających kolejne elementy.

To nie był wcale przypadek. Tablice takie mają bowiem ważną cechę:

Kolejne elementy tablicy jednowymiarowej są ułożone obok siebie , w ciągłym obszarze pamięci.

Nie są więc porozrzucane po całej dostępnej pamięci (czyli pofragmentowane), ale grzecznie zgrupowane w jeden pakiet.


Schemat 34. Ułożenie tablicy jednowymiarowej w pamięci operacyjnej

Dzięki temu kompilator nie musi sobie przechowywać adresów każdego z elementów tablicy, aby programista mógł się do nich odwoływać. Wystarczy tylko jeden: adres początku tablicy , jej zerowego elementu.

W kodzie można go łatwo uzyskać w ten sposób:

// tablica i wskaźnik
int aTablica[ 5 ];
int * pnTablica;
// pobranie wskaźnika na zerowy element tablicy
pnTablica = &aTablica[ 0 ];

Napisałem, że jest to także adres początku samej tablicy, czyli w gruncie rzeczy wartość kluczowa dla całego agregatu. Dlatego reprezentuje go również nazwa tablicy:

// inny sposób pobrania wskaźnika na zerowy element (początek) tablicy
pnTablica = aTablica;

Wynika stąd, iż:

Nazwa tablicy jest także stałym wskaźnikiem do jej zerowego elementu ( początku ).

Stałym - bo jego adres jest nadany raz na zawsze przez kompilator i nie może być zmieniany w programie.

Wskaźnik w ruchu

Posiadając wskaźnik do jednego z elementów tablicy, możemy z łatwością dostać się do pozostałych - wykorzystując fakt, iż tablica jest ciągłym obszarem pamięci. Można mianowicie odpowiednio przesunąć nasz wskaźnik, np.:

pnTablica += 3 ;

Po tej operacji będzie on pokazywał na 3 elementy dalej niż dotychczas. Ponieważ na początku wskazywał na początek tablicy (zerowy element), więc teraz zacznie odnosić się do jej trzeciego elementu.

To ciekawe zjawisko. Wskaźnik jest przecież adresem, liczbą, zatem dodanie do niego jakiejś liczby powinno skutkować odpowiednim zwiększeniem przechowywanej wartości. Ponieważ kolejne adresy w pamięci są numerami bajtów, więc pnTablica powinien, zdawałoby się, przechowywać adres trzeciego bajta , licząc od początku tablicy.

Tak jednak nie jest, gdyż kompilator podczas dokonywania arytmetyki na wskaźnikach korzysta także z informacji o ich typie . "Skoki" spowodowane dodawaniem liczb całkowitych następują w odstępach bajtowych równych wielokrotnościom rozmiaru zmiennej , na jaką wskazuje wskaźnik. W naszym przypadku pnTablica przesuwa się więc o 3 * sizeof ( int ) bajtów, a nie o 3 bajty!

Obecnie wskazuje zatem na trzeci element tablicy aTablica . Dokonując dereferencji wskaźnika, możemy odwołać się do tego elementu:

// obie poniższe linijki są równoważne
*pnTablica = 0 ;
aTablica[ 3 ] = 0 ;

Wreszcie, dozwolony jest także trzeci sposób:

*(aTablica + 3 ) = 0 ;

Używamy w nim wskaźnikowych właściwości nazwy tablicy. Wyrażenie aTablica + 3 odnosi się zatem do jej trzeciego elementu. Jego dereferencja pozwala przypisać temu elementowi jakąś wartość.

Wydało się więc, że do i -tego elementu tablicy można odwołać się na dwa różne sposoby:

*( tablica + i )
tablica [ i ]

W praktyce kompilator sam stosuje tylko pierwszy. Wprowadzenie drugiego miało oczywiście głęboki sens: jest on zwyczajnie prostszy, nie tylko w zapisie, ale i w zrozumieniu. Nie wymaga też żadnej wiedzy o wskaźnikach, a ponadto daje większą elastyczność przy definiowaniu własnych typów danych.

Nie należy jednak zapominać, że oba sposoby są tak samo podatne na błąd przekroczenia indeksów, który występuje, gdy i wykracza poza przedział < 0 ; rozmiar_tablicy - 1 > .

Tablice wielowymiarowe w pamięci

Dla tablic wielowymiarowych sprawa ich rozmieszczenia w pamięci jest nieco bardziej skomplikowana. W przeciwieństwe do pamięci nie mają one bowiem struktury liniowej, zatem kompilator ją jakoś symulować (czyli linearyzować tablicę).

Nie jest to specjalnie trudna czynność, ale praktyczny sens jej omawiania jest raczej wątpliwy. Z tego względu mało kto stosuje wskaźniki do pracy z wielowymiarowymi tablicami, zaś my nie będziemy tutaj żadnym wyjątkiem od reguły :)

Zainteresowanym mogę wyjaśnić, że wymiary tablicy są układane w pamięci według kolejności ich zadeklarowania w kodzie, od lewej do prawej. Posuwając się wzdłuż takiej zlinearyzowanej tablicy najszybciej zmienia się więc ostatni indeks, wolniej przedostatni, i tak dalej.
Formułka matematyczna służąca do obliczania wskaźnika na element wielowymiarowej tablicy jest natomiast podana w MSDN .

Łańcuchy znaków w stylu C

Kiedy już omawiamy wskaźniki w odniesieniu do tablic, nadarza się niepowtarzalna okazja, aby zapoznać się także z łańcuchami znaków w języku C - poprzedniku C++.

Po co? Otóź jak dotąd jest to najcześciej wykorzystywana forma wymiany tekstu między aplikacjami oraz bibliotekami. Do koronnych przykładów należy choćby Windows API, której obsługi przecież będziemy się w przyszłości uczyć.

Od razu spotka nas tutaj pewna niespodzianka. O ile bowiem C++ posiada wygodny typ std::string , służący do przechowywania napisów, to C w ogóle takiego typu nie posiada! Zwyczajnie nie istnieje żaden specjalny typ danych, służący reprezentacji tekstu.

Zamiast niego stosowanie jest inne podejście do problemu. Napis jest to ciąg znaków , a więc uporządkowany zbiór kodów ANSI, opisujących te znaki. Dla pojedynczego znaku istnieje zaś typ char , zatem ich ciąg może być przedstawiany jako odpowiednia tablica .

Łańcuch znaków w stylu C to jednowymiarowa tablica elementów typu char .

Różni się ona jednak on innych tablic. Są one przeznaczone głównie do pracy nad ich pojedynczymi elementami, natomiast łańcuch znaków jest częściej przetwarzany w całości, niż znak po znaku.

Sprawia to, że dozwolone są na przykład takie (w gruncie rzeczy trywialne!) operacje:

char szNapis[ 256 ] = "To jest jakiś tekst" ;

Manipulujemy w nich więcej niż jednym elementem tablicy naraz.

Zauważmy jeszcze, że przypisywany ciąg jest krótszy niż rozmiar tablicy ( 256 ). Aby zaznaczyć, gdzie się on kończy, kompilator dodaje zawsze jeszcze jeden, specjalny znak o kodzie 0, na samym końcu napisu. Z powodu tej właściwości łańcuchy znaków w stylu C są często nazywane napisami zakończonymi zerem (ang. null-terminated strings ).

Dlaczego jednak ten sposób postępowania z tekstem jest zły (został przecież zastąpiony przez typ std::string )?.

Pierwszą przyczyną są problemy ze zmienną długością napisów. Tekst jest kłopotliwym rodzajem danych, który może zajmować bardzo różną ilość pamięci, zależnie od liczby znaków. Rozsądnym rozwiązaniem jest oczywiście przydzielanie mu dokładnie tylu bajtów, ilu wymaga; do tego potrzebujemy jednak mechanizmów zarządzania pamięcią w czasie działania programu (poznamy je zresztą w tym rozdziale). Można też statycznie rezerwować więcej miejsca, niż to jest potrzebne - tak zrobiłem choćby w poprzednim skrawku przykładowego kodu. Wada tego rozwiązania jest oczywista: spora część pamięci zwyczajnie się marnuje.

Drugą niedogodnością są utrudnienia w dokonywaniu najprostszych w zasadzie operacji na tak potraktowanych napisach. Chodzi tu na przykład o konkatenację; wiedząc, jak proste jest to dla napisów typu std::string , pewnie bez wahania napisalibyśmy coś w tym rodzaju:

char szImie[] = "Max" ;
char szNazwisko[] = "Planck" ;
char szImieINazwisko[] = szImie + " " + szNazwisko; // BŁĄD!
Visual C++ zareagowałby zaś takim oto błędem:
error C2110: '+': cannot add two pointers

Miałby w nim całkowitą słuszność. Rzeczywiście, próbujemy tutaj dodać do siebie dwa wskaźniki, co jest niedozwolne i pozbawione sensu. Gdzie są jednak te wskaźniki?.

To przede wszystkim szImie i szNazwisko - jako nazwy tablic są przecież wskaźnikami do swych zerowych elementów. Również spacja " " jest przez kompilator traktowana jako wskaźnik, podobnie zresztą jak wszystkie napisy wpisane w kodzie explicité .

Porównywanie takich napisów poprzez operator == jest więc niepoprawne !

Łączenie napisów w stulu C jest naturalnie możliwe, wymaga jednak użycia specjalnych funkcji w rodzaju strcat() . Inne funkcje są przeznaczone choćby do przypisywania napisów ( str [ n ] cpy() ) czy pobierania ich długości ( strlen() ). Nietrudno się domyśleć, że korzystanie z nich nie należy do rzeczy przyjemnych :)

Na całe szczęście ominie nas ta "rozkosz". Standardowy typ std::string zawiera bowiem wszystko, co jest niezbędne do programowej obsługi łańcuchów znaków. Co więcej, zapewnia on także kompatybilnośc z dawnymi rozwiązaniami.

Metoda c_str() (skrót od C string ), bo o nią tutaj chodzi, zwraca wskaźnik typu const char * , którego można użyć wszędzie tam, gdzie wymagany jest napis w stylu C. Nie musimy przy tym martwić się o późniejsze zwolnienie zajmowanej przez nasz tekst pamięci - zadba oto sama Biblioteka Standardowa.

Przykładem wykorzystania tego rozwiązania może być wyświetlenie okna komunikatu przy pomocy funkcji MessageBox() z Windows API:

#include <string>
#include <windows.h>
std::string strKomunikat = "Przykładowy komunikat" ;
strKomunikat += "." ;
MessageBox (NULL, strKomunikat.c_str(), "Komunikat" , MB_OK);

 

O samej funkcji MessageBox() powiemy sobie wszystko, gdy już przejdziemy do programowania aplikacji okienkowych. Powyższy kod zadziała jednak także w programie konsolowym.

Drugi oraz trzeci parametr tej funkcji powinien być łańcuchem znaków w stylu C. Możemy więc skorzystać z metody c_str() dla zmiennej strKomunikat , by uczynić zadość temu wymaganiu. W sumie więc nie przeszkadza ono zupełnie w normalnym korzystaniu z dobrodziejstw standardowego typu std::string .

Przekazywanie wskaźników do funkcji

Jedną z ważniejszych płaszczyzn zastosowań wskaźników jest usprawnienie korzystania z funkcji. Wskaźniki umożliwiają osiągnięcie kilku niespotykanych dotąd możliwości i optymalizacji.

Dane otrzymywane poprzez parametry

Wskaźnik jest odwołaniem do zmiennej ("kluczem" do niej), które ma jedną zasadniczą zaletę: może mianowicie być przekazywane gdziekolwiek i nadal zachowywać swoją podstawową rolę. Niezależnie od tego, w którym miejscu programu użyjemy wskaźnika, będzie on nadal wskazywał na ten sam adres w pamięci, czyli na tą samą zmienną.

Jeżeli więc przekażemy wskaźnik do funkcji, wtedy będzie ona mogła operować na jego docelowej komórce pamięci. W ten sposób możemy na przykład sprawić, aby funkcja zwracała więcej niż jedną wartość w wyniku swego działania.

Spójrzmy na prosty przykład takiego zachowania:

// funkcja oblicza całkowity iloraz dwóch liczb oraz jego resztę
int Podziel( int nDzielna, int nDzielnik, int * const pnReszta)
{
// zapisujemy resztę w miejscu pamięci, na które pokazuje wskaźnik
*pnReszta = nDzielna % nDzielnik;
// zwracamy iloraz
return nDzielna / nDzielnik;
}

Ta prosta funkcja dzielenia całkowitego zwraca dwa rezultaty. Pierwszy to zasadniczy iloraz - jest on oddawany w tradycyjny sposób poprzez return . Natomiast reszta z dzielenia jest przekazywana poprzez stały wskaźnik pReszta , który funkcja otrzymuje jako parametr. Dokonuje jego dereferencji i zapisuje żądaną wartość w miejscu, na które on wskazuje.

Jeżeli pamiętamy o tym, skorzystanie z powyższej funkcji jest raczej proste i przedstawia się mniej więcej tak:

// Division - dzielenie przy użyciu wskaźnika przekazywanego do funkcji
void main()
{
// (pominiemy pobranie dzielnej i dzielnika od użytkownika)
// obliczenie rezultatu

int nIloraz, nReszta;
nIloraz = Podziel(nDzielna, nDzielnik, &nReszta);
// wyświetlenie rezultatu
std::cout << std::endl;
std::cout << nDzielna << " / " <<nDzielnik << " = "
<< nIloraz << " r " << nReszta;
getch();
}

Jako trzeci parametr w wywołaniu funkcji Podziel() :

nIloraz = Podziel(nDzielna, nDzielnik, &nReszta);

przekazujemy adres zmiennej (uzyskany oczywiście poprzez operator & ). W niej też znajdziemy potem żądaną resztę i wyświetlimy ją w oknie konsoli:


Screen 37. Dwie wartości zwracane przez jedną funkcję

W podobny sposób działa wiele funkcji z Windows API czy DirectX. Zaletą tego rozwiązania jest także możliwość oddzielenia zasadniczego wyniku funkcji (zwracanego przez wskaźnik) od ewentualnej informacji o błędzie czy też sukcesie jego uzyskania (przekazywanego w tradycyjny sposób).

Oczywiście nic nie stoi na przeszkodzie, aby tą drogą zwracać więcej niż jeden "dodatkowy" rezultat funkcji. Jeśli jednak ich liczba jest znaczna, lepiej złączyć je w strukturę niż deklarować po kilkanaście parametrów w nagłówku funkcji.

Zapobiegamy niepotrzebnemu kopiowaniu

Oprócz otrzymywania kilku wyników z jednej funkcji, zastosowanie wskaźników może mieć też podłoże optymalizacyjne. Pomyślmy, że taki wskaźnik to zawsze jest tylko zwykła liczba całkowita, zajmująca zaledwie 4 bajty w pamięci. Jednocześnie jednak może ona odnosić się do bardzo wielkich obiektów.

Kiedy zaś wywołujemy funkcję z parametrami, wówczas kompilator dokonuje ich całościowego kopiowania - tak, że w ciele funkcji mamy do czynienia z duplikatami rzeczywistych parametrów aktualnych funkcji. Mówiliśmy zresztą we właściwym czasie, iż parametry pełnią w funkcji rolę dodatkowych zmiennych lokalnych .

Aby to zilustrować, weźmy taką oto banalną funkcję:

int Dodaj( int nA, int nB)
{
nA += nB;
return nA;
}

Jak widać, dokonujemy w niej modyfikacji jednego z parametrów. Kiedy jednak wywołamy niniejszą funkcję w sposób podobny do tego:

int nLiczba1 = 1 , nLiczba2 = 2 ;
std::cout << Dodaj(nLiczba1, nLiczba2);
std::cout << nLiczba1; // nadal nLiczba1 == 1 !

zobaczymy, że podana jej zmienna pozostaje nietknięta . Funkcja otrzymała bowiem tylko jej wartość, która została w tym celu skopiowana .

Trzeba jednak przyznać, że większość funkcji z założenia nie modyfikuje swoich parametrów, a jedynie odczytuje z nich wartości. W takim przypadku jest im więc "wszystko jedno", czy odwołują się do faktycznie istniejących zmiennych, czy też do ich kopii, istniejących tylko podczas działania funkcji.

Jednak nam, programistom, nie jest wszystko jedno. Stworzenie kopii zmiennych wymaga bowiem dodatkowego czasu - na przydzielenie odpowiedniej ilości pamięci i zapisanie w niej pożądanej wartości. Naturalnie, w przypadku typów liczbowych jest to pomijalnie mały interwał, ale dla większych obiektów (chociażby łańcuchów znaków) może stać się znaczący. A przecież wcale nie musi tak być!

Możliwe jest zlikwidowanie konieczności tworzenia duplikatów zmiennych dla wywoływanych funkcji: wystarczy tylko zamiast wartości przekazywać odwołania do nich, czyli. wskaźniki! Skopiowanie czterech bajtów będzie na pewno znacznie szybsze niż przemieszczanie ilości danych liczonej na przykład w dziesiątkach kilobajtów.

Zobaczmy więc, jak można przyspieszyć działanie funkcji operujących na dużych obiektach. Posłużę się tu przykładem na wyszukiwanie pozycji jednego ciągu znaków wewnątrz innego:

#include <string>
// funkcja przeszukuje drugi napis w poszukiwaniu pierwszego;
// gdy go znajdzie, zwraca indeks pierwszego pasującego znaku,
// w przeciwnym wypadku wartość -1

int Wyszukaj ( const std::string* pstrSzukany,
const std::string* pstrPrzeszukiwany)
{
// przeszukujemy nasz napis
for ( unsigned i = 0 ;
i <= pstrPrzeszukiwany->length() - pstrSzukany->length(); ++i)
{
// porównujemy kolejne wycinki napisu (o odpowiedniej długości)
// z poszukiwanym łańcuchem. Metoda std::string::substr() służy
// do pobierania wycinka napisu

if (pstrPrzeszukiwany->substr(i, pstrSzukany->length())
== *pstrSzukany)
// jeżeli wycinek zgadza się, to zwracamy jego indeks
return i;
}
// w razie niepowodzenia zwracamy -1
return -1 ;
}

Przeszukiwany tekst może być bardzo długi - edytory pozwalają na przykład na poszukiwanie wybranej frazy wewnątrz całego dokumentu, liczącego nieraz wiele kilobajtów. Nie jest to jednak problemem: dzięki temu, że funkcja operuje na nim poprzez wskaźnik, pozostaje on cały czas "na swoim miejscu" w pamięci i nie jest kopiowany . Zysk na wydajność aplikacji może być wtedy znaczny.

W zamian jednakże doświadczamy pewnej niedogodności, związanej ze składnią działań na wskaźnikach. Aby odwołać się do przekazanego napisu, musimy każdorazowo dokonywać jego dereferencji; także wywoływanie metod wymaga innego operatora niż kropka, do której przyzwyczailiśmy się, operując na napisach.

Ale i na to jest rada. Na koniec podrozdziału poznamy bowiem referencje, które zachowują cechy wskaźników przy jednoczesnym umożliwieniu stosowania zwykłej składni, właściwej zmiennym.

Dynamiczna alokacja pamięci

Kto wie, czy nie najważniejszym polem do popisu dla wskaźników jest zawłaszczanie nowej pamięci w trakcie działania programu. Mechanizm ten daje nieosiągalną inaczej elastyczność aplikacji i pozwala manipulować danymi o zmiennej wielkości . Bez niego wszystkie programy miałyby z góry narzuczone limity na ilość przetwarzanych informacji, których nijak nie możnaby przekroczyć.

Koniecznie więc musimy przyjrzeć się temu zjawisku.

Przydzielanie pamięci dla zmiennych

Wszystkie zmienne deklarowane w kodzie mają statycznie przydzieloną pamięć o stałym rozmiarze. Rezydują one w obszarze pamięci zwanym stosem , który również ma niezmienną wielkość. Stosując wyłącznie takie zmienne, nie możemy więc przetwarzać danych cechujących się dużą rozpiętością zajmowanego miejsca w pamięci.

Oprócz stosu istnieje wszak także sterta . Jest to reszta pamięci operacyjnej, niewykorzystana przez program w momencie jego uruchomienia, ale stanowiąca rezerwę na przyszłość. Aplikacja może zeń czerpać potrzebną w danej chwili ilość pamięci (nazywamy to alokacją ), wypełniać własnymi danymi i pracować na nich, a po zakończeniu roboty zwyczajnie oddać ją z powrotem ( zwolnić ) do wspólnej puli.

Najważniejsze, że o ilości niezbędnego miejsca można zdecydować w trakcie działania programu , np. obliczyć ją na podstawie liczb pobranych od użytkownika czy też z jakiegokolwiek innego źródła. Nie jesteśmy więc skazani na stały rozmiar stosu, lecz możemy dynamicznie przydzielać sobie ze sterty tyle pamięci, ile akurat potrzebujemy. Zbiory informacji o niestałej wielkości stają się wtedy możliwe do opanowania.

Alokacja przy pomocy new

Całe to dobrodziejstwo jest ściśle związane z wskaźnikami, gdyż to właśnie za ich pomocą uzyskujemy nową pamięć, odwołujemy się do niej i wreszcie zwalniamy ją po skończonej pracy.

Wszystkie te czynności prześledzimy na prostym przykładzie. Weźmy więc sobie zwyczajny wskaźnik na typ int :

int * pnLiczba;

Chwilowo nie pokazuje on na żadne sensowne dane. Moglibyśmy oczywiście złączyć go z jakąś zmienną zadeklarowaną w kodzie (poprzez operator & ), lecz nie o to nam teraz chodzi. Chcemy sobie sami takową zmienną stworzyć - używamy do tego operatora new ('nowy') oraz nazwy typu tworzonej zmiennej:

pnLiczba = new int ;

Wynikiem działania tego operatora jest adres , pod którym widnieje w pamięci nasza świeżo stworzona, nowiutka zmienna. Umieszczamy go zatem w przygotowanym wskaźniku - odtąd będzie on służył nam do manipulowania wykreowaną zmienną.

Cóż takiego różni ją innych, deklarowanych w kodzie? Ano całkiem sporo rzeczy:

  • nie ma ona nazwy , poprzez którą moglibyśmy się do niej odwływać. Wszelka "komunikacja" z nią musi zatem odbywać się za pośrednictwem wskaźnika, w którym zapisaliśmy adres zmiennej.

  • czasu istnienia zmiennej nie kontroluje kompilator, ale sam programista. Inaczej mówiąc, nasza zmienna istnieje aż do momentu jej zwolnienia (poprzez operator delete , który omówimy za chwilę). Wynika stąd również, że dla takiej zmiennej nie ma sensu pojęcie zasięgu.

  • początkowa wartość zmiennej jest przypadkowa. Zależy bowiem od tego, co poprzednio znajdowało się w tym miejscu pamięci, które teraz system operacyjny oddał do dyspozycji naszego programu.

Poza tymi aspektami, możemy na tak stworzonej zmiennej wykonywać te same operacje, co na wszystkich innych zmiennych tego typu. Dereferując pokazujący nań wskaźnik, otrzymujemy pełen dostęp do niej:

*pnLiczba = 100 ;
*pnLiczba += rand();
std::cout << *pnLiczba;
// itp.

Oczywiście nasze możliwości nie ograniczają się tylko do typów liczbowych czy podstawowych. Przeciwnie, za pomocą new możemy alokować pamięć dla dowolnych rodzajów zmiennych - także tych definiowanych przez nas samych.

Widzimy więc, że to bardzo potężne narzędzie.

Zwalnianie pamięci przy pomocy delete

Z każdej potęgi trzeba jednak korzystać z rozwagą. W przypadku dynamicznej alokacji zasada BHP brzmi:

Zawsze zwalniaj zaalokowaną przez siebie pamięć.

Służy do tego odrębny operator delete ('usuń'). Użycie go jest nadzwyczaj łatwe: wystarczy jedynie podać mu wskaźnik na przydzielony obszar pamięci, a on posłusznie posprząta po nim i zwróci go do dyspozycji systemu operacyjnego, a więc i wszystkich pozostałych programów.

Bez zwolnienia pamięci operacyjnej następuje jej wyciek (ang. memory leak ). Zaalokowana, a niezwolniona pamięć nie jest już bowiem dostępna dla innych aplikacji.

Po skończeniu pracy z naszą dynamicznie stworzoną zmienną musimy ją zatem usunąć. Wygląda to następująco:

delete pnLiczba;

Należy mieć świadomość, że delete niczego nie modyfikuje w samym wskaźniku, zatem nadal pokazuje on na ten sam obszar pamięci. Teraz jednak nasz program nie jest już jego właścicielem, dlatego też aby uniknąć omyłkowego odwołania się do nieswojego rejonu pamięci, wypadałoby wyzerować nasz wskaźnik:

pnLiczba = NULL;

Wartość NULL to po prostu zero , zaś zerowy adres nie istnieje. pnLiczba staje się więc wskaźnikiem pustym , niepokazującym na żadną konkretną komórkę pamięci. Gdybyśmy teraz (omyłkowo) spróbowali ponownie zastosować wobec niego operator delete , wtedy instrukcja ta zostałaby po prostu zignorowana. Jeżeli jednak wskaźnik nadal pokazywałby na już zwolniony obszar pamięci, wówczas bez wątpienia wystąpiłby błąd ochrony pamięci (ang. access violation ).

Zatem pamiętaj, aby dla bezpieczeństwa zerować wskaźnik po zwolnieniu dynamicznej zmiennej , na którą on wskazywał.

Nowe jest lepsze

Jeżeli czytają to jakieś osoby znające język C (w co wątpie, ale wyjątki zawsze się zdarzają :D), to pewnie nie darowałyby mi, gdybym nie wspomniał o sposobach na alokację i zwalnianie pamięci w tym języku. Chcą zapewne wiedzieć, dlaczego powinny o nich zapomnieć (a powinny!) i stosować wyłącznie new oraz delete .

Otóż w C mieliśmy dwie funkcje, malloc() i free() , służące odpowiednio do przydzielania obszaru pamięci o żądanej wielkości oraz do jego późniejszego zwalniania. Radziły sobie z tym zadaniem całkiem dobrze i mogłyby w zasadzie nadal sobie z nim radzić. W C++ doszły jednak nowe zadania związane z dynamiczną alokacją pamięci operacyjnej.

Chodzi tu naturalnie o kwestię klas z programowania obiektowego i związanymi z nimi konstruktorami i destruktorami . Kiedy używamy new i delete do tworzenia i niszczenia obiektów, w poprawny sposób wywołują one te specjalne metody. Funkcje znane z C nie robią tego ; nie ma w tym jednak niczego dziwnego, bo w ich macierzystym języku w ogóle nie istniało pojęcie klasy czy obiektu, nie mówiąc już o metodach uruchamianych podczas ich tworzenia i niszczenia.

" Nowy" sposób alokacji ma jeszcze jedną zaletę. Otóż malloc() zwraca w wyniku wskaźnik ogólny, typu void * , zamiast wskaźnika na określony typ danych. Aby przypisać go do wybranej zmiennej wskaźnikowej, należało użyć rzutowania.

Przy korzystaniu z new nie jest to konieczne. Za pomocą tego operatora od razu uzyskujemy właściwy typ wskaźnika i nie musimy stosować żadnych konwersji.

Dynamiczne tablice

Alokacja pamięci dla pojedynczej zmiennej jest wprawdzie poprawna i klarowna, ale raczej mało efektowna. Trudno wówczas powiedzieć, że faktycznie operujemy na zbiorze danych o niejednostajnej wielkości, skoro owa niestałość objawia się jedynie. obecnością lub nieobecnością jednej zmiennej!

O wiele bardziej interesują są dynamiczne tablice - takie, których rozmiar jest ustalany w czasie działania aplikacji. Mogą one przechowywać różną ilość elementów, więc nadają się do mnóstwa wspaniałych celów :)

Zobaczymy teraz, jak obsługiwać takie tablice.

Tablice jednowymiarowe

Najprościej sprawa wygląda z takimi tablicami, których elementy są indeksowane jedną liczbą, czyli po prostu z tablicami jednowymiarowymi. Popatrzmy zatem, jak odbywa się ich alokacja i zwalnianie.

Tradycyjnie już zaczynamy od odpowiedniego wskaźnika. Jego typ będzie determinował rodzaj danych, jakie możemy przechowywać w naszej tablicy:

float * pfTablica;

Alokacja pamięci dla niej także przebiega w dziwnie znajomy sposób. Jedyną różnicą w stosunku do poprzedniego paragrafu jest oczywista konieczność podania wielkości tablicy :

pfTablica = new float [ 1024 ];

Podajemy ją w nawiasach klamrowych, za nazwą typu pojedynczego elementu. Z powodu obecności tych nawiasów, występujący tutaj operator jest często określony jako new [] . Ma to szczególny sens, jeżeli porównamy go z operatorem zwalniania tablicy, który zobaczymy za momencik.

Zważmy jeszcze, że rozmiar naszej tablicy jest dosyć spory. Być może wobec dzisiejszych pojemności RAMu brzmi to zabawnie, ale zawsze przecież istnieje potencjalna możliwość, że zabraknie dla nas tego życiodajnego zasobu, jakim jest pamięć operacyjna. I na takie sytuacje powinniśmy być przygotowani - tym bardziej, że poczynienie odpowiednich kroków nie jest trudne.

W przypadku braku pamięci operator new zwróci nam pusty wskaźnik ; jak pamiętamy, nie odnosi się on do żadnej komórki, więc może być użyty jako wartość kontrolna (spotkaliśmy się już z tym przy okazji rzutowania dynamic_cast ). Wypadałoby zatem sprawdzić, czy nie natrafiliśmy na taką nieprzyjemną sytuację i zareagować na nią odpowiednio:

if (pfTablica == NULL) // może być też if (!pfTablica)
std::cout << "Niestety, zabraklo pamieci!" ;

 

Możemy zmienić to zachowanie i sprawić, żeby w razie niepowodzenia alokacji pamięci była wywoływana nasza własna funkcja. Po szczegóły możesz zajrzeć do new_Setnewhandler.htm">opisu funkcji set_new_handler() w MSDN.

Jeżeli jednak wszystko poszło dobrze - a tak chyba będzie najczęściej :) - możemy używać naszej tablicy w identyczny sposób , jak tych alokowanych statycznie. Powiedzmy, że wypełnimy ją treścią przy pomocy następującej pętli:

for ( unsigned i = 0 ; i < 1024 ; ++i)
pfTablica[i] = i * 0.01 ;

Widać, że dostęp do poszczególnych elementów odbywa się tutaj tak samo, jak dla tablic o stałym rozmiarze. A właściwie, żeby być ścisłym, to raczej tablice o stałym rozmiarze zachowują się podobnie, gdyż w obu przypadkach mamy do czynienia z jednym i tym samym mechanizmem - wskaźnikami.

Należy jeszcze pamiętać, aby zachować gdzieś rozmiar alokowanej tablicy, żeby móc na przykład przetwarzać ją przy pomocy pętli for , podobnej do powyższej.

Na koniec trzeba oczywiście zwolnić pamięć, która przeznaczyliśmy na tablicę. Za jej usunięcie odpowiada operator delete [] :

delete [] pfTablica;

Musimy koniecznie uważać, aby nie pomylić go z podobnym operatorem delete . Tamten służy do zwalniania wyłącznie pojedyncznych zmiennych , zaś jedynie niniejszy może być użyty do usunięcia tablicy. Nierespektowanie tej reguły może prowadzić do bardzo nieprzyjemnych błędów!

Zatem do zwalniania tablic korzystaj tylko z operatora delete [] !

Łatwo zapamiętać tę zasadę, jeżeli przypomnimy sobie, iż do alokowania tablicy posłużyła nam instrukcja new [] . Jej usunięcie musi więc również odbywać się przy pomocy operatora z nawiasami kwadratowymi.

Opakowanie w klasę

Jeśli często korzystamy z dynamicznych tablic, warto stworzyć dlań odpowiednią klasę, która ułatwi nam to zadanie. Nie jest to specjalnie trudne.

My stworzymy tutaj przykładową klasę jednowymiarowej tablicy elementów typu int .

Zacznijmy może od jej prywatnych pól. Oprócz oczywistego wskaźnika na wewnętrzną tablicę klasa powinna być wyposażona także w zmienną, w której zapamiętamy rozmiar utworzonej tablicy. Uwolnimy wtedy użytkownika od konieczności zapisywania jej we własnym zakresie.

Metody muszą zapewnić dostęp do elementów tablicy, a więc pobieranie wartości o określonym indeksie oraz zapisywanie nowych liczb w określonych elementach tablicy. Przy okazji możemy też kontrolować indeksy i zapobiegać ich przekroczeniu, co znowu zapewni nam dozgonną wdzięczność programisty-klienta naszej klasy ;)

Definicja takiej tablicy może więc przedstawiać się następująco:

class CIntArray
{
// domyślny rozmiar tablicy
static const unsigned DOMYSLNY_ROZMIAR = 5 ;
<font color="#0000CC"><font color="#0000CC">private</font></font> :
// wskaźnik na właściwą tablicę oraz jej rozmiar
int * m_pnTablica;
unsigned m_uRozmiar;
public :
// konstruktory
CIntArray() // domyślny
{ m_uRozmiar = DOMYSLNY_ROZMIAR;
m_pnTablica = new int [m_uRozmiar]; }
CIntArray( unsigned uRozmiar) // z podaniem rozmiaru tablicy
{ m_uRozmiar = uRozmiar;
m_pnTablica = new int [m_uRozmiar]; }
// destruktor
~CIntArray() { delete [] m_pnTablica; }
//
// pobieranie i ustawianie elementów tablicy

int Pobierz( unsigned uIndeks) const
{ if (uIndeks < m_uRozmiar) return m_pnTablica[uIndeks];
else return 0 ; }
bool Ustaw( unsigned uIndeks, int nWartosc)
{ if (uIndeks >= m_uRozmiar) return false ;
m_pnTablica[uIndeks] = uWartosc;
return true ; }
// inne
unsigned Rozmiar() const { return m_uRozmiar; }
};

Są w niej wszystkie detale, o jakich wspomniałem wcześniej.

Dwa konstruktory mają na celu zaalokowanie pamięci na naszą tablicę; jeden z nich jest domyślny i ustawia określoną z góry wielkość (wpisaną jako stała DOMYSLNY_ROZMIAR ), drugi zaś pozwala podać ją jako parametr. Destruktor natomiast dba o zwolnienie tak przydzielonej pamięci. W tego typu klasach metoda ta jest więc szczególnie przydatna.

Pozostałe funkcje składowe zapewniają intuicyjny dostęp do elementów tablicy, zabezpieczając przy okazji przed błędem przekroczenia indeksów. W takiej sytuacji Pobierz() zwraca wartość zero, zaś Ustaw() - false , informując o zainstniałym niepowodzeniu.

Skorzystanie z tej gotowej klasy nie jest chyba trudne, gdyż jej definicja niemal dokumentuje się sama. Popatrzmy aczkolwiek na następujący przykład:

#include <cstdlib>
#include <ctime>
srand ( static_cast < unsigned >(time(NULL)));
CIntArray aTablica(rand());
for ( unsigned i = 0 ; i < aTablica.Rozmiar(); ++i)
aTablica.Ustaw (i, rand());

Jak widać, generujemy w nim losową ilość losowych liczb :) Nieodmiennie też używamy do tego pętli for , nieodzownej przy pracy z tablicami.

Zdefiniowana przed momentem klasa jest więc całkiem przydatna, posiada jednak trzy zasadnicze wady:

  • raz ustalony rozmiar tablicy nie może już ulegać zmianie. Jego modyfikacja wymaga stworzenia nowej tablicy

  • dostęp do poszczególnych elementów odbywa się za pomocą mało wygodnych metod zamiast zwyczajowych nawiasów kwadratowych

  • typem przechowywanych elementów może być jedynie int

Na dwa ostatnie mankamenty znajdziemy radę, gdy już nauczymy się przeciążać operatory oraz korzystać z szablonów klas w języku C++.

Niemożność zmiany rozmiaru tablicy możemy jednak usunąć już teraz. Dodajmy więc jeszcze jedną metodę za to odpowiedzialną:

class CIntArray
{
// (resztę wycięto)
public :
bool ZmienRozmiar( unsigned );
};

Wykona ona alokację nowego obszaru pamięci i przekopiuje do niego już istniejącą część tablicy. Następne zwolni ją, zaś cała klasa będzie odtąd operowała na nowym fragmencie pamięci.

Brzmi to dosyć tajemniczo, ale w gruncie rzeczy jest bardzo proste:

#include <memory.h>
bool CIntArray::ZmienRozmiar( unsigned uNowyRozmiar)
{
// sprawdzamy, czy nowy rozmiar jest większy od starego
if (!(uNowyRozmiar > m_uRozmiar)) return false ;
// alokujemy nową tablicę
int * pnNowaTablica = new int [uNowyRozmiar];
// kopiujemy doń starą tablicę i zwalniamy ją
memcpy (pnNowaTablica, m_pnTablica, m_uRozmiar * sizeof ( int ));
delete [] m_pnTablica;
// "podczepiamy" nową tablicę do klasy i zapamiętujemy jej rozmiar
m_pnTablica = pnNowaTablica;
m_uRozmiar = uNowyRozmiar;
// zwracamy pozytywny rezultat
return true ;
}

Wyjaśnienia wymaga chyba tylko funkcja memcpy() . Oto jej prototyp (zawarty w nagłówku memory.h , który dołączamy):

void * memcpy( void * dest, const void * src, size_t count);

Zgodnie z nazwą (ang. memory copy - kopiuj pamięć), funkcja ta służy do kopiowania danych z jednego obszaru pamięci do drugiego. Podajemy jej miejsce docelowe i źródłowe kopiowania oraz ilość bajtów , jaka ma być powielona.

Właśnie ze względu na bajtowe wymagania funkcji memcpy() używamy operatora sizeof , by pobrać wielkość typu int i pomnożyć go przez rozmiar (liczbę elementów) naszej tablicy. W ten sposób otrzymamy wielkość zajmowanego przez nią rejonu pamięci w bajtach i możemy go przekazać jako trzeci parametr dla funkcji kopiującej.

Pełna dokumentacja funkcji memcpy() jest oczywiście dostępna w MSDN .

Po rozszerzeniu nowa tablica będzie zawierała wszystkie elementy pochodzące ze starej oraz nowy obszar, możliwy do natychmiastowego wykorzystania.

Tablice wielowymiarowe

Uelastycznienie wielkości jest w C++ możliwe także dla tablic o większej liczbie wymiarów. Jak to zwykle w tym języku bywa, wszystko odbywa się analogicznie i intuicyjnie :D

Przypomnijmy, że tablice wielowymiarowe to takie tablice, których elementami są. inne tablice. Wiedząc zaś, iż mechanizm tablic jest w C++ zarządzany poprzez wskaźniki, dochodzimy do wniosku, że:

Dynamiczna tablica n -wymiarowa składa się ze wskaźników do tablic ( n-1 )-wymiarowych.

Dla przykładu, tablica o dwóch wymiarach jest tak naprawdę jednowymiarowym wektorem wskaźników, z których każdy pokazuje dopiero na jednowymiarową tablicę właściwych elementów.

Aby więc obsługiwać taką tablicę, musimy użyć dość osobliwej konstrukcji programistycznej - wskaźnika na wskaźnik . Nie jest to jednak takie dziwne. Wskaźnik to przecież też zmienna, a więc rezyduje pod jakimś adresem w pamięci. Ten adres może być przechowywany przez kolejny wskaźnik.

Deklaracja czegoś takiego nie jest trudna:

int ** ppnTablica;

Wystarczy dodać po prostu kolejną gwiazdkę do nazwy typu, na który ostatecznie pokazuje nasz wskaźnik.

Jak taki wskaźnik ma się do dynamicznych, dwuwymiarowych tablic?. Ilustrując nim opis podany wcześniej, otrzymamy schemat podobny do tego:


Schemat 35. Dynamiczna tablica dwuwymiarowa jest tablicą wskaźników do tablic jednowymiarowych

Skoro więc wiemy już, do czego zmierzamy, pora osiągnąć cel.

Alokacja dwywumiarowej tablicy musi odbywać się dwuetapowo: najpierw przygotowujemy pamięć pod tablicę wskaźników do jej wierszy . Potem natomiast przydzielamy pamięć każdemu z tych wierszy - tak, że w sumie otrzymujemy tyle elementów, ile chcieliśmy.

Po przełożeniu na kod C++ algorytm wygląda w ten sposób:

// Alokacja tablicy 3 na 4
// najpierw tworzymy tablicę wskaźników do kolejnych wierszy
ppnTablica = new int * [ 3 ];
// następnie alokujemy te wiersze
for ( unsigned i = 0 ; i < 3 ; ++i)
ppnTablica[i] = new int [ 4 ];

Przeanalizuj go dokładnie. Zwróć uwagę szczególnie na linijkę:

ppnTablica[i] = new int [ 4 ];

Za pomocą wyrażenia ppnTablica[i] odwołujemy się tu do i -tego wiersza naszej tablicy - a ściślej mówiąc, do wskaźnika na niego. Przydzielamy mu następnie adres zaalokowanego fragmentu pamięci, który będzie pełnił rolę owego wiersza. Robimy tak po kolei ze wszystkimi wierszami tablicy.

Użytkowanie tak stworzonej tablicy dwuwymiarowej nie powinno nastręczać trudności. Odbywa się ono bowiem identycznie, jak w przypadku statycznych macierzy. Najczęstszą konstrukcją jest tu znowu zagnieżdżona pętla for :

for ( unsigned i = 0 ; i < 3 ; ++i)
for ( unsigned j = 0 ; j < 4 ; ++j)
ppnTablica[i][j] = i - j;

Co zaś ze zwalnianiem tablicy? Otóż przeprowadzamy je w sposób dokładnie przeciwny do jej alokacji. Zaczynamy od uwolnienia poszczególnych wierszy, a następnie pozbywamy się także samej tablicy wskaźników do nich.

Wygląda to mniej więcej tak:

// zwalniamy wiersze
for ( unsigned i = 0 ; i < 3 ; ++i)
delete [] ppnTablica[i];
// zwalniamy tablicę wskaźników do nich
delete [] ppnTablica;

Przedstawioną tu kolejność należy zawsze bezwględnie zachowywać . Gdybyśmy bowiem najpierw pozbyli się wskaźników do wierszy tablicy, wtedy nijak nie moglibyśmy zwolnić samych wierszy! Usuwanie tablicy "od tyłu" chroni zaś przed taką ewentualnością.

Znając technikę alokacji tablicy dwuwymiarowej, możemy łatwo rozszerzyć ją na większą liczbę wymiarów. Popatrzmy tylko na kod odpowiedni dla trójwymiarowej tablicy:

/* Dynamiczna tablica trójwymiarowa, 5 na 6 na 7 elementów */
// wskaźnik do niej ("trzeciego stopnia"!)
int *** p3nTablica;
/* alokacja */
// tworzymy tablicę wskaźników do 5 kolejnych "płaszczyzn" tablicy
p3nTablica = new int ** [ 5 ];
// przydzielamy dla nich pamięć
for ( unsigned i = 0 ; i < 5 ; ++i)
{
// alokujemy tablicę na wskaźniki do wierszy
p3nTablica[i] = new int * [ 6 ];
// wreszcie, dla przydzielamy pamięć dla właściwych elementów
for ( unsigned j = 0 ; j < 6 ; ++j)
p3nTablica[i][j] = new int [ 7 ];
}
/* użycie */
// wypełniamy tabelkę jakąś treścią
for ( unsigned i = 0 ; i < 5 ; ++i)
for ( unsigned j = 0 ; j < 6; ++j)
for ( unsigned k = 0 ; k < 7 ; ++k)
p3nTablica[i][j][k] = i + j + k;
/* zwolnienie */
// zwalniamy kolejne "płaszczyzny"
for ( unsigned i = 0 ; i < 5 ; ++i)
{
// zaczynamy jednak od zwolnienia wierszy
for ( unsigned j = 0 ; j < 6 ; ++j)
delete [] p3nTablica[i][j];
// usuwamy "płaszczyznę"
delete [] p3nTablica[i];
}
// na koniec pozbywamy się wskaźników do "płaszczyzn"
delete [] p3nTablica;

Widać niestety, że z każdym kolejnym wymiarem kod odpowiedzialny za alokację oraz zwalnianie tablicy staje się coraz bardziej skomplikowany. Na szczęście jednak dynamiczne tablice o większej liczbie wymiarów są bardzo rzadko wykorzystywane w praktyce.

Referencje

Naocznie przekonałeś się, że domena zastosowań wskaźników jest niezwykle szeroka. Jeżeli nawet nie dałyby w danym programie jakichś niespotykanych możliwości, to na pewno za ich pomocą można poczynić spore optymalizacje w kodzie i przyspieszyć jego działanie.

Za poprawę wydajności trzeba jednak zapłacić wygodą: odwoływanie się do obiektów poprzez wskaźniki wymaga bowiem ich dereferencji. Wprowadza ona nieco zamieszania do kodu i wymaga poświęcenia mu większej uwagi. Cóż, zawsze coś za coś, prawda?.

Otóż nieprawda :) Twórcy C++ wyposażyli bowiem swój język w mechanizm referencji , który łączy zalety wskaźników z normalną składnią zmiennych. Zatem i wilk jest syty, i owca cała.

Referencje (ang. references ) to zmienne wskazujące na adresy miejsc w pamięci, ale pozwalające używać zwyczajnej składni przy odwoływaniu się do tychże miejsc.

Można je traktować jako pewien szczególny rodzaj wskaźników, ale stworzony dla czystej wygody programisty i poprawy wyglądu pisanego przezeń kodu. Referencje są aczkolwiek niezbędne przy przeciążaniu operatorów (o tym powiemy sobie niedługo), jednak swoje zastosowania mogą znaleźć niemal wszędzie.

Przy takiej rekomendacji trudno nie oprzeć się chęci ich poznania, nieprawdaż? ;) Tym właśnie zagadnieniem zajmiemy się więc teraz.

Typy referencyjne

Podobnie jak wskaźniki wprowadziły nam pojęcie typów wskaźnikowych, tak i referencje dodają do naszego słownika analogiczny termin typów referencyjnych .

W przeciwieństwie jednak do wskaźników, dla każdego normalnego typu istnieją jedynie dwa odpowiadające mu typy referencyjne. Dlaczego tak jest, dowiesz się za chwilę. Na razie przypatrzmy się deklaracjom przykładowych referencji.

Deklarowanie referencji

Referencje odnoszą się do zmiennych, zatem najpierw przydałoby się jakąś zmienną posiadać. Niech będzie to coś w tym rodzaju:

short nZmienna;

Odpowiednia referencja, wskazująca na tę zmienną, będzia natomiast zadeklarowana w ten oto sposób:

short & nReferencja = nZmienna;

Kończący nazwę typu znak & jest wyróżnikiem, który mówi nam i kompilatorowi, że mamy do czynienia właśnie z referencją. Inicjalizujemy ją od razu tak, ażeby wskazywała na naszą zmienną nZmienna . Zauważmy, że nie używamy do tego żadnego dodatkowego operatora !

Posługując się referencją możliwe jest teraz zwyczajne odwoływanie się do zmiennej, do której się ona odnosi. Wygląda to więc bardzo zachęcająco - na przykład:

nReferencja = 1 ; // przypisanie wartości zmiennej nZmienna
std::cout << nReferencja; // wyświetlenie wartości zmiennej nZmienna

Wszystkie operacje, jakie tu wykonujemy, odbywają się na zmiennej nZmienna , chociaż wygląda, jakby to nReferencja była jej celem. Ona jednak tylko w nich pośredniczy , tak samo jak czynią to wskaźniki. Referencja nie wymaga jednak skorzystania z operatora * (zwanego notabene operatorem dereferencji ) celem dostania się do miejsca pamięci, na które sama wskazuje. Ten właśnie fakt (między innymi) różni ją od wskaźnika.

Prawo stałości referencji

Najdziwniej wygląda pewnie linijka z przypisaniem wartości. Mimo że po lewej stronie znaku = stoi zmienna nReferencja , to jednak nową wartość otrzyma nie ona, lecz nZmienna , na którą tamta pokazuje. Takie są po prostu uroki referencji i trzeba do nich przywyknąć.

No dobrze, ale jak w takim razie zmienić adres pamięci, na który pokazuje nasza referencja?. Powiedzmy, że zadeklarujemy sobie drugą zmienną:

short nInnaZmienna;

Chcemy mianowicie, żeby odtąd nReferencja pokazywała właśnie na nią (a nie na nZmienna ). Jak (czy?) można to uczynić?.

Niestety, odpowiedź brzmi: nijak. Raz ustalona referencja nie może być bowiem "doczepiona" do innej zmiennej, lecz do końca pozostaje związana wyłącznie z tą pierwszą. A zatem:

W C++ występują wyłącznie stałe referencje . Po koniecznej inicjalizacji nie mogą już być zmieniane.

To jest właśnie powód, dla którego istnieją tylko dwa warianty typów referencyjnych. O ile więc w przypadku wskaźników atrybut const mógł występować (lub nie) w dwóch różnych miejscach deklaracji, o tyle dla referencji jego drugi występ jest niejako domyślny . Nie istnieje zatem żadna "niestała referencja".

Przypisanie zmiennej do referencji może więc się odbywać tylko podczas jej inicjalizacji . Jak widzieliśmy, dzieje się to prawie tak samo, jak przy stałych wskaźnikach - naturalnie z wyłączeniem braku operatora & , np.:

float fLiczba;
float & fRef = fLiczba;

Czy fakt ten jest jakąś niezmiernie istotną wadą referencji? Śmiem twierdzić, że ani trochę! Tak naprawdę prawie nigdy nie używa się mechanizmu referencji w odniesieniu do zwykłych zmiennych. Ich prawdziwa użyteczność ujawnia się bowiem dopiero w połączeniu z funkcjami.

Zobaczmy więc, dlaczego są wówczas takie wspaniałe ;D

Referencje i funkcje

Chyba jedynym miejscem, gdzie rzeczywiście używa się referencji, są nagłówki funkcji (prototypy). Dotyczy to zarówno parametrów, jak i wartości przez te funkcje zwracanych. Referencje dają bowiem całkiem znaczące optymalizacje w szybkości działania kodu, i to w zasadzie za darmo. Nie wymagają żadnego dodatkowego wysiłku poza ich użyciem w miejsce zwykłych typów.

Brzmi to bardzo kusząco, zatem zobaczmy te wyśmienite rozwiązania w akcji.

Parametry przekazywane przez referencje

Już przy okazji wskaźników zauważyliśmy, że wykorzystanie ich jako parametrów funkcji może przyspieszyć działanie programu. Zamiast całych obiektów funkcja otrzymuje wtedy odwołania do nich, zaś poprzez nie może odnosić się do faktycznych obiektów. Na potrzeby funkcji kopiowane są więc tylko 4 bajty odwołania, a nie czasem wiele kilobajtów właściwego obiektu!

Przy tej samej okazji narzekaliśmy jednak, że zastosowanie wskaźników wymaga przeformatowania składni całego kodu, w którym należy dodać konieczne dereferencje i zmienić operatory wyłuskania. To niewielki, ale jednak dolegliwy kłopot.

I oto nagle pojawia się cudowne rozwiązanie :) Referencje, bo o nich rzecz jasna mówimy, są także odwołaniami do obiektów, ale możliwe jest stosowanie wobec nich zwyczajnej składni, bez uciążliwości związanych ze wskaźnikami. Czyniąc je parametrami funkcji, powinniśmy więc upiec dwie pieczenie na jednym ogniu, poprawiając zarówno osiągi programu, jak i własne samopoczucie :D

Spójrzmy zatem jeszcze raz na funkcję Wyszukaj() , z którą spotkaliśmy się już przy wskaźnikach. Tym razem jej parametry będą jednak referencjami. Oto jak wpłynie to na wygląd kodu:

#include <string>
int Wyszukaj ( const std::string& strSzukany,
const std::string& strPrzeszukiwany)
{
// przeszukujemy nasz napis
for ( unsigned i = 0 ;
i <= strPrzeszukiwany.length() - strSzukany.length(); ++i)
{
// porównujemy kolejne wycinki napisu
if (strPrzeszukiwany.substr(i, strSzukany.length())
== strSzukany)
// jeżeli wycinek zgadza się, to zwracamy jego indeks
return i;
}
// w razie niepowodzenia zwracamy -1
return -1 ;
}

Obecnie nie widać tu najmniejszych oznak silenia się na jakąkolwiek optymalizację, a mimo jest ona taka sama jak w wersji wskaźnikowej. Powodem jest forma nagłówka funkcji:

int Wyszukaj ( const std::string& strSzukany,
const std::string& strPrzeszukiwany)

Oba jej parametry są tutaj referencjami do stałych napisów, a więc nie są kopiowane w inne miejsca pamięci wyłącznie na potrzeby funkcji. A jednak, chociaż faktycznie funkcja otrzymuje tylko ich adresy, możemy operować na tych parametrach zupełnie tak samo, jakbyśmy dostali całe obiekty poprzez ich wartości. Mamy więc zarówno wygodną składnię, jak i dobrą wydajność tak napisanej funkcji.

Zatrzymajmy się jeszcze przez chwilę przy modyfikatorach const w obu parametrach funkcji. Obydwa napisy nie w jej ciele w żaden sposób zmieniane (bo i nie powinny), zatem logiczne jest zadeklarowanie ich jako referencji do stałych. W praktyce tylko takie referencje stosuje się jako parametry funkcji; jeżeli bowiem należy zwrócić jakąś wartość poprzez parametr, wtedy lepiej dla zaznaczenia tego faktu użyć odpowiedniego wskaźnika.

Zwracanie referencji

Na podobnej zasadzie, na jakiej funkcje mogą pobierać referencje poprzez swoje parametry, mogą też je zwracać na zewnątrz. Uzasadnienie dla tego zjawiska jest również takie samo, czyli zaoszczędzenie niepotrzebnego kopiowania wartości.

Najprotszym przykładem może być ciekawe rozwiązanie problemu metod dostępowych - tak jak poniżej:

class CFoo
{
<font color="#0000CC">private</font> :
unsigned m_uPole;
public :
unsigned & Pole() { return m_uPole; }
};

Ponieważ metoda Pole() zwraca referencję, możemy używać jej niemal tak samo, jak zwyczajnej zmiennej:

CFoo Foo;
Foo.Pole() = 10 ;
std::cout << Foo.Pole();

Oczywiście kwestia, czy takie rozwiązanie jest w danym przypadku pożądane, jest mocno indywidualna. Zawsze należy rozważyć, czy nie lepiej zastosować tradycyjnego wariantu metod dostępowych - szczególnie, jeżeli chcemy zachowywać kontrolę nad wartościami przypisywanymi polom.

Z praktycznego punktu widzenia zwracanie referencji nie jest więc zbytnio przydatną możliwością. Wspominam jednak o niej, gdyż stanie się ona niezbędna przy okazji przeładowywania operatorów - zagadnienia, którym zajmiemy się w jednym z przyszłych rozdziałów.

***

Tym drobnym wybiegnięciem w przyszłość zakończymy nasze spotkania ze wskaźnikami na zmienne. Jeżeli miałeś jakiekolwiek wątpliwości co do użyteczności tego elementu języka C++, to chyba do tego momentu zostały one całkiem rozwiane. Najlepiej jednak przekonasz się o przydatności mechanizmów wskaźników i referencji, kiedy sam będziesz miał okazję korzystać z nich w swoich własnych aplikacjach. Przypuszczam także, że owe okazje nie będą wcale odosobnionymi przypadkami, ale stałą praktyką programistyczną.

Oprócz wskaźników na zmienne język C++ oferuje również inną ciekawą konstrukcję, jaką są wskaźniki na funkcje. Nie od rzeczy będzie więc zapoznanie się z nimi, co też pilnie uczynimy.

| | | |
Copyright © 2006-2013 egrafik.pl | Kontakt | Reklama | Projekty domów
jocker