Podstawy programowania 6

Wskaźniki

Wskaźniki są specjalnym typem zmiennych. Jak sama nazwa wskazuje, wskaźnik wskazuje na inny obiekt. Implementowane jest to w ten sposób, że wskaźnik przechowuje adres do obiektu, na który wskazuje. Jak na tym rysunku.

Składnia

Aby zadeklarować wskaźnik do typu, do nazwy zmiennej dodajemy gwiazdkę:

// p jest wskaźnikiem na int:
int *p;
// s jest wskaźnikiem na char:
char *s;

// Tutaj i jest normalną zmienną, nie wskaźnikiem(!):
int *q, i;
// Równoważnie:
int i, *q;

Gwiazdkę można też dołożyć do typu, jak tutaj:

// Tutaj i jest normalną zmienną, nie wskaźnikiem(!):
int* q, i;

ale nie zmienia to faktu, że dotyczy tylko zmiennej przy której bezpośrednio się znajduje. Można też tworzyć wskaźniki na wskaźniki (i jeszcze dalej), choć oczywiście myślenie o takim kodzie robi się szybko bardzo skomplikowane:

int i;
int *p = &i;
int **pp = &p;

Operator &

Aby zainicjalizować wskaźnik potrzebujemy operatora pobrania adresu&. Operator ten, jak nazwa wskazuje, zwraca adres podanego obiektu.

int i;
int *p = &i;

Adres można pobrać tylko dla niektórych obiektów, tych dla których ma to sens. Poniższe napisy są błędne:

&(x+1);
&2.0;
&(x++);

Operator *

Aby wskaźniki były przydatne, musi istnieć mechanizm pozwalający na dostanie się do elementu, na który wskaźnik wskazuje. Mechanizm ten to tzw. operator dereferencji, oznaczany znowu gwiazdką przed wskaźnikiem. Pozwala on zarówno na pobranie wartości zmiennej na którą wskaźnik wskazuje, jak i na jej modyfikację:

int i = 1;
int p = &i;

cout <<  p; // wypisuje coś w rodzaju 0x1798010
cout << *p; // wypisuje 1

*p = 2;
cout << *p; // wypisuje 2
cout << i;  // wypisuje 2

Zmienne typu void *

Co prawda nie ma zmiennych typu void, można jednak tworzyć wskaźniki typu void *. Ze względu na zgodność z językiem C, wskaźnikom takim można przypisać adres dowolnego obiektu (lub wartość dowolnego wskaźnika), i zrzutować na dowolny inny typ wskaźnika, obchodząc tym samym kontrolę typów wykonywaną przez kompilator.

#include <iostream>
using namespace std;
int main() {
    unsigned short i = 0;
    void *p = &i;
    // Tworzymy nowy wskaźnik na int
    // o takiej samej wartości co p,
    // który był wskaźnikiem na short.
    int *pi = static_cast<int *>(p);

    // kompilator zakłada, że &pi jest typu int,
    // ale pod tym adresem znajduje się zmienna typu unsigned short.
    // Efektem jest otrzymanie częściowo losowych wartości.
    cout << &pi << endl;
}

W języku C mechanizm ten był potrzebny, aby móc tworzyć możliwie elastyczne procedury biblioteczne, których zachowanie można zmieniać podając własne funkcje (np. funkcja porównująca przy sortowaniu tablicy). W języku C++ mechanizm ten trzeba było zostawić. Jednak istnieje znacznie lepszy sposób pisania kodu do stosowania z wieloma typami – szablony. Tym samym raczej nie należy stosować wskaźników typu void *.

Zmienne typu char *

W języku C łańcuchy znaków są implementowane jako ciąg znaków zakończony bajtem zerowym Najprostszym sposobem przekazywania takiego łańcucha jest wskaźnik na jego pierwszy element. Stąd typ char * ma dla wielu funkcji z biblioteki standardowej C znaczenie specjalne – funkcje te przyjmują argumenty tego typu rozumiejąc, że oznaczają one łańcuchy zakończone właśnie bajtem zerowym. Takie właśnie łańcuchy generuje kompilator w przypadku napotkania "". Dlatego też, jak już widzieliśmy wcześniej, łańcuch zajmuje zawsze o jeden bajt pamięci więcej niż na pierwszy rzut oka wygląda.

