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 生效了!

代码编译通过、运行也不报错——但 words 的状态完全没有联系,是个潜伏的 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 时,这套”部分引用”心智模型会继续复用。

实践建议与总结

  1. 函数参数优先用引用、返回切片而非索引:需要只读访问时,写 &String 甚至直接 &str(更通用),别用 usize 裸索引——”带着长度和边界的数据引用”远比”孤立数字”安全;
  2. 编译报 E0499/E0502 时先冷静查”并存”:不是你的代码逻辑错了,而是两个借用在时间上有重叠——用大括号收窄作用域,或让不可变引用最后使用后再开可变引用,往往就过了(NLL 已经在帮你放宽边界);
  3. 见到 &mut 就警觉:它意味着”我可能要改这块数据”,同块数据上不该再有人读或写——这条纪律在并发场景(第 16 章)会回报你千倍。

至此,”所有权三件套”全部登场:值有唯一所有者、离开作用域即释放(上篇)→ 想用不想要就”借用”,同一时刻一个可变引用或无数不可变引用、绝不悬垂(本篇)→ 想取一段数据就用切片,让部分引用也接受同样的安全检查。 三者的共同承诺只有一个:编译通过 = 内存安全,把 C 语言里要靠几十年经验才能躲开的坑,变成编译器在你眼前划掉的红色波浪线。下一站(第 5 章),把这套规则带进自定义类型 struct