🦀 Rust独家秘笈:所有权与借用精解,让你写出“不可能”出错的代码!💪

209 阅读15分钟

前言

大家好,我是土豆,欢迎关注我的公众号:土豆学前端

Rust 最核心也最具特色的功能之一就是其所有权(Ownership)系统。它与借用(Borrowing)规则相结合,能够在编译时保证内存安全和线程安全,而无需垃圾回收器(GC)或运行时开销。对于习惯了 C/C++ 手动管理内存或 Java/Python 自动垃圾回收的开发者来说,这套机制初看起来可能有些陌生,但一旦理解,你就会发现它的强大之处。

核心概念:所有权 (Ownership)

Rust 中的所有权系统围绕以下三个核心规则构建:

  1. 每个值在 Rust 中都有一个变量,称为其所有者(Owner)。
  2. 一次只能有一个所有者。
  3. 当所有者离开作用域(Scope)时,该值将被丢弃(Dropped)。

1. 作用域与资源释放

与许多语言类似,Rust 中的变量也有作用域。当变量离开作用域时,Rust 会自动调用一个特殊的函数 drop,该函数允许值的所有者释放其占用的资源(例如内存、文件句柄等)。

{
    let s = String::from("hello"); // s 进入作用域,分配内存于堆上
    // 使用 s
} // s 离开作用域,s 的内存被自动释放 (drop 被调用)
  // 这里 s 不再有效

2. "Move" 语义:所有权的转移

对于存储在堆上的数据(如 String, Vec<T>),当我们将一个变量赋值给另一个变量,或者将值作为参数传递给函数时,所有权会发生转移(Move)。

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

    // println!("{}", s1); // 编译错误!s1 不再拥有数据,它已经被 "move" 了
    println!("{}", s2); // 正确,s2 现在是 "hello" 的所有者
}

这种行为与 C++ 中的移动构造函数/移动赋值类似,但 Rust 在编译期就强制执行。与 Python 等语言中变量只是标签,赋值是让新标签指向同一对象不同,Rust 的 Move 意味着原变量失效,以防止“悬垂指针”或“二次释放”等问题。

函数参数传递与所有权

当将拥有堆数据的变量传递给函数时,所有权同样会转移:

fn takes_ownership(some_string: String) { // some_string 获得所有权
    println!("{}", some_string);
} // some_string 离开作用域,内存被释放

fn main() {
    let s = String::from("world");
    takes_ownership(s); // s 的所有权转移到 takes_ownership 函数的 some_string 参数
    // println!("{}", s); // 编译错误!s 不再有效
}

如果函数需要“归还”所有权,它可以从函数返回值:

fn takes_and_gives_back(a_string: String) -> String { // 获得所有权
    // ... 做一些事情 ...
    a_string // 返回所有权
}

fn main() {
    let s1 = String::from("test");
    let s2 = takes_and_gives_back(s1);
    // s1 不再有效,s2 现在拥有数据
    println!("{}", s2);
}

虽然这种方式可行,但频繁地转移和归还所有权会显得很繁琐。这时,借用就派上用场了。

3. Copy Trait 与栈上数据

对于一些简单类型,它们完全存储在栈上,复制它们的成本很低(例如整数、浮点数、布尔值、字符,以及只包含这些类型的元组)。这些类型可以实现 Copy Trait。

如果一个类型实现了 Copy Trait,那么在赋值或传参时,会进行一次“按位复制”(bitwise copy),而不是转移所有权。原变量在赋值后仍然有效。

fn main() {
    let x = 5;    // i32 实现了 Copy Trait
    let y = x;    // y 是 x 的一个副本

    println!("x = {}, y = {}", x, y); // x 和 y 都有效
}

常见的 Copy 类型:

  • 所有整数类型,如 u32, i64
  • 布尔类型 bool
  • 所有浮点类型,如 f32, f64
  • 字符类型 char
  • 元组,当且仅当其所有元素都实现了 Copy Trait。
  • 不可变引用 &T (我们稍后会讲到)。

注意:如果一个类型实现了 Drop Trait (自定义资源清理逻辑),它就不能实现 Copy Trait。因为如果允许 Copy,那么就会有多个变量指向同一份需要清理的资源,可能会导致重复释放。

4. Clone Trait:显式复制

对于堆上数据,如果确实需要创建一份独立的深拷贝(Deep Copy),而不是转移所有权,可以使用 Clone Trait。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // s2 是 s1 的一个深拷贝

    println!("s1 = {}, s2 = {}", s1, s2); // s1 和 s2 都有效,且指向不同的内存区域
}

