Menu

Destruktory w C++: ~ClassName, RAII i sprzątanie

Destruktor uruchamia się automatycznie, gdy obiekt jest niszczony. Poznaj składnię ~ClassName(), moment jego wywołania, powód, dla którego zwalnia zasoby, oraz regułę trzech/pięciu.

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

Czym jest destruktor

Na poprzedniej stronie pojawiły się konstruktory: specjalne funkcje, które uruchamiają się, gdy obiekt się rodzi, i ustawiają jego stan początkowy. Destruktor to lustrzane odbicie: specjalna funkcja, która uruchamia się, gdy obiekt umiera, aby po nim posprzątać.

Deklarujesz go jako nazwę klasy poprzedzoną tyldą (~). Nie przyjmuje parametrów, nic nie zwraca, a klasa może mieć dokładnie jeden. Prawie nigdy nie wywołujesz go ręcznie: C++ robi to za ciebie we właściwym momencie.

Zwróć uwagę, że komunikat destruktora pojawia się po zakończeniu ciała main, ale przed wyjściem z programu. Gdy log wychodzi z zasięgu przy zamykającej klamrze, C++ uruchamia za ciebie ~Logger().

Kiedy działają destruktory

Dokładny moment zależy od tego, gdzie żyje obiekt:

  • Obiekty na stosie (lokalne) są niszczone, gdy wychodzą z zasięgu, przy zamykającym } bloku.
  • Obiekty na stercie (utworzone przez new) są niszczone, gdy wywołujesz delete. Jeśli zapomnisz o delete, destruktor nigdy się nie uruchomi i powstanie wyciek.

Ten przykład pokazuje różnicę:

Obiekty są niszczone w odwrotnej kolejności do konstrukcji. a powstał pierwszy, więc ginie ostatni. Ta kolejność LIFO (ostatni na wejściu, pierwszy na wyjściu) ma znaczenie, gdy obiekty od siebie zależą.

Dlaczego destruktory są ważne: RAII

Prawdziwa siła destruktorów polega na tym, że sprzątanie staje się automatyczne i bezpieczne wobec wyjątków. Zamiast pamiętać o zwolnieniu zasobu na każdej ścieżce kodu, umieszczasz zwolnienie w destruktorze, a język gwarantuje, że ono nastąpi. Ten wzorzec nazywa się RAII (Resource Acquisition Is Initialization) i stanowi kręgosłup współczesnego C++.

Tutaj klasa posiada bufor na stercie: alokuje go w konstruktorze i zwalnia w destruktorze, więc wywołujący nigdy sami nie dotykają new/delete.

Kluczowa obserwacja: nawet gdyby po utworzeniu squares został rzucony wyjątek, stos by się zwinął, a ~IntArray() i tak by się uruchomił. Ta gwarancja sprawia, że RAII jest tak niezawodne, i dlatego w dobrym kodzie C++ rzadko pisze się gołe delete.

Reguła trzech (i pięciu)

Klasa z własnym destruktorem prawie zawsze posiada surowy zasób, a to tworzy ukryte niebezpieczeństwo. Konstruktor kopiujący i przypisanie kopiujące wygenerowane przez kompilator wykonują kopię płytką: kopiują wskaźnik, a nie bufor, na który on wskazuje. Teraz dwa obiekty trzymają ten sam wskaźnik i oba destruktory wywołają na nim delete, co kończy się awarią podwójnego zwolnienia.

IntArray a(5);
IntArray b = a;   // płytka kopia: a.data i b.data to TEN SAM wskaźnik
// na końcu zasięgu: destruktor b zwalnia bufor,
// potem destruktor a zwalnia go PONOWNIE -> niezdefiniowane zachowanie (double free)

Stąd reguła trzech: jeśli piszesz którykolwiek z elementów: destruktor, konstruktor kopiujący albo operator przypisania kopiującego, niemal na pewno potrzebujesz wszystkich trzech. Od C++11 rozszerza się to do reguły pięciu, która dodaje konstruktor przenoszący i przypisanie przenoszące.

Jest jednak jeszcze lepsza zasada, reguła zera: projektuj klasy tak, aby w ogóle nie zarządzały surowymi zasobami. Trzymaj zamiast tego std::vector, std::string albo inteligentny wskaźnik, a destruktor wygenerowany przez kompilator za darmo zrobi, co trzeba.

