Warum muss die Basisklasse hier einen virtuellen Destruktor haben, wenn die abgeleitete Klasse keinen dynamischen Rohspeicher zuweist?

12

Der folgende Code verursacht einen Speicherverlust:

#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>();
    }
}

Es ergab für mich keinen großen Sinn, da die abgeleitete Klasse keinen dynamischen Rohspeicher zuweist und unique_ptr sich selbst freigibt. Ich verstehe, dass der implizite Destruktor dieser Klassenbasis anstelle des abgeleiteten aufgerufen wird, aber ich verstehe nicht, warum das hier ein Problem ist. Wenn ich einen expliziten Destruktor für derivative schreiben würde, würde ich nichts für vec schreiben.

Inertial Ignorance
quelle
4
Sie gehen davon aus, dass ein Destruktor nur dann existiert, wenn er manuell geschrieben wurde. diese annahme ist fehlerhaft: die sprache bietet eine ~derived(), die an vecs destruktor delegiert. Alternativ nehmen Sie an, dass Sie unique_ptr<base> ptden abgeleiteten Destruktor kennen. Ohne eine virtuelle Methode kann dies nicht der Fall sein. Einem unique_ptr kann zwar eine Löschfunktion zugewiesen werden, die ein Vorlagenparameter ohne Laufzeitdarstellung ist, und diese Funktion ist für diesen Code nicht von Nutzen.
amon
Können wir Klammern in dieselbe Zeile setzen, um den Code zu verkürzen? Jetzt muss ich scrollen.
laike9m

Antworten:

14

Wenn der Compiler das Implizite delete _ptr;innerhalb des unique_ptrDestruktors des Compilers ausführt (wo _ptrder Zeiger in gespeichert ist unique_ptr), weiß er genau zwei Dinge:

  1. Die Adresse des zu löschenden Objekts.
  2. Die Art des Zeigers, der _ptrist. Da der Zeiger in ist unique_ptr<base>, ist das Mittel _ptrvom Typ base*.

Dies ist alles, was der Compiler weiß. Wenn also ein Objekt vom Typ gelöscht wird base, wird es aufgerufen ~base().

Also ... wo ist der Teil, in dem es das derviedObjekt zerstört, auf das es tatsächlich zeigt? Denn wenn der Compiler nicht weiß, dass er a zerstört derived, dann weiß er überhaupt nicht, dass es überhaupt derived::vec existiert , geschweige denn, dass es zerstört werden sollte. Sie haben das Objekt zerbrochen, indem Sie die Hälfte davon unzerstört gelassen haben.

Der Compiler kann nicht davon ausgehen , dass es sich bei base*einer Zerstörung tatsächlich um eine handelt derived*. es könnte doch eine beliebige Anzahl von Klassen geben, von denen abgeleitet ist base. Wie würde es wissen, auf welchen Typ diese bestimmte base*tatsächlich verweist?

Was der Compiler tun muss, ist herauszufinden, welcher Destruktor aufzurufen ist (ja, er derivedhat einen Destruktor. Sofern Sie = deletekein Destruktor sind, hat jede Klasse einen Destruktor, unabhängig davon, ob Sie einen schreiben oder nicht). Dazu müssen einige in gespeicherte Informationen verwendet werden base, um die richtige Adresse des Destruktorcodes zum Aufrufen abzurufen. Diese Informationen werden vom Konstruktor der tatsächlichen Klasse festgelegt. Dann muss es diese Informationen verwenden, um den base*Zeiger in einen Zeiger auf die Adresse der entsprechenden derivedKlasse umzuwandeln (die sich möglicherweise an einer anderen Adresse befindet oder nicht. Ja, wirklich). Und dann kann es diesen Destruktor aufrufen.

Welchen Mechanismus habe ich gerade beschrieben? Es wird im Allgemeinen als "virtueller Versand" bezeichnet. Dies ist das, was passiert, wenn Sie eine Funktion aufrufen, die markiert ist, virtualwenn Sie einen Zeiger / Verweis auf eine Basisklasse haben.

Wenn Sie eine abgeleitete Klassenfunktion aufrufen möchten, obwohl Sie nur einen Basisklassenzeiger / eine Basisklassenreferenz haben, muss diese Funktion deklariert werden virtual. Destruktoren sind diesbezüglich grundsätzlich nicht anders.

Nicol Bolas
quelle
0

Erbe

Der Sinn der Vererbung besteht darin, eine gemeinsame Schnittstelle und ein gemeinsames Protokoll für viele verschiedene Implementierungen zu verwenden, sodass eine Instanz einer abgeleiteten Klasse mit jeder anderen Instanz eines anderen abgeleiteten Typs identisch behandelt werden kann.

