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.
Zadanie 1 – Analiza wyników pomiarów
W tym zadaniu należy skorzystać ze wskaźników klasycznych.Podczas eksperymentu fizycznego wykonano pięć pomiarów tej samej wielkości. Otrzymano następujące wyniki:
10.1, 10.3, 9.9, 10.2, 10.0
Ze względu na błąd kalibracji przyrządu do wybranych wyników należy dodać poprawkę o wartości 0.2.
Napisz program w C++, który przechowuje wyniki pomiarów w tablicy utworzonej w pamięci dynamicznej i wykonuje poniższe operacje.
-
Utworzenie tablicy dynamicznej.
Zadeklaruj wskaźnik typudouble*, początkowo ustawiony nanullptr. Następnie utwórz dynamiczną tablicę pięciu elementów i zainicjalizuj ją podanymi wynikami pomiarów.Wskazówka: Tablicę dynamiczną tworzymy za pomocą operatoranew[], podając typ elementów i liczbę elementów, np.double* array = new double[3];. Pamięć zwalniamy za pomocądelete[] array;. -
Przekazywanie argumentu przez wartość.
Napisz funkcjęcorrectByValue, która przyjmuje pojedynczy wynik pomiaru i wartość poprawki. Funkcja powinna zwracać wynik po dodaniu poprawki, nie zmieniając oryginalnego elementu tablicy. Wywołaj ją dla pierwszego pomiaru i wyświetl wynik funkcji oraz niezmienioną wartość elementu tablicy. -
Przekazywanie argumentu przez wskaźnik.
Napisz funkcjęcorrectByPointer, która przyjmuje wskaźnik do elementu typudoubleoraz wartość poprawki. Funkcja powinna zmodyfikować wskazywany element tablicy. Wywołaj ją dla drugiego pomiaru i wyświetl zmienioną wartość. -
Przekazywanie argumentu przez referencję.
Napisz funkcjęcorrectByReference, która przyjmuje element typudoubleprzez referencję oraz wartość poprawki. Funkcja powinna zmodyfikować oryginalny element tablicy. Wywołaj ją dla trzeciego pomiaru i wyświetl zmienioną wartość. -
Utworzenie skorygowanej kopii pomiarów.
Napisz funkcjęcorrectMeasurements, która przyjmuje wskaźnik do tablicy pomiarów, liczbę elementów oraz wartość poprawki. Funkcja powinna utworzyć nową tablicę dynamiczną, skopiować do niej pomiary, a następnie dodać poprawkę0.2do każdego elementu. Funkcja powinna zwracać wskaźnik do nowej tablicy. Tablica wejściowa nie może zostać zmodyfikowana. Należy wykorzystać początkowe wartości pomiarów, a nie te, które zostały zmodyfikowane w poprzednich podpunktach. -
Porównanie tablic.
Wyświetl zawartość tablicy oryginalnej i skorygowanej, aby sprawdzić, czy funkcjacorrectMeasurementspoprawnie utworzyła kopię i nie zmieniła danych wejściowych. -
Wyszukiwanie największego pomiaru.
Napisz funkcjęfindLargestMeasurement, która przyjmuje wskaźniki do tablicy oryginalnej i skorygowanej oraz liczbę elementów. Funkcja powinna zwracać wskaźnik do elementu tablicy skorygowanej, który odpowiada największemu pomiarowi w tablicy oryginalnej. Wyświetl wartość wskazywaną przez zwrócony wskaźnik. -
Zwolnienie pamięci.
Po zakończeniu obliczeń zwolnij pamięć zajmowaną przez obie tablice za pomocą odpowiednich operatorówdelete[]. Pamiętaj, że tablice są niezależnymi obiektami i każdą z nich należy zwolnić osobno.
Zadanie 2 – Inteligentne wskaźniki
Zmodyfikuj program z Zadania 1 tak, aby do zarządzania dynamicznie
alokowaną pamięcią wykorzystywał inteligentne wskaźniki
std::unique_ptr zamiast wskaźników klasycznych.
Podczas modyfikacji programu spełnij następujące wymagania:
-
Zastąp wskaźniki zarządzające dynamicznie utworzonymi tablicami
odpowiednimi obiektami typu
std::unique_ptr<double[]>.Wskazówka: Inteligentny wskaźnik do tablicy można utworzyć za pomocąstd::make_unique<double[]>(5). Inteligenty wskaźnik utworzy tablicę i zainicjalizuje ją zerami. Żeby wypełnić tablicę potrzebnymi wartościami, trzeba wpisać je jeden po drugim jak w przypadku standardowej tablicy. -
Dostosuj funkcje
correctMeasurementsifindLargestMeasurementtak, aby przyjmowały referencje do inteligentnych wskaźników tablic. FunkcjacorrectMeasurementspowinna zwracać inteligentny wskaźnik do nowej tablicy. FunkcjafindLargestMeasurementpowinna zwracać indeks elementu odpowiadającego największemu pomiarowi w tablicy oryginalnej. Wartość tego elementu odczytaj z tablicy skorygowanej za pomocą zwróconego indeksu.Wskazówka: Inteligentny wskaźnikstd::unique_ptrzarządza pamięcią, ale nie wolno go kopiować przez zwykłe przypisanie. Aby funkcja mogła korzystać z istniejącego inteligentnego wskaźnika bez przejmowania jego własności, można przekazać go przez referencję zconst, np.const std::unique_ptr<double[]>& data. W tym przypadkuconstuniemożliwia zmianę samego inteligentnego wskaźnika za pośrednictwem tej referencji, ale nie blokuje modyfikowania elementów tablicy, np. przezdata[0] = 1.0;. Dostęp do elementów tablicy jest taki sam jak w przypadku zwykłej tablicy.
Punktacja
| Punkty | Kryterium |
|---|---|
| 0.5 | Ćwiczenia: Wykonanie trzech ćwiczeń wprowadzających. |
| 0.25 | Zadanie 1.1: Utworzenie i inicjalizacja tablicy dynamicznej. |
| 1.5 |
Zadanie 1.2-4: Poprawna implementacja i użycie funkcji
correctByValue, correctByPointer, correctByReference (0.5 za każdą).
|
| 0.5 |
Zadanie 1.5: Utworzenie skorygowanej kopii pomiarów w funkcji
correctMeasurements.
|
| 0.5 | Zadanie 1.6: Wyświetlenie i porównanie zawartości obu tablic. |
| 0.5 |
Zadanie 1.7: Poprawna implementacja funkcji
findLargestMeasurement.
|
| 0.25 | Zadanie 1.8: Poprawne zwolnienie pamięci obu tablic. |
| 1.0 pkt | Zadanie 2: Powtórzenie Zadania 1 przy użyciu inteligentnych wskaźników. |