An in-depth exploration of C++ polymorphism ③ – Virtual destructor

Foreword

The first two chapters explored C++ polymorphic virtual function call links and inheritance relationships. This chapter will explore the working principle of virtual destructor.

When a class object with virtual destructor polymorphism is released:

  • A polymorphic class with an inheritance relationship will destruct the derived class first and then the base class, which is exactly the opposite of its construction order.

  • When the destructor of a class is called, the object’s this pointer and virtual pointer will be reset within the corresponding class. The this pointer points to the memory location corresponding to the current class object, and the virtual pointer will also be reset to point to the virtual pointer corresponding to the current class. surface.

  • Release the memory of the current derived class object.

  • In-depth exploration of C++ polymorphism ① – Virtual function call link

  • A Deeper Exploration of C++ Polymorphism ② – Inheritance

  • A Deeper Exploration of C++ Polymorphism ③ – Virtual Destruction

1. Overview

1.1. Concept

C++ virtual destructor is a special function used in C++ to handle inheritance relationships. It allows defining a virtual destructor in the base class to properly release resources when the derived class object is deleted. A virtual destructor is declared by adding the keyword “virtual” before the destructor of the base class.

When releasing a base class pointer pointing to an object of a derived class, if the destructor in the base class is not a virtual function, only the destructor of the base class will be called, not the destructor of the derived class. This can lead to resource leaks or undefined behavior.

By declaring the base class’s destructor as a virtual function, you ensure that when a derived class object is deleted, the derived class’s destructor is called first and then the base class’s destructor, thus properly releasing all related resources.

Part of the text source: ChatGPT

1.2. Destructor type

To understand C++ virtual destructors, we need to understand some concepts. The compiler will generate different types of destructors for the program to call according to different scenarios. These concepts will be mentioned below with examples.

  • base object destructor of a class T

    A function that runs the destructors for non-static data members of T and non-virtual direct base classes of T.

  • complete object destructor of a class T

    A function that, in addition to the actions required of a base object destructor, runs the destructors for the virtual base classes of T.

  • deleting destructor of a class T

    A function that, in addition to the actions required of a complete object destructor, calls the appropriate deallocation function (i.e. operator delete) for T.

Text source: Itanium C++ ABI

  • [base object destructor]: It destroys the object itself, as well as data members and non-virtual base classes.

  • [complete object destructor]: In addition to executing [base object destructor], it also destroys the virtual base class.

  • [deleting object destructor]: In addition to executing [deleting object destructor], it also calls operator delete to actually release the memory.

[Note] If there is no virtual base class, [complete object destructor] and [base object destructor] are basically the same. Part of the text source: GNU GCC (g++): Why does it generate multiple dtors?

2. Single inheritance

Through single inheritance, let’s observe the results of ordinary destruction and virtual destruction code.

2.1. Non-virtual destruction

2.1.1. Test source code

In the following test code, the destructor of the Derived class is not executed.

/* g + + -O0 -std=c + + 11 test.cpp -o test */
#include <iostream>

class Base {<!-- -->
   public:
    ~Base() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_base_data = 0x11;
};

class Base2 : public Base {<!-- -->
   public:
    ~Base2() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_base2_data = 0x21;
};

class Derived : public Base2 {<!-- -->
   public:
    ~Derived() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_derived_data = 0x31;
};

int main() {<!-- -->
    auto d = new Derived;
    auto b = static_cast<Base2*>(d);
    delete b;
    return 0;
}

// Output:
// ~Base2
// ~Base
2.1.2. Destruction process

Next, we use a tool (Compiler Explorer) to view the assembly implementation of the source code, so that we can clearly see the internal logic of the C++ source code: the compiler does not generate any logic about the ~Derived destructor for the Derived class.

  • Destruction process.
|-- main
    |-- ...
    |-- delete b
        |-- Base2::~Base2() [complete object destructor]
            |-- Base::~Base() [base object destructor]
        |-- operator delete(void*, unsigned long)
  • Assemble source code.
main:
        ...
        #Destruct Base2 class object
        call Base2::~Base2() [complete object destructor]
        ...
        # delete object
        call operator delete(void*, unsigned long)

