Jaki jest koszt korzystania z wirtualnych niszczycieli, jeśli go używam, nawet jeśli nie jest potrzebny?
Koszt wprowadzenia dowolnej funkcji wirtualnej do klasy (odziedziczonej lub części definicji klasy) jest prawdopodobnie bardzo stromy (lub nie zależy od obiektu) początkowy koszt wirtualnego wskaźnika przechowywanego na obiekt, tak jak:
struct Integer
{
virtual ~Integer() {}
int value;
};
W takim przypadku koszt pamięci jest stosunkowo ogromny. Rzeczywisty rozmiar pamięci instancji klasy będzie teraz często wyglądał następująco na architekturach 64-bitowych:
struct Integer
{
// 8 byte vptr overhead
int value; // 4 bytes
// typically 4 more bytes of padding for alignment of vptr
};
Łącznie dla tej Integer
klasy jest 16 bajtów, w przeciwieństwie do zaledwie 4 bajtów. Jeśli przechowujemy milion z nich w macierzy, otrzymamy 16 megabajtów pamięci: dwa razy większy niż typowy 8 MB pamięci podręcznej procesora L3, a powtarzanie tej macierzy wielokrotnie może być wielokrotnie wolniejsze niż ekwiwalent 4 megabajtów bez wirtualnego wskaźnika w wyniku dodatkowych braków pamięci podręcznej i błędów strony.
Koszt wirtualnego wskaźnika na obiekt nie wzrasta jednak wraz z większą liczbą funkcji wirtualnych. Możesz mieć 100 wirtualnych funkcji członka w klasie, a narzut na instancję nadal byłby pojedynczym wirtualnym wskaźnikiem.
Wirtualny wskaźnik jest zwykle bardziej bezpośrednim problemem z ogólnego punktu widzenia. Jednak oprócz wirtualnego wskaźnika na instancję jest koszt na klasę. Każda klasa z funkcjami wirtualnymi generuje vtable
w pamięci, która przechowuje adresy funkcji, które powinna faktycznie wywoływać (dyspozycja wirtualna / dynamiczna), gdy wykonywane jest wywołanie funkcji wirtualnej. vptr
Przechowywane na przykład wtedy punkty do tej klasy specyficznych vtable
. Narzut ten zwykle stanowi mniejszy problem, ale może nadmuchać rozmiar pliku binarnego i dodać trochę kosztów w czasie wykonywania, jeśli koszty te zostały niepotrzebnie zapłacone za tysiąc klas w złożonej bazie kodu, np. Ta vtable
strona kosztu faktycznie rośnie proporcjonalnie z większą liczbą i więcej funkcji wirtualnych w miksie.
Programiści Java pracujący w obszarach krytycznych pod względem wydajności bardzo dobrze rozumieją tego rodzaju koszty ogólne (choć często opisywane w kontekście boksu), ponieważ typ zdefiniowany przez użytkownika Java domyślnie dziedziczy z centralnej object
klasy bazowej, a wszystkie funkcje w Javie są domyślnie wirtualne (możliwe do zastąpienia ) z natury, chyba że zaznaczono inaczej. W rezultacie Java Integer
również wymaga 16 bajtów pamięci na platformach 64-bitowych ze względu na vptr
metadane związane ze stylem na instancję i zazwyczaj nie jest możliwe zawinięcie w Javę czegoś takiego jak pojedyncza int
klasa bez płacenia za środowisko uruchomieniowe koszt wydajności.
Zatem pytanie brzmi: dlaczego c ++ domyślnie nie ustawia wirtualnych wszystkich destruktorów?
C ++ naprawdę faworyzuje wydajność dzięki podejściu typu „pay as you go”, a także wciąż wielu projektom opartym na sprzęcie metalowym odziedziczonym po C. Nie chce niepotrzebnie uwzględniać kosztów ogólnych związanych z generowaniem vtable i dynamicznym wysyłaniem każda zaangażowana klasa / instancja. Jeśli wydajność nie jest jednym z kluczowych powodów, dla których używasz języka takiego jak C ++, możesz odnieść większe korzyści z innych języków programowania, ponieważ duża część języka C ++ jest mniej bezpieczna i trudniejsza, niż byłoby to możliwe w przypadku często występującej wydajności kluczowy powód, aby faworyzować taki projekt.
Kiedy NIE muszę używać wirtualnych niszczycieli?
Całkiem często. Jeśli klasa nie jest zaprojektowana do dziedziczenia, to nie potrzebuje wirtualnego destruktora i ostatecznie zapłaciłby tylko potencjalnie duży koszt za coś, czego nie potrzebuje. Podobnie, nawet jeśli klasa jest zaprojektowana do dziedziczenia, ale nigdy nie usuwasz instancji podtypu za pomocą wskaźnika bazowego, to również nie wymaga wirtualnego destruktora. W takim przypadku bezpieczną praktyką jest zdefiniowanie chronionego niewirtualnego destruktora, takiego jak:
class BaseClass
{
protected:
// Disallow deleting/destroying subclass objects through `BaseClass*`.
~BaseClass() {}
};
W takim przypadku NIE powinienem używać wirtualnych niszczycieli?
W rzeczywistości łatwiej jest pokryć, kiedy powinieneś używać wirtualnych niszczycieli. Dość często znacznie więcej klas w twojej bazie kodu nie będzie zaprojektowanych do dziedziczenia.
std::vector
, na przykład, nie jest przeznaczony do dziedziczenia i zwykle nie powinien być dziedziczony (bardzo chwiejny projekt), ponieważ będzie to podatne na ten problem usuwania wskaźnika podstawowego ( std::vector
umyślnie omija wirtualny destruktor) oprócz niezręcznych problemów z wycinaniem obiektów , jeśli twój klasa pochodna dodaje dowolny nowy stan.
Ogólnie rzecz biorąc, dziedziczona klasa powinna mieć albo publiczny wirtualny niszczyciel, albo chroniony, niewirtualny. Z C++ Coding Standards
rozdziału 50:
50. Upublicznij niszczyciele klasy podstawowej jako publiczne i wirtualne lub chronione i niewirtualne. Aby usunąć lub nie usunąć; to jest pytanie: Jeśli usunięcie wskaźnika przez bazę do bazy powinno być dozwolone, niszczyciel bazy musi być publiczny i wirtualny. W przeciwnym razie powinien być chroniony i niewirusowy.
Jedną z rzeczy, które C ++ zwykle podkreśla w sposób dorozumiany (ponieważ projekty stają się naprawdę kruche i niewygodne, a być może nawet niebezpieczne inaczej) jest idea, że dziedziczenie nie jest mechanizmem zaprojektowanym do późniejszej refleksji. Jest to mechanizm rozszerzalności z uwzględnieniem polimorfizmu, ale taki, który wymaga przewidywania, gdzie jest potrzebna rozszerzalność. W rezultacie twoje klasy podstawowe powinny być zaprojektowane jako pierwiastki hierarchii dziedziczenia z góry, a nie coś, co odziedziczysz później, bez uprzedzenia.
W tych przypadkach, w których po prostu chcesz odziedziczyć do ponownego użycia istniejącego kodu, często zaleca się skład (Zasada złożonego ponownego użycia).