C++ 虚析构函数

在使用多态时,我们经常通过基类指针去指向派生类对象。调用虚函数时,程序可以根据实际指向的对象去执行派生类的版本。不过当对象使用完毕、需要通过这个基类指针将其销毁时,派生类那一部分能不能被完整清理掉呢?

如果基类的析构函数只是普通函数,通过基类指针销毁派生类对象时,派生类自己的析构函数根本不会被执行。为了让派生类对象能够顺着继承链完整销毁,我们需要将基类的析构函数声明为 “虚析构函数(Virtual Destructor)”。

为什么需要虚析构函数?

为了演示通过基类指针手动销毁对象的过程,这里我们先借用下一章将要学习的 new 和 delete 运算符:new FileLogger() 用于动态创建一个对象并返回它的地址,delete logger 则用于销毁指针 logger 所指向的对象。

下面我们先来看一个例子:如果基类 Logger 的析构函数没有加 virtual,直接通过 Logger* 基类指针去 delete 一个 FileLogger 派生类对象会发生什么。

示例 1:普通基类析构的问题

#include <iostream>

class Logger {
public:
    Logger() {
        std::cout << "Logger 基类构造\n";
    }

    // 普通析构函数(未加 virtual)
    ~Logger() {
        std::cout << "Logger 基类析构\n";
    }
};

class FileLogger : public Logger {
public:
    FileLogger() {
        std::cout << "FileLogger 派生类构造(打开日志文件)\n";
    }

    ~FileLogger() {
        std::cout << "FileLogger 派生类析构(关闭日志文件)\n";
    }
};

int main() {
    Logger* logger = new FileLogger();
    delete logger;    // 通过基类指针删除派生类对象
    return 0;
}

运行结果如下。

Logger 基类构造
FileLogger 派生类构造(打开日志文件)
Logger 基类析构

分析:

从运行结果可以看到,创建 FileLogger 对象时,程序按顺序执行了基类和派生类的构造函数。但执行 delete logger; 销毁对象时,屏幕上只输出了基类的析构信息,派生类的 ~FileLogger() 完全被跳过了,打开的日志文件也就没有正常关闭。

可能有些小伙伴会觉得奇怪:前面直接定义局部对象 FileLogger log; 时,基类析构函数没加 virtual 也能正常析构,为什么这里却漏掉了呢?

这是因为直接定义局部对象时,变量本身就是明确的 FileLogger 类型,离开作用域时编译器自然知道要先执行子类析构、再执行基类析构。

而这里对象是通过 new 动态创建的,销毁时只能靠 delete logger;。编译器看到 logger 只是 Logger* 基类指针,且 ~Logger() 不是虚函数,于是就只调用了基类的析构函数。

注意 在 C++ 标准中,通过 “非虚析构” 的基类指针去 delete 派生类对象属于未定义行为(即运行结果不再可控)。这不仅会导致派生类申请的资源无法释放而引发内存泄漏,还可能直接导致程序崩溃,在实际开发中千万不要这样写。

C++ 虚析构函数的用法

在 C++ 中,要想让 delete 基类指针时能够准确找到并执行派生类的析构函数,我们只需要在基类的析构函数前面加上 virtual 关键字,将其声明为 “虚析构函数” 就可以了。

语法:

class 基类名 {
public:
    virtual ~基类名() {
        // 函数体
    }
};

说明:

细心的小伙伴可能会问:前面讲虚函数重写不是要求函数名一致吗?基类的 ~Logger() 和派生类的 ~FileLogger() 名字并不一样,也能触发动态绑定吗?

其实编译器在底层会对析构函数做统一的特殊处理。只要基类的析构函数加上了 virtual,所有派生类的析构函数都会自动成为虚析构函数,不管类名叫什么都能正常匹配。通过基类指针执行 delete 时,程序会先执行派生类的析构函数,随后再自动执行基类的析构函数。

示例 2:使用虚析构函数

#include <iostream>

class Logger {
public:
    Logger() {
        std::cout << "Logger 基类构造\n";
    }

    // 声明为虚析构函数
    virtual ~Logger() {
        std::cout << "Logger 基类析构\n";
    }
};

class FileLogger : public Logger {
public:
    FileLogger() {
        std::cout << "FileLogger 派生类构造(打开日志文件)\n";
    }

    ~FileLogger() {
        std::cout << "FileLogger 派生类析构(关闭日志文件)\n";
    }
};

int main() {
    Logger* logger = new FileLogger();
    delete logger;
    return 0;
}

运行结果如下。

Logger 基类构造
FileLogger 派生类构造(打开日志文件)
FileLogger 派生类析构(关闭日志文件)
Logger 基类析构

分析:

对比示例 1 可以看到,其他代码一行都没动,我们只是将基类 Logger 的析构函数改写为了 virtual ~Logger()。

当再次执行 delete logger; 时,程序发现基类析构函数是虚函数,就会根据 logger 实际指向的对象先调用 ~FileLogger() 关闭日志文件,紧接着再自动调用 ~Logger(),走完了 “先析构派生类、后析构基类” 的完整清理流程。

在实际开发中,像前面学过的抽象类这类多态基类,自身往往没有需要额外清理的资源,不需要在析构函数的花括号里写具体代码。此时我们可以直接在声明末尾加上 = default;,让编译器生成默认的空实现。请看下面例子。

示例 3:使用 = default 声明虚析构

#include <iostream>

class Logger {
public:
    virtual void writeLog() = 0;

    // 生成默认虚析构函数
    virtual ~Logger() = default;
};

class FileLogger : public Logger {
public:
    void writeLog() {
        std::cout << "正在将日志写入文件\n";
    }

    ~FileLogger() {
        std::cout << "FileLogger 析构:已关闭日志文件\n";
    }
};

int main() {
    Logger* logger = new FileLogger();
    logger->writeLog();
    delete logger;
    return 0;
}

运行结果如下。

正在将日志写入文件
FileLogger 析构:已关闭日志文件

分析:

virtual ~Logger() = default; 这句代码将 Logger 的析构函数声明为了默认虚析构函数,既省去了手写空花括号 {} 的麻烦,又能保证执行 delete logger; 时正常触发子类的 ~FileLogger()。

对于空实现的虚析构函数,下面 2 种方式是等价的,平时写代码时我们更推荐使用方式 1:

// 方式 1(推荐)
virtual ~Logger() = default;

// 方式 2
virtual ~Logger() {}

什么时候需要定义虚析构函数?

既然虚析构函数能够保证派生类对象安全销毁,那以后我们写任何一个类,是不是都应该给析构函数加上 virtual 呢?其实并不需要,在实际开发中,大家可以根据以下 2 条原则来判断:

  • 作为多态基类时:只要一个类内部定义了虚函数或纯虚函数,设计初衷是作为多态基类供其他类继承,就应该把析构函数声明为 virtual。
  • 作为普通独立类时:如果一个类不会被继承,或者不会作为多态基类使用,就不需要加 virtual。因为一旦类中出现虚函数,每个对象都会多出一个隐藏的虚函数表指针,给普通类加 virtual 只会白白增加内存开销。

细心的小伙伴可能会发现,前两节的 Hero 和 Shape 基类并没有写虚析构函数。那是因为当时只创建了自动销毁的局部对象,并没有通过基类指针执行 delete。而在实际项目中,我们很难保证后续没人用基类指针去管理子类对象,因此建议给所有多态基类都加上 virtual ~类名() = default;。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号