Base2::~Base2() [base object destructor]:
        ...
        call Base::~Base() [base object destructor]
        ...

Base::~Base() [base object destructor]:
        ...

2.2. Virtual destruction

We add the virtual keyword before the destructor of the base class ~Base (or ~Base2), and the program results are as expected.

2.2.1. Test source code
/* g + + -O0 -std=c + + 11 -fdump-class-hierarchy test.cpp -o test */
#include <iostream>

class Base {<!-- -->
   public:
    virtual ~Base() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_base_data = 0x11;
};

class Base2 : public Base {<!-- -->
   public:
    ~Base2() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_base2_data = 0x21;
};

class Derived : public Base2 {<!-- -->
   public:
    ~Derived() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_derived_data = 0x31;
};

int main() {<!-- -->
    auto d = new Derived;
    auto b = static_cast<Base2*>(d);
    delete b;
    return 0;
}

// Output:
// ~Derived
// ~Base2
// ~Base
2.2.2. Destruction process
  • process.
|-- main
    |-- ...
    |-- delete b;
        |-- Derived::~Derived() [deleting destructor] # The program calls the virtual destructor on the virtual table.
            |-- Derived::~Derived() [complete object destructor] # Call the Derived destructor.
                |-- Base2::~Base2() [base object destructor] # Call the Base2 destructor.
                    |-- Base::~Base() [base object destructor] # Call the Base destructor.
            |-- operator delete(void*) # delete releases the object.
  1. The program finds the virtual table through the virtual pointer. The position of the virtual pointer pointing to the virtual table is shifted 8 bytes to the high bit. It finds the corresponding virtual function: Derived::~Derived() [deleting destructor] to call, and then calls Derived::~ Derived() [complete object destructor] Begins destructing the object.
  2. Derived::~Derived() [complete object destructor] internally calls Base2::~Base2() [base object destructor] to destruct the Base2 class and point the virtual pointer to the Base2 virtual table.
  3. Base2::~Base2() [base object destructor] internally calls Base::~Base() [base object destructor] to destruct the Base class and point the virtual pointer to the Base virtual table.
  4. After the destructors of each class are called, the program calls the delete operator to release the Derived object.

There is no difference between the [complete object destructor] and [base object destructor] type destructors here, they both point to the same function.

  • Assemble source code.
main:
        ...
        call operator new(unsigned long)
        ...
        call Derived::Derived() [complete object constructor]
        ...
        # The virtual pointer is stored at the first position of the object memory.
        movq -32(%rbp), %rax
        # The virtual pointer points to the virtual table.
        movq (%rax), %rax
        # The program finds the corresponding virtual function on the virtual table through the offset.
        addq $8, %rax
        # Save the virtual function address.
        movq (%rax), %rax
        # Write the this pointer of the object into the register and pass it as a parameter to the virtual function.
        movq -32(%rbp), %rdx
        movq %rdx, %rdi
        # Call the virtual function of the destructor (Derived::~Derived() [deleting destructor]).
        call *%rax

# The program finds the virtual function on the virtual table through the virtual pointer.
Derived::~Derived() [deleting destructor]:
        ...
        # Call Derived destructor.
        call Derived::~Derived() [complete object destructor]
        ...
        # Call delete to release the object.
        call operator delete(void*)
        ...

Derived::~Derived() [base object destructor]:
        ...
        # The virtual pointer points to the Derived virtual table.
        movq $vtable for Derived + 16, (%rax)
        # Call Base2 destructor.
        call Base2::~Base2() [base object destructor]
        ...

Base2::~Base2() [base object destructor]:
        ...
        # The virtual pointer points to the Base2 virtual table.
        movq $vtable for Base2 + 16, (%rax)
        # Call Base destructor.
        call Base::~Base() [base object destructor]
        ...

Base::~Base() [base object destructor]:
        ...
        # The virtual pointer points to the Base virtual table.
        movq $vtable for Base + 16, (%rax)
        ...

# Derived virtual table.
vtable for Derived:
        .quad 0
        .quad typeinfo for Derived
        .quad Derived::~Derived() [complete object destructor]
        # When the main function calls the delete operator, it calls the destructor of type [deleting destructor].
        .quad Derived::~Derived() [deleting destructor]

