Top
Panel główny
Portal
Powitanie
Poglądy
Ciasteczka
Kontakt
Katolicyzm
Droga krzyżowa
Tawiusz
Praktyczne
Notacje
Unikod
Programy
Muzyka
Dźwięki
Terminy
Gitara
Gry
Mafia
Argentynopol
Szachowe
Galeria
Zadania
Suchary
Download

data ostatniej zmiany tej podstrony: 6 I 2015 r.

Wskaźniki

Jednym z elementów języków C i C++ są wskaźniki. Zrozumienie ich zastosowania może znacznie poszerzyć wyobraźnie programistyczną, ponieważ wiele struktur danych, jak np. tablica lub lista wiązana, opiera się właśnie na wskaźnikach. Ten artykuł ma na celu jak najbardziej przyjaźnie wprowadzić w tematykę wskaźników na przykładzie języka C++. Na końcu artykułu znajduje się kod programu prezentujący omówione własności wskaźników.

Intuicja

Zmienne używane w programach są zapamiętywane w pamięci komputera, czyli w pewnym miejscu pamięci ciąg bajtów reprezentuje wartość tej zmiennej. Wskaźniki służą temu, aby zapamiętać, gdzie takie miejsce w pamięci się znajduje. Dlatego o wskaźniku możemy myśleć jako o adresie miejsca w pamięci. Część pamięci, gdzie znajdują się zmienne, nazywamy stertą.

Stworzymy przykład, który będzie bazował na intuicji wskaźnika jako adresu. Pod adresem Baker Street 221B mieszka człowiek nazywający się Sherlock Holmes. Dla reprezentacji tej sytuacji definiujemy klasę Czlowiek, która składa się z pól plec i wiek. Obiekt Sherlock_Holmes jest obiektem tej klasy, Sherlock_Holmes.plec ma wartość "mezczyzna", a Sherlock_Holmes.wiek początkowo 60.

Wskaźnik na obiekt

Typ Czlowiek* określa zmienną wskaźnikową wskazującą na obiekt typu Czlowiek. To znaczy, że ta zmienna przechowuje adres pamięci, gdzie rozpoczyna się reprezentacja tego obiektu. Do przechowywania adresu w pamięci bez informacji o tym, co się w nim znajduje, służy typ void*. Idąc dalej typ Czlowiek** oznacza wskaźnik wskazujący na dane typu Czlowiek*.

Każda zmienna typu wskaźnikowego może także przyjąć wartość pustą. W nowszych wersjach języka C++ nadajemy jej wartość nullptr, a w C NULL. Nie istnieje miejsce w pamięci o takim adresie, więc taką wartość nadajemy wskaźnikowi, gdy np. jest niezainicjowany lub nie ma wskazywać na nic konkretnego.

Zgodnie z powyższym Baker_Street_221B jest typu Czlowiek* i wskazuje na dane obiektu Sherlock_Holmes, który jest typu Człowiek.

Operatory pobrania adresu

Do pobrania adresu zmiennej w języku C++ służy operator &. Zatem do Baker_Street_221B przypisana jest wartość &Sherlock_Holmes.

Operator & ma jednak zastosowanie jedynie przy zmiennych, ponieważ niezadeklarowana stała nie jest przechowywana na stercie, więc nie ma adresu. Dlatego napisanie &1 spowoduje błąd kompilacji. Idąc naszą intuicją można porównać 1 do bezdomnego człowieka, choć 1 jest oczywiście typu int, a nie Czlowiek.

Zauważmy, że możemy powołać inną zmienną wskaźnikową np. Siddons_Lane_1, do której również możemy przypisać wartość &Sherlock_Holmes. W ten sposób mamy dwa wskaźniki wskazujące na to samo miejsce. Możemy powiedzieć, że te zmienne są równe, ponieważ ich wartością jest ten sam adres. Nazwy ulic są jedynie nazwami zmiennych, a nie bezpośrednio adresami pamięci.

Operator wyłuskania

Operatorem o działaniu odwrotnym do pobrania adresu jest *. Działanie to, zwane wyłuskaniem, polega na otrzymaniu danych z adresu, gdzie się znajdują. Przykładowo (*Baker_Street_221B).plec ma wartość "mezczyzna", bo (*Baker_Street_221B) to obiekt typu Czlowiek o danych obiektu Sherlock_Holmes, a Sherlock_Holmes.plec ma wartość "mezczyzna".

Trzeba odróżnić dwa znaczenia *. Po pierwsze * występując za typem oznacza razem z nim typ wskaźnika na ten typ. Po drugie * występując przed daną wskaźnikową oznacza razem z nią dane, na które wskazuje dana wskaźnikowa. Teoretycznie można by używać dwóch rożnych symboli na te dwa różne znaczenia, ale twórcy języka C++ postanowili nie rozszerzać języka o dodatkowe symbole, skoro nie powoduje to kolizji.

