Pamięć wolna i zarządzanie pamięcią

Pamięć wolna i zarządzanie pamięcią

Cykl istnienia obiektu mającego nazwę określa jego zakres. Jednak czasami dobrze jest utworzyć obiekt istniejący niezależnie od zakresu, w którym został utworzony. Na przykład często tworzy się obiekty, których można używać po zwrocie wartości przez funkcję, w której zostały utworzone. Do tworzenia takich obiektów służy operator new, a do ich usuwania przeznaczony jest operator delete. Obiekty alokowane za pomocą operatora new określa się jako zapisane „w pamięci wolnej” (nazywanej też stertą i pamięcią dynamiczną). Funkcje analizy składni mogłyby budować drzewo wyrażeń, z którego korzystałby generator kodu:

struct Enode {
    Token_value oper;
    Enode*left;
    Enode*right;
    //...
};
Enode*expr(bool get)
{
    Enode*left = term(get);
    for (;;) {
        switch (ts.current().kind) {
        case Kind::plus:
        case Kind::minus:
            left = new Enode {ts.current().kind,left,term(true)};
            break;
        default:
            return left; // zwraca węzeł
        }
    }
}

W przypadkach Kind::plus i Kind::minus w pamięci wolnej tworzony jest nowy obiekt Encode inicjowany wartością {ts.current().kind,left,term(true)}. Otrzymany wskaźnik jest przypisywany do left i na koniec zwracany przez funkcję expr(). Do określenia argumentów zastosowałem notację listową {}. Mogłem też użyć starej notacji (). Jednak użycie notacji = do inicjacji obiektu tworzonego przy użyciu operatora new jest błędem:

int* p = new int = 7; // błąd

Jeśli typ ma konstruktor domyślny, to można opuścić inicjator, ale typy wbudowane są domyślnie nieinicjowane. Na przykład:

auto pc = new complex; // obiekt complex jest zainicjowany wartością {0,0}
auto pi = new int; // obiekt int jest niezainicjowany

To może być mylące. Aby mieć pewność, że obiekt zostanie domyślnie zainicjowany, należy użyć operatora {}. Na przykład:

auto pc = new complex{}; // obiekt complex jest zainicjowany wartością {0,0}
auto pi = new int{}; // obiekt int jest zainicjowany wartością 0

Generator kodu może używać obiektów Encode tworzonych przez funkcję expr() i je usuwać:

void generate(Enode*n)
{
    switch (n−>oper) {
    case Kind::plus:
        // użycie n
        delete n; // usunięcie obiektu Enode z pamięci wolnej
    }
}

Obiekt utworzony przy użyciu operatora new istnieje, aż zostanie usunięty przez operator delete. Później zajmowane przez niego miejsce można ponownie wykorzystać poprzez new. Implementacja języka C++ nie gwarantuje obecności systemu usuwania nieużytków szukającego nieużywanych obiektów i przywracającego je do puli pamięci wolnej. Dlatego przyjmuję założenie, że każdy obiekt utworzony przez operator new musi zostać usunięty przez operator delete. Operator delete można zastosować tylko do wskaźnika zwróconego przez operator new oraz do nullptr, chociaż w tym drugim przypadku nie ma żadnego efektu. Jeśli usuwany obiekt jest typu klasy zawierającej destruktor, operator delete najpierw wywołuje ten destruktor i dopiero potem zwalnia pamięć.

Zarządzanie pamięcią

Największe problemy z pamięcią wolną dotyczą:

  • Wycieków obiektów - programiści używający operatora new często zapominają o usunięciu alokowanych obiektów przy użyciu operatora delete.
  • Przedwczesnego usuwania - programistom zdarza się usuwać obiekty wskazywane jeszcze przez jakiś wskaźnik, a następnie próbować użyć tego wskaźnika.
  • Podwójnego usuwania - obiekt bywa usuwany dwukrotnie, co powoduje dwukrotne wywołanie jego destruktora (jeżeli go ma).

Wyciek obiektów jest poważnym problemem, bo może doprowadzić do wyczerpania pamięci. Przedwczesne usuwanie prawie zawsze powoduje duże problemy, bo wskaźnik do „usuniętego obiektu” nie wskazuje już prawidłowego obiektu (więc jego odczyt może mieć bardzo złe skutki), ale może wskazywać miejsce przechowywania jakiegoś innego obiektu (i zapis w nim może spowodować uszkodzenie tego obiektu). Spójrz na poniższy przykład bardzo źle napisanego kodu:

