Ownership - Rust Hardcore Head to Toe

很多人第一次学 Rust,最先劝退的不是语法,而是这一串词:

  • stack | heap

  • ownership | borrowing | slice

  • mutable | inmutable

  • copy | clone | drop

每个词单看起来都懂,可一旦放到一起,脑子就容易乱。你可能也有过这些疑问:

  • 为什么 i32 赋值后还能继续用,String 却不行?

  • 为什么有时候函数传参会把值"用没了"?

  • 为什么字符串参数经常写成 &str,而不是 &String

  • 为什么 Rust 要分 CopyCloneDrop 这么多概念?

如果把这些问题压缩成一句话,其实它们都在问同一个东西:这份资源现在归谁管?而这正是 Rust 的核心。


  1. 栈和堆 (Stack | Heap)

一切都要从 Stack 和 Heap 说起。在程序运行时,数据总得放在内存里。而最常见的两个地方,就是:

  • stack,中文翻译成栈

  • heap,中文翻译成堆

它们都是内存,但它们的组织方式完全不同。也正因为不同,Rust 才会有后面那整套所有权 (ownership) 规则。

1.1. 快而规矩的栈

你可以把栈理解成一摞盘子。放盘子时,只能往最上面放。拿盘子时,也只能从最上面拿。这就是后进先出 (Last In First Out, LIFO)。

比如:

go 复制代码
先放 A
再放 B
再放 C

拿出来时一定是:

go 复制代码
先拿 C
再拿 B
再拿 A

Push 和 Pop 是对栈操作的两个词:

  • push onto the stack:压入栈

  • pop off the stack:从栈弹出

比如:

go 复制代码
Push A
Push B
Push C

现在栈顶是 C,然后:

go 复制代码
pop -> C
pop -> B
pop -> A

栈的特点非常鲜明:结构规整;分配快;回收快;适合大小固定的数据,比如这些类型:

i32, bool, char, f64

它们的大小在编译时就已经确定了,所以很适合直接放到栈里。为什么栈快?因为它根本不用"找位置",新数据永远放栈顶,而旧数据永远从栈顶拿走。

1.2. 慢但灵活的堆

你可以把堆理解成一个大仓库。往里面放数据时,不能像栈那样直接堆上去。首先需要一块这么大的空间,然后内存分配器会帮你在堆里找到一块够大的空位,把它分给你,并返回这块地方的地址。这个过程叫分配 (allocation),而返回的地址就是指针 (pointer)。

堆的优势在于灵活。如果一个值:

  • 编译时大小不确定

  • 或者运行时大小可能变化

那通常就更适合放在堆上。最经典的例子就是 String,因为字符串长度是会变的。因为你拿到的往往不是数据本身,而是它的地址。你得先拿指针,再顺着指针找到真正的数据。所以,栈更像"伸手就拿",而堆更像"先拿门牌号,再去仓库找货"。

如果所有数据都像栈一样:

  • 进入作用域时创建

  • 离开作用域时自动消失

那程序员会轻松很多。

真正麻烦的是堆。因为堆上的数据会带来一串问题:

  • 谁拥有它?

  • 谁负责释放它?

  • 会不会还在使用时就被释放?

  • 会不会被释放两次?

  • 会不会没人用了却一直不释放?

解决上述问题是 Rust 的真正目标是,即安全地管理堆上的内存,同时又不需要垃圾回收器 (garbage collector, GC),也就是说,Rust 想做到两件事:

  1. 即不像 C/C++ 那样容易内存泄漏、无效指针、重复释放

  2. 又不像 Java/Python 那样依赖运行时 GC

所以 Rust 选择了一条更难但更强的路,在编译期就把内存管理规则检查清楚,而 ownership 就是这套规则的核心。


  1. 所有权 (Ownership)

Rust 的所有权是为了安全地管理堆上的资源。

2.1. String 数据类型

为什么 String 赋值后,原变量不能再用?这是初学者最经典的疑问。看代码:

go 复制代码
let s1 = String::from("hello");
let s2 = s1;

很多人的直觉是:这不就是把 s1 复制给 s2 吗?其实不是。

String 不是直接把字符串内容整个放在栈上。它通常分成两部分:

  1. 栈上保存三个固定大小的信息:指针 ptr,长度 len 和容量 capacity。

2. 堆上保存真正的字符内容。

go 复制代码
stack: s1 -> [ptr, len, capacity]
heap:  [h][e][l][l][o]

可视化一下:

go 复制代码
这行代码 let s2 = s1; 到底代表什么?
猜测一:深度复制 (deep copy) 
Rust 不会如此低效的事情,因此不是深度复制。

猜测二:浅度复制 (shallow copy)
浅度复制会导致:s1 和 s2 都指向这块堆数据。

