Elektronika - baza wiedzy

Cześć 15 kursu C/C++


15. Wskaźniki

Załóżmy, że zadeklarowaliśmy sobie w programie zmienną typu int o nazwie x i od razu przypisaliśmy jej wartość 124. Czyli wyglądałoby to następująco:

int x=124;

Załóżmy teraz, że kompilator umieścił naszą zmienną w pamięci pod adresem 1000. Czyli wyglądałoby to tak jak na rysunku przedstawionym powyżej. Spróbój teraz wczuć się w rolę kompilatora w przypadku, gdy w swoim programie każdesz mu wykonać następujące polecenie:

i = x;

Czyli do jakiejś zmiennej o nazwie i (która jest tu nieistotna) przypisujesz wartość zmiennej x. Pomyśl co musi zrobić kompilator, żeby wykonać to polecenie. Interesuje nas skąd kompilator wie jaką wartość ma nasza zmienna x ? Odpowiedź brzmi: nie wie. Jednak wie, w którym miejscu pamięci znajduje się zmienna. Sprawdza więc jaki jest jej adres - w naszym przypadku jest to adres 1000. Następnie odczytuje komórkę pamięci znajdującą się pod tym adresem i voila ! Otrzymuje w efekcie liczbę 124, która się tam znajduje. Zastanawiasz się teraz zapewne po co Ci to wszystko mówię ? Otóż podobnie, z naszego (czyli programisty) punktu widzenia, działają wskaźniki. Załóżmy, że oprócz naszej zmiennej x mamy jeszcze jedną zmienną (której deklaracji na razie nie podam), która ma wartość 1000. W naszym przypadku jest ona umieszczona pod adresem 1016. Przypatrz się teraz uważnie rysunkowi. Czy nic nie wydaje Ci się podejrzane ? Nasza druga zmienna zawiera wartość, która jest równa adresowi zmiennej x ! Tą drugą zmienną nazywamy wskaźnikiem i w przypadku, gdy chcemy odczytać zawartość komórki pamięci o adresie, który jest zawarty w tej zmiennej to mówimy kompilatorowi (oczywiście przy pomocy odpowiedniej składni): "Słuchaj, podaj mi zawartość komórki pamięci, której adres jest zawarty w tej oto zmiennej (i tu ją podajemy)". Kompilator oczywiście stosuje się do naszego polecenia i w efekcie mamy to o co nam chodziło.
To by było na tyle jeśli chodzi o wyjaśnienie pojęcia wskaźnika. W następnych podpunktach zostaną pokazane na podstawie przykładowych programów sposoby ich deklaracji i wykorzystania. Mam prośbę, żebyś ten punkt czytał szczególnie uważnie, gdyż jest to temat, który po pierwsze jest trudny, a po drugie bardzo ważny. Praktycznie nie ma żadnego poważniejszego programu, który nie operowałby na wskaźnikach.

I jeszcze jedna uwaga na koniec tego wstępu do wskaźników. Nie bierz, proszę, zbyt dosłownie sposobu odczytu watości zmiennej przez kompilator. Opisałem sposób, w jaki robiłby to gdyby był człowiekiem. Kompilator człowiekiem jednak nie jest i robi to w rzeczywistości trochę inaczej. Z Twojego punktu widzenia nie jest jednak ważne jak, ważne jest to, żeby zrobił to dobrze. Zastosowałem taką formę opisu w takim celu, żebyś mógł łatwiej zrozumieć istotę wskaźników. Mam nadzieję, że dzięki temu uda mi się wyjaśnić ten trudny dla początkującego temat w sposób przystępny.



15.1 Pierwszy program korzystający ze wskaźników


Ponieważ masz już teoretyczne podstawy nadszedł już czas, aby zaprezentować istotę wskaźników w praktyce. A oto nasz pierwszy program, w którym wykorzystamy wskaźniki:



#include <stdio.h>


void main(void) {

int a;

int *b;

a = 11;

printf("Zmienna a jest rowna %d ", a);

b = &a;

printf("Wartosc wskazywana przez wskaznik b wynosi %d ", *b);

*b = 14;

printf("Zmienna a jest rowna %d ", a);

}

Zanim przejdziesz do dalszego czytania kursu skompiluj i uruchom powyższy program. Na ekranie powinno pojawić się:


