Dynamiczna alokacja pamięci

tech • 1106 słów • 6 minut czytania

W języku C do dynamicznego przydzielania pamięci wykorzystuje się funkcje (biblioteki standardowej) malloc(), calloc() i realloc(), a zwalniania pamięci - free(). Funkcje te są popularne i praktyczne w użyciu, ale jak wspomniał Bruce Eckel, w swojej książce “Thinking In C++”, są również prymitywne, bo wymagają one zarówno zrozumienia, jak i uwagi ze strony programisty.

Niby wszystko jasne, ale nie tak do końca jest to takie kolorowe.

Typowe przydzielenie pamięci wygląda tak:

Obj* obj = (Obj*)malloc(sizeof(Obj));

Jest to trochę nie wygodne. Programista musi określić wielkość obiektu, a następnie jeszcze dodatkowo rzutować zwracaną wartość funkcji malloc() do odpowiedniego typu. Funkcja malloc() tworzy obszar pamięci (a nie obiekt), dlatego zwraca wartość typu void*.

Warto wspomnieć, że język C++ nie pozwala na przypisanie wartości typu void* jakiemukolwiek innemu wskaźnikowi, dlatego wymagane jest rzutowanie.

Kolejny problem występuje, gdy malloc() nie przydzieli wymaganej pamięci i zwróci wartość zerową. Wymusza to sprawdzanie zwracanego wskaźnika, aby mieć pewność, że pamięć została poprawnie zaalokowana.

Gdy już o to wszystko zadbany nie zapomnijmy o inicjalizacji obiektu, zanim z niego skorzystamy, np.:

Obj->initialize();

Pamiętajmy, że nie można tutaj użyć konstruktora, ponieważ nie ma sposobu wywołania go w jawny sposób1.

Problem polega na tym, że użytkownik może zapomnieć o dokonaniu inicjalizacji przed rozpoczęciem używania obiektu, umieszczając w ten sposób w programie źródło potencjalnych poważnych błędów.

To tyle wspomnień z języka C.

Teraz mamy C++ i dużo łatwiejsze dynamiczne alokowanie/zwalnianie pamięci. A wszystko dzięki dwóm operatorom new i delete.

Użycie new do alokacji pamięci jest bardzo elastyczne i wygodne. Nie tylko zostanie przydzielona na stercie odpowiednia dla obiektu ilość pamięci, ale obiekt zostanie, także zainicjowany poprzez wywołanie odpowiedniego konstruktora danego obiektu.

Obj* obj = new Obj(1, 2);

Tak, więc użycie powyższej instrukcji jest równoważne wywołaniu funkcji malloc(sizeof(Obj)) (często jest to rzeczywiście wywołanie funkcji malloc()) oraz konstruktora klasy Obj, z adresem przydzielonej pamięci w charakterze wskaźnika this oraz listą argumentów (1, 2).

W chwili przypisania adresu wskaźnikowi obj wskazywany obszar pamięci jest już istniejącym, zainicjowanym obiektem, a nawet jest d razu typu Obj, dzięki czemu nie ma potrzeby rzutowania wskaźnika.

W wyrażeniu new można wykorzystać dowolny dostępny konstruktor klasy.

Domyślnie operator new upewnia się, że przydzielenie pamięci zakończyło się powodzeniem, zanim jeszcze przekaże adres konstruktorowi. Nie ma, więc potrzeby jawnego sprawdzania, czy jego wywołanie zakończyło się pomyślnie.

Gdy zabraknie nam dostępnej pamięci (free store), new zwróci wartość zerową (0 lub NULL), wtedy automatycznie zostanie wywołana funkcja obsługi operatora new. Domyślnie funkcja ta zgłosi odpowiedni wyjątek.

Możemy jednak sami zastosować swoją funkcje jako funkcje obsługi operatora new. Aby tego dokonać należy wywołać set_new_handler(), podając jej jako parametr adres naszej funkcji.

Funkcja obsługi operatora new nie może pobierać argumentów i musi zwracać wartość void.

Przykład, prezentujący działanie:

// Zmiana funkcji obsługi operatora new
#include <iostream>
#include <cstdlib>
#include <new>

using namespace std;

int count = 0;

void out_of_memory() {
	cout << "pamiec wyczerpała sie po " << count << " przydziałach!" << endl;
	exit(l);
}

int main() {

	set_new_handler(out_of_memory);

	while(l) {
		count++;
		new int[1000];
	}

}

Do zwalniania pamięci alokowanej przez new służy operator delete. Operator ten najpierw wywołuje destruktor obiektu, a dopiero później zwalnia przydzielana mu pamięć (wywołując często free()).

Należy pamiętać, że delete może być użyte tylko do zwalniania pamięci zaalokowanej przez new.

Jeżeli użyjemy go w stosunku do zaalokowanej pamięci przez malloc() to jego działanie nie jest zdefiniowane. Często jednak w domyślnych implementacjach new i delete wykorzystuje się funkcje malloc() i free(), wiec prawdopodobnie zostanie zwolniona pamięć, ale nie zostanie wywołany destruktor, co doprowadzi do wycieków pamięci.

I właśnie o tym chciałem coś napisać - o wyciekach pamięci. Przeglądając “Thinking In C++” zaciekawił mnie ten podrozdział, ale wyszło jak wyszło ;)

Próba usunięcia zerowego wskaźnika przez delete nie wywoła żadnych szkód, po prostu nic się nie stanie, komputer przecież wie, ze nie istnieje adres 0. Dlatego zaleca się po zwolnieniu pamięci przypisać danemu wskaźnikowi wartość NULL. Bo próba usunięcia powtórnie tego samego obiektu (czegoś czego już nie ma, zostało usunięte prędzej) może doprowadzić do katastrofy Błąd nie musi się od razu ujawnić, przez co trudno później go znaleźć.

Wywołanie operatora delete dla wskaźnika typu void* jest błędem. Chyba, że obiekt, wskazywany przez ten wskaźnik, jest bardzo prosty (nie posiada destruktora).

Poniższy przykład pokazuje, co się w takim przypadku dzieje:

// Usuwanie wskaźników typu void* może powodować wyciekanie pamięci
#include <iostream>

using namespace std;

class Object {
	void* data;			// jakiś obszar pamięci
	const int size;
	const char id;

public:
	Object(int sz, char c) : size(sz). id(c) {
		data = new char[size];
		cout << "Konstrukcja obiektu " << id << ". size - " << size << endl;
	}

	~Object() {
		cout << "Destrukcja obiektu " << id << endl;
		delete []data;	// w porządku, tylko zwalniana jest pamięć
						// - nie ma potrzeby wywoływania destruktora
	}
};

int main() {
	Object* a = new Object(40. 'a');
	delete a;
	void* b = new Object(40. 'b');
	delete b;
}

Wyniki działania programu są następujące:

Konstrukcja obiektu a. size = 40
Destrukcja obiektu a
Konstrukcja obiektu b. size = 40

Jak widzimy bardzo ważne jest, aby operator delete znał typ obiektu, w stosunku do którego został użyty.

Ponieważ wyrażenie delete a “wie”, że zmienna a jest wskaźnikiem do obiektu klasy Object, wywoływany jest destruktor i tym samym następuje zwalnianie pamięci wskazywanej przez składową data. Jednakże w przypadku gdy operuje się obiektem, wykorzystując wskaźnik typu void*, jak w wyrażeniu delete b, zwalniana jest tylko pamięć, przydzielona obiektowi wskazywanemu przez ten wskaźnik. Nie jest natomiast wywoływany destruktor obiektu, a w związku z czym nie jest również zwalniana pamięć, wskazywana przez składową data. Podczas kompilacji tego programu nie pojawią się prawdopodobnie żadne ostrzeżenia - kompilator założy, że programista wie, co robi. Efektem będzie natomiast bardzo powolne wyciekanie pamięci.

J. Grebosz w książce “Symfonia C++” przedstawił krótko zalety stosowania new i delete w stosunku do malloc() i free().

