Przejdź do zawartości

C++/Zarządzanie pamięcią

Z Wikibooks, biblioteki wolnych podręczników.
< C++
(Przekierowano z C++:Zarządzanie pamięcią)

W języku C++ do alokowania pamięci na stercie służy operator new, a do zwalniania - delete. W C można również stosować funkcje malloc i free, jednak należy być ostrożnym. Najczęstszym błędem jest mieszanie operatorów new i delete z funkcjami malloc i free, np. zwalnianie pamięci zaalokowanej przez new przy pomocy free.

Rozważmy prosty przykład. Załóżmy, że chcemy stworzyć wektor 10 liczb typu całkowitego. Możemy to zrobić na dwa sposoby. W stylu znanym z języka C:

int *wektor = (int*) malloc (sizeof(int)*10);
free (wektor);

Albo w stylu C++:

int *wektor = new int[10];
delete [] wektor;

Od razu widać, że drugi zapis jest łatwiejszy i przyjemniejszy w użyciu. To jest podstawowa zaleta operatora new - krótszy zapis. Wystarczy wiedzieć jakiego typu ma być obiekt, który chcemy powołać do życia, nie martwiąc się o rozmiar alokowanego bloku pamięci. Za pomocą operatora new można również tworzyć tablice wielowymiarowe:

int **wektory = new int *[5];
for (int i = 0; i < 5; ++i)
   wektory[i] = new int [10];

W ten sposób stworzono tablicę dwuwymiarową którą statycznie zadeklarowalibyśmy jako:

int wektory[5][10];

Jednak w przeciwieństwie do int wektory[5][10], która jest tablicą dwuwymiarową, nasze int **wektory jest tablicą tablic i może być rozrzucone po całej pamięci.

Ilość elementów poszczególnych wymiarów nie musi być jednakowa. Można np zadeklarować tablicę taką:

int **wektory = new int *[2];
wektory[0] = new int [5];
wektory[1] = new int;


Przy takiej deklaracji pierwszy wiersz ma 5 elementów (tablica) a drugi to jeden element. Deklaracja tablic o większej ilości wymiarów przebiega podobnie:

int ***wektory;             // deklarujemy tablicę 3-wymiarową
wektory = new int **[5];    // pierwszy wymiar
wektory[0] = new int *[10]; // pierwszy element pierwszego wymiaru
wektory[1] = new int *[3];  // drugi element pierwszego wymiaru
....
wektory[0][0] = new int [3] // wymiar I = 0 -> wymiar II = 1 -> 3 elementy(tablica)
wektory[0][1] = new int [5] // wymiar I = 0 -> wymiar II = 3 -> 5 elementów(tablica)
wektory[1][0] = new int;    // wymiar I = 1 -> wymiar II = 2 -> 1 element
...


Stosując ten sposób, ogólnie można deklarować tablice n-wymiarowe bez większego problemu. Usuwanie tablic wielowymiarowych przebiega podobnie jak jednowymiarowych, z tą różnicą, że usuwanie zaczynamy od "najgłębszego" wymiaru:

delete  wektory[1][0];        // kasujemy pojedynczą zmienną
delete [] wektory[0][1];
delete [] wektory[0][0];
// II wymiar
delete [] wektory[0];
delete [] wektory[1];
// I wymiar
delete [] wektory;

Zwrócić uwagę trzeba na dwie rzeczy:

  • delete [] używamy dla zmiennych tablicowych, a delete dla pojedynczych zmiennych
  • Kolejność zwalniania wymiarów jest odwrotna niż ich tworzenia


Drugą zaletą jest fakt, że przy okazji alokacji pamięci możemy wywołać odpowiedni konstruktor inicjując wartości zmiennych obiektu, np.

Test *test = new Test(1,2);

zakładając, że obiekt Test posiada dwie zmienne typu całkowitego i zdefiniowany konstruktor Test(int,int).

Kolejną korzyścią jest możliwość przeciążania. Jednak to już jest temat na inny rozdział.

Działanie w przypadku braku pamięci

[edytuj]

1. Domyślnie gdy przydział pamięci jest niemożliwy operator new zgłasza wyjątek std::bad_alloc, np.

#include <new>      // wyjątek std::bad_alloc
#include <cstdio>