Zmienna a jest rowna 11

Wartosc wskazywana przez wskaznik b wynosi 11

Zmienna a jest rowna 14

Jak zwykle przejdziemy teraz do analizy naszego programu. Na początku mamy zwykłą deklarację zmiennej o nazwie a, która jest typu int. Jednak już w następnej linijce mamy coś nowego - jak się domyślasz jest to deklaracja wskaźnika. Zauważ jak niewiele różni się ona od deklaracji zwykłej zmiennej. Na początku piszemy jakiego typu będzie nasz wskaźnik (tutaj jest to wskaźnik na int), a następnie nazwę zmiennej, którą jednak, w odróżnieniu od deklaracji zwykłej zmiennej, poprzedzamy gwiazdką. Następne kilka linijek ma za zadanie pokazać istotę wskaźników. Mam nadzieję, że to co po wyjaśnieniu teoretycznym mogło wydawać się niejasne, teraz stanie się zrozumiałe. Pierwsze dwie linijki tego fragmentu to nic nowego - zmiennej a&mbsp; przypisujemy wartość jedenaście i następnie wyświetlamy ją na ekranie. Jednak w następnej linijce mamy do czynienia z nową konstrukcją. Do zmiennej b (która, jak zapewne pamiętasz, jest typu wskaźnik na int) przypisujemy adres zmiennej a. Aby otrzymać adres danej zmiennej wystarczy postawić przed nią znak ampersand "&". Tak więc wyrażenie "&a" określa nam adres zmiennej a. Zwróć uwagę na to, co zostało podkreślone - adres, a nie wartość ! Tak jak to było w wyjaśnieniu teoretycznym: jeśli nasza zmienna a znajdowałaby się w pamięci komputera pod adresem 1000 to zmienna b będzie miała wartość 1000, a nie 11 (która jest wartością przechowywaną przez zmienną a). Następna linijka pokazuje jak odczytać wartość wskazywaną przez zmienną b - wystarczy przed nazwą zmiennej wskaźnikowej postawić gwiazdkę. Zapamiętaj więc: jeśli b jest zmienną wskaźnikową to wyrażenie "b" określa nam adres, który jest przechowywany w tej zmiennej, natomiast wyrażenie "*b" określa wartość przechowywaną w pamięci komputera pod adresem, który zawiera ta zmienna. W następnej linijce mamy pokazane do czego może nam to się przydać. Mamy tu do czynienia z taką oto sytuacją:

*b = 14;

Pamiętasz co oznaczało wyrażenie "*b" ? Byłąa to wartość wskazywana przez zmienną b. A ponieważ zmienna b wskazywałą na zmienną a (wskazania tego dokonaliśmy w linijce "b = &a;") to mimo tego, że w całym tym wyrażeniu nie jest wspomniana nawet zmienna a, to tak naprawdę dokonamy zmiany właśnie jej wartości ! Aby to udowodnić w następnej linijce wypisujemy na ekranie wartość zmiennej a - i rzeczywiście jest ona teraz równa czternaście. Jeśli ciągle nie wiesz dlaczego to przeczytaj uważnie jeszcze raz ten podpunkt (oraz podpunkt teoretyczny). Jest to niezmiernie ważne, gdyż bez jego zrozumienia nie uda Ci się zrozumieć podpunktów następnych.

15.2 Wskaźniki a tablice



Między tymi dwoma konstrukcjami w języku C zachodzi ścisły związek. Nazwa tablicy może być traktowana jako stały wskaźnik do pierwszego jej elementu. Wynika z tego, że tablicę można bezpośrednio (bez rzutowania) przypisać do wskaźnika (oczywiście zarówno tablica, jak i wskaźnik muszą być tego samego typu). Pokazuje to następujący program:



#include <stdio.h>


void main(void) {

int tablica[3] = {5, 10, 15};

int *wskaznik;

int i;

for(i=0; i<3; i++)

printf("tablica[%d] = %d ", i, tablica[i]);

wskaznik = tablica;

for(i=0; i<3; i++, wskaznik++)

printf("*(wskaznik+%d) = %d ", i, *wskaznik);

}



