Menu

Dynamiczna alokacja pamięci w C++: new i delete

Jak alokować pamięć w czasie działania programu przez new, zwalniać ją przez delete i unikać wycieków, wiszących wskaźników oraz podwójnych zwolnień, które towarzyszą ręcznemu zarządzaniu stertą.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Po co prosić o pamięć w czasie działania

Dotąd każda zmienna żyła na stosie: jej rozmiar jest znany w czasie kompilacji, a niszczona jest automatycznie, gdy kończy się jej zasięg. To szybkie i bezpieczne, ale nie radzi sobie z sytuacją, gdy ilość potrzebnej pamięci znasz dopiero w trakcie działania programu: bufor o rozmiarze podanym przez użytkownika, struktura, która musi przeżyć funkcję, która ją utworzyła, albo graf, którego kształtu nie znasz z góry.

W takich przypadkach C++ pozwala alokować pamięć na stercie (zwanej też pamięcią wolną) przez new i oddawać ją przez delete. Wyrażenie new rezerwuje blok, uruchamia konstruktor i zwraca do niego wskaźnik, bezpośrednio korzystając ze wskaźników z poprzedniej strony.

Sama zmienna p żyje na stosie: to tylko wskaźnik. int, na który wskazuje, żyje na stercie i istnieje, dopóki nie wywołasz na nim delete, niezależnie od tego, ile zasięgów się pojawi i zniknie.

Stos a sterta

To rozróżnienie jest całym powodem istnienia new, więc warto je skonkretyzować.

void demo() {
    int a = 10;            // na stosie: znika, gdy demo() zwraca
    int* b = new int(10);  // 'b' na stosie, int, na który wskazuje, na stercie
}                          // 'a' zniszczone; int na stercie WYCIEKA, nigdy nie usunięty

Kluczowe różnice:

  • Stos: automatyczny czas życia, bardzo szybki, ograniczony rozmiar (zwykle kilka MB), zwalniany za ciebie po wyjściu z zasięgu.
  • Sterta: ręczny czas życia, nieco wolniejsza, duża i zwalniana tylko wtedy, gdy wywołasz delete.

To wymiana elastyczności na odpowiedzialność: pamięć na stercie żyje dokładnie tak długo, jak chcesz, ale to ty musisz pamiętać o jej zwolnieniu.

Alokowanie tablic przez new[]

Gdy potrzebujesz bloku o długości ustalanej w czasie działania, użyj formy tablicowej new T[n]. Zwraca ona wskaźnik na pierwszy element, a zwalniasz ją pasującym delete[].

Reguła jest ścisła i łatwo ją złamać: pamięć z new zwalnia się przez delete, a pamięć z new[] przez delete[]. Mieszanie ich, czyli delete arr na czymś zaalokowanym przez new[], to niezdefiniowane zachowanie, nawet jeśli na twoim komputerze wydaje się działać.

Trzy klasyczne błędy

Ręczne zarządzanie pamięcią ma niewielki zestaw pomyłek, które odpowiadają za większość błędów związanych ze stertą. Naucz się rozpoznawać wszystkie trzy.

1. Wyciek pamięci: nigdy nie wywołujesz delete. Blok pozostaje zarezerwowany na zawsze. Raz nieszkodliwy, w pętli zabójczy.

void leaky() {
    int* p = new int(5);
    // ... brak delete ...
}   // p zniknęło; int na stercie jest teraz nieosiągalny I niezwolniony

2. Wiszący wskaźnik: używasz pamięci po jej zwolnieniu. Wskaźnik nadal przechowuje stary adres, ale ta pamięć nie jest już twoja.

3. Podwójne zwolnienie: wywołujesz delete dwa razy na tym samym bloku. To psuje wewnętrzną ewidencję sterty i zwykle kończy się awarią.

int* p = new int(1);
delete p;
delete p;   // podwójne zwolnienie: niezdefiniowane zachowanie, często awaria

Ustawienie wskaźnika na nullptr po usunięciu rozbraja zarówno użycie wiszącego wskaźnika, jak i podwójne zwolnienie: wyłuskanie nullptr od razu powoduje awarię (łatwą do zdebugowania), a delete nullptr jest jawnie bezpieczną operacją, która nic nie robi.

Realistyczny cykl alokacja, użycie, zwolnienie

Po złożeniu wszystkiego razem poprawne ręczne zarządzanie wygląda tak: zaalokuj, użyj, zwolnij dokładnie raz i nie dotykaj potem wskaźnika.

Zauważ, że dla typu klasowego delete u robi dwie rzeczy: najpierw uruchamia destruktor obiektu, a potem zwalnia surową pamięć. Ta kolejność ma znaczenie, gdy twoje obiekty same posiadają zasoby.

Subtelna pułapka: jeśli między new a delete zostanie rzucony wyjątek, delete nigdy się nie wykona i pamięć wycieknie. Owijanie każdej alokacji w try/catch, aby to obsłużyć, jest żmudne i podatne na błędy, a to dokładnie problem, który rozwiązuje następna strona.

Dalej: inteligentne wskaźniki

Znasz już pełny koszt ręcznego zarządzania pamięcią: każde new to obietnica późniejszego delete, a jedno pominięte, podwójne lub przedwczesne zwolnienie to niezdefiniowane zachowanie. Współczesny C++ prawie nigdy nie składa tej obietnicy ręcznie. Następna strona przedstawia inteligentne wskaźniki, czyli std::unique_ptr i std::shared_ptr: obiekty, które posiadają alokację na stercie i automatycznie wywołują za ciebie delete, gdy wychodzą z zasięgu. Dzięki temu wszystkimi trzema klasycznymi błędami zajmują się w twoim imieniu kompilator i RAII.

Najczęściej zadawane pytania

Jaka jest różnica między new a delete w C++?

new alokuje pamięć na stercie w czasie działania programu i zwraca do niej wskaźnik; delete zwalnia pamięć zaalokowaną przez new. Każdemu new musi odpowiadać dokładnie jedno delete, inaczej pamięć wycieka. Dla tablic używaj new[] razem z delete[].

Co się stanie, gdy zapomnisz wywołać delete w C++?

Powstaje wyciek pamięci: blok na stercie pozostaje zarezerwowany przez cały czas życia programu, choć nic już na niego nie wskazuje. Pojedynczy wyciek zwykle nie szkodzi, ale wycieki w pętli albo w długo działającej usłudze rosną, aż programowi zabraknie pamięci i się wysypie.

Czy we współczesnym C++ używać new i delete bezpośrednio?

Rzadko. Wybieraj kontenery takie jak std::vector albo inteligentne wskaźniki (std::unique_ptr, std::shared_ptr), które zwalniają pamięć automatycznie. Surowe new/delete warto rozumieć, bo inteligentne wskaźniki je opakowują, ale w codziennym kodzie to źródło wycieków i wiszących wskaźników.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