int*p1 = new int{99};
int*p2 = p1; // potencjalne kłopoty
delete p1; // teraz p2 nie wskazuje poprawnego obiektu
p1 = nullptr; // daje fałszywe wrażenie bezpieczeństwa
char*p3 = new char{'x'}; // p3 może teraz wskazywać pamięć wskazywaną przez p2
*p2 = 999; // to może powodować problemy
cout <<*p3 << ' '; // może nie wydrukować x

Problem z podwójnym usuwaniem polega na tym, że zarządcy zasobów zazwyczaj nie mają możliwości sprawdzania, które fragmenty kodu są właścicielami zasobów. Na przykład:

void sloppy() // bardzo zły kod
{
int*p = new int[1000]; // zajęcie pamięci
//... użycie *p...
delete[] p; // zwolnienie pamięci
//... odczekanie chwilę...
delete[] p; // ale funkcja sloppy() nie jest właścicielem *p
}

Przed drugim użyciem operatora delete[] pamięć wskazywana wcześniej przez wskaźnik *p mogła zostać alokowana do jakiegoś innego celu i alokator może zostać uszkodzony. Jeśli typ int zamienisz na string w tym przykładzie, to otrzymasz kod, w którym destruktor typu string będzie próbował odczytać pamięć, która została już ponownie alokowana i potencjalnie zapisana przez inny kod, oraz wykorzystać ją do wykonania operacji delete. Ogólnie rzecz biorąc, efekt podwójnego usuwania jest niezdefiniowany, więc nie da się przewidzieć jego wyniku, który z reguły jest katastrofalny.

Powodem takich błędów zazwyczaj nie jest złośliwość ani nawet niedbalstwo programistów. Po prostu w dużym programie bardzo trudno jest zapanować nad dezalokacją (jeden raz i w ściśle określonym momencie) wszystkich alokowanych obiektów. Analiza zlokalizowanej części programu nie wykaże, co jest źródłem problemów, bo błąd najczęściej dotyczy kilku miejsc.

Dlatego jako alternatywę dla „nagich” operatorów new i delete zalecam stosowanie dwóch ogólnych technik zarządzania zasobami, pozwalających uniknąć wspomnianych problemów: 

1. Nie zapisuj obiektów w pamięci wolnej, jeśli nie musisz. Lepiej używaj zmiennych o określonym zakresie dostępności.
2. Tworząc obiekt w pamięci wolnej, zapisz wskaźnik do niego w obiekcie zarządzającym (czasami nazywanym uchwytem) z destruktorem, który go usunie. Wśród przykładów można wymienić typy string, vector i wszystkie inne kontenery z biblioteki standardowej, unique_ptr oraz shared_ptr. Jeśli jest to możliwe, obiekt zarządzający powinien być zmienną o ograniczonym zakresie. Wiele klasycznych przypadków użycia pamięci wolnej można wyeliminować, używając semantyki przenoszenia
do zwracania z funkcji dużych obiektów reprezentowanych jako obiekty zarządzające.

Reguła [2] często jest określana mianem RAII (zajęcie zasobu jest inicjacją) i stanowi podstawową technikę pozwalającą uniknąć wycieków zasobów oraz sprawiającą, że obsługa błędów poprzez wyjątki jest łatwa i bezpieczna. Przykładem zastosowania opisanych technik jest typ vector z biblioteki standardowej: 

void f(const string& s)
{
    vector v;
    for (auto c : s)
        v.push_back(c);
    // ...
}

Wektor przechowuje swoje elementy w pamięci wolnej, ale wszelkie alokacje i dezalokacje wykonuje sam. W tym przykładzie funkcja push_back() używa operatora new do zajmowania pamięci dla elementów oraz operatora delete do zwalniania miejsca po obiektach, które są już niepotrzebne. Jednakże użytkownicy wektora nie muszą wiedzieć o tych kwestiach implementacyjnych
i po prostu wiedzą, że wektor nie powoduje wycieków pamięci. Jeszcze prostszym przykładem jest typ Token_stream. Użytkownik może użyć operatora new i przekazać otrzymany wskaźnik do obiektu Token_stream w celu obsłużenia:

Token_stream ts{new istringstream{some_string}};

