Dlaczego klasa podstawowa musi mieć tutaj wirtualny destruktor, skoro klasa pochodna nie przydziela surowej pamięci dynamicznej?

12

Poniższy kod powoduje wyciek pamięci:

#include <iostream>
#include <memory>
#include <vector>

using namespace std;

class base
{
    void virtual initialize_vector() = 0;
};

class derived : public base
{
private:
    vector<int> vec;

public:
    derived()
    {
        initialize_vector();
    }

    void initialize_vector()
    {
        for (int i = 0; i < 1000000; i++)
        {
            vec.push_back(i);
        }
    }
};

int main()
{
    for (int i = 0; i < 100000; i++)
    {
        unique_ptr<base> pt = make_unique<derived>();
    }
}

Nie miało to dla mnie większego sensu, ponieważ pochodne klasy nie przydzielają surowej pamięci dynamicznej, a Unique_ptr sama się zwalnia. Rozumiem, że wywoływany jest domyślny destruktor tej klasy zamiast pochodnych, ale nie rozumiem, dlaczego jest to problem. Gdybym miał napisać wyraźny destruktor dla pochodnych, nie napisałbym niczego dla vec.

Ignorancja bezwładności
źródło
4
Zakładasz, że destruktor istnieje tylko wtedy, gdy jest napisany ręcznie; to założenie jest błędne: język zapewnia ~derived()delegację do destruktora vec. Alternatywnie zakładasz, unique_ptr<base> ptże poznasz pochodną destruktor. Bez metody wirtualnej tak nie jest. Podczas gdy unikatowi można przypisać funkcję usuwania, która jest parametrem szablonu bez reprezentacji środowiska wykonawczego, a funkcja ta nie ma zastosowania w tym kodzie.
amon
Czy możemy umieścić nawiasy klamrowe w tej samej linii, aby skrócić kod? Teraz muszę przewijać.
laike9m

Odpowiedzi:

14

Kiedy kompilator wykonuje niejawne delete _ptr;wnętrze unique_ptrdestruktora (gdzie _ptrjest przechowywany wskaźnik unique_ptr), wie dokładnie dwie rzeczy:

  1. Adres obiektu do usunięcia.
  2. Typ wskaźnika, który _ptrjest. Ponieważ wskaźnik jest w unique_ptr<base>środku, oznacza _ptrto , że jest tego typu base*.

To wszystko, co kompilator wie. Zatem, biorąc pod uwagę, że usuwa obiekt typu base, wywoła ~base().

Więc ... gdzie jest ta część, gdzie niszczy derviedobiekt, że faktycznie wskazuje? Ponieważ jeśli kompilator nie wie, że niszczy a derived, to wcale nie wie derived::vec , że istnieje , nie mówiąc już o tym, że należy go zniszczyć. Więc złamałeś obiekt, pozostawiając połowę z niego niezniszczonym.

Kompilator nie może zakładać, że jakiekolwiek base*zniszczenie jest w rzeczywistości derived*; w końcu może istnieć dowolna liczba klas pochodzących z base. Skąd miałby wiedzieć, na jaki typ ten konkretnie base*wskazuje?

Kompilator musi znaleźć odpowiedni wywoływacz Destruktor (tak, derivedma Destruktor. Jeśli nie jesteś = deleteDestruktorem, każda klasa ma Destruktor, niezależnie od tego, czy napiszesz jeden, czy nie). Aby to zrobić, będzie musiał użyć niektórych informacji przechowywanych w basecelu uzyskania właściwego adresu kodu destruktora do wywołania, informacji ustawionej przez konstruktora faktycznej klasy. Następnie musi użyć tych informacji do przekonwertowania base*wskaźnika na adres odpowiedniej derivedklasy (która może, ale nie musi mieć inny adres. Tak, naprawdę). A potem może wywołać ten destruktor.

Ten mechanizm, który właśnie opisałem? Jest to powszechnie nazywane „wirtualną wysyłką”: inaczej dzieje się to za każdym razem, gdy wywołujesz funkcję oznaczoną, virtualgdy masz wskaźnik / odwołanie do klasy bazowej.

Jeśli chcesz wywołać pochodną funkcję klasy, gdy wszystko, co masz, to wskaźnik / referencja klasy bazowej, funkcja ta musi zostać zadeklarowana virtual. Niszczyciele zasadniczo nie różnią się pod tym względem.

Nicol Bolas
źródło
0

Dziedzictwo

