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'; // print the value of variable x
int* ptr{ &x }; // ptr holds the address of x
std::cout << *ptr << '\n'; // use dereference operator to print the value of the object at the address that ptr is holding (which is x's address)
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. A 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 is now a null pointer, and is not holding an address
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 is a null pointer, and is not holding an address
int x { 5 };
ptr = &x; // ptr now pointing at object x (no longer a null pointer)
std::cout << *ptr << '\n'; // print value of x through dereferenced 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 }; // can use nullptr to initialize a pointer to be a null pointer
int value { 5 };
int* ptr2 { &value }; // ptr2 is a valid pointer
ptr2 = nullptr; // Can assign nullptr to make the pointer a null pointer
someFunction(nullptr); // we can also pass nullptr to a function that has a pointer parameter
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 {}; // Create a null pointer
std::cout << *ptr << '\n'; // Dereference the null pointer
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) // explicit test for equivalence
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"); // explicit test for equivalence
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 };
// pointers convert to Boolean false if they are null, and Boolean true if they are non-null
if (ptr) // implicit conversion to Boolean
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"); // implicit conversion to Boolean
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:
// Assume ptr is some pointer that may or may not be a null pointer
if (ptr) // if ptr is not a null pointer
std::cout << *ptr << '\n'; // okay to dereference
else
// do something else that doesn't involve dereferencing ptr (print an error message, do nothing at all, etc...)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 is now a null pointer (for example only, don't do this)
float* ptr2; // ptr2 is uninitialized
ptr2 = 0; // ptr2 is now a null pointer (for example only, don't do 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> // for NULL
int main()
{
double* ptr { NULL }; // ptr is a null pointer
double* ptr2; // ptr2 is uninitialized
ptr2 = NULL; // ptr2 is now a null pointer
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; // assign the pointer to an object that will be destroyed (not possible with a reference)
} // ptr is now dangling and pointing to invalid object
if (ptr) // condition evaluates to true because ptr is not 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”?

