Podręcznik
Wersja podręcznika: 1.0
Data publikacji: 01.01.2022 r.
4. Typy pochodne
4.1. Wskaźniki
Wskaźniki historycznie wywodzą się z niskopoziomowych elementów języka C, ściśle powiązanych ze sprzętem. Wtedy to wskaźnik był fizycznym adresem w pamięci RAM. W C++ do tej pory jest tak często traktowany – choć często nie jest to prawdą. Po pierwsze – większość kompilatorów operuje adresami wirtualnymi w odniesieniu do typów prostych. Po drugie – w przypadku obiektów złożonych nawet ta właściwość (adres wirtualny) nie zawsze musi być zachowana, jeden obiekt może mieć wiele adresów wirtualnych. Tak więc zdecydowanie lepszym przybliżeniem roli wskaźnika będzie następująca definicja:
Dzięki temu jesteście w stanie rozróżnić dwie zmienne tego samego typu i mające tą samą wartość.
Mimo że tożsamość między wskaźnikami a fizycznymi adresami nie jest prawdziwa, to w przypadku przechowywania podstawowych typów danych w pierwszym przybliżeniu możemy przyjąć, że zmienna wskaźnikowa przechowuje w pamięci adres elementu danego typu (nie sam element). Skoro przechowuje adres - to sama w rzeczywistości jest zmienną statyczną przechowującą wartość całkowitą, unikalną dla każdego obiektu (bytu) w programie. Rozmiar tej zmiennej jest zależny od architektury, najczęściej jednak na systemach 32 bitowych możecie spodziewać się 4 bajtów, na systemach 64 bitowych - 8 bajtów. Skoro wskaźnik sam w sobie jest zmienną, to oznacza że i on posiada tożsamość, vide - można tworzyć wskaźniki do wskaźników. Podobnie z wszelkimi innymi bytami – możliwe (często nawet wskazane) jest tworzenie wskaźników do obiektów i funkcji.
Wartością zerową, pokazującą „adres donikąd” lub też „byt który nie istnieje” jest nullptr, w starszych wersjach standardu także NULL lub po prostu 0.
Wskaźniki definiujemy identycznie jak zmienne danego typu – fakt definiowania lub deklarowania wskaźnika a nie zmiennej statycznej oznaczamy podając gwiazdkę przed nazwą zmiennej. Przykłady definicji zmiennych wskaźnikowych:
char c = 'a';
char *p = &c; // wskaźnik na c
int *pInt; // wskaźnik na int
int **ppInt; // wskaźnik na wskaźnik na int
int *pInt[10]; // wskaźnik na tablicę int
int ***pppInt; // wskaźnik na wskaźnik na wskaźnik na int
int (*fp)(int *p); // wskaźnik na funkcję
void (*SetMes)(void *_func); // wskaźnik na wskaźnik na funkcję
Pierwsze dwie linie powyższego kodu pokazują nam wzajemną zależność między wskaźnikiem a zmienną – c jest zmienną statyczną, natomiast p – wskaźnikiem do niej. Do wartości zmiennej można się dobrać zarówno przez wskaźnik p jak i bezpośrednio korzystając z nazwy c, natomiast jednoznacznie zidentyfikować o jaką zmienną chodzi można tylko poprzez p.
Operatory pobrania adresu (referencji) oraz wyłuskania
Ze wskaźnikami wiążą się dwa operatory ułatwiające, czy też umożliwiające pracę ze zmiennymi tego typu. Aby uzyskać wskaźnik do zmiennej – stosuje się operator referencji &, aby uzyskać dostęp do wartości wskazywanej, wykorzystuje się operator wyłuskania *:
int s1 = 5, s2 = 2;
int *p1, *p2;
p1 = &s1; // p1 wskazuje na zmienną s1
p2 = &s2; // p2 wskazuje na zmienną s2
// wydrukuje kolejno – wskaźnik (adres) zmiennej s2, oraz wartość s2
cout << p1 << "\t" << *p1 << endl;
// zwiększa wartość wskazywaną (teraz s1)
(*p1)++;
cout << s1 << endl;
// kopiowanie wartości wskazywanej (teraz s1) do s2
s2 = *p1;
s2++;
Arytmetyka wskaźników
W C++ wskaźniki można do siebie dodawać, wykorzystywać jako przełącznik w instrukcji switch, mnożyć, inkrementować itd. - typ wskaźnikowy jest typem przeliczalnym i arytmetycznym. W większości przypadków wyniki takich operacji są zgodne z intuicyjnym wyobrażeniem efektu działania na wskaźniku jak na adresie. W związku z tym możemy traktować operacje na wskaźnikach tak jak bezpośrednie operacje na pamięci RAM, z pominięciem jakiejkolwiek kontroli …. tak – C++ zakłada, że programista jest człowiekiem inteligentnym – nie zawiedźcie więc tego zaufania, i nie korzystajcie z możliwości takich operacji dopóki na pewno nie wiecie co robicie, i co przez to chcecie osiągnąć!
W przypadku arytmetyki na wskaźnikach działanie operatorów arytmetycznych jest „inteligentne”, tzn – jeśli mamy do czynienia ze zmienną jednobajtową (char) to zwiększenie wskaźnika o 1 przeniesie nas do następnego adresu (zwiększy wartość wskaźnika fizycznie o 1). Jeśli zdefiniowaliśmy wskaźnik na liczbę całkowitą (4 bajty) – to zwiększenie wskaźnika o 1 spowoduje zmianę adresu o 4 (bo skaczemy 4 bajty do przodu). Takie zachowanie wskaźników jest bardzo przydatne przy operacjach wykonywanych na tablicach – ale o tym za chwilę.
Na razie skupmy się na pozostałych aspektach arytmetyki wskaźników. Czasem w trakcie operacji na nich pewna szczątkowa kontrola typów jest zachowana. Przykładowo – nie można przypisać bezpośrednio wartości wskaźnika na char do wskaźnika na int:
char *a;
int *b;
// źle
b = a;
// dobrze formalnie – ale może być przyczyną strasznych błędów!
b = (int*)a;
Można natomiast dokonać jawnego rzutowania – i przypisanie zadziała … choć w pamięci nie zostanie zmieniony typ zmiennej a na int, i nie zostanie przydzielona dodatkowa pamięć na nią. Jeśli teraz chcielibyśmy do zmiennej b jakąś wartość, to zniszczymy strukturę pamięci danych naszego programu, i jego zachowanie stanie się niezdefiniowane.
Tak więc powtórzymy jeszcze raz – należy wiedzieć co się robi. Lecz jak już wiecie co robicie – to zauważcie, do czego może zostać wykorzystany typ void (a dokładniej – wskaźnik na ten typ). Skoro wiadomo, że zmienne typu void nie istnieją, to powszechnie wykorzystuje się go do porównywania adresów zmiennych dowolnego typu (ich tożsamości), rzutowania zmiennych, i przekazywania dowolnych struktur danych i funkcji między podprogramami. Pojawienie się zmiennej lub argumentu typu ... void *cos_tam ... jest informacją przekazywaną przez jednego programiście innym programistom: „.. wyłączam kontrolę typów – musisz wiedzieć jak obsłużyć mój kod, i mechanizmy języka w tym Ci nie pomogą ...”.
Wskaźniki i stałe
W przypadku pracy ze wskaźnikami musicie pamiętać, że zawsze mamy do czynienia z dwoma różnymi zmiennymi – jedną z nich jest sam wskaźnik, drugą jest zmienna wskazywana. Wykorzystanie const przy operacjach na wskaźnikach czyni stałym obiekt (zmienną) wskazywany, natomiast w żaden sposób nie wpływa na możliwość czy brak możliwości zmiany wskaźnika (adresu). Dopiero jak podamy *const - stały staje się wskaźnik. To rozróżnienie jest wykorzystywane głównie przy deklarowaniu parametrów funkcji.
char a, b;
// stały wskaźnik do znaku – nie można zmienić wskaźnika,
// natomiast można zmienić znak
char *const cp = &a;
// wskaźnik do stałego znaku – znaku nie można zmienić,
// można zmienić wskaźnik
char const* cp;
// forma alternatywna wskaźnika do stałego znaku
const char* cp;
// stały wskaźnik do stałego znaku
const char *const cp = &a;
// przykłady zastosowań
char n[] = "czem";
const char *p = n;
// to nie zadziała
p[1] = 'a';
// natomiast to tak
p = &b