# Base2 virtual table.
vtable for Base2:
        .quad 0
        .quad typeinfo for Base2
        .quad Base2::~Base2() [complete object destructor]
        .quad Base2::~Base2() [deleting destructor]

# Base virtual table.
vtable for Base:
        .quad 0
        .quad typeinfo for Base
        .quad Base::~Base() [complete object destructor]
        .quad Base::~Base() [deleting destructor]

3. Multiple inheritance

3.1. Test code

/* g + + -O0 -std=c + + 11 -fdump-class-hierarchy test.cpp -o test */
#include <iostream>

class Base {<!-- -->
   public:
    virtual ~Base() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_base_data = 0x11;
};

class Base2 {<!-- -->
   public:
    virtual ~Base2() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_base2_data = 0x21;
};

class Derived : public Base, public Base2 {<!-- -->
   public:
    ~Derived() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_derived_data = 0x31;
};

int main() {<!-- -->
    auto d = new Derived;
    auto b = static_cast<Base2*>(d);
    delete b;
    return 0;
}

// Output:
// ~Derived
// ~Base2
// ~Base

3.2. Destruction process

  • Destruction process. To understand the detailed logic, combine the assembly source code and pictures.
|-- main
    |-- ...
    |-- delete b
        |-- non-virtual thunk to Derived::~Derived() [deleting destructor]
        |-- Derived::~Derived() [deleting destructor]
            |-- Derived::~Derived() [complete object destructor]
                |-- Base2::~Base2() [base object destructor]
                |-- Base::~Base() [base object destructor]
            |-- operator delete(void*)

  • compilation.
main:
        ...
        # The virtual pointer vptr2 points to the virtual table.
        movq -32(%rbp), %rax
        movq (%rax), %rax
        #Offset on the virtual table to find the corresponding virtual destructor.
        addq $8, %rax
        movq (%rax), %rax
        # Pass the address pointed to by the b pointer to the virtual destructor as a function parameter.
        movq -32(%rbp), %rdx
        movq %rdx, %rdi
        # Call (non-virtual thunk to Derived::~Derived() [deleting destructor]).
        call *%rax

# The delete operator triggers the virtual destructor call and jumps to Derived::~Derived() [deleting destructor]
non-virtual thunk to Derived::~Derived() [deleting destructor]:
        # Through the offset, the first address of the object memory is passed to Derived::~Derived() [deleting destructor].
        subq $16, %rdi
        jmp .LTHUNK1

# delete Derived virtual destructor.
Derived::~Derived() [deleting destructor]:
        ...
        # Pass the this pointer of the Derived object as a parameter to the Derived destructor.
        movq -8(%rbp), %rax
        movq %rax, %rdi
        # Call Derived destructor.
        call Derived::~Derived() [complete object destructor]
        # Pass Derived's this pointer as a parameter to the delete operation function.
        movq -8(%rbp), %rax
        movq %rax, %rdi
        # Call delete to release the Derived object.
        call operator delete(void*)
        ...

Derived::~Derived() [base object destructor]:
        ...
        # vptr1 points to the corresponding location of the Derived virtual table.
        movq %rdi, -8(%rbp)
        movq -8(%rbp), %rax
        movq $vtable for Derived + 16, (%rax)
        # vptr2 points to the corresponding location of the Derived virtual table.
        movq -8(%rbp), %rax
        movq $vtable for Derived + 48, 16(%rax)
        movq -8(%rbp), %rax
        # Pass the address pointed to by the b pointer to the Base2 destructor.
        addq $16, %rax
        movq %rax, %rdi
        # Call the Base2 object destructor.
        call Base2::~Base2() [base object destructor]
        # Pass the first address of the Derived object to the Base destructor.
        movq -8(%rbp), %rax
        movq %rax, %rdi
        # Call the Base object destructor.
        call Base::~Base() [base object destructor]
        ...

Base2::~Base2() [base object destructor]:
        ...
        # vptr2 points to the Base2 virtual table.
        movq %rdi, -8(%rbp)
        movq -8(%rbp), %rax
        movq $vtable for Base2 + 16, (%rax)
        ...