Na początku deklarujemy trzyelementową tablicę typu int (z automatyczną inicjalizacją), jedną zmienną będącą wskaźnikiem na int oraz "zwykłą" zmienną typu int. Zaraz po deklaracji zmiennych używanych przez program wyświetlamy w pętli wartości poszczególnych elementów tablicy. Czyli na razie nic nowego. To o czym traktuje ten podpunkt jest zawarte w następnych trzech linijkach programu. W pierwszej z nich dokonujemy przypisania tablicy do wskaźnika. Możemy to zrobić, gdyż, tak jak napisałem we wstępie do tego podpunktu, nazwa tablicy może być traktowana jako wskaźnik do jej pierwszego elementu. Zapamiętaj jednak że ta operacja przypisuje wskaźnikowi adres pierwszego elementu tablicy, a nie jego wartość ! Następne dwie linijki pokazują z kolei, że na wskaźnikach można także dokonywać pewnych operacji arytmetycznych. W naszym przykładowym programie w pętli for dokonujemy zwiększenia wskaźnika o jeden przy pomocy operatora ++ . Zapamiętaj jednak, że operacja ta zwiększa wskaźnik o jeden element typu, na który wskazuje wskaźnik, a nie o jeden bajt ! Czyli jeśli wskaźnik wskazuje (tak jak w naszym przypadku) na int to operacja zwiększenia wskaźnika spowoduje, że zmienna wskaźnikowa będzie zawierała adres następnego elementu typu int (czyli de facto będzie większa o cztery bajty, bo tyle właśnie zajmuje int). W większości przypadków wiedza ta co prawda nie będzie Ci potrzebna, ale czasami może okazać się przydatna (np. w sytuacji odnoszenia się do tego samego obszaru pamięci przy pomocy różnych wskaźników). Ostatnia linijka programu ma za zadanie wyświetlenie wartości, na którą wskazuje nasz wskaźnik. Zauważ, że użyliśmy tu konstrukcji "*wskaznik" ponieważ właśnie w ten sposób otrzymujemy tą wartość.

15.3 Wskaźniki do struktur


Kolejnym zagadnieniem, które chcę poruszyć jest używanie wskaźników, które wskazują na strukturę. Odwoływanie się do poszczególnych pól struktury jest nieco inne w takim przypadku, jednak różnica jest minimalna. Pokazuje to poniższy program (który jest drobną przeróbką programu już wcześniej przez nas analizowanego):


#include <stdio.h>

typedef struct {

int godziny;

int minuty;

int sekundy;

} CZAS;


void main(void)

{

CZAS teraz={23,53,21};

CZAS *wsk = &teraz;

printf("Teraz jest %d:%d:%d ", teraz.godziny, teraz.minuty, teraz.sekundy);

printf("Teraz jest %d:%d:%d ", wsk-godziny, wsk-minuty, (*wsk).sekundy);

}


Program ten ma za zadanie wyświetlić na ekranie godzinę, która zawarta jest w strukturze typu CZAS. Na początku deklarujemy sobie zmienną strukturalną o nazwie teraz i od razu przypisujemy jej konkretną wartość. W następnej linijce mamy do czynienia z deklaracją wskaźnika na strukturę typu CZAS, któremu także przypisujemy od razu wartość - w naszym przypadku inicjalizujemy go tak, aby wskazywał na naszą zmienną strukturalną teraz. Następne dwie linijki wyświetlają na ekranie ten sam tekst tylko używając do tego celu różnych zmiennych. W pierwszej z nich robimy to przy użyciu "normalnej" zmiennej strukturalnej - w takim przypadku, jak zapewne pamiętasz, nazwę pola oddzielamy kropką. W drugiej linijce mamy do czyniania z sytacją, w której odwołujemy się do pola struktury poprzez wskaźnik do niej. W takim przypadku jedyną różnicą jest to, że zamiast kropki stosujemy swego rodzaju strzałkę zbudowaną z dwóch znaków: minusa oraz znaku większości. W naszym programie odwołujemy się w ten sposób do dwóch pierwszych pól: godziny i minuty. Aby zademonstrować inną możliwość do pola sekundy odwołaliśmy się w nieco inny sposób. Jak zapewne pamiętasz konstrukcja "*nazwa", gdzie nazwa jest wskaźnikiem powoduje, że w efekcie otrzymujemy wartość, na którą wskazuje nasz wskaźnik. W tym przypadku właśnie to wykorzystaliśmy - użycie konstrukcji "*wsk" powoduje, że teraz mamy do czynienia z wartością, na którą wskazuje zmienna wsk (w naszym przypadku jest to strukturą CZAS). A ponieważ mamy teraz do czynienia ze zwykłą strukturą to do pola możemy się "dobrać" przy pomocy zwykłej kropki. Należy jednak tu pamiętać o tym, aby "*wsk" zawrzeć w nawiasach ponieważ operator * ma mniejszy priorytet od operatora .


