跳至内容

所有权与借用

这篇讲 Rust 最核心的概念——所有权(Ownership)与借用(Borrowing)。这是 Rust 区别于所有主流语言的根本机制,理解它就是掌握了 Rust 的钥匙。

为什么需要所有权

主流语言管理内存的三种方式:

  • C/C++:程序员手动 malloc/freenew/delete——灵活但容易出错
  • Java/Go/Python:垃圾回收器(GC)自动回收——省心但有运行时开销
  • Rust所有权系统——编译期检查,无运行时开销,无 GC 停顿

所有权规则(三条铁律):

  1. Rust 中每个值都有且仅有一个所有者(owner)
  2. 当所有者离开作用域,值被自动释放
  3. 值在任何时刻只能有一个所有者(也有例外——后面会讲 Rc

作用域与 Drop

fn main() {
    {                           // s 尚未声明
        let s = String::from("hello"); // s 从此处开始有效
        println!("{s}");
    }                           // s 离开作用域,内存自动释放
    // println!("{s}");         // ❌ s 已经不存在了
}

这类似于 C++ 的 RAII(Resource Acquisition Is Initialization),值在离开作用域时调用 drop

移动(Move)

先理解栈和堆:栈上的数据大小固定(如 i32),拷贝成本低;堆上的数据大小不固定(如 String),拷贝需要深复制。Rust 对这两种数据的「赋值」处理不同:

fn main() {
    // 栈数据:自动 Copy
    let x = 5;
    let y = x;   // x 被复制,x 仍然可用
    println!("x={}, y={}", x, y);  // ✅ 都可用

    // 堆数据:Move(所有权转移)
    let s1 = String::from("hello");
    let s2 = s1;  // s1 的所有权移给了 s2
    // println!("{s1}");  // ❌ s1 已经失效!
    println!("{s2}");     // ✅ s2 是新的所有者
}

为什么要 move 而不是 copy?——String 在堆上的数据如果被「浅拷贝」,两个指针指向同一块内存,离开作用域时会 double free。Rust 直接从编译期禁止了这种可能性。

函数传参和返回值也会触发 move:

fn main() {
    let s = String::from("hello");
    takes_ownership(s);      // s 的所有权移入函数
    // println!("{s}");      // ❌ s 已经不可用

    let s2 = gives_ownership(); // 函数返回值的所有权移给 s2
    println!("{s2}");
}

fn takes_ownership(s: String) {
    println!("{s}");
} // s 被 drop

fn gives_ownership() -> String {
    let s = String::from("world");
    s  // 所有权移出函数
}

克隆(Clone)

如果真的需要深拷贝堆数据,显式调用 .clone()

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // 深拷贝,堆数据被复制
    println!("s1={}, s2={}", s1, s2);  // ✅ 两者都可用
}

Copy trait

为什么 i32 赋值后还能用?因为它实现了 Copy trait。实现了 Copy 的类型在赋值时自动按位复制,不会触发 move。

实现了 Copy 的类型:

  • 所有整数、浮点数、布尔、字符类型
  • 元素都是 Copy 的元组,如 (i32, i32)
  • 不可变引用 &T

StringVec 等涉及堆分配的类型没有实现 Copy——因为这相当于隐式深拷贝,违背了 Rust「显式表达成本」的理念。

引用与借用

每次都 move 所有权太麻烦了——函数用完参数还得把所有权还回来。Rust 提供了**引用(Reference)**机制,允许「借用」值而不获取所有权:

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // &s1 创建一个指向 s1 的引用
    println!("'{s1}' 的长度是 {len}");  // ✅ s1 仍然可用
}

fn calculate_length(s: &String) -> usize {  // &String = 引用类型
    s.len()
}  // s 离开作用域,但它不拥有值,所以不 drop

& 创建引用,这种行为叫借用(Borrowing)——你借了别人的东西,用完得还。

可变引用

默认引用是不可变的。要修改借用的值,需要 &mut

fn main() {
    let mut s = String::from("hello");
    change(&mut s);  // 可变引用
    println!("{s}"); // "hello, world!"
}

fn change(s: &mut String) {
    s.push_str(", world!");
}

可变引用的铁律:在同一作用域中,对于同一块数据:

  • 要么有任意多个不可变引用
  • 要么有恰好一个可变引用
  • 两者不能同时存在
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;     // ✅ 不可变引用
    let r2 = &s;     // ✅ 可以有多个不可变引用
    // let r3 = &mut s; // ❌ 不能同时有不可变和可变引用!

    println!("{r1} {r2}");  // r1、r2 最后一次使用在此

    let r3 = &mut s;  // ✅ r1、r2 不再使用,可以创建可变引用
    r3.push_str("!");
}
这条规则是 Rust 能在编译期防止数据竞争的根本原因。数据竞争发生的三个条件:两个以上指针同时访问同一数据;至少一个用于写入;没有同步机制。Rust 直接从类型系统层面让这不可能发生。

悬垂引用

Rust 编译器保证永远不会出现悬垂引用——指向已释放内存的引用:

// ❌ 编译错误
fn dangle() -> &String {
    let s = String::from("hello");
    &s  // s 在函数结束时被释放,返回的引用指向无效内存!
} // 编译器会报错:missing lifetime specifier / returns a reference to data owned by the current function

切片(Slice)

切片是集合中一段连续元素的引用,不拥有数据:

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

    let hello = &s[0..5];   // "hello"
    let world = &s[6..11];  // "world"
    let all = &s[..];       // "hello world"

    // 更常用的场景:函数参数用 &str 代替 &String
    let word = first_word(&s);
    println!("第一个单词: {}", word);  // "hello"
}

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}
&str(字符串切片)是 Rust 中处理字符串的首选类型——比 &String 更通用,类似 Go 中推荐用 io.Reader 而非具体类型。绝大多数接受字符串参数的函数应该用 &str

一句话小结

所有权是 Rust 的基石:每个值有唯一所有者,离开作用域自动释放。赋值/传参默认是 move,要保留原值用 .clone() 或借用(&/&mut)。可变引用最多一个,且不能与不可变引用共存——这是 Rust 编译期消灭数据竞争的秘诀。下一篇讲所有权的延伸——生命周期

练习

  1. 以下代码能否编译?为什么?
fn main() {
    let s = String::from("hello");
    let r1 = &s;
    let r2 = &mut s;  // 这里?
    println!("{r1}");
}
  1. 写一个函数 longest<'a>(x: &'a str, y: &'a str) -> &'a str,返回两个字符串切片中较长的那个。(提示:需要生命周期标注,下一篇会详细讲。)
参考答案
  1. 不能编译r1 是不可变引用,r2 是可变引用,Rust 不允许同时存在。把 println!("{r1}") 移到 let r2 之前就可以编译——这是「非词法作用域生命周期」(NLL)的特性。
  2. 暂时跳过——生命周期标注将在下一篇详解。
最后更新于