Base::~Base() [base object destructor]:
        ...
        # vptr1 points to the Base virtual table.
        movq %rdi, -8(%rbp)
        movq -8(%rbp), %rax
        movq $vtable for Base + 16, (%rax)
        ...

# Class virtual table.
vtable for Derived:
        .quad 0
        .quad typeinfo for Derived
        .quad Derived::~Derived() [complete object destructor]
        #Virtual destructor called by delete operator.
        .quad Derived::~Derived() [deleting destructor]
        .quad -16
        .quad typeinfo for Derived
        .quad non-virtual thunk to Derived::~Derived() [complete object destructor]
        .quad non-virtual thunk to Derived::~Derived() [deleting destructor]

vtable for Base2:
        .quad 0
        .quad typeinfo for Base2
        .quad Base2::~Base2() [complete object destructor]
        .quad Base2::~Base2() [deleting destructor]

vtable for Base:
        .quad 0
        .quad typeinfo for Base
        .quad Base::~Base() [complete object destructor]
        .quad Base::~Base() [deleting destructor]

4. Virtual inheritance

4.1. Test code

/* g + + -O0 -std=c + + 11 -fdump-class-hierarchy test.cpp -o test */
#include <iostream>

class Base {<!-- -->
   public:
    virtual ~Base() {<!-- --> std::cout << __FUNCTION__ << std::endl; }
    long long m_base_data = 0x11;
};

class Base2 : virtual public Base {<!-- -->
   public:
    ~Base2() {<!-- --> std::cout << __FUNCTION__ << std::endl; }
    long long m_base2_data = 0x21;
};

class Base3 : virtual public Base {<!-- -->
   public:
    ~Base3() {<!-- --> std::cout << __FUNCTION__ << std::endl; }
    long long m_base2_data = 0x31;
};

class Derived : public Base2, public Base3 {<!-- -->
   public:
    ~Derived() {<!-- --> std::cout << __FUNCTION__ << std::endl; }

    long long m_derived_data = 0x41;
};

int main() {<!-- -->
    auto d = new Derived;
    auto b = static_cast<Base*>(d);
    delete b;
    return 0;
}

// Output:
// ~Derived
// ~Base3
// ~Base2
// ~Base

4.2. Object memory layout

Let’s first take a look at the overall object memory layout characteristics of Derived:

  1. Data for shared base classes is stored at the bottom of object memory.
  2. The compiler adds a VTT (virtual table table) for virtual inheritance, which coordinates the construction of Derived’s virtual table (see the figure below for details).

# Derived virtual table
vtable for Derived:
        .quad 40
        .quad 0
        .quad typeinfo for Derived
        .quad Derived::~Derived() [complete object destructor]
        .quad Derived::~Derived() [deleting destructor]
        .quad 24
        .quad -16
        .quad typeinfo for Derived
        .quad non-virtual thunk to Derived::~Derived() [complete object destructor]
        .quad non-virtual thunk to Derived::~Derived() [deleting destructor]
        .quad -40
        .quad -40
        .quad typeinfo for Derived
        .quad virtual thunk to Derived::~Derived() [complete object destructor]
        .quad virtual thunk to Derived::~Derived() [deleting destructor]

# virtual table table.
VTT for Derived:
        .quad vtable for Derived + 24
        .quad construction vtable for Base2-in-Derived + 24
        .quad construction vtable for Base2-in-Derived + 64
        .quad construction vtable for Base3-in-Derived + 24
        .quad construction vtable for Base3-in-Derived + 64
        .quad vtable for Derived + 104
        .quad vtable for Derived + 64

construction vtable for Base2-in-Derived:
        .quad 40
        .quad 0
        .quad typeinfo for Base2
        .quad Base2::~Base2() [complete object destructor]
        .quad Base2::~Base2() [deleting destructor]
        .quad -40
        .quad -40
        .quad typeinfo for Base2
        .quad virtual thunk to Base2::~Base2() [complete object destructor]
        .quad virtual thunk to Base2::~Base2() [deleting destructor]

