Zmienna, która przechowuje adres
Każda zmienna mieszka gdzieś w pamięci, w ponumerowanym miejscu zwanym jej adresem. Zwykle nie obchodzi cię, gdzie dokładnie: po prostu używasz nazwy zmiennej. Wskaźnik odwraca tę sytuację: to zmienna, której wartością jest adres. Zamiast 42 przechowuje „miejsce, w którym zapisano 42”.
Ta pośredniość daje wskaźnikom moc. Funkcje mogą przez nie zmieniać zmienną wywołującego, struktury danych takie jak listy wiązane łączą nimi węzły, a (jak zobaczysz przy pamięci dynamicznej) to przez nie sięgasz do pamięci alokowanej w czasie działania programu.
& w &score to operator pobrania adresu: daje położenie score. * w *p to operator dereferencji: podąża za adresem z powrotem do wartości, która tam mieszka.
Dwa operatory: & i *
Najbardziej mylące dla początkujących jest to, że * znaczy co innego zależnie od miejsca, w którym stoi. Trzymaj to w głowie:
int* p; // DEKLARACJA: "p to wskaźnik na int"
p = &x; // & = pobranie adresu: zapisz adres x w p
int y = *p; // * = dereferencja: odczytaj wartość, na którą wskazuje p
*p = 99; // dereferencja po lewej stronie: zapis przez wskaźnik
W deklaracji * jest częścią typu. W wyrażeniu * wykonuje pracę. Gdy wskaźnik jest już ustawiony, jego dereferencja daje pełny dostęp do odczytu i zapisu oryginalnej zmiennej:
Zauważ, że po pierwszej linii ani razu nie użyto nazwy health, a jej wartość wciąż się zmieniała. O to właśnie chodzi: hp to alias do tego samego miejsca w pamięci. Odstępy (int* p, int *p, int*p) są kosmetyczne i dla kompilatora identyczne; ten przewodnik używa int* p.
nullptr: wskazywanie na nic
Wskaźnik, który nigdzie nie wskazuje, powinien być ustawiony na nullptr (C++11). To jasny, bezpieczny typowo sposób powiedzenia „jeszcze bez celu”, a przy okazji daje coś, z czym można porównać wskaźnik przed dereferencją.
Wybieraj nullptr zamiast starego makra NULL albo gołego 0. Ponieważ nullptr ma prawdziwy typ wskaźnikowy, przy wyborze przeciążenia nigdy nie zostanie odczytany jako liczba 0, a stary styl mógł powodować taki subtelny błąd.
Pułapka: dereferencja null. Odczyt lub zapis przez pusty (albo niezainicjalizowany) wskaźnik to niezdefiniowane zachowanie, zwykle natychmiastowa awaria:
int* p = nullptr;
cout << *p; // AWARIA: dereferencja null to niezdefiniowane zachowanie
Przed dereferencją czegokolwiek, co może być null, zawsze zabezpieczaj się przez if (p) (albo if (p != nullptr)).
Wskaźniki i tablice
Nazwa tablicy rozpada się do wskaźnika na jej pierwszy element, więc wskaźniki i tablice są ze sobą głęboko splecione. Dodanie 1 do wskaźnika nie dodaje jednego bajtu, tylko przesuwa o jeden element, i na tym opiera się arytmetyka wskaźników:
p[i] i *(p + i) to dosłownie to samo wyrażenie i właśnie z tej równoważności bierze się indeksowanie tablic od zera. Klasyczny błąd to wyjście za koniec: nums + 4 to poprawny znacznik pozycji tuż za końcem, z którym można porównywać, ale dereferencja *(nums + 4) czyta poza zakresem. Błędy o jeden przy wskaźnikach to jedna z głównych przyczyn awarii i cichego psucia danych, więc świadomie dobieraj warunek zatrzymania.
const i wskaźniki
const może dotyczyć tego, na co wskazuje wskaźnik, samego wskaźnika albo obu naraz. Aby rozszyfrować deklarację, czytaj ją od prawej do lewej:
const int* p; // wskaźnik na const int: nie zmienisz *p, możesz przestawić p
int* const p = &x; // stały wskaźnik na int: zmienisz *p, nie przestawisz p
const int* const p = &x; // oba zablokowane
W prawdziwym kodzie ma to znaczenie na każdym kroku. Funkcja, która obiecuje nie zmieniać twoich danych, przyjmuje wskaźnik na const:
Oznaczenie wskazywanej wartości jako const dokumentuje intencję i pozwala kompilatorowi zatrzymać przypadkowe zapisy: darmowe bezpieczeństwo bez żadnego kosztu w czasie działania.
Wielka pułapka: wiszące wskaźniki
Wiszący wskaźnik wskazuje na pamięć, która nie przechowuje już oczekiwanej wartości: zmienna wyszła z zasięgu albo pamięć została zwolniona. Jego dereferencja to niezdefiniowane zachowanie, a najpaskudniejsze jest to, że często wygląda na działającą, dopóki nie przestanie.
int* makeBad() {
int local = 5;
return &local; // BŁĄD: local umiera, gdy funkcja się kończy
} // zwrócony wskaźnik teraz wisi
Adres to nadal poprawna liczba, ale wskazuje na miejsce na stosie, które zostało odzyskane: odczyt daje śmieci albo awarię. To samo dzieje się, gdy trzymasz wskaźnik do obiektu na stercie usuniętego przez delete albo do elementu vector, który później przealokuje pamięć.
Trzy zasady zapewniają bezpieczeństwo:
- Nigdy nie zwracaj adresu zmiennej lokalnej. Zwracaj przez wartość albo niech pamięć należy do wywołującego.
- Ustaw wskaźnik na
nullptr, gdy to, na co wskazywał, zniknęło, i sprawdzaj go przed użyciem. - Do własności i czasu życia obiektów używaj inteligentnych wskaźników zamiast surowych
new/delete: zwalniają pamięć automatycznie i mocno ograniczają całą tę klasę błędów.
Dalej: referencje a wskaźniki
Wskaźniki to nie jedyny sposób pośredniego odwoływania się do innej zmiennej. C++ ma też referencje, które działają podobnie, ale nie mogą być null, nie da się ich przestawić i mają czystszą składnię. Dalej zestawimy je obok siebie na stronie o referencjach i wskaźnikach, żeby było dokładnie wiadomo, po które narzędzie sięgnąć, i dlaczego nowoczesny C++ wybiera referencje wszędzie tam, gdzie się da.
Najczęściej zadawane pytania
Czym jest wskaźnik w C++?
Wskaźnik to zmienna, która przechowuje adres w pamięci innej wartości, a nie samą wartość. Deklarujesz go przez * (np. int* p), pobierasz adres operatorem & (p = &x), a wskazywaną wartość odczytujesz lub zapisujesz przez dereferencję *p.
Czym się różni & od * przy wskaźnikach w C++?
W kontekście wskaźników & to operator pobrania adresu: &x daje adres x. * ma dwa zadania: w deklaracji (int* p) oznacza zmienną jako wskaźnik, a w wyrażeniu (*p) wykonuje dereferencję, czyli sięga po wartość zapisaną pod tym adresem.
Czym jest nullptr w C++ i dlaczego lepiej go używać zamiast NULL?
nullptr to bezpieczny typowo literał pustego wskaźnika dodany w C++11. Oznacza „nie wskazuje na nic”. Wybieraj go zamiast starego NULL czy 0, bo nullptr ma prawdziwy typ wskaźnikowy, więc przy wyborze przeciążenia nigdy nie zostanie pomylony z liczbą całkowitą. Przed dereferencją zawsze sprawdzaj if (p): dereferencja pustego wskaźnika to niezdefiniowane zachowanie.