In C ++ bringt die Vererbung auch Implementierungsdetails mit sich. Das Markieren (oder Nicht-Markieren) des Destruktors als virtuell ist ein solches Implementierungsdetail.

Funktionsbindung

Wenn nun eine Funktion oder einer ihrer Spezialfälle wie ein Konstruktor oder Destruktor aufgerufen wird, muss der Compiler auswählen, welche Funktionsimplementierung gemeint war. Dann muss Maschinencode generiert werden, der dieser Absicht folgt.

Dies funktioniert am einfachsten, wenn Sie die Funktion zur Kompilierungszeit auswählen und nur so viel Maschinencode ausgeben, dass bei der Ausführung dieses Codeteils unabhängig von den Werten immer der Code für die Funktion ausgeführt wird. Dies funktioniert hervorragend, außer für die Vererbung.

Wenn wir eine Basisklasse mit einer Funktion haben (kann jede Funktion sein, einschließlich des Konstruktors oder Destruktors) und Ihr Code eine Funktion darauf aufruft, was bedeutet das?

Wenn Sie initialize_vector()den Compiler aufgerufen haben, müssen Sie anhand Ihres Beispiels entscheiden, ob Sie die in gefundene Implementierung Baseoder die in gefundene Implementierung wirklich aufrufen wollten Derived. Es gibt zwei Möglichkeiten, dies zu entscheiden:

  1. Die erste ist zu entscheiden, dass Sie Basedie Implementierung in gemeint haben , weil Sie von einem Typ aufgerufen haben Base.
  2. Die zweite BaseMöglichkeit besteht darin Base, zu entscheiden, dass der Laufzeit-Typ des in dem eingegebenen Wert gespeicherten Werts oder Deriveddie Entscheidung, welcher Aufruf zu treffen ist, zur Laufzeit beim Aufruf (jedes Mal, wenn er aufgerufen wird) getroffen werden muss.

Der Compiler ist an dieser Stelle verwirrt, beide Optionen sind gleichermaßen gültig. Dies ist, wenn virtualin die Mischung kommt. Wenn dieses Schlüsselwort vorhanden ist, wählt der Compiler Option 2 aus, um die Entscheidung zwischen allen möglichen Implementierungen zu verzögern, bis der Code mit einem realen Wert ausgeführt wird. Wenn dieses Schlüsselwort fehlt, wählt der Compiler Option 1 aus, da dies das ansonsten normale Verhalten ist.

Der Compiler wählt im Falle eines virtuellen Funktionsaufrufs möglicherweise immer noch Option 1 aus. Aber nur wenn es beweisen kann, dass dies immer der Fall ist.

Konstruktoren und Destruktoren

Warum geben wir keinen virtuellen Konstruktor an?

Intuitiver, wie würde der Compiler zwischen identischen Implementierungen des Konstruktors für Derivedund wählen Derived2? Das ist ziemlich einfach, es kann nicht. Es gibt keinen bereits vorhandenen Wert, anhand dessen der Compiler lernen kann, was wirklich beabsichtigt war. Es gibt keinen vorhandenen Wert, da dies die Aufgabe des Konstruktors ist.

Warum müssen wir also einen virtuellen Destruktor angeben?

Intuitiver, wie würde der Compiler zwischen Implementierungen für Baseund wählen Derived? Es handelt sich nur um Funktionsaufrufe, sodass das Funktionsaufrufverhalten auftritt. Ohne einen deklarierten virtuellen Destruktor entscheidet der Compiler, Baseunabhängig vom Laufzeittyp der Werte , direkt an den Destruktor zu binden .

Wenn in vielen Compilern die abgeleiteten keine Datenelemente deklarieren oder von anderen Typen erben, ist das Verhalten in den ~Base()geeignet, es wird jedoch nicht garantiert. Es würde rein zufällig funktionieren, ähnlich wie vor einem Flammenwerfer zu stehen, der noch nicht gezündet worden war. Dir geht es eine Weile gut.

Die einzig richtige Möglichkeit, einen Basis- oder Schnittstellentyp in C ++ zu deklarieren, besteht darin, einen virtuellen Destruktor zu deklarieren, sodass der richtige Destruktor für eine bestimmte Instanz der Typhierarchie dieses Typs aufgerufen wird. Auf diese Weise kann die Funktion mit den meisten Kenntnissen der Instanz diese Instanz ordnungsgemäß bereinigen.

Kain0_0
quelle