Nie trzeba korzystać z pamięci wolnej tylko po to, by odebrać duży obiekt z funkcji. Na przykład:

string reverse(const string& s)
{
    string ss;
    for (int i=s.size()−1; 0<=i; −−i)
        ss.push_back(s[i]);
    return ss;
}

Podobnie jak vector typ string jest tak naprawdę uchwytem do swoich elementów. A zatem po prostu przenosimy łańcuch ss z funkcji reverse(), zamiast go kopiować. Kolejnymi przykładami zastosowania tych technik są „inteligentne wskaźniki” służące do
zarządzania zasobami (np. unique_ptr i smart_ptr). Na przykład:

void f(int n)
{
    int*p1 = new int[n]; // potencjalne kłopoty
    unique_ptr p2 {new int[n]};
    //...
    if (n%2) throw runtime_error("dziwne");
    delete[] p1; // tu możemy nigdy nie dotrzeć
}

Dla f(3) pamięć wskazywana przez p1 wycieknie, natomiast pamięć wskazywana przez p2 zostanie prawidłowo i niejawnie dezalokowana. Podstawowa zasada, jaką się kieruję przy używaniu operatorów new i delete, to nie pozostawiać „nagich operatorów new”. Inaczej mówiąc, new należy do konstruktorów i innych tego typu operacji, a delete do destruktorów, a w połączeniu operatory te stanowią spójny zestaw narzędzi do zarządzania pamięcią. Dodatkowo operatora new czasami używa się w argumentach do uchwytów do zasobów.

Jeśli wszystko inne zawiedzie (np. mamy dużo kodu zawierającego wiele przypadków niezdyscyplinowanego użycia operatora new), można skorzystać z dostępnego w języku C++ standardowego interfejsu do systemu usuwania nieużytków.

Tablice

Za pomocą operatora new można też tworzyć tablice obiektów. Na przykład: 

char*save_string(const char*p)
{
    char*s = new char[strlen(p)+1];
    strcpy(s,p); // kopiuje z p do s
    return s;
}
int main(int argc, char*argv[])
{
    if (argc < 2) exit(1);
    char*p = save_string(argv[1]);
    //...
    delete[] p;
}

„Nieuzbrojony” operator delete służy do usuwania pojedynczych obiektów. Natomiast operator delete[] służy do usuwania tablic.

Jeśli nie musisz z jakiegoś powodu bezpośrednio używać char*, to funkcję save_string() można znacznie uprościć przy użyciu standardowego typu string: 

string save_string(const char*p)
{
    return string{p};
}
int main(int argc, char*argv[])
{
    if (argc < 2) exit(1);
    string s = save_string(argv[1]);
    //...
}

Przede wszystkim udało się pozbyć operatorów new[] i delete[]. Aby dezalokować pamięć zajętą przez new, operator delete i delete[] musi mieć możliwość sprawdzenia, jaki jest rozmiar alokowanego obiektu. To znaczy, że obiekt alokowany przy użyciu standardowej implementacji operatora new powinien być nieco większy od obiektu statycznego. Potrzebna jest przynajmniej dodatkowa pamięć do przechowywania rozmiaru obiektu. Zazwyczaj do zarządzania pamięcią wolną wykorzystywane są nie mniej niż dwa słowa na alokację. W większości nowoczesnych komputerów słowo ma 8 bajtów. Przy alokacji dużych obiektów nie ma to wielkiego znaczenia, ale może być poważnym utrudnieniem przy alokowaniu w pamięci wolnej dużej liczby małych obiektów (np. typu int albo Point). Zwróć uwagę, że vector jest właściwym obiektem, więc można go alokować przy użyciu operatorów new i delete. Na przykład:

void f(int n)
{
    vector*p = new vector(n); // pojedynczy obiekt
    int*q = new int[n]; // tablica
    // ...
    delete p;
    delete[] q;
}

Operator delete[] można zastosować tylko do wskaźnika do tablicy zwróconego przez operator new oraz do wskaźnika pustego. Zastosowanie operatora delete[] do wskaźnika pustego nie wywołuje żadnego efektu. Operatora new nie należy używać do tworzenia obiektów lokalnych. Na przykład:

void f1()
{
    X*p =new X;
    //... użycie *p...
    delete p;
}

