W tym cyklu podstaw C++ omówię zarządzanie pamięcią. Jest jednym z najważniejszych aspektów programowania w tym języku. W tak niskopoziomowym języku otrzymujemy pełną kontrolę i elastyczność nad alokacji zasobów. Jednocześnie oznacza to, że programista jest odpowiedzialny zarówno za przydzielanie, jak i zwalnianie pamięci.
Rodzaje pamięci
Podczas programowania programista ma do dyspozycji dwa rodzaje pamięci, pamięć statyczną oraz dynamiczną.
- Pamięć statyczna jest to wydzielony obszar pamięci, w której znajdują sie obiekty statyczne, czyli takie, które istnieją w całym cyklu działania programu. Kompilator w trakcie kompilacji zbiera wszystkie zmienne globalne i skladowe statyczne, oblicza ich rozmiar i rezerwuje dla nich obszar, który następnie jest przydzielany tuż przed uruchomieniem programu. Rozmiar ten jest znany i na stałe wpisany w binarke programu.
- Pamięć dynamiczna jest to pamięć, która jest przydzielana na żądanie programisty, zależnie od potrzeb. Pamięć ta może być także zwolniona, gdy już nie jest potrzebna, także na żądanie programisty.
Pamięć dynamiczną można jeszcze podzielić na dwie kategorie
- Stos (stack) to pamięć, która kompilator zarządza automatycznie. Cechą charakterystyczną tej struktury jest to, że alokacja i dealokacja jest bardzo szybka (wręcz pomijalna), natomiast tej pamięci mamy do dyspozycji niewiele, zależnie od środowiska, będzie to od kilkuset bajtów do kilku kilobajtów. Dlatego służy ona głównie do przechowywania zmiennych lokalnych oraz parametrów przekazywanych do funkcji, których czas życia jest bardzo krótki.
- Sterta (heap) jest pamięcią dynamiczną, która pozwala na większą elastyczność, to z tej pamięci czerpie programista, gdy decyduje sie na ręczne zarządzanie. Tą pamięcią zarządza system operacyjny, więc częste operacje rezerwacji i zwalniania należy traktować jako kosztowne.
Błędy w zarządzaniu pamięcią
Ponieważ zarządzanie pamięcią jest w dużym stopniu manualne, to czyha tutaj na nas kilka pułapek, na które musimy szczególnie uważać, gdyż mogą one prowadzić do nieoczekiwanych rezultatów a nawet błędów bezpieczeństwa. A są to przede wszystkim:
Niezwalnianie pamięci
Niezwalnianie pamięci czyli inaczej wycieki pamięci, najczęstszym skutkiem jest to, że program w trakcie działania „pożera” coraz więcej pamięci i nie oddaje jej do systemu, gdy nie jest już potrzebna. W efekcie system operacyjny najpierw zrzuca fragmenty pamięci do pliku wymiany i system zaczyna zwalniać, a finalnie może nawet zakończyć taki program (Out-Of-Memory Killer).
Przepełnienie bufora
Przepełnienie bufora (ang. buffer overflow) czyli pisanie lub czytanie poza obszarem zarezerwowanym. Może się zdarzyć gdy w wyniku błędów logicznych w programie, a najczęściej w wyniku niedostatecznej walidacji danych pochodzących od użytkownika program nagle zacznie pisać po pamięci zmiennych sąsiadujących. Niestety ani C ani C++ nie posiadają żadnych mechanizmów zabezpieczających. Każdorazowe sprawdzanie, czy indeks nie wychodzi poza dozwolony zakres byłoby zbyt kosztowne. Sytuacja jest tu o tyle groźna, że hakerzy często ostrzą sobie zęby na takie błędy. W przeszłości były one (i nadal są) często wykorzystywane to nieuprawnionego uzyskania dostępu do komputera, uzyskania wyższych uprawnień, lub wycieku haseł bądź kluczy szyfrujących.
Wielokrotne zwalnianie pamięci
Podwójne zwalnianie pamięci wydaje się z pozoru niewinne, ale raz zwoniona pamięć za chwilę będzie przydzielona innemu obiektowi. Ponowne zwonienie tej pamięci może byc nieprzewidywalne w skutkach, bo w jego obszarze zaczną sie zmieniać dane w sposób losowy. To będzie miało istotny wpływ na dalsze decyzje programu z nim związane, a nawet do skutków podobnych do tych z przepełnieniem bufora.
Wiszące wskażniki i referencje
Wiszące wskaźniki lub referencje (dangling pointers), tu mamy do czynienia z sytuacją, gdy używamy wskażnika na pamięć już zwolnioną, lub gdy z funkcji zwracamy wskażnik bądź referencję do obiektu znajdującego się na stosie. Wówczas do dalszej części programu pprzekazujemy adres zmiennej, która właśnie została usunięta ze stosu. Kompilator nie zawsze jest w stanie wyłapać takie sytuacje.
Niewłaściwa funkcja zwalniająca
Niewłaściwa funkcja zwalniająca pamięć. Zazwyczaj do alokacji i dealokacji mamy do do dyspozycji kilka zestawów funkcji. Ważne jest aby nie mieszać ich ze sobą, i do dealokacji użyć właściwego odpowiednika. W przeciwnym razie w najlepszym wypaku skończymy z wyciekiem pamięci, w trochę gorszym z uszkodzeniem struktu sterty, co uniemożliwi dalsze działanie programu. Taki problem może być trudny do zlokalizowania, bo może sie ujawniać dopiero w następnej operacji alokacji.
Na szczęście jest kilka technik, które nieco odciążą nas od przykrych obowiązków i pozwolą sie lepiej skupić na problemie, który chcemy rozwiązać. Czytajcie dalej!
Zarządzanie pamięcią czyli alokacja i dealokacja
Jak już wspominałem wcześniej, pamięć statyczna jest wyznaczana jeszcze na etapie „kompilacji”, więc z puktu widzenia programisty, niewiele jest tu do robienia. Deklarujemy zmienną w programie i już, gotowe. W środowisku embedded możemy jednak napotkać na pewne ogranizenia związane z możliwościami mikrokontrolera. Zwykle kompilator będzie w stanie nas poinformować, czy mieścimi sie w założonym limicie i ewentualnie zakończy kompilacje z odpowiednim komunikatem błędu.
Zerknijmy na przykłady
class Licznik {
public:
static int licznik;
};
int Licznik::licznik; // statyczna składowa klasy
int globalna; // zmienna globalna
void funkcja()
{
// statyczna zmienna wewnątrz funkcji
// jej zawartość pozostanie w pamieci pomiedzy wywolaniami funkcji
static char bufor[16];
}
int main()
{
funkcja();
return 0;
}
Alokacja na stosie
Alokacja na stosie, to przypadek, z którym progamista ma do czynienia zdecydowanie najczęściej. Na szczęście tutaj kompilator odwala za nas całą robotę związaną z zarządzaniem i zwykle robi to dobrze. Do tego stopnia, że nawet nie jesteśmy świadomi, że to się dzieje. Stos jest bardzo prostą strukturą, do której dane może my wkładać kolejno, a następnie zdejmować w kolejności odrotnej. Zerknijmy najpierw na przykład poniżej.
int dodaj(int a, int b) {
return a + b;
}
int main()
{
int x = 1; // zmienne utworzona na stosie
int y = 2;
int z;
z = dodaj(x, y); // argumenty funkcji także przekazywane są na stosie
return 0;
}
Prześledźmy działanie tego prostego przykładu
- po wejściu do funkcji
main()
mamy już przydzielony stos i jeden z rejestrów procesora wskazuje na szczyt stosu. Rejestr ten jest wskażnikiem miejsca, gdzie będziemy mogli zapisać kolejną zmienną - następnie pod tym adresem jest zapisywana zmienna
x
a wskaznik jest przesuwany na następne wolne miejsce, w tym przypadku wartość rejestru jest zwiekszana o rozmiar tej zmiennejsizeof(int)
- te same kroki co w p. 2) są wykonane dla zmiennych
y
orazz
- następnie chemy wywołać funkcje
dodaj()
która wymaga dwóch argumentów, w tym celu identycznie jak w p. 2) na szczyt stosu kopiowane są kolejno wartościx
orazy
i wykonywany jest skok do adresu funkcji - funcja
dodaj()
odczytuje ze stosu dwie ostatnie zmienne i wykonuje na nich operacje, wynik jest zapisywany w jednym z rejestrów procesora - gdyby funkcja
dodaj()
miała swoje zmienne lokalne albo wywoływała kolejne funkcje, wówczas na stosie alokowane by były kolejne zmienne i argumenty wg tego samego scenariusza - następnie funkcja wykonuje instrukcję powrotu do miejsca wywaołania, wówczas ze stosu dealokowane są argumenty
a
ib
, wskaźnik stosu jest zmniejszany o ich rozmiar a wynik jest zapisywany w zmiennejz
.
Tak to działa w znaczym uproszczeniu. Łatwo tu zauważyć prostotę tego rozwiązania i jego wydajność. Alokacja polega na przesunieciu wskaznika, a ponieważ to rejestr procesora, sprowadza się to do zwykłego dodawania i odejmowania. Tak proste operacje procesor zazwyczaj wykonuje w jednym cyklu zegarowym. Szybciej po prostu się nie da.
Alokacja na stercie
Alokacja na stercie polega na wywołaniu dedykowanych funkcji, które zwracają wskaźnik na zarezerwowany obszar. Każda funkcja alokująca ma swój odpowiednik do zwalniania pamięci. W języku C mamy do tego odpowiednio funkcje malloc()
oraz free()
. W C++ służą temu operatory new
oraz delete
.
Przykładowa alokacja w C
#include <stdlib.h>
int main()
{
char* char_ptr = (char*)malloc(16); // alokacja 16 bajtów pamieci
free(char_ptr); // zwolnienie pamieci
int* int_ptr = (int*)malloc(16 * sizeof(int)); // alokacja tablicy int[16]
free(int_ptr); // zwolnienie pamieci
}
Bardziej intuicyjna forma alokacji w C++
#include <iostream>
#include <new>
int main()
{
int* int1_ptr = new int; // alokacja pojedynczej zmiennej int
delete int1_ptr; // zwolnienie pamieci
int* int16_ptr = new int[16]; // alokacja tablicy int[16]
delete[] int16_ptr; // zwolnienie pamieci !!!
size_t rozmiar = 128;
char* char_ptr = new char[rozmiar]; // alokacja char[rozmiar]
delete[] char_ptr; // zwolnienie pamieci
}
Tutaj mamy troche łatwiej, bo nie musimy ręcznie wyliczać romiaru pamięci na postwaie liczby elementów i wielkości typu. Ale trzeba też być ostrożnym, bo ważne jest aby wskażnik z operatora new
zwalniać operatorem delete
, natomiast wskażnik od operatora new[]
zwalniać operatorem delete[]
. Ważne jest także aby nie mieszać ze sobą malloc()
i delete
oraz new
i free()
.
Przeciązanie alokatorów
C++ dostarcza nam jeszcze kilka przydatnych możliwości. Są to:
- przeciążenie operatora
new
idelete
globalnie, wówczas w całym programie, tam gdzie jest wywołanienew/delete
będa podstawione nasze funkcje. Dzięki temu możemy rozszerzyć ich zachowanie poprzez dodanie własnej logiki, bez konieczności modyfikowania kodu całego programu. - definiowanie osobnych operatorów
new/delete
tylko dla wybranej klasy pozwala w pełni kontrolować alokacje pamięci dla wybranych przez nas typów.
#include <new>
void* operator new(std::size_t size) { return malloc(size); }
void* operator new[](std::size_t size) { return malloc(size); }
void operator delete(void* ptr) { free(ptr); }
void operator delete[](void* ptr) { free(ptr); }
class Memory
{
public:
void* operator new(std::size_t size) { return malloc(size); }
void* operator new[](std::size_t size) { return malloc(size); }
void operator delete(void* ptr) { free(ptr); }
void operator delete[](void* ptr) { free(ptr); }
};
Cykl życia obiektów
Obiekty, którymi się posługujemy w programie mają zazwyczaj ściśle określony czas życia. Czas ten liczymy od momentu konstukcji obiektu to jego destrukcji. Tylko w tym czasie możemy sie odwoływac do nich. Czas ten zależy przede wszystkim od tego, w której części pamięci je umieścimy. I tak:
- w pamięci statycznej żyją tak długo, jak działa program. Są inicjowane jeszcze przed wejściem do funknic
main()
i są niszczone, po jej zakończeniu. Możemy więc uznać, że istnieją zawsze. - na stosie istnieją tylko przez czas wykonywania funkcji, w której się znajdują. Ten czas moze być dodatkowo ograniczony poprzez operator zakresu, czyli nawiasy klamrowe
{}
. Za każdym razem, gdy program dochodzi do inicjalizacji obiektu, wywoływany jest konstruktor, a gdy wykonanie programu dochodzi do nawiasu zamykającego}
, wywoływany jest destruktor. Jeśli wewnątrz funkcji jest kilka obiektów, niszczone są one w kolejności odwrotnej, do tej, w której były inicjowane. - na stercie istnieją od momentu allokacji pamięci (operator
new
) i pozostają tam, do póki programista nie zdecyduje o ich zwolnieniu (operatordelete
), bądź do zakończenia programu.
Spójrzmy na te przykłady
#include <iostream>
class MemoryTracker
{
public:
MemoryTracker(const char * n): _nazwa{n} {
std::cout << "+konstruktor " << _nazwa << std::endl;
}
~MemoryTracker() {
std::cout << "-destruktor " << _nazwa << std::endl;
}
const char * _nazwa;
};
void funkcja_non_static() {
std::cout << "enter funkcja_non_static()" << std::endl;
// ten kostruktor zostanie wywolany w tym miejscu
// destruktor podczas wychodzenia z funkcji (za exit)
MemoryTracker t{"non_static"};
std::cout << "exit funkcja_non_static()" << std::endl;
}
void funkcja_static() {
std::cout << "enter funkcja_static()" << std::endl;
// ten konstruktor bedzie wywołany tylko przy pierwszym uruchmieniu funkcji
// destruktor na zakonczenie programu
static MemoryTracker t{"static"};
std::cout << "exit funkcja_static()" << std::endl;
}
void funkcja_arg(const MemoryTracker& t) {
std::cout << "enter funkcja_arg()" << std::endl;
std::cout << "exit funkcja_arg()" << std::endl;
}
int main()
{
std::cout << "enter main()" << std::endl;
std::cout << std::endl;
funkcja_non_static();
std::cout << std::endl;
funkcja_static();
std::cout << std::endl;
funkcja_static(); // przy kolejnym wywołaniu zmienna statyczna nie jest inicjowana
std::cout << std::endl;
// obiekt zostanie utworzony przed wywołaniem funckji
// będzie utrzymywany na stosie do zakończenia działania funkcji
funkcja_arg(MemotyTracker{"arg"});
std::cout << std::endl;
{
// kostruktor "main" zostanie wywolany w tym miejscu
MemoryTracker t{"main"};
} // destruktor "main" zostanie wywolany tutaj
std::cout << std::endl;
std::cout << "exit main()" << std::endl;
return 0;
}
Pojęcie własności
Gdy alokujemy pamięć na stercie, musimy pamiętać o tym, aby tą pamięć zwolnić, gdy już nie bedzie nam potrzebna. Zawsze jest to dla na obciążenie, które sprawia, że program staje sie trudny w utrzymaniu i łatwo popełnić błąd. A ponieważ w C++ nie ma żadnych mechanizmów automatycznego zwalniania pamięci znanych z innych wysokopoziomowych języków (jak np. garbage collector), to programiści wypracowali sobie pewne konwencje pracy ze wskaźnikami. Konwencja, to po prostu zestaw reguł, wg których nalezy postępować. Jeśli będziemy się ich trzymać, to na pewno będzie nam trochę łatwiej. Jedną z nich jest właśnie pojęcie własności (ownership). Oznacza kto, a właściwie co bedzie odpowiedzialne, za alokację i zwolnienie pamięci. Może to być zarówno funkcja jak i klasa. Zobaczmy najprostszy przykład z klasą w C++
class Tekst
{
public:
Tekst(size_t rozmiar): _rozmiar{rozmiar} {
_dane = new char[_rozmiar];
}
~Tekst() {
delete _dane;
}
void przetwarzaj() {
// pracuj z danymi
}
private:
size_t _rozmiar;
char * _dane;
}
W tym przykładzie, konstruktor jest odpowiedzialny za rezerwację pamięci podczas tworznia obiektu. Natomiast destruktor jest odpowiedzialny za jej zwonienie. Gdy obiekt klasy Tekst
zostanie zniszczony, automatycznie zwolni pamięć, którą sam zaalokował. Ważne jest tuaj, aby nie udostępniac wskażnika _dane
na zewnątrz tej klasy, a wszelkie działania z nim związane zaimplementować jako jej metody.
Jesli już mówimy o regułach, to w tym przypadku ważna jest też „Reguła Trzech” (ang. Rule of Three). Mówi ona, że jeśli klasa zarządza zasobami dynamicznymi (np. pamięcią) ważne jest, żeby zdefiniować także własny konstruktor kopiujący, operator przypisania i destruktor. Powodem tego jest to, że w takich przypadkach obiekty tej klasy mogą wymagać głębokiej kopii zasobów (aby uniknąć współdzielenia wskaźników do tej samej pamięci) oraz odpowiedniego zwolnienia zasobów w destruktorze.
RAAI
RAII (Resource Acquisition Is Initialization) jest esencją pojęcia własności i mocno czerpie z automatyzacji czasu życia obiektów na stosie. To kluczowy wzorzec projektowy w C++, który automatyzuje zarządzanie zasobami, w tym pamięcią. Zasada RAII polega na tym, że zasoby – takie jak pamięć, pliki czy połączenia sieciowe – alokujemy w momencie inicjalizacji obiektu, zazwyczaj w konstruktorze, i zwalniamy w destruktorze. Dzięki temu zarządzanie zasobami jest bezpieczniejsze, ponieważ jest ściśle powiązane z cyklem życia obiektów. RAII eliminuje potrzebę ręcznego zwalniania zasobów, co minimalizuje ryzyko wycieków pamięci i innych problemów. Inteligentne wskaźniki, takie jak std::unique_ptr
, są przykładem zastosowania RAII, gdzie pamięć jest automatycznie zwalniana, gdy wskaźnik wychodzi z zasięgu, bez potrzeby używania delete
.
Wskażniki inteligentne
Skoro już zostały wywołane inteligetne wskażniki (ang. smart pointers), to przybliżę je tutaj nieco, ponieważ uważam, że warto. Są to narzędzia które pojawiły się w C++11 i potrafią zarządzać pamięcią dynamiczną w sposób deterministyczny. Eliminują wiele błędów związanych z ręcznym zwalnianiem zasobów. Trzy główne typy inteligentnych wskaźników to: std::unique_ptr
, std::shared_ptr
i std::weak_ptr
.
std::unique_ptr
zapewnia wyłączną własność zasobu – tylko jeden wskaźnik będzie zarządzać danym zasobem, a po jego zniszczeniu zasób będzie automatycznie zwolniony.std::shared_ptr
pozwala na współdzielenie zasobu przez wiele wskaźników i zwalnia zasób, gdy ostatni wskaźnik zostanie zwolniony.std::weak_ptr
jest używany w połączeniu zstd::shared_ptr
do tworzenia nieposiadających, bezpiecznych referencji, które nie wpływają na cykl życia zasobu.
Dzięki tym mechanizmom inteligentne wskaźniki minimalizują ryzyko wycieków pamięci, wskaźników wiszących oraz wielokrotnego zwalniania pamięci. Tu się już zatrzymam, ponieważ myślę, że szersze ich omówienie mieści sie bardziej w cyklu dla zaawansowanych.
Podsumowanie
Omówiliśmy kluczowe zagadnienia zarządzania pamięcią w C++, koncentrując się na rodzajach pamięci, alokacji, błędach, cyklu życia obiektów, koncepcji własności, wzorcu RAII oraz inteligentnych wskaźnikach. Cykl życia obiektów zależy od rodzaju alokacji – obiekty statyczne kompilator zwalnia automatycznie po wyjściu z zasięgu, a obiekty dynamiczne wymagają ręcznego zwalniania. Pojęcie własności określa, który obiekt odpowiada za zasoby, co jest kluczowe w unikaniu wycieków pamięci. RAII automatyzuje zarządzanie zasobami, przypisując je do cyklu życia obiektu. Inteligentne wskaźniki, takie jak std::unique_ptr
i std::shared_ptr
, ułatwiają bezpieczne zarządzanie pamięcią dynamiczną, minimalizując ryzyko błędów i wycieków pamięci.