所谓“析构函数”(destructor),是与“构造函数”(constructor)相对应的概念。
“构造函数”是对象被创建的时候调用的函数,“析构函数”是对象被销毁的时候调用的函数。
Rust中没有统一的“构造函数”这个语法,对象的构造是直接对每个成员进行初始化完成的,我们一般将对象的创建封装到普通静态函数中。
相对于构造函数,析构函数有更重要的作用。
它会在对象消亡之前由编译器自动调用,因此特别适合承担对象销毁时释放所拥有的资源的作用。
比如,Vec类型在使用的过程中,会根据情况动态申请内存,当变量的生命周期结束时,就会触发该类型的析构函数的调用。
在析构函数中,我们就有机会将所拥有的内存释放掉。
在析构函数中,我们还可以根据需要编写特定的逻辑,从而达到更多的目的。
析构函数不仅可以用于管理内存资源,还能用于管理更多的其他资源,如文件、锁、socket等。
在C++中,利用变量生命周期绑定资源的使用周期,已经是一种常用的编程惯例。
此手法被称为RAII (Resource Acquisition Is Initialization)。
在变量生命周期开始时申请资源,在变量生命周期结束时利用析构函数释放资源,从而达到自动化管理资源的作用,很大程度上减少了资源的泄露和误用。
在Rust中编写“析构函数”的办法是impl std::ops::Drop。Drop trait的定义如下:
Drop trait允许在对象即将消亡之时,自行调用指定代码。我们来写一个自带析构函数的类型。示例如下:
编译,执行结果为:
从上面这段程序可以看出析构函数的调用时机。
变量_y的生存期是内部的大括号包围起来的作用域(scope),待这个作用域中的代码执行完之后,它的析构函数就被调用;变量_x的生存期是整个main函数包围起来的作用域,待这个函数的最后一条语句执行完之后,它的析构函数就被调用。
对于具有多个局部变量的情况,析构函数的调用顺序是:先构造的后析构,后构造的先析构。因为局部变量存在于一个“栈”的结构中,要保持“先进后出”的策略。
资源管理
在创建变量的时候获取某种资源,在变量生命周期结束的时候释放资源,是一种常见的设计模式。
这里的资源,不仅可以包括内存,还可以包括其他向操作系统申请的资源。
比如我们经常用到的File类型,会在创建和使用的过程中向操作系统申请打开文件,在它的析构函数中就会去释放文件。
所以,RAⅡ手法是比GC更通用的资源管理手段,GC只能管理内存,RAIⅡ可以管理各种资源。
下面用Rust标准库中的“文件”类型,来展示一下RAIⅡ手法。示例如下:
除去那些错误处理的代码以后,整个逻辑实际上相当清晰:首先使用open函数打开文件,然后使用read_to_string方法读取内容,最后关闭文件,这里不需要手动关闭文件,因为在File类型的析构函数中已经自动处理好了关闭文件这件事情。
再比如标准库中的各种复杂数据结构(如vec linkedList HashMap等),它们管理了很多在堆上动态分配的内存。
它们也是利用“析构函数”这个功能,在生命终结之前释放了申请的内存空间,因此无须像C语言那样手动调用free函数。
主动析构
一般情况下,局部变量的生命周期是从它的声明开始,到当前语句块结束。然而,我们也可以手动提前结束它的生命周期。请注意,用户主动调用析构函数是非法的,示例如下:
这说明编译器不允许手动调用析构函数。那么,我们怎样才能让局部变量在语句块结束前提前终止生命周期呢?办法是调用标准库中的std::mem::drop函数:
这段代码会编译出错,是因为调用drop方法的时候,v的生命周期就结束了,后面继续使用变量v就会发生编译错误。
那么,标准库中的std::mem::drop函数是怎样实现的呢?可能许多人想不到的是,这个函数是Rust中最简单的函数,因为它的实现为“空”:
drop函数不需要任何的函数体,只需要参数为“值传递”即可。将对象的所有权移人函数中,什么都不用做,编译器就会自动释放掉这个对象了。
因为这个drop函数的关键在于使用move语义把参数传进来,使得变量的所有权从调用方移动到drop函数体内,参数类型一定要是T,而不是&T或者其他引用类型。
函数体本身其实根本不重要,重要的是把变量的所有权move进入这个函数体中,函数调用结束的时候该变量的生命周期结束,变量的析构函数会自动调用,管理的内存空间也会自然释放。
这个过程完全符合前面讲的生命周期、move语义,无须编译器做特殊处理。
事实上,我们完全可以自己写一个类似的函数来实现同样的效果,只要保证参数传递是move语义即可。
因此,对于Copy类型的变量,对它调用std::mem::drop函数是没有意义的。下面以整数类型作为示例来说明:
这种情况很容易理解。因为Copy类型在函数参数传递的时候执行的是复制语义,原来的那个变量依然存在,传入函数中的只是一个复制品,因此原变量的生命周期不会受到影响。
变量遮蔽(Shadowing)不会导致变量生命周期提前结束,它不等同于drop。示例如下:
编译,执行,输出的结果为:
这里函数调用的顺序为:先创建第一个x,再创建第二个x,退出函数的时候,先析构第二个x,再析构第一个x。由此可见,在第二个x出现的时候,虽然将第一个x遮蔽起来了,但是第一个x的生命周期并未结束,它依然存在,直到函数退出。这也说明了,虽然这两个变量绑定了同一个名字,但在编译器内部依然将它们视为两个不同的变量。
另外还有一个小问题需要提醒注意,那就是下划线这个特殊符号。请注意:如果你用下划线来绑定一个变量,那么这个变量会当场执行析构,而不是等到当前语句块结束的时候再执行。下划线是特殊符号,不是普通标识符。示例如下:
执行结果为:
之所以是这样的结果,是因为用下划线绑定的那个变量当场就执行了析构,而其他两个变量等到语句块结束了才执行析构,而且析构顺序和初始化顺序刚好相反。所以,如果大家需要利用RAⅡ实现某个变量的析构函数在退出作用域的时候完成某些功能,千万不要用下划线来绑定这个变量。
最后,请大家注意区分,std::mem::drop()函数和std::ops::Drop::drop()方法。
- std::mem::drop()函数是一个独立的函数,不是某个类型的成员方法,它由程序员主动调用,作用是使变量的生命周期提前结束;std::ops::Drop::drop()方法是一个trait中定义的方法,当变量的生命周期结束的时候,编译器会自动调用,手动调用是不允许的。
- std::mem::drop(_x:T)的参数类型是T,采用的是move语义;std::ops::Drop::drop(&mut self)的参数类型是&mut Self,采用的是可变借用。在析构函数调用过程中,我们还有机会读取或者修改此对象的属性。
析构标记
在Rust里面,析构函数是在变量生命周期结束的时候被调用的。
然而,既然我们可以手动提前终止变量的生命周期,那么就说明,变量的生命周期并不是简单地与某个代码块一致,生命周期何时结束,很可能是由运行时的条件决定的。
下面用一个示例来说明变量的析构函数调用时机是有可能在运行阶段发生改变的:
在上面这段示例代码中,我们在变量的析构函数中写了一条打印语句,用于判断析构函数的调用顺序。
在主函数里面,则通过判断当前的环境变量信息来决定是否提前终止某个变量的生命周期。
编译执行,如果我们没有设置DROP环境变量,输出结果为:
如果我们设置了export DROP=2这个环境变量,不重新编译,执行同样的代码,输出结果为:
我们还可以将DROP环境变量的值分别改为“1”、“2”、“3”,结果会导致析构函数的调用顺序发生变化。
然而,问题来了,前面说过,析构函数的调用是在编译阶段就确定好了的,调用析构函数是编译器自动插入的代码做的。
而且示例又表明,析构函数的具体调用时机还是跟运行时的情况相关的。那么编译器是怎么做到的呢?
编译器是这样完成这个功能的:首先判断一个变量是否可能会在多个不同的路径上发生析构,如果是这样,那么它会在当前函数调用栈中自动插入一个bool类型的标记,用于标记该对象的析构函数是否已经被调用,生成的代码逻辑像下面这样:
编译器生成的代码类似于上面的示例,可能会有细微差别。
原理是在析构函数被调用的时候,就把标记设置一个状态,在各个可能调用析构函数的地方都先判断一下状态再调用析构函数。
这样,编译阶段确定生命周期和执行阶段根据情况调用就统一起来了。