C++ 析构函数

对象有被创建出来的那一刻,自然也有使用完毕、被销毁的时候。对象创建时由构造函数负责初始化,而当对象即将被销毁时,专门负责收尾清理工作的特殊成员函数就叫做 “析构函数(Destructor)”。

和构造函数一样,析构函数也不需要我们手动调用。一个对象从创建到销毁的存活时间称为 “生命周期”,一旦对象的生命周期结束、即将被销毁,编译器就会自动执行析构函数。

C++ 析构函数的基本用法

在 C++ 中,析构函数的写法和无参构造函数非常相似,只是在函数名前面多加了一个英文波浪号(~)。

语法:

class 类名 {
public:
    ~类名() {
        // 清理代码
    }
};

说明:

编写析构函数时,我们需要遵循以下 4 条语法规则:

  • 函数名固定:析构函数的名字是在类名前面加上波浪号,也就是写成 ~类名。比如 FileLogger 类的析构函数名就是 ~FileLogger。
  • 没有返回值类型:和构造函数一样,析构函数前面不能写任何返回值类型,连 void 都不能写。
  • 不能带任何参数:析构函数的小括号 () 内部必须为空,不能接收任何参数。正因为没有参数,析构函数不支持函数重载,一个类中最多只能有一个析构函数。
  • 对象销毁时自动调用:当对象离开所在的作用域、即将被销毁时,C++ 编译器会自动调用析构函数。

为了直观看到析构函数是在什么时候自动执行的,我们可以定义一个模拟日志记录的 FileLogger 类,在构造函数和析构函数中各打印一句提示文字。下面我们来看一个例子。

示例 1:析构函数的自动执行

#include <iostream>
#include <string>

class FileLogger {
private:
    std::string fileName;

public:
    // 构造函数
    FileLogger(std::string name) : fileName(name) {
        std::cout << "打开日志文件:" << fileName << '\n';
    }

    // 析构函数
    ~FileLogger() {
        std::cout << "关闭日志文件:" << fileName << '\n';
    }
};

int main() {
    FileLogger logger("app.log");
    std::cout << "正在写入运行日志...\n";
    return 0;
}

运行结果如下。

打开日志文件:app.log
正在写入运行日志...
关闭日志文件:app.log

分析:

在这个例子中,当我们在 main() 函数中执行 FileLogger logger("app.log"); 创建对象时,构造函数会立刻自动执行,先打印出打开日志文件的提示。随后程序继续往下执行,输出 “正在写入运行日志...”。

等执行完 return 0;、程序即将离开 main() 函数的右花括号 } 时,局部对象 logger 的生命周期结束。此时编译器自动调用了 ~FileLogger() 析构函数,输出最后一行关闭提示。

可能有些小伙伴会问:“既然只是做清理工作,我们自己在 main() 末尾写一行关闭代码不就行了吗?” 其实原因很简单。如果全靠我们手动写清理代码,万一函数在中途提前 return 退出了,或者一时粗心漏写了,占用的文件或内存就会一直无法释放。

而析构函数就像酒店退房时的 “自动断电机制” 一样。不管函数是从末尾正常结束,还是在中途提前退出,只要对象离开它所在的花括号 {},编译器都会保证自动执行一次析构函数。

析构函数的调用时机与顺序

对于在函数内部定义的普通局部对象来说,判断它什么时候析构非常简单:只要程序执行离开定义该对象的那一对花括号 {},这个对象就会立刻被销毁,并自动调用析构函数。

那么,如果同一个作用域里先后创建了多个对象,或者一个类的内部还包含其他成员对象,销毁时谁先析构、谁后析构呢?下面我们分 2 种情况来看。

1. 多个局部对象的析构顺序

在函数内部,我们也可以单独使用一对花括号 {} 划分出一个局部作用域。下面我们通过一个例子,观察多个局部对象的构造与析构顺序。

示例 2:局部对象的析构顺序

#include <iostream>
#include <string>

class Task {
private:
    std::string name;

public:
    Task(std::string n) : name(n) {
        std::cout << "创建:" << name << '\n';
    }

    ~Task() {
        std::cout << "销毁:" << name << '\n';
    }
};

int main() {
    Task t1("任务 1");        // 外层作用域对象
    {
        Task t2("任务 2");    // 内层第 1 个对象
        Task t3("任务 3");    // 内层第 2 个对象
    }                         // 离开内层花括号
    std::cout << "已离开内部作用域\n";
    return 0;
}

