Почему базовый класс должен иметь здесь виртуальный деструктор, если производный класс не выделяет необработанную динамическую память?

12

Следующий код вызывает утечку памяти:

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

Для меня это не имело особого смысла, так как производный класс не выделяет необработанную динамическую память, а unique_ptr освобождает себя. Я понимаю, что неявный деструктор базы классов вызывается вместо производного, но я не понимаю, почему это проблема. Если бы я написал явный деструктор для производного, я бы ничего не написал для vec.

Инерционное невежество
источник
4
Вы предполагаете, что деструктор существует, только если написан вручную; это предположение неверно: язык предоставляет ~derived()делегату деструктору vec. В качестве альтернативы вы предполагаете, unique_ptr<base> ptчто знаете производный деструктор. Без виртуального метода этого не может быть. Хотя unique_ptr может быть предоставлена ​​функция удаления, которая является параметром шаблона без какого-либо представления во время выполнения, и эта функция не используется для этого кода.
Амон
Можем ли мы поставить скобки на одной строке, чтобы сделать код короче? Теперь я должен прокрутить.
laike9m

Ответы:

14

Когда компилятор выполняет неявный delete _ptr;внутри unique_ptrдеструктора (где _ptrхранится указатель unique_ptr), он точно знает две вещи:

  1. Адрес объекта, который нужно удалить.
  2. Тип указателя, который _ptrесть. Поскольку указатель находится внутри unique_ptr<base>, это означает, что _ptrимеет тип base*.

Это все, что знает компилятор. Таким образом, учитывая, что он удаляет объект типа base, он будет вызываться ~base().

Итак ... где та часть, где он уничтожает derviedобъект, на который он фактически указывает? Потому что, если компилятор не знает, что он уничтожает derived, тогда он вообще не знает, derived::vec существует , не говоря уже о том, что он должен быть уничтожен. Итак, вы сломали объект, оставив половину его неуничтоженным.

Компилятор не может предположить, что любое base*уничтожаемое на самом деле является a derived*; в конце концов, может быть любое количество классов, полученных из base. Как бы он узнал, на какой тип конкретно base*указывает этот тип ?

Компилятор должен derivedнайти правильный деструктор для вызова (да, у него есть деструктор. Если вы не = deleteдеструктор, у каждого класса есть деструктор, независимо от того, пишете вы его или нет). Чтобы сделать это, он должен будет использовать некоторую информацию, хранящуюся в нем, baseчтобы получить правильный адрес вызываемого кода деструктора, информацию, которая устанавливается конструктором фактического класса. Затем он должен использовать эту информацию для преобразования в base*указатель на адрес соответствующего derivedкласса (который может быть или не быть по другому адресу. Да, действительно). И тогда он может вызвать этот деструктор.

Этот механизм я только что описал? Обычно это называется «виртуальная диспетчеризация»: то есть то, что происходит каждый раз, когда вы вызываете функцию, помеченную, virtualкогда у вас есть указатель / ссылка на базовый класс.

Если вы хотите вызвать функцию производного класса, когда у вас есть только указатель / ссылка базового класса, эта функция должна быть объявлена virtual. Деструкторы принципиально не отличаются в этом отношении.

Николь Болас
источник
0

наследование

Весь смысл наследования заключается в том, чтобы совместно использовать общий интерфейс и протокол среди множества различных реализаций, так что экземпляр производного класса может обрабатываться идентично любому другому экземпляру из любого другого производного типа.

В C ++ наследование также приносит с собой детали реализации, помечая (или не помечая) деструктор как виртуальный - одна из таких деталей реализации.

Привязка функций

Теперь, когда вызывается функция или любой из ее особых случаев, таких как конструктор или деструктор, компилятор должен выбрать, какая реализация функции была предназначена. Затем он должен сгенерировать машинный код, который следует за этим намерением.

Самый простой способ для этого - выбрать функцию во время компиляции и выдать достаточно машинного кода, чтобы независимо от каких-либо значений, когда этот фрагмент кода выполнялся, он всегда выполнял код функции. Это прекрасно работает за исключением наследования.

Если у нас есть базовый класс с функцией (это может быть любая функция, включая конструктор или деструктор) и ваш код вызывает для него функцию, что это значит?

Исходя из вашего примера, если вы вызвали initialize_vector()компилятор, он должен решить, действительно ли вы хотели вызвать реализацию, найденную в Base, или реализацию, найденную в Derived. Есть два способа решить это:

  1. Первый - решить, что, поскольку вы вызывали Baseтип, вы имели в виду реализацию в Base.
  2. Второй - решить, что, поскольку тип времени выполнения значения, хранящегося в Baseтипизированном значении, может быть Base, или Derivedчто решение о том, какой вызов сделать, должно быть принято во время выполнения при вызове (каждый раз, когда он вызывается).

Компилятор на этом этапе запутался, оба параметра одинаково действительны. Это когда virtualвходит в смесь. Когда это ключевое слово присутствует, компилятор выбирает вариант 2, откладывая решение между всеми возможными реализациями, пока код не будет запущен с реальным значением. Когда это ключевое слово отсутствует, компилятор выбирает вариант 1, потому что это нормальное поведение.

Компилятор может по-прежнему выбирать вариант 1 в случае вызова виртуальной функции. Но только если это может доказать, что это всегда так.

Конструкторы и деструкторы

Так почему бы нам не указать виртуальный конструктор?

Более интуитивно понятно, как компилятор будет выбирать между одинаковыми реализациями конструктора для Derivedи Derived2? Это довольно просто, не может. Не существует заранее существующего значения, из которого компилятор может узнать, что на самом деле было задумано. Предварительно существующего значения не существует, потому что это работа конструктора.

Так зачем нам указывать виртуальный деструктор?

Более интуитивно понятно, как компилятор будет выбирать между реализациями для Baseи Derived? Это просто вызовы функций, поэтому происходит поведение вызова функций. Без объявленного виртуального деструктора компилятор решит привязать его непосредственно к Baseдеструктору независимо от значения типа времени выполнения.

Во многих компиляторах, если производный не объявляет никаких элементов данных и не наследует от других типов, поведение в ~Base()будет подходящим, но это не гарантируется. Это сработало бы просто по случайности, как если бы вы стояли перед огнеметом, который еще не был зажжен. Вы в порядке на некоторое время.

Единственный правильный способ объявить любой базовый или интерфейсный тип в C ++ - это объявить виртуальный деструктор, чтобы правильный деструктор вызывался для любого данного экземпляра иерархии типов этого типа. Это позволяет функции с наибольшим знанием экземпляра правильно его очистить.

Kain0_0
источник