Rust 学习笔记(5/21):认识所有权(下)——”借用”与切片:先借后还,天经地义
本系列基于官方《Rust 程序设计语言》(The Rust Programming Language,TRPL)逐章学习。上篇讲了所有权的”移动与释放”,本篇把视角换成:不想拥有值时,怎么办?
开篇:图书馆的借书证
上篇学到:值被传给函数 = 所有权被”搬走”,想继续用还得靠返回值”搬回来”——那计算长度这类只读操作,岂不是每回都要上演一场搬运大戏?
现实里我们不会为了”看一眼书”就把书买下来。去图书馆,我们借:借书证在手上,书看完还回去,所有权始终归图书馆。Rust 的引用(reference)正是这张借书证,而”借”这个动作,Rust 叫借用(borrowing)。
1
2
3
4
5
6
7
8
9
10
11
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // &s1:把 s1 "借"给函数,不是送出去
println!("The length of '{}' is {}.", s1, len); // s1 还能用!
}
fn calculate_length(s: &String) -> usize { // 参数类型是 &String(引用)而非 String
s.len()
}
&s1 创建了一个指向 s1 的引用但并不拥有它。因为没拥有,引用停止使用时,它指向的值不会被丢弃;函数用引用当参数,也不用费劲”交还所有权”——因为压根就没拿走。注意与 & 相反的操作是解引用(dereference),用运算符 *,第 8、15 章会细讲。
引用默认不可变:别想顺手改书
借来的书当然不能在上面写字。引用同样默认不可变——下面的代码通不过编译:
1
2
3
fn change(some_string: &String) {
some_string.push_str(", world"); // error[E0596]
}
报错信息很贴心,直接给出修复建议:把参数改成可变引用 &mut String。引用要可变,得”借”得明明白白:
1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello"); // ① s 本身要先 mut
change(&mut s); // ② 创建可变引用 &mut s
}
fn change(some_string: &mut String) { // ③ 签名接受 &mut String
some_string.push_str(", world"); // 借的是"能改"的资格,push_str 合法
}
三处改动(mut s、&mut s、&mut String)缺一不可——Rust 要求”会改变借用值”这件事在代码里一目了然。
可变引用的一大限制:同一时刻只能借给一个人
在大多数语言里,多个地方同时改一个变量稀松平常(也埋下无数 bug)。Rust 选择同一时间,只能有一个对同一数据的可变引用:
1
2
3
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // error[E0499]: cannot borrow `s` as mutable more than once at a time
直觉上”很不自由”,但这正是为了在编译期杜绝数据竞争(data race)。数据竞争由三个行为同时发生造成:两个及以上指针同时访问同一数据、至少一个是写入、没有同步机制。数据竞争导致未定义行为、运行时极难追踪,Rust 干脆让这样的代码无法编译——错误从”半夜排查诡异崩溃”提前到”敲完回车那一刻”。
想要多个可变引用?可以用大括号圈出作用域,让它们先后出现而非”同时”:
1
2
3
4
5
6
7
let mut s = String::from("hello");
{
let r1 = &mut s;
} // r1 在这里离开作用域,借书证作废
let r2 = &mut s; // 现在完全没问题
不可变引用与可变引用也不能共存(E0502):读者不希望手里的书在自己眼皮底下被改写。但多个不可变引用同时存在是允许的——只读的人再多,也不会互相影响。
NLL:借书证自动”到期”
引用的作用域从声明处开始,到最后一次使用为止。看这段能编译的代码:
1
2
3
4
5
6
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // r1、r2 最后一次使用在这
let r3 = &mut s; // 没问题:r1/r2 的"借期"已结束
println!("{}", r3);
编译器判断”引用在作用域结束前已不再使用”的能力,叫非词法作用域生命周期(Non-Lexical Lifetimes,NLL)——借书证不必等到离开图书馆大楼才失效,书还了、没人看了,借期即止。
悬垂引用:Rust 根本不让你制造”幽灵指针”
C 语言里极易写出悬垂指针:内存已释放,指针还攥在手里,指向的内容可能已被别的变量占用。Rust 保证引用永远不悬垂——数据不会在它的引用之前离开作用域:
1
2
3
4
5
6
fn dangle() -> &String {
let s = String::from("hello");
&s // 想把 s 的引用带出去?
} // 但 s 在这里就被 drop 了!
// error[E0106] ... this function's return type contains a borrowed value,
// but there is no value for it to be borrowed from
错误信息直白地解释:返回值是个”借来的值”,却没有任何东西可借。修复方法也很干脆——直接把 s 还回去(移动所有权,而不是借):
1
2
3
4
fn no_dangle() -> String {
let s = String::from("hello");
s // 所有权移出,没人被释放
}
引用的规则(本章核心总结)
| 场景 | 是否允许 | 说明 |
|---|---|---|
| 多个不可变引用 | ✅ | 只读者互不干扰 |
| 一个可变引用 | ✅ | 前提:无其他可变/不可变引用并存 |
| 多个可变引用(同时) | ❌ E0499 | 编译期杜绝数据竞争 |
| 可变 + 不可变(同时) | ❌ E0502 | 读者不希望书被改 |
| 返回悬垂引用 | ❌ E0106 | 数据先于引用被释放 |
一句话:要么同时多个不可变引用,要么同时一个可变引用。这是借用检查器的全部”交通规则”。
切片:为什么”返回索引”是个坏主意
理解了引用,来看它最优雅的应用——切片(slice):允许引用集合中一段连续的元素序列,而不必引用整个集合。
经典习题:写个 first_word,返回字符串的第一个单词。函数签名 fn first_word(s: &String) -> ? 返回什么?新手最容易想到的是返回结尾索引:
1
2
3
4
5
6
7
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' { return i; } // 找到空格,返回其索引
}
s.len()
}
麻烦立刻来了。返回的 usize 是独立于字符串的一个数字,只在算它那一刻有意义:
1
2
3
4
let mut s = String::from("hello world");
let word = first_word(&s); // word = 5
s.clear(); // s 变空
// word 还是 5,但已经没有任何字符串能让 5 生效了!
代码编译通过、运行也不报错——但 word 与 s 的状态完全没有联系,是个潜伏的 bug。再写 second_word 就得返回 (usize, usize) 同时跟踪起始和结尾,三个飘忽不定的数要手动保持同步,又啰嗦又易错。问题根源:索引值脱离了数据本身。
字符串切片:让”部分引用”自带边界
Rust 的解法是返回字符串切片,把”从哪到哪”连同底层数据绑定在一起:
1
2
3
let s = String::from("hello world");
let hello = &s[0..5]; // "hello"
let world = &s[6..11]; // "world"
内部结构只有两样:指向起始位置的指针 + 长度(ending_index - starting_index)。&s[0..5] 用 [起始..结束] 的 range 语法,其中 结束 是最后一个位置的后一个值。语法有糖可吃:
| 写法 | 等价于 | 含义 |
|---|---|---|
&s[..2] |
&s[0..2] |
从开头取 |
&s[3..] |
&s[3..len] |
取到末尾 |
&s[..] |
&s[0..len] |
取整个字符串 |
注意:字符串切片的下标必须落在有效的 UTF-8 字符边界上,从多字节字符中间切会直接报错退出(本章暂假设 ASCII,第 8 章细讲 UTF-8)。
重写 first_word,把返回类型从 usize 换成 &str(字符串切片的类型名):
1
2
3
4
5
6
7
8
9
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]; // 返回"部分引用",而不是裸索引
}
}
&s[..] // 没有空格,返回整个字符串的切片
}
奇迹发生了:上一种写法里”clear 之后再拿索引用”的潜伏 bug,现在直接变成编译错误——s.clear() 需要可变引用,而 word 还握着不可变引用,E0502 当场拦截:
1
2
3
4
let mut s = String::from("hello world");
let word = first_word(&s); // 不可变借用开始
s.clear(); // error[E0502]:不能一边借一边改
println!("the first word is: {}", word); // word 这里还要用呢
Rust 不仅让 API 更简单,还在编译期就消灭了一整类 bug。这就是切片的意义:把”部分数据的引用”变成头等公民,让它的生命周期跟着底层数据一起被检查。
字符串字面量本质是 &str:借了一辈子
还记得字符串字面量吗?let s = "Hello, world!"; 的 s,类型其实是 &str——一个指向二进制程序中固定位置的切片。这也解释了为什么字符串字面量不可变:&str 是不可变引用,指向的数据被写死在可执行文件里,永远有人”拥有”它,所以引用可以长期有效。
参数写成 &str:一个函数通吃所有字符串
有经验的 Rustacean 会把 first_word 的签名写成接收 &str 而非 &String:
1
2
3
4
5
6
7
8
fn first_word(s: &str) -> &str { /* 函数体完全相同 */ }
// 调用时,以下全都合法:
let my_string = String::from("hello world");
first_word(&my_string[0..6]); // String 的部分切片
first_word(&my_string[..]); // String 的全部切片
first_word(&my_string); // String 的引用(会被自动转成 &str)
first_word(my_string_literal); // 字面量本身就是 &str,直接传!
只改一个参数类型,函数立刻从”只能服务 String“变成”通吃 &String、&str、字符串字面量”,API 更通用还不损失任何功能(背后是第 15 章要讲的 deref coercion 隐式转换)。
其他类型的切片:不止字符串
切片是通用机制。数组也可以切:
1
2
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3]; // 类型是 &[i32],同样存"起始引用 + 长度"
字符串切片只是 &[u8] 在字符串领域的特化。第 8 章讲 vector 时,这套”部分引用”心智模型会继续复用。
实践建议与总结
- 函数参数优先用引用、返回切片而非索引:需要只读访问时,写
&String甚至直接&str(更通用),别用usize裸索引——”带着长度和边界的数据引用”远比”孤立数字”安全; - 编译报 E0499/E0502 时先冷静查”并存”:不是你的代码逻辑错了,而是两个借用在时间上有重叠——用大括号收窄作用域,或让不可变引用最后使用后再开可变引用,往往就过了(NLL 已经在帮你放宽边界);
- 见到
&mut就警觉:它意味着”我可能要改这块数据”,同块数据上不该再有人读或写——这条纪律在并发场景(第 16 章)会回报你千倍。
至此,”所有权三件套”全部登场:值有唯一所有者、离开作用域即释放(上篇)→ 想用不想要就”借用”,同一时刻一个可变引用或无数不可变引用、绝不悬垂(本篇)→ 想取一段数据就用切片,让部分引用也接受同样的安全检查。 三者的共同承诺只有一个:编译通过 = 内存安全,把 C 语言里要靠几十年经验才能躲开的坑,变成编译器在你眼前划掉的红色波浪线。下一站(第 5 章),把这套规则带进自定义类型 struct。