Wracając do spostrzeżenia, że możemy mieć dwa wskaźniki wskazujące na jedno miejsce, warto dodać, że w danym miejscu pamięci występuje jeden konkretny ciąg bitów, więc znajduje się tylko jeden konkretny obiekt. Przykładowo Baker_Street_221B nie może wskazywać jednocześnie na obiekty Sherlock_Holmes i John_Watson, jeśli są różnymi obiektami.

Próba wyłuskania czegoś z pustego wskaźnika zakończy się błędem wykonania programu.

Kolejny błąd który łatwo popełnić, to wyrażenie *Baker_Street_221B.plec, które od poprawnego różni się brakiem nawiasów. Operator . ma w języku C++ wyższy priorytet niż *, więc kompilator będzie próbował odwołać się do pola plec zmiennej Baker_Street_221B i z tego, co otrzyma, wyłuskać dane. Oczywiście zmienna Baker_Street_221B nie ma pola plec, więc spowoduje to błąd kompilacji.

Twórcy języka C++ uznali, że zapis (*Baker_Street_221B).plec jest niewygodny, więc powołali operator ->, który jest właściwie cukrem syntaktycznym, a pozwala zapisać Baker_Street_221B->plec, co znaczy to samo i ma wartość "mezczyzna".

Przypisania a wskaźniki

Do Baker_Street_221B->wiek możemy po prostu przypisać za pomocą operatora = wartość 61. Po tym przypisaniu Sherlock_Holmes.wiek ma również wartość 61, bo Baker_Street_221B wskazuje na dane obiektu Sherlock_Holmes. Podobnie nawet Siddons_Lane_1->wiek ma wartość 61.

Jeśli definiujemy zmienną number typu int oraz zmienną pointer typu int* o wartości &number, to przypisanie wartości do *pointer operatorem = poskutkuje nadaniem tej samej wartości zmiennej number.

Alokacja pamięci

Jeśli zadeklarujemy wskaźnik p typu int* i spróbujemy coś przypisać do *p, to z dużym prawdopodobieństwem spowoduje to błąd wykonania programu. Stanie się tak, ponieważ p będzie wskazywać na przypadkowe miejsce w pamięci, którego prawdopodobnie nie będziemy prawa użytkować.

Jeśli p będzie akurat wskazywać na obszar pamięci, do którego mamy prawo, to tym gorzej, bo przypisanie może poskutkować nadpisaniem, więc także utratą, fragmentu danych innego obiektu.

Możemy jednak poprosić zarządce pamięci o przydzielenie nam jakiejś jej części. W języku C++ dzieje się to zwykle za pośrednictwem słowa kluczowego new, a w języku C przez użycie funkcji takiej jak np. malloc.

Funkcja malloc przyjmuje jeden argument mówiący o liczbie bajtów, o które prosimy zarządcę pamięci. Jeśli przydział pamięci powiedzie się, to funkcja zwróci wskaźnik na początek przydzielonego fragmentu. Jest to jednak wskaźnik typu void*, więc aby przypisać go do p musimy wcześniej zrzutować go na typ int*. Zatem chcąc zaalokować pamięć na jedną liczbę typu int przypiszemy do p wartość (int*) malloc(1*sizeof(int)).

W C++ do p możemy przypisać new int, co będzie miało takie same efekty. Natomiast w przypadku obiektów wywołanie operatora new wiąże się także z wywołaniem konstruktora obiektu.

Kiedy już p ma wartość adresu pamięci, do której mamy prawo, możemy dokonać przypisania do *p.

Zwalnianie pamięci

W ciągu wykonywania programu alokacja pamięci może powtarzać się bardzo wiele razy. Zarządca pamięci nie może jednak ponownie przyznać pamięci, która jest zaalokowana, bo uważa ją za zajętą. Chcąc ponownie wykorzystać przyznaną pamięć musimy ją zwolnić. Nie mamy prawa do korzystania ze zwolnionej pamięci, dopóki nie zostanie ponownie zaalokowana.

Zwolnienie pamięci przyznanej dla wskaźnika p w języku C będzie skutkiem wywołania free(p). W C++ napisalibyśmy delete p, co w przypadku obiektów wiąże się także z wywołaniem destruktora.

Jeśli nadpiszemy lub w inny sposób stracimy adres zaalokowanej pamięci, to mamy do czynienia z wyciekiem pamięci. Bez adresu nie możemy ani skorzystać z tej pamięci, ani jej zwolnić, więc na czas działania programu zostaje ona utracona. Dlatego należy bardzo na wycieki pamięci uważać.

Tablica

Jedną z najprostszych struktur danych jest tablica. Służy ona do przechowywania wielu danych tego samego typu. W tym celu w pamięci alokowany jest fragment wielkości wielokrotności rozmiaru jednej danej tego typu. W ten sposób po znajomości typu można wywnioskować, gdzie w pamięci zaczyna się kolejny element tablicy.

Chcąc utworzyć czteroelementową tablicę w języku C możemy zaalokować po prostu 4 razy więcej pamięci przypisując do p wartość (int*) malloc(4*sizeof(int)). Zaś w języku C++ korzystając z operatora new[] do p przypiszemy new int[4].

