Wskaźniki

To jest rozdział, którego najbardziej się obawiałem :-/ Wskaźniki są bardzo przydatne i mogą znacznie ułatwić Ci życie pod warunkiem, że się je rozumie 🙁 No właśnie.. Tutaj pojawia się problem. Zagadnienie wskaźników jak kilka innych aspektów C++ może skutecznie odstraszyć od programowania. Wielu ludzi uważa je za zbędne. Jeśli czegoś nie rozumiemy, to uważamy to za głupie, nielogiczne i zbędne 😉 Prawdopodobnie na początku odniesiesz podobne wrażenie i będziesz się trochę gubił. Nie przejmuj się. Ja również miałem drobne problemy ze zrozumieniem wskaźników. Na moje szczęście z natury jestem bardzo uparty i nie dałem za wygraną. Tobie też tak radzę. Zobaczysz, że trud włożony w opanowanie wskaźników szybko się opłaci. A jak już poznasz je dobrze, to będziesz się śmiał, że to przecież jest prościutkie 🙂 Bez zbędnych komentarzy zacznijmy od.. przykładziku.
Jest lekcja Historii [jakże ja nie cierpię tego przedmiotu 😀 ] Belfer nawija smutnym monologiem, a od czasu do czasu poopiera swe wybory mapką wiszącą na ścianie. Wskazuje ręką pewne istotne miejsca na mapie. Niestety gość ma pecha, gdyż mapka wisi dość wysoko, a on jest zbyt niski i nie może dosięgnąć 😉 Bierze więc drewniany patyk i z pełnym entuzjazmem wskazuje wcześniej nie osiągalne miejsce na mapie.
W naszym przykładzie owy patyczek to właśnie wskaźnik. Widzisz, jak pomógł w rozwiązaniu problemu? Oczywiście drewniany wskaźnik nie był niezbędny, gdyż można było.. obniżyć mapkę, podstawić stołek etc. Jednak drewienko okazało się najrozsądniejszym i najszybszym rozwiązaniem.
Tak właśnie jest w programowaniu. Można w ogóle nie używać wskaźników i radzić sobie. Pytanie tylko, co na tym zyskamy? Lub bardziej trafnie: Czy w ogóle coś na tym zyskamy? Odpowiem prosto: Zyskać, nie zyskamy nic! Wręcz przeciwnie – stracimy i to bardzo dużo! To właśnie za sprawą wskaźników C++ jest tak uniwersalny i elastyczny na późniejsze modyfikacje kodu. Uwierz mi umiejętne korzystanie ze wskaźników jest na prawdę pomocne, a w niektórych przypadkach po prostu nie da się osiągnąć pewnych zamierzeń bez użycia wskaźników!
Dobra, jeśli tutaj dotarłeś to zapewne wziąłeś sobie głęboko do serducha moje słowa i chcesz zrozumieć istotę wskaźników. Bardzo dobrze, wyjaśnijmy sobie czym właściwie wskaźnik jest? Już sama nazwa stanowi pewną informację. Wskaźnik służy do.. wskazywania. Oczywiście od razu nasuwa się pytanie: na co wskaźnik pokazuje? To już nasza sprawa. Wskaźnik można ustawić na dowolny obszar w pamięci 🙂 Pamiętasz jeszcze, czym są zmienne? Sądzę, że tak. Jednak dla formalności przypomnę. Zmienna to wydzielony obszar pamięci posiadający własną nazwę oraz określoną wartość. Ze wskaźnikiem jest ciut inaczej. To ustrojstwo podobnie jak zmienna także znajduje się w pamięci. Posiada też swoją nazwę. Teraz pojawia się nowość. Zmienne poznane do tej pory zawierały dowolną wartość liczbową. Wskaźnik nie posiada wartości liczbowej, lecz.. Teraz uważaj – adres komórki pamięci :-/ [wprawdzie adres jest określany liczbowo, ale nie chciałem zaciemniać] Rozumiesz co to oznacza? Wskaźnik zawiera jedynie informację o lokalizacji konkretnego obiektu. Pora na przykład definicji:

