Wszystko o wskaźnikach

Aby dobrze zrozumieć język C, należy dobrze zrozumieć wskaźniki, ponieważ są one używane praktycznie wszędzie, a ich zastosowanie jest praktycznie nieocenione. Wykorzystuje się je w funkcjach do przekazywania wartości oraz jako tablice zmiennej długości, a nawet jako listy (dynamiczne struktury).

Wskaźniki są niczym innym jak adresem do komórki pamięci. Taki adres można uzyskać dla zwykłej zmiennej poprzez operator "&".

Przykład 1:

int zmienna =3;
printf("adres komórki w pamięci to: %p  \n" ,&zmienna);
printf("wartosc zmiennej to: %i \n" ,zmienna);

Teraz wiemy, że każda zmienna ma jakiś adres w pamięci komputera. Oczywiście jest możliwość operowania tylko i wyłącznie na wskaźnikach. Aby zadeklarować wskaźnik należy użyć operatora gwiazdki "*". Tego samego operatora należy użyć jeśli chcemy zapisać wartość do wskaźnika.

Przykład 2:

int *wsk; // wskaznik na typ int
printf("adres komórki w pamięci to: %p  \n" ,wsk);
*wsk=4;
printf("wartosc wsk to: %i  \n" ,*wsk);

Podsumowując, aby dostać adres w pamięci dla zwykłej zmiennej należy użyć operatora"&". Dla zmienne zadeklarowanej jako wskaźnik nie trzeba stosować żadnego operatora, ale gdy dla takiej zmiennej chcemy uzyskać wartość, to musimy użyć operator "*", tak jak na przykładzie 2.

Mając już taką wiedzę możemy stworzyć funkcję, która będzie przekazywała wartości jako wskaźnik. Dzięki takiemu zastosowaniu będziemy mogli modyfikować wartości wewnątrz funkcji i wynosić te wartości na zewnątrz nawet po zamknięciu funkcji. Oczywiście dzięki temu sposobowi można wynieść wiele zmiennych i to jest jedyny sposób, aby tego dokonać.

Przykład 3:

#include <stdio.h>
#include <stdlib.h>

void zrob(int *zm)
{
 *zm = 100; //uzywajac operatora "*" możemy dostac sie do wartosci
}

int main(int argc, char *argv[]) {
 int zmienna =3;
 printf("zmienna przed wywolaniem funkcji zrob = %i \n" ,zmienna);
 zrob(&zmienna); //trzeba uzyc "&", aby dostac się do adresu
 printf("zmienna po wywolaniu funkcji zrob = %i",zmienna);
 return 0;
}

Widzimy, że zmienna jest zadeklarowana jako wartość int. Do funkcji zrob() przekazywany jest tylko adres tej zmiennej w postaci "&zmienna". W funkcji zrob() zmieniamy wartość na 100, dlatego nawet po zakończeniu działania funkcji program wie, że zmienna została zmieniona na 100.

Można się zastanowić, dlaczego do wskaźników należy zawsze podać typ zmiennej skoro wskaźnik przekazuje tylko adres do pamięci. Przecież komórki pamięci mogłyby przydzielać pamięć automatycznie i dostosowywać się do zmiennych zapisywanych w pamięci. Odpowiedź jest prosta. Otóż wskaźniki i tablice mają ze sobą wiele wspólnego. Tablicę można traktować jako wskaźnik, który wskazuje na następne elementy tablicy tak jak to zaprezentowałem na przykładzie 4. Oczywiście jest jeden haczyk, który można zauważyć poprzez użycie funkcji sizeof();

Przykład 4:

#include <stdio.h>
#include <stdlib.h>


int main(int argc, char *argv[]) {
 char *wsk; // wskaznik na typ char
 char tab[2]={'a','b'};    
 wsk=tab;
 printf("adres komorki w pamieci to: %p  \n" ,wsk);
 printf("wartosc wsk to: %c  \n" ,*wsk);    
 printf("adres komorki w pamieci to: %p  \n" ,wsk + 1);
 printf("wartosc wsk to: %c  \n" ,*(wsk + 1));
 printf("dlugosc wsk: %i  \n\n" ,sizeof(wsk));
 
 printf("adres komorki w pamieci to: %p  \n" ,tab);
 printf("wartosc wsk to: %c  \n" ,*tab);    
 printf("adres komorki w pamieci to: %p  \n" ,tab + 1);
 printf("wartosc wsk to: %c  \n" ,*(tab + 1));
 printf("dlugosc tablicy: %i  \n" ,sizeof(tab));
 return 0;
}

