Rust 编程入门:所有权与内存安全

FreeGuideOnline 最新 2026-06-13

Rust 编程入门:所有权与内存安全

欢迎来到 Rust 编程入门核心章节。本教程将带你理解 Rust 中最重要的概念——所有权系统,正是它让 Rust 无需垃圾回收器即可保证内存安全。掌握所有权是写出安全高效 Rust 代码的关键。

1. 为什么需要所有权?

在开始之前,先理解所有权要解决什么问题。在 C/C++ 中,开发者需要手动管理内存(malloc/free、new/delete),任何疏漏都可能导致:

  • 内存泄漏:忘记释放不再使用的内存。
  • 悬垂指针:引用已被释放的内存。
  • 双重释放:多次释放同一块内存。
  • 数据竞争:多线程环境下无序访问共享数据。

垃圾回收语言(如 Java、Go)通过运行时自动回收内存,避免了前三个问题,但增加了运行时开销,且仍难以避免数据竞争。Rust 选择了第三种路径:通过编译期的所有权规则,在保证零开销的前提下彻底杜绝以上所有问题

2. 所有权规则速览

Rust 所有权有三条铁律,编译器会严格检查:

  1. Rust 中的每一个值都有一个被称为其所有者的变量。
  2. 值在任一时刻有且仅有一个所有者
  3. 当所有者离开作用域时,这个值将被自动丢弃(调用 drop 函数释放资源)。

这三条规则简单而强大,下面我们逐步实战拆解。

3. 所有权与变量作用域

变量从声明开始有效,直到离开作用域:

{                      // s 尚未声明
    let s = "hello";   // s 有效
    // 可以使用 s
}                      // 作用域结束,s 不再有效,内存释放

这是最基础的堆栈数据(字符串字面量,大小固定,存于栈上)。真正体现所有权重要性的是堆上数据,例如 String 类型。

4. 移动语义:所有权转移

4.1 浅拷贝与移动

考虑以下代码:

let s1 = String::from("world");
let s2 = s1;
println!("{}", s1); // 编译错误!s1 已无效

String 由三部分组成:指向堆内存的指针、长度、容量。赋值 s2 = s1 时,Rust 不会复制堆上数据(否则开销巨大),而是仅拷贝栈上的指针、长度和容量,并将 s1 标记为失效。这个过程称为移动。此后 s1 不再指向任何有效内存,避免双重释放。

图形化理解:

s1 -> [ptr|len|cap] ---> heap("world")
执行 let s2 = s1 后:
s1 标记为无效,s2 -> [ptr|len|cap] ---> heap("world")

4.2 克隆:深度拷贝

如果需要真正复制堆数据,使用 clone 方法:

let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2); // 两者均有效

clone 会完整拷贝堆上字符串内容,代价较高。

4.3 栈上数据的拷贝

对于整数、布尔等大小固定且完全存于栈上的类型,赋值会自动进行位拷贝,无需移动:

let x = 5;
let y = x;
println!("x = {}, y = {}", x, y); // 均有效

这类类型实现了 Copy trait,赋值后原变量仍可用。String 没有实现 Copy,所以发生移动。

5. 所有权与函数传参

将值传递给函数的规则与赋值完全一致:

fn takes_ownership(some_string: String) {
    println!("{}", some_string);
} // some_string 离开作用域,调用 drop 释放内存

fn makes_copy(some_integer: i32) {
    println!("{}", some_integer);
}

fn main() {
    let s = String::from("hello");
    takes_ownership(s);        // s 移动进函数,此后 s 无效
    // println!("{}", s);      // 编译错误

    let x = 5;
    makes_copy(x);             // x 被拷贝,仍然有效
    println!("{}", x);
}

函数的返回值也会转移所有权:

fn gives_ownership() -> String {
    let some_string = String::from("yours");
    some_string  // 所有权转移给调用者
}

fn takes_and_gives_back(a_string: String) -> String {
    a_string  // 所有权先移入函数,再移出给调用者
}

fn main() {
    let s1 = gives_ownership();
    let s2 = String::from("hello");
    let s3 = takes_and_gives_back(s2); // s2 移入,s3 获得所有权
    // s2 已无效
}

这种模式虽然清晰,但每次都要返回所有权比较繁琐。Rust 提供了引用来解决。

6. 引用与借用:在转移所有权之外工作

引用允许你使用某个值而不获取其所有权。创建引用的行为叫做借用

fn calculate_length(s: &String) -> usize {
    s.len()
} // s 离开作用域,但它没有所有权,所以不会释放任何东西

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("The length of '{}' is {}.", s1, len); // s1 仍可用
}