Jest to rozwlekła, niewydajna metoda, przy stosowaniu której łatwo popełnić błąd. W szczególności wykonanie instrukcji return i zgłoszenie wyjątku przed wykonaniem operatora delete spowoduje wyciek pamięci (chyba że doda się jeszcze więcej kodu). W zamian lepiej jest użyć zmiennej lokalnej:

void f2()
{
    X x;
    //... użycie x...
}

Lokalna zmienna x zostanie niejawnie usunięta na końcu funkcji f2.

Sprawdzanie dostępności miejsca w pamięci

Operatory new, delete, new[] i delete[] są zaimplementowane przy użyciu funkcji znajdujących się w nagłówku :

void*operator new(size_t); // alokuje miejsce dla pojedynczego obiektu
void operator delete(void*p); // jeśli (p), dezalokuje przestrzeń alokowaną przy użyciu operator new()
void*operator new[](size_t); // alokuje miejsce dla tablicy
void operator delete[](void*p); // jeśli (p), dezalokuje przestrzeń alokowaną przy użyciu operator new[]()

Operator new w celu alokowania pamięci dla obiektu wywołuje funkcję operator new(), za pomocą której zajmuje odpowiednią liczbę bajtów. Analogicznie gdy trzeba alokować tablicę, wywoływana jest funkcja operator new[](). Standardowe implementacje funkcji operator new() i operator new[]() nie inicjują zwracanej pamięci. Funkcje alokacji i dezalokacji pracują na niezainicjowanej pamięci bez określonego typu (często nazywanej pamięcią surową), nie na obiektach określonego typu. W konsekwencji pobierają
argumenty i zwracają wartości typu void*. Operatory new i delete stanowią pomost między warstwą pamięci bez określonego typu a warstwą obiektów o określonych typach. Co się dzieje, gdy operator new nie może znaleźć wystarczającej ilości pamięci, aby alokować obiekt? Domyślnie w takiej sytuacji alokator zgłasza standardowy wyjątek bad_alloc. Na przykład:

void f()
{
    vectorv;
    try{
        for (;;) {
            char * p = new char[10000]; // zajmuje trochę pamięci
            v.push_back(p); // utworzenie referencji do alokowanej pamięci
            p[0] = 'x'; // użycie alokowanej pamięci
        }
    }
    catch(bad_alloc) {
        cerr << "Pamięć została wyczerpana! ";
    }
}

Nieważne jak dużo mamy pamięci, powyższy kod w końcu spowoduje wywołanie procedury obsługi wyjątku bad_alloc. Należy jednak pamiętać, że operator new nie musi zgłosić wyjątku, gdy wyczerpie się pamięć fizyczna. Dlatego w systemie z pamięcią wirtualną powyższy program może zająć bardzo dużo miejsca na dysku, a zanim zgłosi wyjątek, może upłynąć bardzo dużo czasu. Można zdefiniować działania, które mają zostać podjęte w przypadku wyczerpania się pamięci.

Funkcje zdefiniowane w nagłówku nie muszą być używane, bo użytkownik może zdefiniować własne funkcje operator new() itd. dla poszczególnych klas. Funkcje te w postaci składowych klas są preferowane w porównaniu z funkcjami z nagłówka ze względu na zasady dotyczące zakresów dostępności.

Przeciążanie operatora new

Domyślnie operator new tworzy obiekty w pamięci wolnej. A co gdybyśmy chcieli je tworzyć
gdzie indziej? Spójrzmy na przykład prostej klasy:

class X {
public:
    X(int);
    //...
};

Obiekty można umieszczać, gdzie się chce, poprzez zdefiniowanie funkcji alokacyjnej z dodatkowymi argumentami i przekazywanie tych argumentów przy użyciu operatora new: 

void* operator new(size_t, void* p) { return p; } // operator jawnej lokalizacji obiektu
void* buf = reinterpret_cast(0xF00F); // znaczący adres
X* p2 = new(buf) X; // tworzy obiekt X w buf;
// wywołuje operator new(sizeof(X),buf)

W tym przypadku składnia new(buf) X przekazująca dodatkowe argumenty do funkcji operator new() nazywa się składnią lokacyjną (ang. placement syntax). Zauważ, że każda funkcja operator new() jako pierwszy argument pobiera rozmiar oraz że rozmiar alokowanego obiektu jest dostarczany niejawnie (19.2.5). Wybór funkcji operator new() przez operator new jest dokonywany wg typowych zasad dopasowywania argumentów. Każda funkcja operator new() ma size_t w pierwszym argumencie.

