Języki Programowania – Zajęcia 4
Wskaźniki i referencje
Celem ćwiczenia jest zapoznanie się z wykorzystaniem wskaźników i referencji w języku C++. W ramach zajęć zostaną omówione zarówno klasyczne mechanizmy zarządzania pamięcią, spotykane w starszym kodzie, jak i nowoczesne rozwiązania dostępne w bibliotece standardowej C++.
1. Czym jest wskaźnik w C++?
Wskaźnik jest zmienną przechowującą adres obiektu w pamięci.
Przykład:
#include <iostream>
int main()
{
double x = 3.14;
double* p = &x;
std::cout << "Wartosc x: " << x << std::endl;
std::cout << "Adres x: " << &x << std::endl;
std::cout << "Wartosc wskaznika p: " << p << std::endl;
std::cout << "Wartosc wskazywanego obiektu: " << *p << std::endl;
*p = 2.71;
std::cout << "Nowa wartosc x: " << x << std::endl;
return 0;
}
Przykładowy output:
Wartosc x: 3.14
Adres x: 0x7ffdd1906098
Wartosc wskaznika p: 0x7ffdd1906098
Wartosc wskazywanego obiektu: 3.14
Nowa wartosc x: 2.71
Wyjaśnienie:
- wartość zmiennej
xto liczba, która została do niej przypisana; - adres zmiennej
xto adres pamięci komputera, gdzie ta zmienna jest przechowywana (dostać się do adresu zmiennej można przy pomocy symbolu&); - wartość wskaźnika
pto adres pamięci komputera, gdzie jest przechowywana zmienna, na którą ten wskaźnik wskazuje; - żeby dostać się do zawartości adresu przechowywanego we wzkaźniku, trzeba ten wskaźnik poprzedzić symbolem
*(formalnie ta operacja nazywa się dereferencjonowaniem); - za pośrednictwem wskaźnika można zmieniać wartości zmiennej, na którą ten wskaźnik wskazuje.
nullptr, które reprezentuje wskaźnik pusty.
Ćwiczenie 1
Ćwiczenie 1: Wskaźniki
Napisać program, w którym znajdzie się wskaźnik do zmiennej typu int. Następnie zmienić wartość tej zmiennej za pośrednictwem wskaźnika. Sprawdzić, jaki jest adres tej zmiennej przed i po zmianie wartości.
2. Czym jest referencja w C++?
Referencja jest inną nazwą istniejącego obiektu. Musi zostać zainicjalizowana przy deklaracji i po zainicjalizowaniu pozostaje związana z tym samym obiektem.
Przykład:
#include <iostream>
int main()
{
double y = 3.14;
double& ref = y;
ref = 2.71;
std::cout << "Wartosc y: " << y << std::endl;
std::cout << "Wartosc referencji do y: " << ref << std::endl;
}
Przykładowy output:
Wartosc y: 2.71
Wartosc referencji do y: 2.71
Wyjaśnienie:
- referencja jest dodatkową nazwą danego obiektu;
- zmiana wartości przez referencję zmienia wartość oryginalnego obiektu.
Ćwiczenie 2
Ćwiczenie 2: Referencje
Napisać program, w którym znajdzie się referencja do zmiennej typu int. Następnie zmienić wartość referencji i zaobserwować zmianę wartości zmiennej. Sprawdzić, co stanie się z referencją, gdy zmienić wartość zmiennej.
Referencja do stałego obiektu
Referencję można zadeklarować z użyciem słowa kluczowego
const, np. const double& ref = x;.
Taka referencja pozwala odczytywać wartość obiektu, ale nie pozwala
modyfikować go za jej pośrednictwem. Nie oznacza to jednak, że sam
obiekt nie może zostać zmodyfikowany w inny sposób.
Referencje z const są często wykorzystywane do przekazywania
argumentów do funkcji, gdy funkcja powinna mieć dostęp do istniejącego
obiektu bez jego kopiowania i bez możliwości modyfikacji za pomocą
tego argumentu.
3. Przekazywanie argumentów do funkcji
Rozważmy trzy sposoby przekazywania argumentów:
double byValue(double x)
{
return x += 2;
}
double byPointer(double* x)
{
return *x += 2;
}
double byReference(double& x)
{
return x += 2;
}
Wyjaśnienie:
- w
byValueprzekazywana jest zwykła wartość zmiennej (w taki sposób były przekazywane wszystkie zmienne na tych zajęciach dotychczas); - w
byPointerprzekazywany jest wskaźnik (należy zwrócić uwagę, że wreturnprzy pomocy*została użyta wartość zmiennej, na którą wskazuje wskaźnik, a nie jej adres); - w
byReferencezostała użyta referencja.
Wszystkie trzy funkcje wykonują taką samą operację. Kluczowym jest argument, który jest innym obiektem w każdym przypadku.
Ćwiczenie 3
Ćwiczenie 3: Przekazywanie argumentów do funkcji
Napisać program, w którym zostaną wywołane podane funkcje. Pokazać, że dają ten sam wynik dla liczby 5.0.
4. Klasyczne zarządzanie pamięcią
Obiekty w C++ zajmują pamięć przez czas swojego życia. W wielu przypadkach pamięcią zarządza język lub biblioteka standardowa. Możemy jednak również jawnie utworzyć obiekt w pamięci dynamicznej.
W starszym kodzie C++ często wykorzystuje się do tego operator
new. Zwraca on wskaźnik do utworzonego obiektu.
Po zakończeniu korzystania z obiektu programista musi zwolnić
zajmowaną przez niego pamięć za pomocą operatora delete.
Przykład:
#include <iostream>
int main()
{
double* p = new double(3.14);
std::cout << *p << std::endl;
delete p;
p = nullptr;
return 0;
}
Wyjaśnienie:
newtworzy obiekt w pamięci dynamicznej i zwraca do niego wskaźnik;deletekończy czas życia obiektu utworzonego przez pojedynczenewi zwalnia zajmowaną przez niego pamięć;- po wykonaniu
delete pzmiennapnadal istnieje, ale nie wolno już używać jej do dostępu do usuniętego obiektu; - przypisanie
p = nullptrustawia wskaźnik na wartość pustą.
5. Nowoczesne zarządzanie pamięcią – inteligentne wskaźniki
W klasycznym podejściu programista odpowiada za zwalnianie pamięci za pomocą delete. W przypadku bardziej złożonych programów łatwo o pomyłkę, np. pominięcie zwolnienia pamięci lub wielokrotne zwolnienie tego samego obiektu.
Nowoczesny C++ oferuje inteligentne wskaźniki zdefiniowane w bibliotece <memory>. Inteligentne wskaźniki automatycznie zarządzają czasem życia obiektów. W przypadku std::unique_ptr obiekt jest usuwany, gdy kończy się czas życia zarządzającego nim wskaźnika lub gdy wskaźnik przestaje być jego właścicielem. W przypadku std::shared_ptr obiekt jest usuwany, gdy znika ostatni współdzielący go właściciel.
std::unique_ptr
std::unique_ptr reprezentuje wyłączną własność obiektu. Nie można skopiować go do innego std::unique_ptr, ponieważ oznaczałoby to dwóch niezależnych właścicieli. Własność można natomiast przenieść.
Przykład:
#include <iostream>
#include <memory>
int main()
{
std::unique_ptr<double> p = std::make_unique<double>(3.14);
std::cout << *p << std::endl;
*p = 2.71;
std::cout << *p << std::endl;
return 0;
} // p przestaje istnieć
Wyjaśnienie:
unique_ptralokuje pamięć dla liczby typudoubleo wartości3.14;- zmiana
*pmodyfikuje wartość wskazywanego obiektu, ale sam wskaźnik nadal zarządza tym samym obiektem; - w momencie skończenia działania programu (dojścia do końcowej klamry instrukcji
mainwskaźnik zostaje automatycznie usunięty, a pamięć zwolniona.
std::shared_ptr
W przeciwieństwie do std::unique_ptr, który reprezentuje własność wyłączną, std::shared_ptr implementuje własność współdzieloną. Kilka wskaźników może wskazywać na ten sam obiekt w pamięci.
Przykład:
#include <iostream>
#include <memory>
int main()
{
auto p1 = std::make_shared<double>(3.14);
{ // wejście do bloku, gdzie zajdzie współdzielenie
std::shared_ptr<double> p2 = p1; // stworzenie p2, który będzie współdzielić adres z p1
std::cout << *p2 << std::endl;
std::cout << p1.use_count() << std::endl; // use_count() zwraca liczbę właścicieli współdzielących zarządzany obiekt
} // p2 przestaje istnieć
std::cout << p1.use_count() << std::endl;
return 0;
} // p1 przestaje istnieć
Przykładowy output:
3.14
2
1
Wyjaśnienie:
- wskaźnik
p2wskazuje na ten sam adres, co wskaźnikp1; - w bloku ze współdzieleniem istnieją dwa wskaźniki do tego samego adresu;
- po wyjściu z bloku ze współdzieleniem wskaźnik
p2znika; - wskaźnik
p1znika dopiero po zakończeniu działania programu.
std::shared_ptr należy stosować wtedy, gdy kilka części programu rzeczywiście muszą współdzielić własność obiektu. Jeśli wystarcza jeden właściciel, lepszym rozwiązaniem jest std::unique_ptr.