在使用多态时,我们经常通过基类指针去指向派生类对象。调用虚函数时,程序可以根据实际指向的对象去执行派生类的版本。不过当对象使用完毕、需要通过这个基类指针将其销毁时,派生类那一部分能不能被完整清理掉呢?
如果基类的析构函数只是普通函数,通过基类指针销毁派生类对象时,派生类自己的析构函数根本不会被执行。为了让派生类对象能够顺着继承链完整销毁,我们需要将基类的析构函数声明为 “虚析构函数(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;。
