Skip to content

11.6 析构函数

所谓“析构函数”(destructor),是与“构造函数”(constructor)相对应的概念。“构造函数”是对象被创建的时候调用的函数,“析构函数”是对象被销毁的时候调用的函数。

Rust 中没有统一的“构造函数”这个语法,对象的构造是直接对每个成员进行初始化完成的,我们一般将对象的创建封装到普通静态函数中。

相对于构造函数,析构函数有更重要的作用。它会在对象消亡之前由编译器自动调用,因此特别适合承担对象销毁时释放所拥有的资源的作用。比如,Vec 类型在使用的过程中,会根据情况动态申请内存,当变量的生命周期结束时,就会触发该类型的析构函数的调用。在析构函数中,我们就有机会将所拥有的内存释放掉。在析构函数中,我们还可以根据需要编写特定的逻辑,从而达到更多的目的。析构函数不仅可以用于管理内存资源,还能用于管理更多的其他资源,如文件、锁、socket 等。

在 C++中,利用变量生命周期绑定资源的使用周期,已经是一种常用的编程惯例。此手法被称为 RAII(Resource Acquisition Is Initialization)。在变量生命周期开始时申请资源,在变量生命周期结束时利用析构函数释放资源,从而达到自动化管理资源的作用,很大程度上减少了资源的泄露和误用。

在 Rust 中编写“析构函数”的办法是 impl std::ops::Drop。Drop trait 的定义如下:


rust
trait Drop {
    fn drop(&mut self);
}

Drop trait 允许在对象即将消亡之时,自行调用指定代码。我们来写一个自带析构函数的类型。示例如下:


rust
use std::ops::Drop;

struct D(i32);
impl Drop for D {
    fn drop(&mut self) {
        println!("destruct {}", self.0);
    }
}
fn main() {
    let _x = D(1);
    println!("construct 1");
    {
        let _y = D(2);
        println!("construct 2");
        println!("exit inner scope");
    }
    println!("exit main function");
}

编译,执行结果为:


rust
construct 1
construct 2
exit inner scope
destruct 2
exit main function
destruct 1

从上面这段程序可以看出析构函数的调用时机。变量_y 的生存期是内部的大括号包围起来的作用域(scope),待这个作用域中的代码执行完之后,它的析构函数就被调用;变量_x 的生存期是整个 main 函数包围起来的作用域,待这个函数的最后一条语句执行完之后,它的析构函数就被调用。

对于具有多个局部变量的情况,析构函数的调用顺序是:先构造的后析构,后构造的先析构。因为局部变量存在于一个“栈”的结构中,要保持“先进后出”的策略。

11.6.1 资源管理

在创建变量的时候获取某种资源,在变量生命周期结束的时候释放资源,是一种常见的设计模式。这里的资源,不仅可以包括内存,还可以包括其他向操作系统申请的资源。比如我们经常用到的 File 类型,会在创建和使用的过程中向操作系统申请打开文件,在它的析构函数中就会去释放文件。所以,RAII 手法是比 GC 更通用的资源管理手段,GC 只能管理内存,RAII 可以管理各种资源。

下面用 Rust 标准库中的“文件”类型,来展示一下 RAII 手法。示例如下:


rust
use std::fs::File;
use std::io::Read;

fn main() {
    let f = File::open("/target/file/path");
    if f.is_err() {
        println!("file is not exist.");
        return;
    }
    let mut f = f.unwrap();
    let mut content = String::new();
    let result = f.read_to_string(&mut content);
    if result.is_err() {
        println!("read file error.");
        return;
    }
    println!("{}", result.unwrap());
}

除去那些错误处理的代码以后,整个逻辑实际上相当清晰:首先使用 open 函数打开文件,然后使用 read_to_string 方法读取内容,最后关闭文件,这里不需要手动关闭文件,因为在 File 类型的析构函数中已经自动处理好了关闭文件这件事情。

再比如标准库中的各种复杂数据结构(如 Vec、LinkedList、HashMap 等),它们管理了很多在堆上动态分配的内存。它们也是利用“析构函数”这个功能,在生命终结之前释放了申请的内存空间,因此无须像 C 语言那样手动调用 free 函数。

11.6.2 主动析构

一般情况下,局部变量的生命周期是从它的声明开始,到当前语句块结束。然而,我们也可以手动提前结束它的生命周期。请注意,用户主动调用析构函数是非法的,示例如下:


rust
fn main() {
    let p = Box::new(42);
    p.drop();
    println!("{}", p);
}

编译,可见错误信息:


rust
error[E0040]: explicit use of destructor method
 --> test.rs:5:7
  |
5 |     p.drop();
  |       ^^^^ explicit destructor calls not allowed

这说明编译器不允许手动调用析构函数。那么,我们怎样才能让局部变量在语句块结束前提前终止生命周期呢?办法是调用标准库中的 std::mem::drop 函数:


rust
use std::mem::drop;

fn main() {
    let mut v = vec![1, 2, 3];         // <--- v 的生命周期开始
    drop(v);                           // ---> v 的生命周期结束
    v.push(4);                         // 错误的调用
}

这段代码会编译出错,是因为调用 drop 方法的时候,v 的生命周期就结束了,后面继续使用变量 v 就会发生编译错误。

那么,标准库中的 std::mem::drop 函数是怎样实现的呢?可能许多人想不到的是,这个函数是 Rust 中最简单的函数,因为它的实现为“空”:


rust
#[inline]
pub fn drop<T>(_x: T) { }

drop 函数不需要任何的函数体,只需要参数为“值传递”即可。将对象的所有权移入函数中,什么都不用做,编译器就会自动释放掉这个对象了。

因为这个 drop 函数的关键在于使用 move 语义把参数传进来,使得变量的所有权从调用方移动到 drop 函数体内,参数类型一定要是 T,而不是&T 或者其他引用类型。函数体本身其实根本不重要,重要的是把变量的所有权 move 进入这个函数体中,函数调用结束的时候该变量的生命周期结束,变量的析构函数会自动调用,管理的内存空间也会自然释放。这个过程完全符合前面讲的生命周期、move 语义,无须编译器做特殊处理。事实上,我们完全可以自己写一个类似的函数来实现同样的效果,只要保证参数传递是 move 语义即可。

因此,对于 Copy 类型的变量,对它调用 std::mem::drop 函数是没有意义的。下面以整数类型作为示例来说明:


rust
use std::mem::drop;

fn main() {
    let x = 1_i32;
    println!("before drop {}", x);
    drop(x);
    println!("after drop {}", x);
}

这种情况很容易理解。因为 Copy 类型在函数参数传递的时候执行的是复制语义,原来的那个变量依然存在,传入函数中的只是一个复制品,因此原变量的生命周期不会受到影响。

变量遮蔽(Shadowing)不会导致变量生命周期提前结束,它不等同于 drop。示例如下:


rust
use std::ops::Drop;

struct D(i32);

impl Drop for D {
    fn drop(&mut self) {
        println!("destructor for {}", self.0);
    }
}

fn main() {
    let x = D(1);
    println!("construct first variable");
    let x = D(2);
    println!("construct second variable");
}

编译,执行,输出的结果为:


rust
construct first variable
construct second variable
destructor for 2
destructor for 1

这里函数调用的顺序为:先创建第一个 x,再创建第二个 x,退出函数的时候,先析构第二个 x,再析构第一个 x。由此可见,在第二个 x 出现的时候,虽然将第一个 x 遮蔽起来了,但是第一个 x 的生命周期并未结束,它依然存在,直到函数退出。这也说明了,虽然这两个变量绑定了同一个名字,但在编译器内部依然将它们视为两个不同的变量。

另外还有一个小问题需要提醒读者注意,那就是下划线这个特殊符号。请注意:如果你用下划线来绑定一个变量,那么这个变量会当场执行析构,而不是等到当前语句块结束的时候再执行。下划线是特殊符号,不是普通标识符。示例如下:


rust
use std::ops::Drop;

struct D(i32);

impl Drop for D {
    fn drop(&mut self) {
        println!("destructor for {}", self.0);
    }
}

fn main() {
    let _x = D(1);
    let _  = D(2);
    let _y = D(3);
}

执行结果为:


rust
destructor for 2
destructor for 3
destructor for 1

之所以是这样的结果,是因为用下划线绑定的那个变量当场就执行了析构,而其他两个变量等到语句块结束了才执行析构,而且析构顺序和初始化顺序刚好相反。所以,如果大家需要利用 RAII 实现某个变量的析构函数在退出作用域的时候完成某些功能,千万不要用下划线来绑定这个变量。

最后,请大家注意区分,std::mem::drop()函数和std::ops::Drop::drop()方法。

1)std::mem::drop()函数是一个独立的函数,不是某个类型的成员方法,它由程序员主动调用,作用是使变量的生命周期提前结束;std::ops::Drop::drop()方法是一个 trait 中定义的方法,当变量的生命周期结束的时候,编译器会自动调用,手动调用是不允许的。

2)std::mem::drop<T>(_x:T)的参数类型是T,采用的是 move 语义;std::ops::Drop::drop(&mut self)的参数类型是&mut Self,采用的是可变借用。在析构函数调用过程中,我们还有机会读取或者修改此对象的属性。

11.6.3 Drop VS.Copy

前面已经讲了,要想实现 Copy trait,类型必须满足一定条件。这个条件就是:如果一个类型可以使用 memcpy 的方式执行复制操作,且没有内存安全问题,那么它才能被允许实现 Copy trait。反过来,所有满足 Copy trait 的类型,在需要执行 move 语义的时候,使用 memcpy 复制一份副本,不删除原件是完全不会产生安全问题的。

