条款11:实现RAII模式的Drop trait

“永远不要派人去做机器的工作。”——Agent Smith

RAII 代表“资源获取即初始化”,这是一种编程模式,其中值的生命周期与某些附加资源的生命周期完全相关。RAII 模式由 C++ 编程语言推广,是 C++ 对编程的最大贡献之一。

值的生命周期与资源的生命周期之间的相关性编码在 RAII 类型中:

  • 类型的构造函数获取对某些资源的访问权限
  • 类型的析构函数释放对该资源的访问权限

结果是 RAII 类型具有不变量:当且仅当项存在时,才可以访问底层资源。由于编译器确保在范围退出时销毁局部变量,这反过来意味着在范围退出时也会释放底层资源。

这对于可维护性特别有用:如果对代码的后续更改改变了控制流,则项和资源的生命周期仍然正确。要了解这一点,请考虑一些手动锁定和解锁互斥体的代码,而不使用 RAII 模式;该代码是用 C++ 编写的,因为 Rust 的 Mutex 不允许这种容易出错的使用!

// C++ code
class ThreadSafeInt {
 public:
  ThreadSafeInt(int v) : value_(v) {}

  void add(int delta) {
    mu_.lock();
    // ... more code here
    value_ += delta;
    // ... more code here
    mu_.unlock();
  }

通过提前退出来捕获错误情况的修改会使互斥锁保持锁定状态:

// C++ code
void add_with_modification(int delta) {
  mu_.lock();
  // ... more code here
  value_ += delta;
  // Check for overflow.
  if (value_ > MAX_INT) {
    // Oops, forgot to unlock() before exit
    return;
  }
  // ... more code here
  mu_.unlock();
}

但是,将锁定行为封装到 RAII 类中:

// C++ code (real code should use std::lock_guard or similar)
class MutexLock {
 public:
  MutexLock(Mutex* mu) : mu_(mu) { mu_->lock(); }
  ~MutexLock()                   { mu_->unlock(); }
 private:
  Mutex* mu_;
};

意味着等效代码对于这种修改是安全的:

// C++ code
void add_with_modification(int delta) {
  MutexLock with_lock(&mu_);
  // ... more code here
  value_ += delta;
  // Check for overflow.
  if (value_ > MAX_INT) {
    return; // Safe, with_lock unlocks on the way out
  }
  // ... more code here
}

在 C++ 中,RAII 模式最初通常用于内存管理,以确保手动分配(newmalloc())和释放(deletefree())操作保持同步。此内存管理的通用版本已添加到 C++11 中的 C++ 标准库中:std::unique_ptr<T> 类型确保单个位置具有内存的“所有权”,但允许“借用”指向内存的指针以供临时使用(ptr.get())。

在 Rust 中,内存指针的这种行为内置于语言中(第 15 条),但 RAII 的一般原则对于其他类型的资源仍然有用。为任何包含必须释放的资源的类型实现 Drop,例如:

  • 访问操作系统资源。对于 Unix 派生的系统,这通常意味着包含文件描述符的东西;如果无法正确释放这些资源,则会占用系统资源(并且最终还会导致程序达到每个进程的文件描述符限制)。
  • 访问同步资源。标准库已经包含内存同步原语,但其他资源(例如文件锁、数据库锁等)可能需要类似的封装。
  • 对于处理低级内存管理的不安全类型(例如,用于外部函数接口 [FFI] 功能),可以访问原始内存。

Rust 标准库中最明显的 RAII 实例是 Mutex::lock() 操作返回的 MutexGuard 项,它往往广泛用于使用第 17 项中讨论的共享状态并行性的程序。这大致类似于前面显示的最后一个 C++ 示例,但在 Rust 中,MutexGuard 项除了是持有锁的 RAII 项之外,还充当互斥保护数据的代理:

#![allow(unused)]
fn main() {
use std::sync::Mutex;

struct ThreadSafeInt {
    value: Mutex<i32>,
}

impl ThreadSafeInt {
    fn new(val: i32) -> Self {
        Self {
            value: Mutex::new(val),
        }
    }
    fn add(&self, delta: i32) {
        let mut v = self.value.lock().unwrap();
        *v += delta;
    }
}
}

第 17 条建议不要对大段代码保持锁定;为确保这一点,请使用块来限制 RAII 项的范围。这会导致略微奇怪的缩进,但为了增加安全性和生命周期精度,这是值得的:

#![allow(unused)]
fn main() {
impl ThreadSafeInt {
    fn add_with_extras(&self, delta: i32) {
        // ... more code here that doesn't need the lock
        {
            let mut v = self.value.lock().unwrap();
            *v += delta;
        }
        // ... more code here that doesn't need the lock
    }
}
}

在介绍了 RAII 模式的用途之后,有必要解释一下如何实现它。 Drop trait允许您将用户定义的行为添加到项目的销毁中。 此特性只有一个方法 drop,编译器会在释放保存该项目的内存之前运行该方法:

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct MyStruct(i32);

impl Drop for MyStruct {
    fn drop(&mut self) {
        println!("Dropping {self:?}");
        // Code to release resources owned by the item would go here.
    }
}
}

drop 方法是专门为编译器保留的,不能手动调用:

#![allow(unused)]
fn main() {
x.drop();
}
#![allow(unused)]
fn main() {
error[E0040]: explicit use of destructor method
  --> src/main.rs:70:7
   |
70 |     x.drop();
   |     --^^^^--
   |     | |
   |     | explicit destructor calls not allowed
   |     help: consider using `drop` function: `drop(x)`
}

值得了解一下这里的技术细节。请注意,Drop::drop 方法的签名是 drop(&mut self),而不是 drop(self):它获取对项目的可变引用,而不是将项目移入方法中。如果 Drop::drop 像普通方法一样运行,则意味着该项目之后仍可供使用 - 即使其所有内部状态都已整理好并且资源已释放!

#![allow(unused)]
fn main() {
{
    // If calling `drop` were allowed...
    x.drop(); // (does not compile)

    // `x` would still be available afterwards.
    x.0 += 1;
}
// Also, what would happen when `x` goes out of scope?
}

编译器建议了一种简单的替代方案,即调用 drop() 函数手动删除一个项目。此函数确实接受一个移动的参数,而 drop(_item: T) 的实现只是一个空的主体 { } — 因此当到达该范围的右括号时,移动的项目将被删除。

还请注意,drop(&mut self) 方法的签名没有返回类型,这意味着它无法发出失败信号。如果尝试释放资源可能会失败,那么您可能应该有一个单独的 release 方法返回结果,这样用户就可以检测到此失败。

无论技术细节如何,drop 方法仍然是实现 RAII 模式的关键位置;它的实现是释放与项目相关的资源的理想位置。