Lokacyjna funkcja operator new() jest najprostszym alokatorem tego rodzaju. Jej definicja znajduje się w standardowym nagłówku :

void*operator new (size_t sz, void* p) noexcept; // lokuje obiekt o rozmiarze sz w p
void*operator new[](size_t sz, void* p) noexcept; // lokuje obiekt o rozmiarze sz w p
void operator delete (void* p, void*) noexcept; // jeśli (p), *p jest niepoprawny
void operator delete[](void* p, void*) noexcept; // jeśli (p), *p jest niepoprawny

Operatory lokacyjne delete tylko informują system usuwania nieużytków, że usunięty wskaźnik nie jest już bezpiecznie derywowany (ang. safely-derived). Konstrukcja new lokacyjnego może być także używana do alokowania pamięci z konkretnego
obszaru:

class Arena {
public:
    virtual void* alloc(size_t) =0;
    virtual void free(void*) =0;
    //...
};
void* operator new(size_t sz, Arena* a)
{
    return a−>alloc(sz);
}

Teraz można alokować obiekty dowolnego typu w różnych obszarach Arena. Na przykład:

extern Arena* Persistent;
extern Arena* Shared;
void g(int i)
{
    X* p = new(Persistent) X(i); // X w pamięci trwałej
    X* q = new(Shared) X(i); // X w pamięci wspólnej
    //...
}

Gdy umieszcza się obiekt w obszarze, który nie jest bezpośrednio kontrolowany przez standardowego zarządcę pamięci wolnej, należy pamiętać, że trzeba samodzielnie zadbać o jego usunięcie. Podstawową techniką stosowaną w takim przypadku jest jawne wywołanie destruktora: 

void destroy(X* p, Arena* a)
{
    p−>˜X(); // wywołanie destruktora
    a−>free(p); // pamięć wolna
}

Podkreślę, że należy wystrzegać się jawnych wywołań destruktorów w sytuacjach innych niż implementacja klas do zarządzania zasobami. Nawet większość uchwytów do zasobów można napisać przy użyciu operatorów new i delete. Chociaż zaimplementowanie wydajnego ogólnego kontenera podobnego do wektora z biblioteki standardowej bez użycia
jawnych wywołań destruktorów byłoby trudne. Początkujący programista powinien zastanowić się trzy razy, zanim jawnie wywoła destruktor, i dodatkowo najlepiej, żeby wcześniej zasięgnął rady bardziej doświadczonego kolegi. 

Nie istnieje żadna specjalna składnia do lokowania tablic. Nie jest potrzebna, bo za pomocą lokacyjnego new można alokować dowolne typy. Można natomiast zdefiniować operator new() dla tablic.

Wyłączanie zgłaszania wyjątków przez operator new

W programach, w których nie mogą być zgłaszane wyjątki, można używać operatorów new i delete z wyłączonym zgłaszaniem wyjątków (nothrow). Na przykład:

void f(int n)
{
    int*p = new(nothrow) int[n]; // alokacja n liczb typu int w pamięci wolnej
    if (p==nullptr) { // brak dostępnej pamięci
        // ... obsługa błędu alokacji...
    }
    //...
    operator delete(nothrow,p); // dezalokacja *p
}

Nazwa nothrow odnosi się do obiektu standardowego typu nothrow_t. Zarówno obiekt nothrow, jak i typ nothrow_t są zadeklarowane w nagłówku . Funkcje wykorzystujące ten typ również znajdują się w nagłówku :

void* operator new(size_t sz, const nothrow_t&) noexcept; // alokuje sz bajtów; zwraca nullptr // w razie niepowodzenia alokacji
void operator delete(void* p, const nothrow_t&) noexcept; // dezalokuje przestrzeń alokowaną przez new
void* operator new[](size_t sz, const nothrow_t&) noexcept; // alokuje sz bajtów; zwraca nullptr
// w razie niepowodzenia alokacji
void operator delete[](void* p, const nothrow_t&) noexcept; // dezalokuje przestrzeń alokowaną przez new

W przypadku braku pamięci wszystkie te funkcje zwracają nullptr, zamiast zgłaszać wyjątek bad_alloc.

 Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion 

Podobne artykuły

Podziel się ze znajomymi tym artykułem - udostępnij na FB lub wyślij e-maila korzystając z poniższych opcji:

wszystkie oferty