construction vtable for Base3-in-Derived:
        .quad 24
        .quad 0
        .quad typeinfo for Base3
        .quad Base3::~Base3() [complete object destructor]
        .quad Base3::~Base3() [deleting destructor]
        .quad -24
        .quad -24
        .quad typeinfo for Base3
        .quad virtual thunk to Base3::~Base3() [complete object destructor]
        .quad virtual thunk to Base3::~Base3() [deleting destructor]

4.3. Destruction process

Although there are some specialities in the memory layout of virtual inheritance, the overall destruction process of the object is not much different from the object destruction process of other inheritance relationships.

|-- main
    |-- ...
    |-- delete b
        |-- virtual thunk to Derived::~Derived() [deleting destructor]
        |-- Derived::~Derived() [deleting destructor]
            |-- Derived::~Derived() [complete object destructor]
                |-- Base3::~Base3() [base object destructor]
                |-- Base2::~Base2() [base object destructor]
                |-- Base::~Base() [base object destructor]
            |-- operator delete(void*)
  • Call the ~Derived() destructor.

main:
        ...
        # The virtual pointer vptr3 points to the virtual table.
        movq -32(%rbp), %rax
        movq (%rax), %rax
        # Find the corresponding virtual function address at the offset on the virtual table:
        # virtual thunk to Derived::~Derived() [deleting destructor].
        addq $8, %rax
        movq (%rax), %rax
        # Pass the b pointer as a parameter to the virtual function to be called.
        movq -32(%rbp), %rdx
        movq %rdx, %rdi
        # The program calls the virtual function found earlier.
        call *%rax
        ...

# The virtual function called by the main program "call *%rax" instruction.
virtual thunk to Derived::~Derived():
        # The virtual pointer points to the location of the virtual table, which is offset by 24 bytes to the lower address.
        # Get the memory offset of the address -40, and the object this pointer is offset 40 bytes to the lower address.
        mov (%rdi),%r10
        add -0x18(%r10),%rdi
        # Call Derived::~Derived() [deleting destructor]
        jmp 401488 <Derived::~Derived()>


Derived::~Derived() [deleting destructor]:
        ...
        # Call the Derived object destructor.
        call Derived::~Derived() [complete object destructor]
        ...
        # Call delete to release the object.
        call operator delete(void*)
        leave
        ret

# Derived is a complete object destructor that resets the object's this pointer and virtual pointer, and the virtual pointer points to the corresponding virtual table.
Derived::~Derived() [complete object destructor]:
        ...
        # This pointer points to the first memory.
        movq %rdi, -8(%rbp)
        #Reset the virtual pointer of the Derived object to point to the corresponding location of the Derived virtual table.
        movl $vtable for Derived + 24, ?x
        movq -8(%rbp), %rax
        movq %rdx, (%rax)
        movl $40, ?x
        movq -8(%rbp), %rax
        addq %rax, %rdx
        movl $vtable for Derived + 104, ?x
        movq %rax, (%rdx)
        movl $vtable for Derived + 64, ?x
        movq -8(%rbp), %rax
        movq %rdx, 16(%rax)

        # Adjust this pointer, find the location where the Base3 virtual function address is saved from VTT, and prepare to reset the Base3 virtual pointer.
        movl $VTT for Derived + 24, ?x
        movq -8(%rbp), %rdx
        addq $16, %rdx
        movq %rax, %rsi
        movq %rdx, %rdi
        call Base3::~Base3() [base object destructor]

        # Adjust the this pointer, find the location where the Base2 virtual function address is saved from VTT, and prepare to reset the Base2 virtual pointer.
        movl $VTT for Derived + 8, ?x
        movq -8(%rbp), %rax
        movq %rdx, %rsi
        movq %rax, %rdi
        call Base2::~Base2() [base object destructor]
        ...
        
        #Adjust this pointer and reset Base virtual pointer
        movq -8(%rbp), %rax
        addq $40, %rax
        movq %rax, %rdi
        call Base::~Base() [base object destructor]
        ...
  • ~Base3() destructor.

