Skip to content

练习与自测

本章练习共 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 拥有堆缓冲区,不实现 Copylet t = s; 把所有权移给 ts 作废。

最小修改(借用,零成本):

rust
fn main() {
    let s = String::from("rust");
    let t = &s;                 // 只借不夺
    println!("{s} {t}");        // 输出:rust rust
}

要点解析&ss.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]
}

修法二:复制出值而不是借用i32Copy):

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。若元素是 Stringdata[0] 反而会报「cannot move out of index」, 那时就得用修法一。

练习 4:不用 clone() 修复 E0382

难度:★★☆

要求:下面代码有三处 E0382。在不使用 clone() 的前提下修复它, 使输出为 helloHELLO(第二行由 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,返回第一个空格之前的部分; 若没有空格则返回整个字符串。不得使用 VecString.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 here
  • longest<'a> 的签名要求:xy 必须满足同一个 '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 行为一致的两种改法:

改成 matchmatch 的临时值作用域仍是「到语句结束」,即 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); }
        }
    }
}

推荐:先把值读出来,让临时 Refif 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_rescope lint 把有风险的 if let 改写成 match,并提示你人工确认(因为 match 保留了 2021 的 「临时值活到语句结束」行为,在本例中反而会保留 panic)。
  • 真正的惯用解法是 ****:不要依赖临时值的 drop 时机, 而是把需要的值显式取出为一个具名绑定(let current = ...)。 这样代码的语义不再依赖 edition。

练习 10:用 mem::take 解决循环中的借用冲突

难度:★★★

要求:下面代码编译失败。用 std::mem::takestd::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。

本章小结 / 自测清单


内容以 rustc 1.98.1 · Rust 2024 edition 为基准