Adres na kolejny element tablicy po elemencie wskazywanym przez p to wartość wyrażenia p+1. W tym wyrażeniu operator + nie oznacza jednak zwykłego dodawania. Dodawanie liczby do wskaźnika oznacza przesuwanie wskaźnika o liczbę komórek takiego typu, na jaki wskazuje wskaźnik, taką, jaka jest wartość tej liczby. Analogicznie przeciążone są operatory -, ++, --, +=, -=. Tych operacji nie można wykonać na zmiennych typu void*.

Zatem naturalnie możemy przypisać coś do *(p+1), ale twórcy języka dodali do niego specjalny operator [] dla wskaźników, kolejny cukier syntaktyczny, którym można wyrazić to samo przez p[1].

Tak więc do pierwszego elementu tablicy A odwołujemy się przez A[0]. Dlatego mówimy, że tablice w językach C i C++ są indeksowane od 0. Jeśli deklarujemy tablicę przez int A[4], to A jest zwykłym wskaźnikiem typu int* i możemy w ten sposób się nim posługiwać.

W języku C zwolnienie pamięci przebiega tak samo przy wywołaniu free(p), a w C++ przez delete[] p.

Aby otrzymać tablicę dwuwymiarową, alokujemy dodatkową pamięć na pomocniczą tablicę wskaźników długości określonej pierwszym wymiarem. W tej tablicy do każdego wskaźnika przypisany jest adres pamięci długości określonej drugim wymiarem. Analogicznie budowane są tablice wyższych wymiarów.

String w stylu C

Wielokrotnie chcemy operować w programach na tekście. Tekst możemy reprezentować jako tablicę liter lub ogólniej znaków, którą nazywamy stringiem. Jeżeli jednak chcemy operować na jakimś tekście, aby np. zmierzyć jego długość potrzebujemy wiedzieć, gdzie się zaczyna, a gdzie kończy. Na początek wskazuje wskaźnik utożsamiany ze stringiem. Aby oznaczyć, gdzie string się kończy, dodajemy do niego jeden dodatkowy znak '\0'. Ta konwencja pozwala na działanie na stringach w stylu języka C.

Zatem pamięć, której potrzebujemy na zapamiętanie słowa "Sherlock" to osiem bajtów potrzebnych na osiem znaków i jeden dodatkowy bajt na zakończenie stringa. Zapis "Sherlock" nadający wartość tablicy nie różni się od {'S','h','e','r','l','o','c','k','\0'}.

Jeśli w dłuższym napisie wstawimy wcześniej znak '\0', to rozumiemy to jako zakończenie stringa, nawet jeśli mamy do dyspozycji więcej pamięci.

Przykładowy program

#include <cstdlib>
#include <cassert>
#include <cstring>

class Czlowiek{
public:
	const char* plec;
	int wiek;

	Czlowiek( //konstruktor
		const char* arg_plec,
		int arg_wiek)
	: //lista inicjalizacyjna
		plec(arg_plec),
		wiek(arg_wiek)
	{}
};

int main(){
	Czlowiek Sherlock_Holmes("mezczyzna",60);

	//blad: &1;
	Czlowiek* Baker_Street_221B = &Sherlock_Holmes;
	Czlowiek* Siddons_Lane_1 = &Sherlock_Holmes;
	assert( Baker_Street_221B == Siddons_Lane_1 );

	//blad: *NULL;
	assert( (*Baker_Street_221B).plec == "mezczyzna" );
	//blad: *Baker_Street_221B.plec
	assert( Baker_Street_221B->plec == "mezczyzna" );

	assert( Sherlock_Holmes.wiek == 60 );
	Baker_Street_221B->wiek = 61;
	assert( Sherlock_Holmes.wiek == 61 );
	assert( Siddons_Lane_1->wiek == 61 );

	int number = 0;
	int* pointer = &number;
	*pointer = 1;
	assert( number == 1 );

	int* p;
	//blad: *p = 4;
	//C: p = (int*) malloc(1*sizeof(int));
	p = new int;
	*p = 4;
	assert( *p == 4 );
	//wyciek pamieci: p = NULL;
	//C: free(p);
	delete p;

	//C: p = (int*) malloc(4*sizeof(int));
	p = new int[4];

	assert( reinterpret_cast<long long>(p+1)
		 == reinterpret_cast<long long>(p)+sizeof(int) );

	p[1]=13;
	assert( *(p+1) == 13 );

	//C: free(p);
	delete[] p;

	int** t;
	t = new int*[2];
	t[0] = new int[2];
	t[1] = new int[2];
	t[0][0] = t[0][1] = t[1][0] = t[1][1] = 4;
	delete[] t[0];
	delete[] t[1];
	delete[] t;

	char S1[] = "Sherlock";
	assert( sizeof(S1)/sizeof(char) == 8+1 );
	char S2[] = "Sherlock Holmes";
	S2[8] = '\0';
	assert( strcmp(S1,S2) == 0 );

	return 0;
}
© MMXI–MMXVIII Tomasz Jan Drab
observations pointers