int main() {

    try {
        char* p = new char[1000000000000];
    } catch (std::bad_alloc& e) {
        // std::bad_alloc::what
        std::printf("Błąd alokacji: %s\n", e.what()); 
    }

    return 0;
}

2. Można wyłączyć zgłaszanie wyjątku, zamiast tego w przypadku braku pamięci zostanie zwrócony pusty wskaźnik (nullptr). W tym celu po słowie kluczowym new trzeba podać symbol std::nothrow, np.

#include <new>      // symbol std::nothrow
#include <cstdio>

int main() {
    
    char* p = new (std::nothrow) char[1000000000000];

    if (p == nullptr) {
        std::puts("Brak pamięci");
    }

    return 0;
}

Placement new

[edytuj]

Jak zostało powiedziane operator new wykonuje dwie operacje:

  • zaalokowanie pamięci o żądanym rozmiarze,
  • wywołanie konstruktorów (domyślnych lub wyspecyfikowanych).

Można jednak użyć specjalnego wywołania new, tzw. "placement new", które jedynie wywołuje konstruktory na pamięci już zaalokowanej w innym miejscu; trzeba być jednak pewnym, że wskazany obszar pamięci jest odpowiedniego rozmiaru.

Jest to niezbyt powszechne użycie, ma też jedną wadę: nie działa operator delete, trzeba ręcznie wywoływać destruktory obiektów. Zastosowanie tego mechanizmu ma głównie sens, gdy samodzielnie zarządzamy pamięcią, np. zawczasu rezerwujemy pamięć dla dużej liczby obiektów i w miarę potrzeb ją przydzielamy, oszczędzając tym samym czas na każdorazowe odwołanie do alokatora pamięci.

#include <new>
#include <cstdlib>  // malloc

class Klasa {
    int numer;
};

int main() {

    void* wskaznik = malloc(sizeof(Klasa));

    Klasa* obiekt = new (wskaznik) Klasa;

    obiekt->~Klasa();

    return 0;
}

Inteligentne wskaźniki

[edytuj]

Klasy nazywane "inteligentnymi wskaźnikami" pomagają ominąć część problemów związanych z czasem życia wskazywanych obiektów i ze współdzieleniem wskaźników. Często występujące problemy to:

  • wycieki pamięci,
  • przedwczesne kasowanie wskazywanych obiektów,
  • wielokrotne kasowanie obiektów (przez delete).

W standardzie zdefiniowano trzy klasy szablonowe:

  • std::unique_ptr,
  • std::shared_ptr,
  • std::weak_ptr.

unique_ptr

[edytuj]

Klasa unique_ptr (unikalny wskaźnik) gwarantuje, że w systemie istnieć będzie dokładnie jedna aktywna instancja wskaźnika. Nie tylko nie istnieje możliwość skopiowania unique_ptr, ale nawet nie można dokonać prostego przypisania, tzn. a = b;. Dostępne jest jedynie przenoszenie wartości (std::move) ewentualnie należy wprost zwolnić wskaźnik metodą release i zastąpić przez reset. Np.

#include <memory>

int main() {
    std::unique_ptr<int> a(new int(5));
    std::unique_ptr<int> b;

    b.reset(a.release());
    a = std::move(b);
}

Kiedy unique_ptr posiadający wskaźnik jest niszczony, niszczony jest również obiekt na który wskazuje. Usunięcie obiektu wykonuje tzw. deleter, jest to klasa będąca drugim argumentem szablonu. Domyślnie to std::default_deleter i nie trzeba jej wprost podawać; dopiero gdy potrzebujemy dodatkowych działań w chwili usuwania obiektu należy taką klasę zaimplementować, co jest raczej rzadko potrzebne.

Przykładowy program:

#include <memory>       // unique_ptr
#include <iostream>

class Klasa {
public:
    Klasa()  { std::cout << "Konstrukor" << '\n'; }
    ~Klasa() { std::cout << "Destruktor" << '\n'; }
};

int main() {

    std::unique_ptr<Klasa> p1(new Klasa());
    std::unique_ptr<Klasa> p2;

    std::cout << "p1 = " << p1.get() << '\n';
    std::cout << "p2 = " << p2.get() << '\n';

    p2 = std::move(p1);

    std::cout << "p1 = " << p1.get() << '\n';
    std::cout << "p2 = " << p2.get() << '\n';
}

Wypisze:

