Języki Programowania – Zajęcia 4

Wskaźniki i referencje

Cel zajęć

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:

Uwaga: w C++ istnieje słowo kluczowe 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:

Ć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 byValue przekazywana jest zwykła wartość zmiennej (w taki sposób były przekazywane wszystkie zmienne na tych zajęciach dotychczas);
  • w byPointer przekazywany jest wskaźnik (należy zwrócić uwagę, że w return przy pomocy * została użyta wartość zmiennej, na którą wskazuje wskaźnik, a nie jej adres);
  • w byReference został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:

  • new tworzy obiekt w pamięci dynamicznej i zwraca do niego wskaźnik;
  • delete kończy czas życia obiektu utworzonego przez pojedyncze new i zwalnia zajmowaną przez niego pamięć;
  • po wykonaniu delete p zmienna p nadal istnieje, ale nie wolno już używać jej do dostępu do usuniętego obiektu;
  • przypisanie p = nullptr ustawia 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_ptr alokuje pamięć dla liczby typu double o wartości 3.14;
  • zmiana *p modyfikuje 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 main wskaź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 p2 wskazuje na ten sam adres, co wskaźnik p1;
  • w bloku ze współdzieleniem istnieją dwa wskaźniki do tego samego adresu;
  • po wyjściu z bloku ze współdzieleniem wskaźnik p2 znika;
  • wskaźnik p1 znika 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.

  1. Utworzenie tablicy dynamicznej.
    Zadeklaruj wskaźnik typu double*, początkowo ustawiony na nullptr. Następnie utwórz dynamiczną tablicę pięciu elementów i zainicjalizuj ją podanymi wynikami pomiarów.
    Wskazówka: Tablicę dynamiczną tworzymy za pomocą operatora new[], podając typ elementów i liczbę elementów, np. double* array = new double[3];. Pamięć zwalniamy za pomocą delete[] array;.
  2. 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.
  3. Przekazywanie argumentu przez wskaźnik.
    Napisz funkcję correctByPointer, która przyjmuje wskaźnik do elementu typu double oraz wartość poprawki. Funkcja powinna zmodyfikować wskazywany element tablicy. Wywołaj ją dla drugiego pomiaru i wyświetl zmienioną wartość.
  4. Przekazywanie argumentu przez referencję.
    Napisz funkcję correctByReference, która przyjmuje element typu double przez referencję oraz wartość poprawki. Funkcja powinna zmodyfikować oryginalny element tablicy. Wywołaj ją dla trzeciego pomiaru i wyświetl zmienioną wartość.
  5. 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.2 do 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.
  6. Porównanie tablic.
    Wyświetl zawartość tablicy oryginalnej i skorygowanej, aby sprawdzić, czy funkcja correctMeasurements poprawnie utworzyła kopię i nie zmieniła danych wejściowych.
  7. 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.
  8. Zwolnienie pamięci.
    Po zakończeniu obliczeń zwolnij pamięć zajmowaną przez obie tablice za pomocą odpowiednich operatorów delete[]. 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:

  1. 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.
  2. Dostosuj funkcje correctMeasurements i findLargestMeasurement tak, aby przyjmowały referencje do inteligentnych wskaźników tablic. Funkcja correctMeasurements powinna zwracać inteligentny wskaźnik do nowej tablicy. Funkcja findLargestMeasurement powinna 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źnik std::unique_ptr zarzą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ę z const, np. const std::unique_ptr<double[]>& data. W tym przypadku const uniemożliwia zmianę samego inteligentnego wskaźnika za pośrednictwem tej referencji, ale nie blokuje modyfikowania elementów tablicy, np. przez data[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.