Nasza strona używa cookies. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

Wirtualne destruktory w C++. Niezbędne, dobre i złe praktyki

Dowiedz się, jakie są zalety i wady używania wirtualnych destruktorów w C++. Przekonaj się, kiedy najlepiej ich używać, a kiedy wiążą się ze zbędnym narzutem

Jednym z powszechnych nadużyć funkcji języka C++ jest deklarowanie wirtualnych destruktorów dla wszystkich klas, w tym tych, które nie wykorzystują dynamicznego polimorfizmu (np. opakowań danych, kontenerów, klas kontrolujących współbieżność itp.). Ktoś, kto koduje w C++ i pracuje na wspólnej bazie kodu, prawdopodobnie napotkał następującą deklarację klasy:

Ze względu na dużą liczbę przypadków takiego nadużycia, autorzy kodu często dodają komentarze obok niewirtualnych deklaracji destruktorów, wyjaśniające, dlaczego zostały one zadeklarowane w taki właśnie sposób oraz wskazujące, że było to zamierzone.

W internecie często padały następujące pytania dotyczące wirtualnych destruktorów: „Czy każda klasa powinna/musi mieć wirtualny destruktor?” oraz „Kiedy (nie)używać wirtualnych destruktorów?”

Niezależnie od tego, czy ktoś szuka porady, czy też wyjaśnienia konieczności użycia wirtualnych destruktorów, wiele odpowiedzi szczegółowo określa, kiedy jego użycie jest zalecane, niewskazane, wymagane lub zabronione.

Oto skutki deklarowania wirtualnych destruktorów. Podzieliłem je na 3 kategorie i oparłem na ich wykorzystaniu dynamicznego polimorfizmu. Uważam, że patrząc na to w taki sposób, stanowią one dowód, że deklarowanie wszystkich destruktorów jako wirtualne, jest niewłaściwą praktyką.


Typ 1: Dynamiczny polimorfizm z polimorficzną destrukcją

Takie typy klas pokazują prawdziwy przypadek użycia wirtualnych destruktorów. Przeanalizuj poniższy pseudokod:

Deklaracja destruktora klasy bazowej jako wirtualnego zapewnia wywołanie destruktora klasy pochodnej, gdy obiekt typu klasy pochodnej jest niszczony poprzez wskaźnik lub referencję do typu bazowego. Jeśli Base1::~Base1 nie jest wirtualny, wykonanie wyrażenia delete wywołałoby Base1::~Base1, ale nie Derived1::~Derived1, co spowodowałoby wyciek zasobów zaalokowanych przez Derived1 innych niż te, które zostały odziedziczone z Base1.

Cóż, nie ma tu nic więcej do dodania... W takiej sytuacji użycie wirtualnych destruktorów jest konieczne ze względu na wykorzystanie dynamicznego polimorfizmu, bo faktycznie się ich do tego używa.


Typ 2: Dynamiczny polimorfizm bez polimorficznej destrukcji

Co się stanie, jeśli dynamiczny polimorfizm jest już w użyciu (tj. klasa bazowa ma co najmniej jedną funkcję wirtualną, która nie jest jej destruktorem), ale klasa nie zakłada wykorzystania polimorficznej destrukcji? W poniższym przykładzie zmieniono metodę niszczenia z poprzedniego przykładu, uwzględniając taki scenariusz:

Ze względu na to, że nie używamy dynamicznego polimorfizmu w procesie niszczenia instancji klas pochodnych, to używając niewirtualnego destruktora w klasie bazowej, nie powstrzymujemy wywołania destruktora klasy pochodnej. Nie musimy się przejmować wyciekami związanymi z pominięciem wywołania destruktora.

Wirtualne destruktory nie są w tym przypadku konieczne, co więc powoduje ich użycie? Jaki jest koszt wykonywania takich wywołań funkcji wirtualnych?

Koszt działania wywołania wirtualnej funkcji: chociaż metoda implementacji funkcji wirtualnych nie jest zdefiniowana w specyfikacji języka, kompilatory zazwyczaj używają struktury danych zwanej tabelą funkcji wirtualnych (vtable), której instancja jest tworzona dla każdej klasy deklarującej lub dziedziczącej co najmniej jedną funkcję wirtualną.

