Wskaźniki są jedno historycznych straszydeł C++ i miejsce, w którym utknęło wielu aspirujących uczniów C++. Jednakże, jak wkrótce się przekonasz, wskaźników nie należy się bać.
W rzeczywistości wskaźniki zachowują się podobnie jak referencje do wartości. Zanim jednak wyjaśnimy to szerzej, dokonajmy pewnych ustawień.
Powiązana treść
Jeśli nie masz doświadczenia z referencjami do lwartości lub nie jesteś zaznajomiony z referencjami do wartości, teraz jest dobry moment, aby je przejrzeć. Odniesienia do lwartości omówimy w lekcjach 12.3 -- Odniesienia do wartości, 12.4 -- Odniesienia do wartości do const, I 12.5 -- Przekazywanie przez lvalue reference.
Rozważmy zwykłą zmienną, taką jak ta:
char x {}; // chars use 1 byte of memoryTrochę upraszczając, gdy kod wygenerowany dla tej definicji zostanie wykonany, fragment pamięci RAM zostanie przypisany do tego obiektu. Dla przykładu załóżmy, że zmiennej x przypisany jest adres pamięci 140. Ilekroć używamy zmiennej x w wyrażeniu lub instrukcji, program przejdzie do adresu pamięci 140 , aby uzyskać dostęp do przechowywanej tam wartości.
Fajną rzeczą w zmiennych jest to, że nie musimy się martwić, jakie konkretne adresy pamięci są przypisane lub ile bajtów potrzeba do przechowywania wartości obiektu. Po prostu odwołujemy się do zmiennej po jej podanym identyfikatorze, a kompilator tłumaczy tę nazwę na odpowiednio przypisany adres pamięci. Kompilator zajmuje się całym adresowaniem.
To samo dotyczy odwołań:
int main()
{
char x {}; // assume this is assigned memory address 140
char& ref { x }; // ref is an lvalue reference to x (when used with a type, & means lvalue reference)
return 0;
}Ponieważ ref działa jako alias dla x, za każdym razem, gdy używamy ref, program przejdzie do adresu pamięci 140 , aby uzyskać dostęp do wartości. Ponownie kompilator zajmuje się adresowaniem, abyśmy nie musieli o tym myśleć.
Operator adresu (&)
Chociaż adresy pamięci używane przez zmienne nie są nam domyślnie ujawniane, mamy dostęp do tych informacji. adres operatora (&) zwraca adres pamięci swojego operandu. Jest to całkiem proste:
#include <iostream>
int main()
{
int x{ 5 };
std::cout << x << '\n'; // print the value of variable x
std::cout << &x << '\n'; // print the memory address of variable x
return 0;
}Na maszynie autora powyższy program wypisał:
5 0027FEA0
W powyższym przykładzie używamy operatora adresu (&), aby pobrać adres przypisany do zmiennej x i wydrukować ten adres na konsoli. Adresy pamięci są zwykle drukowane jako wartości szesnastkowe (omówiliśmy je w lekcji 5.3 — Systemy liczbowe (dziesiętny, binarny, szesnastkowy i ósemkowy) w postaci szesnastkowej), często bez przedrostka 0x.
W przypadku obiektów, które używają więcej niż jednego bajtu pamięci, adres-of zwróci adres pamięci pierwszego bajtu użytego przez obiekt.
Wskazówka
Symbol & może powodować zamieszanie, ponieważ ma różne znaczenia w zależności od kontekstu:
- Podczas podążania za typem nazwa, & oznacza odniesienie do wartości:
int& ref. - W przypadku użycia w kontekście jednoargumentowym w wyrażeniu, & jest operatorem adresu:
std::cout << &x. - W przypadku użycia w kontekście binarnym w wyrażeniu, & jest operatorem bitowym AND:
std::cout << x & y.
Operator dereferencji (*)
Pobieranie adresu zmiennej nie jest zbyt przydatne siebie.
Najbardziej użyteczną rzeczą, jaką możemy zrobić z adresem, jest dostęp do wartości przechowywanej pod tym adresem. Operator wyłuskiwania (*) (czasami nazywany także operatorem pośrednim) zwraca wartość pod danym adresem pamięci jako lwartość:
#include <iostream>
int main()
{
int x{ 5 };
std::cout << x << '\n'; // print the value of variable x
std::cout << &x << '\n'; // print the memory address of variable x
std::cout << *(&x) << '\n'; // print the value at the memory address of variable x (parentheses not required, but make it easier to read)
return 0;
}Na maszynie autora powyższy program wypisał:
5 0027FEA0 5
Ten program jest całkiem prosty. Najpierw deklarujemy zmienną x i wypisujemy jej wartość. Następnie wypisujemy adres zmiennej x. Na koniec używamy operatora dereferencji, aby uzyskać wartość znajdującą się pod adresem pamięci zmiennej x (która jest po prostu wartością x), którą wypisujemy na konsolę.
Kluczowa informacja
Mając adres pamięci, możemy użyć operatora dereferencji (*), aby uzyskać wartość pod tym adresem (jako lwartość).
Operator adresu (&) i operator dereferencji (*) działają na zasadzie przeciwieństw: adres-of pobiera adres obiektu, a dereferencja pobiera obiekt pod adresem.
Wskazówka
Chociaż operator dereferencji wygląda podobnie jak operator mnożenia, można je rozróżnić, ponieważ operator dereferencji jest jednoargumentowy, natomiast operator mnożenia jest binarny.
Pobieranie adresu pamięci zmiennej, a następnie natychmiast dereferencja do tego adresu w celu uzyskania wartości też nie jest zbyt użyteczna (w końcu możemy po prostu użyć zmiennej, aby uzyskać dostęp do wartości).
Ale teraz, gdy mamy dodany do naszych narzędzi operator adresu (&) i operator dereferencji (*), jesteśmy gotowi porozmawiać o wskaźnikach.
Wskaźniki
A wskaźnik jest obiektem, który przechowuje adres pamięci (zazwyczaj innej zmiennej) jako ich wartość. Dzięki temu możemy przechowywać adres innego obiektu do późniejszego wykorzystania.
Na marginesie…
We współczesnym C++ wskaźniki, o których tu mówimy, są czasami nazywane „surowymi wskaźnikami” lub „głupimi wskaźnikami”, aby pomóc odróżnić je od „inteligentnych wskaźników”, które zostały wprowadzone do języka niedawno. Inteligentne wskaźniki opisujemy w rozdziale 22.
Typ określający wskaźnik (np. int*) nazywany jest typem wskaźnika. Podobnie jak typy referencyjne deklaruje się za pomocą znaku ampersand (&), typy wskaźników deklaruje się za pomocą gwiazdki (*):
int; // a normal int
int&; // an lvalue reference to an int value
int*; // a pointer to an int value (holds the address of an integer value)Aby utworzyć zmienną wskaźnikową, po prostu definiujemy zmienną za pomocą typu wskaźnika:
int main()
{
int x { 5 }; // normal variable
int& ref { x }; // a reference to an integer (bound to x)
int* ptr; // a pointer to an integer
return 0;
}Zauważ, że ta gwiazdka jest częścią składni deklaracji wskaźników, a nie użyciem operatora dereferencji.
Najlepsza praktyka
Podczas deklarowania typu wskaźnika umieść gwiazdkę obok do nazwy typu.
Ostrzeżenie
Chociaż generalnie nie należy deklarować wielu zmiennych w jednym wierszu, jeśli to zrobisz, przy każdej zmiennej należy umieścić gwiazdkę.
int* ptr1, ptr2; // incorrect: ptr1 is a pointer to an int, but ptr2 is just a plain int!
int* ptr3, * ptr4; // correct: ptr3 and ptr4 are both pointers to an intChociaż jest to czasami używane jako argument, aby nie umieszczać gwiazdki przy nazwie typu (zamiast umieszczać ją obok nazwy zmiennej), jest to lepszy argument za unikaniem definiowania wielu zmiennych w tej samej instrukcji.
Wskaźnik inicjalizacja
Podobnie jak zwykłe zmienne, wskaźniki są nie domyślnie inicjowane. Wskaźnik, który nie został zainicjowany, nazywany jest czasem wskaźnikiem dzikim. Wskaźniki typu Wild zawierają adres śmieci, a wyłuskanie wskaźnika typu Wild spowoduje niezdefiniowane zachowanie. Z tego powodu powinieneś zawsze inicjować swoje wskaźniki znaną wartością.
Najlepsza praktyka
Zawsze inicjuj swoje wskaźniki.
int main()
{
int x{ 5 };
int* ptr; // an uninitialized pointer (holds a garbage address)
int* ptr2{}; // a null pointer (we'll discuss these in the next lesson)
int* ptr3{ &x }; // a pointer initialized with the address of variable x
return 0;
}Ponieważ wskaźniki przechowują adresy, kiedy inicjujemy lub przypisujemy wartość do wskaźnika, ta wartość musi być adresem. Zwykle wskaźniki służą do przechowywania adresu innej zmiennej (który możemy uzyskać za pomocą operatora adresu (&)).
Gdy już mamy wskaźnik przechowujący adres innego obiektu, możemy użyć operatora dereferencji (*), aby uzyskać dostęp do wartości pod tym adresem. Na przykład:
#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 at the address that ptr is holding (which is x's address)
return 0;
}Wypisuje:
5 5
Koncepcyjnie możesz pomyśleć o powyższym fragmencie w ten sposób:
To stąd wzięły się nazwy wskaźników -- ptr zawiera adres x, więc mówimy, że ptr wskazuje na x.
Nota autora
Uwaga na temat nomenklatury wskaźników: „Wskaźnik X” (gdzie X jest jakimś typem) jest powszechnie używanym skrótem dla „wskaźnika do X”. Kiedy więc mówimy „wskaźnik na liczbę całkowitą”, tak naprawdę mamy na myśli „wskaźnik na liczbę całkowitą”. To zrozumienie będzie cenne, gdy będziemy mówić o wskaźnikach stałych.
Podobnie jak typ referencji musi odpowiadać typowi obiektu, do którego się odwołujemy, typ wskaźnika musi odpowiadać typowi obiektu, na który wskazujemy:
int main()
{
int i{ 5 };
double d{ 7.0 };
int* iPtr{ &i }; // ok: a pointer to an int can point to an int object
int* iPtr2 { &d }; // not okay: a pointer to an int can't point to a double object
double* dPtr{ &d }; // ok: a pointer to a double can point to a double object
double* dPtr2{ &i }; // not okay: a pointer to a double can't point to an int object
return 0;
}Z jednym wyjątkiem, który omówimy w następnej lekcji, inicjowanie wskaźnika wartością dosłowną jest niedozwolone:
int* ptr{ 5 }; // not okay
int* ptr{ 0x0012FF7C }; // not okay, 0x0012FF7C is treated as an integer literalWskaźniki i przypisanie
Przypisania ze wskaźnikami możemy używać na dwa różne sposoby:
- Aby zmienić to, na co wskazuje wskaźnik (poprzez przypisanie wskaźnikowi nowego adresu)
- Aby zmienić wskazywaną wartość (poprzez przypisanie wyłuskanemu wskaźnikowi nowej wartości)
Najpierw przyjrzyjmy się przypadkowi, w którym wskaźnik zostaje zmieniony tak, aby wskazywał inny obiekt:
#include <iostream>
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr initialized to point at x
std::cout << *ptr << '\n'; // print the value at the address being pointed to (x's address)
int y{ 6 };
ptr = &y; // // change ptr to point at y
std::cout << *ptr << '\n'; // print the value at the address being pointed to (y's address)
return 0;
}Powyższe wypisuje:
5 6
W powyższym przykładzie definiujemy wskaźnik ptr, inicjujemy go adresem x i wyrejestruj wskaźnik, aby wydrukować wskazywaną wartość (5). Następnie używamy operatora przypisania, aby zmienić adres ptr zachowujący adres y. Następnie ponownie odwołujemy się do wskaźnika, aby wydrukować wskazywaną wartość (która jest teraz 6).
Przyjrzyjmy się teraz, jak możemy użyć wskaźnika do zmiany wskazywanej wartości:
#include <iostream>
int main()
{
int x{ 5 };
int* ptr{ &x }; // initialize ptr with address of variable x
std::cout << x << '\n'; // print x's value
std::cout << *ptr << '\n'; // print the value at the address that ptr is holding (x's address)
*ptr = 6; // The object at the address held by ptr (x) assigned value 6 (note that ptr is dereferenced here)
std::cout << x << '\n';
std::cout << *ptr << '\n'; // print the value at the address that ptr is holding (x's address)
return 0;
}Ten program wypisuje:
5 5 6 6
W tym przykładzie definiujemy wskaźnik ptr, inicjujemy go adresem x, a następnie drukujemy wartość obu x i *ptr (5. Ponieważ *ptr zwraca wartość, możemy jej użyć po lewej stronie instrukcji przypisania, co robimy, aby zmienić wartość wskazywaną przez ptr Do 6. Następnie ponownie wypisujemy wartości obu x i *ptr , aby pokazać, że wartość została zaktualizowana zgodnie z oczekiwaniami.
Kluczowa informacja
Kiedy używamy wskaźnika bez dereferencji (ptr), uzyskujemy dostęp do adresu przechowywanego przez wskaźnik. Modyfikacja tego (ptr = &y) zmienia to, na co wskazuje wskaźnik.
Kiedy usuwamy referencję do wskaźnika (*ptr), uzyskujemy dostęp do wskazywanego obiektu. Modyfikacja tego (*ptr = 6;) zmienia wartość wskazywanego obiektu.
Wskaźniki zachowują się podobnie jak odniesienia do lwartości
Wskaźniki i odniesienia do wartości zachowują się podobnie. Rozważmy następujący program:
#include <iostream>
int main()
{
int x{ 5 };
int& ref { x }; // get a reference to x
int* ptr { &x }; // get a pointer to x
std::cout << x;
std::cout << ref; // use the reference to print x's value (5)
std::cout << *ptr << '\n'; // use the pointer to print x's value (5)
ref = 6; // use the reference to change the value of x
std::cout << x;
std::cout << ref; // use the reference to print x's value (6)
std::cout << *ptr << '\n'; // use the pointer to print x's value (6)
*ptr = 7; // use the pointer to change the value of x
std::cout << x;
std::cout << ref; // use the reference to print x's value (7)
std::cout << *ptr << '\n'; // use the pointer to print x's value (7)
return 0;
}Ten program wypisuje:
555 666 777
W powyższym programie tworzymy zmienną normalną x o wartości 5, a następnie tworzymy odwołanie do lwartości i wskaźnik do x. Następnie używamy odniesienia do wartości, aby zmienić wartość z 5 Do 6 i pokazujemy, że możemy uzyskać dostęp do zaktualizowanej wartości wszystkimi trzema metodami. Na koniec używamy wyłuskanego wskaźnika, aby zmienić wartość z 6 Do 7 i ponownie pokazujemy, że możemy uzyskać dostęp do zaktualizowanej wartości wszystkimi trzema metodami.
Zatem zarówno wskaźniki, jak i referencje umożliwiają pośredni dostęp do innego obiektu. Podstawowa różnica polega na tym, że w przypadku wskaźników musimy jawnie uzyskać adres, na który ma wskazywać, i musimy jawnie wyłuskać wskaźnik, aby uzyskać wartość. W przypadku referencji adresowanie i dereferencja zachodzą niejawnie.
Istnieją pewne inne różnice między wskaźnikami a referencjami, o których warto wspomnieć:
- Referencje muszą być inicjalizowane, wskaźniki nie muszą być inicjowane (ale powinny być).
- Referencje nie są obiektami, wskaźniki nimi są.
- Referencji nie można ponownie zainicjalizować (zmienić tak, aby odwoływały się do czegoś). w przeciwnym razie), wskaźniki mogą zmienić to, na co wskazują.
- Referencje muszą być zawsze powiązane z obiektem, wskaźniki nie mogą wskazywać na nic (zobaczymy tego przykład w następnej lekcji).
- Referencje są „bezpieczne” (poza referencjami wiszącymi), wskaźniki są z natury niebezpieczne (omówimy to również w następnej lekcji).
Operator adresu zwraca a wskaźnik
Warto zauważyć, że operator adresu (&) nie zwraca adresu swojego operandu w postaci literału (ponieważ C++ nie obsługuje literałów adresowych). Zamiast tego zwraca wskaźnik do operandu (którego wartością jest adres operandu). Innymi słowy, podana zmienna int x, &x zwraca int* przechowując adres x.
Możemy to zobaczyć w następującym przykładzie:
#include <iostream>
#include <typeinfo>
int main()
{
int x{ 4 };
std::cout << typeid(x).name() << '\n'; // print the type of x
std::cout << typeid(&x).name() << '\n'; // print the type of &x
return 0;
}W Visual Studio wyświetliło się:
int int *
W przypadku gcc zamiast tego zostanie wydrukowane i (int) i pi (wskaźnik do int). Ponieważ wynik typeid().name() jest zależny od kompilatora, Twój kompilator może wydrukować coś innego, ale będzie to miało to samo znaczenie.
Rozmiar wskaźników
Rozmiar wskaźnika zależy od architektury, dla której plik wykonywalny jest skompilowany — 32-bitowy plik wykonywalny używa 32-bitowych adresów pamięci — w związku z tym wskaźnik na maszynie 32-bitowej ma 32 bity (4 bajty). W przypadku 64-bitowego pliku wykonywalnego wskaźnik będzie miał 64 bity (8 bajtów). Należy pamiętać, że dzieje się tak niezależnie od wielkości wskazywanego obiektu:
#include <iostream>
int main() // assume a 32-bit application
{
char* chPtr{}; // chars are 1 byte
int* iPtr{}; // ints are usually 4 bytes
long double* ldPtr{}; // long doubles are usually 8 or 12 bytes
std::cout << sizeof(chPtr) << '\n'; // prints 4
std::cout << sizeof(iPtr) << '\n'; // prints 4
std::cout << sizeof(ldPtr) << '\n'; // prints 4
return 0;
}Rozmiar wskaźnika jest zawsze taki sam. Dzieje się tak dlatego, że wskaźnik jest po prostu adresem pamięci, a liczba bitów potrzebnych do uzyskania dostępu do adresu pamięci jest stała.
Wiszące wskaźniki
Podobnie jak odwołanie wiszące, wskaźnikiem wiszącym to wskaźnik przechowujący adres obiektu, który nie jest już ważny (np. ponieważ został zniszczony).
Wyłuskiwanie wskaźnika wiszącego (np. w celu wydrukowania wskazywanej wartości) doprowadzi do niezdefiniowanego zachowania podczas próby dostępu do obiektu, który nie jest już ważny.
Być może zaskakujące, jak mówi norma „Każde inne użycie nieprawidłowego wskaźnika wartości ma zachowanie zdefiniowane w implementacji”. Oznacza to, że możesz przypisać nieprawidłowemu wskaźnikowi nową wartość, np. nullptr (ponieważ nie spowoduje to użycia wartości nieprawidłowego wskaźnika). Jednakże wszelkie inne operacje wykorzystujące wartość nieprawidłowego wskaźnika (takie jak kopiowanie lub zwiększanie nieprawidłowego wskaźnika) spowodują zachowanie zdefiniowane w implementacji.
Kluczowa informacja
Wyłuskanie nieprawidłowego wskaźnika doprowadzi do niezdefiniowanego zachowania. Każde inne użycie nieprawidłowej wartości wskaźnika jest zdefiniowane w implementacji.
Oto przykład tworzenia wiszącego wskaźnika:
#include <iostream>
int main()
{
int x{ 5 };
int* ptr{ &x };
std::cout << *ptr << '\n'; // valid
{
int y{ 6 };
ptr = &y;
std::cout << *ptr << '\n'; // valid
} // y goes out of scope, and ptr is now dangling
std::cout << *ptr << '\n'; // undefined behavior from dereferencing a dangling pointer
return 0;
}Powyższy program prawdopodobnie wyświetli:
5 6 6
Ale niekoniecznie, ponieważ obiekt, na który wskazywał ptr wyszedł poza zakres i został zniszczony na końcu wewnętrznego bloku, pozostawiając ptr zawieszone.
Wnioski
Wskaźniki to zmienne przechowujące adres pamięci. Można je wyłuskać za pomocą operatora dereferencji (*), aby pobrać wartość pod adresem, który przechowują. Dereferencja do wskaźnika typu wild lub wiszącego (lub zerowego) spowoduje niezdefiniowane zachowanie i prawdopodobnie spowoduje awarię aplikacji.
Wskaźniki są zarówno bardziej elastyczne niż referencje, jak i bardziej niebezpieczne. Będziemy kontynuować zgłębianie tego w nadchodzących lekcjach.
Czas quizu
Pytanie nr 1
Jakie wartości wypisuje ten program? Załóżmy, że short ma 2 bajty i jest maszyną 32-bitową.
#include <iostream>
int main()
{
short value{ 7 }; // &value = 0012FF60
short otherValue{ 3 }; // &otherValue = 0012FF54
short* ptr{ &value };
std::cout << &value << '\n';
std::cout << value << '\n';
std::cout << ptr << '\n';
std::cout << *ptr << '\n';
std::cout << '\n';
*ptr = 9;
std::cout << &value << '\n';
std::cout << value << '\n';
std::cout << ptr << '\n';
std::cout << *ptr << '\n';
std::cout << '\n';
ptr = &otherValue;
std::cout << &otherValue << '\n';
std::cout << otherValue << '\n';
std::cout << ptr << '\n';
std::cout << *ptr << '\n';
std::cout << '\n';
std::cout << sizeof(ptr) << '\n';
std::cout << sizeof(*ptr) << '\n';
return 0;
}Pytanie nr 2
Co jest nie tak z tym fragmentem kodu?
int v1{ 45 };
int* ptr{ &v1 }; // initialize ptr with address of v1
int v2 { 78 };
*ptr = &v2; // assign ptr to address of v2
