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.