Na poniższym obrazku można zobaczyć co zrobił kod z przykładu 4:

Kod działa następująco. Najpierw deklaruje wskaźnik i tablicę dwuelementową typu char z wartościami a i b. Następnie przypisuje wskaźnik wsk do wskaźnika na tablicę tab. Dalej dane zostają wyświetlone jako wsk i tab bez ich modyfikacji.

Jak widać na obrazku kolorem zielonym zaznaczyłem komórki pamięci. Jak można było się spodziewać dla zmiennej wsk i tab są takie same, bo wskazują te same elementy w pamięci. Adresy w komórkach został powiększony o 1 bajt z 0022FEEA do 0022FEEB, czyli z A na B, to 1 bajt. Tyle zajmuje w pamięci zapisanie zmiennej char 1 bajt = 8 bitów = 256. Jeśli byśmy zmienili char na int, to najprawdopodobniej system zarezerwowałby 4 bajty w pamięci = 32 bity, bo tyle zajmuje zmienna int w systemie 32 bitowym. Oczywiście te wartości mogą się zmieniać w zależności od systemu operacyjnego i kompilatora.

Na obrazku zaznaczyłem także kolorem czerwonym wywołanie funkcji sizeof() i w tym przypadku wywołanie dla wsk i tab są różne.Dlaczego tak się dzieje?

Wywołując sizeof(tab) uzyskaliśmy 2, czyli liczbę elementów tablicy (tak jak chcieliśmy). A wywołując zmienną wsk uzyskaliśmy 4. Ale dlaczego? Jest to liczba bajtów potrzebna do zapisania adresu w pamięci komputera. Dla systemów 32 bitowych jest to 4 bajty, a dla systemów 64 bitowych jest to 8 bajtów. Dlatego też należy uważać na to gdzie wywołuje się funkcję sizeof().

Przykład 5:

void rob(char tab[])
{...}
void rob(char *tab)
{...}
//obie te funkcje są identyczne

Obie funkcje z przykładu 5 są identyczne. Nie jest istotne, że mają inne wartości funkcji, kompilator i tak zawsze zrzutuje tablice do wskaźnika.

Jeśli wskaźnik tablicowy przekażemy do funkcji (tak jak to zaprezentowałem w przykładzie 5), to zawsze zostanie on zrzutowany na zwykły wskaźnik i funkcja sizeof() będzie wskazywała 4 lub 8. Nieważne czy przekazujemy tablice, czy wskaźnik, zawsze wynikiem będzie wskaźnik. Dobrze wiedzieć o takim zachowaniu wskaźników i tablic w funkcjach, bo może to przysporzyć wielu nieoczekiwanych błędów.

Następną bardzo ważną sprawą jest, aby działać na tablicach wtedy gdy chcemy modyfikować dane, ponieważ raz zadeklarowany wskaźnik będzie zachowywał się jak stała i będzie służyć tylko do odczytu. Dla przykładu:

Przykład 6:

#include <stdio.h>
#include <stdlib.h>


int main(int argc, char *argv[]) {
 char tab[]="avrkwiat";    
 puts(tab);
 tab[0]='A';
 puts(tab);
 return 0;
}

Przykład 6 po skompilowaniu ukaże nam napisy avrkwiat i Avrkwiat. Ponieważ zmienne tablicowe można modyfikować, ale co się stanie, gdy zamiast talbicy użyjemy wskaźników tak jak w przykładzie 7.

Przykład 7:

#include <stdio.h>
#include <stdlib.h>


int main(int argc, char *argv[]) {
 char *wsk = "avrkwiat";
 puts(wsk);
 wsk[0]='A';
 puts(wsk);
 return 0;
}

W powyższym przykładzie kod się skompiluje i odpali. Nawet wyświetli nam napis avrkwiat, ale gdy spróbujemy modyfikować literał łańcuchowy, to program się wykrzaczy. Przyczyną takiego zachowania jest zapis wskaźników w pamięci tylko do odczytu. Aby uniknąć takich błędów zawsze kiedy przypisujemy łańcuch znaków do wskaźnika, to należy dodać słówko const char *wsk ="avrkwiat"; wtedy kompilator wyrzuci błąd w momencie modyfikacji tego łańcucha. Najlepszą metodą na uniknięcie tego błędu jest stosowanie tablic, a nie wskaźników wszędzie tam, gdzie mamy zamiar modyfikować dane.