Głównym celem dziedziczenia jest współdzielenie wspólnego interfejsu i protokołu między wieloma różnymi implementacjami, tak że instancja klasy pochodnej może być traktowana identycznie jak każda inna instancja z dowolnego innego typu pochodnego.

W C ++ dziedziczenie niesie ze sobą również szczegóły implementacji, oznaczenie (lub brak oznaczenia) destruktora jako wirtualnego jest jednym z takich szczegółów implementacji.

Wiązanie funkcji

Teraz, gdy wywoływana jest funkcja lub którykolwiek z jej specjalnych przypadków, takich jak konstruktor lub destruktor, kompilator musi wybrać, która implementacja funkcji była przeznaczona. Następnie musi wygenerować kod maszynowy zgodny z tą intencją.

Najprostszym sposobem jest wybranie funkcji w czasie kompilacji i wysłanie wystarczającej ilości kodu maszynowego, aby niezależnie od jakichkolwiek wartości, podczas wykonywania tego fragmentu kodu, zawsze uruchamiał kod funkcji. Działa to świetnie, z wyjątkiem dziedziczenia.

Jeśli mamy klasę podstawową z funkcją (może to być dowolna funkcja, w tym konstruktor lub destruktor), a kod wywołuje na niej funkcję, co to oznacza?

Biorąc z twojego przykładu, jeśli initialize_vector()wywołałeś kompilator, musisz zdecydować, czy naprawdę chciałeś wywołać implementację znalezioną w Baselub implementację znalezioną w Derived. Istnieją dwa sposoby, aby to ustalić:

  1. Pierwszą jest decyzja, że ​​ponieważ zadzwoniłeś z Basetypu, miałeś na myśli implementację w Base.
  2. Drugi polega na podjęciu decyzji, ponieważ ponieważ typem wykonawczym wartości przechowywanej w Basewpisanej wartości może być Base, lub Derivedże decyzja o tym, które wezwanie do wykonania, musi zostać podjęta w czasie wykonywania po wywołaniu (za każdym razem, gdy jest wywoływana).

Kompilator w tym momencie jest zdezorientowany, obie opcje są jednakowo prawidłowe. To jest, kiedy virtualwchodzi w skład miksu. Gdy to słowo kluczowe jest obecne, kompilator wybiera opcję 2 opóźniającą decyzję między wszystkimi możliwymi implementacjami, aż kod będzie działał z rzeczywistą wartością. W przypadku braku tego słowa kluczowego kompilator wybiera opcję 1, ponieważ jest to normalne zachowanie.

Kompilator może nadal wybrać opcję 1 w przypadku wirtualnego wywołania funkcji. Ale tylko jeśli może udowodnić, że tak jest zawsze.

Konstruktory i niszczyciele

Dlaczego więc nie określamy wirtualnego konstruktora?

Bardziej intuicyjnie, w jaki sposób kompilator wybierałby między identycznymi implementacjami konstruktora dla Derivedi Derived2? To jest dość proste, nie może. Nie ma żadnej wcześniejszej wartości, na podstawie której kompilator mógłby dowiedzieć się, co naprawdę było zamierzone. Nie ma wcześniejszej wartości, ponieważ jest to zadanie konstruktora.

Dlaczego więc musimy określić wirtualny destruktor?

Bardziej intuicyjnie, w jaki sposób kompilator wybierałby implementacje dla Basei Derived? Są to tylko wywołania funkcji, więc zachowuje się zachowanie wywołania funkcji. Bez deklarowanego wirtualnego destruktora kompilator zdecyduje się na bezpośrednie powiązanie z Basedestruktorem, niezależnie od typu środowiska uruchomieniowego wartości.

W wielu kompilatorach, jeśli pochodne nie deklarują żadnych elementów danych ani nie dziedziczą po innych typach, zachowanie w ~Base()będzie odpowiednie, ale nie jest gwarantowane. Działałoby to wyłącznie przypadkowo, podobnie jak stanie przed miotaczem ognia, który nie został jeszcze zapalony. Przez jakiś czas nic ci nie jest.

Jedynym prawidłowym sposobem zadeklarowania dowolnego typu bazowego lub interfejsu w C ++ jest zadeklarowanie wirtualnego destruktora, aby wywołać prawidłowy destruktor dla dowolnej instancji hierarchii typów tego typu. Dzięki temu funkcja z największą wiedzą o instancji może poprawnie wyczyścić tę instancję.

Kain0_0
źródło