Konstrukor
p1 = 0x894b008
p2 = 0
p1 = 0
p2 = 0x894b008
Destruktor

Warto zwrócić uwagę, że nigdzie w programie nie ma bezpośredniego wywołania delete, mimo to destruktor klasy jest wołany. Z tej cechy można korzystać we własnych klasach: zamiast przechowywać wskaźniki do jakiś obiektów wykorzystywanych wewnętrznie, łatwiej mieć unique_ptr, dzięki czemu nie trzeba pamiętać o zwalnianiu pamięci w destruktorze. Analogicznie w przypadku funkcji unique_ptr załatwia za nas zwalnianie pamięci, także w przypadku wystąpienia wyjątku. Na przykład:

void funkcja() {
    
    char* dane = new char[100000];      // wyjątek może wystąpić tutaj
    char* tymczasowe = new char[5000];  // i tutaj też

    // obliczenia ...                   // podczas obliczeń również

    delete[] dane;
    delete[] tymczasowe;
}

Ta realizacja ma oczywistą wadę: w przypadku jakiegokolwiek wyjątku nastąpi wyciek pamięci (no, chyba, że nie uda się zaalokować pamięci na dane). Oprócz tego programista jest odpowiedzialny za zwolnienie zasobów. Czasem można zapomnieć, szczególnie gdy rozwija się już istniejącą funkcję.

Drugim podejściem jest złapanie wszystkich możliwych wyjątków:

void funkcja_poprawiona() {
    char* dane = nullptr;
    char* tymczasowe = nullptr;

    try {
        dane = new char[100000];
        tymczasowe = new char[5000];

        // obliczenia ...

        delete[] dane;
        delete[] tymczasowe;
    } catch (...) {
        delete[] dane;
        delete[] tymczasowe;

        throw;
    }
}

To podejście jest lepsze, jednak również ma wadę z poprzedniego rozwiązania: ręczne zwalnianie pamięci, do tego powielone. Użycie unique_ptr skraca i znacząco upraszcza kod, eliminując wszystkie wymienione mankamenty:

#include <memory>

void funkcja_najlepsza() {
    
    std::unique_ptr<char> dane(new char[100000]);
    std::unique_ptr<char> tymczasowe(new char[5000]);

    // obliczenia ...
}


shared_ptr i weak_ptr

[edytuj]

Klasa shared_ptr (współdzielony wskaźnik) umożliwia istnienie wielu wskaźników do tego samego obiektu, podtrzymując go przy życiu tak długo, jak istnieje przynajmniej jeden shared_ptr, który by go zawierał. Klasa shared_ptr w istocie realizuje odśmiecanie pamięci ze zliczaniem referencji. Z każdym obiektem związana jest liczba odwołań (odczyt metodą use_count), która jest automatycznie aktualizowana. Kiedy osiągnie zero obiekt jest niszczony.

Standard C++11 gwarantuje bezpieczeństwo w środowisku wielowątkowym.

#include <memory>
#include <iostream>

class Klasa {
public:
    Klasa()  {std::cout << "Konstruktor" << '\n';}
    ~Klasa() {std::cout << "Destruktor" << '\n';}
};

void przyklad() {

    std::shared_ptr<Klasa> p(new Klasa());  
    std::cout << p.use_count() << '\n';         // licznik referencji = 1
    {
        std::shared_ptr<Klasa> p1(p);
        std::cout << p.use_count() << '\n';     // licznik referencji = 2

        {
            std::shared_ptr<Klasa> p2(p);       // licznik referencji = 3
            std::cout << p.use_count() << '\n';
        }                                       // licznik referencji = 2 - p2 jest niszczony

        std::shared_ptr<Klasa> p3(p1);          // licznik referencji = 3
        std::cout << p.use_count() << '\n';
    }                                           // licznik referencji = 1 - p1 i p3 są niszczone

    std::cout << p.use_count() << '\n';

}                                               // licznik referencji = 0 - p jest niszczony, niszczona jest też instancja Klasa

int main() {
    przyklad();
}

Program wyświetli:

Konstruktor
1
2
3
3
1
Destruktor

