Виртуальные деструкторы и удаление объектов с множественным наследованием... Как это работает? - программирование
Подтвердить что ты не робот

Виртуальные деструкторы и удаление объектов с множественным наследованием... Как это работает?

Во-первых, я понимаю, почему деструкторы virtual необходимы в терминах единого наследования и удаления объекта с помощью базового указателя. Это особенно касается множественного наследования и причины, почему это работает. Этот вопрос возник в одном из моих университетских классов, и никто (включая профессора) не был уверен, почему это сработало:

#include <iostream>

struct A
{
    virtual ~A()
    {
        std::cout << "~A" << std::endl;
    }
    int memberA;
};

struct B
{
    virtual ~B()
    {
        std::cout << "~B" << std::endl;
    }
    int memberB;
};

struct AB : public A, public B
{
    virtual ~AB()
    {
        std::cout << "~AB" << std::endl;
    }
};

int main()
{
    AB* ab1 = new AB();
    AB* ab2 = new AB();

    A* a = ab1;
    B* b = ab2;

    delete a;
    delete b;
}

Выход для этого:

~AB
~B
~A
~AB
~B
~A

Как компилятор знает, как вызвать A и B деструктор при удалении A или B? В частности, как выкладывается память для AB (в частности, таблица виртуальных функций), так что деструкторы A и B могут быть вызваны?

Мой профессор предположил, что память будет выложена (что-то вроде этого):

    AB
+---------+              +----+
|  A VFT  | - - - - - -> | ~A |
+---------+              +----+
| memberA |
+---------+              +----+
|  B VFT  | - - - - - -> | ~B |
+---------+              +----+
| memberB |
+---------+

// I have no idea where ~AB would go...

Нам всем любопытно, как эти деструкторы фактически выложены в памяти и как вызов delete на любом из A или B приводит к тому, что все деструкторы будут правильно вызваны. Имеет смысл, что удаление базового объекта работает в одиночном наследовании (потому что есть одна таблица виртуальных функций для работы), но, видимо, я неправильно понимаю вещи, потому что я не могу понять свое единственное наследственное исполнение и применить его к этому примеру множественного наследования.

Итак, как это работает?

4b9b3361

Ответ 1

Это работает, потому что стандарт говорит, что он работает.

На практике компилятор вставляет неявные вызовы ~A() и ~B() в ~AB(). Механизм точно такой же, как с одиночным наследованием, за исключением того, что для компилятора есть несколько базовых деструкторов.

Я думаю, что основным источником путаницы в вашей диаграмме является несколько отдельных записей vtable для виртуального деструктора. На практике будет единственная запись, которая будет указывать на ~A(), ~B() и ~AB() для A, B и AB() соответственно.

Например, если я скомпилирую ваш код с помощью gcc и осмотрю сборку, я вижу следующий код в ~AB():

LEHE0:
        movq    -24(%rbp), %rax
        addq    $16, %rax
        movq    %rax, %rdi
LEHB1:
        call    __ZN1BD2Ev
LEHE1:
        movq    -24(%rbp), %rax
        movq    %rax, %rdi
LEHB2:
        call    __ZN1AD2Ev

Это вызывает ~B(), за которым следует ~A().

Виртуальные таблицы трех классов выглядят следующим образом:

; A
__ZTV1A:
        .quad   0
        .quad   __ZTI1A
        .quad   __ZN1AD1Ev
        .quad   __ZN1AD0Ev

; B
__ZTV1B:
        .quad   0
        .quad   __ZTI1B
        .quad   __ZN1BD1Ev
        .quad   __ZN1BD0Ev

; AB
__ZTV2AB:
        .quad   0
        .quad   __ZTI2AB
        .quad   __ZN2ABD1Ev
        .quad   __ZN2ABD0Ev
        .quad   -16
        .quad   __ZTI2AB
        .quad   __ZThn16_N2ABD1Ev
        .quad   __ZThn16_N2ABD0Ev

Для каждого класса запись № 2 относится к классу "полный деструктор объекта". Для A это указывает на ~A() и т.д.

Ответ 2

Элемент vtable просто указывает на деструктор для AB. Просто определено, что после выполнения деструктора вызываются деструкторы базового класса:

После выполнения тела деструктора и уничтожения любых автоматических объектов, выделенных в теле, деструктор для класса X вызывает [...] деструкторы для X s прямых базовых классов и [...].

Итак, когда компилятор видит delete a;, а затем видит, что деструктор A является виртуальным, он выглядит деструктором для динамического типа A (который AB) с помощью vtable. Это находит ~AB и выполняет его. Это приводит к вызову ~A и ~B.

Это не vtable, которая говорит "call ~AB, затем ~A, затем ~B"; он просто говорит "call ~AB", который включает вызов ~A и ~B.

Ответ 3

Деструкторы вызываются в порядке "от наиболее производных к большинству базальных" и в обратном порядке объявления. Итак, сначала вызывается ~AB, затем ~B, затем ~A, потому что AB является наиболее производным классом.

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

Ответ 4

(Я знаю, что этот вопрос почти двухлетний, но я не мог устоять перед тем, как наткнулся на него)

Хотя в заголовке вы используете слово вопроса , как, вы также указываете почему в вопросительном посте. Люди дали хорошие технические ответы для того, как, но почему, кажется, они остались без внимания.

Это особенно касается множественного наследования и причины почему это работает

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