Łańcuchy takie często są niebezpieczne. Bardzo łatwo jest zapisać gdzieś łańcuch dłuższy niż przeznaczone miejsce, tak samo zdarza się nadpisać bajt zerowy. Ma to katastrofalne skutki dla programu. Znacznie bezpieczniejsza jest biblioteczna klasa std::string.

Wskaźniki na funkcje

Mimo, że funkcje nie są zmiennymi, to można także pobierać ich adresy i tworzyć wskaźniki do funkcji. Składnia dla takich wskaźników jest nieco skomplikowana:

#include <iostream>
using namespace std;

int f(int x) {
    return 2*x;
}

int main() {
    // g jest wskaźnikiem na funkcję
    // przyjmującą int i zwracającą int
    int (*g)(int) = &f;
    cout << g(2) << endl;

    return 0;
}

Nawiasy w definicji g są niezbędne, inaczej g byłoby deklaracją funkcji (nie wskaźnika) zwracającą wskaźnik na int i przyjmującą parametr typu int.

Arytmetyka wskaźników i tablice

Tablicę dynamiczną tworzymy następująco:

int *t = new int[n];

Dalej w kodzie można już pisać np t[0]. Jest to wynik tego, że tablice są de facto wskaźnikami na ich pierwsze elementy. Dlatego można napisać:

int t[5];
*t = 0; // równoważne t[0] = 0;
t[1] = 2 // równoważne *(t+1) = 2

1[t] = 2 // równoważne *(1+t) = 2 (naprawdę działa!)

Arytmetykę wskaźników należy rozumieć w tym kontekście.

Modyfikacja wskaźnika

Do każdego wskaźnika można dodać lub odjąć liczbę naturalną. Oznacza to przesunięcie wskaźnika o odpowiednią ilość pozycji, gdyby wskaźnik był wskaźnikiem na tablicę. Dla przykładu:

int *v = new int[n];
int *t = v; // Dwa równe wskaźniki
*t = 0;     // t wskazuje na v[0]
t = t + 2;
*t = 1      // t wskazuje na v[2]
++t;
*t = 2;     // t wskazuje na v[3]
--t;
*t = 1;     // t wskazuje na v[2]

Porównywanie wskaźników

Operację na dwóch wskaźnikach można wykonać tylko, gdy odnoszą się one do tego samego obszaru pamięci – do jednej tablicy – lub gdy jeden z nich wskazuje na element znajdujący się zaraz za taką tablicą (czyli na element t[n], gdy t ma n elementów). W szczególności oba wskaźniki muszą być tego samego typu. W pozostałych przypadkach działanie programu jest niezdefiniowane.

Wynikiem odjęcia dwóch wskaźników jest ilość elementów tablicy, która się między nimi zmieści:

int *v = new int[n];
int *p = v;
int *q = v;
if (p == q)
    cout << "Wskaźniki są równe" << endl;
++p; // Zwiększamy p
cout << p - q << endl; // Wypisuje  1
cout << q - p << endl; // Wypisuje -1
if (p > q)
    cout << "p jest większe" << endl;

Operacje na dwóch wskaźnikach, jak wyżej, są jednak bardzo rzadko potrzebne. Arytmetyka wskaźników z reguły ogranicza się do zmieniania jednego wskaźnika.

Ponieważ różne typy mają różną wielkość w bajtach, wielkość zwracana przez p - q wyżej nie liczy liczby bajtów, tylko liczbę zmiennych typu wskaźnika.

Wartość NULL

Ponieważ wskaźniki to zasadniczo adresy liczbowe komórek w pamięci, przyjęto, że przypisanie wartości zero oznacza wskaźnik nie wskazujący na nic. Próba dostania się do jego zawartości powoduje oczywiście błąd, ale przynajmniej dość przewidywalny. W bibliotece języka C, w nagłówku cstddef zdefiniowane jest specjalne zero do stosowania ze wskaźnikami o nazwie NULL. W najnowszym standardzie C++ istnieje również analogiczny twór – std::nullptr. Niestety kompilator na sprawdzarce jeszcze tego nie wie...