15.4 Dynamiczny przydział pamięci


Pamiętasz tablice, prawda ? Wielkość tablicy musiała być ustalona już w momencie pisania programu. W przypadku prostych programów nie jest to problemem, ale co zrobić, gdy potrzebną wielkość tablicy będzie można określić dopiero po uruchomieniu programu ? Na przykład dane o pracownikach jakiejś firmy mają być odczytywne z pliku - w takim przypadku, aż do momentu otwarcia pliku wielkość ta nie jest znana. Można co prawda zadeklarować tablicę o wielkości dużo większej niż to prawdopodobnie będzie potrzebne, ale wiaże się z tym duże marnotrawstwo pamięci. Poza tym może okazać się, że w pewnym momencie wielkość ta i tak nie będzie wystarczająca. Z pomocą przychodzą nam wskaźniki i możliwość, którą udostępniają a mianowicie dynamiczny (czyli już w trakcie pracy programu) przydział pamięci operacyjnej. Ich zastosowanie do tego celu pokażemy jak zwykle na przykładowym programie. Aby nie wprowadzać niepotrzebnego zamieszania zdecydowałem się dokonać przeróbki programu już analizowanego (w podpunkcie poświeconemu tablicom struktur). Omówione zostaną tu tylko różnice. A oto program:



#include <stdio.h>

#include <stdlib.h>

typedef struct {

int nr_id;

float pensja;

} PRACOWNIK;


void main(void) {

int i;

PRACOWNIK *kadra;

// przydzielamy pamiec na dynamiczna tablice

kadra = (PRACOWNIK*) malloc(3 * sizeof(PRACOWNIK));

// sprawdzenie czy udalo sie zaalokowac pamiec

if(kadra != NULL) {

printf("Brak pamieci !"); exit(0);
}

// wpisujemy wartosci dla poszczegolnych pracownikow

kadra-nr_id = 25803; kadra-pensja = 1299.10;

(kadra+1)-nr_id = 25809; (kadra+1)-pensja = 2100;

(kadra+2)-nr_id = 7; (kadra+2)-pensja = 1500;


// wyswietlamy informacje o pracowniku - jego nr id, oraz pensje

for(i=0; i<3; i++)

printf("Nr identyfikacyjny: %d Pensja: %5.2f ",

kadra[i].nr_id, kadra[i].pensja);

// zwalniamy przydzielona pamiec

free(kadra);

}