&s1 创建了一个指向 s1 但不拥有它的引用。函数签名中的 &String 表示接受一个引用。

6.1 不可变引用与可变引用

默认情况下,引用是不可变的,不允许通过引用修改数据:

fn change(s: &String) {
    s.push_str(", world"); // 编译错误:s 是不可变引用
}

需要使用 &mut 创建可变引用

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}

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

可变引用的核心限制:在同一个作用域中,同一时刻对某个特定数据只能有一个可变引用,并且不能同时存在可变引用和不可变引用。这个规则在编译期杜绝了数据竞争。

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s; // 编译错误:不能同时存在两个可变引用

但是可以创建多个不可变引用:

let r1 = &s;
let r2 = &s; // 没问题
let r3 = &mut s; // 错误:不可变引用存在时不允许可变引用

引用的作用域从声明开始,一直持续到最后一次使用为止。这使得某些看似冲突的代码实际可行:

let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // r1,r2 之后不再使用

let r3 = &mut s; // 此时 r1,r2 作用域已结束,所以可以
r3.push_str(" world");

6.2 悬垂引用

Rust 编译器保证引用永远不会指向已被释放的内存:

fn dangle() -> &String {
    let s = String::from("hello");
    &s // 错误:s 离开函数会被释放,返回的引用将悬垂
}

返回局部变量的引用会被编译器直接拒绝。

7. 切片类型:不持有所有权的引用

切片让你引用集合中一段连续元素,而不获取所有权。字符串切片 &str 最为常用:

let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];

helloworld 都是切片引用,包含一个指针和长度。它们不拥有数据,原 s 依然有效。

使用切片作为参数比 &String 更灵活:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }
    &s[..]
}

fn main() {
    let my_string = String::from("hello world");
    let word = first_word(&my_string[..]); // 传入 String 的 full slice

    let my_literal = "hello world";
    let word = first_word(my_literal);    // 字符串字面量本身就是 &str
}

数组切片 &[i32]&[T] 等工作方式类似,对所有连续集合类型通用。

8. 所有权在复合类型中的表现

  • 结构体:若结构体字段持有 String 等非 Copy 类型,该结构体赋值或传参时会发生移动。可手动实现 Clone,或让字段使用引用(需引入生命周期标注)。
  • 枚举:如 Option<String>,同样遵循移动语义。unwrap() 或将值移出枚举时需要小心。
  • 集合Vec<T>HashMap<K,V> 等当元素非 Copy 时,整个集合的赋值会移动所有权。
struct User {
    name: String,
    age: u32,
}

let user1 = User { name: String::from("Alice"), age: 30 };
let user2 = user1; // user1 被移动,name 所有权移到了 user2
// println!("{}", user1.name); // 错误

9. 常见误区和最佳实践

误区 正确理解
以为每次赋值都深拷贝 移动是默认行为,开销极小(仅拷贝栈上字段)
不知道何时用 clone 仅在确实需要独立拷贝堆数据时使用,不要为避免移动滥用 clone
试图绕过可变引用限制 可使用 RefCell(运行时借用检查)或重构成更小的作用域,但优先遵守编译期限制
将所有权系统视为负担 它是在编译期帮你审查内存错误;适应后你会发现代码更健壮、意图更清晰

实战建议:

  • 函数参数优先使用不可变引用 &T,需要修改时用 &mut T,只有确实需要转移所有权(如存入结构体)时才用 T
  • 当发现所有权来回传递导致代码复杂时,考虑使用 Rc/Arc(引用计数)或重新设计数据结构。
  • 初期可以多用 clone 来“安抚”编译器,待熟悉所有权之后再逐步优化。

10. 总结与下一步

Rust 的所有权系统通过移动语义、借用规则和生命周期检查,在无运行时开销的情况下确保了内存安全和线程安全。本教程覆盖了:

  • 所有权的三条基本规则
  • 移动与克隆的差异
  • 函数间所有权转移
  • 不可变/可变引用及借用规则
  • 切片与 &str
  • 复合类型中的所有权行为

掌握这些概念后,你已经具备了阅读和编写绝大多数 Rust 程序的基础。下一步建议深入学习 生命周期标注(让包含引用的结构体和函数通过编译)、智能指针BoxRcRefCell)以及多线程下的所有权模型(SendSync trait),它们建立在所有权之上,将带你进入系统级编程的安全世界。


继续探索:在官方书籍《The Rust Programming Language》的第四章、第十章和第十六章可以找到更详尽的讨论。动手编写代码是巩固所有权的唯一捷径,现在打开 Rust Playground 开始练习吧!