Praktycznie wszystko byłoby jasne, gdyby nie gwiazdka. Jest to operator odniesienia się do obiektu. Czasem nazywa się go operatorem wyłuskania. W przykładzie zdefiniowaliśmy sobie wskaźnik mogący pokazywać na obiekty typu int. Zauważ podobieństwo do definicji zwykłej zmiennej. Jeżeli wyrzucilibyśmy gwiazdkę mielibyśmy zwykłą definicję zmiennej typu int o nazwie wskaznik. Składnia jest bardzo zbliżona, dzięki czemu nie trzeba się dużo uczyć 🙂 Mając taki wskaźnik możemy go ustawić na dowolnym obiekcie typu int. Teraz popatrz jak się to robi.

Najpierw zdefiniowaliśmy sobie zmienną typu int, później wskaznik również typu int. To jest już znane. W trzeciej linijce jest ustawienie wskaźnika na zmienną. Zwróć uwagę, że teraz nie ma żadnej gwiazdki. Pojawia się natomiast ampersand [&]. Jest to kolejny operator. Służy do pobrania adresu obiektu. Spójrz raz jeszcze na przykład. Ustawienie jest zbliżone do podstawienia wartości do zmiennej. Różnicą jest znaczek &. Zapamiętaj to sobie tak. Wskaźnik zawsze zawiera adres. Zmienna posiada określoną wartość. Jeżeli nie podałbyś znaczka & oznaczałoby to, że chcesz podstawić wartość zmiennej do wskaźnika. Wskaźnik przechowuje adres, a nie wartość. Zatem byłby to błąd. Pamiętaj o tym! Mając już ustawiony wskaźnik na zmienną możemy się do niej odwołać dwojako. Pierwszy sposób już znasz od dawna. Zwykła operacja na zmiennej. Teraz zobacz, jak można się odnieść do zmiennej za pomocą wskaźnika.

Najpierw jest podstawienie wartości 22 do zmiennej. To znamy. Teraz wykonujemy tą samą operację, ale z użyciem wskaźnika. Jak widzisz jest to trochę zagmatwane. Znów pojawia się gwiazdka. Jeżeli nie podałbyś gwiazdki kompilator uznałby, że chcesz ustawić wskaźnik na 22 komórkę pamięci. Nam jednak chodzi o podstawienie wartośći 22 do zmiennej pokazywanej przez wskaźnik. Zatem zupełnie coś innego. Gwiazdka informuje kompilator, że chodzi o obiekt, na który pokazuje wskaźnik. Wiem, że to trochę zawiłe. Najlepszym sposobem na opanowanie wskaźników jest po prostu.. trening. Poćwicz trochę definicje wskaźników oraz odnoszenie się do obiektów za pomocą wskaźników. Zobaczysz, że po pewnym czasie powyższe operacje okażą się bardzo proste. Wskaźniki mają jeszcze jedna przydatną cechę. W dowolnym momencie można zmienić typ wskaźnika. Robi się to za pomocą operatora rzutowania. Zerknij sobie:

Wpierw zdefiniowaliśmy sobie wsk_uniwersalny mogący pokazywać na liczby long. Następnie definiujemy sobie trzy zmienne różnych typów. Teraz ustawiamy nasz wskaźnik na zmienną typu long. Wszystko jest w porządku. Zarówno zmienna, jak i wskaźnik są tego samego typu. Jak myślisz, co się stanie, gdy podstawimy do wskaźnika zmienną innego typu? Nie trudno się domyślić, że kompilator wywali błąd. Jednakże są sytuacje kiedy zmiana typu wskaźnika jest przydatna. Zmiana, czyli konwersja zachodzi za sprawą operatora rzutowania. Taki operator, to nic innego, jak zwykły nawias stawiany przed konwertowanym typem, a zawierający określenie nowego typu. Zatem linijka:

spowoduje, że wsk_uniwersalny, który był wskaźnikiem pokazującym na liczby long staje się wskaźnikiem do liczb int. W następnej linijce jest ta sama sytuacja. Konwertujemy nasz wskaźnik do typu double. Pamiętaj tylko, że w nawiasie należy jeszcze podać gwiazdkę. Informuje ona, że chodzi o wskaźnik, a nie o zmienną.

Wskaźniki stałe vs. wskaźniki do stałych

