在偷懒了一天后终于想起了把昨天落下的补上(昨天下班回家就睡着了,真是睡的舒坦)。

在入门篇的时候有所提及所有权,但是感觉仍然不够详细,所以今天补上。

1. 所有权的核心原则

1.1 所有权三原则

Rust的所有权机制遵循以下三条核心原则:

  1. 每个值都有一个被称为其“所有者”(Owner)的变量:在Rust程序中,任何一块数据(无论在栈上还是堆上)都明确地归属于一个变量。
  2. 一个值在同一时间只能有一个所有者:这种排他性的所有权是防止数据竞争的关键。当多个部分的代码试图同时修改同一数据时,就会产生不可预测的行为。Rust从根本上杜绝了这种情况。
  3. 当所有者离开作用域(Scope)时,其拥有的值将被自动丢弃(Drop)‍:Rust编译器会自动在变量离开作用域的地方插入代码,以释放其拥有的资源。这被称为“资源获取即初始化”(RAII)模式,它保证了内存和文件句柄、网络套接字等其他资源能够被及时、自动地清理,无需垃圾回收器。

1.2 所有权转移:移动(Move)语义

在Rust中,对于大多数存储在堆上的数据类型(如 String、Vec< T >),赋值、函数传参或函数返回等操作并非简单的“浅拷贝”,而是所有权的转移,也称为“移动”(Move)。

当一个变量 x 的值被赋给另一个变量 y 时,x 的所有权会转移给 y。此后,为了防止“二次释放”(double free)错误——即两个变量在离开作用域时尝试释放同一块内存——原来的变量 x 会立即失效,任何后续对 x 的使用都将在编译时被禁止 。

示例:

let s1 = String::from("hello");
let s2 = s1; // s1的所有权转移给s2

// println!("s1 = {}", s1); // 编译错误!s1已经失效,其值已被移动
println!("s2 = {}", s2); // 正常,s2现在是所有者

1.3 替代移动:复制(Copy)语义

对于完全存储在栈上的简单数据类型,如整数(i32)、浮点数(f64)、布尔值(bool)和字符(char)等,进行移动操作的开销较大且不直观。因此,这些类型实现了 Copy trait。

对于实现了 Copy trait 的类型,赋值操作会创建一个值的完整副本,而不是转移所有权。这意味着旧的变量在赋值后依然有效 。

  • Copy vs Clone:Copy trait 用于可以进行简单按位复制的类型,这个过程是隐式的。而 Clone trait 则提供了一个显式的 .clone() 方法,用于创建数据的深拷贝(deep copy),通常涉及堆内存的分配。一个类型可以同时实现 Copy 和 Clone,此时 .clone() 的实现通常就是简单的复制。

1.4 部分移动(Partial Moves)

Rust允许我们进行更精细的所有权控制,即部分移动。我们可以从一个复合类型(如结构体或元组)中移动其中某些字段的所有权,而其他的字段不受影响。

当一个结构体实例的某个字段被移走后,整个结构体变量本身将变为“部分移动”状态。此时,你不能再使用这个完整的结构体变量,因为这样做可能会访问到已经被移走的、无效的字段。然而,那些未被移动的字段仍然可以通过解构等方式单独访问。

需要我们注意的是,只有那些未实现 Drop trait 的类型才能进行部分移动。如果一个类型需要自定义的清理逻辑(实现了 Drop),编译器会禁止对其进行部分移动,以防止在清理时出现不一致的状态。

2. 借用与引用

如果所有权转移是唯一的交互方式,那么代码会变得非常繁琐。为了在不转移所有权的情况下允许代码访问数据,Rust引入了 借用(Borrowing)‍ 的概念,通过 引用(References)‍ 来实现。

2.1 借用的概念

借用就像是现实生活中的“借阅”一本书:你可以在不拥有它的情况下阅读它,但最终需要归还。在Rust中,创建一个引用被称为“借用”。编译器通过借用检查器(Borrow Checker)确保这些引用永远不会比它们所引用的数据活得更久。

2.2 不可变借用(Immutable Borrows)

通过 &T 语法,你可以创建一个对值的不可变引用。不可变引用允许你读取数据,但不能修改它。Rust的借用规则规定,在任何给定时间,你可以拥有任意多个对特定数据的不可变引用。

示例:

let s = String::from("hello");
let r1 = &s;
let r2 = &s; // 允许多个不可变引用
println!("{} and {}", r1, r2);

2.3 可变借用(Mutable Borrows)

通过 &mut T 语法,你可以创建一个可变引用,它允许你修改所借用的数据。为了防止数据竞争,Rust施加了一条极其重要的限制:在任何给定时间,对于一个特定的数据,你只能有一个可变引用

此外,当存在一个可变引用时,就不能有任何不可变引用。这确保了在数据可能被修改期间,没有任何其他代码可以读取它,避免了读到不一致或中间状态的数据。

示例:

let mut s = String::from("hello");
let r1 = &mut s; // 只有一个可变引用是允许的
// let r2 = &mut s; // 编译错误!不能有第二个可变引用
// let r3 = &s; // 编译错误!存在可变引用时,不能有不可变引用
r1.push_str(", world!");
println!("{}", r1);

2.4 借用规则总结

Rust的借用规则可以总结为:在一个特定作用域内,对任一资源,你只能选择以下一种借用方式:

  1. 一个或多个不可变引用 (&T)。
  2. 仅一个可变引用 (&mut T)。

这一规则是Rust实现“无数据竞争并发”的基石。由于它在编译时被强制执行,Rust能够在不牺牲性能的前提下提供强大的线程安全保证。

