Wskaźniki należą do jednych z najważniejszych elementów programowania. Dzięki nim można dużo swobodniej przetwarzać dane i docierać do ukrytych w pamięci struktur w sposób niemożliwy do zrealizowania przy podejściu “klasycznym”. Z jednej strony są łatwe do zrozumienia, jako charakterystyczne zmienne wskazujące na coś w pamięci; z drugiej strony ich poprawne zrozumienie często długo się wymyka początkującym programistom, aż do chwili, gdy przychodzi nagłe olśnienie, a wiele poprzednio nierozwiązywalnych problemów nagle stało się banalnie prostych.
Część I: Wskaźniki – czym są.
Wskaźnik jest zmienną, która przechowuje w sobie adres komórki pamięci z całego bloku pamięci wydzielonego dla programu, składa się on w zależności od sposobu adresacji pamięci z 4 lub 8 bajtów w przypadku obecnych systemów odpowiednio 32 czy 64 bitowych. Dla zrozumienia wskaźnika w tym momencie nie ma znaczenia co on wskazuje, a fakt, że adres w nim zapisany może przybrać wartość NULL, 0, czy nullptr (C++11) oznaczające, że istniejący wskaźnik nie wskazuje na nic; albo dostępny dla programu adres komórki pamięci. Posługując się wskaźnikiem i analizując ilość pamięci zajętą przez niego możemy w łatwy sposób sprawdzić, czy nasz program pracuje w środowisku 32, czy 64 bitowym. Do tego celu można się posłużyć znaną już komendą sizeof(); Powołanie do życia wskaźnika jest możliwe poprzez deklarację go jako zmiennej zgodnie z poniższym przykładem:
#include <iostream> using namespace std; int main() { void * t; cout <<sizeof(t); return 0; }
Jak powyżej można zaobserwować powołanie do życia wskaźnika jest banalnie proste – jest to tzw. wskaźnik nieoznaczony, albo bez typu. Jego zadaniem jest po prostu przechować w sobie adres komórki pamięci w celach jakie założył sobie programista. Zazwyczaj jednak używa się znaczników, które w swojej deklaracji sygnalizują na typ zmiennych jakie mogą się znajdować we wskazywanych przez nie komórkach pamięci, tak więc:
#include <iostream> using namespace std; int main() { int * t; string * s; bool * b; cout <<sizeof(t) << endl << sizeof(s) << endl <<sizeof(b) << endl; return 0; }
Uruchamiając powyższy program stwierdzimy, że wszystkie wskaźniki mają tę samą długość (ilość bajtów), niezależną od zajętości pamięci przez dany typ zmiennej. Jest to oczekiwane spostrzeżenie, bowiem wskaźnik, jak wyżej było zakomunikowane, nie jest zmienną danego typu, a zmienną przechowującą w sobie adres komórki pamięci i wskazującą na tę komórkę pamięci. Typ wskaźnika sugeruje jedynie co może znajdować się w komórkach pamięci zaczynając od tej wskazanej poprzez kolejne po niej następujące.
Posiłkując się grafiką można pokusić się o próbę zobrazowania czym jest wskaźnik w przestrzeni programu. W tym celu musimy wiedzieć, że każdy program w momencie uruchomienia otrzymuje tzw. stos. Nie wnikając w jego strukturę, można zauważyć, że jest w nim miejsce dla zmiennych lokalnych tworzonych w programie. Nazwijmy to miejsce tablicą zmiennych używanych, gdzie program “szuka” zmiennych (ich nazw), które ma używać. Tak więc wszystkie “powołane do życia” zmienne są zaczepione w stosie, a ich zawartość znajduje się gdzieś w przydzielonej pamięci programu. Już teraz widać, że tak naprawdę wszystkie elementy w ramach tablicy zmiennych programu są właśnie wskaźnikami, wskazując na adres “zawartości”, mniej więcej jak na poniższym rysunku:
Jak widać powyżej z zasady wszystkie zmienne lokalizowane na stosie są “wskaźnikami”. Tylko dla uproszczenia operacji typy proste są traktowane bezpośrednio przy ich deklaracjach i użytkowaniu. Używając ich można było odnieść do tej pory wrażenie, że nie mają nic wspólnego ze wskaźnikami.
Podczas gdy powołanie do życia “zwykłych” zmiennych jest zautomatyzowane, to użycie wskaźników wymaga już świadomości. Jeśli wartość wskaźnik jest tożsama z NULL to można przyjąć, że nie został on skierowany i nie wskazuje żadnej komórki pamięci, ale jeśli jest tam jakakolwiek wartość inna niż NULL, to nie można odróżnić takiej z premedytacją przypisanej od wartości bezsensownej powstałej w wyniku tworzenia zmiennej wskaźnikowej bez inicjalizacji wartością NULL (na przykładach zachodzi ten przypadek). Jakkolwiek w tym momencie można posiłkować się ustawieniami środowiska programistycznego, które jeśli będzie ustawione na “zerowanie” powoływanych do życia zmiennych, to samo automatycznie ustawi nam wartość NULL na wszystkich powołanych do życia wskaźnikach jeśli nie przypiszemy im od razu wartości (adresu na który winny wskazywać). Zatem sprawdźmy to jak to wygląda w naszym przypadku i wyświetlmy wartość wskaźnika. Należy przy tym rozróżnić wartość wskaźnika, od wartości wskazywanej przez wskaźnik. W pierwszym przypadku jest to adres przechowywany we wskaźniku, w drugim wartość przechowywana w komórce pamięci wskazywanej przez adres ze wskaźnika. Dobre zrozumienie tej zasady pomoże unikać krytycznych błędów w pisaniu programów. Zatem nasz przykład wygląda następująco:
#include <iostream> using namespace std; int main() { int * t; cout << t << "...." ; return 0; }
I jaki jest efekt?! Jak widać może być to jakaś “dziwna” wartość, lub O (NULL) w zależności od konfiguracji środowiska programistycznego. Dla pewności jednak dobrym zwyczajem jest stosować deklarację wskaźnika wraz z jego inicjalizacją wartością NULL, tak jak poniżej:
#include <iostream> using namespace std; int main() { int * t=NULL; cout << t << "...." ; return 0; }
Można także w jednej linii powołać wiele zmiennych tego samego typu, jeśli jednak chcemy utworzyć kilka wskaźników danego typu to musimy pamiętać, aby przed każdą nazwą zmiennej użyć znaku * :
#include <iostream> using namespace std; int main() { int * t, z, u, j; //TYLKO!!! pierwsza zmienna jest wskaźnikiem int * m, * y, *r; //wszystkie zmienne są wskaźnikami typu int return 0; }
Wskaźniki – jak użyć.
Samo istnienie struktur wskaźnikowych byłoby bezsensem jeśli nie dałoby się ich pożytecznie użyć. Aby to zrobić musimy być świadomi dwu instrumentów:
- & – znak referencji do obiektu (struktury) w pamięci
- * – znak dereferencji do obiektu (struktury) w pamięci
Użyjmy wyżej poznanych instrumentów w prostym programie:
#include <iostream> #include <windows.h> using namespace std; int main() { SetConsoleCP( 852 ); setlocale ( LC_ALL, "" ); char c='A'; char tab[]= {"Ala ma kota"}; char *ptr_c = & c; char *ptr_tab = & tab[0]; cout.setf( ios::hex, ios::basefield ); cout << "Rozmiar wskaźnika: " << sizeof(ptr_c) << ", Adres wskazywany: 0x" << (long long)ptr_c << ", Adres wskaźnika: " << & ptr_c << ", Wartość wskazywana: " << * ptr_c << endl; cout << "Rozmiar wskaźnika: " << sizeof(ptr_tab) << ", Adres wskazywany: 0x" << (long long)ptr_tab << ", Adres wskaźnika: " << & ptr_tab << ", Wartość wskazywana: " << * ptr_tab << endl; cout << "Adres zmiennej c: 0x" << (long long)& c << endl; cout << "Adres zmiennej tab: 0x" << (long long)& tab[0] << endl; return 0; }
Udało się skompilować program? Pewnie tak. I co uzyskaliśmy? Powinniśmy uzyskać efekt podobny do poniższego:
Listing programu, prócz oczywistych elementów zawiera przykład operowania wskaźnikami. Ciekawostką jest zapis w wyświetlaniu:
cout.setf( ios::hex, ios::basefield ); cout << (…) “Adres wskazywany: 0x” << (long long)ptr_c (…) , gdzie jest wykonywana konwersja wartości zmiennej wskaźnikowej ptr_c na liczbę heksadecymalną. Dlaczego tak? Można spróbować oczywiście po prostu wyświetlić wartość zmiennej wskaźnikowej ptr_c bez konwersji i jaki otrzymamy efekt?? Albo identyczny, albo taki:
Co się stało? Wystarczy się przyjrzeć jak została wyświetlona wartość zmiennej wskaźnikowej – co najmniej dziwnie… jakieś znaczki, w przypadku tablicy jeszcze dziwniej, bo nie wiadomo skąd i dlaczego wyświetliła się zawartość tablicy.
Wynikło to z tego, że kompilator nie poradził sobie z interpretacją naszego żądania i zależnie od jego wersji i konfiguracji… dokonał konwersji niejawnej, bądź wyświetlił w postaci tekstowej wartość adresu na jaki wskazuje wskaźnik. Aby upewnić się co do poprawności z jaką będzie wyświetlany adres należy zawsze używać jawnej konwersji zgodnie z przykładem podanym w załączonym wyżej programie. Unikniemy dzięki temu “dziwnego działania programu rzucającego na ekran jakieś nieoczekiwane znaczki”.
Pamiętajmy aby po wyświetleniu tego co zamierzaliśmy w podstawie szesnastkowej przywrócić formatowanie cout do podstawy dziesiętnej: cout.setf( ios::dec, ios::basefield );.
Można jeszcze zauważyć, że wskaźnik jako zmienna, może posiadać także wskaźnik na siebie… dla przykładu wskaźnik na wskaźnik na typ int (czyli na int *), to np. int**, a czy to czegoś nam nie przypomina i nie zetknęliśmy się już z takim zapisem? Zerknij na notację podstawową metody: int main(int argc, char** argv[]).
Zadanie do samodzielnego wykonania: Spróbuj przećwiczyć wyżej wymienione elementy z własnymi pomysłami co do zmiennych i wskaźników na nie. Spróbuj przypisać wskazanie na zmienną do wskaźnika bez typu lub do wskaźnika innego typu, albo też wskaźnik do innego wskaźnika – czy udaje się ta operacja?
Przed chwilą była mowa o problemach z interpretacją wskaźnika przy konwersji niejawnej – dzieje się to z powodów, że kompilator “domyśla się” w przypadku wskaźników wskazujących na typ char, że chodzi nam o łańcuch znaków (zakończony znakiem \null czyli 0) i dlatego też pojawiła się nam linijka z tekstem Ala ma kota. Są to tzw. łańcuchy znakowe w stylu C. Spróbuj poprzedni przykład przerobić tak, aby wyświetlić rozmiar tablicy tab. Ile ma elementów?? A ile liter jest w zdaniu? Zastanawiałeś się nad tym skąd się wzięła rozbieżność? Przeanalizuj zatem poniższy przykład:
#include <iostream> using namespace std; int main() { char tab[]= {"Ala ma kota"}; char tab1[]={'A','l','a',' ','m','a',' ','k','o','t','a'}; cout << sizeof(tab) << endl; cout << sizeof(tab1) << endl; return 0; }
Co z tego wynika?! Musisz być od teraz świadomy, że użycie uproszczonej formy wpisywania wartości typu char do tablicy jako ciągu znakowego to tak naprawdę wpisywanie następującego ciągu: {‘A’,’l’,’a’,’ ‘,’m’,’a’,’ ‘,’k’,’o’,’t’,’a’,0}, bowiem ciąg znakowy zakończony jest 0.
Część II: Arytmetyka wskaźników
Używane do tej pory przekształcenia i “zabawy” z adresami i wskaźnikami nie wyczerpują zagadnień z nimi związanych – dopiero teraz po zrozumieniu idei wskaźnika możemy zająć się ich największą użytecznością – arytmetyką wskaźników.
W zasadzie arytmetyka ogranicza się do dwu operacji: dodawania do wskaźnika i odejmowania wskaźników. Tak jak dodawanie do wskaźnika tak i odejmowanie wskaźników ma sens w ramach wskazywanej struktury. Rozpatrzmy to na przykładzie tablicy elementów typu int.
#include <iostream> using namespace std; int main() { int intTab[] = {0,10,20,30,40,50,60,70,80,90}; int *ptr_intTab = intTab; //lub: int *ptr_intTab = &intTab[0]; // intTab jest już wskaźnikiem na tablicę, możemy więc wskazać go bezpośrednio, lub wskaźnik na pierwszy element tablicy. cout << *ptr_intTab +5 <<endl; //błędne dodawanie cout << *(ptr_intTab +5) <<endl; //prawidłowo return 0; }
Co robi powyższy przykład? Po utworzeniu tablicy kopiujemy wskazanie na jej miejsce w pamięci do wskaźnika, a następnie wyświetlamy dowolny jej element korzystając z dodawania wskaźników… co należy zauważyć – dodawanie jest co 1 element, nie co jeden bajt! Kompilator zauważył, że typem wskaźnika jest int, który ma 4 bajty, tak więc dodanie 1 elementu do adresu wskaźnika spowoduje przesunięcie wskazywanego wynikowo adresu względnego o 4 bajty. W programie występuje linijka, gdzie w dodawaniu nie zgrupowano operacji za pomocą nawiasów ( ) – jest to błędne odwołanie, bowiem kompilator potraktuje to jako polecenie: odczytaj wartość wskazywaną i do niej dodaj wartość 5, zamiast tego należy użyć poprawnej notacji: odczytaj wartość wskazywaną poprzez ( ptr_intTab + 5 elementów typu int ).
Rozważmy jeszcze taki przypadek…
#include <iostream> using namespace std; int main() { int intTab[] = {0,10,20,30,40,50,60,70,80,90}; int *ptr_intTab = &intTab[0]; void *ptr=(ptr_intTab+7); char *ptr_char=(char*)ptr; // lub: char *ptr_char = static_cast<char*>(ptr); cout << *(ptr_char+4) <<endl; //P return 0; } Kolejną wariacją programu jest: a) wpisanie na pozycji wartości 80 liczby 16961 i wyświetlenie ptr_char+4 i +5, (skąd A i B?!) oraz następnie b) liczby 6387525 i wyświetlenie ptr_char+4, +5 i +6. Pytanie docelowe: Skąd się wzięła Ewa?!
Co się tu stało?! Użyliśmy konstrukcji wskaźnika typu int, przypisaliśmy go jednak do wskaźnika generycznego (bez typu) z przesunięciem o 7 elementów typu int (wartość: 70), a następnie zrzutowaliśmy go (cast) na wskaźnik typu char, przez co używając go będziemy interpretować wartości zapisane pod wskazywanymi adresami jako typ znakowy. Wyświetliliśmy wskazywany element (wskaźnik + przesunięcie o 4 bajty – ponieważ char zajmuje 1 bajt, więc w arytmetyce dodając 4 elementy typu char – to w rezultacie przesunięcie o 4 bajty) i okazało się, że to literka P.
Zadanie do samodzielnego wykonania: Pobaw się powyższym kodem. Spróbuj poruszać się arytmetyką wskaźników aby “dostawać” się do poszczególnych elementów danych. Spróbuj przekraczać “sensowny” rozmiar użytych struktur – co otrzymujesz? Spróbuj dokonywać rzutowania wskaźników na inne, czy da się to zrobić bezpośrednio, czy trzeba rzutować poprzez wskaźnik generyczny. Czy można przypisać wskaźnik jednego typu do wskaźnika innego typu bez rzutowania?!
Inną operacją wskaźników jest ich odejmowanie. Jak zostało to już wspomniane odejmowanie ma sens w ramach jednej struktury. Odejmowanie różnych wskaźników najczęściej nie ma sensu bowiem ich rozmieszczenie w pamięci jest “losowe” i zależne od kodu programu, więc wynik będzie bezwartościowy (chyba, że zależy nam na wylosowaniu jakiejś liczby właśnie).
#include <iostream> using namespace std; int main() { char c='A'; char tab[]= {"Ala ma kota"}; char *ptr_c = & c; char *ptr_tab = & tab[0]; int intTab[] = {0,10,20,30,40,50,60,70,80,90}; int *ptr_intTab = intTab; cout << ((ptr_intTab+10) - ptr_intTab ); cout << ((ptr_intTab+10) - (int*)ptr_c ); // jaka wartość się wyświetli? Czy taka sama za każdym uruchomieniem programu? A teraz spróbuj skasować linijkę: char tab[]= {"Ala ma kota"}; jak to wpłynęło na wynik? Zrozumiałeś? return 0; }
Zadanie do samodzielnego wykonania: Pobaw się powyższym kodem. Spróbuj poruszać się arytmetyką wskaźników aby “dostawać” się do poszczególnych elementów danych. Próbuj odejmować wskaźniki, także różnych typów.
Zadanie 1: Napisz program w którym zdefiniujesz kilkunasto-elementową tablicę wartości całkowitych. W ramach programu napisz procedurę, która posortuje jej zawartość od i do wskazanych elementów start i stop. W procedurze sortującej posługuj się tylko(!) wskaźnikami.
Zadanie 2: Napisz program w którym zdefiniujesz wielowierszową tablicę ciągów znakowych typu string. Napisz funkcję, która będzie wyszukiwała dany jej jako parametr ciąg znaków, a zwracała ilość ich wystąpień, tzn: wartością zwracaną przez Policz(*tab_cz, “Ala”) będzie ilość słów “Ala” we wszystkich wierszach tablicy. Parsując tablicę posługuj się tylko(!) wskaźnikami.
Powyższy materiał nie wyczerpuje tematu wskaźników, bowiem istnieją jeszcze stałe wskaźnikowe, wskaźniki na stałą, wskaźniki na wskaźnik, wskaźniki funkcyjne, inteligentne itp., które zostały tutaj pominięte jako wykraczające poza zakres programowy.
Część III: Zarządzanie pamięcią.
Poprzednie części wprowadziły nas do rzeczywistości wskaźników. Obecna część będzie ich głównym wykorzystaniem. Tworzenie nienazwanych struktur w pamięci wymaga ręcznej alokacji pamięci i przypisania jej adresu początkowego jakiemuś wskaźnikowi oraz w konsekwencji ręcznego zwolnienia tej pamięci (lub zakończenia programu, co definitywnie zwalnia pamięć przezeń zajętą).
Operacje zarządzania pamięcią w C++ obsługuje się za pomocą rozkazów new i delete. Historycznie rzecz ujmując w C operowało się rozkazami malloc, realloc i free służącymi temu zadaniu – należy pamiętać, że pamięć przydzielona za pomocą new musi być konsekwentnie zwalniana za pomocą delete i adekwatnie malloc za pomocą free.
Skupimy się jednak na modelu C++ z komendami new i delete.
Poniżej przedstawiony jest program rezerwujący pamięć dla nienazwanej zmiennej typu double umieszczonej w pamięci i wskazywanej przez wskaźnik.
#include <iostream> using namespace std; int main() { double *ptr_Dbl = new double; *ptr_Dbl = 3.14159; cout << *ptr_Dbl << endl; delete ptr_Dbl; //ptr_Dbl=nullptr; cout << *ptr_Dbl << endl; return 0; }
Program działa… a przynajmniej wydaje się, że działa… należy jednak zauważyć, że drugie wyświetlenie wartości wskazywanej przez wskaźnik następuje już po zwolnieniu pamięci przydzielonej do jej przechowywania. Wynikają z tego dwa wnioski: pierwszy to to, że zwolnienie pamięci nie powoduje “zniszczenia” jej obszaru, ani “zresetowania” wskazania przez wskaźnik (w celu “zresetowania” należałoby do zmiennej wskaźnikowej przypisać predefiniowaną wartość nullptr – jest umieszczona jako komentarz w listingu); drugi to fakt, że takie niechlujne posługiwanie się zarządzaniem pamięcią przez programistę prowadzi do bardzo trudno wykrywalnych błędów w programach, bowiem… program zazwyczaj działa, chyba, że… obszar uprzednio zarezerwowany dla zmiennej i następnie zwolniony, zostanie nadpisany innymi wartościami wynikającymi z działania programu. Do programisty więc należy właściwe posługiwanie się alokacją pamięci w programie.
Już jako uwagę należy też przytoczyć fakt, że zmienna ptr_Dbl jest standardową zmienną programu typu wskaźnikowego double i jest składowany jej adres i nazwa na stosie – więc zwolnienie pamięci nie powoduje skasowania tej zmiennej, a “jedynie” przeznaczenie wskazywanego przez nią obszaru pamięci do ponownego wykorzystania. Sama zmienna jest egzystującą w programie zgodnie z zasadą zasięgu zmiennych; i w przypadku bycia zmienną globalną jest zwalniana wraz z końcem programu. Należy pamiętać o tej różnicy pomiędzy istotą zmiennej i jej miejscem “zaczepienia” na stosie, a zawartością tej zmiennej co jest już zupełnie inną kwestią.
Innym użyciem alokacji pamięci jest przydzielanie pamięci dla struktur tablicowych. Ma to kilka zalet nad statyczną deklaracją tablicy:
- Rozmiar tablicy nie musi już być znany z góry (użycie zmiennej jako rozmiaru w zwykłej tablicy nie należy do standardu C++),
- Rozmiar tablicy zależy tylko od dostępnej pamięci i fantazji programisty,
- Rozmiar tablicy można dowolnie zmieniać podczas działania programu, a dokładniej można tworzyć nową strukturę o większej alokacji pamięci jeśli będzie tego wymagać działanie programu, a dane z mniejszej przekopiować do większej struktury. Co daje nam w zasadzie dynamicznie zmieniany rozmiar tablicy.
Odbywa się to zgodnie z poniższym przykładem:
#include <iostream> #include <sstream> #include <windows.h> using namespace std; int main() { SetConsoleCP( 852 ); setlocale ( LC_ALL, "" ); int * tablica = nullptr, rozmiar = 0; std::cout << "Podawaj kolejno liczby, 0 kończy wczytywanie.\n"; while( true ) { int liczba; string zd; do { std::cout << "Liczba nr: " << rozmiar+1 <<" wartość: "; getline(cin,zd); } while (0==istringstream(zd) >> liczba); if( liczba == 0 ) break; // Brakuje miejsca, utwórz większą tablicę int * nowa = new int[ rozmiar + 1 ]; // Skopiuj dane for( int i = 0; i < rozmiar; ++i ) nowa[ i ] = tablica[ i ]; // Dodaj nową wartość nowa[ rozmiar ] = liczba; // Usuń starą tablicę delete[] tablica; // Zaktualizuj zmienne tablica = nowa; rozmiar++; } std::cout << "Te same liczby, ale w odwrotnej kolejności!\n"; for( int i = rozmiar - 1; i >= 0; --i ) std::cout << tablica[ i ] << ' '; delete[] tablica; return 0; }
Zadanie do samodzielnego wykonania: Poeksperymentuj z kodem – spróbuj tworzyć nowe struktury i je usuwać.