Pamiętasz jeszcze, jedną z początkowych lekcji, w której mówiliśmy o zmiennych? Tam też dowiedziałeś się, że czasem zachodzi potrzeba [lub ktoś ma taki kaprys ;)] aby pewne zmienne nie zmieniały swych wartości, czyli były.. stałe. Stałe definiowaliśmy poprzedzając je słówkiem const. Stałe można było inicjalizować tylko podczas ich definicji. Wartość raz nadana nie mogła ulec zmianie. Jak już pewnie się domyślasz, wskaźnikami również można tak manipulować. Tradycyjnie przykładzik:

Zdefiniowaliśmy sobie dwie zmienne: x i y. Obie typu float. Następnie definiujemy stały wskaźnik wsk mogący pokazywać na obiekty typu float i od razugo inicjalizujemy. Zwróć uwagę, że inicjalizacja stałego wskaźniku musi nastąpić podczas jego definiowania. Jeśli tego nie uczynimy to przepadło. Później już nic nie da się zrobić. Dlatego dwie następne linijki ujęte są w komentarzu. Zauważ też, że stałego wskaźnika nie można ustawić nawet na ten sam obiekt – linijka: wsk = &x. Po prostu jedynym miejscem ustawienia wskaźnika jest miejsce jego definicji. Co za tym idzie żadne operacja przesuwania typu wsk++; wsk–; wsk += 3; etc. także nie zadziałają! Wskaźnik jest stały i koniec. Dobra to był przykład stałego wskaźnika a teraz przykład wskaźnika do stałej:

Mamy zwykłą zmienną x typu float oraz wskaźnik wsk ustawiony na niej. Sam wskaźnik posiada jednak pewne ograniczenie. Nie może bowiem modyfikować wartości obiektu, na który wskazuje. Sam obiekt oczywiście nie musi być stałą, co widać w przykładzie.
Można także tworzyć stałe wskaźniki do stałych. Nie ma tu żadnych cudów. Najzwyczajniej wskaźnikiem nie można poruszać, ani przestawiać, a dodatkowo nie można zmieniać wartości obiektu, na który wskazuje. Dla formalności przykład takiego wskaźnika:

Dynamiczna rezerwacja pamięci

Powiedzieliśmy sobie ciut o wskaźnikach. Masz już pojęcie na ten temat. Zatrzymajmy się na chwilę. Przeanalizuj powyższe przykłady raz jeszcze i zastanów się co na tym zyskujemy? Na pierwszy rzut oka wydaje się, że nic. Jedynie możliwość odwołania się do zmiennej na dwa sposoby. Oczywiście jeżeli to byłby jedyny powód to nawet bym nie pisał o wskaźnikach. Bo i po co. Tak naprawdę wskaźniki zostały wprowadzone do C++ w celu usprawnienia programowania oraz przyspieszenia działania programu. Wcześniej powiedziałem, że wskaźnik zawiera adres. Jest to jak najbardziej prawdziwe. Tak się składa, że szybciej dociera się do komórki pamięci mając dany jej adresu, aniżeli nazwę zmiennej. Różnica w szybkości oczywiście nie jest aż tak zauważalna. Są to ułamki sekund. Jednak w przypadku większej ilości zmiennych czas wykonania może być zauważalny. Oto jedna mało istotna korzyść ze wskaźników. Do tej pory ustawialiśmy wskaźniki na konkretne zmienne. Pamiętasz jeszcze, jak mówiłem, że wskaźnik można ustawić na dowolny obszar pamięci. Tym się teraz zajmiemy. Konkretnie dynamiczną rezerwacją pamięci. Nazwa chyba wiele wyjaśnia. Dynamiczna rezerwacja pamięci to nic innego jak przydzielenie odpowiedniej ilości pamięci w czasie wykonywania programu! Do zarezerwowania pamięci służą oczywiście wskaźniki. Tak się fajnie składa, że przydatna jest tutaj także znajomość tablic. Pamiętasz jeszcze czym były tablice? Niedawno o tym mówiliśmy. Tablica to obszar pamięci zawierający określoną ilość elementów. Niestety rozmiar tablicy należało ustalić już na etapie kompilacji, co skutecznie ograniczało możliwości programu. Od teraz jest inaczej. Wskaźniki dają nam większą swobodę. Teraz możemy utworzyć dowolnie dużą tablicę już w czasie wykonywania programu! Jest to ogromna korzyść. Oczywiście należy tutaj pamiętać o możliwościach pamięciowych komputera. Dość gadania looknij sobie na przykład:

