Pourquoi la classe de base doit-elle avoir un destructeur virtuel ici si la classe dérivée n'alloue aucune mémoire dynamique brute?

12

Le code suivant provoque une fuite de mémoire:

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

Cela n'avait pas beaucoup de sens pour moi, car la classe dérivée n'alloue aucune mémoire dynamique brute et unique_ptr se désalloue. J'obtiens que le destructeur implicite de cette base de classe est appelé au lieu de dérivé, mais je ne comprends pas pourquoi c'est un problème ici. Si je devais écrire un destructeur explicite pour dérivé, je n'écrirais rien pour vec.

Ignorance inertielle
la source
4
Vous supposez qu'un destructeur n'existe que s'il est écrit manuellement; cette hypothèse est erronée: le langage fournit un ~derived()qui délègue au destructeur de vec. Alternativement, vous supposez que unique_ptr<base> ptconnaîtrait le destructeur dérivé. Sans méthode virtuelle, cela ne peut pas être le cas. Alors qu'un unique_ptr peut recevoir une fonction de suppression qui est un paramètre de modèle sans aucune représentation d'exécution, et cette fonctionnalité n'est d'aucune utilité pour ce code.
amon
Pouvons-nous mettre des accolades sur la même ligne pour raccourcir le code? Maintenant, je dois faire défiler.
laike9m

Réponses:

14

Lorsque le compilateur va exécuter l'implicite à l' delete _ptr;intérieur du unique_ptrdestructeur de (où _ptrest le pointeur stocké dans le unique_ptr), il sait précisément deux choses:

  1. L'adresse de l'objet à supprimer.
  2. Le type de pointeur qui _ptrest. Puisque le pointeur est dedans unique_ptr<base>, cela signifie qu'il _ptrest du type base*.

C'est tout ce que le compilateur sait. Donc, étant donné qu'il supprime un objet de type base, il invoquera ~base().

Alors ... où est la partie où il détruit l' derviedobjet vers lequel il pointe réellement ? Parce que si le compilateur ne sait pas qu'il détruit un derived, alors il ne sait pas du tout qu'il derived::vec existe , encore moins qu'il doit être détruit. Vous avez donc cassé l'objet en en laissant la moitié non détruite.

Le compilateur ne peut pas supposer que tout base*être détruit est en fait un derived*; après tout, il pourrait y avoir n'importe quel nombre de classes dérivées base. Comment pourrait-il savoir à quel type ce particulier base*pointe réellement?

Ce que le compilateur doit faire est de trouver le destructeur correct à appeler (oui, deriveda un destructeur. Sauf si vous êtes = deleteun destructeur, chaque classe a un destructeur, que vous en écriviez un ou non). Pour ce faire, il devra utiliser certaines informations stockées dans basepour obtenir la bonne adresse du code destructeur à invoquer, informations définies par le constructeur de la classe réelle. Il doit ensuite utiliser ces informations pour convertir le base*en un pointeur vers l'adresse de la derivedclasse correspondante (qui peut ou non être à une adresse différente. Oui, vraiment). Et puis il peut invoquer ce destructeur.

Ce mécanisme que je viens de décrire? Il est communément appelé «envoi virtuel»: c'est-à-dire cette chose qui se produit chaque fois que vous appelez une fonction marquée virtuallorsque vous avez un pointeur / une référence vers une classe de base.

Si vous souhaitez appeler une fonction de classe dérivée alors que tout ce que vous avez est un pointeur / référence de classe de base, cette fonction doit être déclarée virtual. Les destructeurs ne sont fondamentalement pas différents à cet égard.

Nicol Bolas
la source
0

Héritage

Le point d'héritage est de partager une interface et un protocole communs entre de nombreuses implémentations différentes de sorte qu'une instance d'une classe dérivée puisse être traitée de manière identique à toute autre instance de tout autre type dérivé.

En C ++, l'héritage apporte également des détails d'implémentation, le marquage (ou non) du destructeur comme virtuel est l'un de ces détails d'implémentation.

Liaison de fonction

Désormais, lorsqu'une fonction, ou l'un de ses cas particuliers, comme un constructeur ou un destructeur, est appelée, le compilateur doit choisir l'implémentation de la fonction voulue. Il doit ensuite générer un code machine conforme à cette intention.

La façon la plus simple de procéder consiste à sélectionner la fonction au moment de la compilation et à émettre juste assez de code machine pour que, quelles que soient les valeurs, lorsque ce morceau de code s'exécute, il exécute toujours le code de la fonction. Cela fonctionne très bien, sauf pour l'héritage.

Si nous avons une classe de base avec une fonction (pourrait être n'importe quelle fonction, y compris le constructeur ou le destructeur) et que votre code appelle une fonction dessus, qu'est-ce que cela signifie?

En prenant votre exemple, si vous avez appelé initialize_vector()le compilateur doit décider si vous vouliez vraiment appeler l'implémentation trouvée dans Baseou l'implémentation trouvée dans Derived. Il y a deux façons de décider:

  1. La première consiste à décider que parce que vous avez appelé à partir d'un Basetype, vous vouliez dire l'implémentation dans Base.
  2. La seconde consiste à décider que, car le type d'exécution de la valeur stockée dans la Basevaleur typée pourrait être Base, ou Derivedque la décision quant à l'appel à effectuer, doit être prise lors de l'exécution lors de l'appel (à chaque appel).

À ce stade, le compilateur est confus, les deux options sont également valides. C'est quand virtualvient le mix. Lorsque ce mot clé est présent, le compilateur choisit l'option 2 retardant la décision entre toutes les implémentations possibles jusqu'à ce que le code s'exécute avec une valeur réelle. Lorsque ce mot clé est absent, le compilateur choisit l'option 1 car c'est le comportement par ailleurs normal.

Le compilateur peut toujours choisir l'option 1 dans le cas d'un appel de fonction virtuelle. Mais seulement si cela peut prouver que c'est toujours le cas.

Constructeurs et destructeurs

Alors pourquoi ne spécifions-nous pas un constructeur virtuel?

Plus intuitivement, comment le compilateur choisirait-il entre des implémentations identiques du constructeur pour Derivedet Derived2? C'est assez simple, ça ne peut pas. Il n'y a aucune valeur préexistante à partir de laquelle le compilateur peut apprendre ce qui était réellement prévu. Il n'y a pas de valeur préexistante car c'est le travail du constructeur.

Alors, pourquoi devons-nous spécifier un destructeur virtuel?

Plus intuitivement, comment le compilateur choisirait-il entre les implémentations pour Baseet Derived? Ce ne sont que des appels de fonction, donc le comportement de l'appel de fonction se produit. Sans destructeur virtuel déclaré, le compilateur décidera de se lier directement au Basedestructeur, quel que soit le type d'exécution des valeurs.

Dans de nombreux compilateurs, si le dérivé ne déclare aucun membre de données, ni hérite d'autres types, le comportement dans le ~Base()sera approprié, mais il n'est pas garanti. Cela fonctionnerait purement par hasard, un peu comme se tenir devant un lance-flammes qui n'avait pas encore été allumé. Tu vas bien pendant un moment.

La seule façon correcte de déclarer n'importe quel type de base ou d'interface en C ++ est de déclarer un destructeur virtuel, de sorte que le destructeur correct soit appelé pour une instance donnée de la hiérarchie de types de ce type. Cela permet à la fonction ayant la plus grande connaissance de l'instance de nettoyer cette instance correctement.

Kain0_0
la source