Wskaźniki
Ogromną zaletą C++ i bardzo potężnym narzędziem jest możliwość
bezpośredniego manipulowania zawartością pamięci za pomocą wskaźników.
Należy jednak pamiętać, że wskaźniki są bardzo często przyczyną dużego
zamieszania w programach w C++.
Wskaźnik jest zmienną przechowującą adres w pamięci.
Pamięć jest miejscem przechowywania wartości. Pamięć dzieli się na
sekwencyjnie ułożone komórki. Każda komórka ma swój adres.
Każda zmienna, każdego typu umieszczona jest pod odrębnym adresem.
każda komórka = 1 bajt
zmienna nWiek typu unsigned long
= 4 bajty = 32 bity
nazwa zmiennej nWiek wskazuje
na pierwszy bajt
adres zmiennej nWiek to 102
nWiek
100 101 102 103104 105106 107108109110111
0101
1111010
1
0011000
1
1011000
0
0101
Pamięć jest różnie adresowana, w zależności od typu komputera. Zazwyczaj
programista nie musi wiedzieć jaki jest szczegółowy adres danej zmiennej, od
tego jest kompilator. Jeśli jednak chcielibyście się bliżej z tym zapoznać,
trzeba wykorzystać operator adresu ( & ) .
Przykład
shortVar
longVar
sVar
0101
0000
5
ff90
ff81
ff82
ff83
ff84
ff85
ff86
ff87
ff88
ff8a ff8c
ff8d ff8f
ff8e
ff8b
ff89
1111
1111
0000
1111
1111
0000
0000
1000
- 65535
65535
shortVar
longVar
sVar
0101
0000
5
ff90
ff81
ff82
ff83
ff84
ff85
ff86
ff87
ff88
ff8a ff8c
ff8d ff8f
ff8e
ff8b
ff89
1111
1111
0000
1111
1111
0000
0000
1000
- 65535
65535
Przypisywanie adresu do wskaźnika
Każda zmienna ma swój adres. Nawet nie znając konkretnego adresu
zmiennej można go przypisać do wskaźnika.
Przykład
Załóżmy, że mamy zmienną całkowitą typu int o nazwie nWiek. Aby
zadeklarować wskaźnik do przechowywania adresu tej zmiennej trzeba
napisać:
int *pWiek = 0;
Kiedy deklaruje się zmienną wskaźnikową, to można w niej umieścić adres
jakiegoś obiektu w pamięci.
W tym przypadku zmienna wskaźnikowa pWiek przechowuje adres zmiennej
całkowitej typu int.
Zauważmy, że zainicjalizowaliśmy wskaźnik pWiek wartością 0. Wskaźnik,
którego wartość wynosi zero określany jest jako
null
(pusty, nie wskazujący
na żaden obiekt). Jeśli nie wiecie jaki adres przypisać do wskaźnika, to
przypiszcie mu wartość zero.
Wskaźniki niezainicjalizowane żadną wartością określane są jako
dzikie
wskaźniki
. Stanowią one potencjalne zagrożenie dla programu, gdyż mogą
przechowywać adres dowolnej komórki pamięci (nie wiemy jakiej).
Modyfikacja pamięci pod tym adresem może doprowadzić np. do zawieszenia
się komputera.
Chcielibyśmy teraz przypisać mu adres zmiennej nWiek.
int nWiek = 50; //stwórz zmienna
int *pWiek = 0; //stwórz wskaźnik
pWiek = &nWiek; //wstaw adres do wskaźnika
Przypisaliśmy adres dzięki operatorowi adresu ( & ). Gdybyśmy zapomnieli o
tym operatorze, to do wskaźnika zostałaby przypisana wartość zmiennej
nWiek (a nie jej adres). Oznacza to, że wskazywałby on na zupełnie inną
komórkę pamięci, niż oczekiwaliśmy.
Dostęp do zmiennej za pomocą jej adresu określany jest jako dostęp
pośredni.
Dostęp
pośredni
oznacza
modyfikowanie
lub
odczytywanie wartości zmiennej za pośrednictwem jej adresu
przechowywanego we wskaźniku.
Wskaźniki, tak jak wszystkie inne zmienne, mogą mieć dowolne,
poprawne w C++ nazwy. Przyjmijmy konwencję nazywania
wskaźników rozpoczynając nazwę od litery p (z ang. wskaźnik -
pointer), np.: pWiek, pLiczba itp.
Operator dostępu pośredniego
Operator dostępu pośredniego ( * ) może służyć do odczytywania i zmieniania
wartości zmiennej, przechowywanej pod adresem zawartym we wskaźniku.
Normalna zmienna pozwala na bezpośredni dostęp do swojej wartości.
Przykład
Jeśli stworzy się zmienną typu int o nazwie nTwojWiek i chce się jej przypisać
wartość zmiennej nWiek to można to zrealizować w następujący sposób:
int nTwojWiek;
nTwojWiek = nWiek;
Wskaźnik pozwala na pośredni dostęp do wartości zmiennej, której adres
przechowuje. Żeby przypisać wartość zmiennej nWiek do zmiennej nTwojWiek
posługując się wskaźnikiem pWiek, trzeba napisać w ten sposób:
int nTwojWiek;
nTwojWiek = *pWiek;
Operator dostępu pośredniego (*) przed zmienną pWiek oznacza "wartość
przechowywana pod adresem zawartym w". To przypisanie można przeczytać
następująco: "Weź wartość przechowywaną pod adresem zawartą we
wskaźniku pWiek i przypisz ją do zmiennej nTwojWiek".
Wskaźniki, adresy i zmienne
Bardzo ważne jest, aby odróżniać wskaźnik, adres który ten wskaźnik
przechowuje i wartość przechowywaną pod adresem zawartym we
wskaźniku. Wiele nieporozumień wynika z nieprawidłowej interpretacji i
błędnego rozumienia tych trzech różnych terminów.
Przykład
int nZmienna = 5;
int *pWskaznik = &nZmienna;
pWskaznik jest zadeklarowany jako wskaźnik na zmienną typu int i jest
inicjalizowany adresem zmiennej nZmienna. pWskaznik (jak sama nazwa
wskazuje) jest wskaźnikiem. Adres przechowywany przez pWskaznik jest
adresem zmiennej zmienna. Wartość pod adresem przechowywanym przez
pWskaznik jest równa 5.
nZmienna
100 101 102 103104 105106 107108109110111
0000010
1
5
pWskaznik
0000
0000
0000
0101
101
Manipulowanie danymi za pomocą
wskaźników
Jeśli przypisze się do wskaźnika adres jakiejś zmiennej, to można
wykorzystywać ten wskaźnik do manipulowania wartością tej zmiennej.
Przykład
odczytujemy wartość spod adresu
przechowywanego
w
pWiek
i
wypisujemy tę wartość.
do
zmiennej
o
adresie
przechowywanym
we
wskaźniku
pWiek
(nMojWiek),
przypisujemy
wartość 7. Na 7 zmienia się zawartość
zmiennej nMojWiek.
do zmiennej nMojWiek przypisujemy
wartość 9. Wartość tę, bezpośrednio
i pośrednio odczytujemy w tych
fragmentach kodu
Kontrolowanie adresu
Wskaźniki pozwalają na manipulowanie adresami bez wiedzy o ich faktycznej
wartości. Powiedzieliśmy, że kiedy przypisujemy adres zmiennej do
wskaźnika, to on na prawdę ma wartość równą adresowi tej zmiennej.
Dlaczego by jednak tego nie sprawdzić?
Przykład
Kwintesencja
wskaźników:
Co przechowuje
wskaźnik?
Jak odczytać tę
wartość?
►
Zawsze do odczytania lub modyfikacji wartości zmiennej
przechowywanej pod danym adresem, wykorzystuj operator
adresowania pośredniego ( *).
►
Zawsze inicjalizuj wskaźniki albo konkretnym adresem zmiennej
wartością null (lub 0).
►
Zawsze pamiętaj o różnicy pomiędzy adresem przechowywanym
we wskaźniku, a wartością przechowywaną pod adresem.
Zazwyczaj wskaźniki są wykorzystywane w trzech sytuacjach:
♦
Zarządzanie danymi w pamięci operacyjnej.
♦
Dostęp do wnętrza klas - danych i funkcji.
♦
Przekazywanie wartości do zmiennych poprzez referencje.
Programiści na ogół wyróżniają pięć obszarów pamięci:
Obszar zmiennych globalnych
Wolna pamięć
Rejestry
Kod programu
Stos
Zmienne lokalne wraz z parametrami funkcji są przechowywane na stosie.
Kod znajduje się w obszarze kodu programu (co jest chyba oczywiste).
Zmienne globalne również znajdują się w przeznaczonym dla siebie
obszarze. Rejestry są wykorzystywane do wewnętrznego zarządzania
funkcjami (np. do przechowywania adresu szczytu stosu lub wskaźnika
instrukcji). Cała pozostała pamięć jest dla programu wolna (określa się ją
czasem jako stertę - ang. heap).
Problem ze zmiennymi lokalnymi polega na tym, że wraz z zakończeniem
funkcji są one przez program "zapominane". Zmienne globalne rozwiązują ten
problem, jednak kosztem nieograniczonego dostępu do nich z dowolnego
miejsca w programie, co niesie ze sobą znaczną komplikację kodu.
Umieszczenie danych w wolnej pamięci operacyjnej rozwiązuje oba problemy.
Można z powodzeniem traktować wolną pamięć jako ogromy zbiór
sekwencyjne ułożonych komórek pamięci "czekających" na dane. Jednak
dostęp do tych komórek nie jest tak swobodny jak np. dostęp do stosu. Przed
wykorzystaniem komórki trzeba „poprosić" system operacyjny o przydzielenie
adresu i zarezerwowanie odpowiedniej liczby komórek. Dopiero wtedy można
taki adres przypisać do wskaźnika i wykorzystywać.
Stos, w momencie wyjścia z funkcji, jest automatycznie czyszczony.
Wszystkie zmienne lokalne są wyrzucane z pamięci. Dane na stercie trwają
aż do zakończenia programu. Jest możliwość zwolnienia zarezerwowanej
pamięci, jeśli nie jest ona już potrzebna.
Zaletą sterty:
• pamięć w niej zarezerwowana jest dostępna tak długo, aż jej się
bezpośrednio nie zwolni. Jeżeli zarezerwuje się pamięć na stercie wewnątrz
funkcji to po zakończeniu funkcji, będzie ona nadal zarezerwowana.
• możliwość dostępu tylko przez te funkcje, które mają dostęp do wskaźnika
danego obszaru. Gwarantuje to spójność dostępu do danych i eliminuje
problem niepożądanej modyfikacji danych przez niepowołane do tego
funkcje.
Aby móc wykorzystywać pamięć na stercie trzeba mieć możliwość stworzenia
wskaźnika do obszaru na stercie i przekazania tego wskaźnika do wybranych
funkcji.
new
Do alokacji (rezerwacji) pamięci służy w C++ słowo kluczowe new.
Następuje po nim nazwa typu obiektu dla którego rezerwujemy pamięć.
Dzięki temu kompilator wie, ile pamięci ma zarezerwować.
Wartością zwracaną przez new jest adres w pamięci. Musi on być
przypisany do wskaźnika.
Przykład
unsigned short int * pWskaznik;
pWskaznik = new unsigned short int;
unsigned short int * pWskaznik = new unsigned short int;
W obu przypadkach, pWskaznik wskazuje na stercie na wartość typu
unsigned short int.
Można ten wskaźnik wykorzystywać dokładnie tak, jak wskaźnik na zmienną i
dowolnie przypisywać wartości do pamięci:
*pWskaznik = 72;
Oznacza to: "Wstaw 72 pod adres wskazywany przez pWskaznik" albo
"Przypisz 72 do obszaru wskazywanego przez pWskaznik".
delete
Kiedy zakończy się operacje na zarezerwowanym obszarze pamięci i nie
będzie się jej już więcej wykorzystywać to należy użyć instrukcji delete na
wskaźniku do danego obszaru.
Wskaźnik zadeklarowany w funkcji jest zmienną lokalną tej funkcji, w
przeciwieństwie do pamięci na stercie, na którą wskazuje. Kiedy funkcja się
skończy to wskaźnik ten, tak jak wszystkie zmienne lokalne zostanie
wyrzucony z pamięci (ze stosu). Oznacza to, że wskaźnika już nie będzie, ale
obszar na stercie będzie nadal zarezerwowany. Taki obszar jest już dla
programu niedostępny. Takie zjawisko określane jest jako ulatnianie się
pamięci. Tak zarezerwowana pamięci pozostanie zajęta (i niedostępna) aż do
zakończenia się programu.
Żeby zwolnić pamięć na stercie, musisz użyć słowa kluczowego delete.
Przykład
delete pWskaznik;
Przykład
Mimo że w tym konkretnym
przypadku jest nadmiarowa
ta instrukcja delete (koniec
programu
automatycznie
zwolni całą zarezerwowaną
pamięć)
to
dobrym
zwyczajem jest zadbanie o
to, aby samemu zwolnić,
przed
zakończeniem
programu,
całą
wykorzystywaną pamięć na
stercie.
Utrata obszarów na stercie
Innym przypadkiem, w którym tracimy dostęp do zarezerwowanego na
stercie obszaru, jest przypisanie nowego adresu do wskaźnika przed
zwolnieniem pamięci wskazywanej przez ten wskaźnik.
unsigned short int * pWskaznik = new unsigned short int;
*pWskaznik = 72;
pWskaznik = new unsigned short int;
*pWskaznik = 84;
Do wskaźnika pWskaznik ponownie przypisujemy, nowy adres obszaru na
stercie i w wstawiamy do tego obszaru wartość 84. Pierwszy obszar, ten z
wartością 72 jest nadal zarezerwowany ale już niedostępny, ponieważ
wskaźnik, który na niego wskazywał otrzymał nową wartość. Nie ma
możliwości odczytania ani zmiany zawartości tego obszaru. Będzie on
niepotrzebnie zajmował pamięć aż do zakończenia się programu.
unsigned short int * pWskaznik = new unsigned short int;
*pWskaznik = 72;
delete pWskaznik;
pWskaznik = new unsigned short int;
*pWskaznik = 84;
Tworzenie obiektów na stercie
Tak jak tworzyliśmy wskaźniki do zmiennych typu int, tak samo możemy
stworzyć wskaźnik do dowolnego innego obiektu. Jeżeli zadeklaruje się obiekt
typu Kot, to można zadeklarować wskaźnik do obiektów tej klasy i stworzyć
obiekt na stercie, podobnie jak na stosie. Składnia jest tu taka sama jak w
przypadku liczb całkowitych:
Kot *pKot = new Kot;
Zostanie wywołany konstruktor domyślny, ten bez parametrów. Konstruktor
jest wywoływany zawsze w momencie tworzenia obiektu danej klasy,
niezależnie od tego, czy operacja ma miejsce na stosie, czy na stercie.
Usuwanie obiektów
Kiedy wywoła się delete na wskaźniku do obiektu klasy znajdującego się na
stercie, to przed zwolnieniem pamięci zajmowanej przez obiekt zostanie
wywołany destruktor klasy, której jest dany obiekt. Dzięki temu klasa ma np.
możliwość zwolnienia dodatkowo zarezerwowanej pamięci tak, jak jest to
robione podczas usuwania obiektów ze stosu w momencie wyjścia z funkcji.
Przykład
na stosie tworzony jest obiekt
klasy ZwyklyKot, wskazywany
przez pRags.
Dostęp do danych wewnętrznych klasy
Dotychczas, dostęp do wewnętrznych danych i funkcji obiektów klasy
zadeklarowanych lokalnie realizowany był poprzez użycie operatora kropka ( .
). Żeby dostać się do elementów obiektu stworzonego na stercie trzeba
pośrednio odwołać się do tego obiektu za pomocą wskaźnika.
Przykład
(*pRags).PobierzWiek();
wywołuje funkcję wewnętrzną
PobierzWiek()
Nawiasy gwarantują, że odwołanie do obiektu nastąpi przed próbą dostępu
do funkcji PobierzWiek ().
Ponieważ takie stosowanie wskaźników jest raczej nieporęczne, C++
oferuje specjalny operator dla pośredniego dostępu do obiektów
wskazywanych przez wskaźniki. Operator "wskazujący na" ( -> )
składający się z myślnika ( - ) i znaku większości ( > ). C++ traktuje
to jako pojedynczy symbol.
Przykład
na stercie, tworzony jest
obiekt klasy ZwyklyKot.
Konstruktor
domyślny
ustala jego wiek na 5.
wywoływana jest metoda
PobierzWiek (). Ponieważ
odwołanie
następuje
poprzez wskaźnik, to
wykorzystujemy operator
"wskazujący na" ( -> ).
Dane wewnętrzne na stercie
Jedna (lub więcej) zmienna wewnętrzna może być wskaźnikiem na obiekt
na stercie. Pamięć może być zarezerwowana w konstruktorze klasy (albo w
jednej z jej metod) i może być zwolniona w destruktorze.
Przykład
Funkcja wywołująca, w tym
przypadku main (), "nie wie", że
nJegoWiek i nJegoWaga są
wskaźnikami. main () wywołuje
metody PobierzWiek () i UstawWiek
() , a szczegóły operacji na pamięci
zaszyte są wewnątrz implementacji
klasy.
Podczas
kasowania
obiektu
oFilemon,
wywoływany
jest
automatycznie
destruktor
klasy.
Destruktor kasuje wskaźniki. Jeśli
wskaźniki wskazywałyby na obiekty
innej klasy, to zostałyby wywołane
destruktory tych klas.
Wskaźnik this
Każda wewnętrzna funkcja klasy ma ukryty parametr: wskaźnik this.
this zawsze wskazuje na aktualny obiekt.
Przy każdym wywołaniu metod PobierzWiek () albo UstawWiek (), wskaźnik
this jest dołączany jako ukryty parametr.
Zadaniem wskaźnika this jest wskazywanie na obiekt, którego metoda
została wywołana. Zazwyczaj nie będziemy go potrzebowali, będzie się tylko
wywoływać metody i zmieniać zmienne wewnętrzne. Jednak czasami trzeba
zagwarantować dostęp do obiektu (np. zwrócić adres aktualnego obiektu). W
takiej sytuacji this będzie bardzo pomocny.
W normalnej sytuacji, aby dostać się do elementów klasy, nie potrzebuje się
wskaźnika this. Można jednak bezpośrednio odwołać się do this.
Przykład
Funkcje dostępu PobierzDlugosc () i UstawDlugosc ()
bezpośrednio wykorzystują wskaźnik this do odczytania
i modyfikacji zmiennych wewnętrznych obiektu
Prostokąt. PobierzSzerokosc() i UstawSzerokosc ()
dokonują odczytania i modyfikacji klasyczną metodą.
Efekt jest taki sam w obu sytuacjach, jednak metoda
klasyczna jest bardziej przejrzysta.
Wskaźniki const
Umieszczenie go przed nazwą typu, na samym początku deklaracji wskaźnika
powoduje że wskaźnik jest stały i nie może ulec zmianie. Słowo const po
gwiazdce i przed nazwą typu obiektu powoduje, że obiekt nie może być
zmieniony z wykorzystaniem deklarowanego wskaźnika.
Przykład
const int *pJeden;
int *const pDwa;
const int *const pTrzy;
pJeden jest wskaźnikiem na stałą typu int. Wartość ta nie może zostać
zmieniona z wykorzystaniem tego wskaźnika. Oznacza to, że nie możesz
napisać np. tak:
*pJeden = 5;
pDwa jest stałym wskaźnikiem na int. Wartość, na którą wskazuje może
się zmienić, ale wskaźnik nie. Oznacza to, że nie wolno napisać tak:
pDwa = &x
pTrzy jest stałym wskaźnikiem na
stałą int. Zarówno wartość jak i
wskaźnik nie mogą się zmienić.