3. 生命周期

借用规则解决了数据竞争问题,但还存在另一个内存安全隐患: 悬垂引用(Dangling References)‍ ,即引用指向了一块已经被释放的内存。为了解决这个问题,Rust引入了 生命周期(Lifetimes)‍ 的概念。

3.1 悬垂引用问题

当一个引用比它所指向的数据“活”得更长时,就会产生悬垂引用。例如,一个函数返回了对函数内部创建的局部变量的引用,当函数结束时,局部变量被销毁,返回的引用就变成了悬垂引用。

3.2 生命周期的概念与作用

生命周期是Rust编译器用来确保所有引用都有效的工具 。它并非改变数据的存活时间,而是描述了多个引用作用域之间的关系,以确保被引用的数据在引用失效前一直有效 。

生命周期是泛型的一种,它允许函数或结构体接受带有不同生命周期的引用,并通过约束来保证安全。

3.3 生命周期标注语法

在大多数情况下,编译器可以根据一套名为“生命周期省略规则”(Lifetime Elision Rules)的规则自动推断生命周期。但当函数或结构体的生命周期关系复杂到编译器无法确定时,就需要开发者手动进行生命周期标注

生命周期标注使用撇号 ’ 后跟一个小写字母名称(如 'a)来表示。它不改变任何值的生命周期,只是为借用检查器提供足够的信息来验证引用的有效性。

示例:返回两个字符串切片中较长的一个

// 'a 是一个泛型生命周期参数
// 它告诉编译器,返回的引用至少会和 s1、s2 中较短的那个生命周期一样长
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

4. 编译器保障:借用检查器

所有权、借用和生命周期这些规则并非仅仅是编程约定,而是由Rust编译器中一个名为 借用检查器(Borrow Checker)‍ 的强大组件在编译时严格强制执行的。

4.1 借用检查器的角色

借用检查器的主要职责是在编译阶段静态分析代码,确保所有内存操作都符合上述规则。其核心目标是:

  • 防止悬垂引用:通过分析生命周期,确保引用永远不会指向无效内存。
  • 防止数据竞争:严格执行“要么多个只读,要么一个可写”的借用规则。
  • 保证内存安全:杜绝二次释放、使用未初始化内存等常见的内存错误。
    这一切都在编译时完成,意味着一旦代码成功编译,就从根本上消除了这类运行时错误,且没有任何运行时性能开销。如果代码违反了这些规则,编译器会拒绝编译。

4.2 Drop检查(Drop Check)

Drop检查是借用检查器的一个关键部分,用于确保当一个值被销毁(调用其Drop trait)时,它不会包含任何比它自己活得更久的引用。这可以防止在析构函数中访问悬垂指针。编译器会静态分析结构体等类型中字段的生命周期,确保所有权的销毁顺序是安全的 。在处理部分移动的情况时,Drop检查也确保只有那些未被移动的、仍然有效的字段会在作用域结束时被正确地销毁。

5. 其他情况

虽然基本规则已经非常强大,但在某些复杂场景下,需要更灵活的工具来处理所有权和可变性。

5.1 内部可变性(Interior Mutability)

标准借用规则有时过于严格,例如在实现某些数据结构(如图、缓存)或在回调函数中修改捕获的状态时。内部可变性模式为此提供了解决方案。它允许你在拥有一个不可变引用的情况下,安全地修改其内部的数据。

这种模式的本质是将借用规则的编译时检查推迟到运行时检查。如果违反了借用规则,程序会在运行时产生一个panic,而不是在编译时报错。

  • UnsafeCell< T >:是所有内部可变性类型的底层基础。它包装了一个值,并提供了一个.get()方法,该方法返回一个原始指针 *mut T。获取这个指针本身是安全的,但解引用它则必须在 unsafe 块中进行。所有其他内部可变性类型都是在 UnsafeCell 之上构建的安全抽象。

  • Cell< T > 和 RefCell< T >:

    • Cell< T >:用于 Copy 类型。它提供 .get()(复制值)和 .set()(替换值)方法。由于操作的是值的副本,因此不存在借用冲突,也没有运行时开销。
    • RefCell< T >:用于非 Copy 类型。它在运行时动态地执行借用检查 。通过 .borrow() 获取一个运行时检查的不可变引用 (Ref< T >),通过 .borrow_mut() 获取一个可变引用 (RefMut < T >)。如果借用规则在运行时被违反(例如,在已有一个可变借用的情况下再次请求可变借用),程序会立即panic 。RefCell 仅适用于单线程场景。
  • Mutex< T > 和 RwLock< T >:当需要在多线程间共享并修改数据时,RefCell 是不安全的。此时应使用线程安全的内部可变性类型,如 Mutex(互斥锁)和 RwLock(读写锁),它们通过操作系统提供的同步原语来保证在并发访问时的安全性。

5.2 智能指针与所有权

Rust标准库提供了多种智能指针,它们是实现了 Deref 和 Drop trait 的结构体,可以用来管理所有权关系:

  • Box< T >:提供在堆上分配数据的唯一所有权。
  • Rc< T >(引用计数):允许多个所有者共同拥有同一份数据,但仅限于单线程环境。
  • Arc< T >(原子引用计数):是 Rc< T > 的线程安全版本,允许在多线程之间安全地共享所有权。
Logo

新一代开源开发者平台 GitCode,通过集成代码托管服务、代码仓库以及可信赖的开源组件库,让开发者可以在云端进行代码托管和开发。旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