Na początku zdefiniowaliśmy sobie wskaźnik mogący pokazywać na obiekty typu int. Teraz jest najważniejsze. Za pomocą operatora newwykreowaliśmy dynamiczną tablicę zawierającą 20 elementów. W każdej chwili możemy dokonać realokacji tej tablicy, czyli skorygować jej rozmiar. Aby to uczynić trzeba najpierw zwolnić tablicę w taki sposób:

Operator delete usuwa tablicę i zwalnia pamięć. Pamiętaj o zwalnianiu pamięci przed zakończeniem programu. Jeżeli tego nie zrobisz katastrofy nie będzie. Jednak zostanie mniej pamięci dla innych programów. Tak więc pamiętaj o tym! Jest jeszcze jedna rzecz, o której powinieneś wiedzieć. Każdy wskaźnik zaraz po utworzeniu wskazuje na jakiś adres. Podobnie jak w przypadku zmiennych będzie to dowolny adres. Dlatego też nie należy korzystać ze wskaźnika bez wcześniejszego ustawienia. Nasuwa się prosty wniosek. Skoro usuwamy tablicę operatorem delete to wskaźnik znów pokazuje na przypadkowe dane. Dlatego też próba odniesienia się do obiektu pokazywanego przez taki wskaźnik może spowodować błąd programu. Podobnie jest w przypadku dwukrotnego zwalniania pamięci. Aby się zabezpieczyć przed takimi pomyłkami najlepiej po zwolnieniu pamięci ustawić wskaźnik na NULL. NULL to jakby zerowy adres pamięci. Jakiekolwiek modyfikacji wskaźnika tak ustawionego nie spowodują błędu. Czasem przez nieuwagę można dokonać powtórnego zwolnienia już usuniętego obszaru pamięci. Takie błędy jest najtrudniej wykryć. Wracając jeszcze do operatora new. Pokazałem tutaj przykład rezerwacji 20 – elementowej tablicy obiektów typu int. Oczywiście nie zawsze chcemy utworzyć tablicę. Jeśli chcesz zarezerwować pamięć na jeden obiekt dowolnego typu wystarczy z definicji usunąć klamrę z liczbą elementów tablicy. Czyli należy zrobić tak:

Tym razem dla odmiany dałem typ float. Pierwsza linijka to definicja wskaźnika mogącego pokazywać na obiekty typu float. Następnie jest zarezerwowanie pamięci dla jednego obiektu typu float. Zwalnianie wygląda analogicznie, jak poprzednio:

Tutaj nie podajemy już klamer, gdyż chodzi o jeden obiekt. Samo korzystanie z takiego obiektu jest takie samo, jak do tej pory. Zatem zapisanie wygląda tak:

Pamiętaj o gwiazdce. Jeśli o niej zapomnisz to kompilator i tak Ci przypomni 🙂 Odczyt również nie uległ zmianie.

Najpierw zdefiniowaliśmy sobie zmienną typu float. Później podstawiliśmy do niej wartość obiektu wskazywanego przez wskaźnik. Tak więc zmienna zawiera teraz wartość 483.45.
To już prawie wszystko. Powinieneś jeszcze wiedzieć, że obiekty kreowane operatorem new są nieśmiertelne. Oznacza to, że istnieją od momentu utworzenia do mementu usunięcia. W przypadku takich obiektów nie ma znaczenia zakres ważności. Posiadają one zakres globalny. Jak zdążyłeś się zorientować obiekty tworzone operatorem new nie posiadają nazwy. Można się do nich odwołać jedynie za pomocą wskaźników. Tutaj również należy uważać. Spójrz na przykład:

Najpierw jest definicja wskaźnika o nazwie tabliczka. Następnie rezerwacja pamięci dla 256 obiektów typu char. Teraz trochę się pobawimy.

