🦀 Rust工程师养成记 Day4-所有权与生命周期

288 阅读12分钟

前言

在编程世界的丛林中,内存错误如同潜伏的猛兽——空指针、数据竞争、内存泄漏……这些C/C++开发者挥之不去的噩梦,正是Rust誓要斩断的枷锁。Rust抛弃了传统的手动内存管理和垃圾回收机制,转而用所有权生命周期构建起独特的编译时守卫:它们如同严谨的交通系统,在代码运行前便规划好每一块内存的轨迹。

所有权和生命周期是Rust下面一个非常重要且核心的概念,即使你是拥有其他很多语言的编程经验,你也会有极大概率天天与Rust编译器争个你死我活也总是不得要领。

🔥 关注我的公众号「哈希茶馆」一起交流更多开发技巧

栈和堆

在讲所有权与生命周期之前,我觉得我们有必要复习一下栈和堆的基础知识。

在大部分像js,java等的语言中,你并不需要经常考虑到数据是放在栈还是堆的问题。但是如果在Rust中,这点非常重要,数据在堆上还是栈上极大的影响到了后续的编码,以及程序的效率。

存储结构对比

  • :采用后进先出模式,数据存取仅发生在顶部。类似整理书籍时,新书叠放顶端,取用时也从顶部拿取。存储要求严格:仅允许已知固定大小的数据(如数值、静态数组)。函数执行时,参数和局部变量(含指向堆的指针)被压入栈,形成独立帧块。函数结束时自动弹出这些数据,实现高效内存回收
  • :支持动态内存分配,数据位置由运行时决定。如同共享停车场,车辆进入时管理员分配空位并记录编号,离开时需凭编号寻车。堆可存储大小可变或未知的数据(如动态字符串),但需通过指针(类似停车编号)访问。

性能差异

  • 分配效率:栈的分配仅需移动顶部标识(毫秒级);堆需搜索可用空间并维护分配记录,耗时增加(百倍于栈操作)。
  • 访问速度:栈数据紧密排列,CPU缓存命中率高(类似连续阅读书架上相邻书籍);堆数据分散,需通过指针跳转访问(如同在不同楼层寻找分散的藏书),缓存利用率低。

关键差异总结表

我们来用一张表更加清晰的表述一下:

维度
存储内容固定大小动态/不定长数据
分配速度纳秒级微秒级
内存碎片可能产生
管理方式自动回收需显式/半自动管理

总结一下就是:栈上存放的数据是静态的,固定大小,固定生命周期;堆上存放的数据是动态的,不固定大小,不固定生命周期。

为什么其他语言不需要考虑到栈和堆,因为他们或多或少都在运行时实现了一套垃圾回收(garbage collector,GC) 机制,用来自动管理堆上的内存回收,但是这样相对的也会带来一些性能以及未知错误的代价。

所有权系统的作用

所有权系统要处理的问题是:跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,它的主要目的就是管理堆数据。并且他不会产生运行时的性能损耗,因为他在编译期就会揪出你的内存管理方面的问题。

所有权规则

  1. Rust 中的每一个值都有一个 所有者owner)。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

Day2的例子

我们回想一下Day2时遇到的一个问题,运行一段再也平常不过的代码的时候居然报错了!

fn main() {
	let s: String = "hello world".to_string();
	let s2 = s;
	println!("{}", s);
}

我们来分三个阶段来理解一下这段代码:

1. 堆内存的创建阶段

  • 当执行 "hello world".to_string() 时:
  • 堆中分配内存存储字符串内容(长度可能变化的动态数据)
  • 栈上的变量 s 存储着三个关键信息:
    • 指向堆内存的指针ptr(0x1234)
    • 当前字符串长度len(11)
    • 总容量capacity(可能为 11 或更多)

