析构函数

析构函数和构造函数一样,也分完整析构函数和基类析构函数两种。而且析构函数的工作流程和构造函数相反。

如果一个类的基类有未实现的析构函数(未提供定义或者是纯虚函数),会导致链接失败。这是因为子类的析构函数有调用基类析构函数的逻辑!

什么时候需要虚析构函数

https://stackoverflow.com/a/461237

Declare destructors virtual in polymorphic base classes. This is Item 7 in Scott Meyers’ Effective C++. Meyers goes on to summarize that if a class has any virtual function, it should have a virtual destructor, and that classes not designed to be base classes or not designed to be used polymorphically should not declare virtual destructors.

**自动生成的析构函数不是虚函数,但是可以显式声明其为 virtual 然后用 = default 引入。**从基类继承的析构函数后虚实性质保持不变。

有了虚析构函数,才能正确地通过非本类的指针 / 引用析构或 delete 某个对象。

虚析构函数如何工作?

虚析构函数是给类型增加 vptr 的一种方法。有了虚析构函数就能通过不匹配的指针和 delete 关键字删除曾经 new 出的对象。

  • complete object destuctor:该类型对象退出作用域时调用(由于可能会通过指针 / 引用人为调用析构函数——比如说 STL 里面——所以完整析构函数也会记录在虚表)。
  • base object destructor:该类型对象被子类处理的时候调用。(在一些场景下和完整析构函数是同一个)
  • deleting destructor:该类型指针被使用 delete 时调用的、通过虚表机制查找到的虚函数。deleting destructor 要负责修正 this 指针,调用 complete object destructor 来析构对象本身,并最终使用无类型 new 归还空间

可以观察这段代码:https://godbolt.org/z/ohePxP9qd

#include <iostream>
#include <cassert>
using namespace std;

struct A { virtual ~A() {} };
struct B { virtual ~B() {} };
struct C: A, B {  };

int main() {
    C *p = new C;
    A *pa = (A *)p;
    B *pb = (B *)p;
    assert((void *)pb != (void *)p);
    delete pb; // pb 是 B* 类型,而 B 又有虚表,因此做偏移可以找到真正的指针起始位置
}

通过基类指针 delete 子类对象

基类必须有虚析构函数!这样才能正确找到应该被析构的对象

  • 反例(对象没有被正确析构) 下面代码是虚拟继承,由于 A 和 C 不能重合 vptr,C 需要一个新的 vptr。delete 时指针的地址和申请时并不匹配,这能被 -fsanitize=address 检查出来。

    #include <cstdio>
    
    struct A {
        int a;
        A() = default;
        virtual void f() {}
    };
    
    struct C : virtual A {
        int c;
        C() = default;
    };
    
    // Compile with gcc 13.1 and option -fsanitize=address,
    // and an error will occur.
    int main() {
        C *pc = new C;
        A *pa = pc;
        fprintf(stderr, "pc=%p\n", pc);
        fprintf(stderr, "pa=%p\n", pa);
        delete pa;
    }
    

    下面这段代码中 A 没有虚析构函数。 A 类型是多态类,delete 时也和原来申请时地址一致(单具体继承就是这样),所以 -fsanitize=address 检查不出来问题。但是调用 ~A() 时没有通过虚拟机制调用到 ~C(),这样 C 类中的数据没有被正确析构!编译下面的代码可以发现 ~C() 中的打印语句没有生效。

    #include <cstdio>
    
    struct A {
        int a;
        A() = default;
        virtual void f() {}
    };
    
    struct C : A {
        int c;
        C() = default;
        virtual ~C() {
            puts("~C()");
        }
    };
    
    int main() {
        C *pc = new C;
        A *pa = pc;
        fprintf(stderr, "pc=%p\n", pc);
        fprintf(stderr, "pa=%p\n", pa);
        delete pa;
    }
    

    接下来增加 C 类的体积:

    #include <cstdio>
    
    struct A {
        int a;
        A() = default;
        virtual void f() {}
    };
    
    struct C : A {
        int c;
        int d; // <--
        C() = default;
        virtual ~C() {
            puts("~C()");
        }
    };
    
    int main() {
        C *pc = new C;
        A *pa = pc;
        fprintf(stderr, "pc=%p\n", pc);
        fprintf(stderr, "pa=%p\n", pa);
        delete pa;
    }
    

    现在 A 的大小是 12 字节,C 的大小是 20 字节。new 和 delete 操作符都将大小对齐到 8 字节再操作,那么用 -fsanitize=address 编译就能够检查出来析构类型不匹配的问题。