练习与自测
本章练习共 10 题,答案折叠在每题下方。建议先自己写、编译通过后再展开答案;难度标记:★☆☆ 基础 / ★★☆ 综合 / ★★★ 挑战。
练习 1:判断 String 赋值后的 move
难度:★☆☆
要求:判断下面的代码能否通过编译。若能,写出输出;若不能,指出错误码、原因,并给出最小修改。
rust
fn main() {
let s = String::from("rust");
let t = s;
println!("{s} {t}");
}提示:思考 s 的所有权去哪了。
参考答案(先自己写再看)
参考答案(先自己写再看)
不能编译。
error[E0382]: borrow of moved value: `s`
|
3 | let t = s;
| - value moved here
4 | println!("{s} {t}");
| ^ value borrowed here after move
|
= note: move occurs because `s` has type `String`, which does not implement the `Copy` trait原因:String 拥有堆缓冲区,不实现 Copy。let t = s; 把所有权移给 t,s 作废。
最小修改(借用,零成本):
rust
fn main() {
let s = String::from("rust");
let t = &s; // 只借不夺
println!("{s} {t}"); // 输出:rust rust
}要点解析:&s 与 s.clone() 都能让它编过,但 &s 不复制堆数据。 只有在「需要两个能独立存活的所有者」时才该用 clone()。
练习 3:可变借用与只读借用的冲突
难度:★★☆
要求:下面代码编译失败。指出错误码、出错的两行,并给出三种不同的修法。
rust
fn main() {
let mut data = vec![1, 2, 3];
let first = &data[0];
data.push(4);
println!("{first}");
}提示:push 为什么不能和 first 共存?
参考答案(先自己写再看)
参考答案(先自己写再看)
错误码 E0502,出错两行是 let first = &data[0];(不可变借用)与 data.push(4); (可变借用),真正的原因是 first 在第 5 行 println! 还被使用。
error[E0502]: cannot borrow `data` as mutable because it is also borrowed as immutable
|
3 | let first = &data[0];
| ---- immutable borrow occurs here
4 | data.push(4);
| ^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here修法一:缩小作用域(零成本,首选):
rust
fn main() {
let mut data = vec![1, 2, 3];
{
let first = &data[0];
println!("{first}"); // 输出:1
} // 借用结束
data.push(4);
println!("{data:?}"); // 输出:[1, 2, 3, 4]
}修法二:复制出值而不是借用(i32 是 Copy):
rust
fn main() {
let mut data = vec![1, 2, 3];
let first = data[0]; // 注意:这里没有 &,得到的是 i32 的副本
data.push(4);
println!("{first} {data:?}"); // 输出:1 [1, 2, 3, 4]
}修法三:先收集后修改:
rust
fn main() {
let mut data = vec![1, 2, 3];
let snapshot: Vec<i32> = data.clone(); // 有代价:O(n) 复制
let first = &snapshot[0];
data.push(4);
println!("{first} {data:?}"); // 输出:1 [1, 2, 3, 4]
}要点解析:修法二的关键是 data[0](索引表达式)会按值取出元素, 因为 i32: Copy。若元素是 String,data[0] 反而会报「cannot move out of index」, 那时就得用修法一。
练习 4:不用 clone() 修复 E0382
难度:★★☆
要求:下面代码有三处 E0382。在不使用 clone() 的前提下修复它, 使输出为 hello 和 HELLO(第二行由 make_upper 返回)。
rust
fn main() {
let s = String::from("hello");
let t = s;
let upper = make_upper(s);
println!("{}", t);
println!("{}", upper);
}
fn make_upper(input: String) -> String {
input.to_uppercase()
}提示:make_upper 需要 String 还是 &str?
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
fn main() {
let s = String::from("hello");
let upper = make_upper(&s); // 借用,不交出所有权
println!("{s}"); // 输出:hello
println!("{upper}"); // 输出:HELLO
}
fn make_upper(input: &str) -> String {
input.to_uppercase() // 新 String 的所有权作为返回值移出
}要点解析:
- 原代码的问题不止一个:
let t = s;移走s,紧接着make_upper(s)再次使用s。 - 把参数从
String改成&str后,调用方保留所有权;to_uppercase()本身就会返回一个新String,所以不需要 move 进函数再 move 出来。 to_uppercase而不是to_ascii_uppercase:前者支持 Unicode('ß' → "SS"), 后者仅处理 ASCII 且逐字节更快。示例数据是纯 ASCII,两者输出相同。
扩展:若函数必须拥有输入(例如要把 String 存进结构体), 签名保留 String,调用方用 s.clone() —— 但那是一次真实的深拷贝,要有理由。
练习 6:用块作用域收缩借用的范围
难度:★★☆
要求:把下面的代码改成「先做只读统计,再修改容器」,只允许使用 {} 块和变量重排, 不得引入 clone()、RefCell 或索引循环。修改后输出应为 sum=6 与 [1, 2, 3, 60]。
rust
fn main() {
let mut v = vec![1, 2, 3];
let last = v.last().unwrap();
v.push(60);
let sum: i32 = v.iter().sum();
println!("sum={sum}");
println!("{v:?}");
println!("last 是 {last}");
}提示:last 这个借用要么提前用掉,要么换成值。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
fn main() {
let mut v = vec![1, 2, 3];
// 只读阶段:把需要的信息变成「值」,不要留着引用
let sum: i32 = {
let last = v.last().unwrap(); // 借用只活在这个块内
println!("last 是 {last}"); // 输出:last 是 3
v.iter().sum()
}; // last 的借用到此结束
v.push(60); // 现在可以可变借用了
println!("sum={sum}"); // 输出:sum=6
println!("{v:?}"); // 输出:[1, 2, 3, 60]
}要点解析:
- 原代码里
last: &i32一直活到最后的println!,与v.push(60)冲突(E0502)。 - 修法的核心思想是 「让借用结束得比修改早」: 把只读工作全部包进
{},块结束时last被 drop,借用随之失效。 println!的顺序被调整了:因为要在借用结束前输出last。 若输出顺序必须保持原样,就把last复制成值:let last = *v.last().unwrap();(i32: Copy),这样它就不再是借用。
练习 7:用切片实现 first_word
难度:★★☆
要求:实现 fn first_word(s: &str) -> &str,返回第一个空格之前的部分; 若没有空格则返回整个字符串。不得使用 Vec、String 或 .clone()。 然后用它写一个示例,演示「拿到切片后修改原 String」会被编译器拒绝(写清错误码)。
提示:s.find(' ') 返回 Option<usize>。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
fn main() {
let s = String::from("hello world");
let w = first_word(&s);
println!("第一个词是 {w}"); // 输出:第一个词是 hello
let only = String::from("hello");
println!("{}", first_word(&only)); // 输出:hello
}演示冲突的部分(把下面这段单独编译会失败):
rust
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
fn main() {
let mut s = String::from("hello world");
let w = first_word(&s);
s.clear(); // 编译失败
println!("{w}");
}error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
|
3 | let w = first_word(&s);
| -- immutable borrow occurs here
4 | s.clear();
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{w}");
| - immutable borrow later used here要点解析:
fn first_word(s: &str) -> &str用的是省略规则 2(只有一个输入生命周期,输出跟着它)。 写成fn first_word<'a>(s: &'a str) -> &'a str完全等价。- 冲突是必然的、也是必要的:
w指向s的堆缓冲区, 如果允许s.clear(),w立刻悬垂。C++ 里同样的代码是 UB,Rust 直接编译不过。 - 修法:先
println!("{w}"),再s.clear()(把读放在写之前)。
练习 8:生命周期标注与 E0597
难度:★★★
要求:实现 fn longest<'a>(x: &'a str, y: &'a str) -> &'a str, 然后用下列 main 验证:解释为什么直接写 println!("{result}") 会报 E0597, 以及怎样调整代码让它通过。
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string is long");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
}
println!("最长的字符串是 {result}");
}提示:'a 取的是两个实参存活区间的交集。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string is long");
// 修法:让 s2 活得和 result 一样久,把 s2 提到外层作用域
let s2 = String::from("xyz");
let result = longest(s1.as_str(), s2.as_str());
println!("最长的字符串是 {result}"); // 输出:最长的字符串是 long string is long
}为什么原代码报 E0597:
error[E0597]: `s2` does not live long enough
|
4 | let s2 = String::from("xyz");
| -- binding `s2` declared here
5 | result = longest(s1.as_str(), s2.as_str());
| ^^ borrowed value does not live long enough
6 | } - `s2` dropped here while still borrowed
7 | println!("最长的字符串是 {result}");
| ------ borrow later used herelongest<'a>的签名要求:x与y必须满足同一个'a。 由于result要在块外使用,'a必须覆盖到第 7 行。- 但
y = s2.as_str()只在块内有效,s2在块尾被 drop。 于是'a的约束无法同时满足「覆盖到第 7 行」和「不超过s2的存活区间」。 - 编译器选取
'a = s2 的存活区间(可行解中最长的一个), 然后发现result在这个区间之外被使用 —— 报E0597。
要点解析:'a 不是「能随意拉长的时长」,它是两个实参存活区间的交集。 想延长 result 的可用范围,只能延长较短的实参的寿命(把 s2 提到外层), 不能靠改签名。若确实需要让返回值独立于 s2,就得返回 String(拥有所有权)。
练习 9:if let 临时值作用域的 edition 差异
难度:★★★
要求:下面的代码在 --edition 2021 与 --edition 2024 下都能编译通过, 但运行结果不同。说明各自的运行结果、根本原因,以及如何让 2021 下的行为与 2024 一致。
rust
use std::cell::RefCell;
fn f(value: &RefCell<Option<bool>>) {
if let Some(x) = *value.borrow() {
println!("value is {x}");
} else {
let mut v = value.borrow_mut();
if v.is_none() {
*v = Some(true);
}
}
}
fn main() {
let c = RefCell::new(None);
f(&c);
f(&c);
println!("{:?}", c.borrow());
}提示:*value.borrow() 产生的是一个临时值;再想想 2024 edition 改了它的 drop 时机。
参考答案(先自己写再看)
参考答案(先自己写再看)
两个 edition 都能编译,但运行结果不同。
--edition 2024 输出:
value is true
Some(true)
--edition 2021 输出:
thread 'main' panicked at src\main.rs:8:39:
RefCell already borrowed根本原因:*value.borrow() 里的 value.borrow() 返回一个临时的 Ref 守卫。
- 2021:这个临时值的作用域被延长到整个
if let语句之后。 进入else分支时Ref仍然活着(只读借用未释放), 此时再borrow_mut()就违反RefCell的运行期规则 —— panic (换成RwLock就是死锁:读锁未放,又去拿写锁)。 - 2024:临时值在进入
else之前就被 drop,所以borrow_mut()拿得到。
让 2021 与 2024 行为一致的两种改法:
改成 match(match 的临时值作用域仍是「到语句结束」,即 2021 行为 —— 会 panic, 但至少两个 edition 一致):
rust
use std::cell::RefCell;
fn f(value: &RefCell<Option<bool>>) {
match *value.borrow() {
Some(x) => println!("value is {x}"),
None => {
let mut v = value.borrow_mut();
if v.is_none() { *v = Some(true); }
}
}
}推荐:先把值读出来,让临时 Ref 在 if let 之前就结束,两个 edition 都不 panic:
rust
use std::cell::RefCell;
fn f(value: &RefCell<Option<bool>>) {
let current = *value.borrow(); // 读出来就放掉借用,得到 Option<bool>
if let Some(x) = current {
println!("value is {x}");
} else {
let mut v = value.borrow_mut(); // 现在没有任何借用冲突
if v.is_none() { *v = Some(true); }
}
}
fn main() {
let c = RefCell::new(None);
f(&c);
f(&c);
println!("{:?}", c.borrow()); // 输出:Some(true)
}验证命令:
powershell
rustc --edition 2021 -o "$env:TEMP\e2021.exe" main.rs; & "$env:TEMP\e2021.exe" # panic
rustc --edition 2024 -o "$env:TEMP\e2024.exe" main.rs; & "$env:TEMP\e2024.exe" # Some(true)要点解析:
- 这是运行期行为差异,不是编译错误差异 —— 借用检查器不会帮你发现它。 这正是「临时值作用域」比「借用何时结束」更难察觉的地方。
- 迁移到 2024 时,
cargo fix --edition会通过if_let_rescopelint 把有风险的if let改写成match,并提示你人工确认(因为match保留了 2021 的 「临时值活到语句结束」行为,在本例中反而会保留 panic)。 - 真正的惯用解法是 ****:不要依赖临时值的 drop 时机, 而是把需要的值显式取出为一个具名绑定(
let current = ...)。 这样代码的语义不再依赖 edition。
练习 10:用 mem::take 解决循环中的借用冲突
难度:★★★
要求:下面代码编译失败。用 std::mem::take 或 std::mem::replace 修复它, 输出应为 [2, 4, 6]。
rust
fn main() {
let mut v = vec![1, 2, 3];
for x in &v {
v.push(x * 2);
}
println!("{v:?}");
}提示:先把 v 搬出去,处理完再搬回来。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
fn main() {
let mut v = vec![1, 2, 3];
// 把数据整体搬出来,v 原位变成空 Vec —— 冲突的「另一方」消失了
let taken = std::mem::take(&mut v);
for x in &taken {
v.push(x * 2);
}
println!("{v:?}"); // 输出:[2, 4, 6]
}用 std::mem::replace 的等价写法(保留原数据所有权,可读性略差):
rust
fn main() {
let mut v = vec![1, 2, 3];
let taken = std::mem::replace(&mut v, Vec::new());
for x in &taken {
v.push(x * 2);
}
drop(taken); // 用完显式释放,表明意图
println!("{v:?}"); // 输出:[2, 4, 6]
}要点解析:
std::mem::take(&mut v)等价于std::mem::replace(&mut v, Vec::default()), 对Vec/String/Option这类实现了Default的类型更简洁。- 关键点:
taken是一个独立的局部变量,对它迭代产生的借用与v无关, 所以v.push(...)合法。 - 代价:移动一个
Vec头(24 字节),不复制任何元素。 - 不要用
v.clone()代替 —— 那是 O(n) 的深拷贝。 - 注意这里的语义是「基于原始元素生成新元素」。若需求是「在原数据后面追加」, 用
v.extend(...)或「先收集再 append」更直白。
练习 11:给结构体字段加生命周期参数
难度:★★☆
要求:下面 struct 定义编译失败。给出两种修法:加生命周期参数; 改成拥有数据。并分别写出一个使用它的 main。
rust
struct Config {
name: &str,
value: i32,
}
fn main() {
let cfg = Config { name: "server", value: 8080 };
println!("{} {}", cfg.name, cfg.value);
}提示:结构体定义上的省略规则不生效。
参考答案(先自己写再看)
参考答案(先自己写再看)
方案一:加生命周期参数(零拷贝,但 'a 会传染到所有使用处)
rust
struct Config<'a> {
name: &'a str,
value: i32,
}
fn main() {
let owned = String::from("server");
let cfg = Config { name: &owned, value: 8080 };
println!("{} {}", cfg.name, cfg.value); // 输出:server 8080
}方案二:让结构体拥有数据(初学阶段推荐)
rust
struct Config {
name: String,
value: i32,
}
fn main() {
let cfg = Config { name: String::from("server"), value: 8080 };
println!("{} {}", cfg.name, cfg.value); // 输出:server 8080
}要点解析:
- 报错是
E0106: missing lifetime specifier(在结构体字段上), 因为省略规则不适用于结构体定义:编译器没有「输入参数」可参考, 必须由你显式声明这个结构体借用了谁的多久。 - 方案一的代价:
Config<'a>不能存进跨线程容器、不能轻松放进Vec<Config<'a>>(需要统一'a)、任何返回它的函数签名都要带<'a>。 - 方案二的代价:构造时一次分配。用
String::from或.to_string()。 - 「字符串字面量」在方案一里可以工作(
Config { name: "server", value: 8080 }), 因为字面量是&'static str,满足任何'a;但一旦名字来自运行期拼接的String, 就会立刻遇到生命周期约束。
练习 12:零拷贝切分字符串
难度:★★★
要求:写一个函数 fn split_at_first_dot(text: &str) -> (&str, &str), 返回(第一个 . 之前的部分,其后的部分)。若没有 .,返回 (text, "")。 要求:零拷贝、不用 clone()、签名上的生命周期必须正确(可以先手写再对照省略规则)。
提示:text.find('.') 给出字节下标;元组里两个元素都借自 text。
参考答案(先自己写再看)
参考答案(先自己写再看)
rust
// 手写生命周期版本(等价于下面的省略版本)
fn split_at_first_dot_explicit<'a>(text: &'a str) -> (&'a str, &'a str) {
match text.find('.') {
Some(i) => (&text[..i], &text[i + 1..]),
None => (text, ""),
}
}
// 省略版本:规则 1 给 text 一个生命周期,规则 2 让两个输出都跟随它 —— 无需手写
fn split_at_first_dot(text: &str) -> (&str, &str) {
match text.find('.') {
Some(i) => (&text[..i], &text[i + 1..]),
None => (text, ""),
}
}
fn main() {
let line = String::from("key=value.rest");
let (head, tail) = split_at_first_dot(&line);
println!("head={head} tail={tail}"); // 输出:head=key=value tail=rest
let (a, b) = split_at_first_dot("no dot here");
println!("a={a} b={b}"); // 输出:a=no dot here b=
}要点解析:
- 两个返回值都借自
text,所以省略规则 2 适用:只有一个输入生命周期, 输出全部绑到它。手写版本只是把这个事实显式化。 text.find('.')返回字节下标,i + 1跳过.本身(.是 1 字节 ASCII)。i + 1一定落在字符边界上 —— 这是这个切片安全的关键。- 若没有
.,返回(text, ""):""是&'static str, 可以向上协变(coerce)成任意更短的生命周期&'a str, 所以它不会强制'a变成'static。 - 零拷贝:
&text[..i]只是构造一个新的(ptr, len),没有任何分配。 整个函数的机器码只有几步整数比较与切片构造。 - 稳健性改进:若要处理「空字符串」或「
.在开头」,当前实现会返回("", "rest"), 语义上是合理的;若.在末尾则返回("key", "")。这些边界都有定义,不会 panic。