Wspomniał o wszystkich przedstawionych tutaj problemach i zaletach, o tym, że new i delete wywołują konstruktory/destruktory, czego w C nie było.

Dodatkowo malloc() zwraca wskaźnik typu void*, który może zostać przypisany gdziekolwiek po rzutowaniu, przez co łatwo o pomyłkę. W C++ new zwraca konkretny typ, dlatego jakakolwiek próba przypisania go do innego typu wskaźnika zakończy się błędem sygnalizowanym przez kompilator.

Wspomniał także o problemie tzw. modelu pamięci. Jeśli model był “samll” to używało się malloc() i free(), a gdy “huge” to farmalloc() i farfree(). W C++ używając new i delete nie musimy się tym przejmować.

To by było na tyle, trochę długawa notka wyszła. Wykorzystano fragmenty z dwóch wymienionych w treści książek, to właśnie jedna z nich skłoniła mnie do głębszego wniknięcia i poruszenia tego tematu ;)


Przypisy

  1. W rzeczywistości w C++ istnieje mechanizm “new placement”, który umożliwia wywołanie konstruktora i poprawne stworzenia obiektu we wcześniej zaalokowanym obszarze pamięci. ↩︎

Komentarze (5)

Nedroxn avatar
Nedroxn
20070130-175540-nedroxn

Nieźle!

Mogłeś wspomniec o ryzykownym przeładowywaniu globalnych operatorów new i delete.

MalCom avatar
MalCom
20070130-175821-malcom

Właśnie przeciążenia to już inny temat, w zamierzeniu miało być o delete i void*, ale wyszło trochę więcej.

Do tego wiele zdań jest identycznych jak w książce, bo się na niej wzorowałem, IMHO chyba bardziej przystępniej niż tam nie dałoby się tego opisać ;)

coorter avatar
coorter
20070426-173829-coorter

A jak zwolnic pamiec dla przydzielonej w sposob dynamiczny dla tablicy wskaznikow? Przydzielona jest naspetpujaco:

Auto **miejsca=new Auto *[rozmiar];

zwolnienie pamiecie ma nastepowac w destruktorze.

Endru avatar
Endru
20080817-203014-endru

Napisał Pan, że nie da się jawnie wywołać konstruktora obiektu używając bloku pamięci zaalokowanego za pomocą malloc. Jest to niezgodne z prawdą, ponieważ w języku C++ istnieje mechanizm “placement new”, który pozwala wywołać konstruktor klasy na wcześniej zaalokowanym obszarze pamięci. Wywołanie wygląda np. tak:

void *block = malloc(sizeof(Klasa));
Klasa *obj = new (block) Klasa();

Zasadniczo operator new języka C++ robi dwie rzeczy:

  1. Alokuje pamięć.
  2. Wywołuje konstruktor obiektu.

Punkt 2 ma newralgiczne znaczenie podczas inicjalizacji obiektów klas dziedziczonych wielokrotnie, z funkcjami wirtualnymi, gdyż niejawnie w konstruktorze ustawiana jest tablica wskaźników funkcji wirtualnych (virtual table), co jest niezbędne do poprawnego działania mechanizmów języka C++. Użycie proponowanego Obj->initialize(); nie rozwiąże tego problemu. Placement new pozwala wywołać konstruktor nie alokując pamięci.

MalCom avatar
MalCom
20080817-204253-malcom

Dziękuję za obszerny komentarz i wspomnienie o “placement new”, oczywiście ma Pan rację.

Niemniej wspomniane wywołanie Obj->initialize() było przedstawione w kontekście języka C i “emulacji” zachowania operatora new za pomocą “prymitywów” tego języka. Przynajmniej takie było zamierzenie, ale przy kolejnych edycjach tekstu trochę się to zmieniło i rozjechało ;)

Postaram się trochę przeredagować wpis, aby lepiej przedstawić intencje, lub chociaż zamieścić wzmiankę o mechanizmie “placement new”, aby nie wprowadzać czytelników i szczególnie początkujących programistów w błąd.

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/