Zerowanie wskaźnika na trzy sposoby wygląda następująco:

int *p = 0;
int *q = NULL;
int *r = nullptr;

Przekazywanie parametrów funkcji „przez wskaźnik”

Ponieważ wskaźniki są tylko kolejnym rodzajem zmiennych nie ma problemu z definiowanie funkcji przyjmujących je jako parametry. Dla przykładu poniższa funkcja przyjmuje wskaźnik na int:

void f(int *p) {
    *p = 1;
}

Niektóre podręczniki błędnie mówią w tym przypadku o przekazaniu argumentu przez wskaźnik czy przez adres. Przekazywanie argumentu odbywa się jednak nadal przez wartość, więc jak dla normalnych zmiennych. Jednak to wskaźnik jest kopiowany. Kopia wskaźnika na obiekt wskazuje nadal na ten sam obiekt. Tym samym można ten obiekt zmieniać w razie potrzeby. Jednak zmiana przekazanego wskaźnika pozostaje niewidoczna dla kodu wywołującego, bo zmieniamy tylko jego kopię.

#include <iostream>
using namespace std;

void f(int *p) {
    *p = 1;
    ++p;
}

int main() {
    int i = 0;
    cout << i << endl;
    f(&i); // Przekazujemy do funkcji f
           // adres zmiennej i (wskaźnik).
    cout << i << endl;
    return 0;
}

Niebezpieczeństwa związane ze wskaźnikami

Wskaźniki są stosunkowo niebezpiecznym elementem języka. Wskaźnik niezainicjowany odpowiednio wskazuje w losowe miejsce pamięci programu (wiszący wskaźnik) i próba jakiegokolwiek dostępu do wskazywanego obiektu kończy się niezdefiniowanym zachowaniem programu. Jeżeli mamy szczęście, to program się od razu zakończy. Jeżeli nie mamy szczęścia to może dla przykładu usunąć jakiś plik czy spowodować błąd w zupełnie innym miejscu.

Zawsze należy inicjować deklarowane wskaźniki. Jeżeli nie możemy podać od razu adresu, należy wskaźnik zainicjować zerem, wartością NULL lub wartością nullptr (jeśli nasz kompilator jest wystarczająco nowy i ją rozumie).

Iteratory

W nowoczesnym C++ ze względu na niebezpieczeństwo stosunkowo rzadko używa się surowych wskaźników. Z reguły korzystamy ze specjalnych klas, które je opakowują lub udają. Co prawda procesor musi wykonać więcej pracy, to jednak zwiększone bezpieczeństwo z nawiązką to rekompensuje. Przy korzystaniu z kontenerów biblioteki standardowej (np. z klasy vector<>) można korzystać z tzw. iteratorów, których zachowanie bardzo przypomina wskaźniki.

Dla przykładu porównajmy wypisywanie elementów tablicy przy użyciu wskaźnika i wektora przy użyciu iteratora (pamiętajmy, że tablica jest de facto wskaźnikiem na pierwszy element):

#include <iostream>
#include <vector>

using namespace std;

int main() {
    int t[3] = {0,1,2};
    vector<int> v;
    v.push_back(0);
    v.push_back(1);
    v.push_back(2);

    for (int *p = t; p < t+3; ++p) {
        cout << *p << endl;
    }

    for (vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
        cout << *it << endl;
    }

    return 0;
}

Wszystkie kontenery udostępniają iteratory które można inkrementować, dekrementować i na których można dokonywać dereferencji. Niektóre kontenery udostępniają iteratory, do których można dodatkowo dodawać liczby (zgodnie zasadami analogicznymi do tych opisanych w sekcji Arytmetyka wskaźników i tablice).

Metody zwracające iteratory to:
  • begin() – iterator wskazujący na pierwszy element
  • end() – iterator wskazujący na element znajdujący się „za ostatnim elementem”, wykorzystywany w warunkach stopu
  • rbegin() – iterator wskazujący na ostatni element, jego inkrementacja powoduje poruszanie się wstecz po kontenerze
  • rend() – iterator wskazujący na element znajdujący się „przed pierwszym elementem”, wykorzystywany w warunkach stopu