本节中需要强调的是,带有析构函数的类型都是不能满足 Copy 语义的。因为我们不能保证,对于带析构函数的类型,使用 memcpy 复制一个副本一定不会有内存安全问题。所以对于这种情况,编译器是直接禁止的。

同样,下面还是用示例来说明:


rust
use std::ops::Drop;

struct T;

impl Drop for T {
    fn drop(&mut self){}
}

impl Copy for T {}

fn main() {}

编译,可见编译错误:


rust
error[E0184]: the trait `Copy` may not be implemented for this type; the type has a destructor
 --> test.rs:9:1
  |
9 | impl Copy for T {}
  | ^^^^^^^^^^^^^^^^^^ Copy not allowed on types with destructors

错误信息说得清清楚楚,带有析构函数的类型不能是 Copy。这两个身份是不能同时存在于一个类型上的。

11.6.4 析构标记

在 Rust 里面,析构函数是在变量生命周期结束的时候被调用的。然而,既然我们可以手动提前终止变量的生命周期,那么就说明,变量的生命周期并不是简单地与某个代码块一致,生命周期何时结束,很可能是由运行时的条件决定的。下面用一个示例来说明变量的析构函数调用时机是有可能在运行阶段发生改变的:


rust
use std::ops::Drop;
use std::mem::drop;

struct D(&'static str);
impl Drop for D {
    fn drop(&mut self) {
        println!("destructor {}", self.0);
    }
}

// 获取 DROP 环境变量的值,并转换为整数
fn condition() -> Option<u32> {
    std::env::var("DROP")
        .map(|s| s.parse::<u32>().unwrap_or(0))
        .ok()
}

fn main() {
    let var = (D("first"), D("second"), D("third"));
    match condition() {
        Some(1) => drop(var.0),
        Some(2) => drop(var.1),
        Some(3) => drop(var.2),
        _ => {},
    }
    println!("main end");
}

在上面这段示例代码中,我们在变量的析构函数中写了一条打印语句,用于判断析构函数的调用顺序。在主函数里面,则通过判断当前的环境变量信息来决定是否提前终止某个变量的生命周期。

编译执行,如果我们没有设置 DROP 环境变量,输出结果为:


rust
main end
destructor first
destructor second
destructor third

如果我们设置了 export DROP=2 这个环境变量,不重新编译,执行同样的代码,输出结果为:


rust
destructor second
main end
destructor first
destructor third

我们还可以将 DROP 环境变量的值分别改为“1”、“2”、“3”,结果会导致析构函数的调用顺序发生变化。

然而,问题来了,前面说过,析构函数的调用是在编译阶段就确定好了的,调用析构函数是编译器自动插入的代码做的。而且示例又表明,析构函数的具体调用时机还是跟运行时的情况相关的。那么编译器是怎么做到的呢?

编译器是这样完成这个功能的:首先判断一个变量是否可能会在多个不同的路径上发生析构,如果是这样,那么它会在当前函数调用栈中自动插入一个 bool 类型的标记,用于标记该对象的析构函数是否已经被调用,生成的代码逻辑像下面这样:


rust
// 以下为伪代码,仅仅是示意
fn main() {
    let var = (D("first"), D("second"), D("third"));
    // 当函数中有拥有所有权的对象时,需要有析构自动标记
    let drop_flag_0 = false;  // ---
    let drop_flag_1 = false;  // ---
    let drop_flag_2 = false;  // ---

    // 退出语句块时,对当前 block 内拥有所有权的对象调用析构函数,并设置标记
    match condition() {
        Some(1) => {
            drop(var.0);
            if (!drop_flag_0) {      // ---
                drop_flag_0 = true;  // ---
            }                        // ---
        }
        Some(2) => {
            drop(var.1);
            if (!drop_flag_1) {      // ---
                drop_flag_1 = true;  // ---
            }                        // ---
        }
        Some(3) => {
            drop(var.2);
            if (!drop_flag_2) {      // ---
                drop_flag_2 = true;  // ---
            }                        // ---
        }
        _ => {},
    }

    println!("main end");
    // 退出语句块时,对当前 block 内拥有所有权的对象调用析构函数,并设置标记
    if (!drop_flag_0) {              // ---
        drop(var.0);                 // ---
        drop_flag_0 = true;          // ---
    }                                // ---
    if (!drop_flag_1) {              // ---
        drop(var.1);                 // ---
        drop_flag_1 = true;          // ---
    }                                // ---
    if (!drop_flag_2) {              // ---
        drop(var.2);                 // ---
        drop_flag_2 = true;          // ---
    }                                // ---
}

编译器生成的代码类似于上面的示例,可能会有细微差别。原理是在析构函数被调用的时候,就把标记设置一个状态,在各个可能调用析构函数的地方都先判断一下状态再调用析构函数。这样,编译阶段确定生命周期和执行阶段根据情况调用就统一起来了。

Released under the MIT License