Por que a classe base precisa ter um destruidor virtual aqui se a classe derivada não aloca memória dinâmica bruta?

12

O código a seguir causa um vazamento de memória:

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

Não fazia muito sentido para mim, pois a classe derivada não aloca memória dinâmica bruta e o unique_ptr se desaloca. Entendo que o destruidor implícito dessa base de classe está sendo chamado em vez de derivado, mas não entendo por que isso é um problema aqui. Se eu escrevesse um destruidor explícito para derivado, não escreveria nada para vec.

Ignorância inercial
fonte
4
Você está assumindo que um destruidor só existe se escrito manualmente; essa suposição está com defeito: a linguagem fornece um ~derived()que delega ao destruidor do vec. Como alternativa, você está assumindo que unique_ptr<base> ptconheceria o destruidor derivado. Sem um método virtual, este não pode ser o caso. Enquanto um unique_ptr pode receber uma função de exclusão que é um parâmetro de modelo sem nenhuma representação de tempo de execução, e esse recurso não serve para esse código.
amon
Podemos colocar chaves na mesma linha para diminuir o código? Agora eu tenho que rolar.
precisa saber é

Respostas:

14

Quando o compilador executa o implícito delete _ptr;dentro do unique_ptrdestruidor do (onde _ptrestá o ponteiro armazenado no unique_ptr), ele sabe exatamente duas coisas:

  1. O endereço do objeto a ser excluído.
  2. O tipo de ponteiro que _ptré. Como o ponteiro está dentro unique_ptr<base>, isso significa _ptré do tipo base*.

Isso é tudo o que o compilador sabe. Portanto, como está excluindo um objeto do tipo base, ele será chamado ~base().

Então ... onde está a parte em que destrói o derviedobjeto para o qual realmente aponta? Porque se o compilador não sabe que está destruindo a derived, ele não sabe que derived::vec existe , e muito menos que deve ser destruído. Então você quebrou o objeto deixando metade dele sem ser destruído.

O compilador não pode assumir que qualquer base*ser destruído é realmente um derived*; afinal, pode haver qualquer número de classes derivadas base. Como ele saberia para qual tipo esse particular base*realmente aponta?

O que o compilador precisa fazer é descobrir o destruidor correto a ser chamado (sim, derivedtem um destruidor. A menos que você seja = deleteum destruidor, toda classe tem um destruidor, independentemente de você escrever um ou não). Para fazer isso, ele precisará usar algumas informações armazenadas basepara obter o endereço correto do código destruidor a ser invocado, informações definidas pelo construtor da classe real. Em seguida, é necessário usar essas informações para converter o base*ponteiro em um endereço da derivedclasse correspondente (que pode ou não estar em um endereço diferente. Sim, na verdade). E então pode invocar esse destruidor.

Esse mecanismo que acabei de descrever? É comumente chamado de "despacho virtual": ou seja, aquilo que acontece sempre que você chama uma função marcada virtualquando você tem um ponteiro / referência a uma classe base.

Se você deseja chamar uma função de classe derivada quando tudo que você tem é um ponteiro / referência de classe base, essa função deve ser declarada virtual. Destruidores não são fundamentalmente diferentes a esse respeito.

Nicol Bolas
fonte
0

Herança

O ponto principal da herança é compartilhar uma interface e protocolo comuns entre muitas implementações diferentes, de modo que uma instância de uma classe derivada possa ser tratada de forma idêntica a qualquer outra instância de qualquer outro tipo derivado.

No C ++, a herança também traz detalhes de implementação, marcando (ou não marcando) o destruidor como virtual é um desses detalhes de implementação.

Função Vinculação

Agora, quando uma função, ou qualquer um de seus casos especiais, como um construtor ou destruidor, é chamada, o compilador deve escolher qual implementação de função se destina. Em seguida, ele deve gerar código de máquina que siga essa intenção.

A maneira mais simples de trabalhar isso seria selecionar a função em tempo de compilação e emitir código de máquina suficiente para que, independentemente de quaisquer valores, quando esse trecho de código for executado, ele sempre execute o código da função. Isso funciona muito bem, exceto pela herança.

Se tivermos uma classe base com uma função (poderia ser qualquer função, incluindo o construtor ou destruidor) e seu código chamar uma função, o que isso significa?

Tomando como exemplo, se você chamou initialize_vector()o compilador, deve decidir se realmente deseja chamar a implementação encontrada em Base, ou a implementação encontrada em Derived. Existem duas maneiras de decidir isso:

  1. A primeira é decidir que, como você chamou de um Basetipo, você quis dizer a implementação Base.
  2. A segunda é decidir que, porque o tipo de tempo de execução do valor armazenado no Basevalor digitado pode ser Base, ou Derivedque a decisão sobre qual chamada será feita, deve ser tomada no tempo de execução quando chamada (sempre que é chamada).

O compilador neste momento está confuso, ambas as opções são igualmente válidas. É quando virtualentra em cena. Quando essa palavra-chave está presente, o compilador escolhe a opção 2, atrasando a decisão entre todas as implementações possíveis até que o código seja executado com um valor real. Quando essa palavra-chave está ausente, o compilador escolhe a opção 1 porque esse é o comportamento normal.

O compilador ainda pode escolher a opção 1 no caso de uma chamada de função virtual. Mas somente se puder provar que esse é sempre o caso.

Construtores e Destrutores

Então, por que não especificamos um construtor virtual?

Mais intuitivamente, como o compilador escolheria entre implementações idênticas do construtor para Derivede Derived2? Isso é bem simples, não pode. Não há valor preexistente a partir do qual o compilador possa aprender o que realmente era pretendido. Não há valor pré-existente porque esse é o trabalho do construtor.

Então, por que precisamos especificar um destruidor virtual?

Mais intuitivamente, como o compilador escolheria entre implementações para Basee Derived? São apenas chamadas de função, portanto, o comportamento da chamada de função acontece. Sem um destruidor virtual declarado, o compilador decidirá ligar diretamente ao Basedestruidor, independentemente do tipo de tempo de execução dos valores.

Em muitos compiladores, se o derivado não declarar nenhum membro de dados nem herdar de outros tipos, o comportamento no ~Base()será adequado, mas não é garantido. Funcionaria puramente por acaso, como estar diante de um lança-chamas que ainda não havia sido aceso. Você está bem por um tempo.

A única maneira correta de declarar qualquer tipo de base ou interface em C ++ é declarar um destruidor virtual, para que o destruidor correto seja chamado para qualquer instância específica da hierarquia de tipos desse tipo. Isso permite que a função com mais conhecimento da instância limpe essa instância corretamente.

Kain0_0
fonte