Właściwie wszystko to już znamy. Jednak krótko wyjaśnię. Utworzyliśmy sobie pętlę for. Ilość obiegów jest znana i wynosi 256. W ciele pętli odpowiednio wypełniamy naszą tabliczkę. Wstawiamy tam kolejno liczby od 0 do 255. Teraz mamy w tabliczce liczby z zakresu <0; 255>. Definiujemy drugi wskaźnik typu char. Już w definicji odpowiednio go ustawiamy. Zwróć uwagę, że tutaj nie ma znaku &. To dlatego, że nazwa tablicy jest adresem jej pierwszego elementu! Warto o tym pamiętać! Teraz możemy korzystać z tabliczki na różne sposoby.

Wpierw zdefiniowaliśmy sobie wsk_0. Jest to wskaźnik typu char, dlatego też możemy do niego podstawić adres obiektu znajdującego się w tabliczce. Jak widzisz można to zrobić na kilka sposobów. Najpierw korzystamy z nazwy tablicy, która jest adresem jej pierwszego obiektu. Następnie jest ta sama operacja, ale w inny sposób. Dwie następne są analogiczne – tyle tylko, że korzystamy z wcześniej zdefiniowanego wskaźnika ustawionego na początku tabliczki. Dalej jest definicja wskaźnika wsk_128, który od razu ustawiamy na 129 obiekt tabliczki. Myślę, że wiesz, dlaczego napisałem obiekt 129 zamiast 128? Jeżeli nie, to przypomnę, że elementy tablicy są numerowane od zera. Nie zapominaj o tym! Następnie identyczna sytuacja, tylko przy użyciu wcześniej zdefiniowanego wskaźnika. Na koniec są dwie instrukcje przestawiające nasz wsk_128. Zwróć uwagę na linijkę wsk_128 += 10;, widzisz coś znajomego? Pewnie, że tak – to przecież zwykła operacja arytmetyczna! Właśnie, zatem gdyby wsk_128 był zmienną liczbową to nastąpiło by dodatnie liczby 10 do aktualnej wartości. My mamy wskaźnik, więc kompilator dobrze wie, że chodzi nam o przesunięcie wsk_128 o 10 elementów do przodu.
Jak widzisz jest wiele sposobów ustawiania wskaźników. Oczywiście pod żadnym pozorem nie ucz się tego na pamięć! Musisz po prostu to poczuć. Przeanalizuj sobie wszystkie sposoby raz jeszcze na spokojnie, a później poćwicz trochę. Wkrótce zobaczysz, że to jest na prawdę do zrozumienia.
No dobra, trochę się pobawiliśmy. Tabliczka już nam nie jest potrzebna. Tak więc trzeba ją zwolnić.

Powyższa operacja załatwia sprawę. Tabliczka już nie istnieje – zapominamy o niej całkowicie. Teraz uważaj, bo czeka na nas pułapka! Podczas naszej zabawy zdefiniowaliśmy jeszcze kilka wskaźników. Konkretnie były to: wsk_0 i wsk_128. Oba ustawiliśmy na elementach tabliczki. Skoro tabliczka już nie istnieje, więc… oba wskaźniki pokazują teraz w maliny. Myślę, że nie muszę powtarzać, co się stanie w takim przypadku:

Porady oraz wskazówki

Garść porad i wskazówek dotyczących pracy ze wskaźnikami, dzięki którym unikniesz kłopotów i zszarganych nerwów 😉 Raz jeszcze przypomnę podstawowe zasady bezpiecznego korzystania ze wskaźników oraz przedstawię kilka częstych błędów.

ślepy wskaźnik

Gdy definiujemy jakiś obiekt bez nadania mu konkretnej wartości, obiekt taki zawiera śmieci. W przypadku zwykłych zmiennych możemy z takich przypadkowych danych skorzystać bez problemu. Natomiast problemy pojawia sie, gdy chcemy użyć niezainicjalizowanego wskaźnika. Taki ślepy wskaźnik ustawiony na przypadkowej komórce pamięci może stać się poważnym zagrożeniem dla programu. Próba zapisu pod adres wskazywany przez ten wskaźnik przeważnie zakończy się wykrzyczeniem programu. Na nasze szczęście jest dość prosty sposób na uniknięcie takich niespodzianek. Należy zainicjalizować wskaźnik podczas jego definicji. Kłopot w tym, że praktycznie nigdy definiując wskaźnik nie znamy adresu obiektu docelowego. Wtedy najlepiej ustawić wskaźnik na adresie zerowym.