运行结果如下。

创建:任务 1
创建:任务 2
创建:任务 3
销毁:任务 3
销毁:任务 2
已离开内部作用域
销毁:任务 1

分析:

从运行结果中,我们可以看出以下 2 个规律:

  • 离开所在花括号即立刻销毁:t2 和 t3 定义在内部那对花括号 {} 中,一旦程序执行到内部的右花括号 },它俩就会立刻析构销毁。而 t1 定义在 main() 的外层花括号中,要等到 main() 结束时才会被销毁。
  • 同一作用域内先构造的后析构:在内部作用域中,t2 比 t3 先创建,但销毁时却是 t3 先析构、t2 后析构,顺序正好相反。

为什么同一作用域里析构的顺序是反过来的呢?这就像往单行死胡同里停车一样:最先开进去的车停在最里面,最后开进去的车堵在最外面,如下图所示。等到倒车出来时,自然得让最外面的车先退出来,最后才轮到最里面的车。

构造与析构顺序的死胡同停车类比

在下面这段代码中,3 个对象被销毁的先后顺序依次是 b、c、a,小伙伴们可以思考一下为什么。

Task a("A");
{
    Task b("B");
}
Task c("C");

2. 包含成员对象时的构造与析构顺序

一个类可以把另一个类的对象作为自己的成员变量,比如汽车类 Car 内部包含一个发动机类 Engine 的对象。上一节学习初始化列表时,我们就接触过这种 “成员对象”。

在创建和销毁这类外层对象时,它们的执行顺序同样符合日常生活常识。要想组装出一辆完整的汽车 Car,肯定得先把里面的发动机 Engine 造好。而当汽车报废拆解时,则是先把外层车壳拆掉,最后才拆解里面的发动机。具体规律如下:

  • 构造顺序(先内后外):先执行内部 “成员对象” 的构造函数,再执行 “外层类自己” 的构造函数。
  • 析构顺序(先外后内):先执行 “外层类自己” 的析构函数,再执行内部 “成员对象” 的析构函数。

下面我们结合具体示例来看。

示例 3:成员对象的析构顺序

#include <iostream>

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

    ~Engine() {
        std::cout << "Engine 析构\n";
    }
};

class Car {
private:
    Engine engine;    // 成员对象

public:
    Car() {
        std::cout << "Car 构造\n";
    }

    ~Car() {
        std::cout << "Car 析构\n";
    }
};

int main() {
    Car myCar;
    return 0;
}

运行结果如下。

Engine 构造
Car 构造
Car 析构
Engine 析构

分析:

当我们执行 Car myCar; 创建外层对象时,它的内部成员对象 engine 必须先在进入 Car 构造函数体之前构造完成。因此,屏幕上会先输出 “Engine 构造”,再输出 “Car 构造”。

等 main() 函数结束、myCar 即将销毁时,执行顺序正好反过来。程序会先执行外层 Car 自己的析构函数输出 “Car 析构”,最后才销毁内部的成员对象 engine,输出 “Engine 析构”。

提示 如果一个类中同时包含多个成员对象(如既有 Engine 又有 Wheel),它们之间的构造顺序只按照在类中声明的先后顺序执行,析构时则按相反的顺序依次销毁。

析构函数的使用原则

在前面的例子中,我们在析构函数里打印文字,只是为了方便小伙伴们直观看到它的执行时机。在实际项目开发中,析构函数真正的职责是释放对象在运行期间占用的外部资源,比如关闭已打开的文件、断开网络连接,或者配合后面要学习的 new 和 delete 释放动态内存。

平时编写类时,我们只需要把握以下 2 点原则:

  • 简单类不需要手写析构函数:如果我们没有手写析构函数,编译器会自动生成一个默认的空析构函数。对于只包含 int、double、std::string 等普通成员的简单类,默认析构函数就已经够用了,不需要我们再去手写一个空函数。
  • 不要手动调用局部对象的析构函数:局部对象离开花括号 {} 时,编译器本来就会自动调用一次析构函数。如果我们提前手动调用了一次,等离开作用域时同一个对象就会被重复销毁两次,直接导致程序崩溃。

对于第 2 点,大家平时写代码时千万不要写出下面这种手动调用析构函数的语句。

FileLogger logger("app.log");
logger.~FileLogger();

上一篇:

下一篇:

给站长反馈

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

邮箱:lvyenet@vip.qq.com

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