听起来好像没什么。但等作用域结束时,问题就来了:

  • s1 觉得自己该释放这块内存

  • s2 也觉得自己该释放这块内存

于是同一块内存会被释放两次。这就是二次释放 (double free)。

猜测三:移动 (move)

Rust 为了避免二次释放问题,做了一个非常关键的设计:对 String 这种拥有堆资源的类型,赋值默认不是 copy,而是 move。也就是说:

go 复制代码
let s1= String::from("hello");
    let s2=s1;

真正发生的是:

  • 所有权从 s1 转移给 s2

  • s1 失效

  • 最终只由 s2 负责释放堆数据

2.2. Stack-only 数据类型

我们再看一个例子:

go 复制代码
let x = 5;
let y = x;

这里 x 还能继续用,因此这时 let y=x; 做的是 copy 而不是 move。不仅仅是整数 (i),Rust 里浮点数 (f)、bool 和 char 类型变量用 = 都是 copy 而不是 move。这些数据类型都是在栈上,被称为 stack-only 数据。

为什么 String 不是 copy?因为它管理堆资源,还记得上图 String 的指针、长度和容量都在栈上,而指针指得内容却在堆上。如果 ``String 也是 copy,又会回到那个老问题:

  • 多个变量同时指向同一块堆内存

  • 最后谁来释放?

  • 会不会释放两次?

所以涉及资源所有权的类型,通常不是 copy 而是 move。

2.3. 小节

所有权规则可以压缩成三句:

  1. 每个值都有一个 owner

  2. 同一时刻只能有一个 owner

  3. owner 离开作用域时,值会被清理

看起来像语法规则。但本质上,它在解决一个底层问题:谁来负责这块资源的生命周期?


  1. 借用 (Borrowing)

试想一下,如果每次使用值都要 move,那写代码会非常痛苦。因为现实中,大部分时候我们只是想:a) 看一眼这个值;b) 打印一下;c) 算个长度;d) 做个判断,而不是接管它。所以 Rust 提供了借用 (borrowing) 这个概念。

看下面这段代码:

go 复制代码
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { s.len() }

注意我们将 &s1 传递给 calculate_length 函数,并且在其定义中,我们接收的类型是 &String 而不是 String。这些连字符 & 代表引用 (reference),它们允许你引用某个值而无需获取其所有权。下图展示了这一概念。

&s1 让我们能创建一个指向 ``s1 值的引用,但并不拥有它。由于该引用并不拥有该值,因此当引用停止使用时,它所指向的值也不会被释放。

再看一遍 calculate_length 函数。

go 复制代码
fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the String is not dropped.

变量 s 的有效范围与任何函数参数的范围相同,但当 s 停止使用时,它所指向的值并不会被释放,因为 s 并不拥有该值的所有权。当函数使用引用而非实际值作为参数时,我们不再需要为了归还所有权而特意返回该值,因为我们从未真正拥有过它。

3.1. 不可变借用

对于只看的函数

go 复制代码
fn print_string(s: &String) {
    println!("{}", s);
}

调用时:

go 复制代码
let s1 = String::from("hello");
print_string(&s1);
println!("{}", s1);

这里发生的事情是:

  • s1 还是 owner

  • 函数只是借来看看

  • 借用结束后,s1 继续可用

所以 &T 的本质就是给访问权,不给所有权。

3.2. 可变借用

对于不仅要看还要改的函数,注意在连字符 & 后面加了一个 mut (mut 表示 mutable,可变的意思):

go 复制代码
fn append_world(s: &mut String) {
    s.push_str(" world");
}

调用时:

go 复制代码
let mut s = String::from("hello");
append_world(&mut s);

这里函数可以修改内容,但仍然不是 owner。也就是说:值还是你的,只是暂时授权别人改一下。

3.3. 小节

Rust 有一条非常重要的规则:要么多个不可变借用,要么一个可变借用。通俗来讲就是:

  • 可以很多人一起读

  • 或者只允许一个人独占修改

  • 但不能一边读一边改

这看起来严格,其实非常合理。因为它直接帮你从语言层面避免了:

  • 数据竞争

  • 状态混乱

  • 难查的共享可变 bug

所以借用的限制本质上是在做资源访问控制。


  1. 切片 (Slice)

本节解决另一个高频困惑:

  • 为什么字符串参数常写 &str,不是 &String

  • 为什么序列参数常写 &[T],不是 &Vec<T>

原因很简单:函数通常应该接收"更通用的视图",而不是更具体的容器。

4.1. &str:字符串的借用视图

String 是拥有型字符串,&str 是对字符串内容的借用视图。

例如:

go 复制代码
let s = String::from("hello");
let slice = &s[..];

这里 slice 的类型就是 &str。为什么函数更喜欢写 &str?因为如果你写:

go 复制代码
fn print_text(s: &String)

那它既能接 &String,又能接字符串字面量 "hello",所以更灵活。

4.2. &[T]: 序列的借用视图

Vec<T> 是拥有型动态数组,&[T] 是一段连续元素的借用视图。

例如:

go 复制代码
let v = vec![1, 2, 3, 4];
let s = &v[..];

这里 s 的类型就是 &[i32]。为什么函数更喜欢 &[T]?因为如果你写

go 复制代码
fn sum(nums: &Vec<i32>) -> i32

那它只能接 Vec。但如果写:

go 复制代码
fn sum(nums: &[i32]) -> i32

那它可以接 Vec | 数组 | 切片 | 子切片。这就更通用,也更符合 Rust 风格。


  1. 可变和不可变 (Mutable | Immutable)

以函数传参为例,其实只是在做三件事:读、改、接管。很多人一看到函数签名就怕,其实函数参数完全可以用一句很简单的话理解:

这个函数到底是想看、想改,还是想拿走?

例如:

go 复制代码
fn a(v: Vec<i32>) {}
fn b(v: &Vec<i32>) {}
fn c(v: &mut Vec<i32>) {}

它们表达的含义分别是:

  • a:我要拿走它

  • b:我只看

  • c:我可以改,但我不拿走

所以你写 Rust 时,一个很实用的脑回路就是:

  • 只看 → &T

  • 要改 → &mut T

  • 接管 → T


  1. 拷贝克隆释放 (Copy | Clone | Drop)

Copy、Clone 和 Drop 这三个一定要分清。它们很常一起出现,但其实在回答三件不同的事。

6.1. Copy - 值能否自动复制

Copy 表示这个类型在赋值、传参、返回时,可以直接复制一份,原值继续可用。

最典型的例子:

go 复制代码
let x = 5;
let y = x;

这里 x 还能继续用,因为 i32Copy。常见的 Copy 类型包括:

  • 整数

  • 浮点数

  • bool

  • char

以及只由这些简单字段组成的结构体。

6.2. Clone - 值明确想复制一份

Clone 表示我知道这可能有成本,但我现在明确要一份新的副本。

例如:

go 复制代码
let s1 = String::from("hello");
let s2 = s1.clone();

这里会真的重新复制堆上的数据。于是 s1 有一份,s2 也有一份,它们互不影响。

为什么 clone() 必须显式写出来?因为它代价可能昂贵。比如复制一个整数几乎没成本。,但复制一个很大的字符串、向量或复杂对象,可能涉及堆分配和大量数据拷贝。

Rust 的风格是昂贵操作不要偷偷发生。

6.3. Drop - 值用完以后怎么清理

Drop 关注的是资源回收。

当一个值离开作用域时,如果它实现了 Drop,Rust 会自动调用清理逻辑。

例如 String 离开作用域时,会自动释放堆内存。这意味着:Rust 不是让你手动回收资源,而是把"谁该回收"这件事绑定到 ownership 上。谁拥有资源,谁最后负责清理。

这也是为什么 ownership 不只是个语法概念,而是整个资源生命周期管理模型。


  1. 总结

读完本文你会发现开头引进的那些概念并不是散的,它们其实是一整套连贯的设计:

Stack | Heap

说明数据为什么会有不同的存放方式

Ownership

说明 heap 资源该由谁负责管理

Move

防止多个 owner 同时管理同一资源

Borrowing

允许使用但不接管

Slice

提供更通用的借用视图

Copy | Clone | Drop

分别处理复制、显式拷贝和资源清理

说到底,它们都围绕一个核心问题:这份资源归谁管?


相关推荐
前端付豪1 小时前
实现一个用户可以有多个会话
前端·后端·llm
庞轩px1 小时前
MinorGC的完整流程与复制算法深度解析
java·jvm·算法·性能优化
Queenie_Charlie1 小时前
Manacher算法
c++·算法·manacher
闻缺陷则喜何志丹2 小时前
【树的直径 离散化】 P7807 魔力滋生|普及+
c++·算法·洛谷·离散化·树的直径
AI_Ming2 小时前
Seq2Seq-大模型知识点(程序员转行AI大模型学习)
算法·ai编程
若水不如远方2 小时前
分布式一致性(六):拥抱可用性 —— 最终一致性与 Gossip 协议
分布式·后端·算法
csdn_zhangchunfeng2 小时前
Qt之slots和Q_SLOTS的区别
开发语言·qt
lianghanwu19992 小时前
深入解析 Apache Kafka:从核心原理到实战进阶指南
后端
计算机安禾2 小时前
【C语言程序设计】第35篇:文件的打开、关闭与读写操作
c语言·开发语言·c++·vscode·算法·visual studio code·visual studio