Słowo NULL to właśnie nasz adres zerowy. Co nam to daje? Otóż teoretycznie wszelkie manipulacje na takim wskaźniku nie stanowią dla nas zagrożenia. Teoretycznie [tak wyczytałem w jednej książce], bo w praktyce program również może sie wysypać, próbując odnieść się do takiego wskaźnika. Zatem:

Można jednak się przed takimi sytuacjami zabezpieczyć:

Jeśli nie mamy pewności, czy wskaźnik został odpowiednio ustawiony – sprawdzamy to instrukcją if i odpowiednio reagujemy.
Trochę to niewdzięczne, bo trzeba dokonywać sprawdzenia, ale lepiej umieścić kilka instrukcji sprawdzających, niż patrzeć, jak wyskakuje nam monit typu: „Program wykonał nieprawidłową… blebleble…”;

Pułapki z wieloma wskaźnikami

Mamy taką sytuację:

Najpierw rezerwacja pamięci dla firmy. Później kilka operacji kosmetycznych – przydział lokalu i rejestracja firmy. Następnie rozpoczyna się kampania reklamowa i ogłoszenie o firmie trafia do mediów:

Po kilku dniach pojawiają się zainteresowani:

Zainteresowanie rośnie. Mija jakiś czas i okazuje się, że to jeden wielki szwindel, ta firma to oszuści i naciągacze! Zatem firma zamyka interes:

o czym jej 'klienci’ nic nie wiedzą. Ludzie postanawiają odzyskać 'zainwestowane’ pieniądze. Jedni piszą pozwy do sądu. Lecz bez skutku. Pod podanym adresem firmy już nie ma. Mieszka tam powiedzmy pewna staruszka 😉 Otrzymuje wezwanie do sądu i dziwi się wielce 😮 Sprawa wyjaśnia się szybko, lecz jeden gość o tym nie wie i nadal chce odzyskać kasę. Jako że gość wychował się w środowisku wiejskim postanawia 'rozliczyć’ się z firmą w swoim stylu – postanawia spalić siedzibę firmy:

Widzisz co tu zaszło? dokonaliśmy dwukrotnego zwolnienia tej samej tablicy. Ponadto wywoływaliśmy funkcję: strcpy na nieistniejącej tablicy [funkcja ta służy do kopiowania stringów].

Popatrz na linijkę: char * adres = firma;. Ta instrukcja nie spowoduje skopiowania pod adres zawartości firmy! Nastąpi jedynie przypisanie. Dzięki temu od tej pory możemy odnosić się do firmy poprzez adres. To w dalszym ciągu jest jedna tablica, tylko ustawione są na nią dwa wskaźniki: firma i adres.
Chciałem pokazać, jak łatwo można popełnić błąd ustawiając kilka wskaźników na ten sam obszar pamięci. Pomyśl, co by się stało, gdyby było ich więcej, zaczęlibyśmy nimi poruszać, przestawiać etc. Krótko mówiąc o błąd w takiej sytuacji bardzo prosto.

Na zakończenie po raz kolejny powiem, że trzeba być ostrożnym w manipulowaniu wskaźnikami i pamiętaj, że:

1. Należy inicjalizować wskaźnik możliwie jak najwcześniej, a jeśli to nie możliwe to ustawić go na NULL
2. Ustawiając wskaźnik na tablicę uważaj, by nie przekroczyć jej zakresu
3. Powtórnego zwolnienia tego samego obszaru pamięci przeważnie jest tragiczne w skutkach
4. Błędnie ustawione wskaźniki stanowią ogromne zagrożenie i często owocuje to załamaniem programu
5. Generalnie trzeba być czujnym i ostrożnym przy pracy ze wskaźnikami

Autor: Kesay

[Artykuł pochodzi ze strony guidecpp.prv.pl, autor wyraził zgodę na publikację]