String 类型实现了 Clone Trait,但没有实现 Copy Trait,因为复制堆上数据通常是相对昂贵的操作,Rust 希望你明确地表示这个意图。

核心概念:借用 (Borrowing) 与引用 (References)

如果我们只是想让函数或其他代码段临时访问数据,而不获取其所有权,就可以使用“借用”。借用通过“引用”(References)来实现。引用允许你使用值但不获取其所有权。

Rust 的借用规则是其内存安全的核心:

  1. 共享不可变 (Shared Immutable): 在任何给定时间,你可以拥有多个对某一数据的不可变引用(&T)。
    • 这意味着你可以随便读,但不能修改数据。
  2. 可变不共享 (Mutable Exclusive): 在任何给定时间,你只能拥有一个对某一数据的可变引用(&mut T)。
    • 这意味着如果你有一个可变引用,那么不能有其他任何引用(可变或不可变)指向该数据。

这套规则由编译器在编译时强制执行,确保了以下两点:

  • 没有数据竞争 (Data Races): 因为你不能同时拥有一个可变引用和任何其他引用(可变或不可变)到同一数据。
  • 没有悬垂引用 (Dangling References): 编译器会确保引用指向的数据在引用有效期内始终有效(通过生命周期机制,大部分情况下由编译器自动推断)。

1. 不可变引用 (&T)

fn calculate_length(s: &String) -> usize { // s 是对 String 的一个不可变引用
    s.len()
} // s 离开作用域,但它并不拥有所引用的数据,所以什么也不会发生

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // 传递 s1 的不可变引用

    println!("The length of '{}' is {}.", s1, len); // s1 仍然有效
}

calculate_length 函数中,s 是一个引用,它指向 s1 拥有的 String 数据。当 calculate_length 返回后,s1 仍然拥有数据,并且可以继续使用。

可以有多个不可变引用:

fn main() {
    let s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    // let r3 = &mut s; // 编译错误!不能在有不可变引用的同时创建可变引用

    println!("{} and {}", r1, r2);
}

2. 可变引用 (&mut T)

可变引用允许你修改所借用的数据。

fn change(some_string: &mut String) { // some_string 是对 String 的一个可变引用
    some_string.push_str(", world");
}

fn main() {
    let mut s = String::from("hello"); // 注意:s 必须是 mut
    change(&mut s); // 传递 s 的可变引用

    println!("{}", s); // 输出 "hello, world"
}

关键点:

  • 要创建可变引用,原始变量必须是可变的(用 mut 关键字声明)。
  • 在特定作用域内,对特定数据只能有一个可变引用。
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    // let r2 = &mut s; // 编译错误!不能有第二个可变引用
    // let r3 = &s;    // 编译错误!不能在有可变引用的同时创建不可变引用

    r1.push_str("!");
    println!("{}", r1); // 或 println!("{}", s);
}

这种限制在编译时就防止了数据竞争。例如,如果两个指针同时尝试修改同一块数据,结果可能是未定义的。Rust 的借用检查器阻止了这种情况的发生。

3. 引用的作用域与生命周期 (Lifetimes)

编译器会确保引用永远不会比它所指向的数据活得更久,这就是生命周期的概念。大多数情况下,编译器可以自动推断生命周期(称为生命周期省略规则)。

一个常见的会引发生命周期问题的场景是返回一个指向函数内部数据的引用:

// fn dangle() -> &String { // 编译错误!
//     let s = String::from("hello");
//     &s // 返回对 s 的引用
// } // s 在这里离开作用域并被 drop,其内存被释放

fn main() {
    // let reference_to_nothing = dangle();
}

上面的 dangle 函数会编译失败,因为 s 在函数结束时被销毁,那么返回的引用就会指向无效的内存(悬垂引用)。Rust 编译器通过生命周期分析阻止了这种情况。

对于更复杂的场景,可能需要显式标注生命周期,但这超出了快速入门的范围。通常,遵循借用规则,编译器会帮你处理好大部分情况。

应用场景详解

1. 函数调用时的参数传递

