Rust 的所有权规则,最适合从一次编译器报错开始理解。它的目的,是把资源什么时候释放、谁可以修改、谁只能读取这些问题,提前放进类型和作用域里。刚开始写时,报错像是在阻止我;读懂以后,它更像是在提醒我把责任写清楚。
先看移动
在 Rust 中,把一个 String 赋给另一个变量,默认会发生移动:
let first = String::from("draft");
let second = first;
// println!("{}", first); // value borrowed here after move
这段代码没有复制字符串,堆内存的所有权已经交给了 second。如果 first 仍然可用,两个变量在作用域结束时就可能尝试释放同一块内存。Rust 选择在编译期禁止这种含糊,把问题挡在运行时之前。
复制和移动需要区分。整数等实现了 Copy 的简单值会按值复制;String、Vec<T> 这样的拥有堆资源的类型则默认移动。需要一份独立数据时,可以显式调用 clone,但 clone 不是免费的修饰词,它会执行内存复制,应该在知道成本以后使用。
借用让接口更像接口
很多函数并不需要接管数据,只要读取它。这时可以借用:
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
let source = String::from("a small note");
let count = word_count(&source);
word_count 接受的是字符串切片,不拥有传入的数据。调用结束以后,source 仍然有效。可变借用也有规则:同一时间可以有多个不可变借用,或者只能有一个可变借用,不能两者同时存在。这个限制看起来麻烦,却把数据竞争的许多可能性压在了编译阶段。
我最容易犯的错误,是为了让编译器闭嘴,把所有参数都改成 clone。这样程序可能很快通过编译,却把所有权问题变成了隐藏的复制成本。更好的做法是回到接口:函数是否需要拥有数据?是否只读?是否应该返回一份新值?这些问题想清楚以后,生命周期标注通常也不会再显得那么突兀。
生命周期不是延长对象寿命
生命周期标注描述的是引用之间的关系,不是让值活得更久。下面的函数返回两个字符串切片中较长的一个:
fn longer<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
'a 表示返回的引用不能超过两个输入引用中更短的那个生命周期。编译器并没有替我决定一个“最长寿命”,而是要求我说明返回值与输入的关系。如果函数返回的是新创建的 String,就不需要把一个已经释放的局部引用带出去。
把报错当成设计反馈
所有权系统真正有用的地方,不只是避免悬空指针。它迫使代码回答资源归属、可变性和接口职责。读文件的函数不必顺手修改全局缓存,处理集合的函数也不必悄悄夺走调用方的所有权。这样的约束会让一部分早期代码写得慢,却让后续维护时少一些“这里到底谁负责释放”的猜测。
我现在遇到借用检查器报错,会先画出值的来源、需要使用它的地方和作用域,而不是马上加 clone 或把生命周期写成一串标注。很多时候,问题在于一个函数承担了太多事情,或者数据应该被拆成两个更小的值。
Rust 不要求所有项目都改写成它的样子。它给出的经验是:资源边界越早写清,运行时越少依赖运气。即使最后仍然使用其他语言,这种先问“谁拥有它、谁能改它、何时释放”的习惯,也会让接口和数据流更容易被解释。