Perché la classe base deve avere un distruttore virtuale qui se la classe derivata non alloca memoria dinamica grezza?


12

Il codice seguente provoca una perdita di memoria:

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

Non aveva molto senso per me, dato che la classe derivata non alloca memoria dinamica non elaborata e unique_ptr si sposta da sola. Ho capito che il distruttore implicito di quella classe viene chiamato invece di quello derivato, ma non capisco perché sia ​​un problema qui. Se dovessi scrivere un distruttore esplicito per derivato, non scriverei nulla per VEC.


4
Supponete che un distruttore esiste solo se scritto manualmente; questa ipotesi è errata: il linguaggio fornisce un ~derived()delegato al distruttore di VEC. In alternativa, stai assumendo che unique_ptr<base> ptconoscerebbe il distruttore derivato. Senza un metodo virtuale, questo non può essere il caso. Mentre a unique_ptr può essere assegnata una funzione di eliminazione che è un parametro modello senza alcuna rappresentazione di runtime e tale funzione non è di alcuna utilità per questo codice.
amon,

Possiamo mettere parentesi graffe sulla stessa riga per ridurre il codice? Ora devo scorrere.
laike9m,

Risposte:


14

Quando il compilatore esegue l'esecuzione implicita delete _ptr;all'interno del unique_ptrdistruttore (dove si _ptrtrova il puntatore memorizzato in unique_ptr), conosce esattamente due cose:

  1. L'indirizzo dell'oggetto da cancellare.
  2. Il tipo di puntatore che _ptrè. Poiché il puntatore è attivo unique_ptr<base>, significa che _ptrè del tipo base*.

Questo è tutto ciò che il compilatore conosce. Quindi, dato che sta eliminando un oggetto di tipo base, invocherà ~base().

Quindi ... dov'è la parte in cui distrugge l' derviedoggetto a cui effettivamente punta? Perché se il compilatore non sa che sta distruggendo un derived, allora non sa affatto che derived::vec esiste , figuriamoci che dovrebbe essere distrutto. Quindi hai rotto l'oggetto lasciandone la metà distrutta.

Il compilatore non può presumere che qualsiasi base*essere distrutto sia effettivamente un derived*; dopo tutto, potrebbe esserci un numero qualsiasi di classi derivate base. Come potrebbe sapere a quale tipo questo particolare base*punta effettivamente?

Quello che il compilatore deve fare è capire il distruttore corretto da chiamare (sì, derivedha un distruttore. A meno che tu non sia = deleteun distruttore, ogni classe ha un distruttore, che tu ne scriva o meno). Per fare ciò, dovrà utilizzare alcune informazioni archiviate baseper ottenere l'indirizzo corretto del codice distruttore da invocare, informazioni che sono impostate dal costruttore della classe effettiva. Quindi deve usare queste informazioni per convertire il base*puntatore a in un indirizzo della derivedclasse corrispondente (che può essere o meno a un indirizzo diverso. Sì, davvero). E poi può invocare quel distruttore.

Quel meccanismo che ho appena descritto? Viene comunemente chiamato "invio virtuale": ovvero, ciò che accade ogni volta che si chiama una funzione contrassegnata virtualquando si dispone di un puntatore / riferimento a una classe base.

Se si desidera chiamare una funzione di classe derivata quando tutto ciò che si possiede è un puntatore / riferimento di classe base, tale funzione deve essere dichiarata virtual. I distruttori non sono sostanzialmente diversi in questo senso.


0

Eredità

L'intero punto dell'ereditarietà è condividere un'interfaccia e un protocollo comuni tra molte diverse implementazioni in modo tale che un'istanza di una classe derivata possa essere trattata in modo identico a qualsiasi altra istanza da qualsiasi altro tipo derivato.

In C ++ l'eredità porta anche con sé i dettagli dell'implementazione, contrassegnare (o non contrassegnare) il distruttore come virtuale è uno di questi dettagli di implementazione.

Funzione Binding

Ora quando viene chiamata una funzione, o uno dei suoi casi speciali come un costruttore o un distruttore, il compilatore deve scegliere quale implementazione di funzione intendesse. Quindi deve generare il codice macchina che segue questa intenzione.

Il modo più semplice per farlo sarebbe selezionare la funzione in fase di compilazione ed emettere il codice macchina in modo tale che, indipendentemente da qualsiasi valore, quando quel pezzo di codice viene eseguito, esegue sempre il codice per la funzione. Funziona benissimo tranne che per l'eredità.

Se abbiamo una classe base con una funzione (potrebbe essere una qualsiasi funzione, incluso il costruttore o il distruttore) e il tuo codice chiama una funzione su di essa, cosa significa?

Prendendo dal tuo esempio, se hai chiamato initialize_vector()il compilatore devi decidere se intendevi davvero chiamare l'implementazione trovata in Base, o l'implementazione trovata in Derived. Esistono due modi per decidere questo:

  1. Il primo è decidere che, poiché hai chiamato da un Basetipo, intendevi l'implementazione in Base.
  2. Il secondo è decidere che, poiché Basepotrebbe essere il tipo di runtime del valore memorizzato nel valore digitato Base, o Derivedche la decisione su quale chiamata effettuare, deve essere presa in fase di esecuzione quando viene chiamata (ogni volta che viene chiamata).

Il compilatore a questo punto è confuso, entrambe le opzioni sono ugualmente valide. Questo è quando virtualentra nel mix. Quando questa parola chiave è presente, il compilatore seleziona l'opzione 2 ritardando la decisione tra tutte le possibili implementazioni fino a quando il codice viene eseguito con un valore reale. Quando questa parola chiave è assente, il compilatore seleziona l'opzione 1 perché è il comportamento altrimenti normale.

Il compilatore potrebbe comunque selezionare l'opzione 1 nel caso di una chiamata di funzione virtuale. Ma solo se può dimostrare che è sempre così.

Costruttori e distruttori

Quindi perché non specifichiamo un costruttore virtuale?

Più intuitivamente come sceglierebbe il compilatore tra implementazioni identiche del costruttore per Derivede Derived2? Questo è piuttosto semplice, non può. Non esiste un valore preesistente da cui il compilatore può apprendere ciò che era realmente previsto. Non esiste alcun valore preesistente perché quello è il lavoro del costruttore.

Quindi perché dobbiamo specificare un distruttore virtuale?

Più intuitivamente come sceglierebbe il compilatore tra le implementazioni per Basee Derived? Sono solo chiamate di funzione, quindi si verifica il comportamento della chiamata di funzione. Senza un distruttore virtuale dichiarato, il compilatore deciderà di collegarsi direttamente al Basedistruttore indipendentemente dal tipo di runtime dei valori.

In molti compilatori, se il derivato non dichiara alcun membro dei dati, né eredita da altri tipi, il comportamento nel ~Base()sarà adatto, ma non è garantito. Funzionava puramente per caso, proprio come stare di fronte a un lanciafiamme che non era ancora stato acceso. Stai bene per un po '.

L'unico modo corretto per dichiarare qualsiasi tipo di base o interfaccia in C ++ è dichiarare un distruttore virtuale, in modo che venga chiamato il distruttore corretto per ogni istanza data della gerarchia di tipi di quel tipo. Ciò consente alla funzione con la maggior conoscenza dell'istanza di ripulirla correttamente.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.