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

#c

#c

Вопрос:

Во-первых, я понимаю, почему 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 ‘s и 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 приводит к правильному вызову всех деструкторов. Имеет смысл, что удаление базового объекта работает при одиночном наследовании (потому что есть единственная таблица виртуальных функций для работы), но, по-видимому, я неправильно понимаю ситуацию, потому что я не могу использовать свое понимание версии с одним наследованием и применить его к этому примеру с множественным наследованием.

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

Комментарии:

1. Ну, записи vtable должны были бы указывать на ~AB , а не ~A на или ~B

2. @OliCharlesworth: Ах, это, безусловно, имело бы больше смысла.

3. В документах Microsoft C есть хороший пример виртуального и невиртуального порядка уничтожения msdn.microsoft.com/en-us/library/6t4fe76c.aspx#Anchor_2

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

Ответ №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() etc.

Ответ №2:

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

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

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

Это не виртуальная таблица, которая говорит «call ~AB , then ~A , then ~B «; это просто говорит «call ~AB «, который включает вызов ~A и ~B .

Ответ №3:

Деструкторы вызываются в порядке «от наиболее производных к наиболее базовым» и в порядке, обратном порядку объявления. Сначала вызывается So ~AB , затем ~B , затем ~A , потому что AB это наиболее производный класс.

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

Комментарии:

1. «действительно то, о чем вам не стоит беспокоиться» О, да ладно, это интересно знать! 🙂 (даже если это варьируется от реализации к реализации)

Ответ №4:

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

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

Это конкретно о множественном наследовании и причине, по которой это работает

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