尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Ownership - Rust Hardcore Head to Toe

Ownership - Rust Hardcore Head to Toe 很多人第一次学 Rust最先劝退的不是语法而是这一串词stack | heapownership | borrowing | slicemutable | inmutablecopy | clone | drop每个词单看起来都懂可一旦放到一起脑子就容易乱。你可能也有过这些疑问为什么i32赋值后还能继续用String却不行为什么有时候函数传参会把值“用没了”为什么字符串参数经常写成str而不是String为什么 Rust 要分Copy、Clone、Drop这么多概念如果把这些问题压缩成一句话其实它们都在问同一个东西这份资源现在归谁管而这正是 Rust 的核心。1. 栈和堆 (Stack | Heap)一切都要从 Stack 和 Heap 说起。在程序运行时数据总得放在内存里。而最常见的两个地方就是stack中文翻译成栈heap中文翻译成堆它们都是内存但它们的组织方式完全不同。也正因为不同Rust 才会有后面那整套所有权 (ownership) 规则。1.1. 快而规矩的栈你可以把栈理解成一摞盘子。放盘子时只能往最上面放。拿盘子时也只能从最上面拿。这就是后进先出 (Last In First Out, LIFO)。比如先放 A 再放 B 再放 C拿出来时一定是先拿 C 再拿 B 再拿 APush 和 Pop 是对栈操作的两个词push onto the stack压入栈pop off the stack从栈弹出比如Push A Push B Push C现在栈顶是 C然后pop - C pop - B pop - A栈的特点非常鲜明结构规整分配快回收快适合大小固定的数据比如这些类型i32, bool, char, f64它们的大小在编译时就已经确定了所以很适合直接放到栈里。为什么栈快因为它根本不用“找位置”新数据永远放栈顶而旧数据永远从栈顶拿走。1.2. 慢但灵活的堆你可以把堆理解成一个大仓库。往里面放数据时不能像栈那样直接堆上去。首先需要一块这么大的空间然后内存分配器会帮你在堆里找到一块够大的空位把它分给你并返回这块地方的地址。这个过程叫分配 (allocation)而返回的地址就是指针 (pointer)。堆的优势在于灵活。如果一个值编译时大小不确定或者运行时大小可能变化那通常就更适合放在堆上。最经典的例子就是 String因为字符串长度是会变的。因为你拿到的往往不是数据本身而是它的地址。你得先拿指针再顺着指针找到真正的数据。所以栈更像“伸手就拿”而堆更像“先拿门牌号再去仓库找货”。如果所有数据都像栈一样进入作用域时创建离开作用域时自动消失那程序员会轻松很多。真正麻烦的是堆。因为堆上的数据会带来一串问题谁拥有它谁负责释放它会不会还在使用时就被释放会不会被释放两次会不会没人用了却一直不释放解决上述问题是 Rust 的真正目标是即安全地管理堆上的内存同时又不需要垃圾回收器 (garbage collector, GC)也就是说Rust 想做到两件事即不像 C/C 那样容易内存泄漏、无效指针、重复释放又不像 Java/Python 那样依赖运行时 GC所以 Rust 选择了一条更难但更强的路在编译期就把内存管理规则检查清楚而 ownership 就是这套规则的核心。2. 所有权 (Ownership)Rust 的所有权是为了安全地管理堆上的资源。2.1. String 数据类型为什么String赋值后原变量不能再用这是初学者最经典的疑问。看代码let s1 String::from(hello); let s2 s1;很多人的直觉是这不就是把 s1 复制给 s2 吗其实不是。String不是直接把字符串内容整个放在栈上。它通常分成两部分1. 栈上保存三个固定大小的信息指针ptr长度 len 和容量 capacity。2. 堆上保存真正的字符内容。stack: s1 - [ptr, len, capacity] heap: [h][e][l][l][o]可视化一下这行代码 let s2 s1; 到底代表什么 猜测一深度复制 (deep copy) Rust 不会如此低效的事情因此不是深度复制。 猜测二浅度复制 (shallow copy) 浅度复制会导致s1 和 s2 都指向这块堆数据。听起来好像没什么。但等作用域结束时问题就来了s1觉得自己该释放这块内存s2也觉得自己该释放这块内存于是同一块内存会被释放两次。这就是二次释放 (double free)。猜测三移动 (move)Rust 为了避免二次释放问题做了一个非常关键的设计对 String 这种拥有堆资源的类型赋值默认不是 copy而是 move。也就是说let s1 String::from(hello); let s2s1;真正发生的是所有权从s1转移给s2s1失效最终只由s2负责释放堆数据2.2. Stack-only 数据类型我们再看一个例子let x 5; let y x;这里x还能继续用因此这时 let yx; 做的是 copy 而不是 move。不仅仅是整数 (i)Rust 里浮点数 (f)、bool 和 char 类型变量用 都是 copy 而不是 move。这些数据类型都是在栈上被称为 stack-only 数据。为什么String不是 copy因为它管理堆资源还记得上图 String 的指针、长度和容量都在栈上而指针指得内容却在堆上。如果String也是copy又会回到那个老问题多个变量同时指向同一块堆内存最后谁来释放会不会释放两次所以涉及资源所有权的类型通常不是 copy 而是 move。2.3. 小节所有权规则可以压缩成三句每个值都有一个 owner同一时刻只能有一个 ownerowner 离开作用域时值会被清理看起来像语法规则。但本质上它在解决一个底层问题谁来负责这块资源的生命周期3. 借用 (Borrowing)试想一下如果每次使用值都要 move那写代码会非常痛苦。因为现实中大部分时候我们只是想a) 看一眼这个值b) 打印一下c) 算个长度d) 做个判断而不是接管它。所以 Rust 提供了借用 (borrowing) 这个概念。看下面这段代码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函数。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. 不可变借用对于只看的函数fn print_string(s: String) { println!({}, s); }调用时let s1 String::from(hello); print_string(s1); println!({}, s1);这里发生的事情是s1还是 owner函数只是借来看看借用结束后s1继续可用所以T的本质就是给访问权不给所有权。3.2. 可变借用对于不仅要看还要改的函数注意在连字符 后面加了一个 mut (mut 表示 mutable可变的意思)fn append_world(s: mut String) { s.push_str( world); }调用时let mut s String::from(hello); append_world(mut s);这里函数可以修改内容但仍然不是 owner。也就是说值还是你的只是暂时授权别人改一下。3.3. 小节Rust 有一条非常重要的规则要么多个不可变借用要么一个可变借用。通俗来讲就是可以很多人一起读或者只允许一个人独占修改但不能一边读一边改这看起来严格其实非常合理。因为它直接帮你从语言层面避免了数据竞争状态混乱难查的共享可变 bug所以借用的限制本质上是在做资源访问控制。4. 切片 (Slice)本节解决另一个高频困惑为什么字符串参数常写str不是String为什么序列参数常写[T]不是VecT原因很简单函数通常应该接收“更通用的视图”而不是更具体的容器。4.1. str字符串的借用视图String是拥有型字符串str是对字符串内容的借用视图。例如let s String::from(hello); let slice s[..];这里slice的类型就是str。为什么函数更喜欢写str因为如果你写fn print_text(s: String)那它既能接 String又能接字符串字面量hello所以更灵活。4.2. [T]: 序列的借用视图VecT 是拥有型动态数组[T] 是一段连续元素的借用视图。例如let v vec![1, 2, 3, 4]; let s v[..];这里s的类型就是[i32]。为什么函数更喜欢[T]因为如果你写fn sum(nums: Veci32) - i32那它只能接Vec。但如果写fn sum(nums: [i32]) - i32那它可以接 Vec | 数组 | 切片 | 子切片。这就更通用也更符合 Rust 风格。5. 可变和不可变 (Mutable | Immutable)以函数传参为例其实只是在做三件事读、改、接管。很多人一看到函数签名就怕其实函数参数完全可以用一句很简单的话理解这个函数到底是想看、想改还是想拿走例如fn a(v: Veci32) {} fn b(v: Veci32) {} fn c(v: mut Veci32) {}它们表达的含义分别是a我要拿走它b我只看c我可以改但我不拿走所以你写 Rust 时一个很实用的脑回路就是只看 →T要改 →mut T接管 →T6. 拷贝克隆释放 (Copy | Clone | Drop)Copy、Clone 和 Drop 这三个一定要分清。它们很常一起出现但其实在回答三件不同的事。6.1. Copy - 值能否自动复制Copy 表示这个类型在赋值、传参、返回时可以直接复制一份原值继续可用。最典型的例子let x 5; let y x;这里x还能继续用因为i32是Copy。常见的Copy类型包括整数浮点数boolchar以及只由这些简单字段组成的结构体。6.2. Clone - 值明确想复制一份Clone 表示我知道这可能有成本但我现在明确要一份新的副本。例如let s1 String::from(hello); let s2 s1.clone();这里会真的重新复制堆上的数据。于是 s1 有一份s2 也有一份它们互不影响。为什么clone()必须显式写出来因为它代价可能昂贵。比如复制一个整数几乎没成本。但复制一个很大的字符串、向量或复杂对象可能涉及堆分配和大量数据拷贝。Rust 的风格是昂贵操作不要偷偷发生。6.3. Drop - 值用完以后怎么清理Drop关注的是资源回收。当一个值离开作用域时如果它实现了DropRust 会自动调用清理逻辑。例如String离开作用域时会自动释放堆内存。这意味着Rust 不是让你手动回收资源而是把“谁该回收”这件事绑定到 ownership 上。谁拥有资源谁最后负责清理。这也是为什么 ownership 不只是个语法概念而是整个资源生命周期管理模型。6. 总结读完本文你会发现开头引进的那些概念并不是散的它们其实是一整套连贯的设计Stack | Heap说明数据为什么会有不同的存放方式Ownership说明 heap 资源该由谁负责管理Move防止多个 owner 同时管理同一资源Borrowing允许使用但不接管Slice提供更通用的借用视图Copy | Clone | Drop分别处理复制、显式拷贝和资源清理说到底它们都围绕一个核心问题这份资源归谁管
返回列表