2. 所有权转移阶段

  • let s2 = s; 执行时:
  • 栈上的指针信息(0x1234 + 长度 + 容量)被复制到 s2
  • 但根据Rust所有权规则,此时发生所有权转移:
    • 原始绑定关系 s → 堆数据 被解除
    • 建立新绑定关系 s2 → 堆数据

从图中看,s已经变虚线了,在Rust中s对于hello world的拥有权已经转移到了s2上,我们也无须担心后续对于s的使用造成空指针之类的错误,因为他在编译期就被阻止使用了。

3. 访问失效指针阶段

  • println!("{}", s); 试图访问已失效的指针
  • Rust编译器在编译期即检测到:
    • s 的堆数据所有权已转移至 s2
    • 继续访问 s 会导致悬垂指针风险

这一些列操作其实叫做移动,其他语言这种拷贝指针、长度和容量而不拷贝数据的操作可能叫做浅拷贝,但是Rust使第一个变量s失效了。所以可以理解为s移动到了s2中,当s2离开作用域,他就会自己释放内存。

原因分析

为什么Rust要这么做呢,主要有下面三个原因:

  • 避免双重释放:若允许同时存在多个所有者,当ss2离开作用域时,会导致对同一堆内存的多次释放
  • 数据竞争防护:所有权单一性从根本上防止了多个指针同时操作同一块堆内存的可能性
  • 零成本抽象:这些检查发生在编译期,运行时无额外开销

解决方案

1. 克隆(clone)

如果我们的确需要深度复制 String 中堆上的数据,可以使用clone的方法:

fn main() {
	let s: String = "hello world".to_string();
	let _s2 = s.clone(); // 使用clone
	println!("{}", s);
}

这样就不会出现栈上多个引用一个堆上的数据了,下图可以清楚看出这样写的时候s和s2的状况:

2. 借用读取

如果只是针对只读场景,我们可以创建s的不可变引用,绕一点的说就是将s的只读指针赋值给s2。

fn main() {
    let s = "hello".to_string();
    let s2 = &s; // 创建不可变引用(指针的指针)
    println!("原始数据: {}, 借用数据: {}", s, s2);
}

用图表示的话清楚一些:

我们乘机来用表格简单的对比一下上面这两种方案,加深一下理解:

维度clone()借用(&)
内存开销双倍堆内存消耗仅增加栈上的指针大小
所有权产生两个独立所有者保持原所有者,只增加借用者
适用场景需要修改副本且不影响原数据时只需读取共享数据时
性能影响O(n)时间复杂度(数据拷贝)O(1)时间复杂度(指针复制)
典型用例配置文件克隆日志记录不修改原数据时

3. 拷贝(copy)

这个例子没有提到Rust中另一个概念,拷贝,我们可以看下面的例子:

fn main() {
    let x = 5;
    let y = x;
    println!("x = {x}, y = {y}");
}

这样写居然没有报错!和我们上面说的互相矛盾了呀!

主要原因是i32 类型实现了 Copy trait,i32的存储方式和String有着本质的区别,我们先看看x和y在栈中的表现:

像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的,没有理由在创建变量 y 后使 x 无效,可以通过文档看看还有哪些类型实现了Copy的trait,存储在栈上的数据复制的语义都是拷贝的,所以不用担心所有权的问题。

我们再通过一个表格比较巩固一下:

特性i32 (x和y)String (之前报错代码)
存储位置完全存储在栈上元数据在栈,内容在堆
复制(=)语义自动按位复制 (Copy trait)转移所有权 (Move 语义)
内存操作直接复制 4 字节仅复制指针/长度/容量三字
克隆必要性不需要 clone()需要显式 clone() 深拷贝

可变引用

上面的借用读取提到了不可变引用,既然有不可变用,那肯定有可变引用,我们来看看下面这个例子:

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

    change(&mut s);
}

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

我们来逐步说一下这段代码发生了什么:

  1. 创建可变字符串s
  2. 创建对s的可变引用some_string(&mut String)
    • 此时发生「可变借用」
    • 原变量s暂时失去直接修改权
    • 函数获得临时修改some_string的权限

