¿Por qué la clase base necesita tener un destructor virtual aquí si la clase derivada no asigna memoria dinámica sin procesar?

12

El siguiente código causa una pérdida de 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>();
    }
}

No tenía mucho sentido para mí, ya que la clase derivada no asigna memoria dinámica en bruto, y unique_ptr se desasigna solo. Recibo el destructor implícito de esa base de clase en lugar de derivado, pero no entiendo por qué eso es un problema aquí. Si tuviera que escribir un destructor explícito para derivado, no escribiría nada para vec.

Ignorancia inercial
fuente
44
Supone que solo existe un destructor si se escribe manualmente; esta suposición es defectuosa: el lenguaje proporciona un ~derived()que delega al destructor de vec. Alternativamente, está asumiendo que unique_ptr<base> ptconocería el destructor derivado. Sin un método virtual, este no puede ser el caso. Si bien un unique_ptr puede recibir una función de eliminación que es un parámetro de plantilla sin ninguna representación de tiempo de ejecución, y esa característica no sirve para este código.
amon
¿Podemos poner llaves en la misma línea para acortar el código? Ahora tengo que desplazarme.
laike9m

Respuestas:

14

Cuando el compilador ejecuta el implícito delete _ptr;dentro del unique_ptrdestructor (donde _ptrestá el puntero almacenado en el unique_ptr), sabe exactamente dos cosas:

  1. La dirección del objeto a eliminar.
  2. El tipo de puntero que _ptres. Como el puntero está adentro unique_ptr<base>, eso significa que _ptres del tipo base*.

Esto es todo lo que el compilador sabe. Entonces, dado que está eliminando un objeto de tipo base, invocará ~base().

Entonces ... ¿dónde está la parte donde destruye el derviedobjeto al que realmente apunta? Porque si el compilador no sabe que está destruyendo a derived, entonces no sabe que derived::vec existe en absoluto , y mucho menos que debe ser destruido. Entonces has roto el objeto dejando la mitad sin destruir.

El compilador no puede asumir que cualquier base*ser destruido es en realidad un derived*; después de todo, podría haber cualquier número de clases derivadas de base. ¿Cómo sabría a qué tipo base*apunta realmente este particular ?

Lo que el compilador tiene que hacer es averiguar el destructor correcto para llamar (sí, derivedtiene un destructor. A menos que sea = deleteun destructor, cada clase tiene un destructor, ya sea que escriba uno o no). Para hacer esto, tendrá que usar cierta información almacenada basepara obtener la dirección correcta del código destructor para invocar, información que establece el constructor de la clase real. Luego tiene que usar esta información para convertir el base*puntero a la dirección de la derivedclase correspondiente (que puede o no estar en una dirección diferente. Sí, realmente). Y luego puede invocar a ese destructor.

¿Ese mecanismo que acabo de describir? Es comúnmente llamado "despacho virtual": también conocido como lo que sucede cada vez que llama a una función marcada virtualcuando tiene un puntero / referencia a una clase base.

Si desea llamar a una función de clase derivada cuando todo lo que tiene es un puntero / referencia de clase base, esa función debe declararse virtual. Los destructores no son fundamentalmente diferentes a este respecto.

Nicol Bolas
fuente
0

Herencia

El punto principal de la herencia es compartir una interfaz y un protocolo comunes entre muchas implementaciones diferentes, de modo que una instancia de una clase derivada se pueda tratar de manera idéntica a cualquier otra instancia desde cualquier otro tipo derivado.

En la herencia de C ++ también trae detalles de implementación, marcar (o no marcar) el destructor como virtual es uno de esos detalles de implementación.

Enlace de funciones

Ahora, cuando se llama a una función, o cualquiera de sus casos especiales, como un constructor o destructor, el compilador debe elegir a qué implementación de función se refería. Entonces debe generar un código de máquina que siga esta intención.

La forma más sencilla de resolver esto sería seleccionar la función en tiempo de compilación y emitir el código de máquina suficiente para que, independientemente de cualquier valor, cuando se ejecute ese fragmento de código, siempre ejecute el código de la función. Esto funciona muy bien, excepto por la herencia.

Si tenemos una clase base con una función (podría ser cualquier función, incluido el constructor o el destructor) y su código llama a una función, ¿qué significa esto?

Tomando de su ejemplo, si llamó initialize_vector()al compilador tiene que decidir si realmente quiso llamar a la implementación que se encuentra Base, o la implementación que se encuentra en Derived. Hay dos formas de decidir esto:

  1. El primero es decidir que, debido a que llamó desde un Basetipo, quiso decir la implementación en Base.
  2. El segundo es decidir que, debido a que el tipo de tiempo de ejecución del valor almacenado en el Basevalor escrito podría ser Base, o Derivedque la decisión sobre qué llamada hacer, debe tomarse en tiempo de ejecución cuando se llama (cada vez que se llama).

El compilador en este punto está confundido, ambas opciones son igualmente válidas. Esto es cuando virtualentra en la mezcla. Cuando esta palabra clave está presente, el compilador elige la opción 2 retrasando la decisión entre todas las implementaciones posibles hasta que el código se ejecute con un valor real. Cuando esta palabra clave está ausente, el compilador elige la opción 1 porque ese es el comportamiento normal.

El compilador aún podría elegir la opción 1 en el caso de una llamada de función virtual. Pero solo si puede probar que este es siempre el caso.

Constructores y Destructores

Entonces, ¿por qué no especificamos un constructor virtual?

Más intuitivamente, ¿cómo elegiría el compilador entre implementaciones idénticas del constructor para Derivedy Derived2? Esto es bastante simple, no puede. No existe un valor preexistente del cual el compilador pueda aprender lo que realmente se pretendía. No hay un valor preexistente porque ese es el trabajo del constructor.

Entonces, ¿por qué necesitamos especificar un destructor virtual?

Más intuitivamente, ¿cómo elegiría el compilador entre implementaciones para Basey Derived? Son solo llamadas de función, por lo que ocurre el comportamiento de la llamada de función. Sin un destructor virtual declarado, el compilador decidirá vincularse directamente al Basedestructor independientemente del tipo de tiempo de ejecución de los valores.

En muchos compiladores, si el derivado no declara ningún miembro de datos, ni hereda de otros tipos, el comportamiento en el ~Base()será adecuado, pero no está garantizado. Funcionaría puramente por casualidad, al igual que pararse frente a un lanzallamas que aún no se había encendido. Estás bien por un tiempo.

La única forma correcta de declarar cualquier tipo de base o interfaz en C ++ es declarar un destructor virtual, de modo que se llame al destructor correcto para cualquier instancia dada de la jerarquía de tipos de ese tipo. Esto permite que la función con el mayor conocimiento de la instancia limpie esa instancia correctamente.

Kain0_0
fuente