#include <iostream>
#include <vector>

using namespace std;

int main() {
    int t[3] = {0,1,2};
    vector<int> v;
    v.push_back(0);
    v.push_back(1);
    v.push_back(2);

    // Wypisywanie liczb w kolejności ich dodawania do wektora
    for (vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
        cout << *it << endl;
    }
    // Wypisywanie liczb w odwrotnej kolejności
    for (vector<int>::iterator it = v.rbegin(); it != v.rend(); ++it) {
        cout << *it << endl;
    }

    return 0;
}

Referencje

Referencje są mechanizmem który często może zastąpić wskaźniki. Są jednym z mechanizmów, który dodano w języku C++ w stosunku do C. O referencji można myśleć jako o innej nazwie dla wskazywanego przez referencję obiektu. Co prawda są implementowane najczęściej jako wskaźniki, ale są jakby nieco ich osłabioną wersją:
  • nie można dostać się normalnie do tego wskaźnika;
  • przy definicji trzeba jednocześnie dokonać inicjalizacji, nie ma pustych referencji;
  • po inicjalizacji nie można już zmieniać „celu” referencji.

Wszelkie operacje wykonują się automatycznie na wskazywanej zmiennej bez potrzeby używania operatora dereferencji.

#include <iostream>

using namespace std;

int main() {
    int i = 0;
    // int &r; // Błąd. Nie może być pustej referencji
    int &r = i;

    r = 1;

    cout << r << endl;

    return 0;
}
Referencje są najczęściej spotykane przy deklarowaniu argumentów funkcji. C++ (w przeciwieństwie do C) umożliwia inny sposób przekazywania argumentów niż tylko przez wartość – można je też przekazać przez referencję. Normalne przekazanie przez referencję ma dwa skutki:
  • przekazywany obiekt nie jest kopiowany, można więc go zmieniać w razie potrzeby (jeżeli obiekt jest duży, to uniknięcie kopiowania może poprawić wydajność, zresztą niektórych obiektów nie można kopiować);
  • jako argument trzeba koniecznie podać istniejącą zmienną lub referencję do takiej zmiennej.
#include <iostream>

using namespace std;

void f(int x, int &y) {
    y = x;
}

int main() {
    int i = 0;
    int &r = i;

    f(1, i);
    cout << i << endl;
    f(2, r);
    cout << r << endl;
    f(r, i);

    // f(2, 2); // Błąd. Drugi argument musi być
                // zmienną bądź referencją.
    return 0;
}

Referencja do stałej

Co prawda nie powiemy tutaj zbyt wiele o słowie kluczowym const, ale koniecznie trzeba wspomnieć o referencjach do stałych. Referencję do stałej definiujemy następująco:

int i = 1;
const int &r = i;

Na pierwszy rzut oka może się ona wydawać niezbyt przydatna, ponieważ nie można zmienić obiektu przy użyciu takiej referencji, jedynie pobrać wartość:

cout << i << endl;
cout << r << endl;
i = 2;
r = 3; // Błąd. Referencja r jest referencją do stałej

Referencje do stałej są jednak bardzo przydatne jako typ argumentu funkcji. Dostajemy wtedy zalety przekazania przez referencję (nie kopiujemy potencjalnie dużego obiektu) z zaletami przekazania przez wartość (funkcja nie może zmienić wartości przekazanej zmiennej, zamiast zmiennej można przekazać coś co zmienną nie jest). Dla przykładu poniżej dwie funkcje są funkcjonalnie równoważne:

void f(int x) {
    cout << x << endl;
}

void g(const int &x) {
    cout << x << endl;
}

W szczególności można napisać później np. g(2).

Zgodnie z tym co napisano wyżej, kompilator nie pozwoli nam skompilować funkcji, której parametr jest referencją do stałej a instrukcje zmieniają tę zmienną. Co prawda możne wykonać małe czary (używając np. const_cast) i napisać coś co kompilator przełknie, ale zmiana obiektu wskazywanego przez referencję do stałej, który tak naprawdę powinien być stały, prowadzi do niezdefiniowanego zachowania programu.