我们可以类比图书馆的书籍来理解不可变引用就是多人同时阅读同一本书(只读),可变引用就是某位读者申请了独家修改权,此时其他人不能阅读(不可变引用失效),修改完成后,其他人才能继续使用。

我们来总结一下三点可变引用的核心规则:

1. 独占性访问

同一作用域内只能存在一个可变引用

let r1 = &mut s;
let r2 = &mut s; // 错误!已有r1存在

2. 作用域时效性

let r1 = &mut s;
// 这里可以使用r1
println!("{}", r1); // 最后一次使用r1
let r2 = &mut s;    // 合法,因为r1已结束生命周期

3. 与不可变引用互斥

let r_immut = &s;     // 不可变引用
let r_mut = &mut s;   // 错误!不能同时存在

我们在通过一张表来看看可变引用和不可变引用的区别:

特性不可变引用(&T)可变引用(&mut T)
同时存在数量无限多个仅一个
数据修改权禁止修改允许修改
与其他引用共存可与多个&共存不能与任何引用共存
典型用途读取数据更新数据结构
生命周期可存在多个长生命周期通常需要短生命周期

生命周期标注

为什么需要生命周期标注?

我们先看看下面这段代码:

fn longest(x: &str, y: &str) -> &str { 
    if x.len() > y.len() { x } else { y }
}

这段代码在rust下会这样报错:

简单的说就是函数返回的引用可能指向已释放的内存(悬垂指针)。Rust 需要确保返回的引用始终有效。所以需要做如上图编译器所建议的修改,修改完后,编译器就会知道返回值的生命周期取两个参数中较短的那个。这么一说,你可能还是没有明白是怎么回事,我们再来看看这段代码:

fn main() {
    let result;
    {
	    // s1生命周期开始
        let s1 = String::from("short");    
        // s2生命周期开始
        let s2 = String::from("longer");  
        // 调用未标注生命周期的函数 
        result = dangerous(&s1, &s2);
    } // s1 和 s2 在这里被释放!内存危险区域!
    
    // 使用已释放的内存 → 未定义行为
    println!("{}", result); 
}

// 未标注生命周期的危险函数
fn dangerous(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

上面这段代码中result可能获取了s1的指针,也可能获取了s2的指针。

但是根据所有权的规则,出了作用域后,s1s2的生命周期就结束了,被Rust释放了,这就会导致println!("{}", result)使用了已释放的内存,很是危险!

所以编译器需要知道result的生命周期和s1s2生命周期的关系,及时提示你println!("{}", result);是错误的!如果我加上生命周期的标注,Rust就会正确提示reslut的问题了:

fn dangerous<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

什么时候需要标注生命周期?

Rust 能在 70% 的常见场景中自动推断生命周期,只有当模式不匹配时才需要手动标注。所以我的建议是我们新手暂时还是不要了解什么时候需要标注生命周期比较好。因为如果需要标注生命周期Rust编译器会友好的提示我们,我们只需要知道生命周期的含义以及为什么Rust编译器会提示我们要标注就可以了。

总结

Rust通过编译期内存管理革新系统编程安全范式。其所有权体系建立三大铁律:值有唯一所有者、作用域结束自动回收、移动语义杜绝重复释放。配合借用检查机制,引用分为共享只读(&T)与独占可变(&mut T),在编译阶段消除数据竞争。

生命周期标注确保引用有效性,强制关联被引对象的存活范围。这套机制使堆内存管理无需垃圾回收,既避免C/C++的手动管理风险,又保持程序运行时零额外开销,实现内存安全与高性能的完美统一。

下节预告

我们将上手一个做一个简单的命令行工具,见识一下Rust更多的特性并巩固一下我们已经学到的知识,继续加油啦!

🔥 关注我的公众号「哈希茶馆」一起交流更多开发技巧