Pierwsza różnica jest już przy dołączają plików nagłówkowych. Oprócz "standardowego" stdio.h dołączamy jeszcze stdlib.h . Plik ten zawiera deklaracje nowych funkcji, które użyjemy w naszym programie - malloc oraz free . Przejdźmy dalej - mamy tu deklarację struktury o nazwie PRACOWNIK, następnie zmiennej i. W następnej linijce natykamy się na następną różnicę - zamiast trzyelementowej tablicy struktur typu PRACOWNIK deklarujemy tu wskaźnik na strukturę typu PRACOWNIK. Następna linijka zawiera już całkiem nową rzecz - wywołanie funkcji malloc . Funkcja ta alokuje (przydziela dla programu) pamięć operacyjną o wielkości podanej przy wywołaniu (w bajtach). W naszym przypadku każemy przydzielić jej pamięć o wielkości "3 * sizeof(PRACOWNIK)". Jak zapewne pamiętasz operator sizeof zwraca wielkość w bajtach podanego parametru. Tak więc wynika z tego, że kazaliśmy funkcji malloc przydzielić tyle pamięci, aby zmieściły się w niej trzy struktury typu PRACOWNIK. Funkcja ta, w przypadku gdy przydział pamięci się powiódł, zwraca adres pierwszego zaalokowanego bajtu pamięci. Ponieważ nasz wskaźnik o nazwie kadra wskazuje na strukturę PRACOWNIK, a funkcja malloc zwraca wskaźnik typu void* to musimy jeszcze dokonać rzutowania. Rzutowanie wskażników robi się w ten sam sposób jak rzutowanie typów prostych - jedynym wyjątkiem jest dodanie gwiazdki za nazwą typu. Tak więc, aby dokonać rzutowania wskaźnika typu void* na wskaźnik na strukturę PRACOWNIK musimy dodać jeszcze "(PRACOWNIK*)" i dopiero teraz możemy przypisać ten adres naszej zmiennej. Jak już do tego doszliśmy to wyjaśnię jeszcze zastosowanie wskaźnika na void. Jak zapewne pamiętasz słowo kluczowe void określa brak typu. Nie można deklarować zmiennych typu void, jedynym dopuszczalnym miejscem, gdzie można go użyć jest określenie typu, który zwraca funkcja. Natomiast wskaźnika na void jak najbardziej można używać i na dodatek jest on bardzo przydatny. Możesz więc zadeklarować sobie zmienną typu wskaźnik na void. Bardzo ważną zaletą tego wskaźnika jest to, że możesz dokonać przypisania na niego dowolnego innego wskaźnika. Oczywiście, z uwagi na to, że typ na który on wskazuje nie jest określony nie możesz dokonywać na nim operacji arytmetycznych. Dobrze to taka mała dygresja, wróćmy do analizy naszego programu. Otóż napisałem, że funkcja malloc zwraca nam adres przydzielonego bloku pamięci. Co jednak jest w sytuacji, gdy przydział pamięci nie jest możliwy np. w sytuacji, gdy próbujemy sobie zaalokować 100 MB pamięci na komputerze wyposażonym w tylko 32 MB (pomijam tu istnienie pamięci wirtualnej). Otóż w takim przypadku zamiast adresu zwrócona zostaje wartość NULL . Jest to specjalnie zadeklarowana wartość mówiąca o tym, że wskaźnik jest pusty (czyli nie wskazuje na żadne dane). Dlatego, aby zabezpieczyć się przed używaniem pamięci, która nie została nam przydzielona należy zaraz po wywołaniu tej funkcji sprawdzić wartość, która zostałą zwrócona. Dokonujemy tego w następnej linijce. W naszym przypadku w sytuacji, gdy przydział pamięci nie był możliwy wypisujemy tylko na ekranie odpowiedni komunikat i wychodzimy z programu. Program możemy zakończyć w dowolnym momencie przy użyciu funkcji exit . Jedynym jej parametrem jest kod wyjścia, który zostanie przekazany do systemu operacyjnego. W przypadku, gdy nasz program nie będzie używany w plikach wsadowych nie jest on ważny. Skoro już udało nam się przydzielić pamięć i jesteśmy tego pewni to musimy wpisać do niej jakieś dane. W naszym programie będą to dane o pracownikach naszej firmy. Przypisania dokonujemy w taki sposób jak przedstawione to było w poprzednich podpunktach. W następnych trzech linijkach programu wyświetlamy dopiero co wpisane dane. Zauważ tu, że mimo tego, że zmienna kadra jest wskaźnikiem to uźywamy jej tu tak samo jakby była tablicą. Wynika to z zależności między wskaźnikami a tablicami przedstawionymi w jednym z poprzednich podpunktów kursu. W ostatniej linijce programu mamy do czynienia z drugą większą nowością - funkcją free . Funkcja ta zwraca do systemu przydzielona wczeniej przy pomocy funkcji malloc pamięć. Mimo, że w tym przypadku nie musieliśmy tego robić bo przy wyjściu z programu pamięć ta zostałaby automatycznie zwrócona, to jednak do dobrego zwyczaju programistycznego należy zwrócenie wszystkich zaalokowanych zasobów. Pamiętaj, że po zwolnieniu pamięci nie możesz już używać tego obszaru pamięci ! Teoretycznie możesz go modyfikować (bo ciągle masz do niego wskaźnik), ale nie powienineś, gdyż system operacyjny mógł przydzielić tą pamięć innemu programowi i takie grzebanie po zasobach drugiego programu może zakończyć tym, że program ten (lub nawet cały system) się zawiesi. W systemach operacyjnych z ochroną pamięci natomist może to spowodować błąd ochrony i niekontrolowane wyjście z twojego programu z błędem.