Derived::~Derived() [complete object destructor]:
        ...
        # Adjust this pointer, find the location where the Base3 virtual function address is saved from VTT, and prepare to reset the Base3 virtual pointer.
        movl $VTT for Derived + 24, ?x
        movq -8(%rbp), %rdx
        addq $16, %rdx
        movq %rax, %rsi
        movq %rdx, %rdi
        call Base3::~Base3() [base object destructor]

Base3::~Base3() [base object destructor]:
        ...
        movq %rdi, -8(%rbp)
        movq %rsi, -16(%rbp)
        # The virtual pointer vptr2 points to the corresponding location of the virtual table.
        movq -16(%rbp), %rax
        movq (%rax), %rdx
        movq -8(%rbp), %rax
        movq %rdx, (%rax)

        # Find vbase_offset through the virtual table offset
        movq -8(%rbp), %rax
        movq (%rax), %rax
        subq $24, %rax
        movq (%rax), %rax

        # The virtual pointer vptr3 points to the corresponding location of the virtual table.
        movq %rax, %rdx
        movq -8(%rbp), %rax
        addq %rax, %rdx
        movq -16(%rbp), %rax
        movq 8(%rax), %rax
        movq %rax, (%rdx)
        ...
  • ~Base2() destructor.

# Derived is a complete object destructor that resets the object's this pointer and virtual pointer, and the virtual pointer points to the corresponding virtual table.
Derived::~Derived() [complete object destructor]:
        ...
        # Adjust the this pointer, find the location where the Base2 virtual function address is saved from VTT, and prepare to reset the Base2 virtual pointer.
        movl $VTT for Derived + 8, ?x
        movq -8(%rbp), %rax
        movq %rdx, %rsi
        movq %rax, %rdi
        call Base2::~Base2() [base object destructor]

Base2::~Base2() [base object destructor]:
        ...
        movq %rdi, -8(%rbp)
        movq %rsi, -16(%rbp)
        # The virtual pointer vptr1 points to the corresponding location of the virtual table (Construction vtable for Base2 in Derived).
        movq -16(%rbp), %rax
        movq (%rax), %rdx
        movq -8(%rbp), %rax
        movq %rdx, (%rax)

        # Find vbase_offset through the virtual table offset
        movq -8(%rbp), %rax
        movq (%rax), %rax
        subq $24, %rax
        movq (%rax), %rax

        # The virtual pointer vptr3 points to the corresponding location of the virtual table.
        movq %rax, %rdx
        movq -8(%rbp), %rax
        addq %rax, %rdx
        movq -16(%rbp), %rax
        movq 8(%rax), %rax
        movq %rax, (%rdx)
        ...
  • ~Base() destructor.

Derived::~Derived() [complete object destructor]:
        ...
        #Adjust this pointer and reset Base virtual pointer.
        movq -8(%rbp), %rax
        addq $40, %rax
        movq %rax, %rdi
        call Base::~Base() [base object destructor]
        ...

Base::~Base() [base object destructor]:
        ...
        movq %rdi, -8(%rbp)
        movq -8(%rbp), %rax
        movq $vtable for Base + 16, (%rax)
        ...

vtable for Base:
        .quad 0
        .quad typeinfo for Base
        .quad Base::~Base() [complete object destructor]
        .quad Base::~Base() [deleting destructor]

5. Summary

  • The working principle of virtual destruction is still the same key points: virtual pointers, virtual tables, virtual functions, and changes in the this pointer within each class.
  • Although the virtual functions and virtual tables of polymorphic classes are generated by the compiler before the program is run, during the process of creating and constructing polymorphic objects, the object memory data is constructed in order (the base class is constructed first, and then the derived class is constructed class), so in each construction step, the this pointer and virtual pointer of the current class may change. Similarly, the destruction of the derived class object is in the opposite order to the construction order (the derived class is destructed first, and then the base class is destructed ), of course, this pointer and virtual pointer may also change accordingly just like the construction.
  • Although virtual destructor is simple to use, if the user forgets to use virtual destructor, the destructor of the derived class may not be called when destroying the object, which is unexpected. A friendly language should make complex things simple. C++ is a language that obviously has a lot of room for optimization.

6. Reference

  • Itanium C++ ABI
  • GNU GCC (g++): Why does it generate multiple dtors?