C/Wskaźniki - więcej
O co chodzi z tym typem, na który ma wskazywać? Czemu to takie ważne?
edytujJest to ważne z kilku powodów.
Różne typy zajmują w pamięci różną wielkość. Przykładowo, jeżeli w zmiennej typu unsigned int zapiszemy liczbę 655 30, to w pamięci będzie istnieć jako:
+--------+--------+ |komórka1|komórka2| +--------+--------+ |11111111|11111010| = (unsigned int) 65530 +--------+--------+
Wskaźnik do takiej zmiennej (jak i do dowolnej innej) będzie wskazywać na pierwszą komórkę, w której ta zmienna ma swoją wartość.
Jeżeli teraz stworzymy drugi wskaźnik do tego adresu, tym razem typu unsigned char*, to wskaźnik przejmie ten adres prawidłowo[1], lecz gdy spróbujemy odczytać wartość na jaką wskazuje ten wskaźnik to zostanie odczytana tylko pierwsza komórka i wynik będzie równy 255:
+--------+ |komórka1| +--------+ |11111111| = (unsigned char) 255 +--------+
Gdybyśmy natomiast stworzyli inny wskaźnik do tego adresu tym razem typu unsigned long* to przy próbie odczytu odczytane zostaną dwa bajty z wartością zapisaną w zmiennej unsigned int oraz dodatkowe dwa bajty z niewiadomą zawartością i wówczas wynik będzie równy 65530 * 65536 + przypadkowa wartość :
+--------+--------+--------+--------+ |komórka1|komórka2|komórka3|komórka4| +--------+--------+--------+--------+ |11111111|11111010|????????|????????| +--------+--------+--------+--------+
Ponadto, zapis czy odczyt poza przydzielonym obszarem pamięci może prowadzić do nieprzyjemnych skutków takich jak zmiana wartości innych zmiennych czy wręcz natychmiastowe przerwanie programu. Jako przykład można podać ten (błędny) program[2]:
#include <stdio.h>
int main(void)
{
unsigned char tab[10] = { 100, 101, 102, 103, 104, 105, 106, 107, 108, 109 };
unsigned short *ptr = (unsigned short*)&tab[2];
unsigned i;
*ptr = 0xffff;
for (i = 0; i < 10; ++i) {
printf("%d\n", tab[i]);
tab[i] = tab[i] - 100;
}
printf("poza tablica: %d\n", tab[10]);
tab[10] = -1;
return 0;
}
Nie można również zapominać, że na niektórych architekturach dane wielobajtowe muszą być odpowiednio wyrównane w pamięci. Np. zmienna dwubajtowa może się znajdować jedynie pod parzystymi adresami. Wówczas, gdybyśmy chcieli adres zmiennej jednobajtowej przypisać wskaźnikowi na zmienną dwubajtową mogłoby dojść do nieprzewidzianych błędów wynikających z próby odczytu niewyrównanej danej.
Zaskakujące może się okazać, że różne wskaźniki mogą mieć różny rozmiar. Np. wskaźnik na char może być większy od wskaźnika na int, ale również na odwrót. Co więcej, wskaźniki różnych typów mogą się różnić reprezentacją adresów. Dla przykładu wskaźnik na char może przechowywać adres do bajtu natomiast wskaźnik na int ten adres podzielony przez 2.
Podsumowując, różne wskaźniki to różne typy i nie należy beztrosko rzutować wyrażeń pomiędzy różnymi typami wskaźnikowymi, bo grozi to nieprzewidywalnymi błędami.
Arytmetyka wskaźników
edytujW języku C na wskaźnikach można dokonywać dodawania i odejmowania, jak na zwykłych liczbach całkowitych. Istotne jest jednak, że zmiana adresu jest ściśle związane z typem obiektu, na który wskazuje wskaźnik. Dodanie do wskaźnika liczby 2 nie spowoduje przesunięcia się w pamięci komputera o dwa bajty. Tak naprawdę przesuniemy się o dwukrotność rozmiaru typu zmiennej, co w przypadku char daje przesunięcie adresu o 2 bajty, ale już dla float o 8 bajtów. Jest to bardzo ważna informacja! Początkujący programiści popełniają często dużo błędów związanych z nieprawidłową arytmetyką wskaźników.
Spójrzmy na przykład:
int *ptr;
int a[] = {1, 2, 3, 5, 7};
ptr = a; /* to znaczy &a[0] */
Otrzymujemy następującą sytuację:
Gdy wykonamy:
ptr += 2;
wskaźnik ustawi się na trzecim elemencie tablicy.
Wskaźniki można również od siebie odejmować, czego wynikiem jest odległość dwóch wskazywanych wartości. Odległość zwracana jest jako liczba obiektów danego typu, a nie liczba bajtów. Np.:
int a[] = {1, 2, 3, 5, 7};
int *ptr = a + 2;
int diff = ptr - a; /* diff ma wartość 2 (a nie 2*sizeof(int)) */
Wynikiem może być oczywiście liczba ujemna. Operacja jest przydatna do obliczania wielkości tablicy (długości łańcucha znaków) jeżeli mamy wskaźnik na jej pierwszy i ostatni element.
Operacje arytmetyczne na wskaźnikach mają pewne ograniczenia. Przede wszystkim nie można (tzn. standard tego nie definiuje) skonstruować wskaźnika wskazującego gdzieś poza zadeklarowaną tablicę, chyba, że jest to obiekt zaraz za ostatnim, np.:
int a[] = {1, 2, 3, 5, 7};
int *ptr;
ptr = a + 10; /* niezdefiniowane */
ptr = a - 10; /* niezdefiniowane */
ptr = a + 5; /* zdefiniowane (element za ostatnim) */
*ptr = 10; /* to już nie! */
Nie można [3] również odejmować od siebie wskaźników wskazujących na obiekty znajdujące się w różnych tablicach, np.:
int a[] = {1, 2, 3}, b[] = {5, 7};
int *ptr1 = a, *ptr2 = b;
int diff = a - b; /* niezdefiniowane */
Tablice a wskaźniki
edytujTrzeba wiedzieć, że tablice to też rodzaj zmiennej wskaźnikowej. Taki wskaźnik wskazuje na miejsce w pamięci, gdzie przechowywany jest jej pierwszy element. Następne elementy znajdują się bezpośrednio w następnych komórkach pamięci, w odstępie zgodnym z wielkością odpowiedniego typu zmiennej.
Na przykład tablica:
int tab[] = {100,200,300};
występuje w pamięci w sześciu komórkach[4]:
+--------+--------+--------+--------+--------+--------+ |wartosc1| |wartosc2| |wartosc3| | +--------+--------+--------+--------+--------+--------+ |00000000|01100100|00000000|11001000|00000001|00101100| +--------+--------+--------+--------+--------+--------+
Stąd do trzeciej wartości można się dostać tak (komórki w tablicy numeruje się od zera):
zmienna = tab[2];
albo wykorzystując metodę wskaźnikową:
zmienna = *(tab + 2);
Z definicji obie te metody są równoważne.
Z definicji (z wyjątkiem użycia operatora sizeof) wartością zmiennej lub wyrażenia typu tablicowego jest wskaźnik na jej pierwszy element (tab == &tab[0]).
Co więcej, można pójść w drugą stronę i potraktować wskaźnik jak tablicę:
int *wskaznik;
wskaznik = tab + 1;
/* lub wskaznik = &tab[1]; */
zmienna = wskaznik[1]; /* przypisze 300 */
Jako ciekawostkę podamy, iż w języku C można odnosić się do elementów tablicy jeszcze w inny sposób:
printf ("%d\n", 1[tab]);
Skąd ta dziwna notacja? Uzasadnienie jest proste:
tab[1] = *(tab + 1) = *(1 + tab) = 1[tab]
Podobną składnię stosuje m.in. asembler GNU.
Należy uważać, aby nie odwoływać się do komórek poza przydzieloną pamięcią, np.:
int tab[] = { 0, 1, 2 };
tab[3] = 3; /* Błąd off by one */
Argument funkcji jako wskaźnik na tablicę
edytujPoniższy program obrazuje przekazywanie adresu tablicy do funkcji oczekującej wskaźnika:
#include <stdio.h>
void func (int *tablica)
{
*tablica += 5;
}
int main (void)
{
int tablica[5] = {1, 2, 3, 4, 5};
func(tablica+4);
return 0;
}
Uwaga!
|
Można to też zrobić konstrukcją: &tablica[4], ale odradzam używanie tej konstrukcji dla tablic. |
Można przyjąć konwencję, że deklaracja określa czy funkcji przekazujemy wskaźnik do pojedynczego argumentu czy do sekwencji, ale równie dobrze można za każdym razem stosować gwiazdkę.
Do czego służy typ void*?
edytujCzasami zdarza się, że nie wiemy, na jaki typ wskazuje dany wskaźnik. W takich przypadkach stosujemy typ void*. Sam void nie znaczy nic, natomiast void* oznacza "wskaźnik na obiekt w pamięci niewiadomego typu". Taki wskaźnik możemy potem odnieść do konkretnego typu danych (w języku C++ wymagana jest do tego operacja rzutowania). Na przykład, funkcja malloc zwraca właśnie wskaźnik za pomocą void*.
Poniższy kod jest poprawny zarówno w C jak i w C++:
void* wskaznik = malloc(sizeof *wskaznik * 10);
Uwaga!
|
Na co wskazuje NULL?
edytujAnalizując kody źródłowe programów, często można spotkać taki oto zapis:
void *wskaznik = NULL;
/* lub void *wskaznik = 0 */
Wiesz już, że nie możemy odwołać się pod komórkę pamięci wskazywaną przez wskaźnik NULL. Po co zatem przypisywać wskaźnikowi 0? Odpowiedź może być zaskakująca: właśnie po to, aby uniknąć błędów! Wydaje się to zabawne, ale większość (jeśli nie wszystkie) funkcji, które zwracają wskaźnik, w przypadku błędu zwróci właśnie NULL, czyli zero. Tutaj rodzi się kolejna wskazówka: jeśli w danej zmiennej przechowujemy wskaźnik, zwrócony wcześniej przez jakąś funkcję zawsze sprawdzajmy, czy nie jest on równy 0 (NULL). Wtedy mamy pewność, że funkcja zadziałała poprawnie.
Dokładniej, NULL nie jest słowem kluczowym, lecz stałą (makrem) zadeklarowaną przez dyrektywy preprocesora. Deklaracja taka może być albo wartością 0 albo też wartością 0 zrzutowaną na void* (((void *)0)), ale też jakimś słowem kluczowym deklarowanym przez kompilator.
Warto zauważyć, że pomimo przypisywania wskaźnikowi zera, nie oznacza to, że wskaźnik NULL jest reprezentowany przez same zerowe bity. Co więcej, wskaźniki NULL różnych typów mogą mieć różną wartość! Z tego powodu poniższy kod jest niepoprawny:
int **tablica_wskaznikow = calloc(100, sizeof *tablica_wskaznikow);
Zakłada on, że w reprezentacji wskaźnika NULL występują same zera. Poprawnym zainicjowaniem dynamicznej tablicy wskaźników wartościami NULL jest (pomijamy sprawdzanie wartości zwróconej przez malloc()):
int **tablica_wskaznikow = malloc(100 * sizeof *tablica_wskaznikow);
int i = 0;
while (i<100)
tablica_wskaznikow[i++] = 0;
Tablice wielowymiarowe
edytujW rozdziale Tablice pokazaliśmy, jak tworzyć tablice wielowymiarowe, gdy ich rozmiar jest znany w czasie kompilacji. Teraz zaprezentujemy, jak to wykonać za pomocą wskaźników[5] i to w sytuacji, gdy rozmiar może być dowolny. Załóżmy, że chcemy stworzyć tabliczkę mnożenia - utworzymy do tego celu tablicę dwuwymiarową, w której oba wymiary będą miały ten sam rozmiar, pobrany od użytkownika:
int rozmiar, i;
printf("Podaj rozmiar tabliczki mnozenia: ");
scanf("%d", &rozmiar);
int **tabliczka = (int **)malloc(rozmiar * sizeof (*tabliczka)); /* 1 */
for(i=0; i<rozmiar; ++i) /* 2 */
{
tabliczka[i]= (int *)malloc(rozmiar * sizeof (**tabliczka));
}
/* tutaj można zainicjować tabliczkę mnożenia */
/* i operować na niej. Gdy już wykonamy wszystkie czynności.. */
for(i=0; i<rozmiar; ++i) /* 3 */
free(tabliczka[i]);
free(tabliczka); /* 4 */
tabliczka = NULL;
Przydzielanie pamięci wygląda następująco: najpierw dla pierwszego wymiaru tablicy, który jest zarazem "tablicą tablic" (1) - tak jak na rysunku, jest to tablica przechowywująca wskaźniki do szeregu kolejnych tablic, w których przechowywane są już liczby. W drugim kroku (2) przydzielamy pamięć wszystkim tym tablicom. Tablica jest typu int* (wskaźnik na pierwszy element tablicy), natomiast tablica tablic jest typu int** (wskaźnik na pierwszy element, którym jest wskaźnik na tablicę). Jako że malloc jako argument przyjmuje rozmiar pamięci w bajtach, posługujemy się konstrukcją sizeof element. Pobiera ona typ i zwraca jego rozmiar, w naszym przypadku - (*tabliczka) oznacza to samo, co tabliczka[0], więc jest pierwszą podtablicą i ma typ int*. Jest to typ wskaźnikowy, dlatego sizeof zwróci ilość bajtów jakie wymaga wskaźnik. Drugim razem używamy jako argumentu (**tabliczka), co odpowiada użyciu tabliczka[0][0] (pierwsza liczba w pierwszej tablicy), które to ma już typ int - sizeof zwróci ilość bajtów odpowiadających zmiennej liczbowej. Dla systemu x86 dla obu sizeof powinniśmy otrzymać wielkość 4 bajty, jednak może to się zmienić dla różnych kompilatorów, a tym bardziej dla systemu x64.
Tablicę zwalniamy najpierw zwalniając wszystkie podtablice (3), a potem tablicę główną (4). Należy nie pomylić kolejności - gdybyśmy najpierw zwolnili pamięć tablicy trzymającej wskaźniki na tablice, wówczas próba uzyskania dalszych tablic mogłaby się zakończyłaby błędem aplikacji (odczyt ze zwolnionej pamięci).
Możemy również symulować tablicę dwuwymiarową za pomocą tablicy jednowymiarowej:
int *tabliczka = (int *)malloc(rozmiar * rozmiar * sizeof (*tabliczka));
W tym przypadku alokujemy pamięć równą liczbie elementów tablicy dwuwymiarowej, jednak w tablicy jednowymiarowej. Na przykład, dla rozmiaru równego 5 zaalokujemy tablicę jednowymiarową z 25 elementami. W ten sposób wszystkie elementy tablicy znajdą się w pamięci obok siebie, jednak utrudnia to programiście dostęp do nich, a także operacje na nich (potrzebna jest do tego Arytmetyka wskaźników).
Zauważmy, że nie jesteśmy ograniczeni wyłącznie do tablic "kwadratowych". Możliwe jest też np. uzyskanie tablicy dwuwymiarowej trójkątnej:
0123 012 01 0
lub tablicy o dowolnym innym rozkładzie długości wierszy, np.:
const size_t wymiary[] = { 2, 4, 6, 8, 1, 3, 5, 7, 9 };
const size_t ilosc_podtablic = sizeof (wymiary) / sizeof (*wymiary);
int i;
int **tablica = malloc(ilosc_podtablic * sizeof (*tablica));
for (i = 0; i<ilosc_podtablic; ++i) {
tablica[i] = malloc(wymiary[i] * sizeof **tablica);
}
Gdy nabierzesz wprawy w używaniu wskaźników oraz innych funkcji malloc i realloc, nauczysz się wykonywać różne inne operacje, takie jak dodawanie kolejnych wierszy, usuwanie wierszy, zmiana rozmiaru wierszy, zamiana wierszy miejscami itp.
Wskaźniki na funkcje
edytujDotychczas zajmowaliśmy się sytuacją, gdy wskaźnik wskazywał na jakąś zmienną. Jednak nie tylko zmienna ma swój adres w pamięci. Oprócz zmiennej także i funkcja musi mieć swoje określone miejsce w pamięci. A ponieważ funkcja ma swój adres[6], to nie ma przeszkód, aby i na nią wskazywał jakiś wskaźnik.
Deklaracja wskaźnika na funkcję
edytujTak naprawdę kod maszynowy utworzony po skompilowaniu programu odnosi się właśnie do adresu funkcji. Wskaźnik na funkcję różni się od innych rodzajów wskaźników. Jedną z głównych różnic jest jego deklaracja. Zwykle wygląda ona tak:
typ_zwracanej_wartości (*nazwa_wskaźnika)(typ1 argument1, typ2 argument2);
Oczywiście argumentów może być więcej (albo też w ogóle może ich nie być). Oto przykład wykorzystania wskaźnika na funkcję:
#include <stdio.h>
int suma (int lhs, int rhs)
{
return lhs+rhs;
}
int main ()
{
int (*wsk_suma)(int a, int b);
wsk_suma = suma;
printf("4+5=%d\n", wsk_suma(4,5));
return 0;
}
Zwróćmy uwagę na dwie rzeczy:
- przypisując nazwę funkcji bez nawiasów do wskaźnika automatycznie informujemy kompilator, że chodzi nam o adres funkcji
- wskaźnika używamy tak, jak normalnej funkcji, na którą on wskazuje
Do czego można użyć wskaźników na funkcje?
edytujJęzyk C jest językiem strukturalnym, jednak dzięki wskaźnikom istnieje w nim możliwość "zaszczepienia" pewnych obiektowych właściwości. Wskaźnik na funkcję może być np. elementem struktury - wtedy mamy bardzo prymitywną namiastkę klasy, którą dobrze znają programiści, piszący w języku C++. Ponadto dzięki wskaźnikom możemy tworzyć mechanizmy działające na zasadzie funkcji zwrotnej[7]. Dobrym przykładem może być np. tworzenie sterowników, gdzie musimy poinformować różne podsystemy, jakie funkcje w naszym kodzie służą do wykonywania określonych czynności. Przykład:
struct urzadzenie {
int (*otworz)(void);
void (*zamknij)(void);
int (*rejestruj)(void);
};
typedef struct urzadzenie urzadzenie;
int moje_urzadzenie_otworz (void)
{
/* kod...*/
}
void moje_urzadzenie_zamknij (void)
{
/* kod... */
}
int rejestruj_urzadzenie(void) {
/* kod... */
}
urzadzenie stworz (void)
{
urzadzenie moje_urzadzenie;
moje_urzadzenie.otworz = moje_urzadzenie_otworz;
moje_urzadzenie.zamknij = moje_urzadzenie_zamknij;
moje_urzadzenie.rejestruj = rejestruj_urzadzenie;
moje_urzadzenie.rejestruj();
return moje_urzadzenie;
}
W ten sposób w pamięci każda klasa musi przechowywać wszystkie wskaźniki do wszystkich metod. Innym rozwiązaniem może być stworzenie statycznej struktury ze wskaźnikami do funkcji i wówczas w strukturze będzie przechowywany jedynie wskaźnik do tej struktury, np.:
struct urzadzenie_metody {
int (*otworz)(void);
void (*zamknij)(void);
int (*rejestruj)(void);
};
struct urzadzenie {
const struct urzadzenie_metody *m;
};
typedef struct urzadzenie urzadzenie;
int moje_urzadzenie_otworz (void)
{
/* kod...*/
}
void moje_urzadzenie_zamknij (void)
{
/* kod... */
}
int rejestruj_urzadzenie (void)
{
/* kod... */
}
static const struct urzadzenie_metody
moje_urzadzenie_metody = {
moje_urzadzenie_otworz,
moje_urzadzenie_zamknij,
rejestruj_urzadzenie
};
urzadzenie stworz (void)
{
urzadzenie moje_urzadzenie;
moje_urzadzenie.m = &moje_urzadzenie_metody;
moje_urzadzenie.m->rejestruj();
return moje_urzadzenie;
}
Ciekawostki
edytuj- w rozdziale Zmienne pisaliśmy o stałych. Normalnie nie mamy możliwości zmiany ich wartości, ale z użyciem wskaźników staje się to możliwe:
const int CONST = 0;
int *c = &CONST;
*c = 1;
printf("%i\n", CONST); /* wypisuje 1 */
Konstrukcja taka może jednak wywołać ostrzeżenie kompilatora bądź nawet jego błąd - wtedy może pomóc jawne rzutowanie z const int*
na int*
.
C++
edytuj- język C++ oferuje mechanizm podobny do wskaźników, ale nieco wygodniejszy – referencje
- język C++ dostarcza też innego sposobu dynamicznej alokacji i zwalniania pamięci - przez operatory new i delete
- język C++ oferuje gotowe kontenery, podobne do dynamicznie alokowanych tablic, ale zdecydowanie wygodniejsze - jak np. klasa vector, czy klasa string dla łańcuchów znaków
- w rozdziale Typy złożone znajduje się opis implementacji listy za pomocą wskaźników. Przykład ten może być bardzo przydatny przy zrozumieniu, po co istnieją wskaźniki, jak się nimi posługiwać oraz jak dobrze zarządzać pamięcią.
Przypisy
- ↑ Tak naprawdę nie zawsze można przypisywać wartości jednych wskaźników do innych. Standard C gwarantuje jedynie, że można przypisać wskaźnikowi typu void* wartość dowolnego wskaźnika, a następnie przypisać tę wartość do wskaźnika pierwotnego typu, oraz że dowolny wskaźnik można przypisać do wskaźnika typu char*.
- ↑ Może się okazać, że błąd nie będzie widoczny na Twoim komputerze.
- ↑ To znaczy standard nie definiuje, co się wtedy stanie, aczkolwiek na większości architektur odejmowanie dowolnych dwóch wskaźników ma zdefiniowane zachowanie. Pisząc przenośne programy nie można jednak na tym polegać, zwłaszcza że odejmowanie wskaźników wskazujących na elementy różnych tablic zazwyczaj nie ma sensu.
- ↑ Ponownie przyjmując, że bajt ma 8 bitów, int dwa bajty i liczby zapisywane są w formacie little endian
- ↑ allocating-arrays-of-function-pointers-in-c
- ↑ Tak naprawdę kod maszynowy utworzony po skompilowaniu programu odnosi się właśnie do adresu funkcji.
- ↑ Funkcje zwrotne znalazły zastosowanie głównie w programowaniu GUI