Używając shared_ptr należy mieć jednak na uwadze pewne niedogodności:

  • Zliczanie referencji nie radzi sobie z cyklami w zależnościach, tzn. jeśli obiekt A wskazuje na B i jednocześnie B wskazuje na A, to nigdy nie zostaną zwolnione, nawet jeśli oba są zbędne.
  • W środowisku wieloprocesorowym zliczanie referencji nie jest najszybsze, ze względu na konieczność synchronizacji pamięci między procesorami.

O ile drugi problem dotyczy wąskiej grupy programów, które intensywnie wykorzystują wątki (gry, bazy danych, programy naukowe), tak pierwszy może wystąpić wszędzie. Oto ilustracja cyklu zależności:

#include <memory>
#include <iostream>

class Klasa {
private:
    std::shared_ptr<Klasa> sasiad;

public:
    Klasa()  {std::cout << "Konstruktor" << '\n';}
    ~Klasa() {std::cout << "Destruktor" << '\n';}

    void ustaw_sasiada(std::shared_ptr<Klasa> s) {
        sasiad = s;
    }
};

void przyklad() {

    std::shared_ptr<Klasa> A(new Klasa());  // licznik A = 1
    std::shared_ptr<Klasa> B(new Klasa());  // licznik B = 1 

    A->ustaw_sasiada(B);                    // licznik B = 2
    B->ustaw_sasiada(A);                    // licznik A = 2
}                                           // licznik A, B = 1

int main() {
    przyklad();
}

Kiedy skompilujemy i uruchomimy powyższy program, na ekranie zostaną wypisane tylko dwa wiersze

Konstrukor
Konstrukor

pochodzące z konstruktorów obiektów A i B. Mimo że po wyjściu z funkcji wskaźniki shared_ptr są niszczone, to obiekty już nie, ponieważ same posiadają dodatkowe shared_ptr podbijające licznik do niezerowej wartości. W tym przypadku doszło do wycieku pamięci.

Jeśli to możliwe należy nie dopuszczać do tworzenia cykli, bo jak widzimy prowadzi to do kłopotów. Pół biedy kiedy jesteśmy ich świadomi, ale gdy taki cykl powstanie przypadkowo, trudniej będzie dojść przyczyny wycieków.

Kiedy jednak cykle są nie do uniknięcia można użyć klasy weak_ptr, która również trzyma wskaźnik do obiektu, jednak nie wpływa na licznik referencji, mówi się, że "przerywa" cykl. Nie istnieje też możliwość dereferencji tego wskaźnika, należy tymczasowo skonwertować weak_ptr na shared_ptr, który podtrzyma przy życiu obiekt - robi to metoda lock. Instancja weak_ptr może istnieć dłużej niż wskazywany obiekt, dlatego konieczne jest stwierdzenie, czy obiekt jest jeszcze ważny – służy do tego funkcja expired.

Wzorce użycia tej klasy są dwa, można albo 1) najpierw testować metodą expired, a następnie użyć lock, albo 2) prościej od razu użyć lock, sprawdzając czy wynikowy wskaźnik nie będzie pusty.

weak_ptr<int> wp;

// ...

// 1)
if (!wp.expired()) {
    auto shared = wp.lock();
    // tu działania na shared
}

// 2)
if (auto shared = wp.lock()) {
    // tu działania na shared
}

Poniżej kompletny przykład.

#include <memory>
#include <iostream>

void wyswietl(std::weak_ptr<int> ptr) {
    if (ptr.expired()) {
        std::cout << "<brak danych>" << '\n';
    } else {
        auto shared = ptr.lock();
        std::cout << "wartość = " << *shared << ", "
                  << "licznik referencji = " << shared.use_count() << '\n';
    }
}

void przyklad() {

    std::weak_ptr<int> wp;

    {
        std::cout << ">>>>" << '\n';
        std::shared_ptr<int> p(new int(71));
        wp = p;
        std::cout << "licznik referencji = " << p.use_count() << '\n';

        wyswietl(wp);
        std::cout << "<<<<" << '\n';
    }

    wyswietl(wp);
}

int main() {
    przyklad();
}

Po uruchomieniu na ekranie wyświetli się:

>>>>
licznik referencji = 1
wartość = 71, licznik referencji = 2
<<<<
<brak danych>

Jak widać po przypisaniu shared_ptr do weak_ptr nie zmienia licznika, dopiero w funkcji wyświetl jest on zmieniany. Po wyjściu z zakresu, p jest niszczone i wskaźnik staje się nieważny.