W poprzedniej lekcji (12.7 -- Wprowadzenie do wskaźników), omówiliśmy podstawy wskaźników, które są obiektami przechowującymi adres innego obiektu. Adres ten można wyłuskać za pomocą operatora dereferencji (*), aby uzyskać obiekt pod tym adresem:
#include <iostream>
int main()
{
int x{ 5 };
std::cout << x << '\n'; // wydrukuj wartość zmiennej x
int* ptr{ &x }; // ptr przechowuje adres x
std::cout << *ptr << '\n'; // użyj operatora dereferencji, aby wydrukować wartość obiektu pod adresem trzymanym przez ptr (który jest adresem x) adres)
return 0;
}Powyższy przykład wypisuje:
5 5
W poprzedniej lekcji zauważyliśmy również, że wskaźniki nie muszą na nic wskazywać. W tej lekcji przyjrzymy się bliżej takim wskaźnikom (i różnym konsekwencjom wskazywania niczego).
Wskaźniki zerowe
Oprócz adresu pamięci, istnieje jeszcze jedna dodatkowa wartość, którą wskaźnik może przechowywać: wartość null. wartość null (często skracana do null) to wartość specjalna, która oznacza, że coś nie ma wartości. Gdy wskaźnik przechowuje wartość null, oznacza to, że wskaźnik na nic nie wskazuje. Taki wskaźnik nazywany jest wskaźnikiem zerowym.
Najłatwiejszym sposobem utworzenia wskaźnika zerowego jest użycie inicjalizacji wartości:
int main()
{
int* ptr {}; // ptr jest teraz wskaźnikiem zerowym i nie jest trzymający adres
return 0;
}Najlepsza praktyka
Wartość inicjuje wskaźniki (aby były wskaźnikami zerowymi), jeśli nie inicjujesz ich adresem prawidłowego obiektu.
Ponieważ możemy użyć przypisania, aby zmienić to, na co wskazuje wskaźnik, wskaźnik, który początkowo ustawione na null, można później zmienić tak, aby wskazywało prawidłowy obiekt:
#include <iostream>
int main()
{
int* ptr {}; // ptr jest wskaźnikiem zerowym i nie przechowuje adresu
int x { 5 };
ptr = &x; // ptr wskazuje teraz na obiekt x (nie jest już wskaźnikiem zerowym)
std::cout << *ptr << '\n'; // wypisz wartość x przez wyłuskany ptr
return 0;
}Słowo kluczowe nullptr
Podobnie jak słowa kluczowe true i false reprezentują wartości literału logicznego, nullptr reprezentuje literał wskaźnika zerowego. Możemy użyć nullptr do jawnej inicjalizacji lub przypisania wskaźnikowi wartości null.
int main()
{
int* ptr { nullptr }; // może użyć nullptr do zainicjowania wskaźnika jako wskaźnik zerowy
int value { 5 };
int* ptr2 { &value }; // ptr2 jest prawidłowym wskaźnikiem
ptr2 = nullptr; // Można przypisać nullptr, aby wskaźnik był wskaźnikiem zerowym
someFunction(nullptr); // możemy także przekazać nullptr do funkcji, która ma parametr wskaźnika
return 0;
}W powyższym przykładzie używamy przypisania do ustawienia wartości ptr2 Do nullptr, tworząc ptr2 wartość null wskaźnik.
Najlepsza praktyka
Użyj nullptr kiedy potrzebujesz literału wskaźnika zerowego do inicjalizacji, przypisania lub przekazania wskaźnika zerowego do funkcji.
Wyłuskiwanie wskaźnika zerowego powoduje niezdefiniowane zachowanie
Podobnie jak wyłuskiwanie wskaźnika wiszącego (lub dzikiego) prowadzi do niezdefiniowanego zachowania, wyłuskiwanie wskaźnika zerowego również prowadzi do niezdefiniowanego zachowania. W większości przypadków spowoduje to awarię aplikacji.
Poniższy program ilustruje to i prawdopodobnie spowoduje awarię lub nienormalne zamknięcie aplikacji po jej uruchomieniu (śmiało, spróbuj, nie zaszkodzisz swojej maszynie):
#include <iostream>
int main()
{
int* ptr {}; // Utwórz wskaźnik zerowy
std::cout << *ptr << '\n'; // Usuń wskaźnik zerowy
return 0;
}Pod względem koncepcyjnym ma to sens. Dereferencja wskaźnika oznacza „przejdź do adresu, na który wskazuje wskaźnik i uzyskaj dostęp do znajdującej się tam wartości”. Wskaźnik zerowy przechowuje wartość zerową, co semantycznie oznacza, że wskaźnik na nic nie wskazuje. Zatem do jakiej wartości miałby dostęp?
Przypadkowe wyłuskiwanie wskaźników zerowych i wiszących jest jednym z najczęstszych błędów popełnianych przez programistów C++ i prawdopodobnie najczęstszą przyczyną awarii programów w C++ w praktyce.
Ostrzeżenie
Za każdym razem, gdy używasz wskaźników, musisz zachować szczególną ostrożność, aby Twój kod nie wyłuskiwał wskaźników zerowych lub wiszących, ponieważ spowoduje to niezdefiniowane zachowanie (prawdopodobnie aplikacja awaria).
Sprawdzanie wskaźników zerowych
Podobnie jak możemy użyć warunku do przetestowania wartości logicznych dla true lub false, możemy użyć warunku do sprawdzenia, czy wskaźnik ma wartość nullptr czy nie:
#include <iostream>
int main()
{
int x { 5 };
int* ptr { &x };
if (ptr == nullptr) // jawna test równoważności
std::cout << "ptr is null\n";
else
std::cout << "ptr is non-null\n";
int* nullPtr {};
std::cout << "nullPtr is " << (nullPtr==nullptr ? "null\n" : "non-null\n"); // jawna test równoważności
return 0;
}Powyższy program wypisuje:
ptr is non-null nullPtr is null
W lekcji 4.9 -- Wartości logiczne, zauważyliśmy, że wartości całkowite zostaną niejawnie zamienione na wartości logiczne: wartość całkowita of 0 konwertuje na wartość logiczną false, a każda inna wartość całkowita jest konwertowana na wartość logiczną true.
Podobnie wskaźniki również będą niejawnie konwertowane na wartości logiczne: wskaźnik zerowy jest konwertowany na wartość logiczną false, a wskaźnik inny niż null jest konwertowany na wartość logiczną true. Dzięki temu możemy pominąć jawne testowanie nullptr i po prostu użyć niejawnej konwersji na wartość logiczną, aby sprawdzić, czy wskaźnik jest wskaźnikiem zerowym. Poniższy program jest odpowiednikiem poprzedniego:
#include <iostream>
int main()
{
int x { 5 };
int* ptr { &x };
// wskaźniki są konwertowane na wartość Boolean false, jeśli mają wartość null, i wartość Boolean true, jeśli są różne od wartości null
if (ptr) // ukryta konwersja na wartość logiczną
std::cout << "ptr is non-null\n";
else
std::cout << "ptr is null\n";
int* nullPtr {};
std::cout << "nullPtr is " << (nullPtr ? "non-null\n" : "null\n"); // ukryta konwersja na wartość logiczną
return 0;
}Ostrzeżenie
Warunków warunkowych można używać tylko do odróżniania wskaźników zerowych od wskaźników innych niż null. Nie ma wygodnego sposobu na określenie, czy wskaźnik inny niż null wskazuje na prawidłowy obiekt, czy jest zwisający (wskazuje nieprawidłowy obiekt).
Użyj nullptr, aby uniknąć zwisających wskaźników
Powyżej wspomnieliśmy, że wyłuskiwanie wskaźnika, który ma wartość null lub jest zwisający, spowoduje niezdefiniowane zachowanie. Dlatego musimy się upewnić, że nasz kod nie wykonuje żadnej z tych rzeczy.
Możemy łatwo uniknąć wyłuskiwania wskaźnika zerowego, używając warunku, aby upewnić się, że wskaźnik nie ma wartości null przed próbą wyłuskania:
// Załóż, że ptr jest wskaźnikiem, który może być wskaźnikiem zerowym lub nie
if (ptr) // jeśli ptr nie jest wskaźnikiem zerowym
std::cout << *ptr << '\n'; // w porządku wyłuskanie
else
// zrób coś innego, co nie wymaga wyłuskiwania ptr (drukuj komunikat o błędzie, w ogóle nic nie rób itp...)Ale co ze zwisającymi wskaźnikami? Ponieważ nie ma możliwości wykrycia, czy wskaźnik jest wiszący, musimy przede wszystkim unikać umieszczania w naszym programie jakichkolwiek wskaźników wiszących. Robimy to poprzez upewnienie się, że każdy wskaźnik, który nie wskazuje na prawidłowy obiekt, ma ustawioną wartość nullptr.
W ten sposób przed wyłuskaniem wskaźnika musimy jedynie sprawdzić, czy ma on wartość null — jeśli nie jest null, zakładamy, że wskaźnik nie wisi.
Najlepsza praktyka
Wskaźnik powinien albo zawierać adres prawidłowego obiektu, albo mieć ustawioną wartość nullptr. W ten sposób musimy jedynie przetestować wskaźniki pod kątem wartości null i możemy założyć, że każdy wskaźnik inny niż null jest prawidłowy.
Niestety unikanie zwisających wskaźników nie zawsze jest łatwe: gdy obiekt zostanie zniszczony, wszelkie wskaźniki do tego obiektu pozostaną w zawieszeniu. Takie wskaźniki są nie automatycznie kasowane! Obowiązkiem programisty jest upewnienie się, że wszystkie wskaźniki do obiektu, który właśnie został zniszczony, są prawidłowo ustawione na nullptr.
Ostrzeżenie
Gdy obiekt zostanie zniszczony, wszelkie wskaźniki do zniszczonego obiektu pozostaną w zawieszeniu (nie zostaną automatycznie ustawione na nullptr). Twoim obowiązkiem jest wykrycie takich przypadków i upewnienie się, że te wskaźniki zostaną następnie ustawione na nullptr.
Starsze literały wskaźników zerowych: 0 i NULL
W starszym kodzie możesz zobaczyć dwie inne wartości literałów zamiast nullptr.
Pierwsza to literał 0. W kontekście wskaźnika literał 0 jest specjalnie zdefiniowany jako oznaczający wartość null i jest to jedyny przypadek, w którym można przypisać do wskaźnika literał całkowity.
int main()
{
float* ptr { 0 }; // ptr jest teraz wskaźnikiem zerowym (tylko na przykład, nie rób tego)
float* ptr2; // ptr2 jest niezainicjowany
ptr2 = 0; // ptr2 jest teraz wskaźnikiem zerowym (tylko na przykład, nie rób tego this)
return 0;
}Na marginesie…
W nowoczesnych architekturach adres 0 jest zwykle używany do reprezentowania wskaźnika zerowego. Jednak ta wartość nie jest gwarantowana przez standard C++, a niektóre architektury używają innych wartości. Literal 0, użyty w kontekście wskaźnika zerowego, zostanie przetłumaczony na dowolny adres używany przez architekturę do reprezentowania wskaźnika zerowego.
Dodatkowo istnieje makro preprocesora o nazwie NULL (zdefiniowane w nagłówku <cstddef>). To makro jest dziedziczone z C, gdzie jest powszechnie używane do wskazywania wskaźnika zerowego.
#include <cstddef> // dla NULL
int main()
{
double* ptr { NULL }; // ptr jest wskaźnikiem zerowym
double* ptr2; // ptr2 jest niezainicjowany
ptr2 = NULL; // ptr2 jest teraz wskaźnikiem zerowym
return 0;
}Oba 0 i NULL należy unikać we współczesnym C++ (zamiast tego użyj nullptr ). Omawiamy to na lekcji 12.11 — Przekazywanie adresu (część 2).
Jeśli to możliwe, preferuj referencje zamiast wskaźników
Zarówno wskaźniki, jak i referencje dają nam możliwość pośredniego dostępu do innych obiektów.
Wskaźniki mają dodatkową możliwość zmiany tego, na co wskazują, i wskazywania na wartość null. Jednak te możliwości wskaźników są również z natury niebezpieczne: wskaźnik zerowy wiąże się z ryzykiem wyłuskania, a możliwość zmiany tego, na co wskazuje wskaźnik, może ułatwić tworzenie wskaźników wiszących:
int main()
{
int* ptr { };
{
int x{ 5 };
ptr = &x; // przypisz wskaźnik do obiektu, który zostanie zniszczony (nie jest to możliwe w przypadku referencji)
} // ptr zwisa teraz i wskazuje na nieprawidłowy obiekt
if (ptr) // warunek ma wartość true, ponieważ ptr nie ma wartości nullptr
std::cout << *ptr; // undefined behavior
return 0;
}Ponieważ referencji nie można powiązać z wartością null, nie musimy się martwić referencjami zerowymi. A ponieważ odniesienia muszą być powiązane z prawidłowym obiektem podczas tworzenia, a następnie nie mogą być ponownie umieszczone, odniesienia wiszące są trudniejsze do utworzenia.
Ponieważ są bezpieczniejsze, referencje powinny być preferowane zamiast wskaźników, chyba że wymagane są dodatkowe możliwości zapewniane przez wskaźniki.
Najlepsza praktyka
Preferuj odniesienia zamiast wskaźników, chyba że potrzebne są dodatkowe możliwości zapewniane przez wskaźniki.
A żart
Słyszałeś dowcip o wskaźniku zerowym?
W porządku, nie doszłoby do dereferencji.
Czas quizu
Pytanie nr 1
1a) Czy możemy określić, czy wskaźnik jest wskaźnikiem zerowym, czy nie? Jeśli tak, to w jaki sposób?
1b) Czy możemy określić, czy wskaźnik o wartości innej niż null jest prawidłowy, czy nieaktualny? Jeśli tak, to w jaki sposób?
Pytanie nr 2
Dla każdego podelementu odpowiedz, czy opisane działanie spowoduje zachowanie: przewidywalne, niezdefiniowane lub prawdopodobnie niezdefiniowane. Jeżeli odpowiedź brzmi „prawdopodobnie niezdefiniowany”, wyjaśnij kiedy.
Załóż, że wszystkie wymienione obiekty są typu, na który może wskazywać wskaźnik.
2a) Przypisanie adresu obiektu do niestałego wskaźnika
2b) Przypisanie nullptr do wskaźnika
2c) Wyłuskanie wskaźnika do prawidłowego obiektu
2d) Wyłuskanie odwołania do wiszącego obiektu wskaźnik
2e) Wyłuskiwanie wskaźnika zerowego
2f) Wyłuskiwanie wskaźnika innego niż zerowy
Pytanie nr 3
Dlaczego powinniśmy ustawiać wskaźniki, które nie wskazują na poprawny obiekt na „nullptr”?