Dla każdej wirtualnej funkcji danej klasy istnieje wiersz w vtable, który zawiera wskaźnik do tej funkcji. Każda instancja takiej klasy zawiera wskaźnik do vtable przechowywany jako ukryte pole. To dzięki niemu wywołania funkcji wirtualnych poprzez wskaźniki i referencje do typów bazowych są kierowane do funkcji powiązanych z właściwym typem. Dlatego wywołanie funkcji wirtualnej faktycznie przypomina coś w rodzaju następującej instrukcji:

Istnieje również mniejsze prawdopodobieństwo, że funkcje wirtualne staną się funkcjami rozwijanymi (ang. inline)

Oczywiste jest to, że koszt korzystania z mechanizmu funkcji wirtualnej nie zawsze jest bez znaczenia. Z drugiej strony, dla klas w hierarchii, które już korzystają z tego mechanizmu (np. Base2 i Derived2 w przykładzie kodu), koszt tabeli funkcji wirtualnych na poziomie klasy i wskaźnika do tej tabeli w każdej instancji klasy został już spłacony.

Dodatkowy koszt deklarowania destruktora klasy bazowej może się pojawić jako dodatkowy wiersz w tabelach vtables, a także dostęp do odpowiedniej tabeli i wiersza podczas wywołania destruktora. W większości przypadków takie koszty są uważane za stosunkowo niskie.

Zatem, podczas wdrażania projektu z dynamicznym polimorfizmem, ale bez polimorficznej destrukcji obiektów klasy pochodnej, deklarowanie destruktorów jako wirtualne jest dobrą praktyką. Może służyć jako mechanizm zabezpieczający przed niezamierzoną destrukcją polimorficzną, wynikającą z pomyłki użytkownika. (Należy również pamiętać, że destruktory klas bazowych można zadeklarować jako chronione, aby taki kod się nie skompilował, o ile nie muszą być z jakiegoś powodu publiczne).


Typ 3: Brak dynamicznego polimorfizmu

Są jeszcze pozostałe klasy, które nie wykorzystują dynamicznego polimorfizmu. Innymi słowy, są to te, które nie potrzebują żadnych funkcji wirtualnych. Niektóre klasy nie są wcale zaprojektowane jako klasy bazowe, tak jak pokazano w SomeDataWrapper z pierwszego fragmentu kodu. Co więcej, klasa bazowa nie może wykorzystywać dynamicznego polimorfizmu w taki sposób, jak na poniższym przykładzie:

Zadeklarowanie wirtualnych destruktorów dla tych klas może wiązać się z kosztami dla mechanizmu funkcji wirtualnej, pomimo że nie zostaną one wcale wykorzystane. Koszty działania wirtualnych destruktorów zostały już omówione dla klas typu drugiego. Koszty te nie są jednak takie same dla klas bez innych funkcji wirtualnych.

Poniżej znajduje się lista potencjalnych wad wynikających z deklarowania destruktorów jako wirtualnych w klasie, która nie wykorzystuje polimorfizmu dynamicznego, przy czym ich waga będzie zależeć od konkretnego przypadku użycia:

  • Zakładając, że mechanizm funkcji wirtualnej jest implementowany przez tabele funkcji wirtualnych (vtable), oddzielne vtable są alokowane w pamięci dla klasy podstawowej i wszystkich klas pochodnych. Co więcej, rozmiar każdej instancji klasy jest zwiększany o rozmiar ukrytego wskaźnika do vtable. Może to spowodować znaczny narzut pamięci w przypadku małych klas, w których rozmiar wskaźnika jest porównywalny z całkowitym rozmiarem obiektu, zwłaszcza jeśli istnieje bardzo dużo instancji jednocześnie. Rozsądne byłoby również założenie, że inne implementacje tabel funkcji wirtualnych dodałyby również ukryte pola do obiektów klasy.
  • Na obiekcie klasy, która ma przynajmniej jedną funkcję wirtualną, nie można wykonać wielu niskopoziomowych operacji. (można założyć, że wynika to z ukrytego wskaźnika w implementacji vtable). Jeśli jednak klasa posiada strukturę plain old data (POD), to posiadanie funkcji wirtualnej sprawia, że klasa staje się czymś więcej niż POD, co narusza jeden z warunków rodzajów tego typu. Zapobiega to np. użyciu funkcji, takich jak std::memcpy i std::memcmp z obiektem tego typu. Co więcej, ponieważ układ pamięci takich obiektów nie jest już prosty, potrzeba dodatkowych zabiegów, by przekazać taki typ do innego języka programowania.
  • Podczas wykonywania programu wywołania funkcji wirtualnych są wolniejsze niż wywołania funkcji innych niż wirtualne, ze względu na dodatkowe instrukcje dostępu do funkcji docelowych, które należy wywołać (zazwyczaj za pomocą wskaźników funkcji w tabelach vt). Pary kompilator/linker mogą optymalizować wirtualne wywołania destruktora na prawdziwym typie klasy i zastępować je zwykłymi wywołaniami. Jednak dzieje się tak, tylko jeśli poziom optymalizacji jest wystarczająco wysoki, a one same są wystarczająco „inteligentne” i zdają sobie sprawę z prawdziwego typu klasy w danym wywołaniu. Jeśli jednak z jakiegokolwiek powodu nie zostanie przeprowadzona optymalizacja, wirtualne destruktory, które nie muszą być wirtualne, cierpią z powodu narzutu. Co więcej, destruktory, które w innym przypadku zostałyby przetworzone przez kompilator w funkcje rozwijane, nie skorzystają na tym, ponieważ kompilatory rzadziej rozwijają funkcje wirtualne.
  • Po przeanalizowaniu kodu, słowo kluczowe virtual przy destruktorze funkcji oznacza polimorfizm dynamiczny, bo taki jest wyłączny cel funkcji wirtualnych. W przypadku napotkania wirtualnego destruktora można poszukać innych deklaracji funkcji wirtualnych jako następnej czynności, ponieważ użycie polimorfizmu dynamicznego jest podstawową cechą klasy. Trzeba na to zawsze zwracać uwagę, nawet jeśli wszystkie inne wady są nieistotne. 