回顾一下:

  • 值传递 (所有权转移): fn foo(s: String)

    • s 获得所有权。
    • 原变量在调用后失效(除非类型实现了 Copy Trait)。
    • 适用于函数需要完全控制或消耗数据的情况。
  • 不可变借用 (引用传递): fn foo(s: &String)

    • s 是一个不可变引用。
    • 函数可以读取数据,但不能修改。
    • 原变量在调用后仍然有效且可访问。
    • 可以同时存在多个不可变借用。
    • 适用于函数只需要读取数据的情况。
  • 可变借用 (引用传递): fn foo(s: &mut String)

    • s 是一个可变引用。
    • 函数可以读取和修改数据。
    • 原变量在调用后仍然有效(并且可能已被修改)。
    • 在可变借用期间,不能有其他任何(可变或不可变)引用指向该数据。
    • 适用于函数需要修改数据的情况。

选择哪种方式?

  • 默认优先考虑不可变借用 (&T)。
  • 如果需要修改数据,使用可变借用 (&mut T)。
  • 只有当函数确实需要获取数据的所有权时(例如,将其存储到结构体中,或者转换它并返回新的拥有所有权的值),才使用值传递 (T)。

2. 匿名闭包 (Closures) 捕获变量

闭包是 Rust 中强大的特性,它们可以捕获其环境中的变量。闭包如何捕获变量与其实现的三种 Fn Trait 相关,这直接关联到所有权和借用:

  • FnOnce: 闭包通过(所有权)捕获变量。这意味着闭包会消耗掉被捕获的变量。这类闭包只能被调用一次。

    • 如果闭包体将捕获的变量移出闭包,则它必须是 FnOnce
    • 例如:let s = String::from("hello"); let c = || drop(s); c();
  • FnMut: 闭包通过可变引用&mut T)捕获变量。这意味着闭包可以修改被捕获的变量。这类闭包可以被多次调用。

    • 如果闭包体修改了捕获的变量,但没有移出,则它至少是 FnMut
    • 例如:let mut s = String::from("hello"); let mut c = || s.push_str("!"); c(); println!("{}", s);
  • Fn: icrobialm通过不可变引用&T)捕获变量。这意味着闭包只能读取被捕获的变量。这类闭包可以被多次调用,甚至可以并发调用。

    • 如果闭包体只读取捕获的变量,则它可以是 Fn
    • 例如:let s = String::from("hello"); let c = || println!("{}", s); c();

Rust 编译器会根据闭包如何使用捕获的变量来自动推断最合适的 Fn Trait。

move 关键字与闭包

有时,我们希望闭包强制获取其捕获变量的所有权,即使闭包体本身可能只需要引用。这在多线程或异步编程中尤其重要,可以确保闭包在与原始变量的作用域分离后仍然有效。这时可以使用 move 关键字。

use std::thread;

fn main() {
    let data = vec![1, 2, 3];

    // 使用 move 关键字,闭包会获取 data 的所有权
    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", data);
        // data 在这里被 drop
    });

    // println!("{:?}", data); // 编译错误!data 的所有权已经转移到线程中了

    handle.join().unwrap();
}

如果不使用 move,闭包默认会尝试借用 data。但新线程可能比 main 函数的 data 活得更久,导致悬垂引用。move 强制闭包取得 data 的所有权,解决了这个问题。

3. 异步函数与多线程

Rust 的所有权和借用系统是其并发安全性的基石。

SendSync Trait

  • Send: 一个类型如果实现了 Send Trait,意味着它的所有权可以安全地从一个线程转移到另一个线程。大多数基本类型和拥有 Send 类型字段的复合类型都是 Send 的。Rc<T>(引用计数指针,非线程安全)不是 Send 的。
  • Sync: 一个类型如果实现了 Sync Trait,意味着它的不可变引用(&T)可以安全地在多个线程之间共享。如果 TSend 的,那么 &T 通常是 Sync 的。Mutex<T>Sync 的(即使 T 不是 Sync,只要 TSend),因为它提供了同步机制。RefCell<T>(内部可变性,非线程安全)不是 Sync 的。

当你在线程间传递数据或在 async 代码块中使用 await 时,编译器会检查相关类型是否实现了 Send 和/或 Sync,以确保线程安全。

Arc<T>:原子引用计数

