哇哦,强大的rust资源管理真的让人欲罢不能

188 阅读6分钟

一门语言的资源管理往往决定了这门语言的好坏,因此一门好的语言也一定会有一个好的资源管理方式。那么rust的资源管理是怎么回事呢?今天就带大家去一探究竟。

基于所有权的资源管理

OBRM(又被成为 RAII:Resource Acquisition is Initialization,资源获取即初始化),在 Rust 中你会有很多和它打交道的机会,特别是在使用标准库的时候。

这个模式简单来说是这样的:如果要获取资源,你只要创建一个管理它的对象。如果要释放资源,你只要销毁这个对象,由对象负责为你回收资源。而所谓资源通常指的就是内存。Box,Rc,以及 std::collections 中几乎所有的东西都是为了方便且正确地管理内存而存在的。这对于 Rust 尤为重要,因为我们并没有垃圾回收器帮我们管理内存。关键点就在这:Rust 要掌控一切。不过我们并不是只能管理内存。差不多所有的系统资源,比如线程、文件、还有 socket,都可以用到这些 API。

构造函数

我们创建一个自定义的结构体类型,然后初始化它。

struct Foo {
    a: u8,
    b:u32,
    c: bool,
}

enum Bar {
    X(u32),
    Y(bool),
}

struct Unit;

let foo = Foo { a: 0, b: 1, c: false };
let bar = Bar::X(0);
let empty = Unit;

就是这样。其他的所谓创建类型实例的方式,不过是调用一些函数,而函数的底层还是要依赖于这个真正的构造函数。

和 C++ 不同,Rust 没有很多不同种类的构造函数,比如拷贝、默认、赋值、移动、还有其他各种构造函数。之所以这样的原因有很多,不过归根结底还是因为 Rust 显式化的设计哲学。

移动构造函数对于 Rust 没什么用,因为我们并不需要让类型关心它们在内存上的位置。没一个类型都有可能随时被 memcopy 到内存中其他的位置上。这也意味和那种存储于栈上却依然可以移动的侵入式链表在 Rust 中是不可能(安全地)存在的。

复制和拷贝构造函数也是不存在的,因为 Rust 中的类型有且仅有移动语义。x = y 只是将 y 的字节移动到 x 的变量中。Rust 倒是提供了两种和 C++ 中的 copy 语义相似的功能:Copy 和 Clone。Clone 很像是拷贝构造函数,但是它不会被隐式调用。你必须在需要复制的元素上显式调用 clone 方法、Copy 是 Clone 的一个特例,它的实现只会拷贝字节码。Copy 类型在移动的时候会隐式地复制,但是因为 Copy 的定义,这个方法只是不把旧的值设置为未初始化而已 —— 其实是一个 no-op。

虽然 Rust 确实有一个 Default trait,它与默认构造函数很相似,但是这个 trait 极少被用到。这是因为变量不会被隐式初始化。Default 一般只有在泛型编程中才有用。而具体的类型会提供一个 new 静态方法来实现默认构造函数的功能。这个和其他语言中的 new 关键字没什么关系,也没有什么特殊的含义。它仅仅是一个命名习惯而已。

析构函数

Rust 通过 Drop trait 提供了一个成熟的自动析构函数,它的主要方法就是:

fn drop(&mut self);

这个方法的牛逼之处在于给了类型一个彻底完成工作的机会。

⚠️ drop 执行之后,Rust 会递归地销毁 self 的所有成员

这个功能很方便,你不需要每次都写一堆重复的代码来销毁子类型。如果一个结构体在销毁的时候,除了销毁子成员之外不需要做什么特殊的操作,那么它其实可以不用实现 Drop。

比如,一个自定义的 Box 的实现,它的 Drop 可能长这样:

#![feature(ptr_internals, allocator_api)]

use std::alloc::{Alloc, Global, GlobalAlloc, Layout};
use std::mem;
use std::ptr::{drop_in_place, NonNull, Unique};

struct Box<T>{ ptr: Unique<T> }

impl<T> Drop for Box<T> {
    fn drop(&mut self) {
        unsafe {
            drop_in_place(self.ptr.as_ptr());
            let c: NonNull<T> = self.ptr.into();
            Global.dealloc(c.cast(), Layout::new::<T>())
        }
    }
}