Jedyną zaletą deklarowania destruktorów takich klas jako wirtualnych, jest zapobieganie możliwemu nadużyciu przez niszczenie pochodnych obiektów klasy za pomocą wskaźników lub referencji klas bazowych. Jak mówiłem wcześniej, koszty mogą być nierównomierne do tej zalety. 

W rzeczywistości wirtualne destruktory są nadal uzasadnione w przypadku statycznie polimorficznych klas bazowych, dla których wydajność i rozmiar nie stanowią istotnego problemu, a wszystkie koszty są do zaakceptowania. Jest to szczególnie ważne, gdy destruktor musi być publiczny, pamiętając, że chronione destruktory klasy podstawowej również zapobiegają takim nadużyciom. Warto również przypomnieć, że klasy bazowe, które nie mają żadnych funkcji wirtualnych, nie są zbyt powszechne w projektowaniu obiektowym.

Jednakże, używanie wirtualnych destruktorów nie ma zbyt wiele sensu dla klas, które nie są zaprojektowane jako bazowe (cecha ta może być narzucona identyfikatorem final od C++11). Ogólnie rzecz biorąc, deklarowanie każdego destruktora jak wirtualnego na ślepo jest złą praktyką i może potencjalnie doprowadzić do znacznego zmarnowania zasobów.

Takie złe praktyki są zazwyczaj rezultatem nieprawidłowych rad udzielanych nowicjuszom C++. W przypadku doświadczonych programistów można to jednak wyjaśnić tendencją do niezauważania przesłanek i kosztów używania pewnych funkcji danego języka. Prowadzi to z kolei do stosowania nieprawidłowego poziomu abstrakcji w procesie kodowania, co prowadzi do powstania złego kodu. 

Z drugiej strony, robienie tego celowo może sugerować przesadną wiarę w defensywne programowanie, która może spowodować znaczny spadek wydajności oraz bałagan w kodzie. 

Gdy badamy zalety i wady używania wirtualnych destruktorów, to najlepszą praktykę można streścić słowami Scotta Meyersa z książki C++. 50 efektywnych sposobów na…

„Polimorficzne klasy bazowe powinny zadeklarować wirtualne destruktory. Jeśli dana klasa ma jakieś funkcje wirtualne, powinna ona też mieć wirtualny destruktor. Klasy niezaprojektowane jako bazowe lub do użycia polimorficznego nie powinny deklarować wirtualnych destruktorów.” (str. 58)


Źródła

  • ISO/IEC. (2017). ISO International Standard ISO/IEC 14882:2017 – Working Draft, Programming Language C++. [N4659]. Geneva, Switzerland: International Organization for Standardization (ISO). Uzyskano stąd
  • “C++. 50 efektywnych sposobów na…” Trzecia Edycja. Scott Meyers