Rc<T> 用于在单线程中共享数据所有权。当需要在多线程中共享所有权时,需要使用 Arc<T> (Atomic Reference Counted)。Arc<T>Rc<T> 类似,但其引用计数是原子操作,因此是线程安全的。Arc<T> 要求 T 必须是 Send + Sync

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(String::from("shared data"));

    let mut handles = vec![];

    for i in 0..3 {
        let data_clone = Arc::clone(&data); // 增加引用计数,每个线程得到一个 Arc 指针
        let handle = thread::spawn(move || {
            println!("Thread {}: {}", i, data_clone);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
    println!("Original data still accessible: {}", data);
}

Mutex<T>:互斥锁

如果需要在多个线程间共享并修改数据,可以使用 Mutex<T> (Mutual Exclusion)。Mutex<T> 确保一次只有一个线程可以访问其内部的数据。

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // 使用 Arc 来允许多个线程拥有 Mutex 的所有权
    // 使用 Mutex 来同步对 Vec 的访问
    let counter = Arc::new(Mutex::new(vec![0]));

    let mut handles = vec![];

    for i in 0..5 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num_vec = counter_clone.lock().unwrap(); // 获取锁,如果其他线程持有则阻塞
            num_vec[0] += 1;
            println!("Thread {} incremented counter to: {}", i, num_vec[0]);
            // 当 num_vec (MutexGuard) 离开作用域时,锁会自动释放
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final counter value: {:?}", counter.lock().unwrap()[0]);
}

lock() 方法会返回一个 MutexGuard,它是一个智能指针,实现了 DerefDerefMut,允许你访问被 Mutex 保护的数据。当 MutexGuard 离开作用域时,锁会自动释放。这种基于作用域的资源管理(RAII)是 Rust 安全性的一个重要方面。

异步 async/await

async 函数中,当跨越 .await 点时,所有被异步任务持有的状态(包括局部变量和捕获的变量)必须是 Send 的,因为异步任务可能会在不同的线程上恢复执行。

async fn my_async_function(data: String) { // data 拥有所有权
    println!("Processing: {}", data);
    // ... 一些异步操作 ...
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("Finished processing: {}", data); // data 仍然有效
}

// 如果需要共享数据,通常会用 Arc
async fn shared_async_function(data: Arc<String>) {
    println!("Accessing shared: {}", data);
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("Finished accessing shared: {}", data);
}

#[tokio::main]
async fn main() {
    let s = String::from("async example");
    my_async_function(s).await;
    // s 在这里不再有效,所有权已转移

    let shared_s = Arc::new(String::from("shared async"));
    let task1 = shared_async_function(Arc::clone(&shared_s));
    let task2 = shared_async_function(Arc::clone(&shared_s));

    tokio::join!(task1, task2); // 并发执行两个任务
    println!("Main sees: {}", shared_s);
}

move 关键字在 async 块或返回 Future 的闭包中也经常使用,以确保捕获的变量在 Future 的整个生命周期内都有效。

总结与上手建议

Rust 的所有权和借用系统是其核心竞争力,它提供了内存安全和线程安全,而没有垃圾回收的开销。

  • 所有权:每个值有唯一所有者,所有者离开作用域则值被销毁。赋值或传参(对堆数据)会导致所有权转移 (Move)。
  • 借用:通过引用 (&T&mut T) 临时访问数据而不获取所有权。
    • 共享不可变:任意多个 &T
    • 可变不共享:仅一个 &mut T,且此时不能有任何 &T
  • Copy Trait:用于栈上数据,赋值时发生位拷贝,原变量仍有效。
  • Clone Trait:用于显式创建数据的深拷贝。

给有经验开发者的上手建议:

  1. 拥抱编译器:Rust 编译器(特别是其借用检查器 borrow checker)是你最好的朋友。它的错误信息初看起来可能很吓人,但通常非常精确,并会指导你如何修复问题。
  2. 优先不可变:默认使用不可变变量和不可变引用。只在确实需要修改时才使用 mut
  3. 明确数据流:思考数据在你的程序中是如何流动、谁拥有它、谁在何时需要访问它。
  4. 从小处着手:先尝试在简单函数和数据结构中运用所有权和借用规则。
  5. clone() 是你的朋友(初期):当你对所有权和生命周期感到困惑,并且编译器报错时,尝试使用 .clone() 来解决问题。这虽然可能不是性能最优的方案,但可以让你先让代码跑起来,之后再逐步优化,理解为什么需要 clone 以及如何避免它。
  6. 理解String vs &strString 是拥有所有权的堆分配字符串,而 &str (字符串切片) 是对字符串数据的不可变引用。这是学习所有权和借用的一个很好的具体例子。
  7. 逐步深入:一旦掌握了基础,再逐步学习生命周期注解、Rc/ArcRefCell/Mutex 等更高级的概念。

虽然上手初期可能会遇到一些挑战,但一旦你内化了 Rust 的这些核心概念,你就能编写出既高效又安全的代码。祝你在 Rust 的学习旅程中一切顺利!