Wskaźniki w C++

Wskaźniki w C++

Dla typu T T* jest typem „wskaźnik do T”. To znaczy, że zmienna typu T* może przechowywać adres obiektu typu T. Na przykład:

char c = 'a';
char*p = &c; // p zawiera adres c; & jest operatorem adresowania

Można to przedstawić graficznie w następujący sposób:

Podstawowym działaniem wykonywanym na wskaźniku jest dereferencja (wyłuskanie), czyli odwołanie się do obiektu wskazywanego przez ten wskaźnik. Działanie to nazywa się też pośredniością (ang. indirection). Symbol dereferencji to przedrostkowy operator *, np.:

char c = 'a';
char*p = &c; // p zawiera adres c; & jest operatorem adresowania
char c2 =*p; // c2 == 'a'; * jest operatorem dereferencji

Wskaźnik p wskazuje obiekt c, natomiast wartość c to 'a', więc wartość *p przypisana do c2 to 'a'. Na wskaźnikach do elementów tablic można wykonywać pewne działania arytmetyczne. Implementacja wskaźników ma zapewnić bezpośrednie odwzorowanie mechanizmów adresowania komputera, na którym działa program. Większość komputerów adresuje bajty. Te, które tego nie robią, często mają specjalne układy do pobierania bajtów ze słów. Z drugiej strony, jest niewiele komputerów zdolnych do adresowania pojedynczych bitów. W konsekwencji najmniejszy obiekt, jaki można niezależnie alokować i wskazywać za pomocą wbudowanego
wskaźnika, to obiekt typu char. Należy podkreślić, że typ bool zajmuje przynajmniej tyle samo miejsca co char. Aby zaoszczędzić miejsca na przechowywaniu małych wartości, można posłużyć się logicznymi operacjami bitowymi, polami bitowymi w strukturach
oraz zbiorami bitów (bitset). Operatora *, oznaczającego „wskaźnik do”, używa się jako przyrostka do nazwy typu. Niestety wskaźniki do tablic i wskaźniki do funkcji są nieco bardziej skomplikowane:

int*pi; // wskaźnik do int
char**ppc; // wskaźnik do wskaźnika do char
int*ap[15]; // tablica 15 wskaźników do int
int (*fp)(char*); // wskaźnik do funkcji pobierającej argument char*; zwraca int
int*f(char*); // funkcja przyjmująca argument char*; zwraca wskaźnik do int

void*

W kodzie niskopoziomowym czasami trzeba zapisać lub przekazać adres miejsca w pamięci bez wiedzy, jakiego typu obiekt jest w tym miejscu zapisany. Do tego celu służy typ void*, którego nazwę można przeczytać: „wskaźnik na obiekt nieznanego typu”.
Do zmiennej typu void* można przypisać wskaźnik do dowolnego typu obiektu, ale nie do funkcji ani składowej. Ponadto void* można przypisać do innej zmiennej typu void*, zmienne typu void* można porównywać oraz void* można jawnie przekonwertować
na inny typ. Inne operacje byłyby niebezpieczne, bo kompilator nie odróżnia, jakiego typu obiekty są wskazywane. W konsekwencji próby wykonania jakichkolwiek innych działań kończą się błędem wykonawczym. Aby użyć zmiennej typu void*, należy przekonwertować ją na wskaźnik konkretnego typu, np.:

void f(int*pi)
{
    void*pv = pi; // OK: niejawna konwersja int* na void*
    *pv; // błąd: nie można wyłuskać void*
    ++pv; // błąd: nie można zwiększyć void* (rozmiar wskazywanego obiektu jest nieznany)
    int*pi2 = static_cast(pv); // jawna konwersja z powrotem na int*
    double*pd1 = pv; // błąd
    double*pd2 = pi; // błąd
    double*pd3 = static_cast(pv); // niebezpieczne (11.5.2)
}

Ogólnie rzecz biorąc, używanie wskaźnika, który został przekonwertowany (rzutowany) na typ inny niż typ wskazywanego obiektu, jest niebezpieczne. Na przykład w komputerze może być przyjęte założenie, że każda wartość typu double jest alokowana w ośmiu bajtach. Gdyby tak było i pi wskazywałby wartość int, która nie została w taki sposób alokowana, to otrzymalibyśmy dziwne wyniki. Pokazany rodzaj jawnej konwersji typów to niebezpieczna i brzydka technika. Dlatego zastosowana notacja, static_cast, została tak zaprojektowana, aby również była brzydka i łatwa do znalezienia w kodzie. Głównym zastosowaniem typu void* jest przekazywanie wskaźników do funkcji, w których nie może być odgórnych założeń co do typu obiektu i zwracania obiektów bez określonego typu z funkcji. Aby użyć takiego obiektu, trzeba zastosować jawną konwersję typów. Funkcje wykorzystujące typ void* z reguły działają na najniższym poziomie systemu, gdzie operuje się wprost na zasobach sprzętowych. Na przykład:

void* my_alloc(size_t n); // alokacja n bajtów z mojej specjalnej sterty

Do wszelkich przypadków użycia typu void* na wyższym poziomie systemu należy podchodzić z rezerwą, ponieważ mogą oznaczać błędy projektowe. W przypadku użycia do celów optymalizacyjnych void* można ukryć za bezpiecznym typowo interfejsem. Wskaźników do funkcji i składowych nie można przypisywać do typu void*.

nullptr

Literał nullptr reprezentuje wskaźnik pusty, czyli taki, który nie wskazuje żadnego obiektu. Można go przypisać do wskaźnika dowolnego typu, ale nie do innych typów wbudowanych:

int*pi = nullptr;
double*pd = nullptr;
int i = nullptr; // błąd: i nie jest wskaźnikiem

Istnieje tylko jeden nullptr, którego można używać z dowolnym typem wskaźnikowym. To znaczy nie ma osobnego pustego wskaźnika dla każdego typu wskaźnikowego. Zanim wprowadzono nullptr, używano zera (0). Na przykład:

int* x = 0; // x ma wartość nullptr

Pod adresem 0 nie jest alokowany żaden obiekt i 0 (szereg samych zerowych bitów) jest najczęściej stosowaną reprezentacją nullptr. Zero jest wartością typu int. Jednak standardowe konwersje zezwalają na używanie 0 jako stałej wskaźnika lub typu wskaźnik do składowej. Popularną techniką jest deklarowanie makra NULL reprezentującego wskaźnik pusty. Na przykład:

int* p = NULL; // użycie makra NULL

Jednak definicje NULL w różnych implementacjach różnią się od siebie. Na przykład NULL może oznaczać 0 albo 0L. W języku C NULL to najczęściej (void*)0, co jest niedozwolone w C++ :

int* p = NULL; // błąd: nie można przypisać void* do int*

Kod, w którym używany jest nullptr, jest bardziej czytelny i nie ma w nim ryzyka wystąpienia nieporozumień, gdy funkcja zostanie przeciążona, aby mogła przyjmować wskaźnik lub liczbę całkowitą.

Wskaźniki i const

W języku C++ istnieją dwa wzajemnie powiązane pojęcia stałej:

  • constexpr - oznacza obliczanie wartości w czasie kompilacji;
  • const - oznacza brak możliwości modyfikowania obiektu w określonym zakresie.

Zasadniczo constexpr służy do wymuszania obliczania wartości wyrażeń w trakcie kompilacji, natomiast const jest używane do zapewniania niezmienności interfejsów. Głównym tematem tego podrozdziału jest druga z wymienionych kwestii, dotycząca interfejsów. Wiele obiektów po inicjacji nie zmienia już wartości:

  • Dzięki użyciu stałych symbolicznych zamiast literałów kod jest łatwiejszy w utrzymaniu.
  • Wiele wskaźników jest używanych do odczytu, a nigdy do zapisu.
  • Większość parametrów funkcji się odczytuje, ale nie zapisuje.

Aby wyrazić tę niezmienność po inicjacji, można dodać const do definicji obiektu. Na przykład:

  • const int model = 90; // model jest const
  • const int v[] = { 1, 2, 3, 4 }; // v[i] jest const
  • const int x; // błąd: brak inicjatora

Jako że do obiektu zadeklarowanego jako const nie można przypisać wartości, obiekt ten musi zostać zainicjowany. Deklarując coś jako const, gwarantujemy, że wartość tego nie zmieni się w całym zakresie dostępności:

void f()
{
    model = 200; // błąd
    v[2] = 3; // błąd
}

Należy podkreślić, że modyfikator const zmienia typ. Ogranicza wachlarz możliwości wykorzystania obiektu, a nie określa sposobu alokacji stałej. Na przykład:

void g(const X*p)
{
    // tu nie można zmodyfikować *p
}

Gdy wykorzystywany jest wskaźnik, w grę wchodzą dwa obiekty: sam wskaźnik i wskazywany przez niego obiekt. Słowo kluczowe const stojące przed deklaracją wskaźnika odnosi się do obiektu wskazywanego przez ten wskaźnik. Aby jako stały zadeklarować sam wskaźnik, należy użyć operatora *const zamiast *. Na przykład:

void f1(char*p)
{
    char s[] = "Gorm";
    const char*pc = s; // wskaźnik do stałej
    pc[3] = 'g'; // błąd: pc wskazuje stałą
    pc = p; // OK
    char*const cp = s; // stały wskaźnik
    cp[3] = 'a'; // OK
    cp = p; // błąd: cp jest stałą
    const char*const cpc = s; // stały wskaźnik do const
    cpc[3] = 'a'; // błąd: cpc wskazuje stałą
    cpc = p; // błąd: cpc jest stałą
}

Operator deklaracyjny, przy użyciu którego tworzy się stały wskaźnik, to *const. Nie istnieje operator const* i dlatego jeśli słowo kluczowe const znajduje się przed gwiazdką, to jest traktowane jako część typu bazowego. Na przykład:

char *const cp; // stały wskaźnik do char
char const* pc; // wskaźnik do const char
const char* pc2; // wskaźnik do const char

Niektórym pomaga czytanie tych deklaracji od prawej strony, np. „cp jest wskaźnikiem const do char” albo „pc2 jest wskaźnikiem do char const”. Jeden obiekt używany poprzez wskaźnik może być stałą, a używany w inny sposób może być zmienną. Jest to szczególnie przydatne w przypadku argumentów funkcji. Dzięki zadeklarowaniu  argumentu wskaźnikowego jako stałego funkcja nie może zmodyfikować wskazywanego obiektu. Na przykład:

const char*strchr(const char*p, char c); // znajduje pierwsze wystąpienie c w p
char*strchr(char*p, char c); // znajduje pierwsze wystąpienie c w p

Pierwsza wersja jest przeznaczona dla łańcuchów, których elementy nie mogą być modyfikowane. Zwraca ona wskaźnik na stałą bez możliwości modyfikacji. Druga wersja jest przeznaczona dla zmiennych łańcuchów. Można przypisać adres zmiennej do wskaźnika stałego, bo działanie takie nie może mieć negatywnych skutków. Natomiast adresu stałej nie można przypisać do nieograniczonego wskaźnika, bo to umożliwiłoby zmianę wartości takiego obiektu. Na przykład:

void f4()
{
    int a = 1;
    const int c = 2;
    const int*p1 = &c; // OK
    const int*p2 = &a; // OK
    int*p3 = &c; // błąd: inicjacja int* przy użyciu const int*
    *p3 = 7; // próba zmiany wartości c
}

Jest możliwe, choć w większości przypadków niemądre, bezpośrednie usunięcie ograniczeń ze wskaźnika do const poprzez zastosowanie konwersji.

Wskaźniki i własność

Zasób to coś, co trzeba pozyskać, a potem zwrócić. Przykładami zasobów, do których najbardziej bezpośrednim uchwytem jest wskaźnik, są pamięć zajmowana za pomocą operatora new i zwalniana przy użyciu delete oraz pliki otwierane za pomocą funkcji fopen() i zamykane przy użyciu funkcji fclose(). Posługiwanie się takimi wskaźnikami może być przyczyną wielu problemów, bo wskaźniki można bez przeszkód przekazywać w różnych miejscach programu, a ponadto w systemie typów języka C++ nie ma niczego, co odróżniałoby wskaźniki posiadające zasoby od wskaźników, które nic nie mają. Spójrz na poniższy przykład:

void confused(int*p)
{
    // usunąć p?
}
int global {7};
void f()
{
    X*pn = new int{7};
    int i {7};
    int q = &i;
    confused(pn);
    confused(q);
    confused(&global);
}

Jeśli funkcja confused() usunie (delete) p, to następne dwa wywołania będą źle działać, ponieważ nie można usuwać za pomocą operatora delete obiektów, które nie są alokowane przez operator new. Jeżeli funkcja confused nie usunie p, to powstanie wyciek pamięci. W takim przypadku oczywiste jest, że to funkcja f() powinna zarządzać cyklem istnienia obiektu alokowanego przez siebie w pamięci wolnej. Jednak ogólnie rzecz biorąc, jeśli w dużym programie chce się zapanować nad usuwaniem obiektów, należy przyjąć jakąś prostą i spójną strategię działania.

Zazwyczaj dobrym pomysłem jest natychmiastowe umieszczenie wskaźnika reprezentującego własność w klasie uchwytowej do zasobu, np. vector, string albo unique_ptr. Dzięki temu wiadomo, że każdy wskaźnik nie znajdujący się w uchwycie do zasobu nie jest właścicielem i nie może zostać usunięty przy użyciu operatora delete.

 Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion 

Podobne artykuły

« Struktury w C++Projektowanie hierarchii klas »

Podziel się ze znajomymi tym artykułem - udostępnij na FB lub wyślij e-maila korzystając z poniższych opcji:

wszystkie oferty