Domyślnie sięgaj po regułę zera. Własny destruktor pisz tylko wtedy, gdy naprawdę posiadasz surowy zasób, którego nie opakowuje za ciebie żaden typ standardowy.

Destruktory wirtualne

Gdy usuwasz obiekt przez wskaźnik na klasę bazową, destruktor musi być virtual. W przeciwnym razie zniszczona zostanie tylko część bazowa, a część pochodna wycieknie. To jeden z najczęstszych błędów w kodzie polimorficznym, a kompilator domyślnie nie ostrzega o nim.

Bez virtual przy ~Base wywołanie delete p uruchomiłoby tylko ~Base(): to niezdefiniowane zachowanie, a część Derived obiektu nigdy nie zostałaby posprzątana. Praktyczna zasada: każda klasa z funkcjami wirtualnymi (polimorficzna klasa bazowa) potrzebuje wirtualnego destruktora. Dokładnie zobaczysz, dlaczego to ważne, gdy zaczniesz tworzyć klasy pochodne.

Częste błędy i pułapki

Kilka pułapek łapie prawie każdego:

Niedopasowane new/delete. Jeśli alokujesz przez new[], zwalniaj przez delete[]. Mieszanie new[] ze zwykłym delete (lub odwrotnie) to niezdefiniowane zachowanie.

Brak virtual przy destruktorze bazowym. Jak wyżej: usunięcie obiektu pochodnego przez wskaźnik bazowy bez wirtualnego destruktora powoduje wyciek części pochodnej. Jeśli piszesz klasę przeznaczoną do dziedziczenia, zrób destruktor wirtualnym.

Wypuszczanie wyjątków z destruktora. Destruktor, który rzuca wyjątek podczas zwijania stosu, kończy działanie programu. We współczesnym C++ destruktory są niejawnie noexcept: pilnuj, aby kod sprzątający nie rzucał wyjątków, albo przechwytuj je wewnątrz destruktora.

Pisanie niepotrzebnego destruktora. Jeśli pola same po sobie sprzątają, puste ~ClassName() {} dodaje tylko szumu i może po cichu wyłączyć operacje przenoszenia. Gdy nie ma czego sprzątać, nie pisz destruktora wcale.

Dalej: dziedziczenie

Znasz już pełny cykl życia obiektu: konstruktory powołują go do życia, destruktory po nim sprzątają, a destruktory virtual dbają o poprawność tego sprzątania, gdy jedna klasa buduje na drugiej. Ten ostatni punkt to zapowiedź kolejnej dużej idei: dziedziczenia, w którym klasa ponownie wykorzystuje i rozszerza dane i zachowanie innej. Następna strona pokazuje, jak wyprowadzić jedną klasę z drugiej, jak konstrukcja i destrukcja przechodzą przez hierarchię i jak łączą się elementy, które właśnie poznajesz.

Najczęściej zadawane pytania

Czym jest destruktor w C++?

Destruktor to specjalna metoda o nazwie ~ClassName(), która uruchamia się automatycznie, gdy obiekt jest niszczony: gdy wychodzi z zasięgu albo gdy wywołujesz na nim delete. Jego zadaniem jest sprzątanie: zwalnianie pamięci, zamykanie plików i zwalnianie wszelkich zasobów, które posiada obiekt. Nie przyjmuje parametrów, nie ma typu zwracanego, a klasa może mieć tylko jeden.

Kiedy uruchamia się destruktor w C++?

Dla obiektu lokalnego (na stosie) destruktor uruchamia się, gdy obiekt wychodzi z zasięgu, przy zamykającym }. Dla obiektu na stercie utworzonego przez new uruchamia się, gdy wywołujesz delete. Pola i klasy bazowe są potem niszczone automatycznie, w kolejności odwrotnej do konstrukcji.

Czy zawsze trzeba pisać destruktor w C++?

Nie. Jeśli klasa przechowuje tylko pola, które same po sobie sprzątają (jak std::string, std::vector czy inteligentne wskaźniki), destruktor wygenerowany przez kompilator wystarczy: nie pisz własnego. Własnego destruktora potrzebujesz tylko wtedy, gdy klasa posiada surowy zasób, taki jak pamięć z new albo otwarty uchwyt pliku.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