这段代码是正确的,因为当 Rust 要销毁 ptr 的时候,它见到的是一个 Unique,没有 Drop 的实现。类似的,也没有人能在销毁后再使用 ptr,因为 drop 函数退出之后,他就不可见了。

看完正确的代码啊后我们看下下面这段错误的代码:

#![feature(allocator_api, ptr_internals)]

use std::alloc::{Alloc, Global, GlobalAlloc, Layout};
use std::ptr::{drop_in_place, Unique, NonNull};
use std::mem;

struct Box<T> { ptr: Unique<T> }

impl<T> Drop for Box<T> {
    fn drop(&mut self) {
        unsafe {
            drop_in_place(self.ptr.as_ptr());
            let c: NonNull<T> = self.ptr.into();
            Global.dealloc(c.cast(), LayOut::new::<T>());
        }
    }
}

struct SuperBox<T> ( my_box: Box<T> )

impl<T> Drop for SuperBox<T> {
    fn drop(&mut self) {
        // 回收box的内容,而不是drop它的内容
        let c: NonNull<T> = self.my_box.ptr.into();
        Global.dealloc(c.cast::<u8>(), LayOut::new::<T>());
    }
}

当我们在 SuperBox 的析构函数里回收了 box 的 ptr 之后,Rust 会继续让 box 销毁它自己,这时销毁后使用 (use-after-free) 和两次释放 (double-free) 的问题立刻接踵而至,摧毁一切。

注意,递归销毁适用于所有的结构体和枚举类型,不管它有没有实现 Drop。所以,这段代码

struct Boxy<T> {
    data1: Box<T>,
    data2: Box<T>,
    info: u32,
}

在销毁的时候也会调用 data1 和 data2 的析构函数,尽管这个结构体本身并没有实现 Drop。这样的类型 “需要 Drop 却不是 Drop”。 类似的:

enum Link {
    Next(Box<Link>),
    None,
}

当(且仅当)一个实例储存着 Next 变量时,它就会销毁内部的 Box 成员。

一般来说这其实是一个很好的设计,它让你在重构数据布局的时候无需费心添加 / 删除 drop 函数。但也有很多的场景要求我们必须在析构函数中玩一些花招。

如果想阻止递归销毁并且在 drop 过程中将 self 的所有权移出,通常的安全的做法是使用 Option:

#![feature(allocator_api, ptr_internals)]

use std::alloc::{Alloc, GlobalAlloc, Global, LayOut};
use std::ptr::{drop_in_place, Unique, NonNull};
use std::mem;

struct Box<T>{ ptr: Unique<T> }

impl<T> Drop for Box<T> {
    fn drop(&mut self) {
        unsafe {
            drop_in_place(self.ptr.as_ptr());
            let c: NonNull<T> = self.ptr.into();
            Global.dealloc(c.cast(), LayOut::new::<T>());
        }
    }
}

struct SuperBox<T> { my_box: Option<Box<T>> }

impl<T> Drop for SuperBox<T> {
    fn drop(&mut self) {
        unsafe {
            // 回收box的内容,而不是drop它的内容
            // 需要将box设置为None,以阻止Rust销毁它
            let my_box = self.my_box.take().unwrap();
            let c: NonNull<T> = my_box.ptr.into();
            Global.dealloc(c.cast(), LayOut::new::<T>());
            mem::forget(my_box);
        }
    }
}

a7f1246149aa5b95e00756a927e1e0ad.png

但是这段代码显得很奇怪:我们认为一个永远都是 Some 的成员有可能是 None,仅仅因为析构函数中用到了一次。但反过来说这种设计又很合理:你可以在析构函数中调用 self 的任意方法。在成员被反初始化之后就完全不能这么做了,而不是禁止你搞出一些随意的非法状态。(斜体部分没看懂,建议看原文)

权衡之后,这是一个可以接受的方案。你可以将它作为你的默认选项。但是,我们希望以后能有一个方法明确声明哪一个成员不会自动销毁。

想要了解更多的rust请关注我的公众号:【花说编程】